useEffect
概述
useEffect 是 React Hooks 体系里最常用的副作用 API。表面看是"组件渲染后跑一段逻辑",但底层涉及effect 节点收集、effectList 构建、commit 阶段调度、scheduler 异步任务四套机制。
useLayoutEffect 与之相似,但执行时机不同——这是面试高频考点,也是最容易写错的地方。
本篇按"收集 → 构建 effectList → 调度执行"的顺序拆解,重点回答:
- effect 信息存在哪?
- 多个组件的 effect 怎么串成一个有序的 effectList?
useEffect和useLayoutEffect到底什么时候执行?为什么一个会"闪烁"一个不会?
一、读源码前的三个前置概念
| 概念 | 作用 | 所在位置 |
|---|---|---|
| Fiber flags | 每个 Fiber 节点上的标记位(HookHasEffect / HookPassive / HookLayout) | fiber.flags |
| updateQueue | 单个 Fiber 上所有 effects 串成的循环链表 | fiber.updateQueue.lastEffect |
| effectList | 整个 Fiber Tree 上有 effect 的 fiber 节点组成的链表 | root.firstEffect → ... → root.lastEffect |
关键认识:单个 Fiber 上存的是 effect(
updateQueue),多个 Fiber 之间串成的是 fiber 节点(effectList)。这两个东西是不同维度,名字相似但别混。
二、数据结构
2.1 effect 节点
挂在单个 Fiber 的 updateQueue 上的循环链表节点:
{
create: Function, // useEffect 传入的第一个参数(回调)
destroy: Function, // 回调 return 的函数(清理函数)
deps: any[], // 依赖项
next: Effect, // 指向下一个 effect
tag: string, // 区分 useEffect(HookPassive)还是 useLayoutEffect(HookLayout)
}
副作用在函数组件中体现为
use(Layout)Effect的回调和销毁函数(统称"effect")。
2.2 两层链表关系
| 层级 | 数据结构 | 作用 |
|---|---|---|
| 单个 Fiber | effect 循环链表(updateQueue) | 收集这个 Fiber 上所有 effects |
| 整个 Tree | fiber 节点链表(effectList) | 按顺序串所有有 effect 的 fiber |
单 Fiber 视角:

整个 Tree 视角:

最终的 effectList 形如:

三、收集阶段(render 阶段)
render 阶段做的事:从组件函数里把 effect 收上来。mount 走一条路径,update 走另一条。
3.1 mount:mountEffectImpl
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook(); // 创建 hook 节点
const nextDeps = deps === undefined ? null : deps;
// 1️⃣ 给 fiber 节点打 effectTag 标记
currentlyRenderingFiber.flags = fiberFlags;
// 2️⃣ 创建 effect 对象,挂到 hook.memoizedState
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined, // mount 阶段没有 destroy
nextDeps,
);
}
mount 的特点:不需要比较依赖(首次渲染肯定要跑一次)。HookHasEffect 表示"这次必跑"。
3.2 update:updateEffectImpl
function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
// 1️⃣ 拿到上次的 effect,提取 destroy(清理函数)
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
// 2️⃣ 依赖项对比
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 依赖没变 → 复用 effect,不打 HookHasEffect
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(hookFlags, create, destroy, nextDeps);
return; // ⚡ 提前返回
}
}
}
// 3️⃣ 依赖变了 → 打 HookHasEffect 标记
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps,
);
}
设计点:
HookHasEffect是 commit 阶段判断"要不要执行这个 effect"的关键。只有打上HookHasEffect的 effect 才会真正执行回调,没打的话只更新 effect 对象不执行。这就是"依赖没变就不跑 effect"的实现。
mount vs update 的核心区别:
- mount:无须调用前一次 destroy,直接挂 effect,必跑
- update:先取上一次的 destroy,比较依赖决定要不要跑
3.3 追加到循环链表 —— pushEffect
不管 mount 还是 update,最终都调用 pushEffect:
function pushEffect(tag, create, destroy, deps) {
const effect = { tag, create, destroy, deps, next };
// 1️⃣ 拿到当前 fiber 的 updateQueue(effect 循环链表)
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
if (componentUpdateQueue === null) {
// updateQueue 为空:创建,自己指向自己形成环
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// updateQueue 不为空:追加到链表尾部
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;
}
3.4 构建 effectList —— completeUnitOfWork
单个 Fiber 的 effect 收集完了,React 还需要把整个 Tree 上有 effect 的 fiber 节点串成一条 effectList——commit 阶段就是沿着这条链表顺序执行的。
function completeUnitOfWork(unitOfWork) {
let completedWork = unitOfWork;
do {
const returnFiber = completedWork.return;
// 1️⃣ 子节点的 effect 上提到父节点
if (returnFiber !== null) {
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
// 连接兄弟节点之间的 effect
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
}
returnFiber.lastEffect = completedWork.lastEffect;
}
// 2️⃣ 当前 fiber 节点有 effect
const flags = completedWork.flags;
if (flags > PerformedWork) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
}
// 3️⃣ 兄弟节点遍历,再返回父级
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
}
设计点:effectList 是自下而上构建的(深度优先)。子节点处理完才处理父节点,保证 effect 执行顺序符合 JSX 树结构。
四、执行阶段(commit 阶段)
render 阶段负责"收集",commit 阶段负责"执行"。两者最大的区别是 useEffect 异步、useLayoutEffect 同步。
4.1 useEffect —— 异步执行(DOM 渲染后)
useEffect 在 commit 阶段有 3 个执行点:
① 刚进入 commit 阶段 —— flushPassiveEffects
function commitRootImpl(root, renderPriorityLevel) {
// 进入 commit 阶段,先执行一次之前未执行的 useEffect
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// ...
}
② beforeMutation 阶段 —— 异步调度 useEffect
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
// ...
if ((flags & Passive) !== NoFlags) {
if (!rootDoesHavePassiveEffects) {
// 锁住调度状态,防止多次调度
rootDoesHavePassiveEffects = true;
// ⚡ 关键:用 scheduler 异步调度
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
设计点:
scheduleCallback把 effect 执行放到 Scheduler 任务队列,等浏览器绘制(paint)之后才真正执行。所以useEffect不会阻塞 DOM 渲染。
③ layout 阶段 —— 填充 effect 执行数组
function commitLifeCycles(finishedRoot, current, finishedWork, committedLanes) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// layout 阶段填充 effect 执行数组
schedulePassiveEffects(finishedWork);
return;
}
}
}
填充逻辑:
function schedulePassiveEffects(finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const { next, tag } = effect;
// ⚡ 关键:只有同时含 HookPassive 和 HookHasEffect 才处理
if (
(tag & HookPassive) !== NoHookEffect &&
(tag & HookHasEffect) !== NoHookEffect
) {
// 1️⃣ 销毁函数入 unmount 数组
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
// 2️⃣ 创建函数入 mount 数组
enqueuePendingPassiveHookEffectMount(finishedWork, effect);
}
effect = next;
} while (effect !== firstEffect);
}
}
为什么先入 unmount 数组再入 mount 数组?因为 effect 的执行顺序是"先销毁旧的,再创建新的"。数组排队保证了顺序。
真正执行 —— flushPassiveEffectsImpl
function flushPassiveEffectsImpl() {
if (rootWithPendingPassiveEffects === null) return false;
// 1️⃣ 先执行所有销毁函数
const unmountEffects = pendingPassiveHookEffectsUnmount;
pendingPassiveHookEffectsUnmount = [];
for (let i = 0; i < unmountEffects.length; i += 2) {
const effect = unmountEffects[i];
const fiber = unmountEffects[i + 1];
const destroy = effect.destroy;
effect.destroy = undefined; // 防止重复执行
if (typeof destroy === "function") {
try {
destroy();
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
}
// 2️⃣ 再执行所有创建函数
const mountEffects = pendingPassiveHookEffectsMount;
pendingPassiveHookEffectsMount = [];
for (let i = 0; i < mountEffects.length; i += 2) {
const effect = mountEffects[i];
const fiber = mountEffects[i + 1];
try {
const create = effect.create;
effect.destroy = create(); // 把返回值存为新的 destroy
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
return true;
}
关键时序:
render 完成 → commit 阶段(同步)→ DOM 更新 → 浏览器 paint
↓
flushPassiveEffects 异步执行
4.2 useLayoutEffect —— 同步执行(DOM 渲染前)
useLayoutEffect 的实现更简单:不用数组缓存,直接在 effect 链表上操作。
① mutation 阶段 —— 卸载上一次的 effect
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);
function commitHookEffectListUnmount(tag, finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) destroy();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
② layout 阶段 —— 执行本次 effect 创建
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
function commitHookEffectListMount(tag, finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
关键时序:
render 完成 → commit 阶段(同步)→ mutation 阶段(执行 destroy + 更新 DOM)
↓
layout 阶段(执行 create)
↓
浏览器 paint
4.3 对比:useEffect vs useLayoutEffect
| 维度 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器 paint 之后 | DOM 更新完成、paint 之前 |
| 调度方式 | 通过 scheduleCallback 异步调度 | 同步执行(commit 阶段直接跑) |
| 是否阻塞渲染 | ❌ 不阻塞 | ✅ 会阻塞 |
| 缓存方式 | 用两个数组(unmount / mount)缓存 | 直接遍历 updateQueue |
| 等价类组件 | componentDidMount + componentDidUpdate(异步版) | componentDidMount + componentDidUpdate(同步版) |
| 典型场景 | 数据请求、订阅、定时器 | DOM 测量、强制同步布局、动画 |
| 能否在服务端运行 | ✅ 可以(render 阶段不执行) | ⚠️ 警告(SSR 阶段会触发) |
// useLayoutEffect 经典用法:避免闪烁
function Tooltip() {
const ref = useRef();
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// 同步测量 DOM 并更新位置(在 paint 之前完成)
// 用户看不到"位置错乱 → 修正"的闪烁过程
const rect = ref.current.getBoundingClientRect();
setPosition({ top: rect.top, left: rect.left + 20 });
}, []);
return (
<div ref={ref} style={position}>
提示
</div>
);
}
选型经验:
- 默认用
useEffect(不阻塞渲染是它的最大价值)- 涉及到 修改 DOM、读取布局、动画 的场景考虑
useLayoutEffect- 不要为了"防止闪烁"乱用
useLayoutEffect——大多数情况加 CSS transition 就能解决
五、面试高频 Q&A
Q1: useEffect 和 useLayoutEffect 的区别是什么?
useEffect:通过 scheduler 异步调度,发生在 DOM 渲染之后,不会阻塞浏览器绘制useLayoutEffect:在 commit 阶段同步执行,与componentDidMount/componentDidUpdate时机一致默认建议使用
useEffect(不阻塞渲染),只有涉及 修改 DOM、测量布局、动画等场景才考虑useLayoutEffect。简记:
useEffect是 paint 之后跑,useLayoutEffect是 paint 之前跑。
Q2: 同一个 effect 回调里多次 setState,会触发多次 render 吗?
不会。所有
setState最终都会调用scheduleUpdateOnFiber:// scheduleUpdateOnFiber
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}只有当
executionContext为NoContext时才会触发同步更新,其余都是批量更新。在 commit 的 beforeMutation 阶段会设置executionContext |= CommitContext,标记已进入 commit 阶段,所以 effect 里的 setState 不会立刻同步刷新。
Q3: useEffect 是异步还是同步?
源码里 useEffect 通过
scheduleCallback放到 Scheduler 任务队列,异步调度。但注意:这里的"异步"不是说它跑在另一个线程,而是当前同步任务结束后再调度。React 18 之后
setState的批处理变得更激进,effect 的行为也更可预测。
Q4: 为什么 useEffect 里能用 useState 的 setter 闭包?
因为 setter 引用是稳定的(见 useState Q7)。
dispatchAction.bind的闭包结果始终是同一个引用,不会变,所以 useEffect 不需要把 setter 加到依赖数组。这也是 React 文档建议的:"所有依赖都列出来,但 setter 是个例外"。
Q5: 依赖项是空数组 [] 的 useEffect 什么时候执行?
- mount 时:执行一次(
mountEffectImpl走路径,不比较依赖)- update 时:永不执行(
areHookInputsEqual([], [])为 true,不打HookHasEffect,effect 进了 updateQueue 但永远不被执行)这就是经典的"只跑一次"模式——等价于类组件的
componentDidMount。
Q6: 怎么在 unmount 时做清理?
用 useEffect 回调的 return 函数:
useEffect(() => {
const timer = setInterval(() => {...}, 1000);
return () => clearInterval(timer); // 卸载时清理
}, []);源码对应:destroy 函数会被存到 effect.destroy 上,下次 effect 跑之前先调用 destroy(或组件 unmount 时调用)。