虚拟DOM与DIFF算法学习

59 阅读9分钟

虚拟DOM(virtual DOM)

什么是虚拟DOM

通俗的讲,虚拟DOM就是一个JavaScript对象,它是真实DOM的抽象,只保留一些有用的信息,更轻量地描述DOM树的结构。

为什么需要虚拟DOM

1、可以利用MVVM框架解决视图和框架同步问题

2、模板引擎可以简化视图操作,但是没有办法跟踪状态,可以利用虚拟DOM跟踪状态状态(可以获取到上一次状态,通过比较前后两种状态差异更新真实DOM)

3、DOM属性过多,频繁改动操作DOM性能消耗很大(尤其在复杂视图下),相对而言虚拟DOM只是虚拟JavaScript对象,描述属性不是很多,开销较小;

4、无需开发人员手动操作

下面可以感受下二者的区别:

模拟一个虚拟DOM:

// 构造虚拟DOM对象类
    function Element(tagName, props, children) {
      this.tagName = tagName;
      this.props = props;
      this.children = children;
    }
    // 创建虚拟DOM
    function createElement(tagName, props, children) {
      return new Element(tagName, props, children)
    }
    //  <ul class="ul-wrap">
    //     <li class="li-item">1</li>
    //     <li class="li-item">2</li>
    //     <li class="li-item">3</li>
    //  </ul>
    // 假设我们有如上的DOM结构, 那我我们就可以利用虚拟DOM模拟出一个类似的DOM树结构
    let VDOM = createElement("ul", {
        class: "ul-wrap",
    }, [
        createElement("li", {
            class: "li-item"
        }, ["1"]),
        createElement("li", {
            class: "li-item"
        }, ["2"]),
        createElement("li", {
            class: "li-item"
        }, ["3"]),
    ]);
    console.dir(JSON.stringify(VDOM,null,2));

其中的props是虚拟dom的属性

image.png

或者直接利用vue中的h()函数进行创建vnode:

// 在vue实例中
import { h } from 'vue'
const vnode = h(
    'ul',//type
    { id: 'foo', class: 'bar' }, //props
    [
    // children
    ]
    )

image.png

再来看下真实的dom:

HTMLCollection [ul.ul-wrap]
  0: ul.ul-wrap
      accessKey: ""
      ariaAtomic: null
      ariaAutoComplete: null
      ariaBusy: null
      ariaChecked: null
      ariaColCount: null
      ariaColIndex: null
      ariaColSpan: null
      ariaCurrent: null
      ariaDescription: null
      ariaDisabled: null
      ariaExpanded: null
      ariaHasPopup: null
      ariaHidden: null
      ariaKeyShortcuts: null
      ariaLabel: null
      ariaLevel: null
      ariaLive: null
      ariaModal: null
      ariaMultiLine: null
      ariaMultiSelectable: null
      ariaOrientation: null
      ariaPlaceholder: null
      ariaPosInSet: null
      ariaPressed: null
      ariaReadOnly: null
      ariaRelevant: null
      ariaRequired: null
      ariaRoleDescription: null
      ariaRowCount: null
      ariaRowIndex: null
      ariaRowSpan: null
      ariaSelected: null
      ariaSetSize: null
      ariaSort: null
      ariaValueMax: null
      ariaValueMin: null
      ariaValueNow: null
      ariaValueText: null
      assignedSlot: null
      attributeStyleMap: StylePropertyMap {size: 0}
      attributes: NamedNodeMap {0: class, class: class, length: 1}
      autocapitalize: ""
      autofocus: false
      baseURI: "file:///E:/demo/test3.html"
      childElementCount: 3
      childNodes: NodeList(7) [text, li.li-item, text, li.li-item, text, li.li-item, text]
      children: HTMLCollection(3) [li.li-item, li.li-item, li.li-item]
      classList: DOMTokenList ["ul-wrap", value: "ul-wrap"]
      className: "ul-wrap"
      clientHeight: 63
      clientLeft: 0
      clientTop: 0
      clientWidth: 1154
      compact: false
      contentEditable: "inherit"
      dataset: DOMStringMap {}
      dir: ""
      draggable: false
      elementTiming: ""
      enterKeyHint: ""
      firstChild: text
      firstElementChild: li.li-item
      hidden: false
      id: ""
      innerHTML: "\n    <li class=\"li-item\">1</li>\n    <li class=\"li-item\">2</li>\n    <li class=\"li-item\">3</li>\n  "
      innerText: "1\n2\n3"
      inputMode: ""
      isConnected: true
      isContentEditable: false
      lang: ""
      lastChild: text
      lastElementChild: li.li-item
      localName: "ul"
      namespaceURI: "http://www.w3.org/1999/xhtml"
      nextElementSibling: script
      nextSibling: text
      nodeName: "UL"
      nodeType: 1
      nodeValue: null
      nonce: ""
      offsetHeight: 63
      offsetLeft: 8
      offsetParent: body
      offsetTop: 16
      offsetWidth: 1154
      onabort: null
      onanimationend: null
      onanimationiteration: null
      onanimationstart: null
      onauxclick: null
      onbeforecopy: null
      onbeforecut: null
      onbeforepaste: null
      onbeforexrselect: null
      onblur: null
      oncancel: null
      oncanplay: null
      oncanplaythrough: null
      onchange: null
      onclick: null
      onclose: null
      oncontextmenu: null
      oncopy: null
      oncuechange: null
      oncut: null
      ondblclick: null
      ondrag: null
      ondragend: null
      ondragenter: null
      ondragleave: null
      ondragover: null
      ondragstart: null
      ondrop: null
      ondurationchange: null
      onemptied: null
      onended: null
      onerror: null
      onfocus: null
      onformdata: null
      onfullscreenchange: null
      onfullscreenerror: null
      ongotpointercapture: null
      oninput: null
      oninvalid: null
      onkeydown: null
      onkeypress: null
      onkeyup: null
      onload: null
      onloadeddata: null
      onloadedmetadata: null
      onloadstart: null
      onlostpointercapture: null
      onmousedown: null
      onmouseenter: null
      onmouseleave: null
      onmousemove: null
      onmouseout: null
      onmouseover: null
      onmouseup: null
      onmousewheel: null
      onpaste: null
      onpause: null
      onplay: null
      onplaying: null
      onpointercancel: null
      onpointerdown: null
      onpointerenter: null
      onpointerleave: null
      onpointermove: null
      onpointerout: null
      onpointerover: null
      onpointerrawupdate: null
      onpointerup: null
      onprogress: null
      onratechange: null
      onreset: null
      onresize: null
      onscroll: null
      onsearch: null
      onseeked: null
      onseeking: null
      onselect: null
      onselectionchange: null
      onselectstart: null
      onstalled: null
      onsubmit: null
      onsuspend: null
      ontimeupdate: null
      ontoggle: null
      ontransitioncancel: null
      ontransitionend: null
      ontransitionrun: null
      ontransitionstart: null
      onvolumechange: null
      onwaiting: null
      onwebkitanimationend: null
      onwebkitanimationiteration: null
      onwebkitanimationstart: null
      onwebkitfullscreenchange: null
      onwebkitfullscreenerror: null
      onwebkittransitionend: null
      onwheel: null
      outerHTML: "<ul class=\"ul-wrap\">\n    <li class=\"li-item\">1</li>\n    <li class=\"li-item\">2</li>\n    <li class=\"li-item\">3</li>\n  </ul>"
      outerText: "1\n2\n3"
      ownerDocument: document
      parentElement: body
      parentNode: body
      part: DOMTokenList [value: ""]
      prefix: null
      previousElementSibling: null
      previousSibling: text
      scrollHeight: 63
      scrollLeft: 0
      scrollTop: 0
      scrollWidth: 1154
      shadowRoot: null
      slot: ""
      spellcheck: true
      style: CSSStyleDeclaration {additiveSymbols: "", alignContent: "", alignItems: "", alignSelf: "", alignmentBaseline: "", …}
      tabIndex: -1
      tagName: "UL"
      textContent: "\n    1\n    2\n    3\n  "
      title: ""
      translate: true
      type: ""
  __proto__: HTMLUListElement
  length: 1
  __proto__: HTMLCollection

由此可以直观的感受到虚拟DOM和真实DOM的差别,真实DOM的属性太多了,创建DOM节点的开销十分的大,相比之下,虚拟DOM是不是十分的小鸟依人!!但是:

那使用了虚拟DOM一定会比直接渲染真实DOM快吗?

答案当然是否定的,这种说法是不严谨的,所谓的“性能更好、更快”是指在频繁修改DOM的情况下运用虚拟DOM与DIFF算法结合,从而提升DOM的复用,减少无谓的节点创建等从而减少性能消耗,实际上底层还是操作的DOM,所以确切一点的说法是虚拟DOM是比操作不当的原生DOM快、性能更佳,下面直接引用大佬的比较过程:

image.png

首次渲染👇不采用虚拟DOM的步骤

  1. 浏览器接受绘制指令
  2. 创建所有节点

首次渲染👇采用虚拟DOM的步骤

  1. 浏览器接受绘制指令
  2. 创建虚拟DOM
  3. 创建所有节点

首次渲染或者所有节点都需要进行更新的时候。这个时候采用虚拟DOM会比直接操作原生DOM多一重构建虚拟DOM树的操作。这会更大的占用内存和延长渲染时间。

其实,无论是Vue还是React,其引用虚拟DOM的核心目的是为了提高开发效率而不是性能,有虚拟DOM之后,无需我们再关注DOM的操作,可以更加集中在数据的改变上;

虚拟DOM的优势在于我们更新节点时候。它会检查哪些节点需要更新。尽量复用已有DOM,减少DOM的删除和重新创建,从而尽量减少开销。并且这些操作我们是可以通过自己手动操作javascript底层api实现的。只是我们手动操作会非常耗费我们的时间和精力。这个工作由虚拟DOM代劳,会让我们开发更快速便捷。

虚拟DOM库

 Snabbdom

Vue.js2.x内部使用的虚拟DOM就是改造的Snabbdom

大约200SLOC(single line of code)

通过模块可扩展

源码使用TypeScript开发

最快的Virtual DOM之一

snabbdom的核心

1、init()设置模块.创建patch()函数

2、使用h()函数生成vnode函数,再用vnode函数创建JavaScript对象(Vnode)描述真实DOM

3、patch()比较新旧两个Vnode,把变化的内容更新到真实DOM树

DIFF算法

Diff 的出现,就是为了减少更新量,找到最小差异部分DOM,只更新差异部分DOM

diff 算法要明确一个概念就是 Diff 的对象是虚拟DOM(virtual dom),更新真实 DOM 是 Diff 算法的结果。

做法

1、根节点直接比较

2、只有两个新旧节点是相同节点的时候,才会去比较他们各自的子节点

3、同层级比较,不需要递归

根节点直接比较,父节点相同才比较子节点

比如下图出现的 四次比较(从 first 到 fouth),他们的共同特点都是有 相同的父节点

比如 蓝色方的比较,新旧子节点的父节点是相同节点 1

比如 红色方的比较,新旧子节点的父节点都是 2

所以他们才有比较的机会

image.png

而下图中,只有两次比较,就是因为在 蓝色方 比较中,并没有相同节点,所以不会再进行下级子节点比较

image.png

Diff 比较的内核是 节点复用,所以 Diff 比较就是为了在 新旧节点中 找到 相同的节点

这个的比较逻辑是建立在上一步说过的同层比较基础之上的

所以说,节点复用,找到相同节点并不是无限制递归查找

比如下图中,的确 旧节点树 和 新节点树 中有相同节点 6,但是然并卵,旧节点6并不会被复用

image.png

就算在同一层级,然而父节点不一样,依旧然并卵

image.png

只有这种情况的节点会被复用,相同父节点 8

image.png

下面说说 Diff 的比较逻辑

1、能不移动,尽量不移动

2、没得办法,只好移动

3、实在不行,新建或删除

比较处理流程是下面这样

在新旧节点中

1、先找到 不需要移动的相同节点,消耗最小

2、再找相同但是需要移动的节点,消耗第二小

3、最后找不到,才会去新建删除节点,保底处理

比较是为了修改DOM 树

其实这里存在 三种树,一个是 页面DOM 树,一个是 旧VNode 树,一个是 新 Vnode 树

页面DOM 树 和 旧VNode 树 节点一一对应的

而 新Vnode 树则是表示更新后 页面DOM 树 该有的样子

这里把 旧Vnode 树 和 新Vnode树 进行比较的过程中

不会对这两棵Vode树进行修改,而是以比较的结果直接对 真实DOM 进行修改

比如说,在 旧 Vnode 树同一层中,找到 和 新Vnode 树 中一样但位置不一样节点

此时需要移动这个节点,但是不是移动 旧 Vnode 树 中的节点

而是 直接移动 DOM

总的来说,新旧 Vnode 树是拿来比较的,页面DOM 树是拿来根据比较结果修改的

如果你有点懵,我们就来就简单说个例子

Diff 简单例子

比如下图存在这两棵 需要比较的新旧节点树 和 一棵 需要修改的页面 DOM树

第一轮比较开始

因为父节点都是 1,所以开始比较他们的子节点

按照我们上面的比较逻辑,所以先找 相同 && 不需移动 的点

毫无疑问,找到 2

拿到比较结果,这里不用修改DOM,所以 DOM 保留在原地

第二轮比较开始

然后,没有 相同 && 不需移动 的节点 了

只能第二个方案,开始找相同的点

找到 节点5,相同但是位置不同,所以需要移动

拿到比较结果,页面 DOM 树需要移动DOM 了,不修改,原样移动

第三轮比较开始

继续,哦吼,相同节点也没得了,没得办法了,只能创建了

所以要根据 新Vnode 中没找到的节点去创建并且插入

然后旧Vnode 中有些节点不存在 新VNode 中,所以要删除

于是开始创建节点 6 和 9,并且删除节点 3 和 4

vue2/vue3中的diff算法简单对比

1、双端DIFF算法

在新旧节点的开始与末尾,分别有一个指针指向,然后在比较过程中往中间收拢;

startIndex不断增大,endIndex不断减小,

oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx时继续比较,老节点或者新节点的开始位置大于结束位置,即startIndex大于endIndex时比较完毕 image.png

源码可以参考Vue的diff算法解析

2、快速DIFF算法

vue2.x中的虚拟dom是进行 「全量的对比」,在运行时会对所有节点生成一个虚拟节点树,当页面数据发生变更好,会遍历判断virtual dom所有节点 (包括一些不会变化的节点) 有没有发生变化;虽然说diff算法确实减少了对DOM节点的直接操作,但是这个 「减少是有成本的」,如果是复杂的大型项目,必然存在很复杂的父子关系的VNode,「而Vue2.x的diff算法,会不断地递归调用 patchVNode,不断堆叠而成的几毫秒,最终就会造成 VNode 更新缓慢」

动静结合 PatchFlag
<div>
  <div>{msg}</div>
  <div>静态文字</div>
</div>

在Vue3.0中,在这个模版编译时,编译器会在动态标签末尾加上 /* Text*/ PatchFlag「也就是在生成VNode的时候,同时打上标记,在这个基础上再进行核心的diff算法」 并且 PatchFlag 会标识动态的属性类型有哪些,比如这里 的TEXT 表示只有节点中的文字是动态的。而patchFlag的类型也很多,这里暂时不细究。

看源码:

export function render(_ctx, _cache, $props, $setup, $data, $options) {
 return (_openBlock(), _createBlock("div", null, [
  _createVNode("p", null, "'HelloWorld'"),
  _createVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
 ]))
}
****

这里的_createVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)就是对变量节点进行标记。

总结:「Vue3.0对于不参与更新的元素,做静态标记并提示,只会被创建一次,在渲染时直接复用。」

其中还有cacheHandlers(事件侦听器缓存),以后再了解。