突破Hooks全部限制,只要50行代码

你们好,我是卡颂。html

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

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

怪本身粗心?怪本身很差好看文档?数组

答应我,不要怪本身。markdown

根本缘由在于React没有将Hooks实现为响应式更新。数据结构

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

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

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

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

万丈高楼平地起

首先来实现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不会执行。

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

magic.gif

答案是:订阅发布。

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

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连接

相关文章
相关标签/搜索