各位同学大家晚上好欢迎来到今天的“React 源码大解剖”特别讲座。我是你们的老朋友一个在 React 内部世界摸爬滚打多年的资深“摸鱼”专家。今天我们不聊useEffect的依赖数组怎么填才不报错也不聊React.memo到底能不能救命。今天我们要像剥洋葱一样剥开 React 的外衣看看那个藏在 Fiber 节点深处、神秘兮兮的memoizedState到底是个什么鬼东西以及它在重渲染时是如何上演一场惊心动魄的“移形换影”大戏。准备好了吗系好安全带我们要钻进 React 的核心里了。第一部分memoizedState—— 它不是数组它是链表很多同学在面试 React 源码时听到memoizedState就头大。为什么因为它不像props那么直观也不像state那么好理解。其实memoizedState是 React Hooks 的基石。如果你问我React Hooks 的本质是什么我会告诉你它就是一个巨大的、嵌套的、单向的链表。想象一下你在一个派对上手里拿着一张号码牌memoizedState。这张号码牌上写着你的名字还贴着一张小纸条next告诉你下一个要去哪里。这就是链表。在 Fiber 节点中memoizedState属性就是指向这个链表头节点的指针。1.1 初始化单身贵族的诞生当你在组件里写下useState(0)时React 做了什么它没有创建一个数组[0]它创建了一个节点。让我们用伪代码来模拟一下这个节点长什么样// 这是一个简化的 Fiber 节点结构 function FiberNode() { this.type null; this.memoizedState null; // 链表头指针 this.updateQueue null; // 待处理的更新队列 this.next null; // 链表节点属性 } // React 内部创建了一个节点 const hookNode { memoizedState: 0, // 当前渲染产生的状态值 next: null // 下一个 hook 节点 }; // Fiber 节点挂载这个链表 currentFiber.memoizedState hookNode;看懂了吗memoizedState指向一个对象这个对象里有两个关键属性memoizedState存的是当前渲染出的状态值比如0。next存的是下一个 hook 的节点地址。1.2 扩展当useEffect加入派对光有useState怎么够我们还需要useEffect来清理旧账。useEffect也会在memoizedState链表里占一席之地。比如你有这样的代码function App() { const [count, setCount] useState(0); useEffect(() { console.log(count); }, [count]); return div{count}/div; }React 会怎么处理它会把useEffect的回调函数塞进memoizedState链表里。现在的结构变成了这样// 第一层useState { memoizedState: 0, // 状态值 next: { // 第二层useEffect memoizedState: function cleanup() {}, // effect 回调函数 next: null // 结束 } }注意看memoizedState里的值在不同类型的 Hook 下含义不同在useState里它是状态值。在useEffect里它是回调函数。在useReducer里它是 reducer 函数。这就是为什么你不能随意打乱 Hook 的顺序否则 React 就会拿着错误的“钥匙”去开错误的“房间”导致内存泄漏或者逻辑混乱。第二部分重渲染 —— 指针的接力赛这是今天讲座的核心memoizedState链表在重渲染时是如何移动的很多面试题会问“为什么useEffect里的count永远是旧的值” 或者 “为什么 React 状态更新是批量的”答案都在这个“移动轨迹”里。2.1 场景设定假设我们的组件App初始渲染了三次 HookuseState(0)- 返回0useState(1)- 返回1useEffect(...)- 挂载回调此时currentFiber.memoizedState指向的链表结构如下[Node 1: count0] --(next)-- [Node 2: count1] --(next)-- [Node 3: effectFn]2.2 触发重渲染现在你点击了按钮调用了setCount(2)。React 开始执行下一次渲染。此时React 携带了一个新角色登场了workInProgressFiber。这是新树是正在构建中的树是“正在进行时”。2.3 移动轨迹详解当 React 重新执行App函数组件时它并没有把旧的链表删了它只是创建了一个新的链表然后把旧的链表“借”过来用。步骤一清空新指针workInProgressFiber.memoizedState null; // 新节点暂时是空的步骤二复制旧链表这是关键React 开始遍历旧的currentFiber.memoizedState链表并把每一个节点复制到新链表中。处理第一个 Hook (useState(0))旧节点Node 1(count0)。新节点NewNode 1(count0)。移动workInProgressFiber.memoizedState指向NewNode 1。更新因为我们要更新countReact 发现updateQueue里有一个新的更新值为 2。于是它修改了NewNode 1.memoizedState的值为2。结果新链表第一个节点变成了2。[NewNode 1: count2] --(next)-- [???]处理第二个 Hook (useState(1))旧节点Node 2(count1)。新节点NewNode 2(count1)。移动NewNode 1.next指向NewNode 2。更新updateQueue里没有这个状态的新更新保持不变。结果新链表第二个节点还是1。[NewNode 1: count2] --(next)-- [NewNode 2: count1] --(next)-- [???]处理第三个 Hook (useEffect)旧节点Node 3(effectFn)。新节点NewNode 3(effectFn)。移动NewNode 2.next指向NewNode 3。结果新链表第三个节点是 effect 回调。[NewNode 1: count2] --(next)-- [NewNode 2: count1] --(next)-- [NewNode 3: effectFn]步骤四完成置换当渲染函数执行完毕React 会把workInProgressFiber.memoizedState赋值给currentFiber.memoizedState。最终状态旧链表内存里还在等着被垃圾回收[0] - [1] - [effect]新链表现在挂在currentFiber上了[2] - [1] - [effect]第三部分为什么useEffect里拿不到新值深度解析好现在我们来聊聊那个经典的面试题。你在useEffect里打印count发现它还是0而不是你刚设置的2。这又是为什么让我们回到上面的“移动轨迹”。在重渲染过程中React 执行了App函数。此时组件内部访问count时它去哪找它去的是workInProgressFiber.memoizedState指向的新链表。但是React 的执行顺序是这样的React 创建workInProgressFiber。React 开始执行App函数。在执行App函数的代码时它读取useState(0)返回的值赋给了局部变量count。此时React 还没有更新NewNode 1.memoizedState的值为2因为更新逻辑是在渲染阶段处理的而不是在执行函数体时处理的虽然它们很近但在 Hook 内部memoizedState的更新是同步的但useEffect的注册是异步的。等函数执行完了React 才去遍历updateQueue把count改成2。最后React 才把useEffect的回调函数注册到链表里。这里有个时间差当 React 把useEffect回调注册到链表里时也就是NewNode 3被创建并挂在链表上时NewNode 1的值可能还没来得及被更新或者更准确地说闭包捕获的是函数执行那一刻的引用。修正理解实际上在renderWithHooks中React 会同步更新memoizedState。让我们重新审视那个时间线renderWithHooks开始。调用useState(0)。React 检查updateQueue发现有新值2。立即修改workInProgressFiber.memoizedState指向的节点的memoizedState为2。函数体执行const count useState(0)[0]。此时count是2。调用useEffect(() console.log(count), [count])。React 把回调函数放入memoizedState链表的下一个节点。那么为什么useEffect打印的还是旧值因为useEffect的依赖数组[]是空的虽然 React 把回调函数放进去了但是当你点击按钮触发重渲染时React 会对比依赖数组。依赖数组是[]。当前渲染产生的值闭包里的count是2。React 发现依赖没变所以不会重新执行useEffect的回调函数。如果依赖是[count]呢如果依赖是[count]React 会发现依赖变了从 0 变成了 2。这时候React 会去workInProgressFiber.memoizedState链表里找依赖值。找到第一个节点值是2新值。找到第二个节点值是1新值。找到第三个节点值是useEffect回调函数。React 会把useEffect回调函数和依赖数组[2, 1, ...]进行比对。如果回调函数引用没变React 就不执行。真正执行useEffect的情况只有当useEffect回调函数的引用发生改变或者你修改了依赖数组里的值导致 React 认为需要重新执行时useEffect才会跑起来。第四部分useReducer的特殊移动轨迹既然聊到了memoizedState我们就不能放过useReducer。它是useState的升级版也是memoizedState链表结构最复杂的版本。4.1useReducer的节点结构useReducer的节点结构稍微有点不同它通常包含两个部分memoizedState当前状态和baseState基础状态。{ memoizedState: 0, // 当前显示的状态 baseState: 0, // 基础状态用于计算 diff next: { // 下一个 hook } }4.2updateReducer的移动逻辑当你在useReducer里派发一个动作时updateQueue会收到一个update对象。const update { memoizedState: null, // 初始是 null action: (state) state 1, next: null };React 会把这个update对象插入到fiber.updateQueue中。在重渲染时React 会遍历updateQueue并从memoizedState链表中取出值结合update的action来计算新值。移动轨迹示例初始渲染memoizedState指向一个节点值为0。DispatchupdateQueue变成[update1, update2]。重渲染React 读取memoizedState的值0。应用update1.action- 变成1。应用update2.action- 变成2。React 更新链表节点的memoizedState为2。这个过程就是所谓的“移动轨迹”数据从memoizedState流向updateQueue经过计算后再流回memoizedState。第五部分实战演练 —— 画出那个“鬼畜”的链表为了让大家彻底明白我们来手写一个极其简化的 React 渲染器模拟memoizedState的移动。假设我们有两个状态和一个 effect。// 模拟 Fiber 节点 const fiber { memoizedState: null // 初始为空 }; // 1. 初始渲染执行 Hook function renderApp() { // 初始化第一个状态 fiber.memoizedState { memoizedState: 0, // useState(0) next: null }; // 初始化第二个状态 fiber.memoizedState.next { memoizedState: 1, // useState(1) next: null }; // 初始化 Effect fiber.memoizedState.next.next { memoizedState: function() { console.log(Effect Run); }, // useEffect next: null }; console.log(初始渲染后的链表结构); console.log(fiber.memoizedState); // 输出: { memoizedState: 0, next: { memoizedState: 1, next: { memoizedState: f(), next: null } } } } // 2. 触发状态更新setCount(2) function updateState(newState) { // React 创建一个新的 workInProgress Fiber const workInProgress { memoizedState: null }; // 复制旧链表 let oldNode fiber.memoizedState; let newNode workInProgress; while (oldNode) { // 创建新节点默认复制旧值 let newNodeCopy { memoizedState: oldNode.memoizedState, next: null }; newNode.memoizedState newNodeCopy; // 如果是第一个节点修改为新值 if (newNode workInProgress) { newNodeCopy.memoizedState newState; } // 指针移动 oldNode oldNode.next; newNode newNodeCopy; } // 完成置换 fiber.memoizedState workInProgress.memoizedState; console.log(状态更新后的链表结构); console.log(fiber.memoizedState); // 输出: { memoizedState: 2, next: { memoizedState: 1, next: { memoizedState: f(), next: null } } } }代码解析看到没有这就是移动轨迹我们并没有改变旧的节点我们创建了一个全新的节点newNodeCopy把旧的值拷贝过来然后修改第一个节点的值最后把旧链表的“头”拔了换成新链表的“头”。这就是 React 保持状态隔离、实现并发渲染的秘诀。它不是在原地修修补补而是像搭积木一样重新构建了一套结构。第六部分深度陷阱 —— 为什么 Hook 顺序不能变现在我们回到最开始的问题。为什么memoizedState是一个链表为什么它不能是一个数组如果它是一个数组const state [0, 1]那么当你重渲染时你只需要修改数组的索引state[0] 2就行了。但因为是链表React 必须知道第一个节点是useState的结果。第二个节点是useState的结果。第三个节点是useEffect的回调。如果你把useEffect挪到了useState后面function App() { // ... useState ... // ... useState ... return div /; } useEffect(() {}); // 移到后面了React 在初始化时会认为useEffect是第四个节点。但在重渲染时因为组件函数执行顺序变了useEffect又变回了第三个节点。React 会拿着“第三个节点”的钥匙去开“第四个节点”的门。这会导致内存泄漏或者useEffect里的闭包引用了错误的上下文。链表结构保证了 Hook 的顺序在渲染过程中是固定的只要你不改代码React 就能通过遍历链表准确无误地找到每个 Hook 对应的节点进行更新。第七部分终极面试题 ——useLayoutEffect的时机既然聊到了useEffect怎么能不提useLayoutEffectuseLayoutEffect的移动轨迹和useEffect是一样的。它也是插入到memoizedState链表中。区别在于执行时机。useEffect在浏览器绘制完成后执行异步。此时memoizedState链表已经更新完毕DOM 已经渲染。useLayoutEffect在浏览器绘制之前执行同步。此时memoizedState链表已经更新完毕DOM 已经渲染但还没显示给用户。在useLayoutEffect里修改 DOM用户会先看到 DOM 变了绘制前然后再看到 DOM 变了绘制后。这会导致页面闪烁。面试加分项如果你能画出useLayoutEffect的移动轨迹并解释它和useEffect在链表中的位置完全一致只是在commit阶段执行的时机不同面试官会对你刮目相看。结语链表的哲学好了同学们今天的讲座要接近尾声了。我们回顾一下今天的重点memoizedState是一个链表不是数组。重渲染时React 会创建一个新链表复制旧链表结构然后修改新链表中的节点值。闭包陷阱是因为链表节点的更新和回调函数的注册存在微妙的时序关系。Hook 顺序不能变是因为链表结构依赖于遍历顺序。React 的设计哲学里充满了这种“链式”思维。从事件委托到虚拟 DOM 的 Diff 算法再到 Hooks 的链表管理一切都是为了可预测性。当你下次看到console.log(fiber.memoizedState)时不要只看到一个奇怪的指针。你要看到那一串排着队、等待着被更新、被渲染、被消费的节点。希望这篇文章能帮你把memoizedState的链表结构刻进脑子里。记住不要死记硬背代码要理解那个“移动”的过程。那个过程就是 React 的心跳。下课大家记得回去多刷几道题别让链表断了