Vue源码解析之虚拟DOM和diff算法

665 阅读9分钟

本篇笔记来自于尚硅谷——Vue源码解析系列课程

虚拟DOM和diff算法也是vue高效的原因。

DOM如何变为虚拟DOM,属于模板编译原理范畴,本课次不研究。

介绍

diff算法可以进行精细化比对,实现最小量更新,只需要更新修改的DOM,而不是整个DOM,尽可能做到节点的复用,减少开销。

image.png

虚拟DOM,将真实的DOM转换为JS对象,因为计算机内部操作JS对象会比操作真实DOM方便得多。

虚拟DOM:用 JavaScript对象描述DOM的层次结构。DOM中的一切属性都在虚拟DOM中有对应的属性。

image.png

两者之间的关系

diff的算法基于虚拟DOM,是两个虚拟DOM之间的精细化比较,算出应该如何最小量更新,最后反映到真正的DOM上。

snabbdom

  • snabbdom是瑞典单词,原意 “ 速度 ”

  • snabbdom是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴了snabbdom

  • 官方git:github.com/snabbdom/sn…

  • 一个专注于简单性、模块化、强大特性和性能的虚拟DOM库

安装

  • 在git上的 snabbdom源码是用 TypeScript写的,git上并不提供编译好的JavaScript版本

  • 如果要直接使用编译出来的 JavaScript版的 snabbdom库,可以从npm上下载

    npm i -S snabbdom
    
  • 学习库底层时,建议大家阅读原汁原味的代码,最好带有库作者原注释。这样对源码阅读能力会有很大的提升。

安装完毕后,查看目录结构,这里安装的版本为3.0.3

image.png

  • 其中src文件夹中存放的是TypeScript源码文件,跟git上面的内容相同
  • build文件夹中就是编译好后的JavaScript源码文件

搭建环境

和Mustache不同,snabbdom是一个虚拟DOM库,也就是说不能在Node端运行。需要安装webpackwebpack-cliwebpack-dev-server开发环境运行。

npm i -D webpack webpack-cli@3 webpack-dev-server

通过实验snabbdom可以和webpack-cli@3版本使用,最新版为4,运行会报错。

  1. 项目根目录下新建webpack.config.js

    module.exports = {
      // 打包入口
      entry: './src/index.js',
      output: {
        //虚拟路径,并不会在真实文件夹中产生,localhost:8080/fake/bundles.js
        publicPath: 'fake',
        filename: 'bundle.js'
      },
      devServer:{
        // 端口号
        port:8080,
        // 静态资源目录
        contentBase:'www'
      }
    };
    
  2. 新建配置文件中的入口和静态资源目录

    image.png

    index.html中引用<script src="fake/bundle.js"></script>

  3. 修改启动命令

    ...
    "scripts": {
        "dev": "webpack-dev-server"
    },
    ...
    
  4. 启动

    npm run dev
    # 若没修改启动命令,使用
    # npm run webpack-dev-server
    

PS:视频中老师的snabbdom版本为2.1.0,该版本package.json中有exports属性,功能类似于为路径取别名:

image.png

2.1.0版本的github的Example中,这样导入模块,但是真实路径中并不存在,都是通过上述的exports别名进行查找的。

image.png

但是webpack4并不支持exports,所以视频中安装的版本如下:

npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3

Example

复制github上的Example到入口/src/index.js中:

index.html中创建div#container节点,因为下列代码要用到。

另外,其中第20行(vnode)和第30行(newVnode),虚拟DOM中的click:someFnsomeFn需要自己定义一个。

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, 
  propsModule, 
  styleModule, 
  eventListenersModule, 
]);

const container = document.getElementById("container");

const vnode = h("div#container.two.classes", { on: { click: someFn } }, [
  h("span", { style: { fontWeight: "bold" } }, "This is bold"),
  " and this is just normal text",
  h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

const newVnode = h(
  "div#container.two.classes",
  { on: { click: anotherEventHandler } },
  [
    h(
      "span",
      { style: { fontWeight: "normal", fontStyle: "italic" } },
      "This is now italic type"
    ),
    " and this is still just normal text",
    h("a", { props: { href: "/bar" } }, "I'll take you places!"),
  ]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state

结果:

image.png

h函数

  • h函数用来创建虚拟节点(vnode)

  • 比如这样调用h函数

    h('a',{ props:{ href: 'http://www.atguigu.com' }}, '尚硅谷')
    
  • 将得到以下虚拟节点

    { "sel": "a", "data": { props: { href: 'http://www.atguigu.com'} }, "text": "尚硅谷" }
    
  • 这个虚拟节点表示的真正DOM节点为

    <a href="http://www.atguigu.com">尚硅谷</a>
    

虚拟DOM的属性

image.png

  • sel:选择器
  • children:子元素,也是虚拟DOM,undefined表示没有子元素
  • data:元素的属性或样式等等,真实DOM的attribute信息
  • elm:表示该虚拟DOM对应的真实DOM,若为undefined则表示该虚拟DOM还未上DOM树(未渲染)
  • key:唯一标识(vue中的v-for使用过)
  • text:元素文本,innerText

基本使用

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // 用所选择的模块初始化patch函数
  classModule, // 使切换类的工作变得简单
  propsModule, // 用于设置DOM元素的属性
  styleModule, // 处理支持动画的元素的样式
  eventListenersModule, // 挂载事件监听器
]);

const container = document.getElementById("container");

const vnode1 = h('a', {
  props: {
    href: 'https://www.baidu.com/',
    target: '_blank'
  }
}, '百度一下')
//省略data children
const vnode2 = h('div', '我是一个盒子')
//嵌套使用,子元素用数组表示,若子元素只有一个可以省略数组
const vnode3 = h('ul', [
  h('li', '西瓜'),
  h('li', '苹果'),
  h('li', '梨子'),
  h('li', [
    h('div', '喜欢西瓜'),
    h('div', '讨厌苹果')
  ])
])
console.log(vnode3)
// 挂载到DOM树
patch(container, vnode3);

image.png

原理

查看snabbdom模块下src/h.ts

import { vnode, VNode, VNodeData } from "./vnode";
import * as is from "./is";

export type VNodes = VNode[];
export type VNodeChildElement = VNode | string | number | undefined | null;
export type ArrayOrElement<T> = T | T[];
export type VNodeChildren = ArrayOrElement<VNodeChildElement>;

// 添加svg namespace(命名空间)
function addNS(
  data: any,
  children: VNodes | undefined,
  sel: string | undefined
): void {
  data.ns = "http://www.w3.org/2000/svg";
  if (sel !== "foreignObject" && children !== undefined) {
    for (let i = 0; i < children.length; ++i) {
      const childData = children[i].data;
      if (childData !== undefined) {
        addNS(childData, children[i].children as VNodes, children[i].sel);
      }
    }
  }
}

// 声明方法重载
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData | null): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(
  sel: string,
  data: VNodeData | null,
  children: VNodeChildren
): VNode;

// 实现h函数
export function h(sel: any, b?: any, c?: any): VNode {
  let data: VNodeData = {};
  let children: any;
  let text: any;
  let i: number;
  // 判断参数类型,实现重载
  if (c !== undefined) {
    if (b !== null) {
      data = b;
    }
    if (is.array(c)) {
      children = c;
    } else if (is.primitive(c)) {
      text = c;
    } else if (c && c.sel) {
      children = [c];
    }
  } else if (b !== undefined && b !== null) {
    if (is.array(b)) {
      children = b;
    } else if (is.primitive(b)) {
      text = b;
    } else if (b && b.sel) {
      children = [b];
    } else {
      data = b;
    }
  }
  // 处理子元素嵌套
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      if (is.primitive(children[i]))
        children[i] = vnode(
          undefined,
          undefined,
          undefined,
          children[i],
          undefined
        );
    }
  }
  // 为svg元素添加命名空间
  if (
    sel[0] === "s" &&
    sel[1] === "v" &&
    sel[2] === "g" &&
    (sel.length === 3 || sel[3] === "." || sel[3] === "#")
  ) {
    addNS(data, children, sel);
  }
  // 返回虚拟节点
  return vnode(sel, data, children, text, undefined);
}

h函数的功能其实非常简单,本质只是将传入的几个参数进行适当加工,并组成一个vnode对象返回。

patch函数

diff算法会在patch函数种进行体现,h函数创建虚拟节点,而patch函数将虚拟节点通过diff算法渲染为真实节点。patch函数是通过init函数生成的。

基本使用

修改index.html

<body>
  <div id="container">我是container</div>
  <button id="toUl2">ul1-->ul2</button>
  <button id="toUl3">ul1-->ul3</button>
  <button id="toUl4">ul1-->ul4</button>
  <script src="fake/bundle.js"></script>
</body>

修改index.js

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // 用所选择的模块初始化patch函数
  classModule, // 使切换类的工作变得简单
  propsModule, // 用于设置DOM元素的属性
  styleModule, // 处理支持动画的元素的样式
  eventListenersModule, // 挂载事件监听器
]);

const container = document.getElementById("container");

const ul1 = h('ul', [
  h('li', '1'),
  h('li', '2'),
  h('li', '3'),
  h('li', '4'),
])
//先将 ul1 更新到 container
patch(container, ul1)

// ul2 只比 ul1从最后多一个子元素
const ul2 = h('ul', [
  h('li', '1'),
  h('li', '2'),
  h('li', '3'),
  h('li', '4'),
  h('li', '5')
])

// 比较ul1 和 ul2 更新
document.getElementById("toUl2").onclick = function () {
  patch(ul1, ul2)
}

// ul3 只比 ul1从最前面多一个子元素
const ul3 = h('ul', [
  h('li', 'E'),
  h('li', '1'),
  h('li', '2'),
  h('li', '3'),
  h('li', '4')
])

// 比较ul1 和 ul3 更新
document.getElementById("toUl3").onclick = function () {
  patch(ul1, ul3)
}

// ul4 和 ul1 的sel参数不同,子元素相同
const ul4 = h('ol', [
  h('li', '1'),
  h('li', '2'),
  h('li', '3'),
  h('li', '4')
])

// 比较ul1 和 ul4 更新
document.getElementById("toUl4").onclick = function () {
  patch(ul1, ul4)
}
  1. 初始界面

    image.png

  2. 进入浏览器开发者模式修改元素内容

    image.png

  3. 点击第一个按钮,更新为ul2

    image.png

结论,对于后面追加的内容,diff算法是可以感知到的,并进行了最小化更新。

  1. 刷新回到初始界面,并进入浏览器开发者模式修改元素内容

    image.png

  2. 点击第二个按钮,更新为ul3,整个ul都被更新了

    image.png

  3. 如果将index.js中的ul1ul2代码修改

    const ul1 = h('ul', [
      h('li', { key: '1' }, '1'),
      h('li', { key: '2' }, '2'),
      h('li', { key: '3' }, '3'),
      h('li', { key: '4' }, '4'),
    ])
    const ul3 = h('ul', [
      h('li', { key: 'E' }, 'E'),
      h('li', { key: '1' }, '1'),
      h('li', { key: '2' }, '2'),
      h('li', { key: '3' }, '3'),
      h('li', { key: '4' }, '4')
    ])
    
  4. 再进行相同操作

    image.png

结论:对于从前插入的数据,diff算法无法感知,会更新掉整个DOM,如果给元素添加key属性,那么key属性相同的元素将不会被更新。

  1. 撤回对ul1ul2对象的修改,回归原始界面和数据,并再次修改元素内容

    image.png

  2. 点击第三个按钮,更新为ul4,整个ul被替换为ol

    image.png

结论:同一个虚拟DOM(key相同且sel相同),才会使用diff算法,否则会更新整个DOM。

另外,diff算法只进行同层比较,即便是同一个虚拟DOM,但是跨层了,diff算法依旧不起作用,会直接更新整个DOM。如下:

const div = h('div', [
  h('p', { key: '1' }, '1'),
  h('p', { key: '2' }, '2'),
  h('p', { key: '3' }, '3'),
  h('p', { key: '4' }, '4')
])
// 虽然子元素都相同,但是多了一层section,diff算法不起作用
const div2 = h('div', h('section', [
  h('p', { key: '1' }, '1'),
  h('p', { key: '2' }, '2'),
  h('p', { key: '3' }, '3'),
  h('p', { key: '4' }, '4')
]))

虽然这样看起来,diff算法并不是那么智能且高效,但是实际上在vue的开发中,这些情况基本不会遇见,这是合理的优化机制。

原理

流程图如下:

image.png

查看snabbdom模块下src/init.ts,可以直接从最后function patch(...) 开始看起,前面的函数都是为了组成patch函数。

// 省略一大堆

   export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
      // 省略一大堆

      return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
         let i: number, elm: Node, parent: Node;
         const insertedVnodeQueue: VNodeQueue = [];
         // 处理钩子,生命周期,不用管
         for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
         // 判断节点是否是虚拟节点,不是则转化为虚拟节点
         if (!isVnode(oldVnode)) {
            oldVnode = emptyNodeAt(oldVnode);
         }
         // 判断新旧虚拟阶段是否为同一个节点:判断sel和key
         if (sameVnode(oldVnode, vnode)) {
            // 相同节点才会精细化比较
            patchVnode(oldVnode, vnode, insertedVnodeQueue);
         } else {
            // 不是相同节点,则直接删除原来的元素,追加新元素(整个更新)
            elm = oldVnode.elm!;
            parent = api.parentNode(elm) as Node;

            createElm(vnode, insertedVnodeQueue);

            if (parent !== null) {
               api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
               removeVnodes(parent, [oldVnode], 0, 0);
            }
         }

         for (i = 0; i < insertedVnodeQueue.length; ++i) {
            insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
         }
         for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
         return vnode;
      };
   }

前面说过,diff算法生效的前提是新旧节点是同一个节点。由源码可知,实现diff算法的是patchVnode()函数。

function patchVnode(
oldVnode: VNode,
 vnode: VNode,
 insertedVnodeQueue: VNodeQueue
) {
   const hook = vnode.data?.hook;
   hook?.prepatch?.(oldVnode, vnode);
   
   const elm = (vnode.elm = oldVnode.elm)!;
   const oldCh = oldVnode.children as VNode[];
   const ch = vnode.children as VNode[];
   // 如果 新旧节点在内存中就是同一个对象,什么都不用做
   if (oldVnode === vnode) return;
   // 更新节点的data
   if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i)
         cbs.update[i](oldVnode, vnode);
      vnode.data.hook?.update?.(oldVnode, vnode);
   }
   // diff算法的核心
   // 若新节点没有文本
   if (isUndef(vnode.text)) {
      // 并且两个节点都有子元素
      if (isDef(oldCh) && isDef(ch)) {
         //两者的子元素不相同,直接更新子元素
         if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      } // 若只有新节点有子元素
      else if (isDef(ch)) {
         // 若老元素有text则清空,
         if (isDef(oldVnode.text)) api.setTextContent(elm, "");
         // 给真实DOM添加新虚拟节点的子元素
         addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } // 若只有旧节点有子元素
      else if (isDef(oldCh)) {
         // 把真实元素存在的的旧子元素删除
         removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      } // 若只有旧节点有text 
      else if (isDef(oldVnode.text)) {
         // 把真实DOM的innerText(这里可以这么理解,Node.textContent与innerText类似,区别可自行搜索)
         api.setTextContent(elm, "");
      }
   } // 旧节点的文本不同于新节点的文本
   else if (oldVnode.text !== vnode.text) {
      // 且旧节点有子元素
      if (isDef(oldCh)) {
         // 删除真实DOM存在的旧节点子元素
         removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      }
      // 将新节点的文本给真实DOM
      api.setTextContent(elm, vnode.text!);
   }
   hook?.postpatch?.(oldVnode, vnode);
}