【图】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 属性(如 Passive、Layout)决定了该副作用在 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 子阶段只负责"调度"而非"执行" :useEffect 的 create 函数绝对不在 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 释放资源。
相关推荐
空中湖1 天前
用 React 写数据大屏:为什么我不建议用图表库
前端·react.js·前端框架
用户094248568031 天前
第7章:Redis有序集合 ZSet 实战:排行榜与延时任务
redis·架构·源码
用户4702784004051 天前
从 0 到 1 写一个扫雷游戏
源码
来一首_We_go3 天前
React Flow Pro 高级交互实现
前端·react.js
weedsfly3 天前
React 不可变数据:为什么页面“明明数据变了却不更新
前端·javascript·react.js
随风一样自由3 天前
【React+TS71007】Next.js “use client” 误用导致 TS71007:客户端/服务端引入问题的完整排查与修复
前端·javascript·react.js
ZoeLandia3 天前
React6:组件通信
前端·react.js·typescript
ZoeLandia4 天前
React03:useState 与不可变更新
前端·react.js·typescript·react
鬼手点金4 天前
单轮对话:最简单的 API 调用
java·服务器·前端·javascript·react.js·servlet·tomcat
console.log('npc')4 天前
从0到1实现任务评价统计功能:React + TypeScript 实战指南(小白注释版)
前端·react.js·typescript