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

显示模式

登录
ARCHIVE DOCUMENTVUE

让虚拟 DOM 和 DOM Diff 不再成为你的绊脚石

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/97-让虚拟DOM和DOM-diff不再成为你的绊脚石
本文目录11 个章节
  1. 一、先明确:这是学习版 renderer
  2. 二、项目准备:保留旧写法,也给出当前选择
  3. 三、创建虚拟 DOM
  4. 四、把虚拟 DOM 渲染成真实 DOM
  5. 五、Diff:比较两棵描述树
  6. 六、应用更新:一个直接递归的 patch
  7. 七、完整调用示例
  8. 八、这段手写代码和 Vue 的关系
  9. 九、常见错误总结
  10. 总结
  11. 参考资料

让虚拟 DOM 和 DOM Diff 不再成为你的绊脚石

原文通过一个小型 React 脚手架项目,手写 ElementcreateElementrenderdiffpatch,帮助读者理解虚拟 DOM 的完整链路。本文保留这条实战路线,修复原代码中空节点、文本节点、子节点数量变化、属性删除、全局索引和 patch 遍历顺序等问题,并说明它与 Vue 2/3 renderer 的关系。

一、先明确:这是学习版 renderer

虚拟 DOM 的基本链路是:

JavaScript 对象描述 UI
  ↓
render/mount 创建真实节点
  ↓
状态变化,生成新的描述树
  ↓
diff 比较新旧树
  ↓
patch/renderer 提交必要的 DOM 操作

下面的代码是一个教学版、无组件、无 key 移动优化的 renderer。它不能替代 Vue/React,因为真实框架还要处理:

  • 组件实例和生命周期;
  • 响应式更新与 scheduler;
  • 事件更新、DOM property 和布尔属性;
  • Fragment、Portal/Teleport、Suspense、指令和 transition;
  • keyed children 移动、异步组件和 SSR hydration;
  • SVG、表单状态、错误边界和卸载清理。

二、项目准备:保留旧写法,也给出当前选择

原文使用 create-react-app

npx create-react-app dom-diff
cd dom-diff
npm start

这是当时常见的学习方式。对于现在的新项目,更推荐使用 Vite:

pnpm create vite dom-diff --template vanilla
cd dom-diff
pnpm install
pnpm dev

无论使用哪个脚手架,下面的 element.jsdiff.jspatch.js 都是普通 JavaScript 模块,不依赖 React 或 Vue。这里不执行构建,仅整理源码原理。

手写 dom-diff 项目目录(原文图片已本地化)

三、创建虚拟 DOM

element.js

export class Element {
  constructor(type, props = {}, children = []) {
    this.type = type
    this.props = props || {}
    this.children = children
  }
}

export function createElement(type, props = {}, children = []) {
  return new Element(type, props, children)
}

export function isElement(node) {
  return node instanceof Element
}

index.js

import { createElement } from './element.js'

const virtualDom = createElement('ul', { class: 'list' }, [
  createElement('li', { class: 'item' }, ['周杰伦']),
  createElement('li', { class: 'item' }, ['林俊杰']),
  createElement('li', { class: 'item' }, ['王力宏']),
])

console.log(virtualDom)

type 表示标签,props 表示属性,children 表示子节点。真实框架的 vnode 还会记录 key、组件类型、事件、指令和内部优化标记。

打印出的虚拟 DOM 对象(原文图片已本地化)

四、把虚拟 DOM 渲染成真实 DOM

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

  if (!isElement(vnode)) {
    throw new TypeError('未知的 vnode 类型')
  }

  const el = document.createElement(vnode.type)

  for (const [key, value] of Object.entries(vnode.props)) {
    setAttr(el, key, value)
  }

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

  vnode.el = el
  return el
}

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

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

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

  if (key === 'style' && typeof value === 'object') {
    Object.assign(node.style, value)
    return
  }

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

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

export function renderDom(el, target) {
  target.appendChild(el)
}

原文的 setAttr 只把属性转换成字符串,遇到 value、style、事件和 false 时会产生错误。教学代码至少要区分这些常见类型;生产 renderer 还要负责移除旧事件监听器,不能每次更新都无条件 addEventListener

调用:

import { createElement, render, renderDom } from './element.js'

const virtualDom = createElement('ul', { class: 'list' }, [
  createElement('li', { class: 'item' }, ['周杰伦']),
  createElement('li', { class: 'item' }, ['林俊杰']),
  createElement('li', { class: 'item' }, ['王力宏']),
])

const el = render(virtualDom)
renderDom(el, document.querySelector('#root'))

虚拟 DOM 渲染后的真实 DOM(原文图片已本地化)

五、Diff:比较两棵描述树

1. 补丁类型

export const PATCH = {
  TEXT: 'TEXT',
  PROPS: 'PROPS',
  REPLACE: 'REPLACE',
  REMOVE: 'REMOVE',
  INSERT: 'INSERT',
}

2. 属性 Diff

属性删除不能用 if (!value) 判断,因为 0false 和空字符串都是合法值。应根据 key 是否存在或值是否为 null/undefined 判断:

export 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
}

3. 基于路径的 Diff

原文使用一个全局 num 记录树索引,存在两个问题:一次 diff 后没有重置会污染下一次比较;插入/删除节点后,真实 DOM 的索引也会变化。下面用路径表示节点位置,例如 [1, 0] 表示根节点的第二个子节点的第一个子节点:

import { PATCH } from './patch-types.js'
import { Element } from './element.js'
import { diffProps } from './diff-props.js'

export default function diff(oldNode, newNode, path = [], patches = []) {
  if (oldNode == null && newNode != null) {
    patches.push({ path, type: PATCH.INSERT, vnode: newNode })
    return patches
  }

  if (newNode == null) {
    patches.push({ path, type: PATCH.REMOVE })
    return patches
  }

  const oldText = ! (oldNode instanceof Element)
  const newText = ! (newNode instanceof Element)

  if (oldText || newText) {
    if (oldText !== newText) {
      patches.push({ path, type: PATCH.REPLACE, vnode: newNode })
    } else if (String(oldNode) !== String(newNode)) {
      patches.push({ path, type: PATCH.TEXT, text: String(newNode) })
    }
    return patches
  }

  if (oldNode.type !== newNode.type) {
    patches.push({ path, type: PATCH.REPLACE, vnode: newNode })
    return patches
  }

  const props = diffProps(oldNode.props, newNode.props)
  if (Object.keys(props).length > 0) {
    patches.push({ path, type: PATCH.PROPS, props })
  }

  const length = Math.max(
    oldNode.children.length,
    newNode.children.length,
  )

  for (let index = 0; index < length; index++) {
    diff(
      oldNode.children[index],
      newNode.children[index],
      [...path, index],
      patches,
    )
  }

  return patches
}

这是按位置的 unkeyed Diff:当子节点排序时,它会复用相同位置的节点,而不会自动识别业务身份。要支持移动,需要给 vnode 增加稳定 key,并实现 keyed children 算法。

原文中的 walk 还直接访问 oldNode.type,遇到文本节点或旧节点不存在时会报错;上面的代码先处理空节点和文本节点,再读取 type

六、应用更新:一个直接递归的 patch

为了让示例足够清楚,下面不把补丁索引和实时 DOM 索引混在一起,而是使用旧 vnode、新 vnode 和 DOM 父容器递归更新。它表达的就是“比较并提交”:

import { Element, render, setAttr } from './element.js'

export function patchNode(parent, oldNode, newNode, index = 0) {
  const currentEl = parent.childNodes[index]

  if (oldNode == null && newNode != null) {
    parent.insertBefore(render(newNode), parent.childNodes[index] || null)
    return
  }

  if (newNode == null) {
    if (currentEl) parent.removeChild(currentEl)
    return
  }

  const oldText = !(oldNode instanceof Element)
  const newText = !(newNode instanceof Element)

  if (oldText || newText) {
    if (String(oldNode) !== String(newNode) && currentEl) {
      parent.replaceChild(render(newNode), currentEl)
    }
    return
  }

  if (oldNode.type !== newNode.type) {
    parent.replaceChild(render(newNode), currentEl)
    return
  }

  updateProps(currentEl, oldNode.props, newNode.props)

  const length = Math.max(
    oldNode.children.length,
    newNode.children.length,
  )

  for (let i = 0; i < length; i++) {
    patchNode(
      currentEl,
      oldNode.children[i],
      newNode.children[i],
      i,
    )
  }
}

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

  for (const key of keys) {
    if (oldProps[key] !== newProps[key]) {
      // 真实实现应先移除旧事件监听器,再绑定新监听器。
      setAttr(element, key, newProps[key])
    }
  }
}

这个 patch 支持文本、属性、替换、插入和删除,但不支持 keyed 移动、事件解绑、组件和 Fragment。它还采用按位置复用的策略,所以只适合作为入门实现。

七、完整调用示例

import { createElement, render, renderDom } from './element.js'
import diff from './diff.js'
import { patchNode } from './patch.js'

const oldTree = createElement('ul', { class: 'list' }, [
  createElement('li', { class: 'item' }, ['周杰伦']),
  createElement('li', { class: 'item' }, ['林俊杰']),
  createElement('li', { class: 'item' }, ['王力宏']),
])

const newTree = createElement('ul', { class: 'list-group' }, [
  createElement('li', { class: 'item active' }, ['七里香']),
  createElement('li', { class: 'item' }, ['一千年以后']),
  createElement('li', { class: 'item' }, ['需要人陪']),
])

const root = document.querySelector('#root')
const el = render(oldTree)
renderDom(el, root)

console.log(diff(oldTree, newTree))
patchNode(root, oldTree, newTree, 0)

如果把状态变化封装进组件,可以在 setState 中重新生成 vnode:

class Component {
  constructor() {
    this.state = { text: '初始化' }
    this.vnode = null
    this.root = null
  }

  render() {
    return createElement('div', {}, [this.state.text])
  }

  mount(root) {
    this.root = root
    this.vnode = this.render()
    renderDom(render(this.vnode), root)
  }

  setState(partial) {
    const nextState = { ...this.state, ...partial }
    const nextVNode = this.renderWith(nextState)
    patchNode(this.root, this.vnode, nextVNode, 0)
    this.state = nextState
    this.vnode = nextVNode
  }

  renderWith(state) {
    return createElement('div', {}, [state.text])
  }
}

这里的 Component 只是帮助理解状态到 vnode 的关系,缺少响应式、生命周期、事件和组件树管理。Vue 的组件更新由响应式 effect 触发,不需要业务代码手动调用这个类。

应用 Diff/patch 后的页面结果(原文图片已本地化)

八、这段手写代码和 Vue 的关系

相似之处

  1. 都用 JavaScript 数据结构描述 UI;
  2. 都需要区分首次 mount 和后续 update;
  3. 都会比较节点类型、属性、文本和子节点;
  4. 都需要处理插入、删除、替换和属性更新;
  5. keyed children 都依赖稳定 key 来识别节点身份。

Vue 2 的额外工作

Vue 2 的 patch 使用 VNode、sameVnodepatchVnodeupdateChildren,还要调用 attrs、class、style、event、directive、transition 等模块钩子;列表使用双端比较和 key 映射。

Vue 3 的额外工作

Vue 3 renderer 还结合:

  • shapeFlag 区分元素、组件、Teleport、Suspense 等;
  • patch flags 和 block tree 跳过静态内容;
  • patchKeyedChildren 使用 key 映射和移动优化;
  • 组件渲染 effect 与 scheduler 批量调度;
  • hydration、Fragment、指令、Transition 和异步组件。

所以“手写 Diff 能运行”不等于“已经实现了 Vue 的 Diff”。

九、常见错误总结

  • 把 JSX 当成虚拟 DOM 本身;
  • 把 vnode 固定写成 tag/props/children,忽略 Vue 2/3/React 的差异;
  • 文本节点、空节点也直接读取 .type
  • 只遍历旧 children,漏掉新列表多出来的节点;
  • 用 truthy 判断属性是否应该删除,误删 false/0
  • 用全局索引记录 Diff,却不在每次调用前重置;
  • patch 时先深度遍历并修改 DOM,导致后续 childNodes 索引错位;
  • 认为按位置比较就能正确处理所有列表,忽略 key 和组件状态。

总结

  1. 手写 VDOM 的最小链路是 createElement → render → diff → patch
  2. vnode 是描述对象,真实 DOM 是 renderer 的提交结果;
  3. Diff 需要先处理空节点和文本节点,再比较类型、属性和子节点;
  4. 属性删除不能用简单的 truthy 判断;
  5. 按位置 Diff 适合教学,复杂列表需要稳定 key 和移动算法;
  6. Vue 2/3 的 renderer 还包含组件、响应式、调度、Fragment、异步和 SSR 等大量能力;
  7. VDOM 没有自动性能保证,算法和真实 DOM 提交都要结合场景衡量。

参考资料

原文作者:chenhongdong。原文关于 ElementcreateElementrender、Diff、patch 和完整调用流程的内容予以保留;create-react-app 的历史背景已补充当前 Vite 选择,原代码中的空节点、子节点数量、属性删除、全局索引和 patch 遍历问题已修正或明确为教学限制。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS