Vue2总结(九)虚拟DOM

本人花费半年的时间总结的《Java面试指南》已拿腾讯等大厂offer,已开源在github ,欢迎star!

本文GitHub https://github.com/OUYANGSIHAI/JavaInterview 已收录,这是我花了6个月总结的一线大厂Java面试总结,本人已拿大厂offer,欢迎star

原文链接:blog.ouyangsihai.cn >> Vue2总结(九)虚拟DOM

目前社区有很多 Vue.js 的源码解读文章,但是质量层次不齐,不够系统和全面,最近我将从各个细枝末节解读 Vue.js 的实现原理,针对 vue3 版本之前,让同学们可以彻底掌握 Vue.js。 友情提示:阅读本文大概需要** 65****分钟**

前言

今天的主题是研究DOM和虚拟DOM之间的关系,在介绍虚拟DOM之前,先复习一下浏览器渲染机制,了解浏览器是如何处理原生DOM,从用户发起请求,浏览器根据URL发送请求,服务器响应并正常返回数据之后,浏览器首先

**①.**处理 HTML 并构建 DOM 树;

**②.**处理 CSS 构建 CSSOM 树;

.将 DOM 与 CSSOM 合并成一个渲染树;

**④.**根据渲染树来布局,计算每个节点的位置;

**⑤.**调用 GPU 绘制,合成图层,显示在屏幕上。

在渲染过程中,在构建 CSSOM 树的时候会阻塞渲染,直至 CSSOM 树构建完成。并且构建 CSSOM 树是一个十分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的 CSS 选择器,执行速度越慢。

当 HTML 解析到 script 标签时,会暂停构建 DOM,直到 script 里的内容解析完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件(当然你可以使用异步加载)。并且CSS 也会影响 JS 的执行,只有当解析完样式表才会执行 JS,所以也可以认为这种情况下,CSS 也会暂停构建DOM。从浏览器最基础的DOM渲染过程我们可以了解到原生DOM渲染是十分消耗性能的一种操作,同时也表明了在大型项目中使用更高效渲染方式的必要性,比较流行的前端 JS 框架也渐渐使用了 虚拟DOM这一手段。

DOM

在理解虚拟DOM之前先复习一下什么是DOM,DOM是什么?文档对象模型(Document Object Model),个人觉得是一种对于在浏览器运行的HTML文件的解析出来的一个API文档,(可以结合上次讲到Vue的事件运行机制)DOM树的构建则是浏览器在呈现一个页面中一个重要的步骤,这就意味着每一个浏览器中必然有着DOM,但是由于浏览器种类很多,不同浏览器对DOM的执行是不一样的,尤其像IE浏览器这种非主流的存在。直接使用真实DOM的损耗计算:

总损耗 = 真实DOM完全增删改 + (可能较多的节点)排版与重绘

Virtual Dom

虚拟 DOM 是同样操作 DOM,不过是把 DOM 树抽象成数据对象,Virtual Dom 它可以使我们操作这块的数据对象,用数据对象来呈现 DOM 树,在每次视图渲染的时候 patch 取得最优,这种做法使我们最小量地去修改 DOM,此外 Virtual DOM 还有一个很大的作用是简化 DOM 操作,让数据与 DOM 之间的关系更直观更简单。

当然在不选定场景的讨论都是耍流氓,但当我们需要更新大量的数据的时候,不见得能快很多,甚至要慢(网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?答案:https://www.zhihu.com/question/31809713),针对原生、Vue、React等渲染速度的对比,在性能优化专题再讨论。

针对Vue2的大环境下,虚拟DOM不同于原生DOM操作,虚拟DOM与真实DOM的具体区别:

  • 虚拟DOM不会进行造成排版与重绘操作;
  • 虚拟DOM进行频繁修改,然后一次性比较并修改真实DOM中需要改的部分(DIFF算法),最后并在真实DOM中进行排版与重绘,减少过多DOM节点排版与重绘损耗;
  • 真实DOM频繁排版与重绘的效率是相当低的;
  • 虚拟DOM有效降低大面积(真实DOM节点)的重绘与排版,因为最终与真实DOM比较差异,可以只渲染局部使用虚拟DOM的损耗计算;
  • 总损耗 = 虚拟DOM增删改 + (与Diff算法效率有关)真实DOM差异增删改 + (较少的节点)排版与重绘

    为何需要VDom

    综上所述,操作 真实DOM 是很耗费性能的一件事情,既然如此,我们可以考虑通过 JS对象来模拟 DOM 对象(虚拟DOM),毕竟操作 JS 对象比操作 DOM 省时的多。

    
    // 假设这里模拟一个 ul,其中包含了 5 个 li
    [1, 2, 3, 4, 5]
    // 这里替换上面的 li
    [1, 2, 5, 4]
    

    从上面这个栗子,我们可以看出先前的 ul 中的第三个 li 被移除了,元素 4 5 互相替换了位置。如果以上操作对应到 DOM 中,那么就是以下代码。

    
    // 删除第三个 li
    ul.childNodes[2].remove()
    // 将第四个 li 和第五个交换位置
    let fromNode = ul.childNodes[4]
    let toNode = node.childNodes[3]
    let cloneFromNode = fromNode.cloneNode(true)
    let cloenToNode = toNode.cloneNode(true)
    ul.replaceChild(cloneFromNode, toNode)
    ul.replaceChild(cloenToNode, fromNode)
    

    当然在实际操作中,还需要给每个节点一个标识作为判断是同一个节点的依据。所以这也是 Vue 和 React 中官方推荐列表里的节点使用唯一的 key 来保证性能。既然 DOM 对象可以通过 JS 对象来模拟,反之也可以通过 JS 对象来渲染出对应的 DOM以下是一个 JS 对象模拟 DOM 对象的简单实现。

    
    export default class Element {
     /**
     * @param {String} tag 'div'
     * @param {Object} props { class: 'item' }
     * @param {Array} children [ Element1, 'text']
     * @param {String} key option
     */
     constructor(tag, props, children, key) {
       this.tag = tag
       this.props = props
       if (Array.isArray(children)) {
         this.children = children
       } else if (isString(children)) {
         this.key = children
         this.children = null
     }
       if (key) this.key = key
     }
     // 渲染
     render() {
       let root = this._createElement(
       this.tag,
       this.props,
       this.children,
       this.key
     )
       document.body.appendChild(root)
       return root
     }
     create() {
       return this._createElement(this.tag, this.props, this.children, this.key)
     }
     // 创建节点
     _createElement(tag, props, child, key) {
     // 通过 tag 创建节点
     let el = document.createElement(tag)
     // 设置节点属性
     for (const key in props) {
       if (props.hasOwnProperty(key)) {
         const value = props[key]
         el.setAttribute(key, value)
       }
     }
     if (key) {
       el.setAttribute('key', key)
     }
     // 递归添加子节点
     if (child) {
       child.forEach(element = {
         let child
         if (element instanceof Element) {
           child = this._createElement(
           element.tag,
           element.props,
           element.children,
           element.key
         )
         } else {
           child = document.createTextNode(element)
         } 
         el.appendChild(child)
       })
      }
      return el
     }
    }
    

    Vue VDom算法

    在JQuery时代,我们需要在各个事件方法中直接操作DOM来达到修改视图的目的。综上所述,我们可以将真实DOM树抽象成一棵以JavaScript 对象构成的抽象树,在修改抽象树数据后将抽象树转化成真实DOM重绘到页面上,于是 虚拟DOM 出现了,它是真实DOM的一层抽象,用属性描述真实DOM的各个特性,当它发生变化的时候,就会去修改视图。

    Vue2总结(九)虚拟DOM

    可以想象,最简单粗暴的方法就是将整个DOM结构用innerHTML修改到页面上,但是这样进行重绘整个视图层是相当消耗性能的,我们是不是可以每次只更新它的修改呢?所以Vue.js将DOM抽象成一个以JavaScript对象为节点的虚拟DOM树,以VNode节点模拟真实DOM,可以对这颗抽象树进行创建节点、删除节点以及修改节点等操作,在这过程中都不需要操作真实DOM,只需要操作JavaScript对象后只对差异修改(这一点Vue和React类似),相对于对整块的 innerHTML 进行修改,大大提升了性能。修改以后经过 DIFF 算法得出一些需要修改的最小单位,再将这些小单位的视图进行更新。这样减少了很多不需要的DOM操作,大大提高了性能。

    现在又出现了一个问题,我们只是将虚拟DOM映射成了真实的DOM。那如何给这些DOM加入attr、class、style等DOM属性呢?这要依赖于虚拟DOM的生命钩子函数。里面有对 attr、class、props、events、style以及transition(过渡状态)的DOM属性进行操作。以attr为例,代码很简单。attr只需要在create以及update钩子被调用时更新DOM的 attr 属性即可。

    Vue VNode对象

    一个Vue VNode的实例对象包含了以下属性

  • **tag: **当前节点的标签名
  • **data: **当前节点的数据对象
  • **children: **数组类型,包含了当前节点的子节点
  • **text:** 当前节点的文本
  • **elm:** 当前虚拟节点对应的真实的dom节点
  • **ns: **节点的namespace
  • **context:** 编译作用域
  • **functionalContext: **函数化组件的作用域
  • **key: **节点的key属性,用于作为节点的标识,有利于patch的优化
  • **componentOptions: **创建组件实例时会用到的选项信息
  • **child: **当前节点对应的组件实例
  • **parent:** 组件的占位节点
  • **raw: **raw html
  • **isStatic: **静态节点的标识
  • **isRootInsert: **是否作为根节点插入,被transition 包裹的节点,该属性的值为false
  • **isComment:** 当前节点是否是注释节点
  • **isCloned: **当前节点是否为克隆节点
  • **isOnce:** 当前节点是否有v-once指令
  • Vue VNode分类

    VNode可以理解为vue框架的虚拟dom的基类,通过new实例化的VNode大致可以分为几类:

    Vue2总结(九)虚拟DOM
  • **EmptyVNode: **没有内容的注释节点
  • **TextVNode: **文本节点
  • **ElementVNode: **普通元素节点
  • **ComponentVNode: **组件节点
  • **CloneVNode: **克隆节点,可以是以上任意类型的节点,唯一的区别在于isCloned属性为true
  • Vue2总结(九)虚拟DOM
    
    // createElement解析
    const SIMPLE_NORMALIZE = 1
    const ALWAYS_NORMALIZE = 2
    
    function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {
      // 兼容不传data的情况
      if (Array.isArray(data) || isPrimitive(data)) {
        normalizationType = children
        children = data
        data = undefined
      }
      // 如果alwaysNormalize是true
      // 那么normalizationType应该设置为常量ALWAYS_NORMALIZE的值
      if (alwaysNormalize) normalizationType = ALWAYS_NORMALIZE
      // 调用_createElement创建虚拟节点
      return _createElement(context, tag, data, children, normalizationType)
    }
    
    function _createElement (context, tag, data, children, normalizationType) {
      /**
       * 如果存在data.__ob__,说明data是被Observer观察的数据
       * 不能用作虚拟节点的data
       * 需要抛出警告,并返回一个空节点
       * 
       * 被监控的data不能被用作vnode渲染的数据的原因是:
       * data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作
       */
      if (data && data.__ob__) {
        process.env.NODE_ENV !== 'production' && warn(
          `Avoid using observed data object as vnode data: ${JSON.stringify(data)}n` +
          'Always create fresh vnode data objects in each render!',
          context
        )
        return createEmptyVNode()
      }
      // 当组件的is属性被设置为一个falsy的值
      // Vue将不会知道要把这个组件渲染成什么
      // 所以渲染一个空节点
      if (!tag) {
        return createEmptyVNode()
      }
      // 作用域插槽
      if (Array.isArray(children) &&
          typeof children[0] === 'function') {
        data = data || {}
        data.scopedSlots = { default: children[0] }
        children.length = 0
      }
      // 根据normalizationType的值,选择不同的处理方法
      if (normalizationType === ALWAYS_NORMALIZE) {
        children = normalizeChildren(children)
      } else if (normalizationType === SIMPLE_NORMALIZE) {
        children = simpleNormalizeChildren(children)
      }
      let vnode, ns
      // 如果标签名是字符串类型
      if (typeof tag === 'string') {
        let Ctor
        // 获取标签名的命名空间
        ns = config.getTagNamespace(tag)
        // 判断是否为保留标签
        if (config.isReservedTag(tag)) {
          // 如果是保留标签,就创建一个这样的vnode
          vnode = new VNode(
            config.parsePlatformTagName(tag), data, children,
            undefined, undefined, context
          )
          // 如果不是保留标签,那么我们将尝试从vm的components上查找是否有这个标签的定义
        } else if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
          // 如果找到了这个标签的定义,就以此创建虚拟组件节点
          vnode = createComponent(Ctor, data, context, children, tag)
        } else {
          // 兜底方案,正常创建一个vnode
          vnode = new VNode(
            tag, data, children,
            undefined, undefined, context
          )
        }
        // 当tag不是字符串的时候,我们认为tag是组件的构造类
        // 所以直接创建
      } else {
        vnode = createComponent(tag, data, context, children)
      }
      // 如果有vnode
      if (vnode) {
        // 如果有namespace,就应用下namespace,然后返回vnode
        if (ns) applyNS(vnode, ns)
        return vnode
      // 否则,返回一个空节点
      } else {
        return createEmptyVNode()
      }
    }
    

    patch原理

    patch函数的定义在官方源码的src/core/vdom/patch.js中,我们先来看下这个函数的逻辑。

    patch函数接收6个参数:
  • **oldVnode:** 旧的虚拟节点或旧的真实dom节点
  • **vnode:** 新的虚拟节点
  • **hydrating: **是否要跟真是dom混合
  • **removeOnly: **特殊flag,用于组件
  • **parentElm: **父节点
  • **refElm: **新节点将插入到refElm之前
  • patch的策略是:
  • 1.如果vnode不存在但是oldVnode存在,说明意图是要销毁老节点,那么就调invokeDestroyHook(oldVnode)来进行销毁
  • 2.如果oldVnode不存在但是vnode存在,说明意图是要创建新节点,那么就调用createElm来创建新节点
  • 3.当vnode和oldVnode都存在时 **①.**如果oldVnode和vnode是同一个节点,就调用patchVnode来进行patch; **②.**当vnode和oldVnode不是同一个节点时,如果oldVnode是真实dom节点或hydrating设置为true,需要用hydrate函数将虚拟dom和真是dom进行映射,然后将oldVnode设置为对应的虚拟dom,找到oldVnode.elm的父节点,根据vnode创建一个真实dom节点并插入到该父节点中oldVnode.elm的位置; **③.**这里面值得一提的是patchVnode函数,因为真正的patch算法是由它来实现的(patchVnode中更新子节点的算法其实是在updateChildren函数中实现的,为了便于理解,我统一放到patchVnode中来解释)。

  • 2.如果oldVnode不存在但是vnode存在,说明意图是要创建新节点,那么就调用createElm来创建新节点

    patchVnode算法是:
  • 1.如果oldVnode跟vnode完全一致,那么不需要做任何事情;
  • 2.如果oldVnode跟vnode都是静态节点,且具有相同的key,当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上,也不用再有其他操作;
  • 2.如果oldVnode跟vnode都是静态节点,且具有相同的key,当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上,也不用再有其他操作;

  • 3.否则,如果vnode不是文本节点或注释节点 Ⅰ.如果oldVnode和vnode都有子节点,且2方的子节点不完全一致,就执行更新子节点的操作(这一部分其实是在updateChildren函数中实现),算法如下

  • **①**分别获取oldVnode和vnode的firstChild、lastChild,赋值给oldStartVnode、oldEndVnode、newStartVnode、newEndVnode
  • **②.**如果oldStartVnode和newStartVnode是同一节点,调用patchVnode进行patch,然后将oldStartVnode和newStartVnode都设置为下一个子节点,重复上述流程
  • **③.**如果oldEndVnode和newEndVnode是同一节点,调用patchVnode进行patch,然后将oldEndVnode和newEndVnode都设置为上一个子节点,重复上述流程
  • **④.**如果oldStartVnode和newEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述流程
  • **⑤.**如果newStartVnode和oldEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,然后把newStartVnode设置为下一个节点,oldEndVnode设置为上一个节点,重复上述流程;
  • **⑥.**如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点;
  • **⑦.**如果上一步找到了跟newStartVnode相同key的节点,那么通过其他属性的比较来判断这2个节点是否是同一个节点,如果是,就调用patchVnode进行patch,如果removeOnly是false,就把newStartVnode.elm插入到oldStartVnode.elm之前,把newStartVnode设置为下一个节点,重复上述流程;
  • **⑧.**如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述流程;
  • **⑨.**如果oldStartVnode跟oldEndVnode重合了,并且newStartVnode跟newEndVnode也重合了,这个循环就结束了; Ⅱ.如果只有oldVnode有子节点,那就把这些节点都删除; Ⅲ.如果只有vnode有子节点,那就创建这些子节点; Ⅳ.如果oldVnode和vnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串;
  • **②.**如果oldStartVnode和newStartVnode是同一节点,调用patchVnode进行patch,然后将oldStartVnode和newStartVnode都设置为下一个子节点,重复上述流程

    **④.**如果oldStartVnode和newEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述流程

    **⑥.**如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点;

    **⑧.**如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述流程;

    Ⅱ.如果只有oldVnode有子节点,那就把这些节点都删除;

    Ⅳ.如果oldVnode和vnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串;

  • 4.如果vnode是文本节点或注释节点,但是vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以;
  • 生命周期

    patch提供了5个生命周期钩子,分别是:

  • create: 创建patch时
  • activate: 激活组件时
  • update: 更新节点时
  • remove: 移除节点时
  • destroy: 销毁节点时
  • 这些钩子是提供给Vue内部的directives/ref/attrs/style等模块使用的,方便这些模块在patch的不同阶段进行相应的操作,这里模块定义在src/core/vdom/modules和src/platforms/web/runtime/modules2个目录中vnode也提供了生命周期钩子,分别是:

  • init: vdom初始化时
  • create: vdom创建时
  • prepatch: patch之前
  • insert: vdom插入后
  • update: vdom更新前
  • postpatch: patch之后
  • remove: vdom移除时
  • destroy: vdom销毁时
  • vue组件的生命周期底层其实就依赖于vnode的生命周期,在src/core/vdom/create-component.js中我们可以看到,vue为自己的组件vnode已经写好了默认的init/prepatch/insert/destroy,而vue组件的mounted/activated就是在insert中触发的,deactivated就是在destroy中触发的。

    React VDOM

    既然讨论了Vue的虚拟DOM,那么连同React一同比较了。React 号称组件式开发,中最神奇的部分莫过于虚拟 DOM,以及其高效的 Diff 算法(经过多次改良)。这让我们可以无需担心性能问题而“毫无顾忌”的随时“刷新”整个页面,由虚拟 DOM 来确保只对界面上真正变化的部分进行实际的 DOM 操作。React 在这一部分已经做到足够透明,在实际开发中我们基本无需关心虚拟 DOM 是如何运作的。然而,作为有态度的程序员,我们总是对技术背后的原理充满着好奇。理解其运行机制不仅有助于更好的理解 React 组件的生命周期,而且对于进一步优化 React 程序也会有很大帮助。Web 界面由 DOM 树来构成,当其中某一部分发生变化时,其实就是对应的某个 DOM 节点发生了变化。在 React 中,构建 UI 界面的思路是由当前状态决定界面。前后两个状态就对应两套界面,然后由 React 来比较两个界面的区别,这就需要对 DOM 树进行 Diff 算法分析。即给定任意两棵树,找到最少的转换步骤。但是标准的的 Diff 算法复杂度需要 O(n^3),这显然无法满足性能要求。要达到每次界面都可以整体刷新界面的目的,势必需要对算法进行优化。这看上去非常有难度,然而 Facebook 工程师却做到了,他们结合 Web 界面的特点做出了两个简单的假设,使得 Diff 算法复杂度直接降低到 O(n)。

    Vue2总结(九)虚拟DOM

    **①.**两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构;

    **②.**对于同一层次的一组子节点,它们可以通过唯一的 id 进行区分。算法上的优化是 React 整个界面 Render 的基础,事实也证明这两个假设是合理而精确的,保证了整体界面构建的性能。

    Facebook团队经过多次改良将 Diff 算法复杂度控制在了 O(n),这让我们考虑 UI 时可以完全基于状态来每次 render 整个界面而无需担心性能问题,简化了 UI 开发的复杂度。而算法优化的基础是刚刚提到的2个假设,以及 React 的 UI 基于组件这样的一个机制。理解虚拟 DOM Diff 算法不仅能够帮助我们理解组件的生命周期,而且也对我们实现自定义组件时如何进一步优化性能具有指导意义。

    此外Diff算法只是为了虚拟DOM比较替换效率更高,通过D iff 算法得到 Diff 算法结果数据表(需要进行哪些操作记录表)。原本要操作的DOM在 vue 这边还是要操作的,只不过用到了 js 的DOM Fragment来操作 DOM(统一计算出所有变化后统一更新一次DOM)进行浏览器DOM一次性更新。其实DOM Fragment我们不用平时发开也能用,但是这样程序员写业务代码就用把DOM操作放到fragment里,这就是框架的价值,程序员才能专注于写业务代码。(据尤大大在 Vue3 的公开演讲中介绍 Vue3 将在此环节优化提升更多,使框架更高效更便捷)。

    
    div id="real-container"
        pReal DOM/p
        divcannot update/div
        ul
            li className="item"Item 1/li
            li className="item"Item 2/li
            li className="item"Item 3/li
        /ul
    /div
    
    const tree = Element('div', { id: 'virtual-container' }, [
        Element('p', {}, ['Virtual DOM']),
        Element('div', {}, ['before update']),
        Element('ul', {}, [
            Element('li', { class: 'item' }, ['Item 1']),
            Element('li', { class: 'item' }, ['Item 2']),
            Element('li', { class: 'item' }, ['Item 3']),
        ]),
    ]);
    
    const root = tree.render();
    document.getElementById('virtualDom').appendChild(root);
    
    // 用js对象模拟DOM节点的好处是,页面的更新可以先全部反映在js对象上,
    // 操作内存中的js对象的速度显然要快多了。
    // 等更新完后,再将最终的js对象映射成真实的DOM,交由浏览器去绘制。
    // 那具体怎么实现呢?看一下Element方法的具体实现:
    
    function Element(tagName, props, children) {
        if (!(this instanceof Element)) {
            return new Element(tagName, props, children);
        }
    
        this.tagName = tagName;
        this.props = props || {};
        this.children = children || [];
        this.key = props ? props.key : undefined;
    
        let count = 0;
        this.children.forEach((child) = {
            if (child instanceof Element) {
                count += child.count;
            }
            count++;
        });
        this.count = count;
    }
    // 第一个参数是节点名(如div),第二个参数是节点的属性(如class),第三个参数是子节点(如ul的li)
    // 除了这三个参数会被保存在对象上外,还保存了key和count。
    

    Virtual Dom 算法实现

    既然我们已经简单分析了 Vue 和 React 的 VDOM 思路,并且通过 JS 来模拟实现了 真实DOM,那么接下来的难点就在于如何判断旧的对象和新的对象之间的差异。DOM 是多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3),这个复杂度肯定是不能接受的。于是 React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。所以判断差异的算法就分为了两步? 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引,便于最后渲染差异,一旦节点有子元素,就去判断子元素是否有不同。

    Vue2总结(九)虚拟DOM
    1.树的递归

    首先来实现树的递归算法,先来考虑下两个节点对比会有几种情况新的节点的 tagName 或者 key 和旧的不同,这种情况代表需要替换旧的节点,并且也不再需要遍历新旧节点的子元素了,因为整个旧节点都被删掉了新的节点的 tagName 和 key(可能都没有)和旧的相同,开始遍历子树没有新的节点,则无需操作。

    
    import { StateEnums, isString, move } from './util'
    import Element from './element'
    
    export default function diff(oldDomTree, newDomTree) {
      // 用于记录差异
      let pathchs = {}
      // 一开始的索引为 0
      dfs(oldDomTree, newDomTree, 0, pathchs)
      return pathchs
    }
    
    function dfs(oldNode, newNode, index, patches) {
      // 用于保存子树的更改
      let curPatches = []
      // 需要判断三种情况
      // 1.没有新的节点,那么什么都不用做
      // 2.新的节点的 tagName 和 `key` 和旧的不同,就替换
      // 3.新的节点的 tagName 和 key(可能都没有) 和旧的相同,开始遍历子树
      if (!newNode) {
      } else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
        // 判断属性是否变更
        let props = diffProps(oldNode.props, newNode.props)
        if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })
        // 遍历子树
        diffChildren(oldNode.children, newNode.children, index, patches)
      } else {
        // 节点不同,需要替换
        curPatches.push({ type: StateEnums.Replace, node: newNode })
      }
    
      if (curPatches.length) {
        if (patches[index]) {
          patches[index] = patches[index].concat(curPatches)
        } else {
          patches[index] = curPatches
        }
      }
    }
    
    2.判断属性的更改

    判断属性的更改也分三个步骤:

  • 遍历旧的属性列表,查看每个属性是否还存在于新的属性列表中;
  • 遍历新的属性列表,判断两个列表中都存在的属性的值是否有变化;
  • 在第二步中同时查看是否有属性不存在与旧的属性列列表中;
  • 
    function diffProps(oldProps, newProps) {
      // 判断 Props 分以下三步骤
      // 先遍历 oldProps 查看是否存在删除的属性
      // 然后遍历 newProps 查看是否有属性值被修改
      // 最后查看是否有属性新增
      let change = []
      for (const key in oldProps) {
        if (oldProps.hasOwnProperty(key) && !newProps[key]) {
          change.push({
            prop: key
          })
        }
      }
      for (const key in newProps) {
        if (newProps.hasOwnProperty(key)) {
          const prop = newProps[key]
          if (oldProps[key] && oldProps[key] !== newProps[key]) {
            change.push({
              prop: key,
              value: newProps[key]
            })
          } else if (!oldProps[key]) {
            change.push({
              prop: key,
              value: newProps[key]
            })
          }
        }
      }
      return change
    }
    
    3.判断列表差异算法实现

    这个算法是整个 Virtual Dom 中最核心的算法,对节点作差异遍历处理,也是分为三步:

  • 遍历旧的节点列表,查看每个节点是否还存在于新的节点列表中;
  • 遍历新的节点列表,判断是否有新的节点;
  • 在第二步中同时判断节点是否有移动;
  • 
    function listDiff(oldList, newList, index, patches) {
      // 为了遍历方便,先取出两个 list 的所有 keys
      let oldKeys = getKeys(oldList)
      let newKeys = getKeys(newList)
      let changes = []
    
      // 用于保存变更后的节点数据
      // 使用该数组保存有以下好处
      // 1.可以正确获得被删除节点索引
      // 2.交换节点位置只需要操作一遍 DOM
      // 3.用于 `diffChildren` 函数中的判断,只需要遍历
      // 两个树中都存在的节点,而对于新增或者删除的节点来说,完全没必要
      // 再去判断一遍
      let list = []
      oldList &&
        oldList.forEach(item = {
          let key = item.key
          if (isString(item)) {
            key = item
          }
          // 寻找新的 children 中是否含有当前节点
          // 没有的话需要删除
          let index = newKeys.indexOf(key)
          if (index === -1) {
            list.push(null)
          } else list.push(key)
        })
      // 遍历变更后的数组
      let length = list.length
      // 因为删除数组元素是会更改索引的
      // 所有从后往前删可以保证索引不变
      for (let i = length - 1; i = 0; i--) {
        // 判断当前元素是否为空,为空表示需要删除
        if (!list[i]) {
          list.splice(i, 1)
          changes.push({
            type: StateEnums.Remove,
            index: i
          })
        }
      }
      // 遍历新的 list,判断是否有节点新增或移动
      // 同时也对 `list` 做节点新增和移动节点的操作
      newList &&
        newList.forEach((item, i) = {
          let key = item.key
          if (isString(item)) {
            key = item
          }
          // 寻找旧的 children 中是否含有当前节点
          let index = list.indexOf(key)
          // 没找到代表新节点,需要插入
          if (index === -1  || key == null) {
            changes.push({
              type: StateEnums.Insert,
              node: item,
              index: i
            })
            list.splice(i, 0, key)
          } else {
            // 找到了,需要判断是否需要移动
            if (index !== i) {
              changes.push({
                type: StateEnums.Move,
                from: index,
                to: i
              })
              move(list, index, i)
            }
          }
        })
      return { changes, list }
    }
    
    function getKeys(list) {
      let keys = []
      let text
      list &&
        list.forEach(item = {
          let key
          if (isString(item)) {
            key = [item]
          } else if (item instanceof Element) {
            key = item.key
          }
          keys.push(key)
        })
      return keys
    }
    
    4.遍历子元素打标识

    对于这个函数来说,主要功能就两个:

  • 判断两个列表差异;
  • 给节点打上标记;

  • 
    function diffChildren(oldChild, newChild, index, patches) {
      let { changes, list } = listDiff(oldChild, newChild, index, patches)
      if (changes.length) {
        if (patches[index]) {
          patches[index] = patches[index].concat(changes)
        } else {
          patches[index] = changes
        }
      }
      // 记录上一个遍历过的节点
      let last = null
      oldChild &&
        oldChild.forEach((item, i) = {
          let child = item && item.children
          if (child) {
            index =
              last && last.children ? index + last.children.length + 1 : index + 1
            let keyIndex = list.indexOf(item.key)
            let node = newChild[keyIndex]
            // 只遍历新旧中都存在的节点,其他新增或者删除的没必要遍历
            if (node) {
              dfs(item, node, index, patches)
            }
          } else index += 1
          last = item
        })
    }
    
    5.渲染差异

    通过前面的算法,可以得出两个状态树的差异,随后就可以根据需要局部去更新 DOM 了,下面就是 Virtual Dom 算法的最后步骤,这个函数主要两个功能:

  • 深度遍历树,将需要做变更操作的取出来;
  • 局部更新 DOM;
  • 
    let index = 0
    export default function patch(node, patchs) {
      let changes = patchs[index]
      let childNodes = node && node.childNodes
      // 这里的深度遍历和 diff 中是一样的
      if (!childNodes) index += 1
      if (changes && changes.length && patchs[index]) {
        changeDom(node, changes)
      }
      let last = null
      if (childNodes && childNodes.length) {
        childNodes.forEach((item, i) = {
          index =
            last && last.children ? index + last.children.length + 1 : index + 1
          patch(item, patchs)
          last = item
        })
      }
    }
    
    function changeDom(node, changes, noChild) {
      changes &&
        changes.forEach(change = {
          let { type } = change
          switch (type) {
            case StateEnums.ChangeProps:
              let { props } = change
              props.forEach(item = {
                if (item.value) {
                  node.setAttribute(item.prop, item.value)
                } else {
                  node.removeAttribute(item.prop)
                }
              })
              break
            case StateEnums.Remove:
              node.childNodes[change.index].remove()
              break
            case StateEnums.Insert:
              let dom
              if (isString(change.node)) {
                dom = document.createTextNode(change.node)
              } else if (change.node instanceof Element) {
                dom = change.node.create()
              }
              node.insertBefore(dom, node.childNodes[change.index])
              break
            case StateEnums.Replace:
              node.parentNode.replaceChild(change.node.create(), node)
              break
            case StateEnums.Move:
              let fromNode = node.childNodes[change.from]
              let toNode = node.childNodes[change.to]
              let cloneFromNode = fromNode.cloneNode(true)
              let cloenToNode = toNode.cloneNode(true)
              node.replaceChild(cloneFromNode, toNode)
              node.replaceChild(cloenToNode, fromNode)
              break
            default:
              break
          }
        })
    }
    
    6.最后

    最终 Virtual Dom 算法的实现也就是以下三步:

  • 通过 JS 来模拟创建 DOM 对象;
  • 判断两个对象的差异;
  • 渲染差异;

  • 
    let test4 = new Element('div', { class: 'my-div' }, ['test4'])
    let test5 = new Element('ul', { class: 'my-div' }, ['test5'])
    
    let test1 = new Element('div', { class: 'my-div' }, [test4])
    
    let test2 = new Element('div', { id: '11' }, [test5, test4])
    
    let root = test1.render()
    
    let pathchs = diff(test1, test2)
    console.log(pathchs)
    
    setTimeout(() = {
      console.log('开始更新')
      patch(root, pathchs)
      console.log('结束更新')
    }, 1000)
    

    经过以上六个步骤就完成了一个轻量版的虚拟DOM算法,可以帮助更好地理解DIFF算法和虚拟DOM的实现过程。

    参考

    1、深度剖析:如何实现一个 Virtual DOM 算法(作者:戴嘉华)

    2、Why Virtual DOM(作者:Sai Kishore Komanduri)

    3、虚拟DOM Diff算法解析(作者:王沛)

    4、深入浅出React和Redux(作者:程墨)

    总结

    虚拟DOM的目的是将所有操作累加起来,统计计算出所有的变化后,统一更新一次DOM。其实即使不懂原理,业务代码照样写,像JQuery这种就属于以选择器为导向的框架,Vue和React属于有明确分层架构的MV*框架,而虚拟DOM属于框架中的节点模块,也是核心模块,理解了虚拟DOM,理解类似框架也就相对轻松,今后出现类型框架也不犯愁了。前端就是这样痛并快乐着。

    最后

    今天的 Vue2总结(九)虚拟DOM 就分享到这里,我的公众号没有留言功能哈,有问题大家心里默念,我能感受到,谢谢 ~ 

    原文始发于微信公众号(程序员思语):

    本人花费半年的时间总结的《Java面试指南》已拿腾讯等大厂offer,已开源在github ,欢迎star!

    本文GitHub https://github.com/OUYANGSIHAI/JavaInterview 已收录,这是我花了6个月总结的一线大厂Java面试总结,本人已拿大厂offer,欢迎star

    原文链接:blog.ouyangsihai.cn >> Vue2总结(九)虚拟DOM


     上一篇
    Vue2总结(十)异步 Vue2总结(十)异步
    目前社区有很多 Vue.js 的源码解读文章,但是质量层次不齐,不够系统和全面,最近我将从各个细枝末节解读 Vue.js 的实现原理,针对 vue3 版本之前,让同学们可以彻底掌握 Vue.js。 友情提示:阅读本文大概需要** 34*
    2021-04-05
    下一篇 
    Vue2总结(八)Template模版编译 Vue2总结(八)Template模版编译
    目前社区有很多 Vue.js 的源码解读文章,但是质量层次不齐,不够系统和全面,最近我将从各个细枝末节解读 Vue.js 的实现原理,针对 vue3 版本之前,让同学们可以彻底掌握 Vue.js。 友情提示:阅读本文大概需要** 30*
    2021-04-05