【图】React源码解析-从源码数据结构、依赖算法、调度流水线到内存管理,深挖useEffect的全部核心机制

useEffect 在 Fiber 上的数据结构(Hook 对象与副作用链表)

这张图展示 useEffect 在首次挂载时,如何在 Fiber 节点上构建 Hook 对象,以及副作用(Effect)如何被挂载到更新队列(updateQueue)中。

📝 源码级深度解析(数据结构)

  1. 双重存储机制useEffect 产生的副作用信息实际上被存储在两个地方。一是作为 Hook 节点 的一部分(hook.memoizedState),供函数组件在更新时读取依赖项;二是被 复制一份 推入 Fiber.updateQueue 的副作用链表中,供 Commit 阶段遍历执行。这种设计使得 Render 阶段和 Commit 阶段的数据流解耦。
  2. Effect 对象的字段 :每个 Effect 对象包含 create(执行函数)、deps(依赖数组)、destroy(清理函数,初始为 null)和 next(指向下一个 Effect 的指针)。tag 属性(如 PassiveLayout)决定了该副作用在 Commit 的哪个子阶段被执行。
  3. tag 的位运算标记 :Fiber 节点的 flags(副作用标识)使用位掩码。当挂载 useEffect 时,React 会执行 fiber.flags |= PassiveEffect。这使得 React 在提交阶段能快速判断:是否需要在 Mutation 子阶段结束后,去调度 flushPassiveEffects

依赖项比较机制(areHookInputsEqual)------是否跳过执行

这张图剖析 useEffect 更新时,React 如何比对前后两次的依赖数组,决定是否调用副作用函数。

📝 源码级深度解析(依赖比较)

  1. 比较算法 Object.is :React 使用 Object.is 进行依赖项的相等性判断,而不是 === 或浅比较(Shallow Compare)。Object.is=== 的区别在于:+0 不等于 -0,且 NaN 等于自身。这是大多数开发者容易忽略的细节。
  2. 浅层比较(Shallow Comparison)areHookInputsEqual 实现的是浅层遍历for 循环逐项比对),而非深拷贝比对。如果依赖项是对象或数组,即使内容不变但引用地址变了,React 依然会判定为"变化",从而触发副作用重新执行。这也是为什么在 useEffect 依赖项中传递对象字面量(如 {id: 1})容易导致无限循环的原因。
  3. 依赖项为空数组 [] 的特殊性 :当 deps 为空数组时,首次挂载后,areHookInputsEqual 比较时发现新旧 deps 长度均为 0,循环自然结束且无差异。因此,该 Effect 仅在首次挂载时执行一次,后续更新全部跳过。

useEffect 的异步调度与执行时机(Passive 阶段)

这张图展示 useEffect 从 Commit 阶段到浏览器绘制,再到空闲期执行的完整时序流水线。

📝 源码级深度解析(调度时机)

  1. Mutation 子阶段只负责"调度"而非"执行"useEffectcreate 函数绝对不在 Mutation 子阶段执行。React 在这个阶段仅仅执行 scheduleCallback(NormalPriority, flushPassiveEffects)(向 Scheduler 发起异步调度请求),将执行权交给浏览器的绘制流水线。
  2. Layout 子阶段与 useLayoutEffect 的边界Layout 子阶段是同步且阻塞绘制的(发生在 Mutation 之后、Paint 之前)。useLayoutEffect 在此阶段同步执行,因此可以安全读取 DOM 布局属性(如 getBoundingClientRect),但耗时操作会直接导致掉帧。
  3. 延迟执行的权衡(Trade-off) :将 useEffect 推迟到绘制后执行,确保了数据请求、日志记录、非关键 DOM 操作不会延迟用户看到屏幕内容的时机。这也是 React 官方推荐将绝大多数副作用放在 useEffect 而不是 useLayoutEffect 中的原因。

清理闭环(Cleanup)与组件卸载时的收尾流程

这张图剖析 useEffect 的清理函数(destroy)在依赖变更时组件卸载时的完整执行逻辑,以及如何防止内存泄漏。

📝 源码级深度解析(清理闭环)

  1. 清理函数的存储链路destroy 函数并非存储在 Hook 节点的 memoizedState 中,而是附着在 Effect 对象本身effect.destroy)。每次 create 执行后,其返回值(清理函数)被赋值给 effect.destroy。这使得 React 在卸载或更新前,能够精准定位到该 Effect 对应的清理逻辑。
  2. 更新时的"先清理后创建"顺序 :当依赖项变化时,React 严格遵循 "先执行旧的 destroy,再执行新的 create" 顺序。这确保了旧的监听器(如 WebSocket 连接、DOM 事件绑定)在建立新连接之前被彻底移除,避免重复订阅导致的内存泄漏。
  3. 卸载时的优先级执行 :组件卸载时,useEffect 的清理函数会在 Mutation 子阶段 (DOM 移除之前或同时)同步执行。这是为了防止在组件从视图中移除后,异步清理函数尚未执行而导致"无法访问已卸载组件的状态"的 React 警告。如果清理函数中存在异步操作(如 setTimeout),React 无法保证其完成时机,因此官方推荐清理函数保持同步简单逻辑。
  4. React 19 的编译器优化(React Compiler) :在未来的 React 19 + Compiler 版本中,如果 useEffect 内部没有引用任何响应式变量,编译器可能自动省略依赖项比较或将其提升为静态常量,进一步提升性能。但清理闭环的核心机制将保持兼容。

useEffect 的完整工作流

维度 核心结论
数据结构存储 useEffect 同时存在于 Hook 链表(用于读依赖)和 updateQueue(用于执行),通过 Passive 标记区分。
依赖比较与跳过 使用 Object.is 逐项浅比较,空数组代表永不更新,对象/数组引用变化会触发重执行。
异步调度时机 Mutation 阶段仅发起调度,Layout 阶段执行 useLayoutEffect,绘制后空闲期才执行 useEffect
清理闭环机制 更新前先执行旧 destroy,再执行新 create;卸载时在 Mutation 阶段同步执行 destroy 释放资源。
相关推荐
Darling噜啦啦5 小时前
React 组件进化论:从状态混乱到 UI = fn(props) 的三次重构
react.js
无人生还5 小时前
从 Vue3 到 React · 快速上手系列第 10 篇:路由
前端·vue.js·react.js
deepThinking5 小时前
从项目里回看 React 生态:React 18/19 新特性、我们踩过的坑,以及那些"用旧写法写出的新思想
react.js
FogLetter5 小时前
我真的写了个“诈尸式”缓存组件:手撕React KeepAlive
前端·react.js·面试
LaughingZhu7 小时前
Product Hunt 每日热榜 | 2026-08-02
前端·神经网络·react.js·搜索引擎·前端框架
无人生还1 天前
从 Vue3 到 React · 快速上手系列第 9 篇:自定义 Hook(对标 Composables)
前端·vue.js·react.js
张元清1 天前
React useInfiniteScroll Hook:无限滚动轻松实现(2026)
javascript·react.js
浮生望1 天前
React组件化实战:从Todo应用洞悉useState状态管理与组件通信
react.js
先吃饱再说2 天前
编写一个颜色选择器之后,我理解了 React 工程化的三层架构
react.js·前端框架·前端工程化
先吃饱再说2 天前
别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
react.js·前端框架