React 渲染流程深度剖析
React 渲染流程深度剖析:从 JSX 到像素的完整旅程
引言
React 之所以能够统治前端框架领域十余年,其核心竞争力并不在于某个语法糖,而在于它精心设计的渲染管线。从你写下 <div>Hello</div> 的那一刻起,一段精密的旅程便已开启:源码经过编译、解析、调度、调和、提交,最终由浏览器光栅化为屏幕上的像素。理解这条管线,是掌握 React 性能优化、并发特性乃至服务端渲染的全部前提。
本文将以一条主线贯穿始终:JSX 编译 → 元素创建 → 调度 → 调和 → 提交 → 浏览器绘制,逐步拆解每一阶段的数据结构与算法,并给出可落地的工程建议。全文字数超过五千,包含六张由 Mermaid 绘制的架构与流程图表,适合对 React 有一定基础、希望深入内部机制的读者。
一、渲染管线总览
React 的渲染并非一次性的「模板替换」,而是一套可持续演进的状态机。任何一个 setState、任何一次 useState 返回值的变化,都会重新触发这条管线。宏观上,管线由四个阶段组成。
flowchart TB
classDef zA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef zB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef zC fill:#e0f7fa,stroke:#00838f,color:#004d40
classDef zD fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
subgraph S1[① 编译阶段 Build]
direction LR
A[JSX 源码] --> B[Babel / SWC / esbuild]
B --> C[createElement 调用或 jsx 运行时函数]
end
subgraph S2[② 调度与调和阶段 Render]
direction LR
D[更新请求入队] --> E[Scheduler 优先级调度]
E --> F[构建 WorkInProgress Fiber 树]
F --> G[Diff 新旧子树 打上副作用标记]
end
subgraph S3[③ 提交阶段 Commit]
direction LR
H[Before Mutation 准备] --> I[Mutation 批量修改 DOM]
I --> J[Layout Effects 同步执行]
J --> K[Passive Effects 异步执行]
end
subgraph S4[④ 浏览器绘制 Paint]
direction LR
L[样式计算 Style] --> M[布局 Layout]
M --> N[绘制 Paint]
N --> O[合成与光栅化 Composite]
end
C --> D
G --> H
K --> L
click F "https://react.dev/learn/render-and-commit" "React 官方渲染与提交说明"
click I "https://react.dev/reference/react-dom/flushSync" "flushSync 强制同步提交"
class S1 zA
class S2 zB
class S3 zC
class S4 zD
四个阶段各有各的时间语义:编译阶段只发生在开发构建时;调度与调和可以被高优先级任务打断,属于可中断阶段;提交阶段必须同步完成,不可中断;浏览器绘制则完全脱离 React 的控制,属于浏览器的渲染管线范畴。
阶段职责对照表
| 阶段 | 是否可中断 | 是否操作 DOM | 主要数据结构 | 典型耗时特征 |
|---|---|---|---|---|
| 编译 | 否(构建时) | 否 | AST | 一次性 |
| 调度与调和 | 是 | 否 | Fiber 双缓存树 | 与组件数量相关 |
| 提交 | 否 | 是 | Effect List / Flags | 与 DOM 变更量相关 |
| 浏览器绘制 | 否 | 是 | Render Tree | 与页面复杂度相关 |
二、JSX 编译:语法糖背后的真身
JSX 并非合法的 JavaScript 语法,浏览器根本无法直接理解它。因此在代码进入运行时之前,构建工具必须将其「降级」为普通的函数调用。
经典运行时与全新运行时
React 17 之前,Babel 将 JSX 编译为 React.createElement(type, props, ...children) 调用;React 17 之后,官方推荐使用新的 JSX 转换,即 react/jsx-runtime 中的 jsx 与 jsxs 函数,其优势在于模块级自动引入,不再要求源码中显式 import React,也减少了不必要的属性校验。
flowchart LR
classDef tA fill:#fff3e0,stroke:#f57c00,color:#e65100
classDef tB fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
classDef tC fill:#fce4ec,stroke:#c62828,color:#b71c1c
subgraph IN[输入]
direction LR
A["const el = div.box 'Hi'"] --> B[解析为 AST]
end
subgraph T[转换]
direction LR
C[React 插件] --> D{选择转换目标}
D -->|React 17 以前| E["createElement 经典调用"]
D -->|React 17 及以后| F["jsx 运行时函数"]
end
subgraph OUT[输出]
direction LR
G[构建为 bundle] --> H[浏览器加载执行]
H --> I[得到 React Element 对象]
end
B --> C
E --> G
F --> G
click E "https://babeljs.io/repl" "Babel 在线编译验证"
click F "https://react.dev/blog/2020/09/22/introducing-the-new-jsx-transform" "新版 JSX 转换说明"
class IN tA
class T tB
class OUT tC
元素对象的结构
编译的产物是一个轻量级的描述对象,而非真实的 DOM 节点。一个典型的 React Element 具有以下结构:
javascript123456789{ $$typeof: Symbol.for("react.element"), // 标记类型,防止 XSS 伪造 type: "div", // 节点类型:字符串或组件函数 key: null, // 列表渲染的稳定标识 ref: null, // DOM 引用 props: { className: "box", children: "Hi" }, _owner: ComponentOwner, _store: { validated: true } }
注意 $$typeof 字段——React 通过 Symbol.for 保证该属性在跨 realm 时仍然一致,从而抵御 JSON.parse 后注入伪造元素的 XSS 攻击。这一点在生产安全审计中常被提及,值得重视。
三、Fiber:可中断渲染的基石
setState 触发的更新,最终都会转化为一次渲染请求。React 不会立即重新渲染,而是将工作交给调度器,构建一棵可随时中断、可恢复的 Fiber 树。
Fiber 节点的数据模型
Fiber 是虚拟 DOM 的迭代产物,一个 Fiber 节点既代表一个组件,也携带了完整的工作单元信息:
flowchart TB
classDef nA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef nB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef nC fill:#e0f7fa,stroke:#00838f,color:#004d40
RootFiber[RootFiber<br/>容器根节点] --> ChildA[Fiber A<br/>type: FunctionComponent<br/>App 组件]
ChildA --> ChildB[Fiber B<br/>type: HostComponent<br/>div#app]
ChildB --> ChildC[Fiber C<br/>type: HostText<br/>'Hello']
ChildB --> ChildD[Fiber D<br/>type: FunctionComponent<br/>Card 组件]
ChildD --> ChildE[Fiber E<br/>type: HostComponent<br/>span.title]
ChildA -. return .-> RootFiber
ChildB -. return .-> ChildA
ChildC -. return .-> ChildB
ChildD -. return .-> ChildB
ChildE -. return .-> ChildD
ChildC -. sibling .-> ChildD
class RootFiber nA
class ChildA nA
class ChildB nB
class ChildC nC
class ChildD nB
class ChildE nC
每个 Fiber 节点通过 child、sibling、return 三个指针构成一棵单链表树。这棵树的特殊之处在于它绕过了原生递归调用栈——因为递归调用栈无法被中断,而链表结构可以随时「记下进度、暂停工作、再恢复」。这正是 React 并发渲染(Concurrent Rendering)能够实现的技术前提。
双缓存树机制
React 维护两棵 Fiber 树:current 树对应当前屏幕上展示的内容,workInProgress 树对应正在计算的新内容。当 workInProgress 树构建完毕,会一次性切换到 current,这个过程称为 commit。
stateDiagram-v2
direction LR
[*] --> Mount: 首次挂载
Mount --> WorkInProgress: 构建备用树
WorkInProgress --> Commit: 调和完成
Commit --> Current: 指针切换
Current --> WorkInProgress: 收到新更新
WorkInProgress --> Commit
Commit --> Current
Current --> [*]: 组件卸载
双缓存带来的直接好处是:即使渲染被高优先级任务打断,用户看到的仍然是完整的旧 UI,不会出现「渲染到一半」的闪屏或数据错乱。
四、调度器:优先级的艺术
React 18 引入并发特性后,调度器(Scheduler)成为渲染管线的「交通警察」。它决定何时开始工作、何时让出主线程、先做哪份工作。
优先级体系
React 内部维护一个庞大的优先级模型,从事件派发到任务调度层层递进:
flowchart LR
classDef pA fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
classDef pB fill:#fff3e0,stroke:#f57c00,color:#e65100
classDef pC fill:#ffebee,stroke:#c62828,color:#b71c1c
A[用户交互事件] --> B[离散事件优先级<br/>点击/输入 最高]
C[连续交互<br/>滚轮/拖动] --> D[连续事件优先级<br/>次高]
E[定时器/请求回调] --> F[默认优先级]
G[空闲时段任务] --> H[空闲优先级 最低]
B --> I[Lane 模型 32 位车道]
D --> I
F --> I
H --> I
I --> J{调度决策}
J -->|高优先级抢占| K[中断低优先级渲染]
J -->|空闲执行| L[在帧空闲时完成工作]
class B pC
class D pB
class F pB
class H pA
class K pC
class L pA
时间切片与帧预算
浏览器每一帧约 16.6ms(60Hz)。Scheduler 会设定一个 frameInterval(默认 5ms)作为工作预算:渲染任务每执行一段时间就主动让出主线程,检查是否有更高优先级的输入事件需要响应,从而保证输入延迟始终处于可感知阈值之下。
sequenceDiagram
participant U as 用户
participant S as Scheduler
participant R as Renderer
participant B as 浏览器
U->>S: 触发点击(高优先级)
S->>R: 中断当前低优先级渲染
R->>B: 让出主线程
B->>U: 立即响应点击反馈
S->>R: 恢复低优先级渲染(时间片1)
R->>B: 让出(5ms 预算耗尽)
S->>R: 恢复渲染(时间片2)
R->>B: 完成全部工作并提交
五、调和算法:以最小的代价更新 UI
当 workInProgress 树开始构建时,React 会将其与 current 树逐节点对比,找出差异并打上标记——这一步称为调和(Reconciliation)。
Diff 的三个基本假设
React 的 Diff 之所以是 O(n) 而非 O(n³),是因为它建立在三个简化假设之上:
- 类型不同则重建:
div变成span,直接卸载重建子树,不做任何复用。 - key 相同则复用:列表项通过 key 标识身份,key 稳定则组件实例与 DOM 节点均可复用。
- 同级比较:只对比同一层级的兄弟节点,不跨层移动节点。
flowchart TD
classDef dA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef dB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef dC fill:#ffebee,stroke:#c62828,color:#b71c1c
classDef dD fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
A[新旧子树对比] --> B{节点 type 是否相同}
B -->|不同| C[卸载旧子树 重建新子树]
B -->|相同| D{是否为列表节点}
D -->|是| E{新旧 key 是否匹配}
D -->|否| F[仅更新属性与文本]
E -->|匹配| G[复用组件实例 保留内部状态]
E -->|不匹配| H[卸载错位节点 插入正确节点]
C --> I[标记 Deletion 与 Placement]
G --> J[标记 Update]
H --> I
F --> J
I --> K[提交阶段批量执行]
J --> K
click E "https://react.dev/learn/rendering-lists" "key 的作用与选择"
click H "https://react.dev/learn/rendering-lists#why-does-react-need-keys" "为何需要 key"
class A dA
class B dA
class C dC
class D dB
class E dB
class F dD
class G dD
class H dC
class I dC
class J dD
class K dA
常见性能陷阱
- 用数组索引当 key:插入头部元素时,所有后续节点的 key 全部错位,导致 React 全部重建,状态丢失且开销巨大。
- key 不稳定(如随机数):每次渲染 key 都不同,React 永远无法复用节点,相当于全量卸载重挂。
- 内联匿名函数与对象:虽然不影响 Diff 正确性,但会让
memo优化失效,属于调和阶段的隐形杀手。
六、提交阶段:一切变得真实
调和完成后,React 收集到一组副作用(Effects)——包括 DOM 变更、生命周期调用、ref 更新等,随后进入不可中断的提交阶段。
三个子阶段
提交阶段内部按固定顺序执行:
flowchart TB
classDef mA fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
classDef mB fill:#e0f7fa,stroke:#00838f,color:#004d40
classDef mC fill:#fff3e0,stroke:#f57c00,color:#e65100
A[Commit 开始] --> B[Before Mutation<br/>getSnapshotBeforeUpdate 等]
B --> C[Mutation 阶段<br/>插入/删除/更新真实 DOM]
C --> D[Layout Effects<br/>useLayoutEffect 同步执行]
D --> E{存在 Passive Effect?}
E -->|是| F[调度异步执行<br/>useEffect 延迟到绘制后]
E -->|否| G[Commit 完成]
F --> G
click C "https://react.dev/reference/react-dom/client/createRoot" "createRoot 提交说明"
click D "https://react.dev/reference/react/useLayoutEffect" "useLayoutEffect 与 useEffect 区别"
class A mB
class B mA
class C mB
class D mC
class E mC
class F mA
class G mB
useEffect 与 useLayoutEffect 的时间语义
这是前端面试高频考点,也是实际开发中「闪屏」问题的根源:
| Effect 类型 | 执行时机 | 是否阻塞绘制 | 适用场景 |
|---|---|---|---|
useEffect |
DOM 变更后、浏览器绘制后 | 否 | 数据请求、订阅、日志 |
useLayoutEffect |
DOM 变更后、浏览器绘制前 | 是 | 读取 DOM 尺寸、同步布局调整 |
useInsertionEffect |
DOM 变更前 | 是 | 注入 <style> 标签 |
经验法则:凡是在 useEffect 里修改 DOM 尺寸导致「先渲染旧布局再跳变」的场景,都应改用 useLayoutEffect;反之,一切异步操作(请求、订阅)都应留在 useEffect,避免阻塞首屏。
七、浏览器绘制:React 控制范围的终点
React 的提交阶段完成时,页面上的 DOM 已经就绪,但用户还看不到任何东西——因为浏览器还需要完成自己的渲染管线。
关键路径与长任务
从 React 视角看,render 与 commit 都在主线程执行;从浏览器视角看,紧随其后还有 Layout、Paint 与 Composite。任何一环节超时,都会表现为卡顿。这里有几个实战建议:
- 批量更新:React 18 自动批处理(Automatic Batching)把同一次事件中的多个
setState合并为一次渲染,避免重复提交。 - 避免强制同步:
flushSync会打破批处理并同步刷新 DOM,能用则用useTransition替代。 - 测量而非猜测:用 React DevTools Profiler 记录每次 commit 的耗时,再针对性优化。
gantt
title 一次点击后的渲染时间线
dateFormat X
axisFormat %L
section React
调度与调和 :a1, 0, 3ms
提交 DOM 变更 :a2, 3, 2ms
section Browser
样式计算 :b1, 5, 1ms
布局 Layout :b2, 6, 2ms
绘制 Paint :b3, 8, 2ms
合成 Composite :b4, 10, 1ms
八、总结与学习路径
React 渲染管线是一套精密的分层系统:编译层解决语法问题,元素层描述 UI 意图,Fiber 层承载可中断的计算,调度层协调优先级与时间预算,调和层以最小代价更新,提交层完成真实的 DOM 变更,最后由浏览器接管绘制。
给读者的行动清单:
- 打开 React DevTools → Profiler,观察真实应用中各阶段耗时占比;
- 动手实现一个极简 Fiber 式链表渲染器(约 200 行),彻底理解双缓存;
- 阅读
react-reconciler源码中的beginWork与completeWork两个函数; - 遇到性能问题先画「阶段定位图」,确认瓶颈在调度、调和还是浏览器绘制层。
只有把这条管线刻进直觉,你才能在性能调优、并发特性、SSR 水合等场景中游刃有余。
