React 源码面试:详细解释 render 阶段与 commit 阶段的本质区别,以及为什么前者可以中断而后者不能?

大家好,欢迎来到今天的“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 阶段的特性总结:

  1. 同步计算:虽然可以中断,但它是同步代码执行(没有 Promise 那种异步)。
  2. 纯计算:只读不写。
  3. 可中断:随时可以被高优先级的任务打断。

第二部分: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 阶段的特性总结:

  1. 同步执行:必须一口气跑完。
  2. DOM 操作:直接调用浏览器 API。
  3. 副作用:此时才会执行 useEffect, useLayoutEffect, 以及生命周期函数 componentDidMount, componentDidUpdate
  4. 不可中断:为了保持 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. 浏览器的渲染管线

浏览器的渲染管线是高度同步的。

  1. Layout (计算布局)
  2. Paint (绘制)
  3. 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 计算时点击了屏幕。

  1. Render 开始
    React 进入 ExpensiveComponent
    开始执行那个巨大的 for 循环。
    CPU 满载,耗时 100ms。
    关键点:在这 100ms 里,页面没有任何变化。DOM 还是旧的。console.log 还没打印。
    50ms 时,用户点击了鼠标。
    React 看到点击事件优先级高。
    中断!
    React 停止那个 for 循环,停止构建 Fiber 树。
    React 去处理点击事件。
    用户看到页面还是原来的样子。

  2. 点击处理完毕
    React 回到 Render 阶段。
    检查一下:“哎?我刚才算到哪了?”
    查看内存中的 workInProgress 指针。
    “哦,刚才算到这里。”
    React 继续那个 for 循环。
    100ms 后,for 循环结束。
    console.log("Render 计算完成") 打印。
    Render 阶段结束。

  3. Commit 开始
    React 发现需要更新 DOM 了。
    调用 commitRoot
    插入 <div>我是结果</div>
    注意:这个动作是同步的,浏览器会阻塞一下,然后页面更新。

如果 Commit 可以中断会怎样?

  1. React 开始 Render。
  2. 用户点击。
  3. Render 被中断(和上面一样,安全)。
  4. 用户点击处理完毕。
  5. Render 完成。
  6. Commit 开始。
  7. React 插入 <div>
  8. 突然,用户又点击了!
  9. 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 分为三个子阶段:

  1. Before Mutation(提交前)

    • 执行 useLayoutEffect(注意:这个是同步的,因为它在 Commit 阶段的最开始)。
    • 这个阶段可以读取 DOM 的最新布局,并同步执行回调。因为还没画到屏幕上,用户看不到同步执行的过程。
  2. Mutation(提交中)

    • 真正的 DOM 更新appendChild, removeChild, setAttribute
    • 这是不可中断的。
  3. 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);
}

运行这段代码,你会看到:

  1. 第一次 renderRoot 打印“Render 阶段:时间到了,暂停!”。
  2. 第二次 renderRoot 继续,直到标记完所有副作用。
  3. 最后 commitRoot 一次性把 DOM 插进去。

这就是 Render 与 Commit 的本质区别。Render 是那个在后台默默算账的程序员,随时可以停下来让你先去上厕所;Commit 是那个正在往墙上钉钉子的装修工,你敢让他停一下试试?

好了,今天的讲座就到这里。希望大家以后面试时,不仅能说出“Render 是可中断的,Commit 是不可中断的”,还能画出 Fiber 树,解释清楚 requestIdleCallback 的作用,甚至能手写一个简化版的调度器。

记住,React 的核心就是把“计算”和“执行”分开,利用浏览器的空闲时间做计算,保证用户体验的流畅。

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注