这其实是一篇,通过逆向设计,帮助理解 “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)。
- 同层比较
- 同标签类型比较
- 使用key
A2:同一层对比 key,是怎么判断新增、删除、移动的呢?(等价与两个数组比较)
- key 相同,则是相同的。没有 key,则按顺序的index作为key。
- React 采用两轮循环对比
- 第一轮,相互遍历。React认为大部分是追加或者删除了尾部。(最长递增子序列的简化版)
- 第二轮,旧的使用map。新的遍历,最后统计 map 匹配情况。
A3: Vue 是怎样diff的?
- 双端对比(头头、尾尾、头尾、尾头)
- 双端基础上,最长递增子序列
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 的那个清除函数)。
- 操作 DOM: 根据 Fiber 上的
第三阶段:Layout (DOM 变更后 / 布局阶段)
- 状态: DOM 已经更新完了,但浏览器还没来得及“绘制” (Paint) 到屏幕上(JS 还在霸占主线程)。
- 任务:
- 切换树:
root.current = workInProgress。此时,内存中的新树正式“转正”。 - 执行布局副作用: 同步执行
useLayoutEffect的 create 回调。 - 执行生命周期:
componentDidMount和componentDidUpdate。 - 更新 Ref: 把
ref.current指向新的 DOM 节点。
- 切换树:
追问 Q: useEffect 在哪个阶段。
- useEffect(异步, post-commit)
- React 会在 Commit 阶段结束后,先让浏览器把画面画出来(Paint)。
- 然后,在浏览器的空闲时段(通过
Scheduler调度),异步执行useEffect的回调。
- useLayoutEffect (同步,commit 的 DOM 改好后)
- 会阻塞
9、一些额外的思考题
既然已经弄清楚 react是怎么工作了,那么以下思考题留给你。
1、React 18 的 useTransition 和 useDeferredValue 到底是怎么利用 Fiber 架构实现“并发渲染”的?
2、你能自己实现一下最长递增子序列算法吗?