深入浅出虚拟DOM

1,237 阅读7分钟

近两年前端发展速度很快,大家很喜欢谈论一些概念,例如闭包、柯里化、高阶函数等。随着 React 的流行,前端又掀起了讨论虚拟 DOM 的热潮,那么究竟什么是虚拟 DOM,为什么会出现虚拟 DOM,它能解决什么问题?本文尝试做出解答,不过在讲虚拟 DOM 之前,我们需要掌握和理解真实 DOM。

DOM

概念

DOM 的全称叫做文档对象模型(Document Object Model),它的定义如下:

文档对象模型 (DOM) 是 HTML 和 XML 文档的编程接口。它提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。

下面这张图列举了 DOM 中的一些常用接口及其继承关系。

DOM接口

每个接口都有详细的结构化描述,大家感兴趣可以查看 w3c 规范,这里以 Node 接口为例,看下其结构化描述(注:经过笔者简化处理):

interface Node : EventTarget {
  /*---------- 属性 ----------*/
  readonly attribute unsigned short nodeType; // 节点类型
  readonly attribute DOMString nodeName; // 节点名称
  readonly attribute NodeList childNodes; // 子节点列表
  readonly attribute Node? parentNode; // 父节点
  readonly attribute Node? firstChild; // 第一个子节点
  readonly attribute Node? lastChild; // 最后一个子节点
  readonly attribute Node? previousSibling; // 前一个兄弟节点
  readonly attribute Node? nextSibling; // 后一个兄弟节点
  attribute DOMString? nodeValue; // 节点值
  attribute DOMString? textContent; // 文本内容
  /*---------- 方法 ----------*/
  Node insertBefore(Node node, Node? child); // 在某个子节点之前插入
  Node appendChild(Node node); // 添加子节点
  Node replaceChild(Node node, Node child); // 替换子节点
  Node removeChild(Node child); // 删除子节点
};

可以看到,规范中给 Node 对象定义了很多属性和方法,上面列举的是最常见的部分,也是后面理解虚拟 DOM 知识点需要掌握的部分。其中节点类型总共有 12 种,不过里面有 4 种已经被废弃了,最常用的只有 3 中,剩下不常用的有 5 中,均列举如下:

// 常用类型
const unsigned short ELEMENT_NODE = 1; //  元素节点
const unsigned short TEXT_NODE = 3; // 文本节点
const unsigned short COMMENT_NODE = 8; // 注释节点
// 不常用类型
const unsigned short CDATA_SECTION_NODE = 4; // <!CDATA[[ … ]]> 节点
const unsigned short PROCESSING_INSTRUCTION_NODE = 7; // <?xml-stylesheet ... ?> 节点(XML专用)
const unsigned short DOCUMENT_NODE = 9; // document 节点
const unsigned short DOCUMENT_TYPE_NODE = 10; // <!DOCTYPE html> 节点
const unsigned short DOCUMENT_FRAGMENT_NODE = 11; // DocumentFragment 节点
// 废弃类型
const unsigned short ATTRIBUTE_NODE = 2; // 废弃
const unsigned short ENTITY_REFERENCE_NODE = 5; // 废弃
const unsigned short ENTITY_NODE = 6; // 废弃
const unsigned short NOTATION_NODE = 12; // 废弃

JS 操作 DOM

浏览器提供的一套编程 API,让开发者可以使用 JS 语言操作 DOM 节点,例如我们经常用到的:

document.getElementById()
document.createElement('p')
// 等等...

值得注意的是,虽然 JS 可以操作 DOM,但是 JS 并不是唯一可以操作 DOM 的语言,其他语言例如 python 也是可以的:

import xml.dom.minidom as m
doc = m.parse("/index.html");
p_list = doc.getElementsByTagName("para");

下面列举一些常用的 API,作为一名合格的前端,这些都需要熟练掌握。

EventTarget 相关

  • addEventListener(type, listener):添加事件监听器
  • removeEventListener(type, listener):移除事件监听器

Node 相关

  • appendChild(childNode):添加子节点
  • removeChild(childNode):删除子节点
  • replaceChild(newNode, oldNode):用新节点替换旧节点
  • insertBefore(newNode, refNode):在参照节点之前插入新的子节点

Document 相关

  • getElementById(id):根据 id 查找元素
  • querySelector(selector) :根据选择器查找元素
  • createElement(tagName):创建元素节点
  • createTextNode(str):创建文本节点

Element 相关

  • getAttribute(attrName):获取元素上指定的属性值
  • setAttribute(name, value):设置指定元素上的某个属性值
  • removeAttribute(attrName):删除元素中一个属性

虚拟 DOM

概念

虚拟 DOM 就是一个用于描述真实 DOM 结构的普通 JS 对象。

如果想要描述真实 DOM 结构,只需要三个属性即可:

  • tag:节点标签
  • props:节点属性
  • children:子节点

例如文章的 HTML 结构如下:

<div id="main">
  <h2>国庆放假安排<span style="color: red">hot</span></h2>
  <p class="article">根据办公厅通知,现将2020年国庆节、中秋节放假安排通知如下:10月1日(星期四)至8日(星期四)放假调休,共8天。9月27日(星期日)、10月10日(星期六)上班。</p>
</div>

上面的 HTML 段落标记完全可以用下面的 JS 对象来描述:

const vnode = {
  tag: 'div',
  props: { id: 'main' },
  children: [
    {
      tag: 'h2',
      children: [
        '国庆放假安排',
        {
          tag: 'span',
          props: { style: { color: 'red' } },
          children: ['hot'],
        },
      ],
    },
    {
      tag: 'p',
      props: { className: 'article' },
      children: ['根据办公厅通知,现将2020年国庆节、中秋节放假安排通知如下:10月1日(星期四)至8日(星期四)放假调休,共8天。9月27日(星期日)、10月10日(星期六)上班。'],
    },
  ],
}

也就是说,只要得到了 vnode 这个 JS 对象,完全能够通过用 JS 调用 DOM API 来生成上面的 HTML 节点。生成代码如下:

function createNode(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode)
  const { tag, props, children } = vnode
  const el = document.createElement(tag)
  for (const k in props) {
    const v = props[k]
    if (k === 'style') Object.keys(v).forEach((key) => (el.style[key] = v[key]))
    else el.setAttribute(k, v)
  }
  children.forEach((child) => el.appendChild(createNode(child)))
  return vnode.el = el
}

也就是说只需要 12 行代码就能够把一个 JS 对象完全还原为真实 DOM,这就是为什么 vnode 这种结构的对象被称为虚拟 DOM 的原因。虚拟 DOM 就好比一张建筑图纸,根据这张图纸能够搭建完整的建筑。

Diff 算法

广义来讲,所谓 diff 算法就是比较两个对象结构,找到其不同的地方。例如:

const a = { name: 'David', empty: true, age: 62, salary: 100, child: { name: 'James', age: 33, baby: { name: 'Jessica', age: 10 },  hobby: ['football', 'reading'] } }
const b = { name: 'Lucy', age: 62, java: 'good', salary: 10, child: {  name: 'Scott',  age: 35, baby: { name: 'Jessica',  age: 11, fool: 2 }, hobby: ['shopping', 'reading'] } }

简单的对象是可以用肉眼找出不同之处,但是对于复杂的对象,肉眼就无能为力了。不妨用可视化的库来看下:

const diff = require('diff-object')
diff.saveHTML(a, b)

对于虚拟 DOM 的 Diff 算法就是比较两个 vnode 对象之间的区别,且只进行同级比较,由于 vnode 对象的结构是固定的,比较起来就容易多了,流程如下:

  • 先看 tag 标签名是否一致
  • 再看 props 属性是否一致
  • 最后递归比较 children

还记得上面的国庆放假安排的文章示例吗?假如今天发表了一篇新的文章:

<div id="main">
  <h2>收购TikTok谈判陷入僵局</h2>
  <p class="article">当外界认为TikTok的收购谈判已进入尾声时,我国调整发布《禁止出口限制出口技术目录》,其中限制出口部分的“基于数据分析的个性化信息推送服务技术”条款,被媒体解读为直接针对TikTok算法技术。<span style="color: red;">【版权所有,禁止转载】</span></p>
</div>

对应的 vnode 如下:

const newVnode = {
  tag: 'div',
  props: { id: 'main' },
  children: [
    {
      tag: 'h2',
      children: ['收购TikTok谈判陷入僵局'],
    },
    {
      tag: 'p',
      props: { className: 'article' },
      children: [
        '当外界认为TikTok的收购谈判已进入尾声时,我国调整发布《禁止出口限制出口技术目录》,其中限制出口部分的“基于数据分析的个性化信息推送服务技术”条款,被媒体解读为直接针对TikTok算法技术。',
        {
          tag: 'span',
          props: { style: { color: 'red' } },
          children: ['【版权所有,禁止转载】'],
        },
      ],
    },
  ],
}

在新闻网站上,用户先浏览「国庆放假安排」这篇文章,又浏览了「收购TikTok谈判陷入僵局」,它们的 DOM 结构是类似的,为了减少创建 DOM 的开销,用 Diff 算法的目的就是找出不同之处,最大化复用节点。下面给出简化版的 Diff 算法:

// 根据对比新老虚拟节点来更新真实dom结构
function diff(v1, v2) {
  if (v1.tag !== v2.tag) return createNode(v2)  // 标签不同时创建新节点
  const el = (v2.el = v1.el) // 标签相同时复用旧节点
  patchProps(v1.props, v2.props, el)
  patchChildren(v1.children, v2.children, el)
}
// 更新属性(删除旧的,添加新的)
function patchProps(p1, p2, el) {
  for (let k1 in p1) el.removeAttribute(k1)
  for (let k2 in p2) {
    const v2 = p2[k2]
    if (k2 === 'style') Object.keys(v2).forEach((k) => (el.style[k] = v2[k]))
    else el.setAttribute(k2, v2)
  }
}
// 更新孩子节点
function patchChildren(c1, c2, el) {
  let i = 0, node, len = Math.max(c1.length, c2.length), childNodes = Array.from(el.childNodes)
  while (i < len) {
    const v1 = c1[i], v2 = c2[i], node = v1 && childNodes[i]
    if (v1 && !v2) el.removeChild(node) // 老节点存在,新节点不存在,删除
    else if (!v1 && v2) el.insertBefore(createNode(v2), node) // 老节点不存在,新节点存在,添加
    else if (v1 && v2) {  // 新老节点都不存在
      const t1 = typeof v1, t2 = typeof v2
      if (t1 !== t2) el.replaceChild(createNode(v2), node) // 替换节点
      else if (t1 === 'string') node.textContent = v2 // 更新文本
      else diff(v1, v2) // 递归 diff
    }
    i++
  }
}

真实的 Diff 算法比这个复杂很多,但核心思想是不变的,那就是最大化节点复用,减少创建 DOM 元素的开销。

源码:git clone git@github.com:keliq/vdom-in-depth.git