Skip to content
微信公众号

Vue虚拟DOM

什么是虚拟DOM

虚拟 DOM(Virtual DOM) 是使用 JavaScript 对象来描述 DOM,虚拟 DOM 的本质就是 JavaScript 对象,使用 JavaScript 对象来描述 DOM 的结构。

我们先看一下真实DOM的成员

html
<div id="root"></div>

<script type="text/javascript">
    let element = document.querySelector("#root");
    let s = '';
    for(let key in element){
        s += key + ','
    }
    console.log(s);
</script>

通过以上代码将div的所有成员打印出来。可以看出一个DOM的成员是非常多的,那么创建一个DOM的成本是非常高的。

那么我们再来看虚拟DOM,它就是一个javascript对象,可以看出创建一个虚拟DOM对象它的成员很少,也就是创建一个虚拟DOM的成本要比真实DOM的成本小很多。

js
{
    sel: "div",
    data: {},
    children: undefined,
    text: "Hello Virtual DOM",
    elm: undefined,
    key: undefined
}

应用的各种状态变化首先作用于虚拟 DOM,最终映射到 DOM。Vue.js 中的虚拟 DOM 借鉴了 Snabbdom,并添加了一些 Vue.js 中的特性,例如:指令和组件机制。

Vue 1.x 中细粒度监测数据的变化,每一个属性对应一个 watcher,开销太大Vue 2.x 中每个组件对应一个 watcher,状态变化通知到组件,再引入虚拟 DOM 进行比对和渲染。

为什么要使用虚拟 DOM

首先前端开发刀耕火种的时代,需要手动操作DOM,还需要考虑浏览器兼容性的问题,非常麻烦,后来有JQuery库简化了DOM操作,我们也不需要考虑浏览器兼容性的问题,但是随着前端项目的复杂,DOM操作也变得越来越复杂。我们既要考虑操作数据,也考考虑操作DOM。

为了简化DOM操作,后来出现了MVVM框架,MVVM框架解决了视图和状态同步的问题,也就是当数据变化后自动更新视图,当视图变化后自动更新数据。在过去为了简化视图的操作我们使用了模板引擎,但是模板引擎没有解决跟踪状态变化的问题。为了解决跟踪状态变化的问题,就有了虚拟DOM,虚拟DOM的好处是当状态改变的时候,不需要立即更新DOM,只需要创建一个虚拟DOM树来描述真实的DOM树,它内部想要如何有效的更新真实的DOM,它的内部会使用diff算法来找到状态的差异,只更新变化的部分。

总体来说虚拟DOM的动机就是虚拟 DOM 可以维护程序的状态,跟踪上一次的状态,通过比较前后两次状态差异更新真实 DOM。

  • 使用虚拟 DOM,可以避免用户直接操作 DOM,开发过程关注在业务代码的实现,不需要关注如何操作 DOM,从而提高开发效率
  • 作为一个中间层可以跨平台,除了 Web 平台外,还支持 SSR、Weex。
  • 关于性能方面,在首次渲染的时候肯定不如直接操作 DOM,因为要维护一层额外的虚拟 DOM,如果后续有频繁操作 DOM 的操作,这个时候可能会有性能的提升,虚拟 DOM 在更新真实 DOM之前会通过 Diff 算法对比新旧两个虚拟 DOM 树的差异,最终把差异更新到真实 DOM

虚拟DOM的作用

  • 它可以维护视图和状态的关系,或者说可以保存视图的状态
  • 在视图比较简单的情况下虚拟DOM并不能够提高渲染的性能,只有在复杂视图的情况下,它可以提升渲染性能
  • 虚拟DOM最大的好处就是可以跨平台,除了可以在浏览器平台渲染DOM以外,还可以用于服务端渲染SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等

虚拟DOM的两个开源库

  • Snabbdom:Vue.js 2.x 内部使用的虚拟 DOM 就是改造的 Snabbdom,大约 200 SLOC (single line of code),通过模块可扩展,源码使用 TypeScript 开发,它是最快的 Virtual DOM 之一。
  • virtual-dom

Snabbdom

Snabbdom文档

看文档的意义在于学习任何一个库都要先看文档,通过文档了解库的作用,看文档中提供的示例,自己快速实现一个 demo,通过文档查看 API 的使用。文档地址为https://github.com/snabbdom/snabbdom

导入Snabbdom

安装Snabbdom

shell
npm install snabbdom

导入Snabbdom,Snabbdom的两个核心函数init()和h(),init()是一个高阶函数,返回patch()。h()返回虚拟节点VNode,这个函数我们在Vue.js中见过。

文档中导入的方式

js
import { init } from 'snabbdom/init'
import { h } from 'snabbdom/h'
const patch = init([])

实际导入的方式,由于parcel/webpack 4不支持package.json中的exports字段,所以

js
import { h } from 'snabbdom/build/package/h'
import { init } from 'snabbdom/build/package/init'

简单使用

js
import { h } from 'snabbdom/build/package/h'
import { init } from 'snabbdom/build/package/init'

const patch = init([])

//第一个参数:标签+选择器
//第二个参数:如果是字符串就是标签中的文本内容
let vnode = h('div#container.cls','Hello World')
let app = document.querySelector('#app')
//第一个参数:旧的VNode,可以是DOM元素
//第二个参数:新的VNode
//返回新的VNode
let oldVNode = patch(app,vnode);
vnode = h('div#container.xxx','Hello Snabbdom')
patch(oldVNode,vnode)
js
import { h } from 'snabbdom/build/package/h'
import { init } from 'snabbdom/build/package/init'

const patch = init([])

let vnode = h('div#container',[
    h('h1','Hello Snabbdom'),
    h('p','这是一个p')
])

let app = document.querySelector('#app')
let oldVnode = patch(app,vnode)

setTimeou(()=>{
    vnode = h('div#container',[
        h('h1','Hello World'),
        h('p','Hello P')
    ])
    patch(oldVnode,vnode)
},2000)

Snabbdom的模块

  • Snabbdom 的核心库只能对VNode进行操作,并不能处理 DOM 元素的属性/样式/事件等,想要处理的话可以通过注册 Snabbdom 默认提供的模块来实现
  • Snabbdom 中的模块可以用来扩展 Snabbdom的功能,类似于插件的机制
  • Snabbdom 中的模块的实现是通过注册全局的钩子函数来实现的

官方提供了6个模块,这些模块不需要去记忆,看懂即可。

  • attributes,设置VNode对应的DOM元素的属性,内部使用DOM的setAttribute实现的
  • props,也是设置VNode对应的DOM元素的属性,但是其内部使用对象.属性的方式来设置的
  • dataset,用来处理Html5中提供的data-自定义属性
  • class,用来切换类样式
  • style,用来设置行内样式
  • eventlisteners,注册和移除事件

模块使用步骤如下:

  • 导入需要的模块
  • init() 中注册模块
  • h() 函数的第二个参数处使用模块
js
import { h } from 'snabbdom/build/package/h'
import { init } from 'snabbdom/build/package/init'

//1.导入模块
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'

//2.注册模块
const patch = init([styleModule,eventListenersModule])

//3.使用h()函数的第二个参数传入模块中使用的数据(对象)
let vnode = h('div',[
    h('h1',{ style:{ backgroundColor:'red' } }),
    h('p',{ on:{ click:eventHandler } },'Hello P')
])
function eventHandler(){
    console.log('点击事件')
}

let app = document.querySelector('#app')
patch(app,vnode)

Snabbdom源码解析

Vue中的虚拟DOM是通过改造Snabbdom实现的,所以看完Snabbdom源码之后能够掌握虚拟DOM的原理,那么我们如何学习源码?

  • 宏观了解,也就是对这个库有一个整体的了解,在看源码之前,先看这个库的整体执行过程
  • 带着目标看源码
  • 看源码的过程要不求甚解,看的时候要围绕目标去看,因为开源项目代码分支有很多,根据目标看可以提高效率
  • 调试,主线逻辑走通之后,可以写个Demo对代码进行调试,加深理解
  • 参考资料,我们还可以参考别人写好的资料,参考别人写好的文章,帮助理解源码,提高效率

根据上述写的代码可以看出核心流程是先使用init() 设置模块,创建 patch() 函数。然后使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM, 使用patch() 比较新旧两个 Vnode,最后把变化的内容更新到真实 DOM 树。

h函数

h函数的作用就是创建VNode对象,我们在Vue中看到过h函数。

js
new Vue({
    router,
    store,
    render: h=>h(App)
}).$mount('#app')

当然,h函数最早见于hyperscript,使用JavaScript创建超文本

然后介绍一下函数重载,h函数源码中用到了重载。函数重载指的是参数个数或参数类型不同的函数,在JavaScript中没有重载的概念,因为它是弱类型语言,在TypeScript中有重载,不过重载的实现还是通过代码调整参数。例如:

ts
//函数重载-参数个数
function add(a:number,b:number){
    console.log(a+b)
}

function add(a:number,b:number,c:number){
    console.log(a+b+c)
}
add(1,2)
add(1,2,3)
ts
//函数重载-参数类型
function add(a:number,b:number){
    console.log(a+b)
}
function add(a:number,b:string){
    console.log(a+b)
}
add(1,2)
add(1,'2')

接下来看一下h函数的源码,看一下h函数如何创建VNode。

ts
//src/h.ts
//函数的重载
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;
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) {
    //处理三个参数的情况
    //sel、data、children/text
    if (b !== null) {
      data = b;
    }
    if (is.array(c)) {
      children = c;
      //如果c是字符串或数字
    } else if (is.primitive(c)) {
      text = c.toString();
      //如果c是VNode
    } else if (c && c.sel) {
      children = [c];
    }
  } else if (b !== undefined && b !== null) {
    //处理两个参数的情况
    //如果b是数组
    if (is.array(b)) {
      children = b;
      //如果b是字符串或数字
    } else if (is.primitive(b)) {
      text = b.toString();
      //如果b是VNode
    } else if (b && b.sel) {
      children = [b];
    } else {
      data = b;
    }
  }
  if (children !== undefined) {
    //处理children中的原始值
    for (i = 0; i < children.length; ++i) {
      //如果child是string/number,创建文本节点
      if (is.primitive(children[i]))
        children[i] = vnode(
          undefined,
          undefined,
          undefined,
          children[i],
          undefined
        );
    }
  }
  if (
    sel[0] === "s" &&
    sel[1] === "v" &&
    sel[2] === "g" &&
    (sel.length === 3 || sel[3] === "." || sel[3] === "#")
  ) {
    //如果是svg,添加命名空间
    addNS(data, children, sel);
  }
  //返回VNode
  return vnode(sel, data, children, text, undefined);
}

h函数的核心就是处理参数,创建VNode对象并返回。我们看一下vnode的定义。

ts
//src/vnode.ts
export interface VNode {
  sel: string | undefined;  //选择器 
  data: VNodeData | undefined; //描述模块中所需要的数据
  children: Array<VNode | string> | undefined; //子节点数据
  elm: Node | undefined; //当前vnode转换之后的DOM元素
  text: string | undefined; //与children互斥,记录文本节点对应的文本内容
  key: Key | undefined;  //唯一标识节点
}

//返回VNode
export function vnode(
  sel: string | undefined,
  data: any | undefined,
  children: Array<VNode | string> | undefined,
  text: string | undefined,
  elm: Element | DocumentFragment | Text | undefined
): VNode {
  const key = data === undefined ? undefined : data.key;
  return { sel, data, children, text, elm, key };
}

patch整理过程分析

patch(oldVnode,newVnode)函数俗称打补丁,它有两个参数oldVnode和newVnode,它内部对比两个vnode的差异,然后更新到真实DOM中,vnode也是树型结构的,所以patch函数内部就是寻找这两个树的差异,这个过程就是我们常说的diff算法。

patch的核心就是把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点。他首先会对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同),如果不是相同节点,删除之前的内容,重新渲染。如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和oldVnode 的 text 不同,直接更新文本内容。如果新的 VNode 有 children,判断子节点是否有变化。

init函数

init函数有三个参数modules、domApi、options,modules之前传入的模块用于处理行内样式和注册事件,domApi用于把vnode对象转换为其他平台下的dom元素,默认不传转换为浏览器的dom元素。最后返回了patch函数。

ts
//src/init.ts
//hooks钩子数组,存放钩子函数的名称
const hooks: Array<keyof Module> = [
  "create",
  "update",
  "remove",
  "destroy",
  "pre",
  "post",
];

export function init(
  modules: Array<Partial<Module>>,
  domApi?: DOMAPI,
  options?: Options
) {
  //存储模块中的钩子函数
  const cbs: ModuleHooks = {
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: [],
  };
  //给DOMAPI赋值
  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;

  for (const hook of hooks) {
    for (const module of modules) {
      const currentHook = module[hook];
      if (currentHook !== undefined) {
        (cbs[hook] as any[]).push(currentHook);
      }
    }
  }
  .....

  return function patch(
    oldVnode: VNode | Element | DocumentFragment,
    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 (isElement(api, oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode);
    } else if (isDocumentFragment(api, oldVnode)) {
      oldVnode = emptyDocumentFragmentAt(oldVnode);
    }

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

patch函数

在上述代码中可以看到path函数有两个参数,这两个参数是新旧vnode对象,但是第一个参数既可以使vnode对象也可以使dom对象,首次渲染的时候,这里提供真实dom,如果是真实dom,这里相当于是占位的地方,新创建的dom元素会替换这个位置。

js
function patch(
    oldVnode: VNode | Element | DocumentFragment,
    vnode: VNode
  ): VNode {
    let i: number, elm: Node, parent: Node;
    //新插入节点的队列,目的是为了触发这些节点上的insert函数
    const insertedVnodeQueue: VNodeQueue = [];
    //触发pre钩子函数
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    //如果是元素DOM则将DOM转换成vnode
    if (isElement(api, oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode);
    } else if (isDocumentFragment(api, oldVnode)) {
      oldVnode = emptyDocumentFragmentAt(oldVnode);
    }
    //判断是否是相同节点
    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      elm = oldVnode.elm!;
      //获取父元素
      parent = api.parentNode(elm) as Node;
      //创建新的vnode对应的DOM元素
      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        //将新创建的DOM元素插入到DOM树上
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
        //把老节点DOM元素移除
        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;
  };

createEle函数

其内部基本分为三个过程

js
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    //1.执行用户设置的init钩子函数
    let i: any;
    let data = vnode.data;
    if (data !== undefined) {
      const init = data.hook?.init;
      if (isDef(init)) {
        init(vnode);
        data = vnode.data;
      }
    }
    const children = vnode.children;
    const sel = vnode.sel;
    //2.把vnode转换成真实dom对象,(没有渲染到页面 )
    if (sel === "!") {
        //如果选择器是!,创建注释节点
      if (isUndef(vnode.text)) {
        vnode.text = "";
      }
      vnode.elm = api.createComment(vnode.text!);
    } else if (sel !== undefined) {
        //如果选择器不为空,解析选择器
      // Parse selector
      const hashIdx = sel.indexOf("#");
      const dotIdx = sel.indexOf(".", hashIdx);
      const hash = hashIdx > 0 ? hashIdx : sel.length;
      const dot = dotIdx > 0 ? dotIdx : sel.length;
      const tag =
        hashIdx !== -1 || dotIdx !== -1
          ? sel.slice(0, Math.min(hash, dot))
          : sel;
      const elm = (vnode.elm =
        isDef(data) && isDef((i = data.ns))
          ? api.createElementNS(i, tag, data)
          : api.createElement(tag, data));
      if (hash < dot) elm.setAttribute("id", sel.slice(hash + 1, dot));
      if (dotIdx > 0)
        elm.setAttribute("class", sel.slice(dot + 1).replace(/\./g, " "));
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
      //如果vnode中有子节点,创建子vnode对应的DOM元素并追加到DOM树上
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text));
      }
      const hook = vnode.data!.hook;
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode);
        if (hook.insert) {
          insertedVnodeQueue.push(vnode);
        }
      }
    } else if (options?.experimental?.fragments && vnode.children) {
      vnode.elm = (
        api.createDocumentFragment ?? documentFragmentIsNotSupported
      )();
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
      for (i = 0; i < vnode.children.length; ++i) {
        const ch = vnode.children[i];
        if (ch != null) {
          api.appendChild(
            vnode.elm,
            createElm(ch as VNode, insertedVnodeQueue)
          );
        }
      }
    } else {
      //如果选择器为空,创建文本节点
      vnode.elm = api.createTextNode(vnode.text!);
    }
    //3.返回新建的vnode
    return vnode.elm;
  }

removeVnodes和addVnodes

removeVnodes是批量移除DOM树上的DOM元素,addVnodes是批量添加vnode对应的DOM元素到DOM树上

removeVnodes有四个参数,第一个参数parent是删除元素所在的父元素,第二个参数是一个数组,其中存放要删除元素的vnode,后面两个参数是要删除节点的开始和结束的位置。

js
function removeVnodes( 
    parentElm: Node,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number
  ): void {
    //遍历vnode数组
    for (; startIdx <= endIdx; ++startIdx) {
      let listeners: number;
      let rm: () => void;
      //找到要删除的vnode节点
      const ch = vnodes[startIdx];
      if (ch != null) {
        //有sel则是元素节点
        if (isDef(ch.sel)) {
          //触发vnode的destroy函数
          invokeDestroyHook(ch);
          //防止重复删除dom元素
          listeners = cbs.remove.length + 1;
          //返回删除dom元素的函数
          rm = createRmCb(ch.elm!, listeners);
          //遍历remove钩子函数并触发
          for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
          const removeHook = ch?.data?.hook?.remove;
          //是否有用户传入的remove钩子函数
          if (isDef(removeHook)) {
            removeHook(ch, rm);
          } else {
            rm();
          }
        } else if (ch.children) {
          // Fragment node
          invokeDestroyHook(ch);
          removeVnodes(
            parentElm,
            ch.children as VNode[],
            0,
            ch.children.length - 1
          );
        } else { //文本节点
          // Text node
          api.removeChild(parentElm, ch.elm!);
        }
      }
    }
  }

addVnodes有5个参数,parentElm是父元素节点,before是参考节点,要将vnode对应的dom元素插入到before之前,vnodes是添加的节点,startIdx和endIdx是开始和结束的位置,insertedVnodeQueue是刚刚插入过具有insert函数的节点。

js
function addVnodes(
    parentElm: Node,
    before: Node | null,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number,
    insertedVnodeQueue: VNodeQueue
  ) {
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx];
      if (ch != null) {
        api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
      }
    }
  }

patchVnode函数

用于新旧两个vnode的节点,找到其差异并更新到真实dom上

js
function patchVnode(
    oldVnode: VNode,
    vnode: VNode,
    insertedVnodeQueue: VNodeQueue
  ) {
    //第一个过程,触发prepatch和update钩子函数
    const hook = vnode.data?.hook;
    hook?.prepatch?.(oldVnode, vnode);
    const elm = (vnode.elm = oldVnode.elm)!;
    if (oldVnode === vnode) return;
    if (
      vnode.data !== undefined ||
      (isDef(vnode.text) && vnode.text !== oldVnode.text)
    ) {
      vnode.data ??= {};
      oldVnode.data ??= {};
      for (let i = 0; i < cbs.update.length; ++i)
        cbs.update[i](oldVnode, vnode);
      vnode.data?.hook?.update?.(oldVnode, vnode);
    }
    const oldCh = oldVnode.children as VNode[];
    const ch = vnode.children as VNode[];
    //第二个过程,真正对比新旧vnode差异的地方
    if (isUndef(vnode.text)) { //判断节点有没有text属性
      //没有则继续比对子节点
      if (isDef(oldCh) && isDef(ch)) {
        //新旧节点都有子节点,且不相同,则调用updateChildren
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      } else if (isDef(ch)) {
        //新节点有子节点,则判断老节点是否有text属性,则清空dom元素的文本内容
        if (isDef(oldVnode.text)) api.setTextContent(elm, "");
        //然后把新节点的子节点插入到elm中
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        //老节点有子节点,就调用removeVnodes移除老节点的子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      } else if (isDef(oldVnode.text)) {
        //老节点有text属性则清空文本内容
        api.setTextContent(elm, "");
      }
    } else if (oldVnode.text !== vnode.text) {
      //如果新旧节点的text不相等,择取判断是否有老节点
      if (isDef(oldCh)) {
        //有老节点,删除老节点的子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      }
      api.setTextContent(elm, vnode.text!);
    }
    //触发postpatch钩子函数
    hook?.postpatch?.(oldVnode, vnode);
  }

updateChildren整体分析

在patchVnode内部,当新旧vnode都有子节点,并且子节点不相同的时候,会调用updateChildren对比子节点。

先介绍一下Diff算法,虚拟dom中为什么要使用diff算法,渲染真实dom的开销很大,dom操作会引起浏览器的重排和重绘,也就是浏览器的重新渲染,重新渲染页面是很消耗性能的。当数据变化后,尤其是大量数据变化后,比如列表中的数据,如果直接操作dom的话会让浏览器重新渲染整个列表,虚拟dom中diff的核心就是当数据变化后不直接操作dom,而是用js对象来描述真实dom,当数据变化后会先比较js对象是否发生变化,然后找到变化后的位置,最后只去最小化更新变化后的位置,从而提高性能。

虚拟DOM中的diff算法用来查找两棵树每一个节点的差异,最麻烦的方式是将第一棵树的每一个节点与第二棵树的每一个节点进行比对

而Snabbdom 根据 DOM 的特点对传统的diff算法做了优化,DOM 操作时候很少会跨级别操作节点,只比较同级别的节点。

Snabbdom在进行同级别节点进行比较的时候,首先会对新老节点数组的开始和结束节点,在遍历过程中移动相对应的索引,对开始和结束节点比较的时候,总共有四种情况:

  • oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
  • oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
  • oldStartVnode / newEndVnode (旧开始节点 / 新结束节点)
  • oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)

比较新旧开始节点,如果新旧开始节点是sameVnode(key和sel相同),调用patchVnode()对比和更新节点,把旧开始和新开始索引往后移动oldStartIdx++/newStartIdx++。

比较旧开始节点与新结束节点,调用patchVnode()对比和更新节点,把oldStartVnode对应的DOM元素,移动到右边,更新索引

比较旧结束节点与新开始节点,调用patchVnode()对比和更新节点,把oldEndVnode对应的DOM元素,移动到左边,更新索引

非上述四种情况,遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点,如果没有找到,说明 newStartNode 是新节点,创建新节点对应的 DOM 元素,插入到 DOM 树中。如果找到了,判断新节点和找到的老节点的 sel 选择器是否相同,如果不相同,说明节点被修改了,重新创建对应的 DOM 元素,插入到 DOM 树中。如果相同,把 elmToMove 对应的 DOM 元素,移动到左边。

循环结束有两种情况

  • 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束。如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边。
  • 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束。如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除。
js
function updateChildren(
    parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue
  ) {
    let oldStartIdx = 0;  //旧的开始索引
    let newStartIdx = 0;  //新的开始索引
    let oldEndIdx = oldCh.length - 1;  //旧的结束索引
    let oldStartVnode = oldCh[0];  //旧的开始节点
    let oldEndVnode = oldCh[oldEndIdx];  //旧的结束节点
    let newEndIdx = newCh.length - 1; //新的结束索引
    let newStartVnode = newCh[0]; //新的开始节点
    let newEndVnode = newCh[newEndIdx]; //新的结束节点
    let oldKeyToIdx: KeyToIndexMap | undefined;
    let idxInOld: number;
    let elmToMove: VNode;
    let before: any;
    //同级别节点之间的比较
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
      } else if (oldEndVnode == null) {
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (newStartVnode == null) {
        newStartVnode = newCh[++newStartIdx];
      } else if (newEndVnode == null) {
        newEndVnode = newCh[--newEndIdx];
        //比较开始和结束的四种情况
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(
          parentElm,
          oldStartVnode.elm!,
          api.nextSibling(oldEndVnode.elm!)
        );
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        //开始和结尾比较结束
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        if (isUndef(idxInOld)) {
          // New element
          api.insertBefore(
            parentElm,
            createElm(newStartVnode, insertedVnodeQueue),
            oldStartVnode.elm!
          );
        } else {
          elmToMove = oldCh[idxInOld];
          if (elmToMove.sel !== newStartVnode.sel) {
            api.insertBefore(
              parentElm,
              createElm(newStartVnode, insertedVnodeQueue),
              oldStartVnode.elm!
            );
          } else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
    //循环结束的收尾工作
    if (newStartIdx <= newEndIdx) {
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
      addVnodes(
        parentElm,
        before,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      );
    }
    if (oldStartIdx <= oldEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

key的意义

snabbdom和vue中key的意义是一样的,都是在diff算法中用来比较vnode是否是相同节点,如果不设置key会最大程度重用当前dom元素,但是重用dom元素有的时候会有问题。

Vue.js 中的虚拟 DOM

h 函数就是 createElement()

js
const vm = new Vue({
    el: '#app',
    render (h) {
            // h(tag, data, children)
            // return h('h1', this.msg)
            // return h('h1', { domProps: { innerHTML: this.msg } })
            // return h('h1', { attrs: { id: 'title' } }, this.msg)
            const vnode = h(
                'h1',
                {
                    attrs: { id: 'title' }
                },
                this.msg
            )
            console.log(vnode)
        return vnode
    },
    data: {
        msg: 'Hello Vue'
    }
})

虚拟 DOM 创建过程

createElement

createElement() 函数,用来创建虚拟节点 (VNode),我们的 render 函数中的参数 h,就是createElement()

js
render(h) {
    // 此处的 h 就是 vm.$createElement
    return h('h1', this.msg)
}

在 vm._render() 中调用了,用户传递的或者编译生成的 render 函数,这个时候传递了 createElement

js
//src/core/instance/render.js

vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

vm.c 和 vm.$createElement 内部都调用了 createElement,不同的是最后一个参数。vm.c 在编译生成的render 函数内部会调用,vm.$createElement 在用户传入的 render 函数内部调用。当用户传入render 函数的时候,要对用户传入的参数做处理

js
// src/core/vdom/create-element.js

export function createElement (
    context: Component,
    tag: any,
    data: any,
    children: any,
    normalizationType: any,
    alwaysNormalize: boolean
): VNode | Array<VNode> {
    // 判断第三个参数
    // 如果 data 是数组或者原始值的话就是 children,实现类似函数重载的机制
    if (Array.isArray(data) || isPrimitive(data)) {
        normalizationType = children
        children = data
        data = undefined
    }
    if (isTrue(alwaysNormalize)) {
        normalizationType = ALWAYS_NORMALIZE
    }
    return _createElement(context, tag, data, children, normalizationType)
}
export function _createElement (
    context: Component,
    tag?: string | Class<Component> | Function | Object,
    data?: VNodeData,
    children?: any,
    normalizationType?: number
): VNode | Array<VNode> {
    if (isDef(data) && isDef((data: any).__ob__)) {
        ……
        return createEmptyVNode()
    }
    // object syntax in v-bind
    if (isDef(data) && isDef(data.is)) {
        tag = data.is
    }
    if (!tag) {
        // in case of component :is set to falsy value
        return createEmptyVNode()
    }
    ……
    // support single function children as default scoped slot
    if (Array.isArray(children) &&
        typeof children[0] === 'function'
    ) {
        data = data || {}
        data.scopedSlots = { default: children[0] }
        children.length = 0
    }
    // 去处理 children
    if (normalizationType === ALWAYS_NORMALIZE) {
        // 当手写 render 函数的时候调用
        // 判断 children 的类型,如果是原始值的话转换成 VNode 的数组
        // 如果是数组的话,继续处理数组中的元素
        // 如果数组中的子元素又是数组(slot template),递归处理
        // 如果连续两个节点都是字符串会合并文本节点
        children = normalizeChildren(children)
    } else if (normalizationType === SIMPLE_NORMALIZE) {
        // 把二维数组转换为一维数组
        // 如果 children 中有函数组件的话,函数组件会返回数组形式
        // 这时候 children 就是一个二维数组,只需要把二维数组转换为一维数组
        children = simpleNormalizeChildren(children)
    }
    let vnode, ns
    // 判断 tag 是字符串还是组件
    if (typeof tag === 'string') {
        let Ctor
        ns = (context.$vnode && context.$vnode.ns) ||
        config.getTagNamespace(tag)
        // 如果是浏览器的保留标签,创建对应的 VNode
        if (config.isReservedTag(tag)) {
            // platform built-in elements
            vnode = new VNode(
            config.parsePlatformTagName(tag), data, children,
            undefined, undefined, context
            )
        } else if ((!data || !data.pre) && isDef(Ctor =
            resolveAsset(context.$options, 'components', tag))) {
            // component
            // 否则的话创建组件
            vnode = createComponent(Ctor, data, context, children, tag)
        } else {
            // unknown or unlisted namespaced elements
            // check at runtime because it may get assigned a namespace when its
            // parent normalizes children
            vnode = new VNode(
                tag, data, children,
                undefined, undefined, context
            )
        }
    } else {
        // direct component options / constructor
        vnode = createComponent(tag, data, context, children)
    }
    if (Array.isArray(vnode)) {
        return vnode
    } else if (isDef(vnode)) {
        if (isDef(ns)) applyNS(vnode, ns)
        if (isDef(data)) registerDeepBindings(data)
        return vnode
    } else {
        return createEmptyVNode()
    }
}

执行完 createElement 之后创建好了 VNode,把创建好的 VNode 传递给 vm._update() 继续处理。

update

它的功能是内部调用 vm.patch() 把虚拟 DOM 转换成真实 DOM。

js
// src/core/instance/lifecycle.js

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
        // initial render
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly
        */)
    } else {
        // updates
        vm.$el = vm.__patch__(prevVnode, vnode)
    }
    restoreActiveInstance()
    // update __vue__ reference
    if (prevEl) {
        prevEl.__vue__ = null
    }
    if (vm.$el) {
        vm.$el.__vue__ = vm
    }
    // if parent is an HOC, update its $el as well
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
        vm.$parent.$el = vm.$el
    }
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
}

patch 函数初始化

它的功能是对比两个 VNode 的差异,把差异更新到真实 DOM。如果是首次渲染的话,会把真实 DOM 先转换成VNode

Snabbdom 中 patch 函数的初始化

js
//src/snabbdom.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
    return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
}
}
js
// vnode

export function vnode (sel: string | undefined,
    data: any | undefined,
    children: Array<VNode | string> | undefined,
    text: string | undefined,
    elm: Element | Text | undefined): VNode {
    const key = data === undefined ? undefined : data.key
    return { sel, data, children, text, elm, key }
}

Vue.js 中 patch 函数的初始化

js
// src/platforms/web/runtime/index.js

import { patch } from './patch'

Vue.prototype.__patch__ = inBrowser ? patch : noop
js
//src/platforms/web/runtime/patch.js 

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'
// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })
js
//src/core/vdom/patch.js

export function createPatchFunction (backend) {
    let i, j
    const cbs = {}
    const { modules, nodeOps } = backend
    // 把模块中的钩子函数全部设置到 cbs 中,将来统一触发
    // cbs --> { 'create': [fn1, fn2], ... }
    for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = []
        for (j = 0; j < modules.length; ++j) {
            if (isDef(modules[j][hooks[i]])) {
                cbs[hooks[i]].push(modules[j][hooks[i]])
            }
        }
    }
    ……
    ……
    ……
    return function patch (oldVnode, vnode, hydrating, removeOnly) {
    }
}

patch 函数执行过程

js
function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 如果没有 vnode 但是有 oldVnode,执行销毁的钩子函数
    if (isUndef(vnode)) {
        if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
        return
    }
    let isInitialPatch = false
    const insertedVnodeQueue = []
    if (isUndef(oldVnode)) {
        // 如果没有 oldVnode,创建 vnode 对应的真实 DOM
        // empty mount (likely as component), create new root element
        isInitialPatch = true
        createElm(vnode, insertedVnodeQueue)
    } else {
        // 判断当前 oldVnode 是否是 DOM 元素(首次渲染)
        const isRealElement = isDef(oldVnode.nodeType)
        if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // 如果不是真实 DOM,并且两个 VNode 是 sameVnode,这个时候开始执行 Diff
            // patch existing root node
            patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null,removeOnly)
        } else {
            if (isRealElement) {
                // mounting to a real element
                // check if this is server-rendered content and if we can perform
                // a successful hydration.
                if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
                    oldVnode.removeAttribute(SSR_ATTR)
                    hydrating = true
                }
                ……
                // either not server-rendered, or hydration failed.
                // create an empty node and replace it
                oldVnode = emptyNodeAt(oldVnode)
            }
            // replacing existing element
            const oldElm = oldVnode.elm
            const parentElm = nodeOps.parentNode(oldElm)
            // create new node
            createElm(
                vnode,
                insertedVnodeQueue,
                // extremely rare edge case: do not insert if old element is in a
                // leaving transition. Only happens when combining transition +
                // keep-alive + HOCs. (#4590)
                oldElm._leaveCb ? null : parentElm,
                nodeOps.nextSibling(oldElm)
            )
            // update parent placeholder node element, recursively
            if (isDef(vnode.parent)) {
                let ancestor = vnode.parent
                const patchable = isPatchable(vnode)
                while (ancestor) {
                    for (let i = 0; i < cbs.destroy.length; ++i) {
                        cbs.destroy[i](ancestor)
                    }
                    ancestor.elm = vnode.elm
                    if (patchable) {
                        for (let i = 0; i < cbs.create.length; ++i) {
                            cbs.create[i](emptyNode, ancestor)
                        }
                        // #6513
                        // invoke insert hooks that may have been merged by createhooks.
                        // e.g. for directives that uses the "inserted" hook.
                        const insert = ancestor.data.hook.insert
                        if (insert.merged) {
                            // start at index 1 to avoid re-invoking component mounted hook
                            for (let i = 1; i < insert.fns.length; i++) {
                                insert.fns[i]()
                            }
                        }
                    } else {
                        registerRef(ancestor)
                    }
                ancestor = ancestor.parent
                }
            }
            // destroy old node
            if (isDef(parentElm)) {
                removeVnodes(parentElm, [oldVnode], 0, 0)
            } else if (isDef(oldVnode.tag)) {
                invokeDestroyHook(oldVnode)
            }
        }
    }
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
}

createElm

把 VNode 转换成真实 DOM,插入到 DOM 树上

js
function createElm(
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
) {
    if (isDef(vnode.elm) && isDef(ownerArray)) {
        // This vnode was used in a previous render!
        // now it's used as a new node, overwriting its elm would cause
        // potential patch errors down the road when it's used as an insertion
        // reference node. Instead, we clone the node on-demand before creating
        // associated DOM element for it.
        vnode = ownerArray[index] = cloneVNode(vnode)
    }
    vnode.isRootInsert = !nested // for transition enter check
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
        return
    }
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
        if (process.env.NODE_ENV !== 'production') {
            if (data && data.pre) {
                creatingElmInVPre++
            }
            if (isUnknownElement(vnode, creatingElmInVPre)) {
                warn(
                    'Unknown custom element: <' + tag + '> - did you ' +
                    'register the component correctly? For recursive components, ' +
                    'make sure to provide the "name" option.',
                    vnode.context
                )
            }
        }
        vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode)
        setScope(vnode)
        /* istanbul ignore if */
        if (__WEEX__) {
        ……
        } else {
            createChildren(vnode, children, insertedVnodeQueue)
            if (isDef(data)) {
                invokeCreateHooks(vnode, insertedVnodeQueue)
            }
            insert(parentElm, vnode.elm, refElm)
        }
        if (process.env.NODE_ENV !== 'production' && data && data.pre) {
            creatingElmInVPre--
        }
    } else if (isTrue(vnode.isComment)) {
        vnode.elm = nodeOps.createComment(vnode.text)
        insert(parentElm, vnode.elm, refElm)
    } else {
        vnode.elm = nodeOps.createTextNode(vnode.text)
        insert(parentElm, vnode.elm, refElm)
    }
}

patchVnode

js
function patchVnode(
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
) {
    // 如果新旧节点是完全相同的节点,直接返回
    if (oldVnode === vnode) {
        return
    }
    if (isDef(vnode.elm) && isDef(ownerArray)) {
        // clone reused vnode
        vnode = ownerArray[index] = cloneVNode(vnode)
    }
    const elm = vnode.elm = oldVnode.elm
……
    // 触发 prepatch 钩子函数
    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
        i(oldVnode, vnode)
    }
    // 获取新旧 VNode 的子节点
    const oldCh = oldVnode.children
    const ch = vnode.children
    // 触发 update 钩子函数
    if (isDef(data) && isPatchable(vnode)) {
        for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode,
            vnode)
        if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    // 如果 vnode 没有 text 属性(说明有可能有子元素)
    if (isUndef(vnode.text)) {
        if (isDef(oldCh) && isDef(ch)) {
            // 如果新旧节点都有子节点并且不相同,这时候对比和更新子节点
            if (oldCh !== ch) updateChildren(elm, oldCh, ch,
                insertedVnodeQueue, removeOnly)
        } else if (isDef(ch)) {
            if (process.env.NODE_ENV !== 'production') {
                checkDuplicateKeys(ch)
            }
            // 如果新节点有子节点,并且旧节点有 text
            // 清空旧节点对应的真实 DOM 的文本内容
            if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
            // 把新节点的子节点添转换成真实 DOM,添加到 elm
            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        } else if (isDef(oldCh)) {
            // 如果旧节点有子节点,新节点没有子节点
            // 移除所有旧节点对应的真实 DOM
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        } else if (isDef(oldVnode.text)) {
            // 如果旧节点有 text,新节点没有子节点和 text
            nodeOps.setTextContent(elm, '')
        }
    } else if (oldVnode.text !== vnode.text) {
        // 如果新节点有 text,并且和旧节点的 text 不同
        // 直接把新节点的 text 更新到 DOM 上
        nodeOps.setTextContent(elm, vnode.text)
    }
    // 触发 postpatch 钩子函数
    if (isDef(data)) {
        if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode,
            vnode)
    }
}

updateChildren

updateChildren 和 Snabbdom 中的 updateChildren 整体算法一致,这里就不再展开了。我们再来看下它处理过程中 key 的作用,再 patch 函数中,调用 patchVnode 之前,会首先调用 sameVnode()判断当前的新老 VNode 是否是相同节点,sameVnode() 中会首先判断 key 是否相同。

通过下面代码来体会 key 的作用

html
<div id="app">
    <button @click="handler">按钮</button>
    <ul>
        <li v-for="value in arr">{{value}}</li>
    </ul>
</div>
<script src="../../dist/vue.js"></script>
<script>
    const vm = new Vue({
        el: '#app',
        data: {
            arr: ['a', 'b', 'c', 'd']
        },
        methods: {
            handler() {
                this.arr = ['a', 'x', 'b', 'c', 'd']
            }
        }
    })
</script>

当没有设置 key 的时候,在 updateChildren 中比较子节点的时候,会做三次更新 DOM 操作和一次插入 DOM 的操作

当设置 key 的时候,在 updateChildren 中比较子节点的时候,因为 oldVnode 的子节点的 b,c,d 和 newVnode 的 x,b,c 的key 相同,所以只做比较,没有更新 DOM 的操作,当遍历完毕后,会再把 x 插入到 DOM 上DOM 操作只有一次插入操作。

本站总访问量次,本站总访客数人次
Released under the MIT License.