前言
Virtual DOM是前端中一个重要的概念,它是React框架中实现高效渲染的核心技术之一。而Diff算法则是比较新旧Virtual DOM树的核心算法,它决定了如何高效地更新真实DOM。Fiber则是React在Virtual DOM基础上进行的一次重构, 使用了拆分工作单元等方式提高了react的性能。本文将详细介绍Virtual DOM、Fiber和Diff算法的概念及其工作原理。
Virtual DOM
什么是Virtual DOM
Virtual DOM(虚拟DOM)是一个轻量级的JavaScript对象,它是对真实DOM的一种抽象表示。通过使用Virtual DOM,React可以在内存中构建一个虚拟的DOM树,当状态发生变化时,React会先更新Virtual DOM,然后通过Diff算法计算出最小的变更,再将这些变更应用到真实DOM上,从而提高性能。
为什么需要Virtual DOM
一个很大的误解是操作Virtual DOM会比操作真实 DOM 要快,实际上直接操作真实 DOM 要比Virtual DOM快不少,使用Virtual DOM并不是因为Virtual DOM本身比真实DOM更快,而是基于以下几个原因:
-
减少直接操作真实DOM的次数:直接操作真实DOM是昂贵的,频繁的DOM操作会触发浏览器的重排(Reflow)与重绘(Repaint),影响性能。Virtual DOM通过批量更新和最小化变更,减少了对真实DOM的操作次数,精确地更新需要改变的部分。
-
实现"声明式"开发:在没有 Virtual DOM 的情况下,开发者需要使用命令式代码(例如 document.getElementById(...))手动管理每一个节点的状态和操作步骤,极易出错且难以维护。而Virtual DOM让开发者只需关注状态数据(State),视图变成数据的函数输出:UI = f(State)。你只需要告诉框架"数据变成了什么",框架负责利用 Virtual DOM 计算出该如何修改界面。
-
跨平台能力:因为 Virtual DOM 本质上只是保存在 JS 内存中的普通对象,它与浏览器的特定 API(如 window 或 document)解耦。这使得一套逻辑可以被渲染到不同的平台:
- 渲染到浏览器真实 DOM → React / Vue
- 渲染到移动端原生控件(iOS/Android) → React Native
- 渲染到服务端 HTML 字符串 → 服务端渲染(SSR)
- 渲染到 Canvas / WebGL → 图形或游戏引擎
Virtual DOM的工作流程
Virtual DOM(虚拟 DOM)的核心思想是:将真实的 DOM 结构抽象为内存中的 JavaScript 对象,通过数据变化触发新旧对象的对比(Diff),最终以最小的开销批量更新真实 DOM。整个工作流程可以清晰地划分为 5 个阶段:
大致流程如下:
scss
[1. 构建 Virtual DOM 树 (h/JSX)]
│
▼
[2. 初次渲染 (Render -> 真实 DOM)]
│
(数据发生变化)
│
▼
[3. 生成新的 Virtual DOM 树]
│
▼
[4. Diff 算法对比新旧树]
│
▼
[5. 生成 Patch 补丁并更新真实 DOM]
阶段一:定义与生成(VNode 的构建)
在应用初始化或声明组件时,框架会将 HTML / JSX 模版编译或直接调用创建函数(如 h() 或 createElement()),在内存中生成一棵描述页面结构的 JS 对象树(即 VNode / 虚拟节点)。
生成 VNode代码示例:
javascript
// 1. 定义创建 VNode 的工厂函数 h()
function h(type, props = {}, children = []) {
return {
type, // 标签名,如 'div', 'h1'
props, // 属性对象,如 { class: 'title' }
children: children.map(child =>
typeof child === 'object'
? child
: { type: 'TEXT_ELEMENT', props: { nodeValue: child }, children: [] }
)
};
}
// 2. 调用 h() 生成内存中的虚拟 DOM 树
const oldVNode = h('div', { id: 'app', class: 'box' }, [
h('h1', { style: 'color: blue;' }, ['Hello Virtual DOM']),
h('p', {}, ['初始状态'])
]);
// 3. 对应的 VNode 对象结构(内存中的 JS 对象):
{
"type": "div",
"props": { "id": "app", "class": "box" },
"children": [
{
"type": "h1",
"props": { "style": "color: blue;" },
"children": [{ "type": "TEXT_ELEMENT", "props": { "nodeValue": "Hello Virtual DOM" }, "children": [] }]
},
{
"type": "p",
"props": {},
"children": [{ "type": "TEXT_ELEMENT", "props": { "nodeValue": "初始状态" }, "children": [] }]
}
]
}
阶段二:首次渲染(Mounting)
首次加载页面时,框架调用渲染函数,递归地将内存中的 VNode 对象树解析并转化为真正的浏览器原生 DOM 节点,最后追加(Mount)到页面文档树中。
渲染真实 DOM代码示例:
javascript
// 将 VNode 递归转换成真实 DOM 节点
function createRealDOM(vnode) {
// 处理文本节点
if (vnode.type === 'TEXT_ELEMENT') {
return document.createTextNode(vnode.props.nodeValue);
}
// 创建真实 HTML 元素
const $el = document.createElement(vnode.type);
// 设置节点属性
Object.keys(vnode.props).forEach(name => {
$el.setAttribute(name, vnode.props[name]);
});
// 递归创建并挂载子节点
vnode.children.forEach(child => {
$el.appendChild(createRealDOM(child));
});
return $el;
}
// 执行首次挂载
const $container = document.getElementById('root');
const $realDOM = createRealDOM(oldVNode);
$container.appendChild($realDOM); // 真实 DOM 挂载完毕
阶段三:响应数据变更(Re-render)
当组件内的 状态(State / Data)发生改变 时,框架重新执行渲染流程,生成一棵全新的 Virtual DOM 树(New VNode Tree)。
生成新 VNode代码示例:
javascript
// 数据更新后,重新生成一棵新的 VNode 树
const newVNode = h('div', { id: 'app', class: 'box active' }, [ // class 改变
h('h1', { style: 'color: blue;' }, ['Hello Virtual DOM']), // 未改变
h('p', {}, ['更新后的状态']) // 文本内容改变
]);
阶段四:新旧比对(Diff 过程)
框架将旧树(oldVNode)与新树(newVNode)进行逐层对比。Diff 算法通过同层比较和节点类型复用策略,迅速识别出发生变化的真正节点,并计算出更新计划(Patch)。
核心 Diff 识别逻辑代码示例:
javascript
function diff(oldVNode, newVNode) {
// 1. 节点被移除
if (!newVNode) {
return ($node) => {$node.remove(); };
}
// 2. 节点新增
if (!oldVNode) {
return ($parent) => {$parent.appendChild(createRealDOM(newVNode)); };
}
// 3. 标签类型变了,整个节点直接替换
if (oldVNode.type !== newVNode.type) {
return ($node) => {$node.replaceWith(createRealDOM(newVNode)); };
}
// 4. 文本节点内容更新
if (oldVNode.type === 'TEXT_ELEMENT') {
if (oldVNode.props.nodeValue !== newVNode.props.nodeValue) {
return ($node) => {$node.nodeValue = newVNode.props.nodeValue; };
}
return () => {};
}
// 5. 类型相同,对比属性并递归对比子节点
const childPatches = [];
const maxLen = Math.max(oldVNode.children.length, newVNode.children.length);
for (let i = 0; i < maxLen; i++) {
childPatches.push(diff(oldVNode.children[i], newVNode.children[i]));
}
// 返回精准修改真实 DOM 的操作补丁
return ($node) => {
updateProps($node, oldVNode.props, newVNode.props);
childPatches.forEach((patchFn, i) => patchFn($node.childNodes[i],$node));
};
}
// 属性更新辅助函数
function updateProps($node, oldProps, newProps) {
Object.keys(oldProps).forEach(key => {
if (!(key in newProps)) $node.removeAttribute(key);
});
Object.keys(newProps).forEach(key => {
if (oldProps[key] !== newProps[key]) $node.setAttribute(key, newProps[key]);
});
}
阶段五:批量更新(Patching)
拿到 Diff 计算出的补丁(Patch)后,框架一次性将修改应用到真实 DOM 上。
应用 Patch 到真实 DOM代码示例:
javascript
// 1. 计算差异并生成 patch 补丁函数
const patch = diff(oldVNode, newVNode);
// 2. 传入首次挂载时生成的真实 DOM 根节点,精准修改视图
patch($realDOM);
// 3. 实际 DOM 修改效果
通过 Diff 对比,整个视图更新阶段真实 DOM 仅执行了两次操作:
- div根节点的 class 被修改为 "box active"。
- <p>节点的文本被修改为 "更新后的状态"。
- <h1>节点无任何改动,未做任何真实 DOM 读写,避免了额外重排与重绘。
Diff算法
Diff算法是用于比较新旧Virtual DOM树的算法,下面主要会介绍React中Diff算法的核心思想和优化策略,并且对react和vue的Diff算法进行简单对比。
1. 算法前提:三大启发式假设
如果使用标准的树对比算法(如 Levenshtein Distance 扩展到树结构),将一棵树转化为另一棵树的最小操作树复杂度为 O(n3) ( n 是树中节点的数量)。当页面有 1000 个节点时, 10003=109 次对比,渲染性能会极其低下。
为了将算法复杂度降至 O(n) ,React 提出了 三大启发式假设(Heuristic Hypotheses):
- 跨层级移动极其罕见(Tree Diff 假设) :两个不同层级的节点很少会相互移动。因此 React 策略性地选择只对同层级节点进行 Diff,放弃跨层级的对比。
- 节点类型决定树结构(Component Diff 假设) :如果两个元素的类型(Type/组件类)不同,React 会判定它们将生成完全不同的树结构,从而直接销毁旧树并重新挂载新树,不再深度递归对比。
- Key 标识节点唯一性(Element Diff 假设) :开发者可以通过给列表子节点设置唯一的
key属性,来标识节点在不同渲染周期中的唯一性与稳定性,从而实现高效率的位置复用。
2. 三大层级的 Diff 策略
基于上述三大假设,React 将复杂的树比对拆分为三个层级具体实施:
2.1 Tree Diff(树层级比对)
React 只对新旧 Fiber 树进行逐层同级对比。
- 如果节点在不同层级间移动(例如将某个子节点
A从Div1移动到Div2下):- React 不会识别出"移动"操作。
- React 会直接在
Div1下销毁(Unmount)A,然后在Div2下重新创建(Mount)A。
性能建议:尽量保持 DOM 结构的稳定性,避免频繁进行跨层级的 DOM 结构调整。
2.2 Component Diff(组件层级比对)
针对 React 组件节点:
- 类型相同(Type 相同) :例如
<Header color="red" />变成<Header color="blue" />。- React 判定为同一组件,保留 DOM 节点。
- 按照组件生命周期/Hooks 更新其
props和state,并递归对子节点进行 Diff。
- 类型不同(Type 不同) :例如
<Header />变成了<Navbar />。- React 直接判定为不同组件。
- 彻底销毁 旧组件及其所有子组件(触发
componentWillUnmount或useEffect的 cleanup)。 - 重新创建新组件并挂载到真实 DOM。
2.3 Element Diff(元素与节点列表比对)
当同层级包含一组子节点(例如列表渲染 <ul>)时,React 涉及三大节点操作策略:INSERT(插入) 、MOVE(移动) 、REMOVE(删除)。
核心机制:lastPlacedIndex(上一置位索引)
在没有 key 的情况下,React 只能按下标顺序逐个对比(只要在头部插入一项,就会导致后续所有节点更新)。
给列表加上唯一的 key 后,React 内部维护了一个全局变量:lastPlacedIndex(在旧树中找到的节点的最大索引),用它来决定节点是原位保留还是需要移动。
移动判断规则:
- 在遍历新节点列表时,拿到当前新节点在旧节点列表中的索引
oldIndex。 - 如果
oldIndex < lastPlacedIndex:说明该旧节点在原树中的相对位置偏靠前,但在新树中被排到了后面,需要执行移动操作(MOVE)。 - 如果
oldIndex >= lastPlacedIndex:说明该旧节点相对位置靠后,无需移动 ,同时更新lastPlacedIndex = oldIndex。
3. 详细实例与算法模拟
假设旧列表顺序为 [A, B, C, D],新列表顺序为 [B, A, D, C]。
模拟演练(旧列表: A-0, B-1, C-2, D-3)
- 初始状态 :
lastPlacedIndex = 0
-
处理新节点 B:
- 在旧列表中找到
B,旧索引oldIndex = 1。 - 比较:
oldIndex(1) >= lastPlacedIndex(0)→ 不移动B。 - 更新:
lastPlacedIndex = max(0, 1) = 1。
- 在旧列表中找到
-
处理新节点 A:
- 在旧列表中找到
A,旧索引oldIndex = 0。 - 比较:
oldIndex(0) < lastPlacedIndex(1)→ 标记A需要发生移动操作! - 更新:
lastPlacedIndex = max(1, 0) = 1。
- 在旧列表中找到
-
处理新节点 D:
- 在旧列表中找到
D,旧索引oldIndex = 3。 - 比较:
oldIndex(3) >= lastPlacedIndex(1)→ 不移动D。 - 更新:
lastPlacedIndex = max(1, 3) = 3。
- 在旧列表中找到
-
处理新节点 C:
- 在旧列表中找到
C,旧索引oldIndex = 2。 - 比较:
oldIndex(2) < lastPlacedIndex(3)→ 标记C需要发生移动操作! - 更新:
lastPlacedIndex = max(3, 2) = 3。
- 在旧列表中找到
最终结果 :只对 A 和 C 执行 DOM 的移动操作,B 和 D 保持物理位置不动。
4. Element Diff 代码模拟
javascript
/**
* 模拟虚拟节点(VNode)
*/
function createVNode(key, type, text) {
return { key, type, text };
}
/**
* React 核心 Element Diff 模拟算法
*/
function reconcileChildren(oldChildren, newChildren) {
// 1. 将旧子节点建立 Key -> Index 的映射表
const oldKeyMap = new Map();
oldChildren.forEach((child, index) => {
oldKeyMap.set(child.key, { vnode: child, index });
});
let lastPlacedIndex = 0;
const patches = [];
const reUsedOldKeys = new Set();
// 2. 遍历新子节点列表
newChildren.forEach((newVNode, newIndex) => {
const key = newVNode.key;
const oldMatched = oldKeyMap.get(key);
if (oldMatched) {
reUsedOldKeys.add(key);
const oldIndex = oldMatched.index;
if (oldIndex < lastPlacedIndex) {
patches.push({
type: 'MOVE',
key: key,
fromIndex: oldIndex,
toIndex: newIndex,
text: newVNode.text
});
} else {
lastPlacedIndex = oldIndex;
patches.push({
type: 'KEEP',
key: key,
index: newIndex,
text: newVNode.text
});
}
} else {
patches.push({
type: 'INSERT',
key: key,
toIndex: newIndex,
text: newVNode.text
});
}
});
// 3. 标记需要删除的节点
oldChildren.forEach((oldVNode) => {
if (!reUsedOldKeys.has(oldVNode.key)) {
patches.push({
type: 'REMOVE',
key: oldVNode.key,
text: oldVNode.text
});
}
});
return patches;
}
// 测试数据
const oldChildren = [
createVNode('A', 'div', '节点 A'),
createVNode('B', 'div', '节点 B'),
createVNode('C', 'div', '节点 C'),
createVNode('D', 'div', '节点 D')
];
const newChildren = [
createVNode('B', 'div', '节点 B'),
createVNode('A', 'div', '节点 A'),
createVNode('D', 'div', '节点 D'),
createVNode('C', 'div', '节点 C'),
createVNode('E', 'div', '节点 E')
];
const patches = reconcileChildren(oldChildren, newChildren);
console.log(patches);
5. 为什么绝不能使用 index 作为 key?
将数组索引 index 设为 key 会引发严重的性能隐患与渲染 Bug:
- 性能变差:如果在数组头部插入一个新元素,所有已有元素的 index 都会发生改变(0 变为 1,1 变为 2...)。React 会误以为所有节点的内容都变了,从而对每一个节点重新进行属性更新(Re-render),失去了复用优势。
- 状态错乱:如果列表项包含非受控组件(如带值的 [ ] )或临时组件状态,当列表顺序变化时,DOM 节点被错误复用,但原有的表单状态/输入值依然留在旧位置,引发数据与 UI 脱节的严重 Bug。
6. React Diff算法与Vue Diff算法的对比
| 对比维度 | React Diff 算法 | Vue 2/3 Diff 算法 |
|---|---|---|
| 核心对比策略 | 单向遍历(从左到右) 使用 lastPlacedIndex 指针扫描新列表,与旧列表中匹配项的索引对比决定移动。 |
双端对比(Vue 2)/ 快速 Diff(Vue 3) Vue 2 使用四指针向中间靠拢;Vue 3 借助最长递增子序列(LIS)计算最小移动集合。 |
| DOM 节点移动效率 | 极端场景开销大 若仅将尾部节点移至头部,由于旧索引小于指针,会导致前面所有节点批量被标记移动。 | 移动次数更少 双端对比与最长递增子序列能够精准找出无需变动的最大节点集合,DOM 真实移动次数显著少于 React。 |
| 组件触发与 Diff 范围 | 自顶向下全树 Reconcile 更新默认从触发变化的组件开始向下一层层生成新 Fiber 树并进行完整 Diff(需借助 React.memo / useMemo 性能优化)。 |
组件级精准更新 依赖响应式数据劫持(Proxy / Getter),状态变化直接精准通知到具体的组件;结合 Vue 3 静态标记(Block Tree & Patch Flag),Diff 阶段自动跳过静态节点。 |
| 算法时间复杂度 | 引入三大启发式假设后降至 O(n) | 引入启发式双端比对及 LIS 后整体为 O(n) |
React Fiber 深度全景解析
React Fiber 是 React 16 引入的全新协调引擎(Reconciler)与底层核心架构。Fiber 取代了以往的栈协调策略,引入了双缓存树、可中断的数据结构、提供了时间切片、优先级调度机制等,解决了复杂应用下的卡顿问题。
一、 什么是 Fiber?
"Fiber" 在不同视角下具有三个层次的含义:
- 从架构视角(Engine): 一种异步、可中断、基于优先级调度的全新 Reconciler(协调器),用于替代旧版 React 15 的 Stack Reconciler(栈协调器)。
- 从数据结构视角(Data Structure): 一种用 JavaScript 对象实现的单向链表节点,用来替代原有的 JavaScript 宿主递归调用栈。
- 从工作单元视角(Work Unit): 一个微型的渲染工作单元,保存了组件的类型、属性、状态、Effect 标记以及用于排队的优先级信息。
二、 核心演进:比对机制的本质变革
在引入 Fiber 架构之前与之后,React 协调阶段的比对载体与工作流发生了根本性的重构:
text
【旧架构(React 15 及更早)】
新渲染的 Virtual DOM 树 ──对比──► 旧的 Virtual DOM 树 ──实时修改──► 真实 DOM 树
(全量递归生成,比对过程不可中断,易阻塞主线程)
【新架构(Fiber / React 16+)】
workInProgress Fiber 树 (内存构建) ──双缓存对比──► current Fiber 树 (当前屏幕渲染)
(基于单链表增量比对,支持时间切片与中断;比对完成后在 Commit 阶段统一批量挂载至 DOM)
1. 旧机制:Virtual DOM 树对比机制
- 工作流: 每次
setState都会重新在内存中递归生成一棵全新的 VNode(Virtual DOM)树,并将其与上一帧的 VNode 树逐层递归对比。 - 致命缺陷:
- 依赖 JavaScript 原生函数调用栈,一旦对比启动就无法中断。
- 对比过程中通常需要实时或无缓存地计算 DOM 操作,没有做到完全的"纯内存增量计算与批量提交分离"。
2. 新机制:current 树与 workInProgress 树对比机制
- 工作流:
- 屏幕上呈现的是
currentFiber 树。 - 当触发更新时,React 在内存中创建或复用一棵
workInProgress(wip)Fiber 树。 - React 逐个节点对比
workInProgress节点与对应的current节点(通过alternate属性关联),在workInProgress节点上打上flags(副作用标记)。 - 比对完成后,在 Commit 阶段 一次性将带
flags的变更应用到 DOM 树,并将指针指向workInProgress树(root.current = workInProgress)。
- 屏幕上呈现的是
三、 为什么需要 Fiber?(解决旧架构致命痛点)
1. 旧架构(Stack Reconciler)的致命痛点
在 React 15 及更早版本中,React 采用的是自顶向下的同步递归机制:
text
[触发 setState] ──> 深度优先同步递归 VNode 树 ──> 计算 Diff ──> 同步修改真实 DOM
└──────────── 整个过程一气呵成,无法中断 ────────────┘
- 主线程独占与阻塞: 浏览器的刷新率通常是 60Hz (即 1 帧约 16.6ms)。在单帧时间内,浏览器不仅要执行 JS,还要处理事件响应、Style 计算、Layout(重排)和 Paint(重绘)。
- 用户体验卡顿(Jank): 当组件树极庞大时,Stack Reconciler 的递归对比可能需要连续占用主线程 50ms 甚至 100ms 以上。在此期间,浏览器无法响应任何用户输入(打字、点击、滚动动画),导致严重的页面丢帧与卡顿。
2. 核心矛盾与解决思路
- 核心矛盾: CPU 密集型的长任务(长时间同步遍历 VNode 树)无差别地抢占了浏览器的渲染主线程。
- 解决思路: 时间切片(Time Slicing)与可中断渲染。把庞大的同步任务切碎成多个极小的"工作单元",利用浏览器空闲时间增量完成;若有高优先级的用户交互(如打字),立刻暂停当前工作,让出主线程优先响应。
四、 Fiber 的数据结构
为了实现"随时暂停、随时恢复"的链表遍历,Fiber 将树状结构重构成单链表。每一个 Fiber 节点的数据结构字段可拆解如下:
1. 核心属性定义
javascript
function FiberNode(tag, pendingProps, key, mode) {
// 1. 静态节点结构标识
this.tag = tag; // 节点类型(如 ClassComponent, FunctionComponent, HostComponent)
this.key = key; // 唯一标识 key
this.elementType = null; // 元素类型
this.type = null; // 对应的 Component 类/函数或 HTML 标签名
// 2. 链表结构指针(构单链表树)
this.child = null; // 指向【第一个子 Fiber 节点】
this.sibling = null; // 指向【下一个右侧兄弟 Fiber 节点】
this.return = null; // 指向【父级 Fiber 节点】(相当于父节点栈回溯)
// 3. 状态与 Hook 数据
this.pendingProps = pendingProps; // 新传入的 props
this.memoizedProps = null; // 上一次渲染使用的 props
this.memoizedState = null; // 函数组件的 Hooks 链表 或 类组件的 State
this.updateQueue = null; // 挂载的待处理 State 更新队列
// 4. 副作用标记与渲染产物
this.flags = Flags.NoFlags; // 记录 DOM 操作标记(如 Placement, Update, Deletion)
this.subtreeFlags = Flags.NoFlags; // 优化项:记录子树中是否有副作用标记
this.stateNode = null; // 映射的真实 DOM 实例或组件实例
// 5. 双缓存机制核心指针
this.alternate = null; // 指向双缓存的另一棵 Fiber 节点(current <-> workInProgress)
// 6. 优先级与调度相关
this.lanes = Lanes.NoLanes; // 当前 Fiber 节点的更新优先级
this.childLanes = Lanes.NoLanes;
}
2. 链表结构拓扑图
text
[ Parent (Return) ]
│
child
│
▼
[ Child 1 (Fiber) ] ──sibling──► [ Child 2 (Fiber) ]
│ ▲ │ ▲
return │ return │
│ return │ return
└─────────┴────── Parent ────────┴─────────┘
五、 Fiber 的四大核心工作机制
text
【Render 阶段】 │ 【Commit 阶段】
(可中断、可暂停、无副作用) │ (同步执行、不可中断)
│
[触发更新] ──> Fiber 遍历 -> 计算 Diff -> 标记 EffectTag ──> 一次性修改真实 DOM -> 执行副作用
1. 两阶段渲染模式(Render / Commit 拆分)
- Render 阶段(Reconcile 协调):
- 职责: 深度优先遍历 Fiber 树,在内存中对比
workInProgress节点与current节点(Diff),并在workInProgress上打上副作用标记(flags)。 - 特性: 纯 JavaScript 计算,不涉及真实 DOM 读写,异步、可暂停、可被打断、可重复执行。
- 职责: 深度优先遍历 Fiber 树,在内存中对比
- Commit 阶段(提交):
- 职责: 收集 Render 阶段计算好的带
flags的 Fiber 节点,集中一次性更新真实 DOM,并将root.current指向workInProgress树,调用生命周期/Hooks(useEffect等)。 - 特性: 同步且不可中断,保证用户看到的 UI 变化是一致且连贯的。
- 职责: 收集 Render 阶段计算好的带
2. 时间切片与可中断调度(Time Slicing & Scheduling)
利用 Scheduler(调度器),React 可以在浏览器每一帧的空闲时间内执行 Fiber 工作单元:
javascript
// 核心工作循环模拟
function workLoopConcurrent() {
// 只要还有未完成的 Fiber 任务,且 Scheduler 认为当前帧尚有剩余空闲时间
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress); // 处理当前 Fiber 节点
}
}
- 出让控制权: 若
shouldYield()返回true(当前帧的时间预算已耗尽,通常保留大于 5ms 供浏览器绘制),React 会暂停workLoop并保存当前的workInProgress游标。 - 优先插队: 当用户产生输入/点击时,Scheduler 将分配高优先级的
Lane,抢占未完成的低优先级渲染任务。
3. 双缓存机制(Double Buffering)与树切换
类似于图形学中的双缓冲区技术,避免视图渲染过程中出现半成品 UI:
current树: 代表当前屏幕上正在呈现的 UI 对应的 Fiber 树。workInProgress(wip)树: 正在内存中基于新的数据进行构建、对比和 Reconciliation 的 Fiber 树。
两棵树节点通过 alternate 属性相互引用:
text
[ current Fiber Node ] ◄────── alternate ──────► [ workInProgress Fiber Node ]
│ │
(当前渲染 UI) (内存中构建/Diff)
一旦 workInProgress 树在 Render 阶段完成构建并通过 Commit 阶段应用到真实 DOM 上,React 只需将根指针一转:root.current = workInProgress,即刻完成 UI 的瞬间切换与属性复用。
4. 优先级调度系统(Lane 模型)
React 使用 31 位二进制位掩码(Lanes) 表示更新的优先级:
- SyncLane / InputContinuousLane: 离散用户交互(点击、按键)、连续交互(拖拽、滚动),优先级最高。
- DefaultLane: 网络请求响应、常规
setState。 - TransitionLane:
startTransition包裹的非紧急状态更新(如大列表搜索过滤)。 - IdleLane: 后台耗时任务。
六、 Fiber 对 Virtual DOM 与 Diff 算法的优化
Fiber 的重构不仅提升了调度能力,也在底层架构上极大地优化了 Virtual DOM 与 Diff 算法 的执行效率。
text
+---------------------------------------------------------------------------------------+
| Fiber 架构优化点 |
+------------------------------------+--------------------------------------------------+
| 1. 对比载体升级 (current <-> wip) | 将 VNode 对比升级为双缓存树对比,实现纯内存增量构建 |
| 2. 遍历载体由 JS 栈转为单链表 | 实现 O(1) 空间复杂度的自建栈遍历,随时保存/恢复游标 |
| 3. 引入 subtreeFlags 剪枝 | 快速跳过无更新子树,将局部 Diff 范围缩小至最小节点集 |
| 4. 复用机制与 alternate 节点 | 借助 alternate 直接复用旧 Fiber,减少内存创建与 GC 开销 |
| 5. Diff 拆分为 Render 阶段增量计算 | 密集 Diff 过程被分散到多帧,消除无差别重排/重绘卡顿 |
+------------------------------------+--------------------------------------------------+
1. 对比机制优化:current 树与 workInProgress 树的增量比对
- 旧 Virtual DOM: 重新全量生成 VNode 树与旧 VNode 树对比。
- Fiber 优化: 借助双缓存的
alternate指针,React 可以直接复用上一次更新创建的 Fiber 节点,对比直接在workInProgress节点与current节点之间展开,极大减少了内存开销与比对范围。
2. 结构层面:由"递归 VNode 树"进化为"单链表 Fiber 树"
- 旧 Virtual DOM: 依赖原生 JS 函数调用栈递归。一旦启动,无法在中间节点留存状态并退出。
- Fiber 优化: 借助
child/sibling/return三个指针,把递归改写为平铺的while循环。即使遍历被中断,只需保留指向当前 Fiber 的引用(workInProgress指针),下次即可精准恢复。
3. 算法层面:subtreeFlags 优化与精准"剪枝"
- 旧 Diff 算法: 逐层递归对比,即使深层只有单节点修改,依然会遍历整棵子树。
- Fiber 优化: 每个 Fiber 节点新增了
subtreeFlags属性,用来记录子树中所有节点产生的副作用标记组合 。- 若某 Fiber 节点的
subtreeFlags === NoFlags且自身无需更新,React 会直接跳过对该节点整个子树的 Diff 遍历 (直接 Clone 或复用),实现 O(1) 的快速剪枝。
- 若某 Fiber 节点的
七、 综合架构对比表
| 对比维度 | Stack Reconciler(React 15 及更早) | Fiber Reconciler(React 16+ / 18+) |
|---|---|---|
| 核心对比载体 | 新旧 Virtual DOM 树对比 | current 树与 workInProgress 树对比 |
| 核心数据结构 | 树状 Virtual DOM + 宿主函数调用栈 | 单链表 Fiber 节点树 |
| 遍历与执行模式 | 同步、递归、不可中断 | 异步、增量、可暂停/可恢复/可废弃 |
| 调度能力 | 无调度系统,按调用顺序先入先出 | 基于 Scheduler 与 Lanes 的优先级抢占调度 |
| 时间切片支持 | 不支持 | 支持(Time Slicing) |
| Diff 遍历效率 | 递归全树比对,无子树标志位剪枝 | 结合 subtreeFlags 深度剪枝与 alternate 对象复用 |
| DOM 更新时机 | 边 Diff 边逐步同步更新 DOM | 拆分为 Render 阶段(纯内存计算)与 Commit 阶段(批量 DOM 操作) |
| 高级特性衍生 | 无 | 支持并发模式(Concurrent)、Suspense、Transitions、Server Components |
拓展 React 组件从状态变更到视图更新全流程详解
在 React(Fiber 架构)中,组件从状态改变(State Update)到最终渲染至屏幕(DOM Commit & Paint),完整生命周期主要分为三大阶段:
- Trigger(触发更新): 收集并调度更新请求。
- Render 阶段(协调与 Diff): 在内存中构建并对比
workInProgress树(可中断)。 - Commit 阶段(提交与渲染): 同步更新真实 DOM 并执行副作用(不可中断)。
一、 全流程概览拓扑图
text
[ Trigger 阶段 ]
setState() / dispatch()
│
▼
创建 Update 对象 ──► 挂载到 Fiber.updateQueue
│
▼
计算优先级 (Lane) ──► 向上标记 childLanes ──► 提交给 Scheduler 调度
───────────────────────────────────────────────────────────────────
[ Render 阶段 (可中断、纯内存计算) ]
Scheduler 调度帧空闲时间 (Time Slicing)
│
▼
双缓存:以 current 树为蓝本构建 workInProgress (wip) 树
│
▼
深度优先遍历 wip 树 (beginWork):
├── 判断是否可复用 (bailout / subtreeFlags 剪枝)
└── 对比 current 与 wip 计算 Diff (Reconciliation)
│
▼
向上归并 (completeWork):
├── 创建/更新 DOM 实例 (未挂载到页面)
└── 收集并打上副作用标记 (flags / subtreeFlags)
───────────────────────────────────────────────────────────────────
[ Commit 阶段 (同步执行、不可中断) ]
root.current = workInProgress (切换双缓存指针)
│
▼
Before Mutation 阶段:执行 getSnapshotBeforeUpdate / 触发异步 useEffect
│
▼
Mutation 阶段:根据 flags 集中增删除改真实 DOM (Placement / Update / Deletion)
│
▼
Layout 阶段:执行 useLayoutEffect / componentDidMount / componentDidUpdate
│
▼
浏览器渲染:主线程响应重排与重绘 (Paint) ──► 异步执行 useEffect 回调
二、 阶段一:Trigger 阶段(触发与调度)
Trigger 阶段的目的是接收更新信号、封装更新任务、确定优先级并通知调度器。
1. 触发状态改变
更新可以通过以下方式触发:
- 类组件:
this.setState()/this.forceUpdate() - 函数组件:
useState的dispatchSetter/useReducer的dispatch - 根节点初始化:
root.render()
2. 创建 Update 对象
当 setState 被调用时,React 会为当前 Fiber 节点创建一个 Update 对象:
javascript
const update = {
lane, // 当前更新的优先级 (Lane)
tag: UpdateState, // 更新类型
payload: null, // 变更的状态(如 setState 的第一个参数)
callback: null, // setState 的回调函数
next: null, // 指向下一个 Update 的链表指针
};
该 Update 会被推入当前 Fiber 节点的 updateQueue(单向环形链表)中保存。
3. 标记更新路径与计算 Lane
- 确定 Lane 优先级: React 根据当前上下文(如是否是输入框打字、点击事件或
startTransition)分配一个优先级标记(Lane)。 - 向上标记路径(
markUpdateLaneFromFiberToRoot): 从触发更新的 Fiber 节点开始,沿着return指针一路向上遍历至HostRoot(根节点),将该 Lane 标记在沿途所有父节点的childLanes属性中。这一步是为了在 Render 阶段快速定位"哪些子树存在更新"。
4. 提交调度系统(Scheduler)
根节点收到更新请求后,将任务注册到 Scheduler(调度器):
- 如果是高优先级更新(如用户输入),立即发起微任务(Microtask)同步执行。
- 如果是低优先级更新(如大列表渲染),利用
MessageChannel在浏览器的下一个空闲帧中发起调度。
三、 阶段二:Render 阶段(协调与 Diff)
Render 阶段的主要任务是深度优先遍历 Fiber 树,对比 current 树与新的 VNode,构建 workInProgress 树,并记录 DOM 操作标记(flags) 。此阶段为纯 JS 运算,无副作用、可中断、可恢复。
text
wip 节点遍历方向 (深度优先)
[Parent]
│
beginWork (向下递)
│
▼
[Child] ──sibling──► [Sibling]
│ ▲
completeWork │
(向上归) ────────────────┘
1. 双缓存构建与启动
React 维护两棵 Fiber 树:
current树: 对应屏幕上当前展示的 UI。workInProgress(wip)树: 正在内存中构建的新树。 React 从HostRoot开始,通过alternate指针复用或新建workInProgress节点。
2. "递"阶段:beginWork
从根节点向下递归处理每个 Fiber 节点:
- Bailout 优化(剪枝): 检查
current.childLanes是否包含当前更新的Lane。若子树没有更新且当前节点props/state未变,直接跳过该节点及其子树的比对(Bailout),直接复用current的子 Fiber。 - 计算新状态: 执行函数组件主体或类组件的
render方法,获取最新的 React Element(Virtual DOM)。 - Diff 算法(Reconciliation): 对比
current.child与最新的 React Element:- 单节点 Diff: 校验
key和type。若一致则复用旧 Fiber;若不一致则标记Deletion销毁旧节点并新建 Fiber。 - 多节点 Diff(列表 Diff): 经历两轮遍历。第一轮按索引复用未移动节点;第二轮借助
keyMap哈希表查找可复用节点,计算节点移动位置。
- 单节点 Diff: 校验
- 标记 flags: 为发生变动的
workInProgress节点打上标记(如Placement挂载、Update更新、Deletion删除)。
3. "归"阶段:completeWork
当某个节点没有子节点,或者子节点已全部处理完毕时,触发 completeWork 向上回溯:
- 构建/更新真实 DOM 实例(
stateNode):- 首次挂载(Mount): 创建真实 DOM 节点,将其子 DOM 节点拼接至该 DOM 下(构建内存 DOM 树)。
- 更新(Update): 对比
memoizedProps和pendingProps,将变更的属性(如style、className、eventListeners)打包存入updateQueue。
- 副作用标记收集(
subtreeFlags): 每个父节点会将所有子节点的flags和subtreeFlags进行按位或(|)合并。这样,根节点的subtreeFlags即可代表整个组件树中是否存在 DOM 操作或副作用。
四、 阶段三:Commit 阶段(提交与真实 DOM 更新)
当 workInProgress 树完全构建完毕后,进入 Commit 阶段。Commit 阶段是同步、不可中断的,旨在将 Render 阶段收集的副作用一次性应用到真实 DOM 并触发视图重绘。
Commit 阶段分为 三个子阶段 ,外加指针切换 与异步 Effect 执行:
0. 准备工作与指针切换
- 收集所有的 Effect 链表。
- 切换双缓存指针: 执行
root.current = workInProgress。此时,内存中构建好的workInProgress树正式变为代表最新 UI 的current树。
1. Before Mutation 阶段(DOM 修改前)
遍历带标记的 Fiber 节点:
- 处理 DOM 节点卸载前的准备工作。
- 类组件: 调用
getSnapshotBeforeUpdate生命周期函数。 - 函数组件: 调度
useEffect的销毁函数与回调函数(将其放入异步微任务队列,等待渲染完成后执行)。
2. Mutation 阶段(真实 DOM 修改)
遍历带标记的 Fiber 节点,正式操作真实 DOM:
- Placement: 将新建的 DOM 节点插入或追加到父级 DOM 节点中。
- Update: 根据 Render 阶段在
updateQueue中打包的属性差异,批量更新 DOM 属性、文本内容及事件绑定。 - Deletion: 从 DOM 树中移除目标节点,并递归调用组件的
componentWillUnmount或useEffect销毁函数,解绑 ref。
3. Layout 阶段(DOM 修改后)
此时真实 DOM 已经完成变更,但浏览器尚未绘制页面(主线程仍被 JS 占用):
- 类组件: 调用
componentDidMount或componentDidUpdate,执行setState回调函数。 - 函数组件: 同步执行
useLayoutEffect的销毁函数与回调函数(此时可以同步读取/修改 DOM 布局且不会造成页面闪烁)。 - Ref 更新: 将真实的 DOM 节点绑定到
ref.current上。
4. 浏览器绘制(Paint)与 Post-Commit 阶段
- 浏览器渲染主线程接管: 执行 Style 计算、Layout(重排)和 Paint(重绘),将最新的 UI 呈现给用户。
- 异步执行
useEffect: 浏览器绘制完成后,控制权交还给 JS 引擎,以非阻塞的方式异步执行在 Before Mutation 阶段注册的useEffect销毁函数与回调函数。
五、 全流程各阶段核心对比表
| 阶段 | 执行环境 | 是否可中断 | 核心任务 | 发生的生命周期 / Hooks |
|---|---|---|---|---|
| Trigger | 同步 | 否 | 创建 Update,标记 Lanes 优先级,提交 Scheduler 调度 | setState() / dispatch() |
| Render - beginWork | 纯内存计算 | 是 | 自顶向下遍历,Diff 对比,计算新 State,打上 flags |
函数组件主体 / render() |
| Render - completeWork | 纯内存计算 | 是 | 自底向上回溯,创建/更新 DOM 实例,归并 subtreeFlags |
无 |
| Commit - Before Mutation | 真实 DOM 修改前 | 否 | 处理 DOM 变动前的快照,调度异步 Effect | getSnapshotBeforeUpdate |
| Commit - Mutation | 操作真实 DOM | 否 | 根据 flags 集中增删改真实 DOM,解绑旧 Ref |
卸载组件的 componentWillUnmount |
| Commit - Layout | 真实 DOM 修改后 | 否 | 绑定新 Ref,同步读取/操作 DOM 布局 | useLayoutEffect, componentDidMount, componentDidUpdate |
| Post-Commit (Paint 后) | 浏览器绘制完成后 | 否(异步) | 执行非阻塞的异步副作用 | useEffect 的销毁与回调函数 |
React 架构演进补充:Effect List 的作用与 Lanes/Flags 的取代
一、 Effect List 的作用(React 16 ~ React 17 架构)
1. 为什么需要 Effect List?
在 Render 阶段,React 会遍历整棵 Fiber 树(可能包含成千上万个节点)。但通常情况下,只有极少数节点 发生了状态变更或需要执行 DOM 操作(即带有 flags / effectTag,如 Placement、Update、Deletion)。
如果 Commit 阶段(不可中断的同步阶段)为了找出这些改变,再次重新全量遍历整棵 Fiber 树,性能开销将非常巨大。
2. Effect List 是如何工作的?
Effect List 本质上是一条穿透 Fiber 树结构、只将"带有副作用(Effect)的 Fiber 节点"串联起来的单链表。
- 构建时机: 在 Render 阶段的
completeWork(归阶段)自底向上收集。 - 收集机制: 当一个子节点拥有
flags(如需要更新 DOM)时,父节点在执行completeWork时会将子节点的 Fiber 节点拼接到自己的 Effect List 链表尾部。 - 链表结构:
firstEffect:指向链表中的第一个有副作用的 Fiber 节点。nextEffect:指向下一个有副作用的 Fiber 节点。lastEffect:指向链表中最后一个有副作用的 Fiber 节点。
text
【Fiber 树拓扑】 【收集生成的 Effect List 链表】
HostRoot
/ \
App [Update] (Node A) HostRoot.firstEffect
/ \ │
[Placement] [No Effect] ▼
(Node B) (Node C) [Node B] (Placement)
│
nextEffect
│
▼
[Node A] (Update) ◄── HostRoot.lastEffect
3. Commit 阶段如何利用 Effect List 提效?
到了 Commit 阶段,React 彻底不再需要遍历庞大的 Fiber 树 ,而是直接拿到 HostRoot.firstEffect,沿着 nextEffect 指针进行线性遍历:
javascript
// React 16/17 Commit 阶段模拟
let nextEffect = root.firstEffect;
while (nextEffect !== null) {
// 1. 针对该节点执行 DOM 增删改查
commitMutationEffects(nextEffect);
// 2. 沿着单链表直接跳到下一个有副作用的节点!
nextEffect = nextEffect.nextEffect;
}
- 核心作用总结: Effect List 实现了 O(N) 树遍历开销向 O(M) 线性开销的降维 (其中 N 为全量 Fiber 节点数, M 为仅包含副作用的节点数),保证了 Commit 阶段(同步阻塞)的极速执行。
二、 架构演进:为什么 React 18+ 废弃了 Effect List?
既然 Effect List 这么高效,为什么在 React 18 / 19 中你很少再看到它了?
React 团队在 React 18(并发模式 Concurrent Mode)中将 Effect List 彻底移除,改用了 subtreeFlags(位掩码标志)与位运算深度优先遍历。
1. Effect List 在并发模式下的致命缺陷
- 不支持部分树跳过(Subtree Bypassing): 在 React 18 的 Concurrent 模式与
Suspense中,渲染是可打断、可选择性渲染局部子树的。Effect List 是一条扁平的单链表,破坏了原本 Fiber 树的层级与结构上下文,导致 React 无法精准地"只提交某一部分子树的更新,而暂缓另一部分"。 - 内存与指针维护成本高: 在频繁打断、重新开始的并发渲染中,反复拼接、重组、解绑
firstEffect / nextEffect / lastEffect单链表极易出错,且增加了额外的 GC 内存开销。
2. 替代方案:subtreeFlags 递归位运算
React 18 引入了 subtreeFlags 机制:
- 机制: 每个 Fiber 节点不仅记录自己的
flags(自身的副作用),还用一个二进制位subtreeFlags记录其所有子孙节点副作用的集合 (通过按位或|运算符自底向上收集)。
javascript
// completeWork 收集逻辑
returnFiber.subtreeFlags |= completedWork.subtreeFlags;
returnFiber.subtreeFlags |= completedWork.flags;
- Commit 阶段遍历逻辑(按需精确剪枝):
javascript
function commitMutationEffectsOnFiber(finishedWork) {
// 1. 如果该节点的子树没有任何副作用,直接整体跳过(剪枝!)
if ((finishedWork.subtreeFlags & MutationMask) !== NoFlags) {
// 递归处理子节点...
let child = finishedWork.child;
while (child !== null) {
commitMutationEffectsOnFiber(child);
child = child.sibling;
}
}
// 2. 处理当前节点自身的副作用
if ((finishedWork.flags & MutationMask) !== NoFlags) {
commitReconcilerEffects(finishedWork);
}
}
三、 新旧机制对比表
| 对比维度 | React 16 / 17 (Effect List) | React 18+ (subtreeFlags + 剪枝树遍历) |
|---|---|---|
| 数据结构 | firstEffect / nextEffect 扁平单链表 |
维持原本 Fiber 树拓扑 + 二进制位 subtreeFlags |
| 遍历方式 | Commit 阶段线性遍历单链表(不走 Fiber 树) | Commit 阶段按位掩码深度优先遍历 Fiber 树 |
| 性能表现 | O(M) 线性开销 ( M 为变更节点数) | O(MlogN) 带有深度剪枝的树遍历,性能基本等价 |
| 并发模式兼容性 | 差(无法精准控制单子树的挂载与卸载) | 极佳 (天生支持 Suspense、并发局部提交与 Transition) |
结语
本篇文章前面看似讲了很多,其实主要都是为了最后讲解 Fiber 架构做铺垫。virtual DOM 的 Diff 算法是 React 的核心优化点,而 Fiber 架构的引入彻底解决了旧架构在性能、调度和用户体验上的痛点。通过双缓存机制、时间切片、优先级调度以及 subtreeFlags 剪枝,React 18+ 实现了高效、可中断、可恢复的渲染流程。React 18 的并发模式(Concurrent Mode)、Suspense、Transition use() 等新特性,也都是在 Fiber 架构的基础上实现的。了解 Fiber 对于了解 React 的底层原理、优化策略等非常重要。