# 虚拟 DOM

关于虚拟 DOM 有非常好的文章,强烈推荐!


注:这篇文章不会完整的分析虚拟 DOM,也没有专门地对 Vue 中的虚拟 DOM 进行分析,这篇文章的主要目的是讲解虚拟 DOM 中的关键点。

在模板编译部分,最终我们会得到 render() 函数,执行后返回的是 VNode 树,通过这颗树渲染页面。

# 终极问题

首先通过回答终极问题,来了解虚拟 DOM。

# 是什么

通过描述状态和 DOM 之间的映射关系,将数据渲染为视图。比如:

<div class="box"></div>

可以描述为:

{
  tag: 'div',
  attrs: {
    class: 'box'
  }
}

然后通过当前平台的渲染 API,就可以渲染出页面。

# 为什么

那么为什么要使用虚拟 DOM 呢?

  • 一方面,因为操作 JavaScript 对象比操作 DOM 要快得多。而当涉及频繁的 DOM 操作时,通过浏览器的 API 来操作 DOM 就可能会导致性能问题。
  • 另一方面,虚拟 DOM 是分层设计,它抽象了渲染的过程,使得框架可以跨平台运行,这是虚拟 DOM 的最大优点

# VNode

JavaScript 对象,用于描述状态和 DOM 之间的关系,形如:

{
  tag: 'div',
  attrs: {
    class: 'box'
  },
  children: []
}

# h()

h() 函数用于创建 VNode() 节点:

function h(tag, data, children) {
  return {
    tag, data, children
  }
}

# mount()

在得到 VNode 节点后,需要做两件事:

  • 创建对应的 DOM 节点
  • 挂载到指定的父节点下
// 原生 DOM 节点
function mountElement(vnode, container) {
  const element = document.createElement(vnode.tag)
  container.appendChild(element)
}

这对平台原生的 DOM 节点来说很简单,但是组件该怎么挂载?

# 组件挂载

每个组件都有 render() 函数,而 render() 函数可以返回 VNode 对象:

function mountComponent(vnode, container) {
  const instance = new node.tag()
  const instance.vnode = instance.render()

  mountElement(instance.vnode, container)
}

# Fragment

假设当前页面的模板如下:

<h1></h1>
<comp-main></comp-main>
<footer></footer>

对应的 comp-main 组件的模板如下:

<div>
  <h2></h2>
  <p></p>
</div>

最终渲染后的 html 就是:

<h1></h1>
<div>
  <h2></h2>
  <p></p>
</div>
<footer></footer>

最外层的 div 可能是不需要的,但是对组件而言必须要有根标签。Fragment 组件表示只挂载子孙节点,而 Fragment 标签不会被挂载。

所以把 comp-main 组件的模板修改后:

<Fragment>
  <h2></h2>
  <p></p>
</Fragment>

最终得到的 html 如下:

<h1></h1>

<h2></h2>
<p></p>

<footer></footer>

# Portal

Portal 组件用于将子节点渲染到父组件以外的节点,当我们在写弹层组件时,可能会用到:

以点击按钮后弹出弹层为例,如果逻辑很复杂,我们可能将按钮和弹层封装在一个组件中,它的模板如下:

<template>
  <div class="comp">
    <button></button>
    <dialog></dialog>
  </div>
<template>

在这个例子中,dialog 组件的样式会受限于 .comp。对于 dialog 组件来说,我们期望它是在 body 节点中的最后一个子元素,而这就是 Portal 组件的作用 - 将子孙节点渲染到指定的节点下

<template>
  <div class="comp">
    <button></button>
    <Portal target="body">
      <dialog></dialog>
    </Portal>
  </div>
<template>

# patch()

挂载完成后,修改数据就需要更新视图。

最简单的方式,可以从根节点开始全部基于新数据重新挂载,这很容易办到。

但是一般情况下,修改的数据可能只会导致部分需要更新,所以全部重新挂载显然是没必要的,这就需要进行所谓的 diff:

  • 判断前、后 tag 的是否相同
  • 比对前、后 vnode 的属性变化,进行更新
  • 更新组件的视图

# 组件的更新

组件更新时,本质上是执行组件的 render() 函数,render() 函数会根据组件的当前状态生成新的 VNode 节点用于 patch(),可以在 mountComponent() 时,给组件实例动态添加 update() 方法:

function mountComponent(vnode, container) {
  const instance = new node.tag()

  instance.update = function() {
    if (instance._mounted) {
      const prevVnode = instance.vnode
      const vnode = instance.vnode = instance.render()
      patch(vnode, prevVnode)
    } else {
      const instance.vnode = instance.render()
      instance._mounted = true
      mountElement(instance.vnode, container)
    }
  }
}

在对组件进行 patch() 时,只需要执行组件的 update() 方法即可。

# key

我们肯定希望尽可能的复用 DOM 元素,而在处理列表时经常会有交换位置的情况,所以上面的方法还有优化的空间 - 通过 keyVNode 对象添加唯一标识。

# diff

在有了 key 之后,就需要对节点的子元素进行 diff 算法,找到列表更新前、后的同一节点进行 patch(),以及交换位置操作。

需要强调的是所有交换位置的操作,都需要针对旧节点元素

# 常规操作 - 双层循环

最容易想到的方式必然是双层循环:

function diff(children, prevChildren, container) {
  for (let i = 0; i < children.length; i++) {
    const vnode = children[i]

    for (let j = 0; j < prevChildren.length; j++) {
      const prevVnode = prevChildren[j]

      if (vnode.key === prevVnode.key) {
        patch(vnode, prevVnode)

        if (i < children.length - 1 && i !== j) {
          const refNode = children[i + 1].$el
          container.insertBefore(prevVnode.$el, refNode)
        }

        break
      }
    }
  }
}

# 递增索引 - 双层循环

上面的双层循环中,其实有些节点是不需要移动的,举个例子:

prev children:  li-a  li-b  li-c  li-d

new  children:  li-c  li-a  li-d  li-b

该例子中,实际上只需要两个移动操作即可:

  • 将 li-a 插入到 li-d 前
  • 将 li-b 插入到末尾

先看一下什么情况下不需要移动:

prev children:  li-a  li-b  li-c  li-d

new  children:  li-a  li-b  li-c  li-d

对应的索引是 0 -> 1 -> 2 -> 3 递增的,所以只需要满足寻找过程中的索引值不是递增的,则说明该节点需要移动。

为了知道寻找过程中的索引值是否是递增的,只需要添加一个变量记录上一次的索引:

function diff(children, prevChildren, container) {
  let lastIndex = 0
  for (let i = 0; i < children.length; i++) {
    const vnode = children[i]

    for (let j = 0; j < prevChildren.length; j++) {
      const prevVnode = prevChildren[j]

      // 不是递增
      if (j < lastIndex) {
        // 插入到两个节点中间
        const refNode = children[i - 1].el.nextSibling
        container.insertBefore(prevVnode.el, refNode)
      } else {
        lastIndex = j
      }
    }
  }
}

# 双端对比

递增索引方式可以避免一些不必要的移动,但是在某些情况下还有提升的空间:

prev children:  li-a  li-b  li-c  li-d

new  children:  li-d  li-a  li-b  li-c

上例中,最好的方法应该是直接吧 li-d 插入到 li-a 前,但是在递增索引方式中居然要移动 3 次。

双端对比的思路如图:

双端对比

详情请看文章

# ref