Vue 原理之从新建实例到 Diff

700 阅读5分钟

写在前面

本文为 Vue Diff 内容的引文,主要阐述 Virtual DOM 相关概念以及从新建 Vue 实例到 Diff 的代码流程,为 Diff 算法的学习做储备。

具体的 Diff 算法内容在后续文章中再深入介绍。→ Vue 原理之图解 Diff 流程

关于 Virtual DOM

开篇,先了解下 Virtual DOM (VM) 定义,Diff 在其中扮演的角色,Vue 与 VM 的关系等基本概念。

VM 是什么

支持 element 创建、diff 计算、patch 操作的 JavaScript DOM 模型,实现高效的重新渲染。

Virtual DOM 本质为 JS 对象,用于描述真实 DOM:

以上是 Vue 定义 VM(vnode) 对象的例子。其中,vnode 有elm属性指向(引用)其对应的真实 DOM。

为什么要使用 VM

前端发展至今,单纯的静态网页早已不能满足需求,动态页面则涉及到页面与数据同步、页面重绘时操作 DOM 开销的问题。为了解决这些问题,渲染方式不断演进,出现了 Virtual DOM 的解决方案:

  • 原生操作 DOM 与数据进行同步

    • 过程比较繁琐复杂,比如需考虑浏览器兼容问题等
    • 很难跟踪之前的 DOM 状态,只能删除界面元素然后重新创建
    • 渲染速度慢,页面输入元素失焦,且消耗性能
  • MVVM 框架简化了 DOM 的复杂操作

    • 可以使用模板引擎简化视图的操作,达到视图与数据的同步
    • 但没有解决跟踪 DOM 状态变化的问题
  • Virtual DOM,维护了视图与状态的关系

    • 使用虚拟 DOM 记录真实 DOM 的状态
    • 当数据改变时不需立即更新 DOM,而是创建新的 VM 来抽象描述
    • 通过 Diff 算法对比新旧的 VM,只更新数据发生变化的 DOM 元素,实现高效更新

因为浏览器操作 JS 对象比操作 DOM 对象的开销小,所以可以减小开销。

VM 的目的是尽量减少浏览器的重绘和重排,所以对于复杂的视图才能提升渲染性能。

Snabbdom

Snabbdom 是基于 VM 原始库 virtual-dom 进行了优化的开源库,Vue 2.x 的 Virtual DOM 是在 Snabbdom 的基础上进行的改造。

了解 Snabbdom 可以对 VM 框架以及 Vue 的部分处理有一个概念。

Snabbdom 核心模块简单,性能强,约只有200行代码;具有可丰富功能的模块化体系结构,可通过自定义模块进行扩展。简单例子:

// 引入 snabbdom 方法
import {h, init} from 'snabbdom'

/** patch 对比函数
 * 第一个参数:可以是 DOM 元素,内部会转换为 vnode;也可以是 vnode
 * 第二个参数:vnode
 * 返回:vnode
 */
const patch = init([]);  // 样式、监听事件等模块在此数组参数中注册

// 存放最新的dom元素
let vnode = h('div#container', [
  h('h1', 'Hello World'),
  h('p', 'this is a snabbdom test')
]);
// 获取当前的 DOM 节点
const app = document.querySelector('#app');
// 替换app,返回页面dom对应的 vnode,存放至 oldNode,以作对比
let oldNode = patch(app, vnode);

// 5s 后更新 div 内容
setTimeout(()=> {
  vnode = h('div#container', 'Hello snabbdom');
  // 对比新旧 vnode,并更新 DOM
  patch(oldNode, vnode);
}, 5000);

核心:

  • 使用 init() 设置模块,创建 patch() 函数
  • 使用 h() 函数创建 JS 对象(vnode)描述真实 DOM
  • 使用 patch() 比较新旧两个 vnode,并将变化的内容更新至真实 DOM 树

其中 patch 方法的基本流程:

  1. 对比新旧 vnode 是否相同(key、sel 相同)
  2. vnode 不同,则删除之前的内容,重新渲染
  3. vnode 相同,则判断是否是文本节点,并判断更新 text
  4. 如不是文本节点且有子节点,则比较子节点的变化

判断子节点的过程,使用的就是 Diff 算法

从新建 Vue 实例到 Diff 代码流程

下列代码中去掉了兼容性、健壮性等相关的处理,只留有基本的流程,以方便理解。

1. new Vue

首先,调用 Vue 构造函数,创建 Vue 实例,其中this指向这个实例。

// 创建 Vue 实例
new Vue({
  el: '#app',
  data: {/* ... */}
});

function Vue(options) {
  this._init(options);  // 调用初始化方法
}

2. _init

prototype为 Vue 的原型,后续在此原型上定义的属性/方法,通过 Vue 创建的实例都可以访问和调用。(关于 Object-oriented JavaScript

找的_init定义的地方,在此方法中最后调用了 $mount函数执行挂载 DOM。

// 提取 init 初始化方法,在 new Vue 或 Vue.component 等场景调用
Vue.prototype._init = function (options) {
  var vm = this;
  // 初始化选项:computed, data, ...
  // 初始化实例,给实例绑定方法
  // 触发 beforeCreated,created 钩子
  // ...
  // 传有 el 选项,则执行挂载 DOM
  if (vm.$options.el) {
    vm.$mount(vm.$options.el);
  }
};

3. $mount

源码中有两处 $mount的定义,其中第二处对 $mount进行了重写,定义了模板渲染函数,并调用了第一个 $mount 函数。

调用第一个 $mount 时,mountComponent函数被执行,为 Vue 实例新建了监听者 watcher,并设置了更新函数。

而更新函数vm._update(vm._render())在新建 watcher 后被立即执行了一次,之后由数据的变化触发执行更新函数(JS设计模式 - 观察者模式)。

// 第一处 $mount
Vue.prototype.$mount = function (el) {
  return mountComponent(this, el, query(el))
};

// 第二处,对 $mount 进行了重写,并调用了第一个 $mount 函数
var mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (el) {
  // ...解析模板,生成模板渲染函数,保存渲染函数到 options
  return mount.call(this, el)
};

function mountComponent(vm, el) {
  // ...
  // 为 Vue 实例新建监听者 watcher,并设置更新函数
  new Watcher(vm, function () {
    vm._update(vm._render());
  });
  // ...
  return vm
}

function Watcher(vm, expOrFn) {
  this.getter = expOrFn;
  // 更新函数,在新建 watcher 后立即执行一次
  this.value = this.get();
}

Watcher.prototype.get = function () {
  return this.getter.call(this.vm, this.vm);
};

// Watch.prototype ...

4. _update

vm._render() 执行渲染函数,返回 vnode 对象。

_update方法中,通过调用vm.__patch__比较新旧 vnode 并进行 DOM 渲染。

Vue.prototype._update = function (vnode, /*...*/) {
  var vm = this;
  var prevVnode = vm._vnode;   // vm._vnode 保存当前 Vnode 树
  vm._vnode = vnode;
  // ...
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, /*...*/);
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode);
  }
  // ...
};

5. patch

patch函数,可以说是 Diff 流程的入口,调用createPatchFunction时返回,处理的流程:

  • 没有旧节点:创建新节点
  • 旧节点 和 新节点 一样(sameVnode: keytag相等,data 是否存在): patchVnode 继续比较
  • 旧节点 和 新节点 不一样:创建新节点,删除旧节点

patchVnode 处理的流程:

若新旧节点都有子节点且不相同,就执行到了updateChildren方法,此方法内通过判断并调用 patchVnode,递归间接调用自身,从而对同一父节点的子节点数组进行比较,并更新 DOM 元素。

var patch = createPatchFunction({nodeOps: nodeOps, modules: modules});
Vue.prototype.__patch__ = patch;

function createPatchFunction(backend) {
  var nodeOps = backend.nodeOps;
  // ...
  // vue diff 核心方法
  function updateChildren(parentElm, oldCh, newCh, /*...*/) {
    // patchVnode,递归间接调用自身,进行同级vnode的diff
  }

  function patchVnode(oldVnode, vnode, /*...*/) {
    // ...
    if (isUndef(vnode.text)) {  // vnode 不是文本节点
      if (isDef(oldCh) && isDef(ch)) {  // 新旧节点都有子节点
        if (oldCh !== ch) {  // 且子节点不相等
          updateChildren(/*...*/);
        }
      } else if (isDef(ch)) {  // 只有新节点有子节点
        addVnodes(/*...*/);
      } else if (isDef(oldCh)) {  // 只有旧节点有子节点
        removeVnodes(/*...*/);
      } else if (isDef(oldVnode.text)) {  // 旧节点是文本节点
        nodeOps.setTextContent(elm, '');
      }
    } else if (oldVnode.text !== vnode.text) {  // vnode 是文本节点,且与旧节点 text 不相等
      nodeOps.setTextContent(elm, vnode.text);
    }
    // ...
  }

  return function patch(oldVnode, vnode) {
    // ...
    if (isUndef(oldVnode)) {  // 没有旧节点,创建
      // empty mount (likely as component), create new root element
      createElm(vnode, /*...*/);
    } else {
      if (sameVnode(oldVnode, vnode)) {  // 旧节点 和 新节点 一样
        // patch existing root node
        patchVnode(oldVnode, vnode, /*...*/);  // 比较
      } else {  // 旧节点 和 新节点 不一样
        // 创建新节点,删除旧节点 ...
      }
    }
    // ...
    return vnode.elm
  }
}

updateChildren方法即为 Diff 算法的核心,具体内容在后续文章中再介绍。

小结

上述从新建实例到 diff 流程的示意图:

通过应用观察者模式,实现数据变化触发视图更新;使用 vm._vnode(vm 指向实例) 记录当前已渲染的 DOM 树,通过 Diff 算法比较新旧 vnode,只更新需要变更 DOM 元素,减少开销。

整体流程达成闭环,而 Diff 算法又是如何巧妙呢,未完待续 :)


已更新 → Vue 原理之图解 Diff 流程