本文希望通过揭开一些 React 隐藏的技术细节, 来辅助对官方文档中某些概念的理解
读者可以将本文看做对官方文档的补充
行文方式我采用的是提问-解答的方式, 即先根据官方文档给出的使用规则, 提出问题, Why ? 然后我们根据实际的调试再来解答这个 Why, 最后系统的整理这些 Why 变成 How, 如果你们有更好的行文方式, 也欢迎留言讨论
另外为了阅读体验, 我不会粘贴过多的源码, 避免打断各位读者的思路.
其实没有什么黑魔法, React 在初始化的过程中会构建一个 ReactCurrentDispatcher 的全局变量用于跟踪当前的 dispatcher
dispatcher 可以理解成一个 Hooks 的代理人
由于你在 FCC 外部执行 Hooks, 这时候要么 React 没有初始化, 要么就是 Hooks 无法关联到 ReactCurrentDispatcher, 大部分场景都是因为生命周期的错配而报错, 所以 React 也并不能百分百知道你的 Hooks 执行时机是否正确
官方文档在关于 Hooks 执行顺序和 State 读写之间的关联说明上语焉不详
"那么 React 怎么知道哪个 state 对应哪个 useState?答案是 React 靠的是 Hook 调用的顺序。"
不得不说这个重要的细节, 官方却给了个模棱两可的答案.
看过其他相关介绍的读者应该知道 React 在 State 读写上借鉴了一个类似状态单元格的概念, 通过将 State 和 setState 分离到两个数组中, 然后根据数组下标就能确定要读写的是哪个 State, 但对于 React 来说基于 Fiber 的架构自然不可能这么简单
要解开这个谜题, 首先我们得知道两点, 即 React 如何存储 State, Hook 究竟是什么
React 对 State 的处理并不复杂, 类似下面的链表结构来存储一个 FCC 内部的, 通过 useState 声明的 State
{ memoizedState:1 next: { memoizedState:"ff" next: null } } 复制代码
通过 next 指针, React 可以按照顺序来读写 State, 这很方便
再次推荐前端开发同学掌握基本的数据结构, 这样有助于你更好的理解代码
那 Hook 呢 ? 究竟什么是 Hook, React 如何存储 Hook ?
Hook 是一个对象, 当然 JS 里一切都是对象, React 将 hook 声明为这样一个结构
var hook = { memoizedState: null, baseState: null, baseQueue: null, queue: null, next: null }; 复制代码
跟 State 一样 Hook 也是一个单向链表结构, 这里的 memoizedState 和上面的那个是一致的, 嗯如果你有遵规则的话, 那就是一致的......
官网其实没有明确给 Hook 做出定义, 相比 State, Hook 主要多了一个 queue 属性, 那么这是什么呢?
var queue = { pending: null, dispatch: null, lastRenderedReducer: basicStateReducer, lastRenderedState: initialState }; 复制代码
这是 React 对 queue 的结构声明, 在不深入 Fiber 关于如何使用 queue 的细节下, 我们姑且做个猜测, queue 是队列的意思, pending 可能意指某个执行中的 dispatch, lastRenderedReducer, 这里是一个默认函数, 在更新阶段保存的是上一次使用的用来更新 State 的 Reducer 函数, 至于 lastRenderedState, 自然是前一个 State.
结合 queue 的结构, 我们可以试着给 Hook 一个定义 Hook 是一个对 State 逻辑处理函数进行管理的管理者, 它通过队列的方式有效管理这些逻辑处理函数
考虑到 Hook 并不止 useState useEffect, React 的源码也在不停的变更, 所以这里的定义或许并不严谨, 不过本系列的文章并不是一篇一次性的文章, 后续随着细节的深入和讨论, 我会更新相关的一些定义和内容来修订原有的版本, 以力求严谨和一致性
这里的概念很接近 Redux, 不过在深入这些细节之前, 本文还是先聚焦 Hooks 的规则, 关于 React 内部的这种 State 更新管理机制以及它和 Fiber 的关系, 我会在后续文章中讨论, 在这里先有个概念吧.
了解了 React 如何存储 State 和 Hook, 同时对 Hook 有了明确的结构定义后, 再补充一个 Fiber 的渲染逻辑, 即在 commit 阶段, 渲染一旦发生就要全部完成, 不存在局部渲染, 每一次都是完整的"所有的节点"
这里所有的节点打了个引号, 对于 Fiber 使用链表实现的树全部遍历一次的开销依然巨大, 所以 React 做了优化, 关于这部分可查看这篇文章写得还是很通俗易懂的
在这种情况下 FCC 的 ReRender 会导致内部的 Hooks 全部都执行一遍, 我们把官网的那个例子稍微改改然后再做说明
"use strict"; function Counter({ initialCount }) { const [count, setCount] = React.useState(1); if (count === 1) { const [count2, setCount2] = React.useState(2); } const [surname, setSurname] = React.useState('Poppins'); return /*#__PURE__*/React.createElement(React.Fragment, null, "Count: ", count, /*#__PURE__*/React.createElement("button", { onClick: () => setCount(initialCount) }, "Reset"), /*#__PURE__*/React.createElement("button", { onClick: () => setCount(prevCount => prevCount - 1) }, "-"), /*#__PURE__*/React.createElement("button", { onClick: () => setCount(prevCount => prevCount + 1) }, "+")); } ReactDOM.render(React.createElement(Counter, { initialCount: 1 }, null), document.getElementById('root') ); 复制代码
为了便于调试, 我只使用了 React 必须的两个库, 例子中的代码也没有使用 JSX
在说明具体的例子前, 将上面的和一些背景知识做个整理
在了解 FCC ReRender 导致所有 Hooks 重新执行的基础上, 我们再加一条, 即对于 State 而言存在两个阶段即 "mount" 和 "update", 两个阶段都有不同的 dispatcher 来触发, 也会分别调用 mountState 和 updateState 这样的函数来处理, 路径的分叉是在 Hooks 被执行前, React 称为 renderWithHooks, 在这个阶段, React 会判断 current 节点上是否有 memoizedState, 无则 mount, 有则 update
current 节点在 performUnitOfWork 中声明, 并通过 beginWork 传递进 renderWithHooks 中, unitOfWork 是一个 FiberNode, 因为涉及到 Fiber 架构的工作逻辑分析, 我们先有个概念, 在后续文章中讨论这些细节
总结下:
回到上面的例子, 第一次 render 后, Counter 节点上的 State 和 Hooks 的两个链表应当是
// State List { memoizedState:1, next: { memoizedState: 2, next: { memoizedState: "Poppins", next: null } } } // Hooks List { memoizedState: 1, queue: { dispatch: fn() }, next: { memoizedState: 2, queue:{ dispatch: fn() }, next: { memoizedState: "Poppins", queue: { dispatch: fn() } next: null } } }; 复制代码
这里简化了结构, 去掉了某些属性以便于理解
然后我们通过点击按钮触发 setCount + 1 来引发 ReRender, 由于此时 count = 2, 导致原有的第二个 useState 不会执行, 但是 React 并不知道这一点, 他会默认你是守规矩的, 这就导致了一个有意思的结果
const [surname, setSurname] = React.useState('Poppins'); 复制代码
我们预期 surname 应该是 'Poppins', 因为我们没做任何变更, 但实际上 React 此时返回的数组中的 surname 是 2. 因为在更新路径中, Hook 对应的链表里第二个 memoizedState 是 2, 不是 'Poppins', React 按照顺序沿着 Hook 的指针前进并调用对应的 queue 里的 dispatch, 它并不关心你的真实逻辑, 于是就产生了预期结果和执行结果的不一致, 也就是官网所说的导致了 bug, 但官网中提到的提前执行其实有歧义, 对于 React 来说一切都是有序的, 不存在将后置的 Hook 提前执行, 只是你预期的和它实际干的没有对应上, 这里可以用图来说明
第二次执行的时候 React给你的 你想的 1 1 2 'Poppins' 'Poppins' 复制代码
React 给了一个你认为是错误, 但是它认为是正确的结果, 不得不说这有点违反直觉, 而且有点反人类, 估计 React 也知道这么干有点不太好, 所以 16.13.1 的开发版中测试了下, React 会针对两种情况抛出异常
关于 React 如何跟踪这里涉及到 Fiber 中 workInProgress 部分的设计, 先埋个坑, 后面来填
文中埋了不少坑, 后续也会逐步填上, 不过也欢迎有兴趣的人一起来填坑, 共同揭开这些技术细节