写在前面
本文为 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 方法的基本流程:
- 对比新旧 vnode 是否相同(key、sel 相同)
- vnode 不同,则删除之前的内容,重新渲染
- vnode 相同,则判断是否是文本节点,并判断更新 text
- 如不是文本节点且有子节点,则比较子节点的变化
判断子节点的过程,使用的就是 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: key、tag相等,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 流程