核心概念:副作用与 Hooks
在 React 函数组件中,诸如数据获取、订阅、手动更改 DOM 等操作被称为"副作用"(Side Effects),因为它们会影响组件之外的东西,并且在渲染过程中无法完成。React Hooks(如 useState
, useEffect
)允许我们在函数组件中使用 state 和其他 React 特性。
useEffect
和 useLayoutEffect
就是专门用来处理副作用的 Hooks。它们的主要区别在于执行时机。
一、useEffect
:异步执行的副作用
用途:处理不需要阻塞浏览器绘制的副作用,如数据获取、设置订阅、手动操作非关键 DOM 等。它是最常用的副作用 Hook。
执行时机:
- 首次渲染后 :在组件完成渲染并且浏览器完成绘制(Paint)之后异步执行。
- 更新渲染后 :在组件更新渲染完成并且浏览器完成绘制之后异步执行(如果依赖项发生变化)。
- 卸载前:执行上一次 effect 返回的清理函数(cleanup function)。
核心原理与源码分析:
-
调度(Scheduling)阶段 - Render Phase:
- 当你调用
useEffect(create, deps)
时,React 内部会通过当前的 Dispatcher 调用相应的 Hook 实现(例如useEffect
对应的mountEffect
或updateEffect
)。 - 关键文件 :
react-reconciler/src/ReactFiberHooks.js
- 核心逻辑 (
mountEffect
/updateEffect
):-
创建 Effect 对象 :React 会创建一个包含
create
函数(你的 effect 函数)、destroy
函数(上一次 effect 返回的清理函数)、依赖项deps
以及tag
(标记 effect 类型)的对象。 -
标记 Effect 类型 :对于
useEffect
,它会被打上Passive
标签。这个标签很重要,决定了它在 Commit 阶段的执行时机。Passive
意味着它不会阻塞浏览器绘制。javascript// react-reconciler/src/ReactFiberHooks.js (简化示意) // mountEffect / updateEffect 内部逻辑类似 function mountEffectImpl(fiberFlags, hookFlags, create, deps) { const hook = mountWorkInProgressHook(); // 获取当前 Hook 状态 const nextDeps = deps === undefined ? null : deps; // ... (省略部分检查逻辑) // 重点:给 Fiber 节点打上 PassiveEffect 标记 // PassiveEffect = Passive | HookHasEffect // Passive 来自 ReactFiberFlags.js, HookHasEffect 表明这个 hook 有副作用要执行 fiber.flags |= fiberFlags; // fiberFlags 包含 PassiveEffect // 将 effect 信息存储在 hook 的 memoizedState 中 hook.memoizedState = pushEffect( HookHasEffect | hookFlags, // hookFlags 区分是 useEffect 还是 useLayoutEffect 等 create, // 你的 effect 函数 undefined, // destroy 函数,首次 mount 为 undefined nextDeps, // 依赖项 ); } function pushEffect(tag, create, destroy, deps) { // 创建 effect 对象 const effect = { tag, create, destroy, deps, // Circular reference 的 next 指针,用于形成 effect 链表 next: null, }; // 将 effect 添加到 Fiber 节点的 updateQueue.lastEffect 链表中 let componentUpdateQueue = fiber.updateQueue; if (componentUpdateQueue === null) { componentUpdateQueue = createFunctionComponentUpdateQueue(); fiber.updateQueue = componentUpdateQueue; componentUpdateQueue.lastEffect = effect.next = effect; } else { const lastEffect = componentUpdateQueue.lastEffect; if (lastEffect === null) { componentUpdateQueue.lastEffect = effect.next = effect; } else { const firstEffect = lastEffect.next; lastEffect.next = effect; effect.next = firstEffect; componentUpdateQueue.lastEffect = effect; } } return effect; }
-
依赖项比较 :在
updateEffect
中,会比较deps
和上一次存储的依赖项。只有当依赖项发生变化(或者没有提供依赖项数组)时,才会标记HookHasEffect
,表示需要执行新的 effect。
-
- 当你调用
-
提交(Commit)阶段:
- React 完成 DOM 更新后,会处理带有副作用标记(Flags)的 Fiber 节点。
- 关键文件 :
react-reconciler/src/ReactFiberCommitWork.js
- 核心逻辑 :
-
Commit 阶段分为几个子阶段,
useEffect
的执行发生在commitPassiveEffects
这个子阶段。 -
异步调度 :
commitPassiveEffects
不会立即执行 effect。它使用 React 的调度器(Scheduler)来异步 调度 effect 的执行。这意味着浏览器可以先完成绘制,然后再执行这些 effect。javascript// react-reconciler/src/ReactFiberCommitWork.js (简化示意) // commitRootImpl 函数是 Commit 阶段的入口 function commitRootImpl(root, ...) { // ... (DOM Mutations 等操作) // 标记需要执行 Passive Effects root.effect_tag |= PassiveEffects; // ... // 在 Commit 阶段的末尾,调度 Passive Effects 的执行 // flushPassiveEffects 会使用 Scheduler 来异步执行 if ((finishedWork.subtreeFlags & PassiveMask) !== NoFlags || (finishedWork.flags & PassiveMask) !== NoFlags) { if (!rootDoesHavePassiveEffects) { rootDoesHavePassiveEffects = true; // 使用 Scheduler 调度 flushPassiveEffects 的执行 scheduleCallback(NormalPriority, () => { flushPassiveEffects(); return null; }); } } // ... } // flushPassiveEffects 会遍历 Fiber 树,执行 Passive Effects function flushPassiveEffects() { // ... (省略状态检查和循环逻辑) commitPassiveUnmountEffects(); // 执行清理函数 commitPassiveMountEffects(); // 执行 effect 函数 // ... } // commitPassiveMountEffects 遍历 Fiber 树,找到带 Passive 标记的节点 function commitPassiveMountEffects(root, finishedWork) { // 遍历 Fiber 树,找到带有 Passive 标记的节点 // ... commitPassiveMountEffects_complete(subtreeRoot, root, finishedWork); // ... } // 真正执行 effect 的地方 function commitPassiveMountEffects_complete(...) { // ... 遍历 Fiber 节点 const effect = node.updateQueue.lastEffect; if (effect !== null) { const firstEffect = effect.next; let currentEffect = firstEffect; do { // 检查 tag 是否包含 Passive 和 HookHasEffect if ((currentEffect.tag & HookPassive) !== NoHookEffect && (currentEffect.tag & HookHasEffect) !== NoHookEffect) { // 执行 effect 的 create 函数 const create = currentEffect.create; currentEffect.destroy = create(); // 保存返回的 cleanup 函数 } currentEffect = currentEffect.next; } while (currentEffect !== firstEffect); } // ... } // commitPassiveUnmountEffects 类似,但执行的是 destroy 函数 function commitPassiveUnmountEffects(...) { // ... 遍历 Fiber 节点 // ... 找到需要 unmount 的 effect // ... 执行 effect.destroy() // ... }
-
执行流程 :
flushPassiveEffects
首先调用commitPassiveUnmountEffects
,遍历 Fiber 树,执行那些在上一次渲染中被标记为需要清理(因为组件卸载或依赖项变化)的useEffect
的destroy
函数。- 然后调用
commitPassiveMountEffects
,遍历 Fiber 树,执行本次渲染中被标记为需要执行的useEffect
的create
函数,并将其返回的清理函数(如果有)存储在effect.destroy
中,供下次清理使用。
-
小结 :useEffect
通过 Passive
标记和异步调度,确保了副作用的执行不会阻塞浏览器渲染,提供了更好的用户体验,尤其适用于那些不需要立即反映在视觉上的操作。
二、useLayoutEffect
:同步执行的副作用
用途 :处理需要在浏览器绘制之前同步执行的副作用。常见场景包括:
- 读取 DOM 布局(如获取元素尺寸、位置)。
- 同步修改 DOM 并希望用户看不到中间状态(避免闪烁)。
执行时机:
- 首次渲染后 :在 React 完成所有 DOM 变更之后,但在浏览器绘制之前 ,同步执行。
- 更新渲染后 :在 React 完成所有 DOM 变更之后,但在浏览器绘制之前 ,同步执行(如果依赖项发生变化)。
- 卸载前:执行上一次 effect 返回的清理函数。
核心原理与源码分析:
-
调度(Scheduling)阶段 - Render Phase:
- 调用
useLayoutEffect(create, deps)
。 - 关键文件 :
react-reconciler/src/ReactFiberHooks.js
- 核心逻辑 (
mountLayoutEffect
/updateLayoutEffect
):-
与
useEffect
非常相似,也会创建 Effect 对象并存入updateQueue
。 -
关键区别 :
useLayoutEffect
给 Fiber 节点打上的标记不同。它通常会打上Update
或Layout
相关的标记(具体标记可能随 React 版本演变,但核心是非 Passive 的 Effect 标记)。这些标记表明 effect 需要在 Layout 阶段同步执行。javascript// react-reconciler/src/ReactFiberHooks.js (简化示意) function mountLayoutEffect(create, deps) { // 注意这里的 fiberFlags 是 Update (或其他同步标记),而不是 Passive return mountEffectImpl(Update, HookLayout, create, deps); } function updateLayoutEffect(create, deps) { // 注意这里的 fiberFlags 是 Update (或其他同步标记),而不是 Passive return updateEffectImpl(Update, HookLayout, create, deps); } // mountEffectImpl / updateEffectImpl 内部逻辑与 useEffect 类似 // 只是传入的 fiberFlags 和 hookFlags 不同 // fiber.flags |= Update; // 打上同步执行的标记 // hook.memoizedState = pushEffect(HookHasEffect | HookLayout, ...);
-
- 调用
-
提交(Commit)阶段:
- 关键文件 :
react-reconciler/src/ReactFiberCommitWork.js
- 核心逻辑 :
-
useLayoutEffect
的执行发生在commitLayoutEffects
这个子阶段。这个阶段在 DOM Mutations 完成之后,但在浏览器绘制之前。 -
同步执行 :与
useEffect
不同,commitLayoutEffects
是同步 执行的。React 会立即遍历 Fiber 树,执行所有标记了Update
/Layout
的 effect。javascript// react-reconciler/src/ReactFiberCommitWork.js (简化示意) function commitRootImpl(root, ...) { // ... // 1. 执行 DOM Mutations (before mutation effects) // ... // 2. 执行 Layout Effects (包括 useLayoutEffect 的清理和执行) // commitLayoutEffects 是同步执行的 commitLayoutEffects(root, finishedWork); // ... (然后才会调度 Passive Effects) if ((finishedWork.subtreeFlags & PassiveMask) !== NoFlags || (finishedWork.flags & PassiveMask) !== NoFlags) { // ... scheduleCallback(NormalPriority, flushPassiveEffects); } // ... (Commit 阶段结束,浏览器可以开始绘制) } // commitLayoutEffects 遍历 Fiber 树,同步执行 Layout Effects function commitLayoutEffects(root, committedLanes) { // ... 遍历 Fiber 树 commitLayoutEffects_begin(subtreeRoot, root, committedLanes); // ... } function commitLayoutEffects_begin(...) { while (nextUnitOfWork !== null) { // ... // 找到带有 LayoutMask (或 Update) 标记的节点 if ((node.flags & LayoutMask) !== NoFlags) { // ... // 执行 Layout Effect 的 destroy (清理) 和 create (执行) commitLayoutEffectOnFiber(root, node); // ... } // ... } } // 真正执行 Layout Effect 的地方 function commitLayoutEffectOnFiber(root, finishedWork) { const current = finishedWork.alternate; // 上一次的 Fiber const flags = finishedWork.flags; if ((flags & LayoutMask) !== NoFlags) { // 检查 Layout 标记 // ... // 执行 unmount (清理) commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork, current); // 执行 mount (执行) commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork); // ... } } // commitHookEffectListUnmount 和 commitHookEffectListMount // 内部会遍历 hook 的 effect 链表,执行 destroy 或 create 函数 // 与 commitPassiveMountEffects / commitPassiveUnmountEffects 类似 // 但它们是同步执行的
-
执行流程 :
commitLayoutEffects
会遍历 Fiber 树。- 对于带有
Layout
/Update
标记的 Fiber 节点,它会先执行上一次useLayoutEffect
返回的清理函数(destroy
)。 - 然后立即执行本次
useLayoutEffect
的create
函数,并将返回的清理函数存起来。 - 这个过程是完全同步的,会阻塞主线程,直到所有
useLayoutEffect
执行完毕,浏览器才能进行绘制。
-
- 关键文件 :
小结 :useLayoutEffect
通过同步执行机制,保证了在浏览器绘制前完成 DOM 读取和修改,适用于需要精确控制布局或避免视觉闪烁的场景。但因为它会阻塞渲染,应谨慎使用,优先考虑 useEffect
。
三、useEffect
vs useLayoutEffect
核心差异总结
特性 | useEffect |
useLayoutEffect |
---|---|---|
执行时机 | 渲染完成 + 浏览器绘制之后 ,异步执行 | 渲染完成 + DOM 更新之后 ,浏览器绘制之前 ,同步执行 |
阻塞渲染 | 否,不阻塞浏览器绘制 | 是,会阻塞浏览器绘制 |
适用场景 | 数据获取、订阅、非关键 DOM 操作等大多数副作用 | 读取 DOM 布局、同步 DOM 修改、避免闪烁 |
性能影响 | 性能较好,不影响首次绘制 | 可能影响性能,阻塞渲染 |
内部标记 | Passive Effect Flag |
Update / Layout Effect Flag |
调度方式 | 通过 Scheduler 异步调度 | 在 Commit 阶段同步执行 |
四、源码中的关键概念回顾
- Fiber Node: React 内部表示组件实例、DOM 节点等的数据结构,它承载了组件的状态、props、effect 列表、flags 等信息。
- Dispatcher : (
ReactCurrentDispatcher.current
) 一个全局对象,指向当前环境(渲染、mount、update)下应该使用的 Hooks 实现。 - Hook Object : 存储单个 Hook 状态(如
useState
的 state,useEffect
的 effect 信息)的数据结构,形成链表挂载在 Fiber 节点上。 - UpdateQueue : 挂载在 Fiber 节点上的队列,用于存储状态更新、effect 列表等。对于 effect,它通常包含一个指向 effect 链表头尾的指针 (
lastEffect
)。 - Effect Object : 包含
create
、destroy
、deps
、tag
、next
指针的对象,代表一个待处理的副作用。 - Effect Tag / Flags : (
fiber.flags
,effect.tag
) Fiber 节点和 Effect 对象上的位标记,用于指示需要执行的操作类型(如 DOM 更新、Passive Effect、Layout Effect)和 effect 的具体类型。 - Commit Phase Sub-stages: Commit 阶段被细分为不同子阶段(如 Mutations、Layout Effects、Passive Effects),以确保不同类型副作用在正确的时机执行。
- Scheduler: React 的并发调度器,用于安排任务(如渲染、Passive Effects)的优先级和执行时机,实现时间分片和异步执行。