Snofly
4588 words
23 minutes
React 设计原理导论

0 前言#

本文适合具有 react 编码经验的读者,或者熟悉原生 js 的读者。

本文基于 react 18 版本,以“函数组件 + hooks”的形式,介绍 react 的设计思路。

本文是一个初步引导,更多详细的内容,可以参阅书籍《React 设计原理(卡颂)》,也可以进一步阅读源码。

本文目标#

  • 1、了解 react 整体设计思路
  • 2、了解主要模块的设计思路
  • 3、了解 react 源码结构

最终期望:读者能够深入对 react 设计的理解,并为后续学习打下基础。

1 正文#

1.1 react 整体设计思路#

1.1.1 简单的 jsx 代码 🌰#

先看一段经典的计数器代码:

import React, { useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
    </div>
  );
};

显然,这会在页面上显示一个按钮,点击按钮后,数字会加 1。

这里我们可能会回到的一些工作:

  • 组件模块的导入导出
  • jsx 语法到 js 语法到转换
  • 状态变量的定义与更新
  • element 的生成、事件加载、dom 挂载、重新渲染

总之,这个组件是能用了。

1.1.2 原生 js 代码 🌰#

思考上面的计数器代码,如果用原生 js 代码实现,我们会怎么去实现?

随即我们能够想到,类似这样的代码:

const renderButton = () => {
  let count = 0;
  const button = document.createElement("button");
  button.textContent = `Count: ${count}`;
  button.addEventListener("click", () => {
    count++;
    button.textContent = `Count: ${count}`;
  });
};

const buttonElement = renderButton();
// appendChild(buttonElement)

基本思路是这样的,在点击按钮后,修改按钮的 textContent,从而实现数字的变化。

但是这里存在一些问题:

  • 1、业务逻辑和 dom 操作耦合在一起,无法复用。
  • 2、随着组件的规模的增加,代码会变得非常复杂,难以维护。
  • 3、组件间的状态管理和交互逻辑不易共享和复用。

显然,这样的代码在实际的工程项目中,是不可取的。

1.1.3 react 的工作#

我们做这样的实验(可以不用实际编码):

  • 1、基于 create-react-app 构建一个简单的 react 项目。

  • 2、使用 1.1.1 中的计数器代码,引入到页面中。

  • 3、编译运行。

  • 4、点击按钮,页面中数字变化。

在这个过程中,react 帮我们做了什么?

这里需要分两种情况来讨论:

  • 运行之前 —— 编码打包阶段
  • 运行时阶段

1.1.3.1 运行之前#

在运行之前,我们按照 react 的编码规范,完成 jsx 代码的编写(可以是 cra 工具生成的代码框架,也可以是手动修改的代码)。

这时,react 提供了一种 JSX 规范

接下来,在打包流程中,使用 Babel 工具将 JSX 代码转换为 JavaScript 代码,然后就能在浏览器中运行。

例如:

// 原始 JSX
<button onClick={() => setCount(count + 1)}>Click me</button>;

// Babel 编译后(简化)
React.createElement(
  "button",
  { onClick: () => setCount(count + 1) },
  "Click me"
);

这个转换过程中,由 react 负责定义规则,并提供相关 API,例如 React.createElement 。

1.1.3.2 运行时阶段#

转换后的 js 代码就可以直接运行在浏览器的 js 引擎中了。

这些 js 代码,除了业务相关代码,就是工具代码了,例如 react 、 lodash 等。

react 的核心模块代码执行之后,一些函数和对象会被加载到内存里面,在运行时发挥重要作用。

运行时分为两个时期来看待 react 的工作:

  • 1、代码初始化时 —— 负责建立组件层级体系
  • 2、状态更新时 —— 负责响应交互,更新 UI

我们常说的 react 设计原理,就是指这两个时期的代码逻辑。

其中包括了我们常见的一些概念:

  • fiber
  • hooks
  • 虚拟 DOM
  • diff 算法

这些概念设计,在后续章节中会介绍到。

1.1.3.3 初始化时期#

浏览器下载 js 代码,在执行 react 的工具代码之后,且在在状态更新之前,这段时间,react 会进行一系列的初始化操作。

这里有两个重要的变化:

1、通过 ReactDom.render() 方法,将组件渲染到页面上。

例如,将一个 App 挂载到指定的 root 元素上:

import React from "react";
import ReactDOM from "react-dom";
const App = () => {
  return <div>Hello World</div>;
};
ReactDOM.render(<App />, document.getElementById("root"));

这里的 App 挂载之后,页面上就能看到 Hello World 了,开发者工具中 Element 里面也能看到这个 div 元素。

2、在内存中加载了很多函数、对象、标记,等待之后使用。

这里比较关键的就是 Fiber Node。

此时,实际上一共会存在三个 App DOM 对象:

  • 其中一个是真实 DOM, 也就是页面中的 root 元素。

  • 另外两个是虚拟 DOM (也称为 Fiber node), 其实本质是相似于真实 DOM 的两个 js 对象,它们只会存在于内存中参与计算。

在真实的 App DOM 生成的时候,会有很多事项要处理:

  • 基于 react 的事件加载机制,为其绑定事件。
  • 如果有状态变量的话,会基于 react 的 hooks 机制,为其生成状态变量。
  • 如果有副作用函数,需要订阅相关的状态变量。

至此,基本的 js 代码执行完成,页面进入一个待更新的状态。

1.1.3.4 更新时期#

在状态更新时期, react 会不断推动工作流程的循环:

  • 触发任务——>计算更新——>更新 DOM——>触发任务——>计算更新——>更新 DOM——>循环…

以用户点击计数器按钮为例,触发任务的过程大致如下:

  • 用户点击按钮之后,触发事件
  • 触发 onClick 回调函数
  • 回调函数执行
  • 函数内执行到 setCount() 函数
  • 创建更新任务

到这里,点击事件的同步代码执行过程就结束了,后续是 react 的调度器的工作了。

后续工作流程是这样的:

  • 调度阶段:调度器,根据优先级给出这一帧要执行的任务,给到渲染中心。
  • 渲染阶段:渲染中心,根据当前的虚拟 DOM ,执行更新代码,得到下一帧要更新的虚拟 DOM 。
  • 提交阶段:切换两个虚拟 DOM,使用 diff 算法确定 UI 更新的内容,然后执行 UI 更新。

再然后就是循环了,等待下一次用户操作,或者执行其他低优先级的更新任务。

1.1.4 小结#

react 的设计原理,就是指它在运行时是怎么发挥作用的,主要包括初始化和更新两个时期。

我们了解了这些作用后,就能很好的理解我们写的计数器组件代码了。

下面重点介绍运行时阶段状态更新时期,结合实例,了解 react 在工作流程中的每一步做了什么。

这是一个多模块协同的工作流程,数据在各个模块之间传递、共享。

1.2 初始化时期简介#

js 中,一个工具函数被加载到调用栈中执行,它通常会做两件事:

  • 1、进行一些运算;
  • 2、申请一些内存空间。

在 react 的初始化时期,也相当于执行了一个函数,函数中同样会做这两件事。

在申请到的内存空间中,会加载各种模块 —— 一些函数和变量,然后等待更新。

在触发更新时期,这些模块和变量将发挥重要作用。

后文我们以计数器点击过程为例,逐步讲解涉及到的模块,及其相关功能。

我们会主要介绍这些模块的作用:

  • 调度中心
  • 渲染中心
  • 提交中心

在这之前,先简要介绍一些初始化过程中建立的一些变量,因为更新时期会用到它们。

1.2.1 状态注册#

初始化的过程中,这句代码做了什么:

const [count, setCount] = useState(0);
  • 1、调用 useState(0)
  • 2、创建一个 Hook 节点
const hook = {
  memoizedState: 0,     // 当前 state
  baseState: 0,         // 用于追踪上次的 state
  queue: {
    pending: null,      // 这里挂的是 Update 链表(setState 生成的 update 就在这里)
    dispatch: ...,      // 就是 setCount 本身
  },
  next: null            // 指向下一个 Hook(useState、useEffect 等)
}
  • 3、把这个 Hook 节点加入当前函数组件对应的 fiber 的 Hook 链表中

如果 Hook 链表不存在,则会创建一个。

  • 4、创建 setCount 函数(dispatch)
function dispatchSetState(action) {
  // 创建 update 对象
  const update = { action, lane, next };

  // 将 update 加入 queue.pending(环形链表)
  // 调用 scheduleUpdateOnFiber(fiber, lane)
}

上面提到的 fiber 是一个树结构的对象,这里面包含了很多属性,其中就有一个 Hook 链表。

fiber 在形式上,就是 DOM 节点在内存中的表示,也称为 Fiber Node,它和真实的 DOM 节点一一对应。

1.2.2 引申的问题:为什么不能在 if 中使用 hook#

在函数组件中,不能使用这样的代码:

const [count, setCount] = useState(0);
if (condition) {
  const [num, setNum] = useState(0);
}

这样的代码会导致 Hook 链表的顺序不一致,从而导致状态管理的错误,所以 react 会直接报错。

更深层的原因是:

  • useState 本身没有“名字”概念,React 不知道你用的是什么变量名;
  • 函数组件每次执行时会重新声明这些变量,变量名只是 JS 层的临时绑定;
  • 链表和顺序,可以准确找到对应的状态;

1.3 更新时期的工作流程#

1.3.1 触发更新#

用户点击按钮,触发事件。我们来看这个过程:

function onClick() {
  setCount(count + 1);
}

其中只有一句代码,调用了 setCount() 函数,这个函数是 react 的 hooks 机制提供的。

源代码中的类型定义:

function useState<S>(
  initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>];

从名称也可以看出,setCount 做的事情是发送(Dispatch)更新操作,而不是直接 change state。

执行这个函数之后,本质上是做了这三件事情:

  • 创建一个更新任务对象(Update)
  • 把任务放入组件的更新队列(Update Queue)
  • 交给调度器(Scheduler)安排“重新渲染一次组件”

所以到这一步执行之后,内存中会多出一个 Update 对象,会由调度器去进一步处理。

Update 对象的结构大概是这样的:

{
  action: newState,      // 或者是一个函数
  next: null,            // 链表结构中的下一个 update
  lane: 优先级标记,
}

这里面的 action 就是用于计算新的 state 的函数。

调度时,如果存在多个更新,那么可能会进行合并。

总之,如果有这样的操作:

setCount(1);
setCount((prev) => prev + 2);

那么会产生这样的更新队列:

Update1: {
  action: 1,
  next: Update2,
  lane: ...
}

Update2: {
  action: prev => prev + 2,
  next: null,
  lane: ...
}

这个更新队列会被放在这个地方:

当前函数组件所对应的 fiber 节点的 memoizedState 属性,所指向的 hook 链表中的第一个 hook 节点的 queue 属性中的 pending 数组中。

举例说明:

如果有这样的代码:

function App() {
  const [count, setCount] = useState(0); // Hook 1
  const [name, setName] = useState("React"); // Hook 2

  return (
    <button onClick={() => setName("Vue")}>
      {count} - {name}
    </button>
  );
}

在执行到两个 hook 声明时,fiber 中会产生这样的变化:

fiber.memoizedState → Hook(count) → Hook(name)

初始化了 Hook 1 和 Hook 2,然后执行了 onClick 函数,产生了更新任务:

fiber.memoizedState → Hook(count=0, queue=null)
Hook(name="React", queue.pending=[update:"Vue"])

如果有新的 UpDate 对象产生,它会被插入到 queue.pending 中。

比如:

fiber.memoizedState → Hook(count=0, queue=null)
Hook(name="React",
                        queue.pending=[
                          update:"Vue"
                          update:"js",
                          update:(pre)=>{pre+"something"}
                          ]
                        )

最后,把更新任务交给调度器,等待执行进一步处理。

1.3.2 调度器#

调度器的核心目标是:按照优先级合理安排组件重新渲染的时间和方式。

如果把调度器理解为一个复杂函数,它的输入是:

  • Fiber
  • Lane
  • UpdateQueue
  • 当前调度状态
  • 当前时间戳
  • 等等

它没有直接的输出,而是产生一些副作用:

  • 标记组件为「有更新」
  • 计算优先级,注册调度任务
  • 启动渲染阶段

回到上面的一个例子,调度中心收到了更新队列,其中包含了两个 Update 对象:

UpdateQueue: Update1 -> Update2

下面看看调度器具体的工作。

1.3.2.1 将组件标记为待更新状态#

我们之前提到了,我们在内存中有两个对应于真实 dom 的虚拟 dom。

这两个虚拟 dom 本质就是树结构的对象,我们也可以称其为两个 Fiber Node 。

我们常说的 Fiber 架构,就是基于这种树结构的设计。

首先,看第一个问题:为什么使用两个对象?

答案是:参考双缓存机制,获得极大的性能优化。

进一步,我们可以区分这两个对象:称一个为 current node,另一个为 workInProgress node。

current node 对应于当前的真实 dom ,它作用有两个: 1、渲染阶段,基于它生成 workInProgress node; 2、和真实 dom 作 diff 计算。

workInProgress node 是一个对象,react 将下一帧需要更新的内容,首先应用到这个对象上。

需要实际应用更新的时候,切换一下这两个 node 即可。

基于 Update 对象,调度器能够知道并标记需要更新的组件,然后会向上级组件递归传递,直到 root 节点。

1.3.2.2 计算优先级(Lane 模型)#

我们知道,浏览器进程中的 js 线程和 UI 线程是互斥的。

为了防止 js 线程阻塞 UI 线程,在任务量很大时,只能分批执行,于是用到了 react 的优先级系统。

这里介绍一下 Lane 模型:它的本质是许多二进制数字的处理机制。

包括两部分:1、优先级定义——数字的定义;2、合并批量处理——优先级逻辑操作。

比如,不同的操作,优先级是不同的:

const SyncLane = 0b0000000000000000000000000000001; // 同步优先级
const InputContinuousLane = 0b0000000000000000000000000000010; // 连续输入
const DefaultLane = 0b0000000000000000000000000000100; // 默认优先级
{
  action: (count) => count + 1,
  lane: SyncLane, // 代表是“同步更新”,比如点击事件触发的
}

这样就是简单的标记了这个操作的优先级。

回到举例中,调度器标记了更新状态后,还会进一步计算和标记优先级状态。

1.3.2.3 合并更新#

如果存在这样的代码:

setCount(1);
setCount(2);
setCount(3);

显然,在调度器中,会合并为一次更新操作,也就只会触发一次下一阶段的处理流程。

更复杂的情况,setCount(p=>p+1),则会有更多处理的逻辑,本文略过。

小结#

至此,调度器的大概工作就介绍完成了。

简单来说,调度器更新了 Fiber node,准备好了全部信息之后,等待下一阶段的执行。

下一阶段,在 react 中称为 render 阶段,中文翻译为渲染阶段,这里的渲染和浏览器中基于 cssom 和 dom 生成渲染树的过程类似。

注意:调度器在当前调度工作完成后,可能还有部分任务没有处理。这些任务,将会等待下一次调度的处理。

1.3.3 render 阶段#

renderRoot 是一个纯计算过程,会:

  • 根据旧的 fiber tree;
  • 按顺序遍历每个 Hook
  • 应用 queue.pending 中的 update,生成新的状态
  • 得出新的虚拟 DOM fiber 树(workInProgress node)

至此,调度器的任务完成了,后续就是渲染中心的事。

注意:调度器代码执行期间,浏览器页面并不会发生变化,此时的操作都是内存中对象的修改。

1.3.4 commit 阶段#

commit 阶段的整体目标是:

  • 将渲染阶段生成的 fiber 树(workInProgress)中的变更,真正「提交」到浏览器的真实 DOM 中。

也就是说:

  • 渲染阶段只是「算出」要干什么
  • 提交阶段才「真正执行」这些事情:修改 DOM、触发副作用、调用生命周期

此时,内存中存在两个 fiber 树,我们之前提到过,一个是 current node,一个是 workInProgress node。

显然,current node 中的 dom 结构和真实 dom 一致,而 workInProgress node 中的 dom 结构就是下一帧会真实 DOM。

commit 阶段会把 workInProgress node 和 current node 做 diff 计算,得到变化的部分。 然后,应用到真实 DOM 中,完成更新。

之后会根据变化的部分,执行生命周期函数和副作用函数。

1.3.4.1 diff 算法#

注意之前提到的标记,diff 时能够更具变化标记,支剪大量遍历工作。

diff 算法会检查逐层变了 node,如果没有变化标记,就不再处理。

如果需要修改,则会确定修改的方式:新增、删除、修改。

在这个过程中,react 会根据 key 来选择复用某些节点,减少不必要的操作。

这里有个常见的问题:为什么不能推荐使用数组的 index 来做 key ?

答案是:React 在「列表渲染」中使用 key 来识别每个列表项,区分哪些元素被插入、删除、移动或复用。当数组发生变化(特别是中间插入/删除)时,会导致「错误的复用」,造成页面状态错乱!

1.3.5 小结#

commit 之后,页面中的真实 DOM 就会被更新。

这一帧的更新就完成了,之后会等待下一次更新。

值得一提的是:虚拟 DOM 的设计,可以支持跨端。

在 commit 阶段之前的工作,都是在内存中进行的,不涉及具体的环境 —— pc 浏览器或手机屏幕。

于是,利用不同的 commit 工具(react native),既可以把 react 代码应用到其他终端上。

1.4 总结#

react 设计原理的核心,是调度器、渲染中心和提交中心这三个核心模块的协同工作。

调度器负责管理更新任务,渲染中心负责计算更新,提交中心负责更新 DOM。

在这个过程中,react 会根据优先级,合并更新任务,提高更新效率。

这就是 react 的设计原理,它是一个复杂的系统,需要不断地学习和探索。

1.5 参考资料#

  • 《react 设计原理(卡颂)》
  • React 官网
  • chatGpt
React 设计原理导论
http://www.snofly.cn/posts/react/react-design/
Author
snofly
Published at
2025-05-23