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 中的 jsxjsxs 函数,其优势在于模块级自动引入,不再要求源码中显式 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 具有以下结构:

注意 $$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 节点通过 childsiblingreturn 三个指针构成一棵单链表树。这棵树的特殊之处在于它绕过了原生递归调用栈——因为递归调用栈无法被中断,而链表结构可以随时「记下进度、暂停工作、再恢复」。这正是 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³),是因为它建立在三个简化假设之上:

  1. 类型不同则重建div 变成 span,直接卸载重建子树,不做任何复用。
  2. key 相同则复用:列表项通过 key 标识身份,key 稳定则组件实例与 DOM 节点均可复用。
  3. 同级比较:只对比同一层级的兄弟节点,不跨层移动节点。
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 视角看,rendercommit 都在主线程执行;从浏览器视角看,紧随其后还有 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 变更,最后由浏览器接管绘制。

给读者的行动清单:

  1. 打开 React DevTools → Profiler,观察真实应用中各阶段耗时占比;
  2. 动手实现一个极简 Fiber 式链表渲染器(约 200 行),彻底理解双缓存;
  3. 阅读 react-reconciler 源码中的 beginWorkcompleteWork 两个函数;
  4. 遇到性能问题先画「阶段定位图」,确认瓶颈在调度、调和还是浏览器绘制层。

只有把这条管线刻进直觉,你才能在性能调优、并发特性、SSR 水合等场景中游刃有余。