11
头图

大家好,我是卡颂。

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

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

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

答应我,不要怪自己。

根本原因在于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不会执行。

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

答案是:订阅发布。

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

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

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

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

window.title = count();

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

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

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

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

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

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

其中:

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

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

实现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

实现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

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

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

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

这里是:完整在线Demo链接


卡颂
3.1k 声望16.7k 粉丝