突破Hooks所有限制,只要50行代码

多态潮涌
• 阅读 4925

大家好,我是卡颂。

你是否很讨厌Hooks调用顺序的限制(Hooks不能写在条件语句里)?

你是否遇到过在useEffect中使用了某个state,又忘记将其加入依赖项,导致useEffect回调执行时机出问题?

怪自己粗心?怪自己不好好看文档?

答应我,不要怪自己。

突破Hooks所有限制,只要50行代码

根本原因在于React没有将Hooks实现为响应式更新。

是实现难度很高么?本文会用50行代码实现无限制版Hooks,其中涉及的知识也是VueMobx等基于响应式更新的库的底层原理。

本文的正确食用方式是收藏后用电脑看,跟着我一起敲代码(完整在线Demo链接见文章结尾)。

手机党要是看了懵逼的话不要自责,是你食用方式不对。

注:本文代码来自Ryan Carniato的文章Building a Reactive Library from Scratch
,老哥是SolidJS作者

万丈高楼平地起

首先来实现useState

function useState(value) {
  const getter = () => value;
  const setter = (newValue) => value = newValue;
  
  return [getter, setter];
}

返回值数组第一项负责取值,第二项负责赋值。相比React,我们有个小改动:返回值的第一个参数是个函数而不是state本身。

使用方式如下:

const [count, setCount] = useState(0);

console.log(count()); // 0
setCount(1);
console.log(count()); // 1

没有黑魔法

接下来实现useEffect,包括几个要点:

  • 依赖的state改变,useEffect回调执行
  • 不需要显式的指定依赖项(即ReactuseEffect的第二个参数)

举个例子:

const [count, setCount] = useState(0);

useEffect(() => {
  window.title = count();
})
useEffect(() => {
  console.log('没我啥事儿')
})

count变化后第一个useEffect会执行回调(因为他内部依赖count),但是第二个useEffect不会执行。

前端没有黑魔法,这里是如何实现的呢?

突破Hooks所有限制,只要50行代码

答案是:订阅发布。

突破Hooks所有限制,只要50行代码

继续用上面的例子来解释订阅发布关系建立的时机:

const [count, setCount] = useState(0);

useEffect(() => {
  window.title = count();
})

useEffect定义后他的回调会立刻执行一次,在其内部会执行:

window.title = count();

count执行时会建立effectstate之间订阅发布的关系。

当下次执行setCount(setter)时会通知订阅了count变化的useEffect,执行其回调函数。

数据结构之间的关系如图:

突破Hooks所有限制,只要50行代码

每个useState内部有个集合subs,用来保存订阅该state变化effect

effect是每个useEffect对应的数据结构:

const effect = {
  execute,
  deps: new Set()
}

其中:

  • execute:该useEffect的回调函数
  • deps:该useEffect依赖的state对应subs的集合

我知道你有点晕。看看上面的结构图,缓缓,咱再继续。

突破Hooks所有限制,只要50行代码

实现useEffect

首先需要一个栈来保存当前正在执行的effect。这样当调用getterstate才知道应该与哪个effect建立联系。

举个例子:

// effect1
useEffect(() => {
  window.title = count();
})
// effect2
useEffect(() => {
  console.log('没我啥事儿')
})

count执行时需要知道自己处在effect1的上下文中(而不是effect2),这样才能与effect1建立联系。

// 当前正在执行effect的栈
const effectStack = [];

接下来实现useEffect,包括如下功能点:

  • 每次useEffect回调执行前重置依赖(回调内部stategetter会重建依赖关系)
  • 回调执行时确保当前effect处在effectStack栈顶
  • 回调执行后将当前effect从栈顶弹出

代码如下:

  function useEffect(callback) {
    const execute = () => {
      // 重置依赖
      cleanup(effect);
      // 推入栈顶
      effectStack.push(effect);

      try {
        callback();
      } finally {
        // 出栈
        effectStack.pop();
      }
    }
    const effect = {
      execute,
      deps: new Set()
    }
    // 立刻执行一次,建立依赖关系
    execute();
  }

cleanup用来移除该effect与所有他依赖的state之间的联系,包括:

  • 订阅关系:将该effect订阅的所有state变化移除
  • 依赖关系:将该effect依赖的所有state移除
function cleanup(effect) {
  // 将该effect订阅的所有state变化移除
  for (const dep of effect.deps) {
    dep.delete(effect);
  }
  // 将该effect依赖的所有state移除
  effect.deps.clear();
}

移除后,执行useEffect回调会再逐一重建关系。

改造useState

接下来改造useState,完成建立订阅发布关系的逻辑,要点如下:

  • 调用getter时获取当前上下文的effect,建立关系
  • 调用setter时通知所有订阅该state变化的effect回调执行
function useState(value) {
  // 订阅列表
  const subs = new Set();

  const getter = () => {
    // 获取当前上下文的effect
    const effect = effectStack[effectStack.length - 1];
    if (effect) {
      // 建立联系
      subscribe(effect, subs);
    }
    return value;
  }
  const setter = (nextValue) => {
    value = nextValue;
    // 通知所有订阅该state变化的effect回调执行
    for (const sub of [...subs]) {
      sub.execute();
    }
  }
  return [getter, setter];
}

subscribe的实现,同样包括2个关系的建立:

function subscribe(effect, subs) {
  // 订阅关系建立
  subs.add(effect);
  // 依赖关系建立
  effect.deps.add(subs);
}

让我们来试验下:

const [name1, setName1] = useState('KaSong');
useEffect(() => console.log('谁在那儿!', name1())) 
// 打印: 谁在那儿! KaSong
setName1('KaKaSong');
// 打印: 谁在那儿! KaKaSong

突破Hooks所有限制,只要50行代码

实现useMemo

接下来基于已有的2个hook实现useMemo

function useMemo(callback) {
  const [s, set] = useState();
  useEffect(() => set(callback()));
  return s;
}

自动依赖跟踪

这套50行的Hooks还有个强大的隐藏特性:自动依赖跟踪。

我们拓展下上面的例子:

const [name1, setName1] = useState('KaSong');
const [name2, setName2] = useState('XiaoMing');
const [showAll, triggerShowAll] = useState(true);

const whoIsHere = useMemo(() => {
  if (!showAll()) {
    return name1();
  }
  return `${name1()} 和 ${name2()}`;
})

useEffect(() => console.log('谁在那儿!', whoIsHere()))

现在我们有3个statename1name2showAll

whoIsHere作为memo,依赖以上三个state

最后,当whoIsHere变化时,会触发useEffect回调。

当以上代码运行后,基于初始的3个state,会计算出whoIsHere,进而触发useEffect回调,打印:

// 打印:谁在那儿! KaSong 和 XiaoMing

接下来调用:

setName1('KaKaSong');
// 打印:谁在那儿! KaKaSong 和 XiaoMing
triggerShowAll(false);
// 打印:谁在那儿! KaKaSong

下面的事情就有趣了,当调用:

setName2('XiaoHong');

并没有log打印。

这是因为当triggerShowAll(false)导致showAll statefalse后,whoIsHere进入如下逻辑:

if (!showAll()) {
  return name1();
}

由于没有执行name2,所以name2whoIsHere已经没有订阅发布关系了!

只有当triggerShowAll(true)后,whoIsHere进入如下逻辑:

return `${name1()} 和 ${name2()}`;

此时whoIsHere才会重新依赖name1name2

自动的依赖跟踪,是不是很酷~

突破Hooks所有限制,只要50行代码

总结

至此,基于订阅发布,我们实现了可以自动依赖跟踪的无限制Hooks

这套理念是最近几年才有人使用么?

早在2010年初KnockoutJS就用这种细粒度的方式实现响应式更新了。

不知道那时候,Steve SandersonKnockoutJS作者)有没有预见到10年后的今天,细粒度更新会在各种库和框架中被广泛使用。

这里是:完整在线Demo链接

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
美凌格栋栋酱 美凌格栋栋酱
6个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
3年前
VBox 启动虚拟机失败
在Vbox(5.0.8版本)启动Ubuntu的虚拟机时,遇到错误信息:NtCreateFile(\\Device\\VBoxDrvStub)failed:0xc000000034STATUS\_OBJECT\_NAME\_NOT\_FOUND(0retries) (rc101)Makesurethekern
Stella981 Stella981
3年前
Linux自动检测网站心跳通知shell脚本
!/bin/bashLIST("http://xxxx.com")NAME("评价系统getwindowList接口")for((i0;i<${LIST@};i))doHTTP_CODEcurlo/dev/nullsw"%{http_code}""${LIST
Wesley13 Wesley13
3年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
3年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Python进阶者 Python进阶者
1年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这