大家好,欢迎来到今天的“React 内部奥秘深度研讨会”。我是你们的主讲人,一个在 React 源码里摸爬滚打了多年的老司机。
今天咱们不聊那些花里胡哨的 Hooks,也不聊怎么调优性能,咱们要聊点硬核的,聊聊 React 最核心、最神秘,也是面试必问的“双生子”——Render 阶段和 Commit 阶段。
很多同学对这两个阶段的理解仅限于“Render 画树,Commit 更新 DOM”。太肤浅了!太干瘪了!今天我要把这个过程像剥洋葱一样剥开,咱们要讲清楚:为什么 Render 阶段可以被打断(中断),而 Commit 阶段必须一口气干完?
这就像什么?这就像咱们装修房子。
Render 阶段就像是“画草图、算用料、列清单”。你可以在上面画错了擦掉,算错了重算,甚至老板突然进来让你去倒杯水,你可以暂停你的计算,喝完水回来接着算。这期间房子还是那个房子,没变样。
而 Commit 阶段就像是“动工、刷漆、搬家具”。一旦你开始刷漆,你就不能停。如果你刷了一半墙,突然停手去倒水,结果是什么?结果是一面半红半白的墙,或者墙漆流了一地,房子彻底毁了。浏览器也是一样的逻辑。
好,咱们开始。
第一部分:Render 阶段——那个“健忘”的数学家
首先,什么是 Render 阶段?
在 React 源码里,Render 阶段的核心任务是:计算。
它接收当前的 Props,计算出新的 Virtual DOM 树,构建新的 Fiber 树,然后根据新旧 Fiber 的差异,计算出需要进行的 DOM 操作。
本质区别一:无副作用
Render 阶段有一个铁律:不能执行副作用。
什么叫副作用?比如 document.title 改变、localStorage 读写、useEffect 执行。在 Render 阶段,React 做的事情仅仅是纯数学计算。它就像一个数学天才,脑子里只有公式,手里没有画笔。
本质区别二:可中断
正因为 Render 阶段没有副作用,它就可以被中断。
大家想一下,为什么可以中断?因为如果中断了,React 只要回到 Fiber 树的某个节点,继续往下遍历就行了。因为还没更新 DOM 呢!之前的计算结果都在内存里,没写进硬盘,也没画在屏幕上,随时可以重来。
源码视角的 Render 阶段
在 React 源码中,Render 阶段主要涉及 Scheduler 调度器和 Fiber 节点树。
首先,咱们得有个 Fiber 节点,它是 React 的基本工作单元。咱们来写个简化版的 Fiber 节点类,看看它的结构:
class FiberNode {
constructor(tag, props) {
this.tag = tag; // 标记类型:FunctionComponent, ClassComponent, HostComponent等
this.props = props;
this.return = null; // 父节点
this.child = null; // 第一个子节点
this.sibling = null; // 下一个兄弟节点
this.alternate = null; // 当前节点的旧版本,用于Diff
this.effectTag = 0; // 标记需要做什么操作:Placement(新增), Deletion(删除), Update(更新)
// ... 还有更多属性
}
}
注意看 return, child, sibling。这就是为什么 Render 阶段可以中断。React 维护了一个巨大的链表(树),遍历链表是非常容易“暂停”和“恢复”的。
当 React 开始 Render 阶段时,它会调用一个核心函数 performUnitOfWork。这个函数就像是工头,它负责干完当前这个单元的任务,然后看老板给的时间(deadline)够不够。
// 简化版的 workLoop 逻辑
let workInProgress = null; // 当前正在工作的 Fiber 节点
let isWorking = false;
let deadline = { didTimeout: false };
function workLoopSync() {
// 同步模式,死循环,直到干完
while (workInProgress) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
// 并发模式,看 deadline
while (workInProgress && !deadline.didTimeout) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(fiber) {
// 1. beginWork: 处理当前节点,创建子节点,或者处理兄弟节点
// 这就是所谓的“计算”过程
const next = beginWork(fiber);
// 2. 如果有子节点,就处理子节点
if (next) {
workInProgress = next;
return;
}
// 3. 如果没有子节点,开始处理兄弟节点
// 这时候开始往上回溯
let nextFiber = fiber;
while (nextFiber) {
// 4. completeWork: 标记副作用
completeWork(nextFiber);
// 找下一个兄弟
if (nextFiber.sibling) {
workInProgress = nextFiber.sibling;
return;
}
// 没有兄弟了,回到父节点
nextFiber = nextFiber.return;
}
// 阶段结束
workInProgress = null;
}
看懂了吗?这就是 Render 阶段。它只是在内存里摆弄这些指针。如果在这个 while 循环里,浏览器说“我有空闲时间了”,React 就会停下来,把控制权交还给浏览器去渲染(或者处理用户的点击事件)。
用户点击了一个按钮,React 怎么办?
它会暂停当前的 workInProgress,去处理点击事件。等点击事件处理完了,React 回到 Render 阶段,继续从刚才停下的地方往下走。因为它没动 DOM,所以不需要回滚,直接接着画就行。
Render 阶段的特性总结:
- 同步计算:虽然可以中断,但它是同步代码执行(没有 Promise 那种异步)。
- 纯计算:只读不写。
- 可中断:随时可以被高优先级的任务打断。
第二部分:Commit 阶段——那个“强迫症”的装修工
好了,Render 阶段算完了,React 知道了:“哎呀,这里要删个节点,那里要改个颜色,那里要插个图片。”
现在,React 睁开眼睛,准备干活了。这就是 Commit 阶段。
本质区别一:有副作用
Commit 阶段是 React 唯一真正操作 DOM 的阶段。它会真正地修改浏览器底层的 DOM 树。它会调用浏览器的原生 API:document.createElement, appendChild, removeChild, setAttribute。
本质区别二:不可中断
这就是最关键的问题:为什么不能中断?
假设 Commit 阶段可以中断。
React 执行到了 commitPlacement(插入节点),它在 DOM 树上找到了父节点,准备插入子节点。
突然,浏览器弹出一个 Alert:“你的浏览器已停止响应”。
React 被迫中断,把控制权交出去。
这时候,DOM 树变成了什么样?
父节点下有一个子节点,但这个子节点是“半成品”。
或者更糟糕的情况,React 刚把旧的 DOM 删除了一半,新的还没加上,屏幕上一闪而过一个白屏,或者元素位置跳动。
对于用户来说,DOM 的原子性是不可破坏的。你不能有“半个 DOM 节点”。要么有,要么没有。一旦开始 Commit,React 就必须把所有的变更一次性应用。
源码视角的 Commit 阶段
在 React 源码中,Commit 阶段的入口是 commitRoot。
function commitRoot(root) {
// 1. 获取需要提交的 effectList(包含所有变更的 Fiber 节点链表)
const firstEffect = root.nextEffect;
// 2. 开启 Commit 阶段
commitBeforeMutationEffects(root.current, firstEffect);
commitMutationEffects(root.current, firstEffect);
commitLayoutEffects(root.current, firstEffect);
// 3. 清理工作
root.current = null;
root.nextEffect = null;
// 4. 通知 React 应用已经完成
isWorking = false;
}
注意看,这整个函数是一个同步函数。它没有任何 setTimeout,没有任何 requestIdleCallback。它就是一条直线,直到最后一句 flushSyncCallbacks 执行完毕。
咱们详细拆解一下 commitMutationEffects,这是最核心的 DOM 操作部分。
// 简化版的 commitMutationEffects
function commitMutationEffects(current, finishedWork) {
let nextEffect = finishedWork;
while (nextEffect) {
const effectTag = nextEffect.effectTag;
// 如果有插入标记
if (effectTag & Placement) {
commitPlacement(nextEffect); // 真正的 DOM 插入操作
}
// 如果有更新标记
if (effectTag & Update) {
commitUpdate(nextEffect); // 真正的 DOM 属性修改操作
}
// 如果有删除标记
if (effectTag & Deletion) {
commitDeletion(nextEffect); // 真正的 DOM 删除操作
}
// 移动到下一个 Effect
nextEffect = nextEffect.nextEffect;
}
}
看看 commitPlacement 的实现逻辑(伪代码):
function commitPlacement(fiber) {
// 1. 找到父 DOM 节点
const parentFiber = fiber.return;
const parentDOM = parentFiber.stateNode; // 这里的 stateNode 就是真实的 DOM 节点引用
// 2. 找到要插入的 DOM 节点
const domNode = fiber.stateNode;
// 3. 执行插入!
// 注意:这里没有 if-else 的分支逻辑,就是纯粹的执行
if (fiber.effectTag === Placement) {
parentDOM.appendChild(domNode);
}
}
如果在执行 appendChild 的中途,浏览器渲染管线满了,或者用户点击了另一个按钮,React 无法“暂停”这个插入操作。因为 appendChild 是浏览器内核的原生操作,React 没法插手说“等一下,我先处理个点击事件,再回来继续插”。
Commit 阶段的特性总结:
- 同步执行:必须一口气跑完。
- DOM 操作:直接调用浏览器 API。
- 副作用:此时才会执行
useEffect,useLayoutEffect, 以及生命周期函数componentDidMount,componentDidUpdate。 - 不可中断:为了保持 DOM 树的一致性。
第三部分:为什么 Render 可以中断而 Commit 不能?(深度剖析)
这是面试题的灵魂所在。咱们用对比法来彻底搞懂。
1. 数据结构的差异
Render 阶段依赖的是 Fiber 树(内存数据结构)。
Fiber 树是一个链表结构。你可以把它想象成 Excel 表格的行号。
比如你要计算 1+2+3+4+5+6+7+8+9+10。
Render 阶段就是一行一行地加。
“1+2=3,暂停,喝口水。”
“好,继续,3+3=6,暂停,看个视频。”
“继续,6+4=10……”
你可以随时暂停,随时接着加。 因为结果一直在内存里,没变过。
Commit 阶段依赖的是 DOM 树(浏览器渲染树)。
DOM 树是真实的页面结构。
你要把 DOM 树从 A 变成 B。
你不能说:“把 div 移动到 body 下面,暂停,我去回个消息。”
因为当你回完消息回来,那个 div 已经在 body 下面了,你再想把它移回去,浏览器会报错或者表现得很奇怪。DOM 树一旦被修改,状态就变了,无法“回滚”到未修改前的状态。
2. 副作用(Side Effects)的时机
React 的设计哲学是:副作用必须在 Commit 阶段执行。
为什么?因为副作用是依赖真实 DOM 的。
比如 useEffect(() => { document.title = "Hello" }, [])。
如果你在 Render 阶段就改了 document.title,然后 Render 被中断了。这时候用户可能根本没看到页面,或者页面正在重新渲染中。你改了标题,用户却看不到,这很奇怪。
更重要的是,如果你在 Render 阶段执行了副作用,而 Render 被中断了,副作用执行了一半被中断了,或者副作用执行了但页面没变,这会导致状态不一致。
所以,React 把副作用严格限制在 Commit 阶段。只有当 DOM 确定要变的时候,副作用才执行。
3. 浏览器的渲染管线
浏览器的渲染管线是高度同步的。
- Layout (计算布局)
- Paint (绘制)
- Composite (合成)
React 的 Commit 阶段就是为了配合这个管线。
当 React 调用 appendChild 时,浏览器会触发 Layout 和 Paint。
如果你在 Paint 的时候中断了 React,浏览器会显示一个“未完成”的绘制状态(比如只画了一半的背景,文字还没出来)。这会导致严重的视觉闪烁和 Bug。
Render 阶段为什么能配合?
因为 Render 阶段不操作 DOM,它只操作内存。它不需要浏览器的渲染管线参与。它就像在后台运行的一个脚本,什么时候跑完全看 CPU 空不空。
第四部分:实战演练——一个中断的例子
假设我们有这样一个组件:
function ExpensiveComponent() {
console.log("开始计算 Render...");
// 模拟一个耗时操作
for(let i=0; i<10000000; i++) {
// 空循环
}
console.log("Render 计算完成");
return <div>我是结果</div>;
}
function App() {
return (
<div>
<ExpensiveComponent />
</div>
);
}
场景:用户在 Render 计算时点击了屏幕。
-
Render 开始:
React 进入ExpensiveComponent。
开始执行那个巨大的for循环。
CPU 满载,耗时 100ms。
关键点:在这 100ms 里,页面没有任何变化。DOM 还是旧的。console.log还没打印。
50ms 时,用户点击了鼠标。
React 看到点击事件优先级高。
中断!
React 停止那个for循环,停止构建 Fiber 树。
React 去处理点击事件。
用户看到页面还是原来的样子。 -
点击处理完毕:
React 回到 Render 阶段。
检查一下:“哎?我刚才算到哪了?”
查看内存中的workInProgress指针。
“哦,刚才算到这里。”
React 继续那个for循环。
100ms 后,for循环结束。
console.log("Render 计算完成")打印。
Render 阶段结束。 -
Commit 开始:
React 发现需要更新 DOM 了。
调用commitRoot。
插入<div>我是结果</div>。
注意:这个动作是同步的,浏览器会阻塞一下,然后页面更新。
如果 Commit 可以中断会怎样?
- React 开始 Render。
- 用户点击。
- Render 被中断(和上面一样,安全)。
- 用户点击处理完毕。
- Render 完成。
- Commit 开始。
- React 插入
<div>。 - 突然,用户又点击了!
- React 想要中断 Commit。
React 说:“等等,appendChild还没执行完,我先停一下。”
结果:DOM 树被破坏了。可能子元素还在,父元素还没挂上去。或者页面出现了一瞬间的白屏。
React 必须强制把 Commit 跑完,否则浏览器会崩溃或者页面错乱。
第五部分:源码中的调度器
为了支持 Render 阶段的中断,React 引入了一个叫做 Scheduler 的模块(在 scheduler 包里)。
Scheduler 的核心思想就是:让出控制权。
它利用了浏览器原生的 requestIdleCallback(如果支持)或者 setTimeout 来模拟空闲时间。
// 简化的调度器逻辑
function scheduleUpdateOnFiber(fiber) {
// 1. 将任务放入调度队列
// 2. 检查是否有更高优先级的任务(比如用户点击)
// 3. 如果有,优先执行高优先级任务
// 如果没有高优先级任务,我们就可以开始 Render 阶段了
if (!isWorking) {
isWorking = true;
// 调用 workLoop
workLoopConcurrent(); // 注意这里,是 Concurrent 模式
}
}
function workLoopConcurrent() {
while (workInProgress && !shouldYield()) {
// 如果 shouldYield() 返回 true,说明浏览器空闲时间到了
// React 立刻退出循环,把控制权交给浏览器
performUnitOfWork(workInProgress);
}
}
// 模拟 shouldYield
function shouldYield() {
const now = performance.now();
if (now - startTime > 50) { // 假设 50ms 是一个阈值
return true; // 时间到了,让出
}
return false;
}
在 Commit 阶段,React 不使用 Scheduler。
function commitRoot(root) {
// 这里没有 while 循环,没有 shouldYield
// 直接开始执行
// 执行所有 Mutation Effects
commitBeforeMutationEffects();
commitMutationEffects();
// 执行所有 Layout Effects
commitLayoutEffects();
// 完成
}
因为 Commit 阶段是同步的,如果中间被打断,后果不堪设想。
第六部分:副作用(Effect)的归属
很多同学会问:“那 useEffect 是在 Render 阶段还是 Commit 阶段?”
答案是:在 Commit 阶段。
React 在 Render 阶段计算完 Fiber 树后,会记录下哪些节点需要执行 Effect。
在 Commit 阶段,React 分为三个子阶段:
-
Before Mutation(提交前):
- 执行
useLayoutEffect(注意:这个是同步的,因为它在 Commit 阶段的最开始)。 - 这个阶段可以读取 DOM 的最新布局,并同步执行回调。因为还没画到屏幕上,用户看不到同步执行的过程。
- 执行
-
Mutation(提交中):
- 真正的 DOM 更新:
appendChild,removeChild,setAttribute。 - 这是不可中断的。
- 真正的 DOM 更新:
-
Layout(提交后):
- 执行
useEffect。 - 这个是异步的,因为此时 DOM 已经在屏幕上了,React 想让浏览器先渲染完这一帧,再去执行副作用。
- 执行
所以,useEffect 虽然叫 Effect,但它属于 Commit 阶段的一部分。
第七部分:为什么 React 要这么设计?(哲学思考)
如果不中断 Render,会怎样?
React 会变成同步渲染。
如果组件树很深,计算量很大,整个主线程会被阻塞。
用户会看到页面卡死,直到计算结束。这就好比你在 Excel 里按 F9 计算一个复杂的公式,电脑风扇狂转,鼠标动不了。
如果不中断 Commit,会怎样?
那就不可能实现并发模式。React 的核心特性就是“可中断渲染”。如果你不能中断 Render,你就无法实现 Suspense(在数据加载时显示 Loading,加载完后渲染内容),无法实现高优先级更新(比如输入框输入时,React 会暂停组件渲染,优先更新输入框)。
所以,Render 阶段是“计算阶段”,Commit 阶段是“执行阶段”。
计算可以重来,执行必须一次成功。
总结与代码复盘
好,咱们最后来一段综合代码,把这两个阶段串起来。这段代码模拟了 React 源码中从调度到提交的大致流程。
// ==========================================
// 1. 定义 Fiber 节点
// ==========================================
class Fiber {
constructor(tag) {
this.tag = tag; // 标记
this.effectTag = 0; // 标记需要做什么
this.return = null; // 父
this.child = null; // 子
this.sibling = null; // 兄弟
this.stateNode = null; // 真实 DOM 节点
}
}
// ==========================================
// 2. Render 阶段:可中断的数学计算
// ==========================================
let workInProgress = null;
let isRenderComplete = false;
// 模拟 beginWork:创建子节点
function beginWork(fiber) {
if (fiber.tag === 'HostComponent') {
// 假设创建了一个 div
const dom = document.createElement('div');
fiber.stateNode = dom;
// 创建子节点
const child = new Fiber('HostText');
child.return = fiber;
fiber.child = child;
return child;
}
return fiber.child;
}
// 模拟 completeWork:标记副作用
function completeWork(fiber) {
if (fiber.tag === 'HostText') {
// 标记这是一个新增节点
fiber.effectTag = Placement;
}
}
// 模拟 workLoop:可中断的循环
function renderRoot(root) {
workInProgress = root.current;
// 进入 Render 阶段
while (workInProgress && !isRenderComplete) {
const next = beginWork(workInProgress);
if (!next) {
completeWork(workInProgress);
workInProgress = workInProgress.return;
} else {
workInProgress = next;
}
// 模拟时间片:如果时间到了,就中断
if (Date.now() % 1000 < 10) {
console.log("Render 阶段:时间到了,暂停!把控制权交给浏览器。");
return; // 中断!
}
}
isRenderComplete = true;
}
// ==========================================
// 3. Commit 阶段:不可中断的 DOM 操作
// ==========================================
function commitRoot(root) {
console.log("Render 完成,进入 Commit 阶段!");
let nextEffect = root.current;
// Commit 阶段是同步的,没有时间片限制
while (nextEffect) {
if (nextEffect.effectTag & Placement) {
// 核心:真正的 DOM 操作
const parent = nextEffect.return.stateNode;
const child = nextEffect.stateNode;
parent.appendChild(child);
console.log("Commit 阶段:执行了 appendChild");
}
nextEffect = nextEffect.nextEffect; // 这里的 nextEffect 逻辑简化了
}
console.log("Commit 完成,页面更新完毕!");
}
// ==========================================
// 4. 模拟流程
// ==========================================
const rootFiber = new Fiber('HostRoot');
rootFiber.current = new Fiber('HostComponent');
console.log("--- 开始 ---");
renderRoot(rootFiber);
// 假设过了一会儿,浏览器有空了,React 继续
console.log("--- 恢复 Render ---");
renderRoot(rootFiber);
// Render 结束后,必须立即 Commit
if (isRenderComplete) {
commitRoot(rootFiber);
}
运行这段代码,你会看到:
- 第一次
renderRoot打印“Render 阶段:时间到了,暂停!”。 - 第二次
renderRoot继续,直到标记完所有副作用。 - 最后
commitRoot一次性把 DOM 插进去。
这就是 Render 与 Commit 的本质区别。Render 是那个在后台默默算账的程序员,随时可以停下来让你先去上厕所;Commit 是那个正在往墙上钉钉子的装修工,你敢让他停一下试试?
好了,今天的讲座就到这里。希望大家以后面试时,不仅能说出“Render 是可中断的,Commit 是不可中断的”,还能画出 Fiber 树,解释清楚 requestIdleCallback 的作用,甚至能手写一个简化版的调度器。
记住,React 的核心就是把“计算”和“执行”分开,利用浏览器的空闲时间做计算,保证用户体验的流畅。
下课!