技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

虚拟 DOM 到底是什么?

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/95-虚拟 DOM 到底是什么?(长文建议收藏)
本文目录11 个章节
  1. 一、虚拟 DOM 是什么
  2. 二、虚拟 DOM、JSX 和模板的关系
  3. 三、从 h 函数说起
  4. 四、把 vnode 渲染为真实 DOM
  5. 五、组件状态改变后为什么需要 Diff
  6. 六、Diff 算法的演进
  7. 七、Vue 3 的 Diff 和编译器优化
  8. 八、虚拟 DOM 真的一定更快吗
  9. 九、手写一个状态更新示例
  10. 总结
  11. 参考资料

虚拟 DOM 到底是什么?

原文从 JSX、h 函数、虚拟 DOM 渲染、Diff,到 virtual-dom、kivi.js 和 snabbdom 的算法演进,内容跨度很大。本文尽量保留原来的学习顺序和手写示例,同时修复 chidren 拼写、属性删除、空节点、文本节点和性能结论等问题,并补充 Vue 2 与 Vue 3 当前的 renderer 优化。

一、虚拟 DOM 是什么

虚拟 DOM(Virtual DOM,VDOM)是用 JavaScript 数据结构表达 UI 的一种中间表示。例如,一个教学版 vnode 可以写成:

const vnode = {
  type: 'div',
  props: {
    id: 'app',
  },
  children: [
    {
      type: 'p',
      props: {
        class: 'text',
      },
      children: ['hello world!'],
    },
  ],
}

它表达了下面的结构:

<div id="app">
  <p class="text">hello world!</p>
</div>

typepropschildren 只是教学模型。不同框架的 vnode 内部字段不同:

  • Vue 2 常见字段是 tagdatachildrentextelmkey
  • Vue 3 常见字段是 typepropschildrenkeypatchFlagshapeFlag
  • React element 通常包含 typekeyrefprops 等字段;
  • 这些对象还可能携带组件、事件、Fragment、注释、指令和 renderer 所需的内部信息。

因此,虚拟 DOM 不是“固定格式的三个属性对象”,也不是浏览器原生 DOM 的完整复制品,而是框架用来描述“应该渲染什么”的数据结构。

二、虚拟 DOM、JSX 和模板的关系

虚拟 DOM 和 JSX 经常一起出现,但二者不是同一个概念:

  • JSX 是一种 JavaScript 语法扩展;
  • 模板是 Vue 常用的声明式语法;
  • 编译器可以把 JSX 或模板转换成 render function;
  • render function 执行后才产生 vnode;
  • vnode 再由 renderer 挂载或更新真实目标。

React 的 JSX 通常通过 Babel 转换为 createElement;Vue 的模板通过 vue/compiler-dom 等编译器转换为 render function。Vue 也支持手写 h/createVNode 或使用 JSX,但模板并不要求 JSX。

一个教学版 JSX 配置可以这样写:

{
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      {
        "pragma": "h"
      }
    ]
  ]
}

输入:

function getVNode() {
  return (
    <div id="app">
      <p className="text">hello world!</p>
    </div>
  )
}

概念上会变成:

function getVNode() {
  return h(
    'div',
    { id: 'app' },
    h('p', { className: 'text' }, 'hello world!'),
  )
}

真实 Babel 输出可能包含 jsx runtime、开发环境 source 信息和更多辅助函数,不能把某一次编译结果当成所有项目的固定输出。

三、从 h 函数说起

h 函数的作用是创建 vnode。下面是一个可以运行的简化版本:

function h(type, props, ...children) {
  return {
    type,
    props: props || {},
    children: children
      .flat(Infinity)
      .filter(child => child !== null && child !== undefined && child !== false),
  }
}

const vnode = h(
  'div',
  { id: 'app' },
  h('p', { class: 'text' }, 'hello world!'),
)

真实的 Vue 3 h 支持组件、数组 children、插槽、key、事件和多种参数重载:

import { h } from 'vue'

const vnode = h('button', {
  class: 'primary',
  onClick: () => console.log('clicked'),
}, '提交')

这个简化 h 没有处理组件、事件、Fragment、v-if、指令和响应式,只用于理解“描述 UI”的过程。

四、把 vnode 渲染为真实 DOM

下面实现一个浏览器端教学 renderer。真实 Vue renderer 还要处理组件、注释、Fragment、事件、属性分类、SVG、指令、transition、hydration 和卸载。

function render(vnode) {
  if (typeof vnode === 'string' || typeof vnode === 'number') {
    return document.createTextNode(String(vnode))
  }

  const element = document.createElement(vnode.type)
  setProps(element, vnode.props)

  for (const child of vnode.children) {
    element.appendChild(render(child))
  }

  // 仅用于教学:记录 vnode 对应的真实节点。
  vnode.el = element
  return element
}

function setProps(element, props = {}) {
  for (const [key, value] of Object.entries(props)) {
    setProp(element, key, value)
  }
}

function setProp(element, key, value) {
  if (value == null || value === false) {
    element.removeAttribute(key === 'className' ? 'class' : key)
    return
  }

  if (key === 'className' || key === 'class') {
    element.setAttribute('class', value)
    return
  }

  if (key === 'value' && 'value' in element) {
    element.value = value
    return
  }

  if (key.startsWith('on') && typeof value === 'function') {
    element.addEventListener(key.slice(2).toLowerCase(), value)
    return
  }

  element.setAttribute(key, String(value))
}

const app = document.querySelector('#app')
app.appendChild(render(vnode))

原文中的 vlaue 拼写错误,以及用 if (!newVal) 判断属性删除,都会误伤 0false 和空字符串。实际 renderer 需要区分属性、DOM property、事件和布尔属性,不能一概使用 setAttribute

手写 vnode renderer 的运行结果(原文图片已本地化)

五、组件状态改变后为什么需要 Diff

首次渲染时,可以直接把 vnode 转成真实 DOM:

const oldVNode = h('div', {}, 'before text')
const oldElement = render(oldVNode)
document.querySelector('#app').appendChild(oldElement)

状态变化后重新得到新 vnode:

const newVNode = h('div', {}, 'after text')

如果每次都把整个旧 DOM 删除,再把新 vnode 全部渲染一遍,逻辑简单但会丢失 DOM 状态,并产生不必要的创建、删除和属性设置。于是 renderer 会比较 oldVNodenewVNode,再把必要更新提交到真实 DOM。

教学版 Diff 可以表示为补丁:

const patches = diff(oldVNode, newVNode)
patch(oldElement, patches)

现代 Vue/React 的内部实现不一定真的生成一个公开的 patches 数组;它们可能在遍历 vnode 时直接调用 host operations。这里的补丁对象是为了帮助理解“比较”和“提交”两个阶段。

旧 vnode 与新 vnode 的树形表示(原文图片已本地化)

一个最小的 Diff 模型

function diff(oldVNode, newVNode) {
  if (oldVNode == null) return { type: 'INSERT', vnode: newVNode }
  if (newVNode == null) return { type: 'REMOVE' }

  const oldText = typeof oldVNode === 'string' || typeof oldVNode === 'number'
  const newText = typeof newVNode === 'string' || typeof newVNode === 'number'

  if (oldText || newText) {
    if (oldText !== newText) {
      return { type: 'REPLACE', vnode: newVNode }
    }

    return oldVNode === newVNode
      ? null
      : { type: 'TEXT', text: String(newVNode) }
  }

  if (oldVNode.type !== newVNode.type || oldVNode.key !== newVNode.key) {
    return { type: 'REPLACE', vnode: newVNode }
  }

  return {
    type: 'UPDATE',
    props: diffProps(oldVNode.props, newVNode.props),
    children: diffChildren(oldVNode.children, newVNode.children),
  }
}

function diffProps(oldProps = {}, newProps = {}) {
  const patches = {}
  const keys = new Set([
    ...Object.keys(oldProps),
    ...Object.keys(newProps),
  ])

  for (const key of keys) {
    if (oldProps[key] !== newProps[key]) {
      patches[key] = newProps[key]
    }
  }

  return patches
}

function diffChildren(oldChildren, newChildren) {
  const length = Math.max(oldChildren.length, newChildren.length)
  return Array.from({ length }, (_, index) => {
    return diff(oldChildren[index], newChildren[index])
  })
}

这个版本按位置比较子节点,没有处理 keyed 移动,也没有处理事件监听器解绑;它只用于展示基本规则:

  1. 旧节点不存在,插入;
  2. 新节点不存在,删除;
  3. 文本变化,更新文本;
  4. 类型或 key 不同,替换;
  5. 类型和 key 相同,继续比较 props 与 children。

六、Diff 算法的演进

原文提到的 virtual-dom、kivi.js、snabbdom 和 Vue 2,展示了列表 Diff 从“按树索引比较”逐步加入 key、双端比较和移动优化的过程。

1. 深度优先遍历

最简单的树 Diff 使用先序深度优先遍历,为节点分配索引:

旧树和新树的根节点 → 索引 0
根的第一个子节点   → 索引 1
该子树的后续节点   → 继续编号
function isText(node) {
  return typeof node === 'string' || typeof node === 'number'
}

function walk(oldNode, newNode, patches, index) {
  if (oldNode == null) {
    patches[index] = { type: 'INSERT', vnode: newNode }
    return
  }

  if (newNode == null) {
    patches[index] = { type: 'REMOVE' }
    return
  }

  if (isText(oldNode) || isText(newNode)) {
    if (isText(oldNode) !== isText(newNode)) {
      patches[index] = { type: 'REPLACE', vnode: newNode }
    } else if (String(oldNode) !== String(newNode)) {
      patches[index] = { type: 'TEXT', text: String(newNode) }
    }
    return
  }

  if (oldNode.type !== newNode.type || oldNode.key !== newNode.key) {
    patches[index] = { type: 'REPLACE', vnode: newNode }
    return
  }

  const props = diffProps(oldNode.props, newNode.props)
  const children = diffChildrenByIndex(oldNode, newNode, patches, index)
  if (Object.keys(props).length || children.some(Boolean)) {
    patches[index] = { type: 'UPDATE', props, children }
  }
}

function isText(node) {
  return typeof node === 'string' || typeof node === 'number'
}

按树索引记录补丁容易实现,但节点插入、删除和移动会改变真实 DOM 的索引;工程 renderer 必须小心维护节点定位,不能把这段代码直接用于生产。

2. key 让列表可以识别身份

旧列表:

[A, B, C]

新列表:

[C, A, B]

如果只按下标比较,可能把 A 的内容改成 C、B 改成 A、C 改成 B;如果列表项是输入框或有内部状态,这种按位置复用会造成状态错位。

稳定 key 可以表达节点身份:

<li v-for="item in items" :key="item.id">
  <input v-model="item.name" />
</li>

key 不是“让节点永远不更新”,而是帮助 renderer 判断哪些节点是同一个实例、哪些节点应该移动、创建或删除。使用 index 作为 key 在会插入、删除、排序的列表中风险较高;纯追加且不保留行状态的简单列表例外较多,但业务列表仍应优先使用稳定 id。

旧列表和新列表的 key 对比示意图(原文图片已本地化)

列表新增和移动节点的示意图(原文图片已本地化)

3. virtual-dom 的排序思路

virtual-dom 早期实现会把旧子节点和新子节点按 key 建立索引,并生成插入、删除和移动操作。原文中的 sortChildren 展示了这个思路:

function keyIndex(children) {
  const keys = new Map()
  const free = []

  children.forEach((child, index) => {
    if (child.key != null) keys.set(child.key, index)
    else free.push(index)
  })

  return { keys, free }
}

如果新旧子节点都有 key,可以先匹配相同 key 的 vnode,再计算它们的相对顺序;没有 key 的节点只能按位置或剩余顺序处理。混用 keyed 和 unkeyed children 会增加歧义,Vue 官方也建议一个列表采用稳定一致的 key 策略。

4. snabbdom 与 Vue 2 的双端比较

snabbdom 的经典算法维护四个指针:

oldStart ↔ oldEnd
newStart ↔ newEnd

优先尝试:

  1. 旧头和新头;
  2. 旧尾和新尾;
  3. 旧头和新尾,节点向右移动;
  4. 旧尾和新头,节点向左移动。

四种情况都不命中时,再建立旧 key 到索引的映射,在旧列表中查找新头对应的 vnode。Vue 2 的 updateChildren 沿用了类似的双端策略,并加入组件、transition、异步组件和模块钩子等框架分支。

七、Vue 3 的 Diff 和编译器优化

Vue 3 并不只是把 virtual-dom 的 Diff 换了一个名称。模板编译器会把结构信息传给运行时:

  • 静态 vnode 可以提升到 render 函数外;
  • 动态文本、class、style 和 props 会携带 patch flag;
  • block tree 会记录动态后代;
  • keyed children 会进行 key 映射,并在必要时使用最长递增子序列(LIS)减少移动;
  • 组件的响应式依赖可以让更新范围停留在相关组件。

概念上的编译结果可能类似:

const staticNode = createElementVNode('div', null, '静态内容', -1)

function render(_ctx) {
  return createElementBlock('section', null, [
    staticNode,
    createElementVNode(
      'p',
      null,
      toDisplayString(_ctx.message),
      1, // TEXT patch flag
    ),
  ])
}

实际生成代码会因编译模式、版本和模板结构不同而变化。patch flag 是内部优化提示,不能把所有 render function 都假设成编译模板的快速路径。

八、虚拟 DOM 真的一定更快吗

不能简单说“操作真实 DOM 很慢,所以虚拟 DOM 一定更快”。更准确地说:

  • DOM 操作、布局、样式计算和绘制有真实成本;
  • vnode 创建、Diff、组件调度也有 JavaScript 成本;
  • 对一个已知的局部变化,手写精确 DOM 更新可能比完整 VDOM 流程更快;
  • 对复杂声明式 UI,VDOM 统一了渲染、组件、事件、跨平台 renderer 和更新调度;
  • Vue 3 的性能优势还来自编译器、patch flags、静态提升、block tree 和响应式更新范围;
  • 十万级 DOM 通常首先是界面设计和虚拟列表问题,不能用固定“React 30 秒、Vue 2 秒”之类的旧测试结论代表所有浏览器和版本。

“跨平台”也不是 vnode 自动变成 iOS、Android 或小程序。需要对应的 renderer 或编译/运行时适配层,把 vnode 映射到目标平台的节点和属性。

vnode 变化对应真实 DOM 更新的示意图(原文图片已本地化)

九、手写一个状态更新示例

下面不使用 JSX,避免把“组件状态”与“JSX 编译”混在一起:

class Component {
  constructor() {
    this.state = { text: 'Initialize the Component' }
    this.vnode = null
    this.el = null
  }

  render() {
    return h('div', {}, this.state.text)
  }

  mount(container) {
    this.vnode = this.render()
    this.el = render(this.vnode)
    container.appendChild(this.el)
  }

  setState(partialState) {
    this.state = { ...this.state, ...partialState }
    const nextVNode = this.render()

    // applyPatches 代表 renderer 的提交阶段,完整实现略去。
    const patches = diff(this.vnode, nextVNode)
    applyPatches(this.el, patches)

    this.vnode = nextVNode
  }
}

如果使用 Vue,响应式状态变化会触发组件渲染 effect,Vue scheduler 会批量安排更新,renderer 再执行 patch;业务代码通常不需要手动调用 diffpatch

总结

  1. 虚拟 DOM 是描述 UI 的 JavaScript 中间表示,不是固定的三字段对象;
  2. JSX、Vue 模板和 render function 都可以生成 vnode,但 JSX 不是虚拟 DOM 的必要条件;
  3. h 负责创建 vnode,renderer 负责 mount、patch 和 unmount;
  4. 基础 Diff 会比较类型、文本、属性和子节点,key 用于表达列表项身份;
  5. virtual-dom 展示了树遍历和 key 排序,snabbdom/Vue 2 使用双端比较,Vue 3 结合编译优化和 keyed Diff/LIS;
  6. VDOM 没有“天然更快”的保证,性能来自框架对更新、编译、调度和 DOM 提交的整体优化;
  7. 手写 Diff 适合学习,不能替代 Vue/React 对组件、事件、Fragment、指令、hydration 和边界情况的完整处理。

参考资料

原文作者:若川。原文关于 JSX 与 vnode、h 函数、渲染真实 DOM、Diff 补丁、virtual-dom、kivi.js、snabbdom、key 和跨平台的主线予以保留;错误的 chidren、属性删除判断、缺失节点处理、固定性能结论、LIS 复杂度和抓取图片引用已修正或本地化。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS