虚拟DOM与Diff算法

虚拟DOM与Diff算法

学习目标

完成本章学习后,你将能够:

  • 理解为什么需要虚拟DOM,以及它解决了什么问题
  • 掌握虚拟DOM的数据结构(VNode)
  • 理解Diff算法的核心思想和优化策略
  • 理解key属性的作用和原理
  • 手写一个简易的虚拟DOM实现
  • 分析Vue 3中Diff算法的优化

前置知识

学习本章内容前,你需要掌握:

问题引入

实际场景

假设你正在开发一个电商网站的商品列表页面,用户可以通过筛选条件(价格、品牌、评分等)来过滤商品。每次用户修改筛选条件,页面都需要重新渲染商品列表。

传统DOM操作的问题

javascript 复制代码
// 传统方式:直接操作DOM
function updateProductList(products) {
  const container = document.getElementById('product-list');
  
  // 清空容器
  container.innerHTML = '';
  
  // 重新创建所有商品元素
  products.forEach(product => {
    const div = document.createElement('div');
    div.className = 'product-item';
    div.innerHTML = `
      <img src="${product.image}" />
      <h3>${product.name}</h3>
      <p>${product.price}</p>
    `;
    container.appendChild(div);
  });
}

这种方式存在严重的性能问题

  1. 全量更新:即使只有一个商品的价格变了,也要重新创建所有DOM元素
  2. 频繁操作DOM:DOM操作是昂贵的,每次appendChild都会触发浏览器的重排和重绘
  3. 丢失状态:用户的滚动位置、输入框内容等状态会丢失
  4. 无法优化:不知道哪些元素真正需要更新

为什么需要虚拟DOM

虚拟DOM就是为了解决上述问题而诞生的。它的核心思想是:

用JavaScript对象来描述DOM结构,通过对比新旧虚拟DOM的差异,只更新真正需要变化的部分。

虚拟DOM的优势

  • 性能优化:通过Diff算法最小化DOM操作
  • 跨平台:虚拟DOM是纯JavaScript对象,可以渲染到不同平台(Web、Native、小程序)
  • 开发体验:声明式编程,不需要手动操作DOM
  • 批量更新:可以将多次更新合并为一次DOM操作

核心概念

概念1:虚拟DOM(VNode)

虚拟DOM本质上是一个JavaScript对象,用来描述真实DOM的结构。

VNode的数据结构
javascript 复制代码
/**
 * ========================================
 * 数据结构:VNode(虚拟节点)
 * ========================================
 * 
 * 【作用】
 * 用JavaScript对象描述DOM节点的结构和属性
 * 
 * 【核心属性】
 */

const vnode = {
  // 【type】节点类型
  // 类型:string | Component
  // 说明:HTML标签名(如'div')或组件对象
  type: 'div',
  
  // 【props】节点属性
  // 类型:object | null
  // 说明:包含class、style、事件监听器等
  props: {
    class: 'container',
    style: { color: 'red' },
    onClick: () => console.log('clicked')
  },
  
  // 【children】子节点
  // 类型:Array<VNode | string> | string | null
  // 说明:子节点数组,可以是VNode或文本
  children: [
    {
      type: 'h1',
      props: null,
      children: 'Hello World'  // 文本节点
    },
    {
      type: 'p',
      props: { class: 'desc' },
      children: 'This is a paragraph'
    }
  ],
  
  // 【key】节点标识
  // 类型:string | number | null
  // 说明:用于Diff算法优化,标识节点的唯一性
  key: null,
  
  // 【el】真实DOM引用
  // 类型:Element | null
  // 说明:指向对应的真实DOM元素(渲染后才有)
  el: null
};
创建VNode的h函数
javascript 复制代码
/**
 * ========================================
 * 函数:h (hyperscript)
 * ========================================
 * 
 * 【作用】
 * 创建虚拟DOM节点(VNode)
 * 
 * 【参数】
 * @param {string | Component} type - 节点类型(标签名或组件)
 * @param {object | null} props - 节点属性
 * @param {Array | string} children - 子节点
 * 
 * 【返回值】
 * 类型:VNode对象
 * 
 * 【使用示例】
 */

function h(type, props, children) {
  return {
    type,
    props,
    children,
    key: props?.key || null,
    el: null
  };
}

// 示例1:创建简单元素
const vnode1 = h('div', { class: 'container' }, 'Hello');
// 结果:{ type: 'div', props: { class: 'container' }, children: 'Hello', ... }

// 示例2:创建嵌套元素
const vnode2 = h('div', { id: 'app' }, [
  h('h1', null, 'Title'),
  h('p', { class: 'desc' }, 'Description')
]);

// 示例3:在Vue中使用(Vue的h函数)
import { h } from 'vue';

const vnode3 = h('div', { class: 'box' }, [
  h('span', null, 'Text')
]);
虚拟DOM对应的真实DOM
javascript 复制代码
// 虚拟DOM
const vnode = h('div', { class: 'container', id: 'app' }, [
  h('h1', null, 'Hello'),
  h('p', { class: 'desc' }, 'World')
]);

// 对应的真实DOM
/*
<div class="container" id="app">
  <h1>Hello</h1>
  <p class="desc">World</p>
</div>
*/

概念2:渲染(Render)

渲染就是将虚拟DOM转换为真实DOM的过程。

首次渲染(mount)
javascript 复制代码
/**
 * 将虚拟DOM渲染为真实DOM并挂载到容器中
 * @param {VNode} vnode - 虚拟DOM节点
 * @param {Element} container - 容器元素
 */
function mount(vnode, container) {
  // 1. 根据vnode.type创建真实DOM元素
  const el = document.createElement(vnode.type);
  
  // 2. 将真实DOM引用保存到vnode.el(后续更新时需要)
  vnode.el = el;
  
  // 3. 处理props(属性、样式、事件等)
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key];
      
      // 处理事件监听器(以on开头的属性)
      if (key.startsWith('on')) {
        const eventName = key.slice(2).toLowerCase(); // onClick -> click
        el.addEventListener(eventName, value);
      }
      // 处理class属性
      else if (key === 'class') {
        el.className = value;
      }
      // 处理style属性
      else if (key === 'style') {
        for (const styleName in value) {
          el.style[styleName] = value[styleName];
        }
      }
      // 处理其他属性
      else {
        el.setAttribute(key, value);
      }
    }
  }
  
  // 4. 处理children(子节点)
  if (vnode.children) {
    if (typeof vnode.children === 'string') {
      // 文本子节点
      el.textContent = vnode.children;
    } else {
      // 数组子节点,递归渲染
      vnode.children.forEach(child => {
        mount(child, el); // 递归挂载子节点
      });
    }
  }
  
  // 5. 将元素挂载到容器中
  container.appendChild(el);
}

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

const container = document.getElementById('root');
mount(vnode, container);
// 结果:在#root中创建了对应的DOM结构

概念3:Diff算法

Diff算法是虚拟DOM的核心,用于对比新旧虚拟DOM的差异,找出最小的更新操作。

Diff算法的核心思想
javascript 复制代码
/**
 * ========================================
 * 算法:Diff(差异对比)
 * ========================================
 * 
 * 【作用】
 * 对比新旧虚拟DOM,找出需要更新的部分
 * 
 * 【核心原则】
 * 1. 同层比较:只比较同一层级的节点,不跨层级比较
 * 2. 类型比较:type不同直接替换,不再比较子节点
 * 3. key优化:使用key标识节点,优化列表更新
 * 
 * 【参数】
 * @param {VNode} oldVNode - 旧的虚拟DOM
 * @param {VNode} newVNode - 新的虚拟DOM
 * 
 * 【更新策略】
 */

function patch(oldVNode, newVNode) {
  // 策略1:类型不同,直接替换
  if (oldVNode.type !== newVNode.type) {
    const parent = oldVNode.el.parentNode;
    parent.removeChild(oldVNode.el); // 移除旧节点
    mount(newVNode, parent); // 挂载新节点
    return;
  }
  
  // 策略2:类型相同,复用DOM元素
  const el = newVNode.el = oldVNode.el; // 复用真实DOM
  
  // 策略3:更新props
  const oldProps = oldVNode.props || {};
  const newProps = newVNode.props || {};
  
  // 添加或更新新属性
  for (const key in newProps) {
    const oldValue = oldProps[key];
    const newValue = newProps[key];
    if (oldValue !== newValue) {
      // 更新属性(简化版,实际需要处理事件、style等)
      el.setAttribute(key, newValue);
    }
  }
  
  // 移除旧属性
  for (const key in oldProps) {
    if (!(key in newProps)) {
      el.removeAttribute(key);
    }
  }
  
  // 策略4:更新children(核心难点)
  const oldChildren = oldVNode.children || [];
  const newChildren = newVNode.children || [];
  
  // 简化版:逐个对比(实际Vue使用更复杂的算法)
  if (typeof newChildren === 'string') {
    // 新children是文本
    el.textContent = newChildren;
  } else {
    // 新children是数组,需要Diff子节点
    patchChildren(el, oldChildren, newChildren);
  }
}
子节点Diff算法(简化版)
javascript 复制代码
/**
 * 对比并更新子节点列表
 * @param {Element} parent - 父元素
 * @param {Array<VNode>} oldChildren - 旧子节点数组
 * @param {Array<VNode>} newChildren - 新子节点数组
 */
function patchChildren(parent, oldChildren, newChildren) {
  // 简化版Diff:逐个对比
  // 实际Vue 3使用更高效的"最长递增子序列"算法
  
  const commonLength = Math.min(oldChildren.length, newChildren.length);
  
  // 1. 对比公共部分
  for (let i = 0; i < commonLength; i++) {
    patch(oldChildren[i], newChildren[i]);
  }
  
  // 2. 新节点更多,需要添加
  if (newChildren.length > oldChildren.length) {
    newChildren.slice(commonLength).forEach(child => {
      mount(child, parent);
    });
  }
  
  // 3. 旧节点更多,需要删除
  if (oldChildren.length > newChildren.length) {
    oldChildren.slice(commonLength).forEach(child => {
      parent.removeChild(child.el);
    });
  }
}

概念4:key的作用

key是虚拟DOM中非常重要的属性,用于标识节点的唯一性,帮助Diff算法更高效地复用节点。

为什么需要key
javascript 复制代码
// 场景:列表中插入新元素

// 没有key的情况
const oldVNodes = [
  h('li', null, 'A'),
  h('li', null, 'B'),
  h('li', null, 'C')
];

const newVNodes = [
  h('li', null, 'A'),
  h('li', null, 'D'),  // 新插入的元素
  h('li', null, 'B'),
  h('li', null, 'C')
];

// 没有key时,Diff算法会认为:
// - 第2个li从'B'变成了'D' → 更新文本
// - 第3个li从'C'变成了'B' → 更新文本
// - 第4个li是新增的'C' → 创建新节点
// 结果:3次DOM操作(2次更新 + 1次创建)

// 有key的情况
const oldVNodesWithKey = [
  h('li', { key: 'a' }, 'A'),
  h('li', { key: 'b' }, 'B'),
  h('li', { key: 'c' }, 'C')
];

const newVNodesWithKey = [
  h('li', { key: 'a' }, 'A'),
  h('li', { key: 'd' }, 'D'),  // 新插入的元素
  h('li', { key: 'b' }, 'B'),
  h('li', { key: 'c' }, 'C')
];

// 有key时,Diff算法会认为:
// - key='a'的节点没变 → 不更新
// - key='d'是新节点 → 创建并插入
// - key='b'的节点移动了位置 → 移动DOM
// - key='c'的节点移动了位置 → 移动DOM
// 结果:1次DOM操作(1次创建 + 2次移动,移动比更新快)
key的使用规则
javascript 复制代码
/**
 * ========================================
 * 属性:key
 * ========================================
 * 
 * 【作用】
 * 标识节点的唯一性,帮助Diff算法复用节点
 * 
 * 【使用场景】
 * - 列表渲染(v-for)
 * - 动态组件切换
 * - 强制替换元素
 * 
 * 【正确用法】
 */

// ✅ 好:使用唯一且稳定的ID
const products = [
  { id: 1, name: 'iPhone' },
  { id: 2, name: 'iPad' },
  { id: 3, name: 'MacBook' }
];

const vnodes = products.map(product => 
  h('div', { key: product.id }, product.name)
);

// ✅ 好:使用业务数据的唯一标识
const users = [
  { userId: 'u001', name: 'Alice' },
  { userId: 'u002', name: 'Bob' }
];

const userVNodes = users.map(user =>
  h('div', { key: user.userId }, user.name)
);

/**
 * 【错误用法】
 */

// ❌ 坏:使用数组索引作为key(列表顺序会变化时)
const badVNodes1 = products.map((product, index) =>
  h('div', { key: index }, product.name)
);
// 问题:插入或删除元素时,索引会变化,导致key对应的元素错乱

// ❌ 坏:使用随机数作为key
const badVNodes2 = products.map(product =>
  h('div', { key: Math.random() }, product.name)
);
// 问题:每次渲染key都不同,无法复用节点

// ❌ 坏:不使用key(在动态列表中)
const badVNodes3 = products.map(product =>
  h('div', null, product.name)
);
// 问题:无法准确识别节点,可能导致状态错乱

/**
 * 【特殊情况:可以使用index作为key】
 */

// ✅ 可以:列表是静态的,不会增删改
const staticList = ['Apple', 'Banana', 'Orange'];
const staticVNodes = staticList.map((item, index) =>
  h('li', { key: index }, item)
);

// ✅ 可以:列表只会在末尾添加,不会插入或删除
const appendOnlyList = todos.map((todo, index) =>
  h('div', { key: index }, todo.text)
);
key导致的常见问题
javascript 复制代码
// 问题场景:使用index作为key导致状态错乱

// Vue组件示例
const TodoList = {
  data() {
    return {
      todos: [
        { id: 1, text: 'Learn Vue', done: false },
        { id: 2, text: 'Learn React', done: false },
        { id: 3, text: 'Learn Angular', done: false }
      ]
    };
  },
  methods: {
    removeTodo(index) {
      this.todos.splice(index, 1);
    }
  },
  template: `
    <div>
      <!-- ❌ 错误:使用index作为key -->
      <div v-for="(todo, index) in todos" :key="index">
        <input type="checkbox" v-model="todo.done" />
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(index)">删除</button>
      </div>
    </div>
  `
};

// 问题演示:
// 1. 用户勾选第2项(Learn React)的checkbox
// 2. 删除第1项(Learn Vue)
// 3. 结果:第2项的checkbox状态丢失了!

// 原因分析:
// 删除前:
// index=0, key=0, text='Learn Vue', done=false
// index=1, key=1, text='Learn React', done=true  ← 用户勾选了
// index=2, key=2, text='Learn Angular', done=false

// 删除后:
// index=0, key=0, text='Learn React', done=false  ← key=0复用了原来的DOM
// index=1, key=1, text='Learn Angular', done=false ← key=1复用了原来的DOM
// 结果:checkbox状态对应错了!

// ✅ 正确:使用唯一ID作为key
const CorrectTodoList = {
  template: `
    <div>
      <div v-for="todo in todos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </div>
    </div>
  `
};
// 使用todo.id作为key,删除元素时能正确识别和复用节点

概念5:Vue 3的Diff算法优化

Vue 3在Diff算法上做了重要优化,性能比Vue 2提升了很多。

优化1:静态标记(PatchFlag)
javascript 复制代码
/**
 * Vue 3会在编译时标记动态内容,运行时只对比动态部分
 */

// 模板
const template = `
  <div>
    <p>Static text</p>
    <p>{{ dynamicText }}</p>
    <p :class="dynamicClass">Text</p>
  </div>
`;

// Vue 3编译后(简化版)
const render = () => {
  return h('div', null, [
    h('p', null, 'Static text'),  // 没有PatchFlag,不会对比
    h('p', null, dynamicText, 1), // PatchFlag=1(TEXT),只对比文本
    h('p', { class: dynamicClass }, 'Text', 2) // PatchFlag=2(CLASS),只对比class
  ]);
};

// 更新时,Vue 3只会对比有PatchFlag的节点,跳过静态节点
// 性能提升:减少了大量不必要的对比
优化2:静态提升(hoistStatic)
javascript 复制代码
/**
 * Vue 3会将静态节点提升到render函数外部,避免重复创建
 */

// 模板
const template = `
  <div>
    <p>Static text 1</p>
    <p>Static text 2</p>
    <p>{{ dynamicText }}</p>
  </div>
`;

// Vue 2编译后:每次render都创建静态节点
const render2 = () => {
  return h('div', null, [
    h('p', null, 'Static text 1'),  // 每次都创建
    h('p', null, 'Static text 2'),  // 每次都创建
    h('p', null, dynamicText)
  ]);
};

// Vue 3编译后:静态节点提升到外部
const hoisted1 = h('p', null, 'Static text 1');  // 只创建一次
const hoisted2 = h('p', null, 'Static text 2');  // 只创建一次

const render3 = () => {
  return h('div', null, [
    hoisted1,  // 直接复用
    hoisted2,  // 直接复用
    h('p', null, dynamicText)
  ]);
};

// 性能提升:减少了VNode的创建开销
优化3:最长递增子序列(LIS)
javascript 复制代码
/**
 * Vue 3使用最长递增子序列算法优化列表Diff
 * 目标:找出不需要移动的节点,最小化DOM移动次数
 */

// 场景:列表重新排序
const oldList = ['A', 'B', 'C', 'D', 'E'];
const newList = ['A', 'C', 'B', 'E', 'D'];

// Vue 2的Diff:双端比较
// 需要移动:C、B、D(3次移动)

// Vue 3的Diff:最长递增子序列
// 1. 找出新列表中元素在旧列表中的位置索引
//    A=0, C=2, B=1, E=4, D=3
//    索引序列:[0, 2, 1, 4, 3]

// 2. 找出最长递增子序列
//    [0, 2, 4] 对应 A、C、E
//    这些元素不需要移动!

// 3. 只移动不在递增子序列中的元素
//    需要移动:B、D(2次移动)

// 性能提升:减少了DOM移动次数

/**
 * 最长递增子序列算法实现(简化版)
 */
function getSequence(arr) {
  const len = arr.length;
  const result = [0]; // 存储递增子序列的索引
  const p = arr.slice(); // 用于回溯
  
  for (let i = 0; i < len; i++) {
    const arrI = arr[i];
    if (arrI !== 0) {
      const j = result[result.length - 1];
      if (arr[j] < arrI) {
        p[i] = j;
        result.push(i);
        continue;
      }
      
      // 二分查找
      let left = 0;
      let right = result.length - 1;
      while (left < right) {
        const mid = (left + right) >> 1;
        if (arr[result[mid]] < arrI) {
          left = mid + 1;
        } else {
          right = mid;
        }
      }
      
      if (arrI < arr[result[left]]) {
        if (left > 0) {
          p[i] = result[left - 1];
        }
        result[left] = i;
      }
    }
  }
  
  // 回溯构建完整序列
  let i = result.length;
  let u = result[i - 1];
  while (i-- > 0) {
    result[i] = u;
    u = p[u];
  }
  
  return result;
}

// 使用示例
const indices = [0, 2, 1, 4, 3];
const lis = getSequence(indices);
console.log(lis); // [0, 1, 3] 对应索引位置的元素不需要移动

最佳实践

企业级应用场景

场景1:大列表渲染优化
javascript 复制代码
// 问题:渲染10000条数据导致页面卡顿

// ❌ 坏:直接渲染所有数据
const BadList = {
  data() {
    return {
      items: Array.from({ length: 10000 }, (_, i) => ({
        id: i,
        text: `Item ${i}`
      }))
    };
  },
  template: `
    <div>
      <div v-for="item in items" :key="item.id">
        {{ item.text }}
      </div>
    </div>
  `
};
// 问题:创建10000个VNode和DOM元素,性能差

// ✅ 好:使用虚拟滚动(只渲染可见区域)
const GoodList = {
  data() {
    return {
      items: Array.from({ length: 10000 }, (_, i) => ({
        id: i,
        text: `Item ${i}`
      })),
      itemHeight: 50,  // 每项高度
      visibleCount: 20, // 可见数量
      scrollTop: 0
    };
  },
  computed: {
    // 计算可见范围
    visibleItems() {
      const start = Math.floor(this.scrollTop / this.itemHeight);
      const end = start + this.visibleCount;
      return this.items.slice(start, end).map((item, index) => ({
        ...item,
        top: (start + index) * this.itemHeight
      }));
    },
    // 容器总高度
    totalHeight() {
      return this.items.length * this.itemHeight;
    }
  },
  methods: {
    handleScroll(e) {
      this.scrollTop = e.target.scrollTop;
    }
  },
  template: `
    <div class="virtual-list" @scroll="handleScroll" style="height: 500px; overflow: auto;">
      <div :style="{ height: totalHeight + 'px', position: 'relative' }">
        <div
          v-for="item in visibleItems"
          :key="item.id"
          :style="{ position: 'absolute', top: item.top + 'px', height: itemHeight + 'px' }"
        >
          {{ item.text }}
        </div>
      </div>
    </div>
  `
};
// 优势:只渲染20个VNode,性能提升500倍
场景2:条件渲染优化
javascript 复制代码
// 问题:频繁切换显示/隐藏导致性能问题

// ❌ 坏:使用v-if频繁切换
const BadToggle = {
  data() {
    return {
      show: true
    };
  },
  template: `
    <div>
      <button @click="show = !show">Toggle</button>
      <div v-if="show">
        <ExpensiveComponent />  <!-- 复杂组件 -->
      </div>
    </div>
  `
};
// 问题:每次切换都会销毁和重建组件,开销大

// ✅ 好:使用v-show保持DOM
const GoodToggle = {
  data() {
    return {
      show: true
    };
  },
  template: `
    <div>
      <button @click="show = !show">Toggle</button>
      <div v-show="show">
        <ExpensiveComponent />  <!-- 复杂组件 -->
      </div>
    </div>
  `
};
// 优势:只切换display样式,不销毁DOM,性能好

// 💡 选择建议:
// - 频繁切换:使用v-show(避免重复创建/销毁)
// - 很少切换:使用v-if(避免初始渲染开销)

常见陷阱

陷阱1:在循环中使用index作为key
javascript 复制代码
// ❌ 错误示例
const BadList = {
  data() {
    return {
      users: [
        { name: 'Alice', age: 25 },
        { name: 'Bob', age: 30 },
        { name: 'Charlie', age: 35 }
      ]
    };
  },
  methods: {
    removeUser(index) {
      this.users.splice(index, 1);
    }
  },
  template: `
    <div>
      <div v-for="(user, index) in users" :key="index">
        <input v-model="user.name" />
        <button @click="removeUser(index)">删除</button>
      </div>
    </div>
  `
};

// 问题:删除中间元素时,后面元素的key会变化,导致不必要的更新

// ✅ 正确做法
const GoodList = {
  data() {
    return {
      users: [
        { id: 1, name: 'Alice', age: 25 },
        { id: 2, name: 'Bob', age: 30 },
        { id: 3, name: 'Charlie', age: 35 }
      ]
    };
  },
  methods: {
    removeUser(id) {
      const index = this.users.findIndex(u => u.id === id);
      this.users.splice(index, 1);
    }
  },
  template: `
    <div>
      <div v-for="user in users" :key="user.id">
        <input v-model="user.name" />
        <button @click="removeUser(user.id)">删除</button>
      </div>
    </div>
  `
};
// 使用唯一ID作为key,确保节点正确复用
陷阱2:直接修改数组导致无法检测
javascript 复制代码
// ❌ 错误示例(Vue 2)
const BadUpdate = {
  data() {
    return {
      items: ['A', 'B', 'C']
    };
  },
  methods: {
    updateItem() {
      this.items[0] = 'X';  // Vue 2无法检测到这种修改
    }
  }
};

// ✅ 正确做法(Vue 2)
const GoodUpdate2 = {
  methods: {
    updateItem() {
      this.$set(this.items, 0, 'X');  // 使用$set
      // 或
      this.items.splice(0, 1, 'X');   // 使用splice
    }
  }
};

// ✅ Vue 3不存在这个问题(Proxy可以检测)
const GoodUpdate3 = {
  methods: {
    updateItem() {
      this.items[0] = 'X';  // Vue 3可以正常检测
    }
  }
};
陷阱3:过度使用虚拟DOM
javascript 复制代码
// ❌ 坏:简单场景过度使用框架
const BadSimple = {
  data() {
    return {
      count: 0
    };
  },
  template: `
    <div>
      <span>{{ count }}</span>
      <button @click="count++">+1</button>
    </div>
  `
};
// 问题:简单的计数器不需要虚拟DOM的开销

// ✅ 好:简单场景直接操作DOM
const goodSimple = () => {
  let count = 0;
  const span = document.getElementById('count');
  const button = document.getElementById('btn');
  
  button.addEventListener('click', () => {
    count++;
    span.textContent = count;  // 直接更新DOM更快
  });
};

// 💡 选择建议:
// - 简单场景:直接操作DOM
// - 复杂场景:使用虚拟DOM(状态管理、组件化)

性能优化建议

优化1:使用v-once渲染静态内容
javascript 复制代码
// 对于不会变化的内容,使用v-once避免重复渲染
const OptimizedStatic = {
  template: `
    <div>
      <header v-once>
        <h1>网站标题</h1>
        <p>这是静态内容,永远不会变化</p>
      </header>
      <main>
        <p>{{ dynamicContent }}</p>
      </main>
    </div>
  `
};
// v-once标记的内容只渲染一次,后续更新会跳过
优化2:使用v-memo缓存子树(Vue 3.2+)
javascript 复制代码
/**
 * ========================================
 * 指令:v-memo
 * ========================================
 * 
 * 【作用】
 * 缓存模板的子树,只有依赖项变化时才重新渲染
 * 
 * 【使用场景】
 * - 大列表中的项(依赖项是item数据)
 * - 复杂组件(依赖项是props)
 * 
 * 【语法】
 * v-memo="[依赖项1, 依赖项2, ...]"
 */

const OptimizedList = {
  data() {
    return {
      items: [
        { id: 1, name: 'Item 1', selected: false },
        { id: 2, name: 'Item 2', selected: false },
        // ... 1000项
      ]
    };
  },
  template: `
    <div>
      <div
        v-for="item in items"
        :key="item.id"
        v-memo="[item.selected]"
      >
        <span>{{ item.name }}</span>
        <input type="checkbox" v-model="item.selected" />
      </div>
    </div>
  `
};
// v-memo会缓存每一项,只有item.selected变化时才重新渲染该项
// 其他项的更新不会影响已缓存的项
优化3:合理使用computed缓存
javascript 复制代码
// ❌ 坏:在模板中使用方法(每次渲染都执行)
const BadComputed = {
  data() {
    return {
      items: [/* 大量数据 */]
    };
  },
  methods: {
    filterItems() {
      // 复杂的过滤逻辑
      return this.items.filter(item => item.active);
    }
  },
  template: `
    <div>
      <div v-for="item in filterItems()" :key="item.id">
        {{ item.name }}
      </div>
    </div>
  `
};
// 问题:每次组件重新渲染都会执行filterItems()

// ✅ 好:使用computed缓存结果
const GoodComputed = {
  data() {
    return {
      items: [/* 大量数据 */]
    };
  },
  computed: {
    filteredItems() {
      // 只有items变化时才重新计算
      return this.items.filter(item => item.active);
    }
  },
  template: `
    <div>
      <div v-for="item in filteredItems" :key="item.id">
        {{ item.name }}
      </div>
    </div>
  `
};
// computed会缓存结果,依赖不变时直接返回缓存值

实践练习

练习1:实现简易虚拟DOM(难度:中等)

需求描述

实现一个简易的虚拟DOM系统,包含以下功能:

  1. h函数:创建虚拟节点
  2. mount函数:将虚拟节点渲染为真实DOM
  3. patch函数:对比并更新虚拟节点

功能点

  • 支持元素节点和文本节点
  • 支持props(class、style、事件)
  • 支持children(字符串或数组)
  • 实现简单的Diff算法

实现提示

  • h函数返回VNode对象
  • mount函数递归创建DOM元素
  • patch函数对比type、props、children
  • 处理边界情况(null、undefined)

参考答案

javascript 复制代码
// 1. 创建虚拟节点
function h(type, props, children) {
  return {
    type,
    props: props || {},
    children: children || [],
    key: props?.key || null,
    el: null
  };
}

// 2. 挂载虚拟节点
function mount(vnode, container) {
  // 创建元素
  const el = document.createElement(vnode.type);
  vnode.el = el;
  
  // 处理props
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key];
      
      if (key === 'key') {
        continue; // key不是DOM属性
      } else if (key.startsWith('on')) {
        // 事件监听器
        const eventName = key.slice(2).toLowerCase();
        el.addEventListener(eventName, value);
      } else if (key === 'class') {
        el.className = value;
      } else if (key === 'style') {
        Object.assign(el.style, value);
      } else {
        el.setAttribute(key, value);
      }
    }
  }
  
  // 处理children
  if (typeof vnode.children === 'string') {
    el.textContent = vnode.children;
  } else if (Array.isArray(vnode.children)) {
    vnode.children.forEach(child => {
      if (typeof child === 'string') {
        el.appendChild(document.createTextNode(child));
      } else {
        mount(child, el);
      }
    });
  }
  
  // 挂载到容器
  container.appendChild(el);
}

// 3. 更新虚拟节点
function patch(oldVNode, newVNode) {
  // 类型不同,直接替换
  if (oldVNode.type !== newVNode.type) {
    const parent = oldVNode.el.parentNode;
    const newEl = document.createElement(newVNode.type);
    newVNode.el = newEl;
    mount(newVNode, parent);
    parent.removeChild(oldVNode.el);
    return;
  }
  
  // 复用DOM元素
  const el = newVNode.el = oldVNode.el;
  
  // 更新props
  const oldProps = oldVNode.props || {};
  const newProps = newVNode.props || {};
  
  // 添加或更新新属性
  for (const key in newProps) {
    if (key === 'key') continue;
    
    const oldValue = oldProps[key];
    const newValue = newProps[key];
    
    if (oldValue !== newValue) {
      if (key.startsWith('on')) {
        const eventName = key.slice(2).toLowerCase();
        if (oldValue) {
          el.removeEventListener(eventName, oldValue);
        }
        el.addEventListener(eventName, newValue);
      } else if (key === 'class') {
        el.className = newValue;
      } else if (key === 'style') {
        Object.assign(el.style, newValue);
      } else {
        el.setAttribute(key, newValue);
      }
    }
  }
  
  // 移除旧属性
  for (const key in oldProps) {
    if (key === 'key') continue;
    if (!(key in newProps)) {
      if (key.startsWith('on')) {
        const eventName = key.slice(2).toLowerCase();
        el.removeEventListener(eventName, oldProps[key]);
      } else {
        el.removeAttribute(key);
      }
    }
  }
  
  // 更新children
  const oldChildren = oldVNode.children;
  const newChildren = newVNode.children;
  
  if (typeof newChildren === 'string') {
    if (typeof oldChildren === 'string') {
      if (oldChildren !== newChildren) {
        el.textContent = newChildren;
      }
    } else {
      el.textContent = newChildren;
    }
  } else if (Array.isArray(newChildren)) {
    if (typeof oldChildren === 'string') {
      el.textContent = '';
      newChildren.forEach(child => mount(child, el));
    } else if (Array.isArray(oldChildren)) {
      patchChildren(el, oldChildren, newChildren);
    }
  }
}

// 4. 更新子节点(简化版)
function patchChildren(parent, oldChildren, newChildren) {
  const commonLength = Math.min(oldChildren.length, newChildren.length);
  
  // 对比公共部分
  for (let i = 0; i < commonLength; i++) {
    patch(oldChildren[i], newChildren[i]);
  }
  
  // 添加新节点
  if (newChildren.length > oldChildren.length) {
    newChildren.slice(commonLength).forEach(child => {
      mount(child, parent);
    });
  }
  
  // 删除旧节点
  if (oldChildren.length > newChildren.length) {
    oldChildren.slice(commonLength).forEach(child => {
      parent.removeChild(child.el);
    });
  }
}

// 测试代码
const vnode1 = h('div', { class: 'container' }, [
  h('h1', null, 'Hello'),
  h('p', null, 'World')
]);

const container = document.getElementById('app');
mount(vnode1, container);

// 2秒后更新
setTimeout(() => {
  const vnode2 = h('div', { class: 'container' }, [
    h('h1', null, 'Hello'),
    h('p', null, 'Vue'),  // 文本变化
    h('span', null, 'New')  // 新增节点
  ]);
  
  patch(vnode1, vnode2);
}, 2000);

答案解析

关键实现点:

  1. h函数:创建VNode对象,保存type、props、children
  2. mount函数:递归创建DOM,处理props和children
  3. patch函数:对比type,复用DOM,更新props和children
  4. patchChildren函数:简化版Diff,逐个对比子节点

边界情况处理:

  • 文本节点和元素节点的区分
  • props的添加、更新、删除
  • children的不同类型(字符串、数组)
  • 事件监听器的移除和添加

练习2:实现带key的Diff算法(难度:困难)

需求描述

优化上一个练习的Diff算法,支持key属性,实现更高效的列表更新。

功能点

  • 使用key标识节点
  • 复用相同key的节点
  • 最小化DOM操作(移动而非重建)
  • 处理新增、删除、移动节点

实现提示

  • 建立key到节点的映射
  • 先处理相同key的节点(patch)
  • 再处理新增节点(mount)
  • 最后删除多余节点(remove)

参考答案

javascript 复制代码
/**
 * 带key的子节点Diff算法
 * 使用双端比较 + key映射的方式
 */
function patchChildrenWithKey(parent, oldChildren, newChildren) {
  let oldStartIdx = 0;
  let oldEndIdx = oldChildren.length - 1;
  let newStartIdx = 0;
  let newEndIdx = newChildren.length - 1;
  
  let oldStartVNode = oldChildren[0];
  let oldEndVNode = oldChildren[oldEndIdx];
  let newStartVNode = newChildren[0];
  let newEndVNode = newChildren[newEndIdx];
  
  // 建立旧节点的key映射
  const oldKeyToIdx = {};
  for (let i = 0; i < oldChildren.length; i++) {
    const key = oldChildren[i].key;
    if (key !== null) {
      oldKeyToIdx[key] = i;
    }
  }
  
  // 双端比较
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVNode === null) {
      // 节点已经被移动,跳过
      oldStartVNode = oldChildren[++oldStartIdx];
    } else if (oldEndVNode === null) {
      oldEndVNode = oldChildren[--oldEndIdx];
    }
    // 情况1:新旧头节点相同
    else if (isSameVNode(oldStartVNode, newStartVNode)) {
      patch(oldStartVNode, newStartVNode);
      oldStartVNode = oldChildren[++oldStartIdx];
      newStartVNode = newChildren[++newStartIdx];
    }
    // 情况2:新旧尾节点相同
    else if (isSameVNode(oldEndVNode, newEndVNode)) {
      patch(oldEndVNode, newEndVNode);
      oldEndVNode = oldChildren[--oldEndIdx];
      newEndVNode = newChildren[--newEndIdx];
    }
    // 情况3:旧头节点与新尾节点相同(节点右移)
    else if (isSameVNode(oldStartVNode, newEndVNode)) {
      patch(oldStartVNode, newEndVNode);
      // 将节点移动到末尾
      parent.insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling);
      oldStartVNode = oldChildren[++oldStartIdx];
      newEndVNode = newChildren[--newEndIdx];
    }
    // 情况4:旧尾节点与新头节点相同(节点左移)
    else if (isSameVNode(oldEndVNode, newStartVNode)) {
      patch(oldEndVNode, newStartVNode);
      // 将节点移动到开头
      parent.insertBefore(oldEndVNode.el, oldStartVNode.el);
      oldEndVNode = oldChildren[--oldEndIdx];
      newStartVNode = newChildren[++newStartIdx];
    }
    // 情况5:使用key查找
    else {
      const idxInOld = oldKeyToIdx[newStartVNode.key];
      
      if (idxInOld === undefined) {
        // 新节点,需要创建
        mount(newStartVNode, parent);
        parent.insertBefore(newStartVNode.el, oldStartVNode.el);
      } else {
        // 找到相同key的旧节点
        const vnodeToMove = oldChildren[idxInOld];
        patch(vnodeToMove, newStartVNode);
        // 将节点移动到正确位置
        parent.insertBefore(vnodeToMove.el, oldStartVNode.el);
        // 标记为已处理
        oldChildren[idxInOld] = null;
      }
      
      newStartVNode = newChildren[++newStartIdx];
    }
  }
  
  // 添加剩余的新节点
  if (newStartIdx <= newEndIdx) {
    const before = newChildren[newEndIdx + 1]?.el || null;
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      mount(newChildren[i], parent);
      parent.insertBefore(newChildren[i].el, before);
    }
  }
  
  // 删除剩余的旧节点
  if (oldStartIdx <= oldEndIdx) {
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      if (oldChildren[i]) {
        parent.removeChild(oldChildren[i].el);
      }
    }
  }
}

/**
 * 判断是否为相同节点
 */
function isSameVNode(vnode1, vnode2) {
  return vnode1.type === vnode2.type && vnode1.key === vnode2.key;
}

// 测试代码
const oldVNodes = [
  h('li', { key: 'a' }, 'A'),
  h('li', { key: 'b' }, 'B'),
  h('li', { key: 'c' }, 'C'),
  h('li', { key: 'd' }, 'D')
];

const newVNodes = [
  h('li', { key: 'a' }, 'A'),
  h('li', { key: 'c' }, 'C'),
  h('li', { key: 'b' }, 'B'),
  h('li', { key: 'e' }, 'E'),  // 新增
  h('li', { key: 'd' }, 'D')
];

// 挂载旧节点
const ul = document.createElement('ul');
document.body.appendChild(ul);
oldVNodes.forEach(vnode => mount(vnode, ul));

// 更新为新节点
setTimeout(() => {
  patchChildrenWithKey(ul, oldVNodes, newVNodes);
  // 结果:B和C交换位置,E插入,只进行了必要的DOM操作
}, 2000);

答案解析

双端比较算法

  1. 四种快速匹配

    • 新旧头节点相同:直接patch
    • 新旧尾节点相同:直接patch
    • 旧头新尾相同:patch + 右移
    • 旧尾新头相同:patch + 左移
  2. key映射查找

    • 快速匹配失败时,使用key查找旧节点
    • 找到则移动,找不到则创建
  3. 收尾处理

    • 添加剩余的新节点
    • 删除剩余的旧节点

性能优化

  • 使用key避免不必要的DOM创建
  • 优先移动节点而非重建
  • 双端比较减少查找次数

进阶阅读

官方文档

深度文章

源码阅读

性能分析工具

下一步

恭喜你完成了虚拟DOM与Diff算法的学习!

你现在已经理解了:

  • ✅ 虚拟DOM的本质和优势
  • ✅ VNode的数据结构
  • ✅ Diff算法的核心思想
  • ✅ key的作用和重要性
  • ✅ Vue 3的性能优化策略

下一章我们将学习Vue组件渲染流程,深入理解从模板到页面的完整过程,包括模板编译、组件初始化、更新和卸载的全流程。

推荐学习路径

  1. 先学习Vue响应式系统源码解析(理解数据变化如何触发更新)
  2. 再学习本章(理解更新如何高效渲染)
  3. 最后学习Vue组件渲染流程(理解完整的渲染链路)

相关推荐
闻缺陷则喜何志丹2 小时前
P8153 「PMOI-5」送分题/Yet Another Easy Strings Merging|普及+
c++·数学·算法·洛谷
tankeven2 小时前
HJ102 字符统计
c++·算法
2301_816997882 小时前
Webpack基础
前端·webpack·node.js
yuki_uix2 小时前
WebSocket 连上了,然后呢?聊聊实时数据的"后半场"
前端·websocket
清粥油条可乐炸鸡2 小时前
tailwind-merge的基本使用
前端
升讯威在线客服系统2 小时前
从 GC 抖动到稳定低延迟:在升讯威客服系统中实践 Span 与 Memory 的高性能优化
java·javascript·python·算法·性能优化·php·swift
wuhen_n2 小时前
reactive 工具函数集
前端·javascript·vue.js
wuhen_n2 小时前
effect的调度与清理:深入Vue3响应式系统的进阶特性
前端·javascript·vue.js
yinmaisoft2 小时前
开箱即用!国产化全兼容,信创生态适配 + 高效开发
前端·低代码·开发工具