这其实是一篇,通过逆向设计,帮助理解 “React是怎么运作的?“

1、JSX 是怎么转变为 HTML的?

Q: 游览器只认识 html,不认识 JSX。那么 JSX 是怎么转变为 html 的?

A:

// JSX:
const element = <div className="box">Hello</div>;
 
// 🔽 by Babel 或其他编译器 → 虚拟 DOM
const element = React.createElement(
  'div', 
  { className: 'box' }, 
  'Hello'
);
 
// 🔽 调用 API: document.createElement, appendChild, insertBefore
<div class="box">Hello</div>

2、React 的更新有哪些阶段?

A:

// 从功能上看
两个阶段
Render 阶段:算要改什么 → Scheduler(调度器) + Reconciler(协调器)
Commit 阶段:真正去改 → Renderer(渲染器)
 
// 从数据结构拆分这两个阶段
Render 阶段
1、React 调用你的组件函数 App(),函数运行完毕,返回新的 Virtual DOM树。
2、Diff:Diff_Functoin(`新的 VDOM`+`旧的 Fiber 树` ){return `WorkInProgress Fiber树`}
3、打标签:如果发现 VDOM 和 Fiber 不一样(比如 className 变了),React 就在 WorkInProgress Fiber 上打一个 Update 的标签
 
Commit 阶段:
1、Renderer 渲染器读取构建好的 WIP Fiber 树;
2、执行指命令:它只看 Fiber 上的标签(Flags)。表如标签说更新,它旧更新。
3、状态持久化。更新完成后,WIP Fiber 树就变成了 Current Fiber 树了。

3、我想知道 VDOM 和 Fiber 树的数据结构长什么样?

见 2 和 4。

4、为什么需要 Fiber,只用一个 VDOM 不行吗?

// VDOM Example,一个大的虚拟 DOM 树
const element = {
  $$typeof: Symbol.for('react.element'), // 标识这是一个 React 元素,防止 XSS
  type: 'div',       // 标签名或组件函数
  key: null,         // 用于 Diff 列表优化
  props: {           // 属性
    className: 'container',
    children: [      // 递归的孩子节点
      { type: 'h1', props: { children: 'Title' } }
    ]
  },
  ref: null
};
 
// Fiber Tree,它有三个部分组成,负责三个 feature
// 1. 架构属性(构建链表),这是为了实现“可中断渲染”而设计的指针
// 每个 Fiber 节点对应一个 React 组件实例或 DOM 节点,它通过三个指针形成树结构
// child    → 第一个子节点
// sibling  → 下一个兄弟节点
// return   → 父节点
const fiber = {
  // 1. 实例相关
  tag: 1,            // 标记组件类型(函数组件、类组件、原生 DOM 等)
  key: null,
  type: 'div',       // 对应的 React Element 的 type
  stateNode: div,    // 【重要】指向真实的 DOM 节点(或者类组件实例)
 
  // 2. 构建链表的指针(替代递归)
  return: Fiber,     // 指向父节点
  child: Fiber,      // 指向第一个子节点
  sibling: Fiber,    // 指向下一个兄弟节点
  index: 0,          // 在兄弟节点中的索引
};
 
// 2. 状态与数据(记录变化)
// 这是为了支持 Hooks 和 State 更新。
const fiber = {
// 3. 数据相关
  pendingProps: {},  // 新的 props(从 React Element 拿来的)
  memoizedProps: {}, // 旧的 props(上一次渲染用的)
  
  memoizedState: {}, // 【重要】组件的状态(Hooks 链表就存在这里)
  updateQueue: {},   // 待执行的状态更新队列
}
 
// 3. 调度与副作用(执行任务)
// 这是为了支持 Diff 算法和 Commit 阶段。
const fiber = {
// 4. 副作用(Diff 的结果)
  flags: Placement,  // 标记要做什么操作(插入、更新、删除)。老版本叫 effectTag
  subtreeFlags: 0,   // 子树中是否有副作用(用于优化,如果没有就不进子树了)
  
  // 5. 双缓存(Time Slicing 的基础)
  alternate: Fiber,  // 指向旧树中对应的 Fiber 节点
}

A:

其实就是 Fiber 的三个特点。

1、Fiber 链表结构,所以支持可中断。而 VDOM 是 DFS,一旦进入就无法停止。

// Fiber Fiber 可以逐节点执行
处理一个 Fiber
暂停
下次继续 sibling 或 child
 
// VDOM 是BFS 查找
一旦进入递归无法暂停

2、有额外字段,可以 state 和 hook 等信息。而 VDOM 比较单薄,只能表示 HTML 状态。

3、存储新旧结构和需要 update 的部分。有 Current Tree表示上一个状态,WIP 表示接下来需要变更的状态(即有短暂的历史状态)。还有记录需要变更部分的flag。而 VDOM 是无状态的。

5、Fiber是怎么做到可中断、恢复的

A:

这个话题,可以换成另一个问题:

Q1: 要遍历一棵树,实现一个可以中断、恢复的算法?

A1:

使用循环代替递归。

// 递归是无法停止的
function DFS(tree) {
  tree.left && DFS(tree.left);
  tree.node && DFS(tree.node);
  tree.right && DFS(tree.right);
}
 
// 循环是有机会中断的
// 1. 通过 nextUnitOfWork 记录当前工作节点的指针
// 2. shouldYield 是关键,这个可以跳出循环,把主线程还给游览器
while (nextUnitOfWork !== null && !shouldYield()) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

以及:

fiber 是链表结构,每个节点是独立单元,通过一个nextunitofwork保存。

Q2: nextUnitOfWork是怎样的顺序遍历tree的?

A2:

  • child → sibling → return(father)

Q3: 那 shouldYield 什么时候执行,什么时候暂停?这个调度是怎么设计的呢?

A3: 利用游览器 requestIdleCallback() API。

  • requestIdleCallback 是游览器每帧结束后回调的方法,会告知剩余空闲时间。
  • requestIdleCallback 传入 schedule 调度逻辑的代码:
    • 检查时间,时间够:nextUnitOfWork 移向下一个节点,继续循环。
    • 时间不够:主动中断break 跳出 while 循环)

6、Fiber 的调度,如果要增加优先级调度,怎么做?

Q:如果像 react18 中,有调度的优先级(比如useTransition和useDeferredValue),应该怎么设计?

A:

本质是通过一个标识符,将 fiber 的 node 分成多个优先级。

相同优先级,组成一个render-commit过程。

实现是:通过两个步骤来解决。

1、给所有 fiber node节点的任务,加上一个优先级的标识。

// React 使用 31 位二进制位 (Bitmask) 来表示不同的优先级。
// 每一个位(Bit)代表一条“车道”。
// 位数越小(越靠右),优先级越高。
const SyncLane = 0b0000000000000000000000000000001; // 1 (最高优,同步,如 onClick)
const InputContinuousLane = 0b0000000000000000000000000000010; // 2 (连续输入,如 onScroll)
const DefaultLane = 0b0000000000000000000000000010000; // 16 (默认,如 useEffect)
const TransitionLane = 0b0000000000000000000000001000000; // 64 (低优,过渡更新)
 

2、在 fiber 节点的循环方法中,增加优先级的判断。

function workLoop(concurrent) {
  // 每一小步都检查一下:
  // 1. 还有没有剩余时间?(shouldYield)
  // 2. 有没有更高优先级的任务插队?(includesBlockingLane)
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}
 
1、默认情况,还是顺序调度(child -> sibling -> return)
2、当一个高优先级的事件出发(比如用户输点击onclick)
3、includesBlockingLane方法发现有比当前更高任务的事情
4、打断当前渲染,重新回到 fiber的 root 开始遍历,但这次遍历会忽略低优先级的 node,只处理出现的高优先级node。
5、到commit 阶段,界面相应。
6、回到低优先级。

6、Fiber 额外信息的 hooks 是怎么存储的?

A:

通过链表存储。每次执行组件,会顺序遍历链表,需要每次执行时,hooks的顺序和数量不变。

  • 组件第一次执行时:生成 hook 链表:Fiber.memoizedState Hook1(useState) Hook2(useEffect) Hook3(useMemo)
  • 更新时:会匹配代码和链表里的hooks。
    • 比如代码执行第一个 Hooks, 会从链表第一个 Hook 中拿数据塞给代码第一个 Hook。
    • 如果不匹配(比如if或者while),会导致数据匹配错误。

7、Diff 是怎么工作的?

A:

公式:Diff(Current Tree + VDOM) = WIP + Flag

抽象成算法就是:

  • 两颗树 Tree,怎么对比,可以计算出一个需要操作的栈,这个栈里存储着可以将旧树变成新树的操作。
// 用伪代码表示,cTree表示 current tree,nTree 表示 VDOM(next tree)
function Diff(cTree, nTree) {
	const flags = [];
	// 1. 同层对比
	for(cRow of cTree, nRow of nTree) {
			// 2. 对比html标签类型
			diff_type_of_node(cRow, nRow);
			// 3. 对比key
			diff_key_of_node(cRow, nRow);
			
			flags.push(flag);
	}
	return {flags, wipTree}
}

A1:

通过 3 个策略比较,让复杂度到o(n)。

  1. 同层比较
  2. 同标签类型比较
  3. 使用key

A2:同一层对比 key,是怎么判断新增、删除、移动的呢?(等价与两个数组比较)

  1. key 相同,则是相同的。没有 key,则按顺序的index作为key。
  2. React 采用两轮循环对比
    1. 第一轮,相互遍历。React认为大部分是追加或者删除了尾部。(最长递增子序列的简化版)
    2. 第二轮,旧的使用map。新的遍历,最后统计 map 匹配情况。

A3: Vue 是怎样diff的?

  1. 双端对比(头头、尾尾、头尾、尾头)
  2. 双端基础上,最长递增子序列

8、Commit 是如何工作的?

A:

Commit 被分成三个阶段。

第一阶段:Before Mutation (DOM 变更前)

  • 状态: DOM 还没变,还是旧的。
  • 任务:
    • 执行 getSnapshotBeforeUpdate 生命周期。
    • 场景**:** 你想在内容更新前,记录一下当前的滚动条位置。因为一旦进入下一阶段,旧 DOM 就没了。

第二阶段:Mutation (DOM 变更中) —— 最核心

  • 状态: 正在修改 DOM。
  • 任务:
    • 操作 DOM: 根据 Fiber 上的 flags (Placement, Update, Deletion),执行真正的 appendChild, removeChild, setAttribute
    • 解绑 Ref: 如果 Ref 变了,这里会先置空 ref.current = null
    • 执行销毁函数: 执行 useLayoutEffect 的 destroy 函数(也就是你 return 的那个清除函数)。

第三阶段:Layout (DOM 变更后 / 布局阶段)

  • 状态: DOM 已经更新完了,但浏览器还没来得及“绘制” (Paint) 到屏幕上(JS 还在霸占主线程)。
  • 任务:
    • 切换树: root.current = workInProgress。此时,内存中的新树正式“转正”。
    • 执行布局副作用: 同步执行 useLayoutEffect 的 create 回调。
    • 执行生命周期: componentDidMountcomponentDidUpdate
    • 更新 Ref: 把 ref.current 指向新的 DOM 节点。

追问 Q: useEffect 在哪个阶段。

  • useEffect(异步, post-commit)
    • React 会在 Commit 阶段结束后,先让浏览器把画面画出来(Paint)。
    • 然后,在浏览器的空闲时段(通过 Scheduler 调度),异步执行 useEffect 的回调。
  • useLayoutEffect (同步,commit 的 DOM 改好后)
    • 会阻塞

9、一些额外的思考题

既然已经弄清楚 react是怎么工作了,那么以下思考题留给你。

1、React 18 的 useTransitionuseDeferredValue 到底是怎么利用 Fiber 架构实现“并发渲染”的?

2、你能自己实现一下最长递增子序列算法吗?