资讯动态

React渲染全流程:从JSX到Fiber再到真实DOM的完整链路

发布时间:2026/10/14 19:17:57 来源:尧图企业网站定制
如果你已经能熟练写出各种 React 组件也背过“Fiber”“协调”“虚拟 DOM”这些概念但被问到“点击一次按钮React 从 JSX 到真实 DOM 到底经历了什么”还是会卡壳那这篇文章就是为你准备的。我自己也经历过这个阶段JSX 当模板语言看Fiber 当面试题背直到把这条完整链路亲手走了一遍才真正读懂 setState 之后的每一行日志。这篇文章不做概括式的概念罗列而是跟着一次真实的组件更新完整拆解从 JSX 编译为 ReactElement、再由 ReactElement 驱动 Fiber 树构建、协调、提交的每一步。内容会比较长适合那些产品代码写得熟、又不想一直停留在“会用”阶段的前端开发者。我会先给出全貌再逐个阶段深入最后用两个小实验验证这套理解。读完你至少能说清楚JSX 编译产物是什么、ReactElement 和 Fiber 有什么区别、render 阶段和 commit 阶段分别做了什么、双缓存切换发生在哪个节点。1. 先把全貌摆出来一次渲染要经过三个“世界”1.1 三个世界JSX、ReactElement、Fiber理解 React 渲染流程最关键的一步是分清三个性质完全不同的结构。第一个是 JSX。它长得很像模板但本质是 JavaScript 的语法糖浏览器根本不认识它需要 Babel、TypeScript 或 SWC 这类编译器把它转成普通函数调用。它是“给人看”的代码形态。第二个是 ReactElement。它是 JSX 编译后的产物是一个普通的 JavaScript 对象里面记录了“这一次 UI 应该长什么样”相当于一张设计图纸。它不可变、轻量、可以被随意创建和丢弃。第三个是 Fiber。它是 React 在内存中维护的一棵工作树每个组件、每个 DOM 节点都对应一个 Fiber 节点。Fiber 不是图纸而是施工现场的“工单”它知道这个组件当前处于什么状态、是否需要更新、更新后要操作哪个真实 DOM 节点、优先级是多少。React 真正干活的载体是 Fiber不是 ReactElement。我之前见过不少同学把“虚拟 DOM”和 Fiber 混为一谈其实更准确的说法是ReactElement 是虚拟 DOM 的描述而 Fiber 是承载渲染副作用、调度优先级和组件状态的内部结构。两者的生命周期完全不同ReactElement 每次 render 都会重新生成Fiber 则尽可能被复用。1.2 一个贯穿全文的最小案例后面所有阶段都会围绕下面这个组件展开方便在脑子里跟踪数据流。它足够简单但覆盖了函数组件、状态更新、原生 DOM 子节点和事件处理import { useState } from react; import { createRoot } from react-dom/client; function Counter() { const [count, setCount] useState(0); return ( div classNameapp h1Counter/h1 p{count}/p button onClick{() setCount((c) c 1)} count is {count} /button /div ); } const root createRoot(document.getElementById(root)); root.render(Counter /);后面的每一节我都会回到这个 Counter 上去看它在不同阶段变成了什么。你可以把这份代码直接复制到本地跟着控制台日志一起看。2. JSX 不是模板Babel 把它变成了什么2.1 从 JSX 到函数调用先看最容易忽略、但其实最直观的一步。Babel 编译下面这行 JSXconst element div classNameapphello/div;在 React 17 之前编译结果是const element React.createElement( div, { className: app }, hello );React 17 之后JSX 转换逻辑换成了新的“automatic runtime”编译结果变成import { jsx } from react/jsx-runtime; const element jsx(div, { className: app, children: hello });注意这里不再需要手动import React编译器会自动帮你引入jsx函数。这是很多老项目升到 React 17 时的一个明显变化代码里可以只写 hooks 相关 import但 JSX 照常能用。本质上JSX 就是createElement或jsx函数的语法糖。这也是为什么 JSX 里可以写任意 JavaScript 表达式因为它本身就是 JavaScript 函数调用。模板语言做不到这件事模板有自己的语法边界而 JSX 没有——{count}、{arr.map(...)}、三元表达式全都会被当作普通参数传给函数。2.2 ReactElement 对象到底长什么样jsx函数执行完返回的是一个普通对象也就是我们说的 ReactElement。结构大致如下const element { $$typeof: Symbol.for(react.element), type: div, key: null, ref: null, props: { className: app, children: hello, }, };几个字段逐个说$$typeof内部类型标记React 用它判断对象是否是合法的 ReactElement。用 Symbol 而不是字符串是为了防止服务端渲染时通过 JSON 伪造 ReactElement 发起攻击。type描述这个元素是什么类型。字符串表示原生 DOM函数或类表示组件还有一个特殊的Symbol.for(react.fragment)表示 Fragment。在 Counter 案例里div classNameapp的 type 是字符串div而Counter /的 type 是 Counter 函数本身。key给元素的唯一标识主要用于列表协调。它不会进 props。ref引用对象同样不会进 props会被单独拿出来处理。props除了 key 和 ref 以外的全部属性包括 children。从 JSX 到 ReactElement整个过程没有涉及任何真实 DOM。它只回答了“这一帧我想要什么”不回答“怎么做”。2.3 组件函数返回的不是组件树另一个常见误解是“组件函数返回的是一棵组件树”。严格说组件函数返回的是一棵 ReactElement 树。看这个例子function Counter() { return ( div classNameapp h1Counter/h1 /div ); }Counter 函数体内 return 的部分编译后是jsx(div, { className: app, children: jsx(h1, { children: Counter }) });返回值为一个嵌套的 ReactElement 对象。React 在后续渲染阶段拿到这个返回值读取$$typeof、type、props才知道下一步要创建什么样的 Fiber。可以把 ReactElement 理解为“每一帧的快照”同一个组件在同一帧里只会产生一棵 element 树但 Fiber 树会跨帧保存状态。3. 为什么非要有 Fiber从同步递归到可中断的工作单元3.1 老的递归式渲染卡在哪React 早期版本的协调过程本质上是一次同步递归。我们知道一棵 element 树之后从根组件开始递归遍历所有子组件依次返回 element再继续往下直到全部处理完。问题在于一旦开始整个调用栈就不能被打断。假如组件层级很深、渲染计算量很大主线程会被一直占用。期间用户点击、输入、滚动浏览器都无法响应页面就会卡顿。我把这套旧机制理解成“一口气在抄一篇长文”抄完之前手不能停电话响了也不能接。React 15 时代的卡顿问题根源基本都在这。3.2 Fiber 的三个关键设计Fiber 架构要解决的核心问题就是能不能把渲染拆成很多小块每一块都足够小做一点就停下来看看主线程要不要去处理用户输入要的话就暂停等空闲了再继续Fiber 的实现靠三个设计。第一用链表替代调用栈。每个 Fiber 节点有return指向父节点、child指向第一个子节点、sibling指向下一个兄弟节点三个指针。React 遍历这棵树时不需要依赖 JavaScript 调用栈而是自己维护遍历位置因此随时可以“记住做到哪了”。这就像看书时不再靠自然页数而是用一个书签想停就停回来接着看。第二把每个 Fiber 当成一个工作单元。每次处理完一个节点React 都会检查时间片是否用完用完了就立刻让出控制权。渲染任务不再是一次不可分割的大动作而是很多可以穿插的小步骤。第三双缓存。React 在内存里同时维护两棵 Fiber 树一棵对应当前已经显示在屏幕上的 UI叫 current 树另一棵是正在构建的工作树叫 workInProgress 树。两棵树的对应节点通过alternate互相引用。所有更新都发生在 workInProgress 树上构建完成后一次性切换current 变成新的 workInProgress。这样用户看到的 UI 始终是完整的不会出现渲染到一半的中间状态。3.3 调度与优先级谁该先做有了可中断的遍历还得有调度规则否则没人知道什么时候该让出、什么时候继续、哪个任务更紧急。React 内部有一套基于 Lane 的优先级模型用位掩码表示不同优先级的更新。事件触发、定时器、底层渲染等场景会被分配不同的 laneReact 的 Scheduler 会依据时间片和优先级决定先做哪个任务、能否打断当前任务。对我们日常写业务代码的人来说不需要记住每种 lane 的编号但理解“React 会打断不重要的工作去优先响应紧急交互”这件事很有用。这也是 React 18 并发特性的基础渲染过程可以被中断、被抢占但最终 commit 永远是一致的。4. 解剖一个 Fiber 节点每个字段都在解决什么问题4.1 用 Counter 对应一个仿真 Fiber先给一个基于我们 Counter 案例的简化 Fiber 对象看看它都存了什么const counterFiber { tag: FunctionComponent, // 节点类型函数组件 type: Counter, // 对应组件函数本身 key: null, stateNode: null, // 函数组件没有真实实例 return: hostRootFiber, // 父节点 child: divFiber, // 第一个子节点 sibling: null, // 没有兄弟节点 pendingProps: {}, // 即将生效的 props memoizedProps: null, // 上一次渲染的 props memoizedState: { // hooks 链表头第一个 hook 是 useState value: 0, queue: [], next: null }, updateQueue: null, flags: NoFlags, // 自身副作用标记 subtreeFlags: NoFlags, // 子树副作用标记 lanes: 0, childLanes: 0, alternate: null // 双缓存对应节点 };4.2 字段分组理解会轻松很多不要死记字段按职责分组就好。第一组是“怎么连接”return、child、sibling。这是 Fiber 树形成链表结构的基础。React 遍历到这棵树时不靠递归栈靠这三个指针知道下一步该去哪。第二组是“当前状态”pendingProps、memoizedProps、memoizedState、updateQueue。pendingProps是本次渲染传入的新 propsmemoizedProps是上次渲染实际使用的 props两者对比可以判断 props 有没有变化。memoizedState对函数组件来说保存的是 hooks 链表对类组件则是实例状态。updateQueue保存待处理的计算动作例如 useState 的 dispatch 会往队列里放 update。第三组是“副作用标记”flags、subtreeFlags。节点需要执行 DOM 插入、属性更新、删除时React 会给flags打标记例如Placement、Update、Deletion而subtreeFlags表示子树里有没有待执行的工作。React 18 之后commit 阶段遍历这棵树时主要靠subtreeFlags快速判断“要不要进入子树”类似索引的作用替代了早期版本里的单向 effectList。第四组是“优先级”lanes、childLanes。它们决定这个节点的更新是否以及何时可以被执行。我们写业务代码不会直接操作它们但它们在调度器决定是否让出主线程时起着关键作用。第五组是“双缓存对应”alternate。一个 Fiber 的 alternate 指向另一个树里对应的 Fiber。current 树和 workInProgress 树的节点通过它互相联系方便复用和逐步替换。4.3 从 Fiber 再看 Hooks 规则知道memoizedState是 hooks 链表之后很多 hooks 规则就变得自然了。还是 Counter 组件第一次渲染时useState(0)会在memoizedState上创建一个 hook 节点里面存了value: 0、更新队列和 dispatch。下次组件运行时随着 setCount 触发的更新React 再调用 Counter 函数它执行到useState时不是重新初始化为 0而是从自己的 Fiber 链表中取出上一次那个 hook读取当时的值和队列计算出新状态 0 - 1再渲染。这也解释了为什么 hooks 禁止写在条件判断里。React 每次 render 都依赖固定的调用顺序去链表中取 hook如果你第二次调用时少调用了一个 hook后面的 hook 全都会匹配错位取到别人的状态。我早期调试过一个奇怪 bug一个组件在某个分支下提前 return 了看起来只是少渲染了一部分结果相关 state 全部乱掉最后定位到就是 hooks 顺序被条件分支打断。5. render 阶段beginWork 往下钻completeWork 往上收5.1 从根开始的深度优先遍历应用启动时createRoot(container)会创建一个 FiberRootNode 和一个 HostRootFiber。注意两个概念FiberRootNode 是根容器相关的管理对象HostRootFiber 是 Fiber 树的根节点两者通过current互相引用。调用root.render(Counter /)后一个更新任务被创建并排入 HostRootFiber 的 updateQueue然后调度开始进入工作循环。工作循环遍历 Fiber 树的方式非常固定就是深度优先。对每个节点先执行 beginWork这叫“进入”节点当所有子节点处理完再执行 completeWork这叫“离开”节点。Counter 案例首次渲染时遍历顺序大致是这样的beginWork(Counter) beginWork(div.app) beginWork(h1) beginWork(text Counter) completeWork(text Counter) completeWork(h1) beginWork(p) beginWork(text 0) completeWork(text 0) completeWork(p) beginWork(button) beginWork(text count is 0) completeWork(text count is 0) completeWork(button) completeWork(div.app) completeWork(Counter)注意顺序div 的三个子节点不是一次性进入的而是走完 h1 的完整子树再走 p再走 button。这就是为什么 Fiber 用 child sibling 而不是普通数组React 要在这个遍历过程中保留“下一个兄弟节点在哪”的信息。5.2 beginWork分派、执行、调和beginWork 的逻辑大体分三步。第一步根据节点类型分派不同的处理函数。函数组件执行组件函数类组件实例化并调用 render原生 div 走宿主组件分支文本节点走文本分支。第二步执行组件函数拿到新的 ReactElement。对 Counter 来说beginWork 时 React 会调用Counter()得到 div.app 开头的 element 树。这里会重新执行组件函数体包括 useState 的计算逻辑所以组件函数内部不能有副作用因为你不知道 render 阶段会执行几次。第三步调和子节点。React 拿刚返回的 element 树和 current 树上对应节点的子 Fiber 进行对比这个对比过程叫 reconcileChildren。如果 Counter 第一次挂载current 树没有对应节点就直接创建新的子 Fiber如果是更新则进入 diff 逻辑尽可能复用旧 Fiber并在需要时给新 Fiber 打上Placement需要插入、Update需要更新属性、Deletion需要删除等 flags。diff 的大原则是“同层比较”React 不会跨层级去比较它默认 UI 结构不会从一个层级跳到另一个层级所以只比较同一层的 child/sibling。比较时优先看 key 是否一致、type 是否一致。如果 key 和 type 都没变就复用旧 Fiber只是更新 props如果 type 变了或 key 对不上直接视为新的节点旧节点标记删除。这也是为什么列表渲染时 key 一定要稳定、唯一。我见过很多次用数组下标做 key 导致的状态错乱本质上就是 diff 复用到了错误的 Fiber。5.3 completeWork创建并组装真实 DOMbeginWork 阶段只负责“算”真正创建 DOM 是在 completeWork。当遍历到原生组件比如 divbeginWork 不会立刻创建 DOM因为此时还不知道子节点的最终结构。等到所有子节点处理完进入 completeWork 时React 才会创建这个 div 的真实 DOM 实例、设置 className 等属性同时把已经创建好的子 DOM 节点按顺序组装进去。也就是说DOM 的创建顺序是自底向上的。最先被创建出来的是最深层的文本节点然后是包含它们的父级。整个过程先“从根往下走”确认结构再“从叶子往上收”拼装 DOM最后形成一棵完整的 DOM 子树但这棵子树还没有挂到页面上真正插入浏览器的动作要留到 commit 阶段。对函数组件和类组件来说completeWork 通常不会创建 DOM因为它们本身没有真实节点只是为下面的原生节点做承载。对文本节点来说completeWork 会创建文本节点并写入文本值比如“count is 0”。5.4 flags 是怎么被收集上来的还有一个小细节值得单独说为什么 commit 阶段能高效找到“哪些节点需要处理”。因为每个节点在处理完自己的 completeWork 后会把自己的 flags 聚合到父节点的 subtreeFlags 上。比如 button 节点有 PlacementcompleteWork(button) 会把 Placement 标记传递到父节点 div 的 subtreeFlags。一路向上最终 HostRoot 的 subtreeFlags 就包含了所有待处理信息。commit 阶段从根开始往下遍历先看 subtreeFlags 决定是否要进入某个子树再根据 flags 决定处理哪些具体节点。这样提交时不需要重新计算整棵树效率高很多。6. commit 阶段分三步把 workInProgress 切换成 currentrender 阶段结束后React 得到一棵完整的、打好了 flags 的新 Fiber 树进而拿到对应的 DOM 子树。commit 阶段把它真正同步到浏览器。这个过程在源码里被分成三个子阶段顺序非常重要。6.1 before mutation提交前的读操作这一阶段主要处理类组件的getSnapshotBeforeUpdate。React 会在这个阶段给真正要修改 DOM 之前的最后一次机会让你读取旧的 DOM 信息。比如获取滚动位置、计算某个元素在更新前的高度然后把快照值返回供componentDidUpdate使用。它也可以处理某些删除相关的副作用比如 clear 某些宿主操作。这个阶段非常适合做“读”操作因为此时 DOM 还没变所有旧状态都还在。如果在这个阶段读了新 DOM 的值拿到的还是旧值。6.2 mutation真正动手改 DOMmutation 阶段是 React 操作 DOM 的地方。它会遍历所有待处理的 Fiber 节点根据 flags 逐一执行插入、删除、更新属性。我们的 Counter 案例在点击按钮触发更新时mutation 阶段要做的事包括更新 p 的文本内容为“1”、更新 button 的文本内容、更新与 onClick 相关的属性等。如果是组件首次挂载这里会把已经组装好的一大棵 DOM 子树插入到容器节点中。这个阶段执行完DOM 已经变成新的状态。要注意的是在 mutation 阶段读取布局信息是不安全的因为浏览器可能还没完成样式计算而且 DOM 正处于变更中间。React 也不会在 mutation 阶段执行 useEffect。6.3 layout 阶段与 passive 副作用mutation 完成后React 会执行useLayoutEffect的创建函数以及类组件的componentDidMount、componentDidUpdate。这个阶段叫 layout 阶段因为此时 DOM 已经变更完成React 会同步地执行你在 layout effect 里写的代码方便你读取新布局、测量 DOM 尺寸、执行与 UI 状态相关的副作用。注意useLayoutEffect是同步阻塞的如果里面做了大计算会卡住主线程。相比之下useEffect是异步的它会在这个阶段被“排队”等到浏览器完成绘制之后才执行。两者从“给用户感觉”上看有明显差异阶段执行时机是否阻塞绘制常见用途useLayoutEffectDOM 更新后、浏览器绘制前是测量布局、同步调整样式useEffect浏览器绘制后异步执行否数据请求、日志上报、外部系统同步所以如果你有一段代码要在 DOM 更新后立刻读取尺寸用useLayoutEffect如果只是发个请求用useEffect足够。这个选择不是随手写而是基于 commit 阶段时序的合理推断。6.4 双缓存切换到底发生在哪很多人以为双缓存切换在 render 完成后就切换了其实不准确。正确顺序是mutation 阶段完成 DOM 操作后在 layout 阶段开始前React 会把root.current指向刚完成的 workInProgress 树旧的 current 树变为备用状态。这个动作保证了 layout effect 中读取到的已经是新树。对应到我们的 Counter点击按钮后工作树在内存中完成了 p 和 button 的文本更新mutation 阶段提交到 DOM随后 current 切换到新树layout effect 里的count已经是新值。整个过程中用户永远不会看到渲染到一半的 UI。7. 用两个小实验验证这套理解7.1 实验一render 执行次数和 commit 次数不一定相等前面反复说 render 阶段可以多次执行很多人会存疑组件函数不是每次更新只执行一次吗我们把组件改一下在函数体内打日志function Counter() { const [count, setCount] useState(0); console.log(render Counter:, count); useEffect(() { console.log(passive effect:, count); }); return button onClick{() setCount((c) c 1)}{count}/button; }如果你在开发模式下使用 StrictMode第一次挂载时控制台会看到两次render Counter: 0但最终只执行一次真实挂载。这是因为 StrictMode 会在开发环境故意“双调用”渲染相关代码用来暴露不纯函数这不是 bug。更关键的是即使用 StrictModeuseEffect打印的次数与最终 commit 是对应的不会因为 render 跑了两遍而提交两次 DOM。这背后的原因就是 commit 阶段只针对一棵最终的 workInProgress 树执行中间多次 render 产生的临时 element 树和中间状态都会被丢弃。理解这一点对调试性能问题很有用看到组件函数执行多次不要第一时间以为“内存泄漏”或“事件重复绑定”先判断是 dev 下的 StrictMode 双调用、并发导致的中断重建还是真的 props 变化引起了重复渲染。7.2 实验二layout effect 和 passive effect 的打印顺序再把 useEffect 换成并列的两个function Counter() { const [count, setCount] useState(0); useLayoutEffect(() { console.log(layout:, count); }); useEffect(() { console.log(passive:, count); }); return button onClick{() setCount((c) c 1)}{count}/button; }点击按钮后控制台输出顺序一定是render Counter: 1 layout: 1 passive: 1这个顺序直接验证了 commit 阶段的三个阶段划分render 阶段先执行layout effect 在 commit 的 layout 子阶段同步执行passive effect 在浏览器绘制完成后异步执行。如果你在 passive effect 里读取 DOM它已经能读到更新后的值但它的执行时机已经不影响绘制了。7.3 这些理解怎么帮到日常排错我个人的体会是把渲染流程拆清之后很多以前“玄学”的问题都能变成可推理的问题。比如列表 key 错乱大多数人只知道“index 当 key 不好”但不知道为什么。有了 Fiber diff 的概念你会明白 key 是复用 Fiber 的依据用 index 会导致新旧列表对应关系错位本来不该复用的节点被复用了状态就被带过去了。比如展示useEffect和useLayoutEffect的选择很多人纠结“看起来它们都能做 DOM 操作”。但知道 commit 三阶段后你会理解 layout effect 会在浏览器绘制前同步执行适合测量布局passive effect 在绘制后执行适合处理不阻塞显示的操作。选择突然变得很清晰。再比如调试一个渲染特别慢的列表时你可以打开 React DevTools 的 Profiler 录制一次交互看火焰图里哪些组件真的在 commit 阶段参与了更新。如果发现大量节点的 render 被重复执行就能沿着 props 比较逻辑去找是哪一层没有 memo 化或者某个父组件每次更新都给了新的函数引用。这时候你不再只是看表面现象而是能准确说出“这里的多余的 render 是因为父组件返回了新的 element 树导致 Fiber 的 props 对比失败Fiber 没办法 bailout只能继续 beginWork。”我自己的习惯是遇到奇怪问题先在笔记里画一条“从事件到 UI 的时间线”事件触发、调度入队、render 执行、diff、DOM mutation、layout effect、passive effect然后把日志对应到时间线上。绝大多数前端疑难问题最后都能落在这条链路的某一段。能准确知道是哪一段出了问题定位和修起来都会快很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑