React的Virtual DOM、Diff算法和Fiber

前言

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更快,而是基于以下几个原因:

  1. 减少直接操作真实DOM的次数:直接操作真实DOM是昂贵的,频繁的DOM操作会触发浏览器的重排(Reflow)与重绘(Repaint),影响性能。Virtual DOM通过批量更新和最小化变更,减少了对真实DOM的操作次数,精确地更新需要改变的部分。

  2. 实现"声明式"开发:在没有 Virtual DOM 的情况下,开发者需要使用命令式代码(例如 document.getElementById(...))手动管理每一个节点的状态和操作步骤,极易出错且难以维护。而Virtual DOM让开发者只需关注状态数据(State),视图变成数据的函数输出:UI = f(State)。你只需要告诉框架"数据变成了什么",框架负责利用 Virtual DOM 计算出该如何修改界面。

  3. 跨平台能力:因为 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)O(n^3) O(n3) ( nn n 是树中节点的数量)。当页面有 1000 个节点时, 10003=1091000^3 = 10^9 10003=109 次对比,渲染性能会极其低下。

为了将算法复杂度降至 O(n)O(n) O(n) ,React 提出了 三大启发式假设(Heuristic Hypotheses):

  1. 跨层级移动极其罕见(Tree Diff 假设) :两个不同层级的节点很少会相互移动。因此 React 策略性地选择只对同层级节点进行 Diff,放弃跨层级的对比。
  2. 节点类型决定树结构(Component Diff 假设) :如果两个元素的类型(Type/组件类)不同,React 会判定它们将生成完全不同的树结构,从而直接销毁旧树并重新挂载新树,不再深度递归对比。
  3. 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 组件节点:

  1. 类型相同(Type 相同) :例如 <Header color="red" /> 变成 <Header color="blue" />。
    • React 判定为同一组件,保留 DOM 节点。
    • 按照组件生命周期/Hooks 更新其 props 和 state,并递归对子节点进行 Diff。
  2. 类型不同(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
  1. 处理新节点 B:

    • 在旧列表中找到 B,旧索引 oldIndex = 1。
    • 比较:oldIndex(1) >= lastPlacedIndex(0) →\rightarrow → 不移动 B。
    • 更新:lastPlacedIndex = max(0, 1) = 1。
  2. 处理新节点 A:

    • 在旧列表中找到 A,旧索引 oldIndex = 0。
    • 比较:oldIndex(0) < lastPlacedIndex(1) →\rightarrow → 标记 A 需要发生移动操作!
    • 更新:lastPlacedIndex = max(1, 0) = 1。
  3. 处理新节点 D:

    • 在旧列表中找到 D,旧索引 oldIndex = 3。
    • 比较:oldIndex(3) >= lastPlacedIndex(1) →\rightarrow → 不移动 D。
    • 更新:lastPlacedIndex = max(1, 3) = 3。
  4. 处理新节点 C:

    • 在旧列表中找到 C,旧索引 oldIndex = 2。
    • 比较:oldIndex(2) < lastPlacedIndex(3) →\rightarrow → 标记 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)O(n) O(n) 引入启发式双端比对及 LIS 后整体为 O(n)O(n) O(n)

React Fiber 深度全景解析

React Fiber 是 React 16 引入的全新协调引擎(Reconciler)与底层核心架构。Fiber 取代了以往的栈协调策略,引入了双缓存树、可中断的数据结构、提供了时间切片、优先级调度机制等,解决了复杂应用下的卡顿问题。


一、 什么是 Fiber?

"Fiber" 在不同视角下具有三个层次的含义:

  1. 从架构视角(Engine): 一种异步、可中断、基于优先级调度的全新 Reconciler(协调器),用于替代旧版 React 15 的 Stack Reconciler(栈协调器)。
  2. 从数据结构视角(Data Structure): 一种用 JavaScript 对象实现的单向链表节点,用来替代原有的 JavaScript 宿主递归调用栈。
  3. 从工作单元视角(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 树对比机制

  • 工作流:
    1. 屏幕上呈现的是 current Fiber 树。
    2. 当触发更新时,React 在内存中创建或复用一棵 workInProgress(wip)Fiber 树。
    3. React 逐个节点对比 workInProgress 节点与对应的 current 节点(通过 alternate 属性关联),在 workInProgress 节点上打上 flags(副作用标记)。
    4. 比对完成后,在 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 读写,异步、可暂停、可被打断、可重复执行。
  • Commit 阶段(提交):
    • 职责: 收集 Render 阶段计算好的带 flags 的 Fiber 节点,集中一次性更新真实 DOM,并将 root.current 指向 workInProgress 树,调用生命周期/Hooks(useEffect 等)。
    • 特性: 同步且不可中断,保证用户看到的 UI 变化是一致且连贯的。

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)O(1) O(1) 的快速剪枝。

七、 综合架构对比表

对比维度 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),完整生命周期主要分为三大阶段:

  1. Trigger(触发更新): 收集并调度更新请求。
  2. Render 阶段(协调与 Diff): 在内存中构建并对比 workInProgress 树(可中断)。
  3. 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:
    1. 单节点 Diff: 校验 key 和 type。若一致则复用旧 Fiber;若不一致则标记 Deletion 销毁旧节点并新建 Fiber。
    2. 多节点 Diff(列表 Diff): 经历两轮遍历。第一轮按索引复用未移动节点;第二轮借助 keyMap 哈希表查找可复用节点,计算节点移动位置。
  • 标记 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 阶段

  1. 浏览器渲染主线程接管: 执行 Style 计算、Layout(重排)和 Paint(重绘),将最新的 UI 呈现给用户。
  2. 异步执行 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(N) O(N) 树遍历开销向 O(M)O(M) O(M) 线性开销的降维 (其中 NN N 为全量 Fiber 节点数, MM M 为仅包含副作用的节点数),保证了 Commit 阶段(同步阻塞)的极速执行。

二、 架构演进:为什么 React 18+ 废弃了 Effect List?

既然 Effect List 这么高效,为什么在 React 18 / 19 中你很少再看到它了?

React 团队在 React 18(并发模式 Concurrent Mode)中将 Effect List 彻底移除,改用了 subtreeFlags(位掩码标志)与位运算深度优先遍历。

1. Effect List 在并发模式下的致命缺陷

  1. 不支持部分树跳过(Subtree Bypassing): 在 React 18 的 Concurrent 模式与 Suspense 中,渲染是可打断、可选择性渲染局部子树的。Effect List 是一条扁平的单链表,破坏了原本 Fiber 树的层级与结构上下文,导致 React 无法精准地"只提交某一部分子树的更新,而暂缓另一部分"。
  2. 内存与指针维护成本高: 在频繁打断、重新开始的并发渲染中,反复拼接、重组、解绑 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)O(M) O(M) 线性开销 ( MM M 为变更节点数) O(Mlog⁡N)O(M \log N) O(MlogN) 带有深度剪枝的树遍历,性能基本等价
并发模式兼容性 差(无法精准控制单子树的挂载与卸载) 极佳 (天生支持 Suspense、并发局部提交与 Transition)

结语

本篇文章前面看似讲了很多,其实主要都是为了最后讲解 Fiber 架构做铺垫。virtual DOM 的 Diff 算法是 React 的核心优化点,而 Fiber 架构的引入彻底解决了旧架构在性能、调度和用户体验上的痛点。通过双缓存机制、时间切片、优先级调度以及 subtreeFlags 剪枝,React 18+ 实现了高效、可中断、可恢复的渲染流程。React 18 的并发模式(Concurrent Mode)、Suspense、Transition use() 等新特性,也都是在 Fiber 架构的基础上实现的。了解 Fiber 对于了解 React 的底层原理、优化策略等非常重要。

相关推荐
乘风gg1 小时前
Spec Kit vs OpenSpec vs Superpowers:我为什么最后自己搭了一套
前端·ai编程·claude
颜进强1 小时前
24 · NestJs LazyLoadingModules 懒加载模块:你在前端天天 `import()` 懒路由,但 Nest 偏偏不能懒加载路由
前端·后端·ai编程
传人1 小时前
页面中心圆圈放大效果如何写
前端·css
liangshanbo12151 小时前
前端面试题:微信小程序怎么优化性能?
前端·微信小程序·notepad++
anew___2 小时前
《从零手写操作系统 (29):管道与重定向进阶——命名管道、Here Document与Shell语法扩展》
java·开发语言·前端·javascript·网络
前端snow2 小时前
ai agent --- 概念串烧
前端
a努力。2 小时前
Context-State-Memory三重信息架构揭秘
java·服务器·前端