虚拟 DOM、Diff 算法与 Fiber

0 阅读6分钟

一、虚拟 DOM 是什么?

一句话:用 JS 对象来描述真实 DOM 的结构,先在内存里算好差异,再最小化更新真实 DOM。

真实 DOM vs 虚拟 DOM

// 真实 DOM(浏览器里的)
<div class="box">
  <h1>标题</h1>
  <p>内容</p>
</div>

// 虚拟 DOM(JS 对象)
{
  type: 'div',
  props: {
    className: 'box',
    children: [
      { type: 'h1', props: { children: '标题' } },
      { type: 'p', props: { children: '内容' } }
    ]
  }
}

为什么要用虚拟 DOM?

操作真实 DOM 很(涉及浏览器重排重绘),而操作 JS 对象很

数据变化
    ↓
生成新的虚拟 DOM 树
    ↓
新旧虚拟 DOM 对比(Diff)
    ↓
找出最小差异
    ↓
只更新变化的真实 DOM(Patch)
方式做法性能
直接操作 DOM数据一变就全量更新 DOM
虚拟 DOM先算差异,只更新变化的部分快(大多数场景)

注意:虚拟 DOM 不是"比直接操作 DOM 快",而是在大量更新时,通过批量 + 最小化更新来优化性能。极简场景下,直接操作 DOM 反而更快。

二、Diff 算法

React 用 Diff 算法对比新旧虚拟 DOM 树,找出需要更新的部分。

三个策略(把 O(n³) 降到 O(n))

策略说明
同层比较只比较同一层级的节点,不跨层级比较
类型判断节点类型不同 → 直接销毁旧树,创建新树
Key 标识同类型的列表元素用 key 来标识,精准匹配

策略一:同层比较

旧树:         新树:
  A              A
 / \            / \
B   C          B   D    ← 只比较同层:发现 C→D,替换
|               |
D               E

React 只会比较 A-A、B-B、C-D... 不会跨层去比较。如果把节点从一棵子树移到另一棵,React 会销毁+重建,而不是移动。

策略二:类型判断

// 旧          新
<div>         <span>
  <Counter/>    <Counter/>
</div>        </span>

// div → span:类型不同 → 整个销毁旧树(包括 Counter),重建新树
// Counter 的 state 会丢失!

策略三:Key 的作用(列表 Diff)

// 没有 key:插入一项,React 不知道哪个是新的,可能全部更新
// 旧:[A, B, C]
// 新:[A, X, B, C]
// React:A不变,B→X(错),C→B(错),新增C(错)—— 大量无效更新

// 有 key:React 能精准识别
// 旧:[A:1, B:2, C:3]
// 新:[A:1, X:4, B:2, C:3]
// React:A不变,新增X,B不变,C不变 —— 只做一次插入 ✅

Key 的最佳实践

// ❌ 用 index 做 key(增删排序时出问题)
list.map((item, i) => <li key={i}>{item.name}</li>)

// ❌ 用随机数做 key(每次渲染都变,等于没加)
list.map(item => <li key={Math.random()}>{item.name}</li>)

// ✅ 用唯一且稳定的 id
list.map(item => <li key={item.id}>{item.name}</li>)

三、Fiber 架构

旧架构的问题(React 15)

React 15 使用递归遍历虚拟 DOM 树(Stack Reconciler):

开始 Diff → 递归遍历整棵树 → 全部算完 → 更新 DOM
            ↑ 这个过程不能中断!

问题:如果组件树很大,递归遍历耗时超过 16ms(一帧),浏览器来不及渲染 → 页面卡顿

Fiber 是什么?(React 16+)

一句话:把大任务拆成小任务,每个小任务做完看看有没有更重要的事(比如用户输入),有就先去做,没有就继续。

旧(Stack):一口气干完  ████████████████████████ 卡了!
新(Fiber):分段干      ██ 空 ██ 空 ██ 空 ████    不卡!
                         ↑  ↑  ↑ 检查有没有更高优先级的任务

Fiber 的核心思想

概念说明
可中断渲染过程可以暂停,让出主线程给浏览器
可恢复暂停后可以从断点继续,不用从头开始
优先级调度高优先级(用户输入)优先处理,低优先级(数据请求后的渲染)延后
增量渲染一帧只做一部分工作,分多帧完成

Fiber 节点结构

每个组件/元素对应一个 Fiber 节点,通过链表关联:

     App (Fiber)
      ↓ child
    Header (Fiber) → sibling → Main (Fiber) → sibling → Footer (Fiber)
      ↓ child                    ↓ child
    Logo (Fiber)              Content (Fiber)
      ↑ returnreturn
    Header                     Main
指针指向
child第一个子节点
sibling下一个兄弟节点
return父节点

遍历顺序:深度优先 — child → sibling → return。因为是链表,可以随时暂停,记住当前位置,之后继续。

Fiber 的两个阶段

阶段名称特点
Render 阶段协调(Reconciliation)计算差异,可中断,不操作 DOM
Commit 阶段提交把差异应用到真实 DOM,不可中断,同步执行
Render 阶段(可中断)          Commit 阶段(同步)
━━━━━━━━━━━━━━━━━━━         ━━━━━━━━━━━━━━━━━
遍历 Fiber 树                 更新真实 DOM
对比新旧,标记变化             执行生命周期/useEffect
可以暂停、恢复                 一口气做完,不暂停

四、双缓冲机制(Double Buffering)

React 同时维护两棵 Fiber 树:

作用
current 树当前屏幕上显示的 UI
workInProgress 树内存中正在构建的新树
current 树(屏幕上)       workInProgress 树(内存中)
      App                        App'
     / \                        / \
  Header Main               Header Main'
                                    |
                                Content'(有更新)

构建完成后 → workInProgress 变成新的 current(指针切换,瞬间完成)

好处:构建过程中用户看到的始终是完整的旧 UI,不会出现"半成品"。跟显卡双缓冲一个道理。

五、优先级模型(Lanes)

React 18 用 Lane 模型 给任务分优先级,高优先级可以打断低优先级:

优先级场景例子
同步(最高)用户直接交互打字、点击
连续输入持续交互拖拽、滚动
普通数据更新请求回来后 setState
过渡不紧急的更新useTransition 包裹的更新
空闲(最低)可延后offscreen 预渲染

核心思想:用户能感知的操作(输入、点击)必须立即响应,数据渲染可以稍等。

六、高频面试题

Q1:虚拟 DOM 一定比真实 DOM 快吗?

不一定。虚拟 DOM 有创建 JS 对象 + Diff 对比的开销。在以下场景,直接操作 DOM 可能更快:

  • 极简单的 UI(一两个元素)
  • 已知确切的 DOM 操作(不需要 Diff)

虚拟 DOM 的优势在于:在复杂应用中,自动帮你找到最小更新范围,开发者不用手动管理 DOM 更新。

Q2:key 为什么不能用 index?

当列表会增删或排序时,index 会变化,React 的 Diff 会把元素搞混:

旧:[A:0, B:1, C:2]   删除A后
新:[B:0, C:1]         key=0A 和 key=0B 对比 → React 认为 A 变成了 B → 错误复用

用唯一 id 做 key 就不会有这个问题。

Q3:Fiber 和之前的区别?

对比Stack Reconciler (React 15)Fiber Reconciler (React 16+)
数据结构递归调用栈Fiber 链表
是否可中断不可中断可中断可恢复
调度同步,一次性完成按优先级分时间片
大组件树可能卡顿不卡顿

Q4:React 的渲染流程?

setState / props 变化
    ↓
触发调度(Scheduler)→ 按优先级安排任务
    ↓
Render 阶段 → 遍历 Fiber 树,Diff 对比,标记需要更新的节点
    ↓(可中断)
Commit 阶段 → 把标记的更新同步应用到真实 DOM
    ↓
浏览器绘制

Q5:什么是双缓冲?为什么需要?

React 在内存中构建 workInProgress 树,完成后一次性替换 current 树(切换指针)。好处是用户始终看到完整 UI,不会看到渲染到一半的中间状态。

Q6:React 怎么决定哪个更新先执行?

通过 Lane 模型。每个更新会被分配一个 Lane(优先级),Scheduler 按优先级调度。用户输入是最高优先级,useTransition 包裹的更新是低优先级,可以被高优先级打断。