子组件 Effect 为什么先于父组件完成
这篇笔记详细解释一个 React 里很容易让人困惑的现象:
子组件的
useEffect通常会先于父组件的useEffect执行。
如果你是在看 构建自己的React,可以先记住简化结论:
React 提交 effect 时,会沿着 Fiber 树做深度优先遍历,并且在这个阶段先处理子 Fiber,再处理父 Fiber。
1. 先看现象
假设有这样一段代码:
function Parent() {
useEffect(() => {
console.log("Parent effect")
}, [])
return <Child />
}
function Child() {
useEffect(() => {
console.log("Child effect")
}, [])
return <div>Child</div>
}很多人第一反应是:
Parent effect
Child effect因为从代码结构上看,Parent 包着 Child,似乎父组件应该更早完成。
但实际常见输出是:
Child effect
Parent effect这不是因为子组件先渲染,也不是因为 useEffect 有特殊的“子优先规则”。
真正原因要从 Fiber 树的遍历顺序说起。
2. 渲染阶段和提交阶段不是一回事
React 更新大致可以分成两个阶段:
render 阶段
↓
计算新的 Fiber 树
↓
commit 阶段
↓
把变化提交到 DOM,并执行 effect在 render 阶段,React 会执行函数组件,得到新的 UI 描述。
在 commit 阶段,React 才会:
- 插入、更新、删除 DOM。
- 执行 layout effect。
- 安排并执行 passive effect,也就是普通
useEffect。
所以要理解 useEffect 的顺序,重点不是看组件函数谁先被调用,而是看:
commit 阶段是按照什么顺序遍历 Fiber 树并提交 effect 的。
3. Parent 和 Child 会形成一棵 Fiber 树
上面的组件大致会形成这样的 Fiber 结构:
Parent Fiber
↓ child
Child Fiber
↓ child
div Fiber每个 Fiber 都可以理解为一个工作单元。
它们之间通过几个指针连起来:
type Fiber = {
parent: Fiber | null
child: Fiber | null
sibling: Fiber | null
}也就是:
parent指向父 Fiber。child指向第一个子 Fiber。sibling指向下一个兄弟 Fiber。
这和 Fiber 是什么 里的结构是同一套心智模型。
4. Fiber 遍历通常是深度优先
React 处理 Fiber 树时,经常使用深度优先遍历。
深度优先的意思是:
先一路往子节点走到底
↓
子节点处理完
↓
再回到父节点
↓
再处理兄弟节点对于这棵树:
Parent
└─ Child
└─ div深度优先会先进入 Parent,再进入 Child,再进入 div。
但关键在于:
进入一个节点的顺序,不等于完成一个节点的顺序。
这就像你读一本书的目录:
进入第一章
进入第一节
进入第一小节
完成第一小节
完成第一节
完成第一章最深的内容往往最先完成。
5. effect 提交更像“完成时处理”
为了理解子组件 effect 为什么先执行,可以先看一个简化版递归:
function commitEffects(fiber: Fiber | null) {
if (!fiber) return
commitEffects(fiber.child)
runEffects(fiber)
commitEffects(fiber.sibling)
}这段代码的顺序是:
- 先处理当前 Fiber 的子 Fiber。
- 子 Fiber 都处理完后,才执行当前 Fiber 的 effect。
- 最后处理兄弟 Fiber。
如果代入 Parent → Child:
commitEffects(Parent)
↓
commitEffects(Child)
↓
runEffects(Child)
↓
runEffects(Parent)所以输出就是:
Child effect
Parent effect这就是“子组件 effect 先于父组件完成”的最小解释。
6. 为什么不是父组件先 effect
很多人会把“父组件先渲染”和“父组件 effect 先执行”混在一起。
但它们是两件事。
函数组件执行顺序可以近似理解为:
执行 Parent 函数组件
↓
发现它返回 Child
↓
执行 Child 函数组件这说明父组件确实更早进入渲染流程。
但是 effect 不在渲染阶段执行。
useEffect 在渲染阶段只是登记:
Parent 登记一个 effect
Child 登记一个 effect真正执行 effect 要等到 commit 之后。
而 commit effect 时的遍历顺序是子 Fiber 优先完成,所以子组件 effect 会先执行。
7. 和 DOM 提交顺序有什么关系
你可以把一次更新拆成两层:
第一层:DOM 变化提交
第二层:effect 执行普通 useEffect 不会阻塞浏览器绘制,它属于 passive effect。
大致心智模型是:
计算 Fiber 树
↓
提交 DOM 变化
↓
浏览器有机会绘制
↓
执行 useEffect当 React 开始执行这些 passive effects 时,它会遍历 Fiber 树上的 effect。
在这个遍历里,子 Fiber 的 effect 会先于父 Fiber 的 effect 被处理。
所以更精确地说:
不是子组件的 DOM 一定晚于或早于父组件,而是 passive effect 的提交遍历呈现出子组件先于父组件的顺序。
8. cleanup 的顺序也要注意
如果 effect 返回 cleanup:
function Parent() {
useEffect(() => {
console.log("Parent effect")
return () => {
console.log("Parent cleanup")
}
}, [])
return <Child />
}
function Child() {
useEffect(() => {
console.log("Child effect")
return () => {
console.log("Child cleanup")
}
}, [])
return <div>Child</div>
}在很多场景下,你会观察到 mount effect 是子先父后:
Child effect
Parent effectcleanup 的具体顺序要看触发场景和 React 内部处理的 effect 类型,不要只用一句“永远子先父后”概括所有情况。
对新手来说,先抓住这点就够了:
useEffect的执行顺序由 commit 阶段的 Fiber 遍历决定,不是由 JSX 书写顺序直接决定。
9. 为什么 React 要这样设计
从 Fiber 的角度看,子节点是父节点 UI 的一部分。
当 React 在 commit 阶段处理 effect 时,采用深度优先的完成顺序,有几个好处:
- 和 Fiber 树的递归结构一致。
- 子树先处理完,父节点再做自己的 effect。
- 父组件 effect 执行时,可以认为子组件对应的提交工作已经完成。
这和很多树结构算法是类似的。
比如删除一个文件夹时,通常也是:
先处理里面的文件
再处理外面的文件夹也就是先子后父。
10. 和 useLayoutEffect 一样吗
useEffect 和 useLayoutEffect 都属于 effect,但时机不同:
| Hook | 执行时机 | 是否阻塞绘制 |
|---|---|---|
useLayoutEffect | DOM 更新后、浏览器绘制前 | 会阻塞绘制 |
useEffect | 浏览器绘制后或非阻塞时机 | 不阻塞绘制 |
这篇笔记主要讲普通 useEffect。
如果你观察 useLayoutEffect,也可能看到类似的子先父后现象,但理解重点仍然是:
effect 顺序来自 Fiber commit 阶段的遍历,不是来自组件代码的视觉嵌套顺序。
11. 最小心智模型
最后用一句话总结:
子组件
useEffect先于父组件完成,是因为 React 在 commit 阶段执行 effect 时,会深度优先遍历 Fiber 树,并在这个过程中先处理子 Fiber,再处理父 Fiber。
更短一点:
组件结构:Parent → Child
Effect 提交:Child → Parent如果你正在实现一个极简 React,可以参考 在 commit 后执行 effects 里的 commitEffects。