之前写过一篇博客 深入解析类 React's Hooks 的实现原理 大约是至少 2 年前了。
最近翻看自己写的博客,发觉写的不是很清晰,自己都被绕得有点迷糊。于是开始再次找出 TNG-Hooks 源码进行学习。尝试再以更系统、更高的视角去分析。
这次我们尝试简化整个博客的思路,仅实现 useState,并分析其思路。
设个题目
先设定一个目标,我们期望下面代码示例最终运行打印如下:
function _clickExpanded() {
let [count, setCount] = useState(0);
let [expanded, setExpanded] = useState(false);
setCount(count + 1);
if (!expanded) {
setExpanded(true);
renderUsername('David');
} else {
setExpanded(false);
renderUsername('David is a King');
}
console.log('count', count);
console.log('expanded', expanded);
}
function _renderUsername(username) {
// using the `useState(..)` hook
var [activated, setActivated] = useState(false);
// do update
// usernameElem.innerHTML = username;
// only run this code the first time
if (!activated) {
setActivated(true);
// set listener
// usernameElem.addEventListener("click",onClickUsername,false);
}
console.log('activated', activated);
}
// ************
function TNG(fn) {
// ...
return fn;
}
function useState(initialVal) {
// ...
return [initialVal, () => initialVal];
}
// ************
const clickExpanded = TNG(_clickExpanded);
const renderUsername = TNG(_renderUsername);
clickExpanded();
/**
activated false
count 0
expanded false
*/
clickExpanded();
/**
activated: true
count 1
expanded true
*/
clickExpanded();
/**
activated: true
count 2
expanded false
*/
大家感兴趣可以先去尝试实现下,再回来看剩下的博客。
题干分析
读题干,有两个函数 _clickExpanded、_renderUserName 被 TNG 处理后,返回的函数 clickExpanded、renderUserName 拥有了状态记忆 的能力。
- 在第 1 次调用时,可以使用初始默认状态。并设置下次状态。
- 在第 >1 次调用时,可以获取上次状态。并设置下次状态。
那么我们此时需要实现的就是两个函数:
- TNG 函数:用以给目标函数设定上下文
- useState:实现目标状态的 get/set
当然,除此之外,可以自己根据需要实现任何辅助函数、变量均可。最终满足调用条件即可。(Done is better than perfect,写完后可以再进行优化
实现
通过阅读源码思想,简化实现 useState 如下。大家也可以先跳过这段源码,先看下面一章节的分析。
// ************
let buckets = new WeakMap();
let tngStack = [];
// 获取 tng 栈顶函数对应的状态桶
// 1. tng 栈可能为空,此时直接返回空即可
// 2. 栈顶函数可能未初始化其状态桶,此时初始化即可
function getCurrentBucket() {
if (tngStack.length > 0) {
let tngf = tngStack[tngStack.length - 1];
let bucket;
if (!buckets.has(tngf)) {
bucket = {
nextStateSlotIdx: 0,
stateSlots: [],
};
buckets.set(tngf, bucket);
}
return buckets.get(tngf);
}
}
// 将无状态函数 fn 转为有状态函数 tngf
// 一个很复杂的闭包
function TNG(fn) {
return function tngf(...args) {
// tngf 它被执行的时候,此时将它入栈,符合直觉
// 潜台词:它未出栈时,currentBucket 一直都是它的 bucket
tngStack.push(tngf);
// 该 tngf 第 1 次执行时,其实就是在初始化的新 bucket
// 该 tngf 第 >1 次执行时,其实就是已经有状态的老 bucket
let bucket = getCurrentBucket();
// 将其重置为 0 的原因是
// 同一个 tngf 重复调用时,进行重置该指针
// 使得每次 useState 的对应到的 slot 均是一致
// 这就是为什么 useState 必须位置稳定,不能使用 if else 来条件式出现
bucket.nextStateSlotIdx = 0;
try {
return fn.apply(this, args);
} finally {
// 将该 tngf 出栈,此时再获取的 currentBucket 就是当前栈顶的另一个 tngf
tngStack.pop();
}
};
}
function useState(initialVal) {
let bucket = getCurrentBucket();
if (bucket) {
// 在第 1 次 tngf 调用时,相当于 useState 每出现,都会先走初始化 slot 的步骤
// 在第 >1 次 tngf 调用时,stateSlots 均已经出现,所以直接按顺序递增指针即可。
if (bucket.nextStateSlotIdx >= bucket.stateSlots.length) {
const slot = [
initialVal,
function update(v) {
slot[0] = v;
},
];
bucket.stateSlots[bucket.nextStateSlotIdx] = slot;
}
// 导出该 slot, 并指向下个 slot(未存在)
return [...bucket.stateSlots[bucket.nextStateSlotIdx++]];
} else {
throw new Error(
'useState() only valid inside an Articulated Function or a Custom Hook.'
);
}
}
// ************
分析
我们试着从 Demo 调用去分析,我们调用了三次 clickExpanded,根据内部实现,我们分析下在单线程下的函数调用栈是怎么样的。(我们不关心其他函数)
tngf 调用栈
执行第一次 clickExpanded 如下(其中 tngf1 是经过 TNG 封装的 clickExpanded,tngf2 是经过 TNG 封装的 renderUsername):
那么三次调用的调用栈变化情况是怎样的呢?
调用栈周而复始,起于平凡,踏浪而行,最终功成身就后,归于平静,风度非凡。
那么有趣的是,这个逻辑维护在浏览器中,对于 js 代码逻辑来说是不可见的。我们想追踪它唯有通过手动维护一个栈的方式去管理。
在上述代码中,其实看到 tngStack 相关逻辑就是手动维护的栈。简化逻辑见下:
function TNG(fn) {
return function tngf(...args) {
tngStack.push(tngf);
fn.apply(this, args);
tngStack.pop();
}
}
那维护这么一个调用栈的好处有哪些?
- 可以知道调用关系链是怎样的?比如 tngf1 -> tngf2
- 某一时刻可以通过看栈顶,知道代码中正在运行的函数。(依赖单线程)
当然,维护所有函数的调用栈是复杂的,在我们只关心 TNG 封装的函数调用情况下的话,就简单很多。
依赖建立
除此之外,还需要有一个 map 来记录不同的 tngf 有哪些依赖。为了让 key 可以是函数对象,并解决内存泄露问题,我们通过 WeakMap 建立依赖表。
一个 tngf 都让它提一个 bucket 桶(让每个 tngf 都很有“体统” ...希望没有太冷哈哈哈)
这个桶里可以放任何东西,目前我们只放 stateSlots 和 nextStateSlotIdx 这两个重要物件。
那么到此为止,我们可以做到一个事情,就是根据 tngStack 取栈顶,结合这个 buckets 的依赖表,可以立马获取这个 tngf 的对应桶。
这就是 getCurrentBucket 的逻辑。当然它里面也默认做了个逻辑,就是这个 tngf 第一次报到的时候,别让它这么惨,都会给默认分配一个空荡荡的桶。第二次再出现的时候,则不会再分配桶,直接取它已有的桶,做到人手一个,公平公正公开透明。
stateSlot 顺序
我们知道,一个函数里,可能会使用多个 useState。那么每个 useState 其实都会对应到一个 slot。
在每次 tngf 调用的时候,nextStateSlotIdx 指针会重置,其中:
- 在第 1 次 tngf 调用时,相当于 useState 每出现,都会先走初始化 slot 的步骤
- 会给每个 useState 初始化分配一个 slot。
- nextStateSlotIdx 作为一个指针,会指向下一个待分配的 slot。
- 在第 >1 次 tngf 调用时,stateSlots 均已分配,所以直接按顺序递增 nextStateSlotIdx 指针逐个取 slot 即可。
这里暗含了一个条件约定,就是 tngf 里所有的 useState 的调用顺序总是需要稳定出现,否则会出现 useState 找不到 slot 或者找到错误的 slot,从而引来异常 bug。
其实,stateSlots 也是一个 map。相当于每个 useState 通过出现的索引顺序, 认领一个 slot。见上图。
- useState(A) 通过 nextStateSlotIdx 0 永远对应 slot 1
- useState(B) 通过 nextStateSlotIdx 1 永远对应 slot 2
- useState(C) 通过 nextStateSlotIdx 2 永远对应 slot 3
最终运行,结果符合预期。
小结
对于我来说,初读 TNG Hooks 源码,让我最惊艳的一个点就是,js 维护目标函数的调用栈,这种思维是很少见的,相当于用模拟了 js 引擎的能力,让我印象深刻。
时隔两年再读源码,还是颇有收益。因此再写篇博客重新分享。
感谢阅读。