6

背景

Vue在2.0版本引入了虚拟DOM。其虚拟DOM算法是基于snabbdom算法所做的修改。参看https://github.com/vuejs/vue/blob/dev/src/core/vdom/patch.js注释部分。要想了解Vue,必须了解虚拟DOM,本篇文章主要介绍了什么是虚拟DOM,为什么用虚拟DOM以及其具体实现。

一、什么是虚拟DOM

用JavaScript模拟DOM树形成虚拟DOM树,如下面的html结构

<ul style="color:#000">
    <li>苹果</li>
    <li>香蕉</li>
    <li>橙子</li>
</ul>

可以使用如下JS表示

{
    sel: 'ul',
    data: { style: {color: '#000'}}, // 节点属性及绑定事件等
    children: [ // 子节点
        {sel: 'li', text: '苹果'},
        {sel: 'li', text: '香蕉'},
        {sel: 'li', text: '橙子'}
    ]
}

二、为什么要用虚拟DOM

因为对DOM的直接操作是非常慢而且低效的。浏览器的渲染流程包括解析html以构建dom树->构建render树->布局render树->绘制render树,而每一次DOM改变从构建render树到布局到渲染都要重来。参考文档

而虚拟DOM的优势就是:1.开发者不再关心DOM而只关心数据,提升开发效率。2.保证最小化的DOM操作,使执行效率得到提升。

虚拟DOM的优势并不在于它操作DOM比较快,而是能够通过虚拟DOM的比较,最小化真实DOM操作,参考文档

三、虚拟DOM的实现

实现虚拟DOM包含以下三个步骤

  1. 用JavaScript模拟DOM树形成虚拟DOM树
  2. 当组件状态发生更新时,比较新旧虚拟DOM树
  3. 将差异应用到真正的DOM上

3.1 用JavaScript模拟DOM树形成虚拟DOM树

虚拟DOM对象包含以下属性:

  • sel:选择器
  • data:绑定的数据(attribute/props/eventlistner/class/dataset/hook)
  • children:子节点数组
  • text:当前text节点内容
  • elm: 对真实dom element的引用
  • key:用于优化DOM操作

参考https://github.com/snabbdom/snabbdom/blob/master/src/tovnode.ts

3.2 当组件状态发生更新时,比较新旧虚拟DOM树

给定任意两棵树,找到最少的转换步骤。但是标准的的Diff算法复杂度需要O(n^3).

这显然无法满足性能的要求,考虑到前端操作的情况--我们很少跨级别的修改节点,通常是修改节点的属性、调整子节点的顺序、添加子节点等。当比较虚拟DOM树的时候,如果发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。这样只需要对树进行一次遍历,便能完成整个DOM树的比较。

虚拟DOM在比较时只比较同层次节点,其复杂度降低到了O(n). 而且比较时只比较其key和sel是否相同,相同即为相同节点。

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

例子:下图节点从左图变为右图

虚拟DOM的做法是

A.destroy(); 
A = new A(); 
A.append(new B()); 
A.append(new C()); 
D.append(A);

而不是

A.parent.remove(A);
D.append(A);
3.3 将差异应用到真正的DOM上
  1. 如果旧节点不在,则将新节点插入
  2. 如果新节点不存在,则将旧节点删除
  3. 如果新旧相同(key和sel相同):
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    ...
    const elm = vnode.elm = (oldVnode.elm as Node);
    let oldCh = oldVnode.children;
    let ch = vnode.children;
    if (oldVnode === vnode) return; // 都是undefined
    ...
    if (isUndef(vnode.text)) { // 新节点不是textNode
      if (isDef(oldCh) && isDef(ch)) {
        // 子节点都存在,updateChildren对子节点进行diff
        if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
      } else if (isDef(ch)) {
         // 旧节点没有子节点,且新节点有子节点。将新节点的子节点添加进来
        if (isDef(oldVnode.text)) api.setTextContent(elm, '');
        addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        // 新节点没有子节点,且旧节点有子节点。 删除旧节点的子节点
        removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
      } else if (isDef(oldVnode.text)) {
         // 新旧节点都没有子节点。更新text
        api.setTextContent(elm, '');
      }
    } else if (oldVnode.text !== vnode.text) { 
      // 新节点是textNode且新旧不一致
      api.setTextContent(elm, vnode.text as string);
    }
    ...
  }

四、举个例子

如果两个元素相同(key和sel),则判断其children,过程中维护四个变量

  • oldStartIdx => 旧头索引
  • oldEndIdx => 旧尾索引
  • newStartIdx => 新头索引
  • newEndIdx => 新尾索引

例如下图中children由ABCDEF -> ADGCEF,其中假设其sel相同且都设置有key,A的key为A,B的key为B,依次类推

循环判断如下:

  • step1:比较首元素(oldStart/newStart),相同则后移继续,否则step2
  • step2:比较尾元素(oldEnd/newEnd) ,相同则前移继续,否则step3
  • step3:比较首尾元素(oldStart/newEnd) ,相同则移动元素并继续,否则step4
  • step4:比较尾首元素(oldEnd/newStart) ,相同则移动元素并继续,否则step5
  • step5:判断newStart在旧节点中是否存在,存在则移动,否则新增
  • 最后:删除多余的旧节点或插入多余的新节点

参看源码https://github.com/snabbdom/snabbdom/blob/master/src/snabbdom.ts#L179

为什么维护四个变量?有什么优势?两个变量是否可以?此处留个疑问。
第一步

oldStart === newStart,则执行上面3.3. 且oldStartIdx++, newStartIdx++.

第二步

oldEnd === newEnd,则执行上面3.3. 且oldEndIdx--, newEndIdx--.

第三步

同上,oldEnd === newEnd,则执行上面3.3. 且oldEndIdx--, newEndIdx--.

第四步

  1. oldStart !== newStart
  2. oldEnd !== newEnd
  3. oldStart !== newEnd
  4. oldEnd === newStart

oldEnd === newStart,将oldEnd插入到oldStart之前,并执行上面3.3. 且oldEndIdx--, newStartIdx++.

第五步

首尾元素均不相同!判断newStart在旧元素中是否存在,存在则移动,否则将新元素插入

oldKeyToIdx = [B, C] // 从oldStartIdx到oldEndIdx的所有元素
G in [B, C] ? NO!

将newStart插入到oldStart之前,并执行上面3.3.且newStartIdx++.

第六步

同上。H in [B, C] ? NO! 将newStart插入到oldStart之前,并执行3.3.且newStartIdx++.

第七步

新节点遍历完成。跳出循环,依次删除B和C。结束

至此,循环遍历结束。现在回答上面的问题,为什么维护四个变量?有什么优势?两个变量是否可以?两个变量当然是可以的,四个变量的优势在于:四个变量可以更好的应对插入的场景。例如:

  • ABCDEF -> GABCDEF的场景
  • ABCDEF -> BCDEFA的场景

参考文档


深蓝一人
1.6k 声望65 粉丝

暂时没有个人简介


下一篇 »
vue实现梳理