大家好,我是卡颂。
你是否很讨厌Hooks
调用顺序的限制(Hooks
不能写在条件语句里)?
你是否遇到过在useEffect
中使用了某个state
,又忘记将其加入依赖项
,导致useEffect
回调执行时机出问题?
怪自己粗心?怪自己不好好看文档?
答应我,不要怪自己。
根本原因在于React
没有将Hooks
实现为响应式更新。
是实现难度很高么?本文会用50行代码实现无限制版Hooks
,其中涉及的知识也是Vue
、Mobx
等基于响应式更新的库的底层原理。
本文的正确食用方式是收藏后用电脑看,跟着我一起敲代码(完整在线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
回调执行 - 不需要显式的指定依赖项(即
React
中useEffect
的第二个参数)
举个例子:
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
执行时会建立effect
与state
之间订阅发布的关系。
当下次执行setCount
(setter)时会通知订阅了count
变化的useEffect
,执行其回调函数。
数据结构之间的关系如图:
每个useState
内部有个集合subs
,用来保存订阅该state变化的effect
。
effect
是每个useEffect
对应的数据结构:
const effect = {
execute,
deps: new Set()
}
其中:
execute
:该useEffect
的回调函数deps
:该useEffect
依赖的state
对应subs
的集合
我知道你有点晕。看看上面的结构图,缓缓,咱再继续。
实现useEffect
首先需要一个栈来保存当前正在执行的effect
。这样当调用getter
时state
才知道应该与哪个effect
建立联系。
举个例子:
// effect1
useEffect(() => {
window.title = count();
})
// effect2
useEffect(() => {
console.log('没我啥事儿')
})
count
执行时需要知道自己处在effect1
的上下文中(而不是effect2
),这样才能与effect1
建立联系。
// 当前正在执行effect的栈
const effectStack = [];
接下来实现useEffect
,包括如下功能点:
- 每次
useEffect
回调执行前重置依赖(回调内部state
的getter
会重建依赖关系) - 回调执行时确保当前
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个state
:name1
、name2
、showAll
。
whoIsHere
作为memo
,依赖以上三个state
。
最后,当whoIsHere
变化时,会触发useEffect
回调。
当以上代码运行后,基于初始的3个state
,会计算出whoIsHere
,进而触发useEffect
回调,打印:
// 打印:谁在那儿! KaSong 和 XiaoMing
接下来调用:
setName1('KaKaSong');
// 打印:谁在那儿! KaKaSong 和 XiaoMing
triggerShowAll(false);
// 打印:谁在那儿! KaKaSong
下面的事情就有趣了,当调用:
setName2('XiaoHong');
并没有log
打印。
这是因为当triggerShowAll(false)
导致showAll state
为false
后,whoIsHere
进入如下逻辑:
if (!showAll()) {
return name1();
}
由于没有执行name2
,所以name2
与whoIsHere
已经没有订阅发布关系了!
只有当triggerShowAll(true)
后,whoIsHere
进入如下逻辑:
return `${name1()} 和 ${name2()}`;
此时whoIsHere
才会重新依赖name1
与name2
。
自动的依赖跟踪,是不是很酷~
总结
至此,基于订阅发布,我们实现了可以自动依赖跟踪的无限制Hooks
。
这套理念是最近几年才有人使用么?
早在2010年初KnockoutJS
就用这种细粒度的方式实现响应式更新了。
不知道那时候,Steve Sanderson(KnockoutJS
作者)有没有预见到10年后的今天,细粒度更新会在各种库和框架中被广泛使用。
这里是:完整在线Demo链接
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。