Vue2总结(九)虚拟DOM

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

目前社区有很多 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中来解释)。

patchVnode算法是:
  • 1.如果oldVnode跟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的文本设置为空字符串;

  • 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">
    <p>Real DOM</p>
    <div>cannot 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.判断属性的更改

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

  1. 遍历旧的属性列表,查看每个属性是否还存在于新的属性列表中;

  2. 遍历新的属性列表,判断两个列表中都存在的属性的值是否有变化;

  3. 在第二步中同时查看是否有属性不存在与旧的属性列列表中;


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 中最核心的算法,对节点作差异遍历处理,也是分为三步:

  1. 遍历旧的节点列表,查看每个节点是否还存在于新的节点列表中;

  2. 遍历新的节点列表,判断是否有新的节点;

  3. 在第二步中同时判断节点是否有移动;


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.遍历子元素打标识

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

  1. 判断两个列表差异;

  2. 给节点打上标记;


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 算法的最后步骤,这个函数主要两个功能:

  1. 深度遍历树,将需要做变更操作的取出来;

  2. 局部更新 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 算法的实现也就是以下三步:

  1. 通过 JS 来模拟创建 DOM 对象;

  2. 判断两个对象的差异;

  3. 渲染差异;


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 就分享到这里,我的公众号没有留言功能哈,有问题大家心里默念,我能感受到,谢谢 ~ 

原文始发于微信公众号(程序员思语):Vue2总结(九)虚拟DOM