Skip to content

Vue 虚拟 DOM 与 Diff 算法

概述

虚拟 DOM 是用 JavaScript 对象描述真实 DOM 的抽象层,通过 Diff 算法计算最小更新,提高渲染效率。

一、虚拟 DOM 基础

VNode 结构

typescript
interface VNode {
  type: string | Component;     // 标签名或组件
  props: Record<string, any>;   // 属性
  children: VNode[] | string;   // 子节点
  key: string | number;         // 唯一标识
  el: Element | null;           // 真实 DOM 引用
  shapeFlag: number;            // 类型标记
  patchFlag: number;            // 优化标记
}

创建 VNode

javascript
// h 函数
function h(type, props, children) {
  return {
    type,
    props,
    children,
    key: props?.key,
    el: null,
    shapeFlag: getShapeFlag(type)
  };
}

// 使用
const vnode = h('div', { class: 'container' }, [
  h('h1', null, 'Hello'),
  h('p', null, 'World')
]);

// JSX 编译结果
<div class="container">
  <h1>Hello</h1>
  <p>World</p>
</div>
// ↓ 编译为
h('div', { class: 'container' }, [
  h('h1', null, 'Hello'),
  h('p', null, 'World')
])

ShapeFlag

javascript
const ShapeFlags = {
  ELEMENT: 1,                    // 普通元素
  FUNCTIONAL_COMPONENT: 1 << 1,  // 函数组件
  STATEFUL_COMPONENT: 1 << 2,    // 有状态组件
  TEXT_CHILDREN: 1 << 3,         // 子节点是文本
  ARRAY_CHILDREN: 1 << 4,        // 子节点是数组
  SLOTS_CHILDREN: 1 << 5,        // 子节点是插槽
  // ...
};

// 使用位运算判断类型
if (shapeFlag & ShapeFlags.ELEMENT) {
  // 是元素
}
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
  // 子节点是数组
}

二、渲染流程

mount 挂载

javascript
function mount(vnode, container) {
  // 创建真实 DOM
  const el = document.createElement(vnode.type);
  vnode.el = el;
  
  // 处理属性
  if (vnode.props) {
    for (const key in vnode.props) {
      patchProp(el, key, null, vnode.props[key]);
    }
  }
  
  // 处理子节点
  if (typeof vnode.children === 'string') {
    el.textContent = vnode.children;
  } else if (Array.isArray(vnode.children)) {
    vnode.children.forEach(child => mount(child, el));
  }
  
  // 挂载到容器
  container.appendChild(el);
}

patch 更新

javascript
function patch(n1, n2, container) {
  // 类型不同,直接替换
  if (n1.type !== n2.type) {
    unmount(n1);
    mount(n2, container);
    return;
  }
  
  const el = (n2.el = n1.el);
  
  // 更新属性
  patchProps(el, n1.props, n2.props);
  
  // 更新子节点
  patchChildren(n1, n2, el);
}

三、Diff 算法

子节点对比情况

javascript
function patchChildren(n1, n2, container) {
  const c1 = n1.children;
  const c2 = n2.children;
  
  // 新子节点是文本
  if (typeof c2 === 'string') {
    if (Array.isArray(c1)) {
      // 卸载旧子节点
      c1.forEach(child => unmount(child));
    }
    if (c1 !== c2) {
      container.textContent = c2;
    }
  }
  // 新子节点是数组
  else if (Array.isArray(c2)) {
    if (Array.isArray(c1)) {
      // 核心 Diff
      patchKeyedChildren(c1, c2, container);
    } else {
      container.textContent = '';
      c2.forEach(child => mount(child, container));
    }
  }
  // 新子节点为空
  else {
    if (Array.isArray(c1)) {
      c1.forEach(child => unmount(child));
    } else {
      container.textContent = '';
    }
  }
}

核心 Diff: 双端对比

javascript
function patchKeyedChildren(c1, c2, container) {
  let i = 0;
  let e1 = c1.length - 1;
  let e2 = c2.length - 1;
  
  // 1. 从头部开始对比
  while (i <= e1 && i <= e2) {
    if (isSameVNodeType(c1[i], c2[i])) {
      patch(c1[i], c2[i], container);
      i++;
    } else {
      break;
    }
  }
  
  // 2. 从尾部开始对比
  while (i <= e1 && i <= e2) {
    if (isSameVNodeType(c1[e1], c2[e2])) {
      patch(c1[e1], c2[e2], container);
      e1--;
      e2--;
    } else {
      break;
    }
  }
  
  // 3. 新节点有剩余,挂载
  if (i > e1 && i <= e2) {
    while (i <= e2) {
      mount(c2[i], container);
      i++;
    }
  }
  
  // 4. 旧节点有剩余,卸载
  else if (i > e2 && i <= e1) {
    while (i <= e1) {
      unmount(c1[i]);
      i++;
    }
  }
  
  // 5. 中间乱序部分
  else {
    patchUnkeyedChildren(c1, c2, container, i, e1, e2);
  }
}

function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key;
}

最长递增子序列优化

javascript
function patchUnkeyedChildren(c1, c2, container, s1, e1, s2, e2) {
  // 建立新节点 key -> index 映射
  const keyToNewIndexMap = new Map();
  for (let i = s2; i <= e2; i++) {
    keyToNewIndexMap.set(c2[i].key, i);
  }
  
  // 记录新节点对应的旧节点索引
  const newIndexToOldIndexMap = new Array(e2 - s2 + 1).fill(0);
  
  // 遍历旧节点
  for (let i = s1; i <= e1; i++) {
    const prevChild = c1[i];
    const newIndex = keyToNewIndexMap.get(prevChild.key);
    
    if (newIndex === undefined) {
      // 旧节点不在新列表中,卸载
      unmount(prevChild);
    } else {
      // 记录映射
      newIndexToOldIndexMap[newIndex - s2] = i + 1;
      patch(prevChild, c2[newIndex], container);
    }
  }
  
  // 获取最长递增子序列
  const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
  
  let j = increasingNewIndexSequence.length - 1;
  
  // 从后向前遍历,移动/挂载节点
  for (let i = e2 - s2; i >= 0; i--) {
    const newIndex = s2 + i;
    const anchor = c2[newIndex + 1]?.el;
    
    if (newIndexToOldIndexMap[i] === 0) {
      // 新增节点
      mount(c2[newIndex], container, anchor);
    } else if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // 需要移动
      move(c2[newIndex], container, anchor);
    } else {
      // 在最长递增子序列中,不需要移动
      j--;
    }
  }
}

// 最长递增子序列算法
function getSequence(arr) {
  const p = arr.slice();
  const result = [0];
  let i, j, u, v, c;
  const len = arr.length;
  
  for (i = 0; i < len; i++) {
    const arrI = arr[i];
    if (arrI !== 0) {
      j = result[result.length - 1];
      if (arr[j] < arrI) {
        p[i] = j;
        result.push(i);
        continue;
      }
      
      u = 0;
      v = result.length - 1;
      while (u < v) {
        c = (u + v) >> 1;
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          v = c;
        }
      }
      
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1];
        }
        result[u] = i;
      }
    }
  }
  
  u = result.length;
  v = result[u - 1];
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  
  return result;
}

四、Vue 3 编译时优化

PatchFlag 静态标记

javascript
// 模板
<div>
  <span>静态文本</span>
  <span>{{ dynamic }}</span>
</div>

// 编译结果
const _hoisted_1 = /*#__PURE__*/_createVNode("span", null, "静态文本", -1 /* HOISTED */);

function render(_ctx) {
  return (_openBlock(), _createBlock("div", null, [
    _hoisted_1, // 静态节点提升
    _createVNode("span", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ]))
}

// PatchFlag 类型
const PatchFlags = {
  TEXT: 1,           // 动态文本
  CLASS: 2,          // 动态 class
  STYLE: 4,          // 动态 style
  PROPS: 8,          // 动态属性
  FULL_PROPS: 16,    // 有动态 key
  HYDRATE_EVENTS: 32,// 需要事件监听器
  STABLE_FRAGMENT: 64,
  KEYED_FRAGMENT: 128,
  UNKEYED_FRAGMENT: 256,
  NEED_PATCH: 512,   // ref 或 hooks
  DYNAMIC_SLOTS: 1024,
  HOISTED: -1,       // 静态提升
  BAIL: -2           // 退出优化
};

Block Tree

javascript
// 开启 Block
function openBlock() {
  blockStack.push([]);
}

function createBlock(type, props, children) {
  const vnode = createVNode(type, props, children);
  // 收集动态子节点
  vnode.dynamicChildren = blockStack.pop();
  return vnode;
}

// Diff 时只对比动态子节点
function patchBlockChildren(n1, n2) {
  for (let i = 0; i < n2.dynamicChildren.length; i++) {
    patch(n1.dynamicChildren[i], n2.dynamicChildren[i]);
  }
}

静态提升

javascript
// 编译前
<div>
  <span class="static">不变的</span>
  <span>{{ msg }}</span>
</div>

// 编译后
const _hoisted_1 = /*#__PURE__*/_createVNode("span", { class: "static" }, "不变的", -1);

function render(_ctx) {
  return (_openBlock(), _createBlock("div", null, [
    _hoisted_1, // 重复渲染使用同一个 VNode
    _createVNode("span", null, _toDisplayString(_ctx.msg), 1)
  ]))
}

事件缓存

javascript
// 编译前
<button @click="onClick">点击</button>

// 编译后(无缓存)
_createVNode("button", { onClick: _ctx.onClick }, "点击")

// 编译后(有缓存)
_createVNode("button", {
  onClick: _cache[0] || (_cache[0] = (...args) => _ctx.onClick(...args))
}, "点击")

五、与 React Diff 对比

特性Vue 3React
策略双端对比 + LIS单向遍历
复杂度O(n)O(n)
key 处理建立 index map建立 fiber map
优化方式编译时 PatchFlag运行时 fiber
静态提升
Block Tree

面试高频题

Q1: 虚拟 DOM 的优势?

:

  1. 跨平台: 可以渲染到 DOM、Canvas、Native
  2. 批量更新: 合并多次更新,减少重排重绘
  3. Diff 优化: 最小化 DOM 操作
  4. 开发体验: 声明式编程

Q2: key 的作用是什么?

:

  1. 标识节点唯一性,帮助 Diff 算法识别节点
  2. 避免就地复用导致的状态错误
  3. 触发组件的卸载和重新挂载
javascript
// ❌ 使用 index 可能导致问题
<li v-for="(item, index) in list" :key="index">

// ✅ 使用唯一 id
<li v-for="item in list" :key="item.id">

Q3: Vue 3 相比 Vue 2 的 Diff 有什么优化?

:

  1. PatchFlag: 标记动态节点类型
  2. Block Tree: 只 Diff 动态节点
  3. 静态提升: 静态节点只创建一次
  4. 事件缓存: 避免重新创建事件处理函数

Q4: 最长递增子序列的作用?

: 在乱序节点移动时,找出不需要移动的最大子集,减少 DOM 操作次数。

前端面试知识库