首页 > 网页制作 >虚拟DOM:中间层的智慧

虚拟DOM:中间层的智慧

来源:互联网 2026-06-14 08:15:02

虚拟DOM用JS对象描述真实DOM,降低操作成本。其Diff算法采用同层比较,时间复杂度降至O(n),结合key标识节点实现最小化移动替换。虽然单个更新不如直接操作DOM快,但批量更新自动合并,提供声明式编程体验和跨平台能力。

操作真实 DOM 有多贵?

先看一段代码:

虚拟DOM:中间层的智慧

长期稳定更新的攒劲资源: >>>点此立即查看<<<

// 把一个 
的背景色改成红色 document.getElementById('box').style.backgroundColor = 'red'

你觉得这一行代码的执行成本是多少?

真相远比这复杂得多:

1. JS 引擎找到 DOM 节点
2. 修改 DOM 节点的 style 属性
3. 浏览器标记这个节点需要重新计算样式(Recalculate Style)
4. 重新布局(Layout/Reflow)—— 可能影响周围元素的位置
5. 重新绘制(Paint)—— 把新的颜色画到屏幕上
6. 合成(Composite)—— 把各层合成最终画面

注意了,修改一个属性就可能触发整个渲染流水线。那要是 1000 个属性同时改呢?如果一次操作要添加、删除、移动几百个节点,浏览器岂不要忙到冒烟?


虚拟 DOM 是什么?

虚拟 DOM 本质上就是用一个普通的 JavaScript 对象来描述一个 DOM 节点。

比如这段真实 DOM:

Hello

对应的虚拟 DOM 长这样:

{
  tag: 'div',
  props: { id: 'app', class: 'container' },
  children: [
    {
      tag: 'p',
      props: {},
      children: [
        { tag: undefined, text: 'Hello' }  // 文本节点
      ]
    }
  ]
}

Vue 里把这个 JS 对象称为 VNode(Virtual Node)


为什么不用真实 DOM,要自己造一个?

一句话总结:真实 DOM 虽然功能强大,但太重了,操作成本太高。来看这组对比:

真实 DOM虚拟 DOM (VNode)
本质C++ 实现的浏览器对象普通 JS 对象
创建成本高(创建几百个属性)低(就几个字段)
操作成本高(可能触发回流)低(只是改 JS 对象)
跨平台只能在浏览器可以渲染到不同平台
可控性浏览器说了算框架完全控制

核心思想其实很简单:把计算放在 JS 层面完成,最后只进行一次性的、最小化的真实 DOM 更新。这笔账,怎么算都值。


打个比方:装修房子

想象一下你正在重新装修一个房间,现在有两种方案可选:

方案一:直接动手(直接操作 DOM)

"把左边这面墙砸掉"    → 工人开始砸
"等等,右边那面也砸"  → 工人换位置砸
"不对,左边还是留着吧" → 工人:???

每次指令都立刻执行,改主意就得返工,工人累、工期长、成本高。

方案二:先在图纸上画(虚拟 DOM)

在图纸上画一遍 → 对比旧图纸 → 标记出所有改动 → 一次施工完成

先在纸上(JS 内存)把所有方案推演完毕,确认无误后,列出最小改动清单,让工人一次性施工。既省钱又省心。


Diff 算法:怎么找出最小改动?

现在有一个旧 VNode 树和一个新 VNode 树。怎么找出“最少改动”的路径?

如果把两棵树完全比较,时间复杂度是 O(n)。什么意思?一棵有 1000 个节点的树,你需要做 10 亿次比较,这显然不可接受。

但前端的实际情况给了我们一个关键观察:大多数 UI 更新中,跨层级的节点移动非常罕见。绝大多数改动都发生在同一层级内部。

基于这个洞见,Vue(和 React)的 diff 算法做了一个大胆的简化:只做同层比较,跳过跨层级比较。这样算法复杂度直接从 O(n) 降到了 O(n)——每个节点只比较一次,性价比极高。


同层 Diff 的三个步骤

Vue 的 diff 采用了双端比较策略。下面以子节点数组的 diff 为例,看它具体怎么操作。

假设旧子节点顺序是 [A, B, C, D],新子节点顺序是 [B, A, D, E]

步骤 1:头头比较

旧: [A, B, C, D]
     ↑
新: [B, A, D, E]
     ↑
A !== B → 不匹配,结束头头比较

步骤 2:尾尾比较

旧: [A, B, C, D]
              ↑
新: [B, A, D, E]
              ↑
D !== E → 不匹配,结束尾尾比较

步骤 3:头尾交叉比较

旧头 vs 新尾: A vs E → 不匹配
旧尾 vs 新头: D vs B → 不匹配

四个指针全部匹配失败,说明这轮变化比较复杂,需要更高的智慧。

如果设置了 key

key 的作用就是给每个 VNode 一个唯一的身份标识。有了它,diff 算法就能识别出“这个节点只是位置变了,不是被删除重建了”。

旧: [{key:'A'}, {key:'B'}, {key:'C'}, {key:'D'}]
新: [{key:'B'}, {key:'A'}, {key:'D'}, {key:'E'}]

有 key 时:
  B 在旧节点中找到 → 移动位置即可
  A 在旧节点中找到 → 移动位置即可
  D 在旧节点中找到 → 移动位置即可
  E 不在旧节点中 → 新建

无 key 时:
  可能把 B 当成了 A(因为都是第一个位置)
  → 更新 A 的内容为 B,而不是移动
  → 效率低,还可能导致状态丢失

这就是为什么在 v-for 中总是要求你给一个稳定的 key。不是框架矫情,而是它真的能让 diff 少走很多弯路。


动手实现一个迷你 VNode + Diff

VNode 创建

function createVNode(tag, props, children) {
  return { tag, props, children }
}

function h(tag, props, ...children) {
  return createVNode(tag, props, children.flat())
}

将 VNode 渲染为真实 DOM

function mount(vnode, container) {
  // 创建元素
  const el = document.createElement(vnode.tag)
  
  // 设置属性
  if (vnode.props) {
    for (const key in vnode.props) {
      el.setAttribute(key, vnode.props[key])
    }
  }
  
  // 处理子节点
  if (vnode.children) {
    vnode.children.forEach(child => {
      if (typeof child === 'string') {
        el.appendChild(document.createTextNode(child))
      } else {
        mount(child, el)  // 递归挂载
      }
    })
  }
  
  container.appendChild(el)
  vnode.el = el  // 保存对真实 DOM 的引用
}

Diff 和 Patch

function patch(oldVNode, newVNode) {
  const el = (newVNode.el = oldVNode.el)
  
  // 1. 标签不同 → 直接替换
  if (oldVNode.tag !== newVNode.tag) {
    const newEl = document.createElement(newVNode.tag)
    el.parentNode.replaceChild(newEl, el)
    mount(newVNode, el.parentNode)
    return
  }
  
  // 2. 更新属性
  // 移除旧属性
  for (const key in oldVNode.props) {
    if (!(key in newVNode.props)) {
      el.removeAttribute(key)
    }
  }
  
  // 设置新属性
  for (const key in newVNode.props) {
    if (oldVNode.props[key] !== newVNode.props[key]) {
      el.setAttribute(key, newVNode.props[key])
    }
  }
  
  // 3. 更新子节点
  const oldChildren = oldVNode.children || []
  const newChildren = newVNode.children || []
  const len = Math.max(oldChildren.length, newChildren.length)
  
  for (let i = 0; i < len; i++) {
    if (i >= oldChildren.length) {
      // 新节点,直接挂载
      mount(newChildren[i], el)
    } else if (i >= newChildren.length) {
      // 旧节点多余,删除
      el.removeChild(oldChildren[i].el)
    } else {
      // 都存在,递归 patch
      if (typeof oldChildren[i] === 'string' && typeof newChildren[i] === 'string') {
        if (oldChildren[i] !== newChildren[i]) {
          el.childNodes[i].textContent = newChildren[i]
        }
      } else {
        patch(oldChildren[i], newChildren[i])
      }
    }
  }
}

上面这个实现省略了 key 的匹配逻辑,已经很精简了,但它清晰说明了 Diff 的核心思想:同层比较,最小化 DOM 操作


虚拟 DOM vs 直接操作 DOM:到底谁更快?

这是个经典问题。说到底,没有绝对的更快,只有更合适的场景:

场景直接操作 DOM虚拟 DOM
单个更新 更快 有 diff 开销
批量更新 需要手动优化 自动合并
代码可维护性 散落各处 声明式
跨平台 仅浏览器 可渲染到原生

虚拟 DOM 从来不是为了“比直接操作 DOM 更快”而生的。它的真正价值在于提供了一种声明式的编程体验,同时保持“足够好”的性能。在绝大多数场景下,这个取舍非常明智。


总结

  1. 为什么需要虚拟 DOM:真实 DOM 操作成本高,虚拟 DOM 在 JS 层完成计算,最后一次性最少地更新真实 DOM。
  2. VNode:用 JS 对象描述 DOM 节点,创建和比较成本极低。
  3. Diff 算法:同层比较,O(n) 复杂度。双端比较 + key 优化是最核心的策略。
  4. Key 的作用:给节点唯一标识,让 Diff 能区分“移动”和“替换”。
  5. 性能本质:虚拟 DOM 是“足够快 + 足够好维护”的平衡方案。

有了虚拟 DOM,Vue 就知道“视图应该长什么样”。但视图是由组件构成的——组件是怎么创建、挂载、更新的?这将是接下来要聊的话题。

侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述

热游推荐

更多
湘ICP备14008430号-1 湘公网安备 43070302000280号
All Rights Reserved
本站为非盈利网站,不接受任何广告。本站所有软件,都由网友
上传,如有侵犯你的版权,请发邮件给xiayx666@163.com
抵制不良色情、反动、暴力游戏。注意自我保护,谨防受骗上当。
适度游戏益脑,沉迷游戏伤身。合理安排时间,享受健康生活。