useEffect 在 Fiber 上的数据结构(Hook 对象与副作用链表)
这张图展示 useEffect 在首次挂载时,如何在 Fiber 节点上构建 Hook 对象,以及副作用(Effect)如何被挂载到更新队列(updateQueue)中。
📝 源码级深度解析(数据结构) :
- 双重存储机制 :
useEffect产生的副作用信息实际上被存储在两个地方。一是作为 Hook 节点 的一部分(hook.memoizedState),供函数组件在更新时读取依赖项;二是被 复制一份 推入Fiber.updateQueue的副作用链表中,供 Commit 阶段遍历执行。这种设计使得 Render 阶段和 Commit 阶段的数据流解耦。 - Effect 对象的字段 :每个 Effect 对象包含
create(执行函数)、deps(依赖数组)、destroy(清理函数,初始为null)和next(指向下一个 Effect 的指针)。tag属性(如Passive、Layout)决定了该副作用在 Commit 的哪个子阶段被执行。 tag的位运算标记 :Fiber 节点的flags(副作用标识)使用位掩码。当挂载useEffect时,React 会执行fiber.flags |= PassiveEffect。这使得 React 在提交阶段能快速判断:是否需要在Mutation子阶段结束后,去调度flushPassiveEffects。
依赖项比较机制(areHookInputsEqual)------是否跳过执行
这张图剖析 useEffect 更新时,React 如何比对前后两次的依赖数组,决定是否调用副作用函数。
📝 源码级深度解析(依赖比较) :
- 比较算法
Object.is:React 使用Object.is进行依赖项的相等性判断,而不是===或浅比较(Shallow Compare)。Object.is与===的区别在于:+0不等于-0,且NaN等于自身。这是大多数开发者容易忽略的细节。 - 浅层比较(Shallow Comparison) :
areHookInputsEqual实现的是浅层遍历 (for循环逐项比对),而非深拷贝比对。如果依赖项是对象或数组,即使内容不变但引用地址变了,React 依然会判定为"变化",从而触发副作用重新执行。这也是为什么在useEffect依赖项中传递对象字面量(如{id: 1})容易导致无限循环的原因。 - 依赖项为空数组
[]的特殊性 :当deps为空数组时,首次挂载后,areHookInputsEqual比较时发现新旧deps长度均为 0,循环自然结束且无差异。因此,该 Effect 仅在首次挂载时执行一次,后续更新全部跳过。
useEffect 的异步调度与执行时机(Passive 阶段)
这张图展示 useEffect 从 Commit 阶段到浏览器绘制,再到空闲期执行的完整时序流水线。
📝 源码级深度解析(调度时机) :
- Mutation 子阶段只负责"调度"而非"执行" :
useEffect的create函数绝对不在Mutation子阶段执行。React 在这个阶段仅仅执行scheduleCallback(NormalPriority, flushPassiveEffects)(向Scheduler发起异步调度请求),将执行权交给浏览器的绘制流水线。 - Layout 子阶段与
useLayoutEffect的边界 :Layout子阶段是同步且阻塞绘制的(发生在Mutation之后、Paint之前)。useLayoutEffect在此阶段同步执行,因此可以安全读取 DOM 布局属性(如getBoundingClientRect),但耗时操作会直接导致掉帧。 - 延迟执行的权衡(Trade-off) :将
useEffect推迟到绘制后执行,确保了数据请求、日志记录、非关键 DOM 操作不会延迟用户看到屏幕内容的时机。这也是 React 官方推荐将绝大多数副作用放在useEffect而不是useLayoutEffect中的原因。
清理闭环(Cleanup)与组件卸载时的收尾流程
这张图剖析 useEffect 的清理函数(destroy)在依赖变更时 和组件卸载时的完整执行逻辑,以及如何防止内存泄漏。
📝 源码级深度解析(清理闭环) :
- 清理函数的存储链路 :
destroy函数并非存储在 Hook 节点的memoizedState中,而是附着在 Effect 对象本身 (effect.destroy)。每次create执行后,其返回值(清理函数)被赋值给effect.destroy。这使得 React 在卸载或更新前,能够精准定位到该 Effect 对应的清理逻辑。 - 更新时的"先清理后创建"顺序 :当依赖项变化时,React 严格遵循 "先执行旧的
destroy,再执行新的create" 顺序。这确保了旧的监听器(如 WebSocket 连接、DOM 事件绑定)在建立新连接之前被彻底移除,避免重复订阅导致的内存泄漏。 - 卸载时的优先级执行 :组件卸载时,
useEffect的清理函数会在Mutation子阶段 (DOM 移除之前或同时)同步执行。这是为了防止在组件从视图中移除后,异步清理函数尚未执行而导致"无法访问已卸载组件的状态"的 React 警告。如果清理函数中存在异步操作(如setTimeout),React 无法保证其完成时机,因此官方推荐清理函数保持同步简单逻辑。 - React 19 的编译器优化(React Compiler) :在未来的 React 19 + Compiler 版本中,如果
useEffect内部没有引用任何响应式变量,编译器可能自动省略依赖项比较或将其提升为静态常量,进一步提升性能。但清理闭环的核心机制将保持兼容。
useEffect 的完整工作流
| 维度 | 核心结论 |
|---|---|
| 数据结构存储 | useEffect 同时存在于 Hook 链表(用于读依赖)和 updateQueue(用于执行),通过 Passive 标记区分。 |
| 依赖比较与跳过 | 使用 Object.is 逐项浅比较,空数组代表永不更新,对象/数组引用变化会触发重执行。 |
| 异步调度时机 | Mutation 阶段仅发起调度,Layout 阶段执行 useLayoutEffect,绘制后空闲期才执行 useEffect。 |
| 清理闭环机制 | 更新前先执行旧 destroy,再执行新 create;卸载时在 Mutation 阶段同步执行 destroy 释放资源。 |