基本概念

设计目标

React 是一个专注于 视图层 的库,而非一个完整的框架,它的设计目标是为了解决大型应用中 UI 状态难以同步、手动操作 DOM 既繁琐又易错的问题。

库和框架的核心区别在于控制反转:你调用库,而框架调用你。React(包括 react 和 react-dom) 是一个 UI 库,Angular 是标准的框架,而 Vue 属于 渐进式框架。

在原生 JavaScript 中,数据与 DOM 相互独立,当数据发生变化时,开发者需要手动查找并修改 DOM 节点,完成视图同步。React 采用了 数据驱动视图 的设计理念:开发者只需描述数据对应的 UI 形态,无需编写 DOM 操作指令,React 内部会 自动维护数据和视图的同步关系,由 react-dom 完成 DOM 更新。

必须是 React 托管的状态,并且通过 React 提供的方式触发变更才会自动更新视图。

核心思想

React 的核心思想有:

  1. 声明式渲染:开发者只需要 描述 给定状态下的 UI,无需编写修改 DOM 的指令(命令式渲染);
  2. 组件化:将 UI 拆分为 独立、可复用的组件,组件可以相互嵌套或组合成复杂的界面;
  3. 单向数据流:子组件 不能直接修改父组件的数据,必须通过父组件传递的更新函数修改;
  4. UI 是状态的纯函数:相同的输入(props 和 state)必须产生相同的输出(JSX),渲染结果是可预测的。

组成部分

浏览器环境下的 React 由 react 和 react-dom 组成:

react 是 React 核心库,与宿主环境无关,它提供的是与渲染目标无关的基础能力:

  • 组件定义:React.Component、React.PureComponent 以及函数组件的定义;
  • 元素创建:React.createElement 创建虚拟 DOM 对象(即 ReactElement);
  • 状态管理:提供内置 Hooks 和其他 API,管理组件状态和副作用。

react-dom 是浏览器环境的渲染器,负责把 react 计算出的更新应用到真实 DOM 中:

  • 渲染入口:通过 ReactDOM.createRoot().render() 将 ReactElement 渲染到真实 DOM 中;
  • 事件系统:实现 合成事件机制,通过根节点上的事件委托统一处理浏览器事件。

合成事件机制

合成事件(SyntheticEvent)是 React 对浏览器原生事件的 跨浏览器包装对象,它统一了不同浏览器中事件对象的差异,无需再写兼容代码。合成事件机制通过 事件委托,减少了内存开销和事件绑定解绑的成本。

React 不会把 JSX 中的 onClick={handler} 直接绑定到对应的 DOM 节点上,而是在根容器(即 ReactDOM.createRoot() 传入的 DOM 节点)上为每一种需要支持的原生事件类型都 注册一个统一的监听器(addEventListener),当某个元素触发了原生事件并冒泡到根容器时,根容器上的监听器就会被触发,React 可以根据原生事件的 target 属性在内部的 Fiber 树中找到对应的节点,再收集沿途所有相关的 onClick 回调并 模拟一套捕获和冒泡流程,按顺序调用这些回调,回调收到的就是 合成事件对象。

冒泡阶段 触发的事件是 onClick,捕获阶段 触发的事件是 onClickCapture,处理函数收到的参数 e 就是 SyntheticEvent 对象。如果需要访问底层原生事件,可以通过 e.nativeEvent 获取。

运行机制

描述 UI

在 React 中,开发者只需要描述 给定状态下 的 UI 长什么样子,真实的 DOM 更新由 React 完成。

语法形式

React 描述 UI 的语法是 JSX,它是 JavaScript 的语法扩展,后缀名为 .jsx 或 .tsx,编译后会 递归生成多层 React.createElement() 的调用。

核心规则

必须返回单个根元素:JSX 本质是函数调用的表达式,一个表达式只能有一个返回值,但可以用空标签 <>...</> 包裹多个元素,相当于 React.createElement(React.Fragment, null, [child1, child2])。

Fragment 是 React 提供的一个内置组件,用于将多个 JSX 元素组合在一起,但不会在真实的 DOM 中产生额外的包装节点,完整写法是 <Fragment> 或 <React.Fragment>,简写为 <>...</>。

标签必须显式闭合:JSX 是基于 XML 语法设计的,XML 强制要求标签必须闭合,但 JSX 并不绑定 XML 或 HTML,它只是被编译为 createElement() 的调用,具体如何渲染与 JSX 无关。

属性用小驼峰命名:JSX 的属性最终会变为 JS 对象的键,因此不能包含 -,而 class 和 for 是 JS 保留字,不能直接用作属性名,需要改写为 className 和 htmlFor。

用大括号嵌入 JS:JSX 本身是语法糖,默认是 静态值,遇到 {} 时,编译器会直接对 JS 表达式求值,大括号里面只能写 表达式(一定会求值),不能写 语句(没有返回值)。

JSX 和 React 是相互独立的,可以用 .js 直接编写 React 应用,例如:

1
2
3
4
5
const element = React.createElement(
"h1",
{ className: "greeting" },
"Hello, world!",
);

相比之下 JSX 写法简洁得多,它也是 React 推荐的默认写法。

基本单元

组件 是描述 UI 的基本单元,它是一个返回 JSX 的函数,通过 props 接收外部数据,通过 state 记住内部状态。组件名称必须 大写开头,以此与 HTML 标签区分。

JSX 语法在编译时,会根据标签的 首字母大小写 来决定如何处理该标签:首字母小写会被编译为 React.createElement(),首字母大写会被编译为对当前作用域内 变量的引用。

React 组件分为 类组件 和 函数组件 两种类型。

类组件

类组件通过继承 React.Component 定义,使用 this.state 和 this.setState 管理状态,拥有明确的生命周期方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React from "react";

class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}

componentDidMount() {
document.title = `点击了 ${this.state.count} 次`;
}

componentDidUpdate() {
document.title = `点击了 ${this.state.count} 次`;
}

render() {
return (
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
点击了 {this.state.count} 次
</button>
);
}
}

JavaScript 的 this 取决于函数如何被调用,而不是在哪里定义。当 类组件的方法被当作回调传递 时,this 会变为 undefined(严格模式)或 window(非严格模式),例如:

1
2
3
4
5
6
7
8
9
10
class Button extends React.Component {
handleClick() {
this.setState({ clicked: true });
}

// handleClick 被调用时内部的 this 不再指向组件实例
render() {
return <button onClick={this.handleClick}>点击</button>;
}
}

类组件是历史遗留的写法,存在生命周期逻辑分散、this 指向容易出错等缺陷,不再推荐新项目使用。

ErrorBoundary 错误边界 只能用类组件实现,没有内置的 Hook,这是 唯一必须用类组件的场景。

函数组件

函数组件本质上是一个返回 JSX 的函数,通过 Hooks 管理状态和副作用,是现代 React 组件的标准写法:

1
2
3
4
5
6
7
8
9
10
11
import { useState, useEffect } from "react";

function Counter() {
const [count, setCount] = useState(0);

useEffect(() => {
document.title = `点击了 ${count} 次`;
}, [count]);

return <button onClick={() => setCount(count + 1)}>点击了 {count} 次</button>;
}

在 React 中,UI 是状态的纯函数 指的是调用组件函数、计算 JSX 的过程中不能有副作用,而 组件的副作用发生在渲染之外,例如在 DOM 更新之后(useEffect)、在用户交互时(事件处理器),这些代码不属于渲染计算,它们是独立执行的另一段逻辑。

一个函数如果 只根据参数计算返回值,就叫纯函数。React 组件本身不是纯函数,只是渲染计算部分被设计为纯粹的,它还对外部状态做了额外的事,这些与渲染无关的事就是 副作用。

最终结果

组件函数执行完毕后,返回的不是 DOM 节点,而是一棵由 ReactElement 组成的对象树,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
type: 'div',
props: {
className: 'card',
children: [
{
type: 'h1',
props: {
children: '标题'
}
},
{
type: 'p',
props: {
children: '内容'
}
}
]
}
}

ReactElement 是一个普通的 JavaScript 对象,它的核心作用是描述 UI,与 DOM 无关。

虚拟 DOM

基本概念

虚拟 DOM 是一类 设计理念 的统称,即用 JavaScript 对象树来表示 UI 结构,在内存中完成构建与比较,最后把结果映射到真实 DOM 中。

React 有两种对象树:

  1. ReactElement 树:即组件返回的结果,不可变,用完即弃;
  2. Fiber 树:长期持有的运行时表示,可变,跨渲染存活。

组件每次渲染都会返回一棵新的 ReactElement 对象树,它是一个 不可变的、用完即弃的快照,下次渲染会 重新生成,而非修改旧的对象树。但界面在重新渲染时需要保持稳定,用户输入的焦点不能丢,DOM 节点要复用,所以必然需要一个持久的、可被 React 长期持有和修改的内部表示,把对象树实例化成能跨渲染存在的东西,这个 持久的内部表示 就是 Fiber 对象树。

虚拟 DOM 是一个比较宽泛的概念,把它等同于 ReactElement 树或 Fiber 树都是不对的,准确的说是两者共同构成了 React 对虚拟 DOM 理念的完整实现。虚拟 DOM 的概念存在很大的歧义,应该避免使用这种模糊的说法,而是用具体的 ReactElement 树或 Fiber 树来代替。

核心作用

直接操作真实 DOM 的成本很高,每次读写 DOM 都可能触发样式计算、布局和绘制,虚拟 DOM 的核心作用在于引入了一层中间表示,把计算变更和应用变更分开:

  1. 在内存中构建和比较:新旧结构都是 JS 对象,比较它们不会触碰真实 DOM,不会频繁触发布局和绘制;
  2. 计算最小的必要变更:比较的结果是一份 更新指令集,描述真实的 DOM 节点需要执行哪些操作;
  3. 集中应用变更:遍历更新指令集,按序执行所有的 DOM 操作,不可中断。

虚拟 DOM 并不总是比手动操作 DOM 快,对比最优的直接 DOM 操作,虚拟 DOM 反而多了一层计算开销。它的真正收益是 在不刻意做精细优化的前提下,稳定地获得接近最优的渲染性能。

Fiber 节点

基本概念

组件函数返回了一棵 ReactElement 对象树,它是一个不可变、用完即弃的快照,本身 不携带任何跨渲染存活的信息:不知道对应的真实 DOM 在哪、哪个节点持有状态、上次渲染留下了什么。React 不能直接拿它去更新界面,需要把这棵元素树实例化成一棵 可以长期持有和修改的 内部树,承载这棵内部树的节点结构,就是 Fiber。

一次更新触发后,React 会从根组件开始递归遍历整棵元素树进行 协调。在 Fiber 之前,这个过程依赖 JS 调用栈,而调用栈不可中断,一旦开始,就必须等整棵树遍历完,主线程才能响应其他任务。如果一次协调的时间过长,就会导致丢帧,并且旧的递归模型 无法区分优先级(例如键盘输入必须立刻响应)。

Fiber 的核心机制是 用链表代替调用栈,让渲染工作可以被 JS 拆分,每一个节点(组件、div、Fragment 或文本)各自都有独立的 FiberNode。

协调是 React 在内存中 计算出最小 UI 变更 的过程,协调的结果是一份 更新指令集,描述真实的 DOM 节点需要执行哪些操作。Fiber 是协调的 工作单元,Diff 是协调的 核心手段。

结构示例

JavaScript 调用栈不能冻结和恢复,Fiber 用链表指针连接不同的节点,遍历时只需维护一个 当前节点 引用。Fiber 还携带了 ReactElement 没有的运行时信息:stateNode 指向对应的真实 DOM,memoizedState 记录组件的内部状态等,这些属性是 Fiber 树能够 长期持有 的必要条件。

Fiber 节点是一个普通的 JavaScript 对象,在逻辑上是一棵树,在存储和遍历方式上是 单向链表。

Fiber 的字段大致可以分为四组:

标识节点身份

  • tag:节点类型枚举,决定 React 走哪套处理逻辑,例如函数组件、类组件、原生 DOM 节点等;
  • key:对应 ReactElement 上的 key,用于 Diff 阶段识别列表项的身份;
  • type:节点具体类型,对于原生 DOM 节点,它是字符串例如 'div',对于函数组件,它指向函数本身;
  • stateNode:指向真实的 DOM 元素或组件实例,用于在 Commit 阶段执行 DOM 操作。

保存状态与更新数据

  • pendingProps:本次渲染待处理的 props,即组件接收的 props 或 DOM 节点的属性;
  • memoizedProps:上次渲染完成后的 props,用于协调时与 pendingProps 对比;
  • memoizedState:指向函数组件的 Hooks 链表头节点或类组件上次渲染后的 state 对象;
  • updateQueue:批处理的存放载体,保存该节点上待处理的状态更新。

Hooks 链表

函数组件的每次渲染,本质上就是重新执行了一遍函数,它没有类组件那样的 this 实例来持久化数据。为了在函数组件中记录内部状态,React 会在首次渲染时按顺序把每个 Hook 产生的数据存储为一条链表,挂在 Fiber 节点的 memoizedState 字段上,下次渲染时 按完全相同的顺序遍历这条链表,取出对应的数据。

这也是 Hooks 必须在顶层调用的根本原因:它们必须按相同的顺序执行。

记录副作用与双缓存

  • flags:标记该节点在本次更新中需要执行的具体 DOM 操作或副作用,记录 Diff 的比较结论;
  • subtreeFlags:汇总子树中所有后代节点的副作用标记,用于快速判断是否需要更新子树;
  • deletions:保存将要被删除的子节点列表,在 Commit 阶段统一处理;
  • alternate:指向另一棵 Fiber 树中对应的节点。

连接树的遍历

  • return:指向父节点;
  • child:指向第一个子节点;
  • sibling:指向下一个兄弟节点。

示例代码

1
2
3
4
5
6
7
8
9
function App() {
const [count, setCount] = useState(0);
return (
<div className="app">
<h1>{count}</h1>
{count > 0 && <p>positive</p>}
</div>
);
}

该函数组件对应的 Fiber 节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
tag: 0, // 函数组件
key: null,
type: App, // 指向函数组件本身
stateNode: null, // 函数组件没有实例

return: HostRootFiber, // 父节点的指针
child: divFiber, // 第一个子节点
sibling: null, // 没有兄弟节点

pendingProps: {}, // 组件没有接收 props
memoizedProps: {}, // 上次渲染后的 props
memoizedState: { // Hooks 链表的头节点
memoizedState: 0, // 该 State 的当前值
queue: { /* 更新队列 */ },
next: null, // 组件只有一个 Hook
},

flags: 0, // 组件节点通常无标记
subtreeFlags: Placement, // 子树中有节点需要插入 DOM
deletions: null, // 没有要删除的子节点
alternate: null, // 首次挂载还没有对应节点
}

Fiber 节点的字段名会随着 React 版本进行变更,只需要理解 Fiber 节点承担哪几类职责,以及每类职责为什么存在即可。

工作原理

React 借鉴了图形学里的 双缓冲思想(在内存里先画好下一帧,再一次性切换显示,避免用户看到不完整的界面),在内存中同时维护了两棵 Fiber 树:

  • current 树:与当前 UI 所对应的 Fiber 树;
  • workInProgress 树:正在内存中构建的、下一次要显示的 Fiber 树。

两棵树的节点通过 alternate 指针互相引用,形成一一对应的关系。

React 使用双缓存树的本质原因是协调支持中断和高优先级任务插队,导致已经修改了一部分的 Fiber 树 可能需要完全回溯,因此不能直接修改旧的 current 树,而是新建一个 workInProgress 树。

ReactElement 树转化为 workInProgress 树是一个 递归交替 的过程:从根节点开始,对 workInProgress 树做 深度优先遍历(遍历开始时只有一个根节点),分为递(beginWork)和归(completeWork)两个动作交替进行,由 workLoop 循环驱动。

React 对 workInProgress 树的遍历会按固定顺序进行:

  1. 有 child,进入 child;
  2. 没有 child,有 sibling,进入 sibling;
  3. 都没有,通过 return 回到父节点。

beginWork 函数负责 向下深入,接收一个 workInProgress 节点,根据它的 tag 属性决定如何获取子节点对应的 ReactElement:对于函数组件,它会执行组件函数,拿到返回的 ReactElement;对于 DOM 节点,它会从 pendingProps.children 中取出所有的 ReactElement。然后用新的 ReactElement 与它所对应的 current 树中的节点做 Diff,根据 Diff 结果为每个子元素创建子 workInProgress 节点并打上副作用标记 flags(记录了在 Commit 阶段该节点需要执行的 DOM 操作或副作用),返回第一个子 workInProgress 节点供遍历继续深入。

beginWork 函数的核心作用是为传入的 workInProgress 节点创建所有的子 workInProgress 节点,并把 ReactElement 与它所对应的 current 节点的 Diff 结果记录在 flags 字段中。

completeWork 函数负责 向上返回,当一个 workInProgress 节点不存在子节点时,会调用 completeWork:对于 DOM 节点,它会根据 type 和 pendingProps 属性创建真实的 DOM 元素,存入 stateNode 字段中,然后把所有子节点对应的真实 DOM 依次挂载到新创建的 DOM 元素下;对于函数组件,不创建 DOM(函数组件对应一个 ReactElement,DOM 是这个 ReactElement 内部的 宿主节点),只处理收尾逻辑,主要是将当前节点与子节点的副作用信息归集到 subtreeFlags 字段中。

宿主节点是 React 中对应宿主环境 原生构建单元 的节点:在浏览器环境下是真实的 DOM 节点;在 React Native 中对应原生视图 View、Text 等。

遍历完整棵 workInProgress 树后,每个节点的 flags 会分散在各自的 workInProgress 节点上。Commit 阶段要执行变更,最直接的做法是从根节点重新遍历一遍,逐个检查每个节点的 flags,但大部分节点可能没有变更,却仍要被访问一遍,树越大,无效遍历就越多。副作用信息的归集 就是把有变更的节点 向上汇总,让 Commit 阶段能借助汇总信息 跳过无变更的子树。

completeWork 函数的核心作用是为传入的 workInProgress 节点创建真实的 DOM,并将整个子树的副作用信息汇总到 workInProgress 节点的 subtreeFlags 属性上,加速 Commit 阶段的执行。

Render 阶段的 completeWork 函数已经创建了真实的 DOM,但还没有把它挂载到页面上。创建 DOM 元素本身是内存操作,不会涉及到布局和绘制。

workLoop 循环(即 Render 阶段)结束后,得到的是一棵 workInProgress 树,它携带了还未挂载到页面上的真实 DOM 元素,以及完整的 UI 变更清单,而 Commit 阶段的任务就是把这棵 workInProgress 树应用到真实 DOM 中。

Commit 阶段会遍历 workInProgress 树中 带副作用标记的节点(通过 subtreeFlags 属性判断是否需要深入),根据每个节点的 flags 字段执行对应的 DOM 操作:

  1. 删除:从真实的 DOM 中移除 deletions 字段记录的所有待删除的子节点;
  2. 插入:对于 flags 中包含 Placement 的节点,把已经创建好的 DOM 插入到页面中;
  3. 更新:对于 flags 中包含 Update 的节点,把差异(属性、样式、文本内容等)应用到真实 DOM 中。

flags 的值是一个 二进制位掩码,即用一个数字的不同二进制位,代表不同副作用,可以叠加多个标记。

与 Render 阶段不同,Commit 阶段是 同步且不可中断 的,这样做是为了避免让用户看到不完整的画面。

Diff 算法

基本概念

Diff 是 beginWork 函数的核心动作,通过对比新生成的 ReactElement 子元素与它所对应的 current 树中的子节点,判断哪些 current 节点可以复用,哪些需要新建或删除。Diff 算法的目标是 尽量高效地创建出包含最小 DOM 变更的 workInProgress 子节点。

Diff 算法不追求理论最优解(即 workInProgress 节点并不一定是最小变更),为了降低算法的时间复杂度,Diff 基于两个假设实现了一套 O(n)O(n) 的启发式算法:

  1. 两个不同类型的元素会产生不同的树;
  2. 开发者可以通过 key 字段提示哪些子元素在不同渲染中是稳定的。

基于以上两个假设,Diff 算法 只会对比同一层级的节点,放弃跨层级的比较。如果一个 DOM 节点在更新前后从一棵子树移动到了另一棵子树下,React 不会尝试跨层级找到它,而是直接销毁旧位置的节点并在新位置重新创建。

工作原理

节点对比

对比新的 ReactElement 与旧的 current 节点时,React 会根据新旧节点的 type 属性决定是否复用旧节点:

  1. 当新旧节点的 type 属性不同时(例如从 <div> 变成 <span>),会新建一个 workInProgress 节点,整棵旧的子树和它所对应的 真实 DOM 会被销毁(记录到 deletions 字段中);

  2. 当新旧节点是同类型的宿主节点时(例如都是 <div>),会复用旧的 current 节点,只更新有变化的属性,底层 DOM 节点会被保留;

  3. 当新旧节点是同一类型的组件时(函数组件或类组件),会复用旧的 current 节点,只更新有变化的属性,组件的状态会被保留。

复用 current 节点的条件是 新旧节点的 type 和 key 都完全相同。没有指定 key 时新旧的 key 都是 null(组件节点的 key 同理),复用条件会退化为只比较 type。

列表对比

对比同一层级的子元素列表时,默认会按顺序逐个对比,如果在头部插入元素,React 会误以为所有元素都变了,从而为每个元素都创建新的 workInProgress 节点。

当子元素拥有 key 时,React 会用 key 来匹配新旧列表中的元素,从而识别出哪些节点是移动的,哪些是新增或删除的,避免不必要的重建。

key 的核心作用是 在元素顺序变更的情况下,让旧的 DOM 节点在渲染中仍然能够被复用,而不是被销毁。

key 需要满足两个条件:

  1. 同一个元素在不同渲染中,key 应该保持不变;
  2. 在同一层级的兄弟节点中,key 的值必须唯一。

批处理

如果每次更新状态后都立刻同步渲染,性能会受到很大的影响。React 会在当前事件处理函数的所有代码都执行完毕后,再 统一处理期间产生的状态更新,只触发一次重新渲染。因此在同步的事件处理函数内部,状态值是一个 静态快照。

在 React 的早期版本中,批处理只发生在事件处理函数内部。但在新版本中,所有的状态更新都会被自动批处理。

批处理解决的核心问题是当一次交互中触发了多个状态更新时,如何避免为每个更新都重新渲染一次组件。

调度器

React 会将渲染任务的优先级挂载到 Fiber 节点的 lanes 字段上,它是一个可叠加的二进制位掩码,可以同时携带多个优先级的更新。调度器会根据 lanes 字段决定渲染任务的执行顺序。

在渲染任务开始执行时,调度器会计算出一个 截止时间,每处理完一个 Fiber 节点就检查是否超出了截止时间,以此决定是否让出主线程。

调度器的核心作用是在不阻塞用户交互的前提下,把 Render 阶段拆分成小块,按优先级执行,并在必要时暂停和恢复。

渲染流程

Trigger

React 只会在 组件初次渲染 或 驱动组件渲染的数据源发生变化 时触发渲染任务,驱动渲染的数据源包括组件自身的 state 和内部订阅的 context。props 不是驱动渲染的数据源,它的变化本质上是父组件渲染带来的结果,子组件会参与协调过程,但 props 本身不是渲染任务的触发器。

React.memo

父组件重新渲染时,默认所有的子组件都会进入协调,执行组件函数然后进行 Diff。

React.memo 会浅比较 workInProgress 节点中本次待更新的 pendingProps 和 current 节点中上一次保存的 memoizedProps(对于对象或函数,比较的是引用地址),props 不变就跳过该组件及其子树的协调,即 不执行组件函数和 Diff,直接复用 current 节点。

memo 的代价是 每次渲染都要执行一次 props 浅比较,只适用于以下情况:

  1. 组件渲染(即执行组件函数)开销大;
  2. 经常被父组件带动重渲染,但是 props 很少改变。

如果 props 频繁改变,memo 反而会徒增一次浅比较过程。

当驱动渲染的数据源发生变化时,React 会把待处理的状态更新记录到对应的 current 节点的 updateQueue 字段中,并将本次更新的优先级 lane 通过 return 指针冒泡到所有祖先节点的 childLanes 字段中,直到根节点。调度器再 根据根节点汇总的优先级排序渲染任务。

Render

当根节点汇总的 childLanes 中存在多个优先级的渲染任务时,React 不会一次性处理所有优先级的更新,而是从中选出一组 renderLanes,本次渲染只处理这组优先级对应的节点,跳过与本次优先级无关的子树。

调度器会根据根节点汇总的优先级,把渲染任务 排序放入任务队列,执行一个渲染任务时,直接进入 Render 阶段。Render 阶段由 workLoop 驱动,对 workInProgress 树做 可中断的深度优先遍历:

  1. 通过 beginWork 函数创建或复用子 Fiber 节点,并把 Diff 结果记录在 flags 字段中;
  2. 通过 completeWork 函数为每个宿主节点创建离屏 DOM,并把整个子树的副作用信息逐级冒泡到根节点的 subtreeFlags 字段中。

Render 阶段最终会生成一棵携带完整变更信息的 workInProgress 树:宿主节点的 stateNode 指向已经创建的离屏 DOM,变更信息以二进制位掩码的形式记录在 flags 和逐级汇总的 subtreeFlags 字段中。这棵 workInProgress 树会作为 Commit 阶段的输入。

Render 阶段以协调为核心,但除了协调,还包含离屏 DOM 构建和副作用信息归集等为 Commit 阶段做准备的工作。

Commit

与 Render 阶段不同,Commit 阶段是 同步且不可中断 的。

整个 Commit 阶段分为三个部分,每个部分都会从根节点开始遍历带有副作用标记的 workInProgress 节点(基于 subtreeFlags 的条件遍历):

Before Mutation

Before Mutation 阶段在 DOM 被修改之前执行。对于函数组件,主要是调度 useEffect,让它在整个 Commit 阶段结束、浏览器完成绘制之后 异步执行。

Mutation

Mutation 阶段执行真实的 DOM 操作。根据 flags 字段执行对应的 DOM 操作:

  • Deletion:从真实的 DOM 中移除 deletions 字段记录的所有待删除的子节点;
  • Placement:把 completeWork 阶段已经创建好的离屏 DOM 挂载到页面中;
  • Update:把差异(属性、样式、文本内容等)应用到真实 DOM 中。

Layout

Layout 阶段在 DOM 修改完成之后、浏览器绘制之前 执行。对于函数组件,主要是同步执行 useLayoutEffect 的回调(先执行清理函数,再执行本次渲染的回调)。

useLayoutEffect 是同步执行的,意味着它可以阻塞页面绘制。核心作用是在真实 DOM 修改完成后,读取布局信息并再次修改 DOM,同时避免用户看到中间状态。

useEffect 的回调是在 Commit 阶段完全结束、并且浏览器完成绘制之后,由调度器异步执行的。React 会先统一执行所有清理函数,再统一执行所有回调,子组件的 useEffect 会先于父组件执行。


  1. 实现原理