Vue 中视图如何与模板绑定?我们在 template 中写下类似 html 的结构,最终呈现的页面如何绑定上 data 中的数据?
为解答上面的问题,本文从一个简单的模板出发,分析(手写)Vue中最关键的源码实现。
模板和数据如下:
<!-- template -->
<div id="myVue">
<div>{{x}}</div>
<button id="addBtn">x++</button>
<input type="text" v-model="x">
</div>
// data
data: {
x: 1,
},
最终要达到的效果:
- 模板中的数据被正确编译,即双大括号和指令生效。
- 模板中的数据是响应式的,当改变数据时,视图能够自动更新。
一、初始化流程
首先看一下Vue初始化时,我们关注的核心流程。
首先创建 MyVue 类,按顺序进行数据监测和模板编译,并且做一个数据代理,将 data 上的数据直接绑定在 Vue 实例上,方便访问。
// MyVue.js
class MyVue {
constructor(options = {}) {
this.$options = options;
this._data = options.data;
observe(this._data); // 将数据变为响应式
this._initData();
new Compile(options.el, this);
}
/* 将data直接绑定在Vue实例上,方便访问,不需要多一层_data */
_initData() {
Object.keys(this._data).forEach(key => {
Object.defineProperty(this, key, {
get: () => {
return this._data[key];
},
set: value => {
this._data[key] = value;
}
});
});
}
}
export default MyVue;
如果大家对比目录,可能会有疑问,流程图的顺序和本文分析的顺序不一样?
是的,对于 Vue 初始化流程来说,是先进行数据监测,再进行模板编译和视图更新。执行过程也必须是这样。本文之所以先分析模板编译,是我经过学习得出的经验:
- 模板编译是解开 Vue 神秘面纱的第一步,学习后会对在 template 中写下的模板如何展示到页面上有基本的认识。
- 模板编译前面一大段逻辑和数据监测并没有关系,只有最后执行 render 函数,渲染视图时,才会用到数据监测,没有数据监测也只是绑定失效而已。
- 学完模板编译后,数据监测就变得呼之欲出了。相反如果先学习数据监测,整体思路上不够顺畅,学习后也不知道有何用处。
二、模板编译
模板编译即把 template 当做一个模板字符串,对其进行词法分析,生成 AST 抽象语法树。
1. 生成 ast 抽象语法树
ast 编译结果
首先看下目标,上面的 template 被编译后需要生成下面的类似结构:
{
attrs:[
{
name:'id',
value:'myVue'
}
],
tag:'div',
type:1,
children:[...],
// 如果标签上带有指令会有directives属性
directives:{
name:'xx',
value:'xx'
},
// 如果节点类型是文本节点,会带有text属性
text:undefined
}
核心属性即以上几个,最外层 id 为 MyVue 的 div 不带有 directives 和 text 属性,但是它的 children 中有可能会出现,所以这里只是稍作示意。children 是 MyVue 的子节点数组,子节点同样是一个与上面类似的结构。
ast 生成过程
现在目标已经很明确了,把模板字符串编译成 ast 抽象语法树。难点在于要把各层级标签一一地统计出来。这里用到了栈结构,遇见开始标签入栈,遇到结束标签就出栈,那么在这个过程中收集到的内容,即是栈顶元素的属性。具体代码如下:
// parse.js
import parseAttrs from './attrsString'; //提取模板中的标签属性
const SingleTagList = ['input', 'img', 'br']; // 单标签特殊处理,只列举了一些常见的
export default function parse(templateStr) {
let index = 0;
let restStr = templateStr;
const stack = [];
const aResult = [];
const startRexExp = /^<(([a-z][a-z\d]*)(\s.+?)?)>/; //匹配开始标签
const endRegExp = /^<\/([a-z][a-z\d]*)>/; //匹配结尾标签
const contentRegExp = /(.+?)<\/?[a-z][a-z\d]*(\s.+?)?>/s; //匹配标签内容
while (index < templateStr.length) {
restStr = templateStr.slice(index);
/* 匹配标签开头 */
/* 匹配开头不能是数字,后面允许有数字 */
if (startRexExp.test(restStr)) {
// 压栈
const startTag = RegExp.$1;
const tagName = RegExp.$2;
const attrsString = RegExp.$3;
const oNodeInfo = {
tag: tagName,
type: Node.ELEMENT_NODE,
children: []
};
const { attrs, directives } = parseAttrs(attrsString);
if (attrs.length) {
oNodeInfo.attrs = attrs;
}
if (directives.length) {
oNodeInfo.directives = directives;
}
if (SingleTagList.includes(tagName)) {
// 单标签,单标签开始即结束了,相当于进行了双标签的入栈和出栈操作
if (stack.length) {
const oLastNodeInfo = stack[stack.length - 1];
oLastNodeInfo.children.push(oNodeInfo);
} else {
/* 暂时只匹配纯文本节点,不考虑其他节点和文本节点都有的情况 */
aResult.push(oNodeInfo);
}
} else {
// 双标签
stack.push(oNodeInfo);
}
console.log(`检测到开始${tagName}`);
// +2是把<>符号也算上了
index += startTag.length + 2;
} else if (endRegExp.test(restStr)) {
/* 匹配标签结尾 */
// 出栈
const endTag = RegExp.$1;
const oContent = stack.pop();
if (!oContent || endTag !== oContent.tag) {
throw new Error('模板字符串格式错误');
}
if (stack.length) {
const oLastNodeInfo = stack[stack.length - 1];
oLastNodeInfo.children.push(oContent);
} else {
/* 暂时只匹配纯文本节点,不考虑其他节点和文本节点都有的情况 */
aResult.push(oContent);
}
console.log(`检测到结束${endTag}`);
index += endTag.length + 3;
} else if (contentRegExp.test(restStr)) {
// 匹配文本节点,全为空的跳过,全为空说明是空格或换行,不需要统计
let content = RegExp.$1;
/* \s 就是[ \t\v\n\r\f]。表示空白符,包括空格、水平制表符、垂直制表符、换行符、回车符、换页符。*/
if (!/^\s+$/.test(content)) {
const oLastNodeInfo = stack[stack.length - 1];
oLastNodeInfo.children.push({
type: Node.TEXT_NODE,
content: content.trim() //存入栈中的文本节点,去掉前后空格。但是index的叠加是需要算上空格的
});
}
index += content.length;
} else {
index++;
throw new Error('怎么进else了?排查下是何特殊情况');
}
}
return aResult[0];
}
parseAttrs方法就是用来提取标签内的属性和指令,内容如下:
// attrsString.js
export default function(str) {
const attrs = []; //属性存放列表
const directives = []; //指令存放列表
let index = 0;
let restStr = str;
while (index < str.length) {
restStr = str.slice(index);
if (/^(\s+(.+?)="(.+?)")/s.test(restStr)) {
const name = RegExp.$2;
const value = RegExp.$3;
const oInfo = {
name,
value
};
// v-开头的即是指令
if (name.startsWith('v-')) {
oInfo.name = oInfo.name.slice(2); // 指令的name不包含v-,把它截取掉
if(oInfo.name==='for'){
const match = oInfo.value.match(/^(.+?)\s+in\s+(.+)$/);
// for指令比较特殊,需要做一些额外的处理
oInfo.alias = match[1]
oInfo.for = match[2]
}
directives.push(oInfo);
} else {
attrs.push(oInfo);
}
index += RegExp.$1.length;
} else {
index++;
}
}
return {
attrs,
directives
};
}
2.生成 render 函数
得到 ast 抽象语法树后,就要利用它生成 render 函数。同样地先来看下结果,即把上面的 template 改用 render 函数实现,熟悉 Vue 的小伙伴应该都很了解,代码大概如下:
render(h) {
return h('div', { attrs: { id: 'myVue' } }, [
h('div', this.x),
h(
'button',
'x++'
),
h('input', {
attrs: { type: 'text' },
domProps: { value: this.x },
on: {
input: $event => {
this.x = $event.target.value;
}
}
})
]);
}
所以现在的任务,就是需要利用 ast 动态生成一个类似上述的函数。动态生成函数使用 new Function() 传入字符串的做法。但用这种方法生成的函数,其词法作用域是在全局,而不在生成该函数的位置。Vue 在这里做了一个巧妙的处理,即利用 with 关键字改变作用域。如果不想使用这种方法,也可通过传参的形式,去使用外部的一些变量。具体代码:
// createRender.js
export default function createRender(ast) {
return new Function('_h', `with (this) return ${createCode(ast)}`);
}
function createCode(ast) {
const children = ast.children.map(child => {
if (child.type === 3) {
// 文本节点,简化处理,如果有双花括号,认为只有变量,不存在表达式
if (/{{(.+?)}}/.test(child.text)) {
return `${RegExp.$1}`;
} else {
return `"${child.text}"`;
}
} else if (child.type === 1) {
// 元素节点
return createCode(child);
}
});
const data = {};
if (ast.attrs) {
data.props = {};
// 转成snabbdom库h函数需要的格式
ast.attrs.forEach(item => {
data.props[item.name] = item.value;
});
}
if (ast.directives) {
data.directives = ast.directives;
}
return `_h("${ast.tag}",${JSON.stringify(data)}, [${children.join(',')}])`;
}
动态生成的render函数如下
function anonymous(_h) {
with (this)
return _h('div', { props: { id: 'myVue' } }, [
_h('div', {}, [x]),
_h('button', { props: { id: 'addBtn' } }, ['x++']),
_h('input', { props: { type: 'text' }, directives: [{ name: 'model', value: 'x' }] }, [])
]);
}
该函数中的 this 就是 vue 实例,所以访问的变量实际上就是访问 data 中的数据。
这里的_h 并不是 Vue 使用的 h 函数。利用 h 函数生成虚拟 DOM 并不是 Vue 开创的,Vue 借鉴了 snbbdom 库。所以我们直接通过 snbbdom 的 h 函数生成虚拟 DOM,_h 是对 Vue 中的指令做一些特殊处理,使之符合 snbbdom 库 h 函数的传参,_h 代码如下:
// Compile.js
import { h } from 'snabbdom';
const _h = (sel, data, children) => {
if (data.directives) {
/* 处理指令,指令处理是相对复杂的,因为不同指令就要做不同的处理,该例子展示双向绑定,只对v-mode指令做处理 */
data.directives.forEach(({ name, value }) => {
if (name === 'model') {
// 处理v-model指令,利用Watcher类监听指令绑定的数据的变化,改变时通知关联依赖进行视图更新
new Watcher(this.$vue, value, () => {
updateMain();
});
const inputFun = $event => {
this.$vue[value] = $event.target.value;
};
data.props.value = this.$vue[value];
data.on = {
input: inputFun
};
}
// 如果要处理其他指令,可以在这里扩展
});
}
return h(sel, data, children);
};
3. 执行 render 函数和 patch 函数
render 函数已经被创建出来了,执行 render 函数后便可以得到虚拟 DOM,为了尽早看到结果,可以先用 snabbdom 库的 patch 函数进行虚拟 DOM 上树(snabbdom 库的 patch 函数创建请自行查阅)。后面再详细分析 patch 函数。render 函数和 patch 函数的执行,是对视图的更新,所以将他们封装到一个函数内。
const updateMain = () => {
this.newVnode = this.renderFun.call(this.$vue, _h);
// 第一次执行时,oldVnode即为$el
this.oldVnode = snabbdom.patch(this.oldVnode, this.newVnode);
};
把这些代码都整合一下,即使没有数据监测,视图也能正确展示,只不过没有和数据进行绑定。进行到这里,就需要分析数据监测了。因为如果没有数据监测,无法监听到数据的改变,那么就不知道什么时候再次执行 updateMain 函数。
三、数据监测
1. 预期效果
数据监测想要实现的效果,在前文中已经给出。这里把他们提炼出来:
observe(this._data); // 将数据变为响应式
// 利用Watcher类监听数据变化,改变时通知关联依赖进行视图更新
new Watcher(this.$vue, value, () => {
updateMain();
});
这两段代码是之前写过的。这里 new Watcher 传入的 this.$vue,可以改为this._data。因为_initData函数对this.$vue 做了数据代理,访问 this.$vue 上的数据实际就是访问了 this._data。也就是访问了传入的 data。
那么目的已经明确:对传入的对象data={x:1},调用 observe 方法进行数据监测后,使用 new Watcher 可以监听到其属性的变化,并且执行回调函数,便完成了任务。
到这里可能有些小伙伴觉得这个事情好像很简单,代码中又是 observe,又是 Watcher 类是不是搞复杂了。其实是因为本文的例子非常简单,数据监测的复杂性来自于以下几点:
- 对 data 对象的监测是深度监测,即可以监测多层。如果 data 是
data={obj1:{obj2:{x:1}}},依然可以对最后一层的 x 或者中间任意一层的属性进行监听。 - 需要实现数组监听,中间任意一层如果有属性值为数组,也需要监听其变化。
- 重新赋值后的数据,如果是对象或数组类型,也需要是响应式的。
- 如果 data 的 x 属性被多个 Vue 组件使用,当 x 改变时要通知到每个组件进行视图更新。
展示一下最后的测试代码:
this.testObj = {
a: {
a1: 1
},
b: 2,
c: [2, 3, [4]]
};
observe(this.testObj)
/* 模板编译时,对于模板中用到的数据,就主动调用Watcher类进行监听 */
new Watcher(this.testObj, 'b', () => {
console.log('b被改变了');
});
this.testObj.b = 20
new Watcher(this.testObj, 'a.a1', () => {
console.log('a1被改变了');
});
this.testObj.a.a1 = 10
new Watcher(this.testObj, 'c', () => {
console.log('c被改变了');
});
this.testObj.c.push(3);
只要最后回调函数执行了,说明数据监测成功。
2.数据监测流程
该流程图引用自 blog.csdn.net/Mikon_0703/…
数据监测的总体流程图如上所示,可以对照上面的模板解析过程进行理解。这里对 3 个重点功能进行说明:
- Observer: 将一个普通对象转换为每个层级的属性都是响应式(可以被监测的)的对象。并且进行依赖收集和依赖触发。
- Dep 类: 作为发布订阅模式的发布者,在依赖收集时,将该数据的依赖(订阅者)都保存起来,当数据发生变化时,会循环依赖列表,把所有 Watcher 都通知一遍。
- Watcher 类:作为一个订阅者,当收到属性变化通知时,执行回调函数。
其中 Observer 完成的功能最多,所以它被拆分成几个单元,包含 observe 函数、Observer 类以及 defineReactive 函数。这些函数和类之间又存在互相引用的关系,所以对这每个单元都画了一下流程图,除了帮助理解它们自身的功能外,主要是辅助厘清它们之间的调用关系。
observe 方法
首先根据测试代码,最先调用的就是 observe 方法,这个方法实现的目的就是对数据做一些判断和筛选;一是排除基本类型,因为只有对象或者数组需要做响应式处理;二是已经是响应式数据的对象不再重复处理。具体流程如下:
具体代码:
// observe.js
import Observer from './Observer';
export default function observe(value) {
if (typeof value !== 'object') {
return;
}
let ob = null;
if (typeof value.__ob__ === 'undefined') {
ob = new Observer(value);
} else {
ob = value.__ob__;
}
return ob;
}
Observer类
Observer类实现的功能依然比较简单,区分传入的数据是数组还是对象,然后做不同的处理。如果是对象,遍历后全权交给defineReactive处理。如果是数组,首先将其原型指向arrayMethods对象(后面详细说明),然后遍历子项调用observe方法,实现递归监听。
// Observer.js
import defineReactive from './defineReactive';
import { def } from './util'; //def方法就是使用defineProperty去增加一个属性,并且传入配置项。
import { arrayMethods } from './array';
import observe from './observe';
import Dep from './Dep';
// 将一个普通Object对象转换为每个层级的属性都是响应式(可以被侦测的)的Object。
class Observer {
constructor(value) {
// 每一个Observer实例身上都有一个Dep实例,所以每一层(对象),都有一个Dep
this.dep = new Dep();
// __ob__属性,值是Observer实例对象,取名__ob__,是防止属性重名
def(value, '__ob__', this, { enumerable: false });
if (Array.isArray(value)) {
// 如果是数组就强行将该数组的原型指向我们创建的以Array的Prototype为原型的对象。
// 所以当下的关系是, 代码中的数组 => Vue创建的数组监测对象(arrayMethods) => 数组的原型对象
/*
/这里比较精妙的点是创造了一个中间对象来监听,我之前一直认为是直接在Array的原型上改写,
所以存在疑惑,直接在原型岂不是所有数组都是响应式的了?浪费性能了? 实际上并不是。
*/
Object.setPrototypeOf(value, arrayMethods);
/* 上一步监听了数组的特定的变化函数,而下面是遍历数组,观察每一项的值 */
this.observeArray(value);
/* 数组的依赖收集在Observer类中进行,arrayMethods只是作为数据改变时的触发器 */
if (Dep.target) {
this.dep.depend();
}
} else {
this.walk(value);
}
}
// 遍历对象属性
walk(obj) {
for (const key in obj) {
if (Object.hasOwnProperty.call(obj, key)) {
defineReactive(obj, key);
}
}
}
// 遍历数组项
observeArray(arr) {
for (let i = 0, l = arr.length; i < l; i++) {
/*
为什么这里调用了observe方法而不是defineReactive?因为defineReactive必须保证父亲是对象,然后去监听子属性
这里数组每一项的数据类型是任意的。所以要从头开始调用observe方法监听。实现了数组类型的递归监听。
*/
observe(arr[i]);
}
}
}
export default Observer;
defineReactive
Observer 类对数组和对象做了区分处理,具体的监听实现放在了 defineReactive 方法和 arrayMethods 原型对象上。所以这两部分有许多类似的地方。先说 defineReactive,defineReactive 主要功能就是使用 Object.defineProperty 实现属性的劫持和监听。具体功能如下:
//defineReactive.js
import observe from './observe';
import Dep from './Dep';
/*
为什么要定义这个函数?原因很简单,如果不使用一个函数封装,就需要一个中间变量中转,才能使getter和setter正常工作。
*/
export default function defineReactive(data, key, value) {
/* 每一个属性有其自己的dep,因为每一个属性都有可能会被多个vue调用,需要将其收集起来 */
const dep = new Dep();
if (arguments.length === 2) {
value = data[key];
}
// 判断该属性值是否是引用数据类型,如果是就继续对属性值进行监听。实际上这里实现了递归监听。
let childObj = observe(value);
Object.defineProperty(data, key, {
get() {
console.log(`你正在访问${key}`);
// getter时收集依赖
// 如果现在处于依赖收集阶段
if (Dep.target) {
dep.depend();
if (childObj) {
childObj.dep.depend();
}
}
return value;
},
set(newValue) {
console.log(`你正在设置${key}`);
/* 如果设置了一个新值,该值是对象同样需要观察,若不做这个处理,当赋值一个对象时,该对象无法成为响应式数据 */
observe(newValue);
/* setter时触发依赖 */
value = newValue;
dep.notify();
}
});
}
arrayMethods
arrayMethodsfs 原型对象的处理类比 defineReactive 其实只是实现了类似 setter 的功能。监听了数组值的改变。对于直接访问数组的某一项,则没有做数据劫持。
因为 arrayMethods 对象的封装比较单线性,并且它只和Observer类存在调用关系,所以没有画流程图,直接看代码:
// array.js
import { def } from './util';
const methodsNeedChange = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
const arrayPrototype = Array.prototype;
// arrayMethods对象以数组的原型对象为原型,保证其他的数组方法不受影响
const arrayMethods = Object.create(arrayPrototype);
methodsNeedChange.forEach(methodName => {
/* 定义arrayMethods自己的方法,在原型链查找时就优先调用自身方法 */
def(
arrayMethods,
methodName,
function() {
/* arguments是不管形参的,只管最终传入了多少实参.
this必须要正确,因为像push等方法,它的目的就是去改变this数据的值的 */
const result = arrayPrototype[methodName].apply(this, arguments); //调用Array原型上的方法,保证原有功能不影响
/* 以下三种方式会在数组中加入项,要保证加入的项也是响应式的 */
let inserted = [];
if (methodName === 'push' || methodName === 'unshift') {
inserted = [...arguments];
} else if (methodName === 'splice') {
inserted = [...arguments].slice(2);
}
if (inserted.length) {
// this.__ob__就是在该数组上绑定的Observer类
this.__ob__.observeArray(inserted);
}
this.__ob__.dep.notify();
return result;
},
{ enumerable: false }
);
});
export { arrayMethods };
在原型上重写了 7 种改变数组的方法,所以只要调用这些方法,Vue 就能实现监听。
到这里为止,实现了将一个普通对象处理为每一层都是响应式的数据。如果不考虑数据被多个地方使用的情况,完全可以在数据被改变的时候写一个自定义触发函数,将被改变的数据属性名和改变后的值传回去。而我们在初始化的时候去监听这个触发函数,就可以接收到数据的改变了。
为了解决数据可能被多个视图使用的问题,就通过 Dep 和 Watcher 实现发布订阅模式。
Dep
Dep 作为发布者,需要添加每个订阅者,并将这些订阅者保存起来。当数据改变时,对应的 Dep 需要去通知保存的订阅者。
什么时候添加订阅者?在上面 defineReactive 中可以看到,是在 getter 的时候收集依赖,添加订阅者。
Dep 的具体代码如下:
// Dep.js
let uid = 0;
/* Dep.target是类本身的属性,所以实际上成为了一个全局属性,只要引入Dep即可读取和设置 */
export default class Dep {
constructor() {
this.id = uid++;
// 用数组存储订阅者
this.subs = [];
}
// 添加订阅,即添加一个watcher实例对象
addSub(sub) {
this.subs.push(sub);
}
// 添加依赖
depend() {
// Dep.target实际上是一个全局性质的变量
if (Dep.target) {
/* 判断是否是重复依赖,若不做判断,get时候会一直收集重复依赖 */
if (!this.subs.includes(Dep.target)) {
this.addSub(Dep.target);
}
}
}
// 通知所有watcher对象更新视图,即分发消息。
notify() {
// 源码中是这么写的,为什么要浅克隆一份?
const subs = this.subs.slice();
console.log(subs, 'subs');
for (let i = 0; i < subs.length; i++) {
subs[i].update();
}
}
}
// 第一次将这个Dep的target属性设置为null
Dep.target = null;
代码中一直出现 Dep.target 属性。它的作用是将当前正在读取数据的依赖添加进自己的 Dep 中。Dep.target 属性平时一直是 null,只有等到 Watcher 类在读取数据前,会将 Dep.target 设置为自身。这时 Dep 收集到的就是正在读取数据的 Watcher。读取数据完成后,再将 Watcher 设置为 null。
Watcher
Watcher 作为订阅者,在创建 Watcher 实例时会读取被检测对象的某个属性,此时会进行依赖收集,该 Watcher 就订阅了 Dep。当数据发生改变时,Dep 会通知自身的订阅者。Watcher 就会执行传入的回调函数,并返回新值和旧值。
// Watcher.js
import Dep from './Dep';
var uid = 0;
/*
用栈结构保存依赖,这么做的目的是在有组件嵌套时,如果父组件渲染到一半时,渲染子组件。
target移向子组件,子组件渲染完后,target就会指向null。而父组件实际并未渲染结束
*/
const TargetStack = [];
function pushTarget(_target) {
TargetStack.push(Dep.target);
Dep.target = _target;
}
function popTarget() {
Dep.target = TargetStack.pop();
}
/*
为什么引入Watcher?
当属性发生变化时,我们要通知用到此数据属性的地方,而使用此数据的地方很多且类型不一样,需要抽象出一个类集中处理
这些情况,然后再依赖收集阶段只收集这个封装好的实例,通知也只需要通知这一个,然后它在负责通知其他的地方。
*/
const parsePath = str => {
let segments = str.split('.');
return obj => {
segments.forEach(segment => {
obj = obj[segment];
});
return obj;
};
};
export default class Watcher {
constructor(vm, expression, callback) {
this.id = uid++;
this.vm = vm;
this.getter = parsePath(expression);
this.callback = callback;
this.value = this.get();
}
update() {
this.run();
}
run() {
const value = this.get();
// 当值发生改变时
if (value !== this.value || typeof value === 'object') {
const oldValue = this.value;
this.value = value;
this.callback.call(this.vm, value, oldValue);
}
}
get() {
// 进入依赖收集阶段,让全局的Dep.target设置为wathcer实例本身。
pushTarget(this)
const vm = this.vm;
let value;
// 只要能找,就一直找
try {
value = this.getter(vm);
} finally {
// 依赖收集结束
Dep.target = null;
}
popTarget()
return value;
}
}
至此,数据监测分析结束了,整合以上代码去跑数据监测最开始的测试代码,是可以跑通的。并且把它放到初始化流程和模板编译中,也可以正常运转。即,双向绑定的效果已经实现,只不过 h 函数和 patch 函数使用的是 snabbdom 库。 到这里,对模板编译和响应式原理分析结束,vue 的内部实现大概有所了解,最后对 DOM 的最小量更新进行分析。
四、虚拟 DOM 和 patch 函数 详解
在模板编译的最后阶段,直接调用了 snabbdom 库的 h 函数和 patch 函数。h 函数生成虚拟 DOM,patch 函数进行虚拟 DOM 上树以及虚拟 DOM 更新。接下来简单实现一下这两个函数。
生成虚拟 DOM
首先,也需要有个模板,这里就不用本文最开始的模板了。因为那个模板里面包含了指令、双大括号等一些 Vue 的专属语法。而底层 h 函数实际上只做一件事,就是根据传参生成虚拟 DOM,它是不管指令、花大括号这些东西的。为方便后续 patch 函数的测试,这里再使用一个模板,如下:
<div id="myH">
<h4 class="x">x</h4>
<ul>
<li>A</li>
<li>B</li>
<li>C</li>
</ul>
</div>
这个模板使用 h 函数去生成一下,应该比较简单,毕竟我们在模板编译中是直接通过代码去动态生成 h 函数,这里只是把它静态地写出来。
const vnode = h('div', { id: 'myH' }, [
h('ul',[
h('li','A'),
h('li','B'),
h('li','C'),
])
]);
看一下 vnode 长啥样。可能这里有小伙伴会觉得和 ast 抽象语法树很像。的确很像,因为它们都是通过一个 js 对象去描述一个真实 DOM 节点。但两者还存在差异。
首先,生成的过程有差异。ast 抽象语法树是分析字符串生成,虚拟 DOM 是通过调用 h 函数直接生成。其次,抽象语法树是为了 vue 服务的,它里面会拆解 vue 的一些特殊语法如指令等;而 h 函数没有这些逻辑。从 ast 到虚拟 DOM 实际上经历了这些:
ast 抽象语法树 => 动态生成 render 函数 => 执行 render 函数 => render 函数内部调用 h 函数 => 得到虚拟 DOM。
// vnode
{
"sel": "div",
"data": {
"id": "myH"
},
"children": [
{
"sel": "ul",
"data": {},
"children": [
{
"sel": "li",
"data": {},
"text": "A"
},
{
"sel": "li",
"data": {},
"text": "B"
},
{
"sel": "li",
"data": {},
"text": "C"
}
]
}
]
}
实现 h 函数,这一步比较简单,因为最终要生成的对象,它的属性实际上都是通过外部传入,只需要规整一下。
注意这里是没有递归的,每一层的对象都是通过 h 函数自己创建出来的。h 函数主要就是对参数个数和类型做了一个判断。直接上代码:
import vnode from './vnode';
// h.js
const isObj = obj => {
return Object.prototype.toString.call(obj) === '[object Object]';
};
// vnode(sel, data, children, text, elm)
export default function(sel, data, c) {
const length = arguments.length;
if (length < 2) {
throw new Error('least 2 params');
}
// 只有两个参数的情况,即中间的data被省略了或者没有children
if (length === 2) {
// sel + text
if (typeof data === 'string' || typeof data === 'number') {
return vnode(sel, undefined, undefined, data);
}
// sel + data
if (isObj(data)) {
return vnode(sel, data);
}
// sel + children
if (Array.isArray(data)) {
return vnode(sel, undefined, data);
}
throw new Error('参数类型错误!');
} else {
// 三个参数的情况
if (!isObj(data)) {
throw new Error('参数类型错误!');
}
// sel + data + text
if (typeof c === 'string' || typeof c === 'number') {
return vnode(sel, data, undefined, c);
}
// sel + data+children
if (Array.isArray(c)) {
return vnode(sel, data, c);
}
throw new Error('参数类型错误!');
}
}
vnode 封装了一个函数,根据传入值最终输出虚拟 DOM 对象,如下:
// vnode.js
export default function(sel, data, children, text, elm) {
let key;
if (data) {
key = data.key;
} else {
/* patch函数内部data不能为undefined */
data = {};
}
return {
sel,
data,
children,
text,
elm,
key
};
}
patch 函数详解
先说下 patch 的使用,其实模板编译最后也调用过。patch 需要传入两个参数,旧虚拟节点和新虚拟节点。旧虚拟节点可以是一个 DOM 对象,这也是首次上树时的场景。这里我们调用 patch,将上面生成的 vnode 虚拟 DOM 上树。
const container = document.getElementById('container')//页面上存放一个容器
patch(container,vnode)
如果 patch 函数被成功创建,那么执行的结果是页面容器被替换成了模板的 DOM 结构。那么接下来就正式开始手写 patch 函数。
先看一下 patch 函数的整体流程图,后面跟着这个流程图一步一步实现。并且在每一步做一些验证。
patch函数步骤一
第一步先分析到精细化比较之前。先看下 patch 函数的整体代码。
// patch.js
import vnode from '../vnode';
import createEle from './createEle';
export default (oldVnode, newVnode) => {
if (oldVnode.sel === undefined) {
oldVnode = vnode(oldVnode.tagName, {}, [], '', oldVnode);
}
// 是否为同一个虚拟节点
const keyIsSame = oldVnode.key === newVnode.key;
const selIsSame = oldVnode.sel === newVnode.sel;
if (keyIsSame && selIsSame) {
patchVnode(oldVnode, newVnode);
} else {
// 不是同一个节点,插入新dom,删除旧dom
const newDom = createEle(newVnode);
if (!oldVnode.elm) {
// 传入的oldVnode如果是真实DOM,调用vnode函数后就会带有elm属性。如果是虚拟DOM,必须是已经生成真实DOM的虚拟节点,即带有elm属性。
return;
}
oldVnode.elm.parentNode.insertBefore(newDom, oldVnode.elm);
oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}
};
上面代码就是 patch 函数最终的代码。但是先忽略是同一个虚拟 DOM 的情况,即可以把 patchVnode 函数那一行注释掉。
剩下的代码对照着流程图就比较好理解了,这里虚拟 DOM 上树时要注意一下,必须先插入新节点,再删除旧节点,不能颠倒。因为反过来的话,插入新节点时就没有一个参考项了。
这时就剩下 createEle 函数了。该函数是将虚拟 DOM 创建为一个真实 DOM。看下代码:
// createEle.js
export default function createEle(vnode) {
const dom = document.createElement(vnode.sel);
/* 处理DOM属性和监听事件 */
if (vnode.data) {
const { props, attributes, on } = vnode.data;
if (props) {
Object.keys(props).forEach(propName => (dom[propName] = props[propName]));
}
if (attributes) {
Object.keys(attributes).forEach(attrName => dom.setAttribute(attrName, attributes[attrName]));
}
if (on) {
Object.keys(on).forEach(evetName => {
dom.addEventListener(evetName, on[evetName]);
});
}
}
if (vnode.children && vnode.children.length) {
vnode.children.forEach(childVnode => {
dom.appendChild(createEle(childVnode));
});
} else if (vnode.text) {
// 简化版不允许又有children又有text的情况,二者互斥
dom.innerHTML = vnode.text;
}
vnode.elm = dom;
return dom;
}
上面代码比较好理解,但是内部是包含递归的,因为 vnode 可能有子元素 children ,每一个 children 也需要生成真实的 DOM 并且被添加入父元素中。
到这里,patch 函数的第一个作用,将虚拟 DOM 上树其实已经完成,可以拿来测试了。执行上文写到的patch(container,vnode)。即可在页面上看到效果。
patch函数步骤二
新旧虚拟 DOM 子节点即 children 属性什么情况下需要进行详细对比?就是 children 依然都是数组的情况下。如果任意一方 children 不是数组(说明子节点中只剩下文本节点),就不必进行详细对比。
这一部分的逻辑就在 patchVnode 函数中,代码如下:
//patch.js
const patchVnode = (oldVnode, newVnode) => {
// 是同一个节点,进行精细化比较
if (newVnode.text || newVnode.text === '') {
if (newVnode.text === oldVnode.text) {
// 新旧节点子节点都是文本节点,且text相同时,不需要做处理
newVnode.elm = oldVnode.elm;
return;
} else {
oldVnode.elm.innerText = newVnode.text;
newVnode.elm = oldVnode.elm;
}
} else if (newVnode.children) {
if (oldVnode.text) {
// 1. 清空旧dom
oldVnode.elm.innerHTML = '';
// 2. 添加新虚拟节点的children属性
newVnode.children.forEach(child => {
oldVnode.elm.appendChild(createEle(child));
});
newVnode.elm = oldVnode.elm;
} else if (oldVnode.children) {
// 最复杂的情况,新老虚拟节点都有children,就需要对比每一个子节点
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
// 个人思路编写updateChildren
// updateChildren_self(oldVnode.elm, oldVnode.children, newVnode.children);
newVnode.elm = oldVnode.elm;
}
}
/* 对属性的更新,这里做简化处理,只对props属性进行更新,让例子双向绑定生效,并且没有比较新老属性是否相同 */
if (newVnode.data) {
const { props } = newVnode.data;
if (props) {
Object.keys(props).forEach(propName => (newVnode.elm[propName] = props[propName]));
}
}
};
这部分逻辑对照流程图来看依然不复杂,同样先忽略 updateChildren 函数所在部分,然后可以做一下测试。
根据流程,先要生成一个带有 text 属性的虚拟 DOM,然后使用带有 text 属性和带有 children 属性的新虚拟 DOM 去和旧虚拟 DOM 做对比。测试时可以在 patchVnode 中打一下 debugger,测试是否按照流程图所画执行,并且最后是否把新虚拟 DOM 更新到视图上即可。
patch函数步骤三
新旧虚拟 DOM 都存在 children 时,就需要进行子节点对比更新。即 updateChildren 函数。这个函数我认为是全文最复杂的部分了。首先说明下这个函数的作用是什么,它实现了什么功能。
根据个人的理解,这个函数就是对新老虚拟节点的子节点进行了高效的匹配。那么它完成了三个工作:
- 新老虚拟节点匹配成功时,调用patchVnode进行节点更新,如果匹配上的子节点依然都带有 children 属性,那么继续递归。
- 循环结束后,统计没有匹配上的新虚拟节点,这部分虚拟节点就是新增的,需要插入。
- 循环结束后,统计没有匹配上的旧虚拟节点,这部分虚拟节点上的elm(真实DOM)需要删除。
个人思路实现子节点更新
作用讲完了,那么该函数复杂就复杂在高效匹配,所以这里先不管效率,自己实现一下 updateChildren 函数。实现过后,对其有一定理解后,再根据源码思路实现,并且可以借此对比源码思路的巧妙。
//patch.js
/*
个人思路实现子节点对比更新,函数接收3个参数,旧虚拟节点的DOM节点,新旧虚拟节点的子节点数组
*/
const updateChildren_self = (parentNode, oldCh, newCh) => {
// 遍历新节点,去老节点中进行匹配
newCh.forEach(newChild => {
const matchOldChild = oldCh.find(oldChild => isSameVnode(oldChild, newChild));
if (matchOldChild) {
// 子节点中增加一个属性,标志该节点成功匹配上了
matchOldChild.isMatched = true;
newChild.isMatched = true;
// 更新子节点
patchVnode(matchOldChild, newChild);
}
});
// 遍历结束后,统计未被匹配的新虚拟子节点和旧虚拟子节点
const noMatchNewCh = newCh.filter(newChild => !newChild.isMatched);
const noMatchOldCh = oldCh.filter(oldChild => !oldChild.isMatched);
// 未被匹配的新虚子节点需要插入
if (noMatchNewCh.length) {
noMatchNewCh.forEach(noMatchNew => {
parentNode.appendChild(createEle(noMatchNew));
});
}
// 未被匹配的旧虚拟子节点需要删除
if (noMatchOldCh.length) {
noMatchOldCh.forEach(noMatchOld => {
parentNode.removeChild(noMatchOld.elm);
});
}
};
上面这个函数基本实现了子节点更新,小伙伴们可自行测试。但是它和源码中的 updateChildren 相比就差的太远了。主要有两点:
- 效率低。每次查找都将新旧虚拟子节点进行了双重循环。从逻辑上讲,已经被匹配到的元素,实际上不需要再参与查找。
- 没有关注顺序问题。新旧虚拟子节点匹配上了不意味没有改变顺序,包括新插入的新虚拟子节点,默认就插入到最后一项了,实际有可能是数组中任意位置新增的。这个在 updateChildren_self 中没有考虑。
源码思路实现子节点更新
源码中只用了一次循环,使用了 diff 算法的四种命中查找,高效匹配新旧虚拟子节点。
源码中使用了指针思想。准备了四个指针,分别为新前、旧前、新后、旧后。这里所谓的新前,是新虚拟 DOM 子节点第一项的 index,如果这项在旧虚拟 DOM 子节点中匹配上了,那么新前指针就向后移一位,此时新虚拟 DOM 子节点第二位就变成了新前的指针,旧前是类似的。新后最开始时是新虚拟 DOM 子节点最后一项的 index,一旦匹配上后就向前移一位。
解释一下四种命中查找:
- 新前与旧前匹配: 实际应用中,若 v-for 循环的数组长度未变化,只变化各项内容时。只用到该种判断即可完成。 若是按顺序在在数组末尾添加项,除添加项外的其他项也只用该种匹配,添加项 4 种匹配都匹配不上,因为旧虚拟节点中没有。 所以其实新前与旧前,就占了开发中最有可能出现的两种情况。
- 新后与旧后匹配: 该种匹配针对非行尾插入项。根据插入位置,插入位置之前的项通过新前与旧前匹配,插入位置之后的项就通过新后与旧后匹配。
- 新后与旧前匹配: 该种匹配主要是去匹配位置移动类的,比方说把某一项移动到数组末尾了。 新前指向的节点,移动到旧后之后
- 与第 3 种情况类似 -- 涉及移动节点,新后指向的节点,移动到旧前之前。
对diff算法的四种匹配再说明下,只要匹配上了一种,就不再进行其他匹配。所以要把最有可能的情况放到开头。另外四种匹配并不包含所有的情况。依然会有匹配不上的可能。匹配不上源码中也不是使用双重循环的,而是对未匹配的旧虚拟DOM子节点定义了一个缓存对象,直接使用属性名(key)进行匹配。
好了,千呼万唤始出来,最后看下源码思路实现的 updateChildren 函数。
// 子节点更新策略
const updateChildren = (parentNode, oldCh, newCh) => {
let testI = 0; // 开发时防止死循环,开发完后删除。
let oldStartIdx = 0; // 旧前指针
let newStartIdx = 0; // 新前指针
let oldEndIdx = oldCh.length - 1;//旧后指针
let newEndIdx = newCh.length - 1;//新后指针
let oldStartVnode = oldCh[oldStartIdx];//旧前虚拟DOM
let oldEndVnode = oldCh[oldEndIdx];//旧后虚拟DOM
let newStartVnode = newCh[newStartIdx];//新前虚拟ODM
let newEndVnode = newCh[newEndIdx];//新后虚拟DOM
let keyMap = null; // 旧虚拟DOM缓存对象
/* 该判断条件就说明,要么新虚拟节点已经遍历完了,不管是否匹配上;要么旧虚拟节点已经被匹配完了。这两种情况任意发生一种,循环终止 */
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx && testI < 1000) {
/* 这两种情况说明oldStartVnode或者oldEndVnode已经被处理了 */
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx];
continue;
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
continue;
}
if (isSameVnode(oldStartVnode, newStartVnode)) {
/*
新前与旧前匹配,实际应用中,若v-for循环的数组长度未变化,只变化各项内容时。只用到该种判断即可完成。
若是按顺序在在数组末尾添加项,除添加项外的其他项也只用该种匹配,添加项4种匹配都匹配不上,因为旧虚拟节点中没有。
所以新前与旧前,就占了开发中最有可能出现的两种情况。
*/
console.log('1命中');
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
console.log('2命中');
/* 新后与旧后,该种匹配针对非行尾插入项。根据插入位置,插入位置之前的项通过新前与旧前匹配,插入位置之后的项就通过新后与旧后匹配 */
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
console.log('3命中');
// 新后与旧前
patchVnode(oldStartVnode, newEndVnode);
/* insertBefore插入一个已经在DOM树上的DOM节点,该DOM节点就会移动,这一点很关键。 */
// 新前指向的节点,移动到旧后之后。因为现在是新后命中,所以最终的排序,插入节点一定是在已经匹配过的元素的最后。所以是旧后之后
parentNode.insertBefore(newEndVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
console.log('4命中');
// 新前与旧后
patchVnode(oldEndVnode, newStartVnode);
// 新后指向的节点,移动到旧前之前,因为当前匹配到的是新前,所以最终顺序,插入节点一定是在已经匹配过的元素的前面。所以是旧前之前。
parentNode.insertBefore(newStartVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 四种情况都未匹配上时循环遍历旧虚拟DOM去匹配。
/* 源码中这段的处理,重点就是提升了匹配效率。如果用传统的数组循环寻找,那么每次都要遍历新旧虚拟节点
而使用了keyMap后,只需要第一次未匹配上时遍历还没有进行匹配的旧虚拟节点项。后续匹配相当于读取缓存了,非常高效。
*/
if (!keyMap) {
keyMap = {};
// 寻找key的map,去旧虚拟节点中寻找key
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key != null) {
keyMap[key] = i;
}
}
}
// 寻找当前这项(newStartIndex),这项在keyMap中的映射的位置序号。
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld == undefined) {
// 判断,如果idxInOld是undefined表示它是全新的项,需要新增
parentNode.insertBefore(createEle(newStartVnode), oldStartVnode.elm);
} else {
// 如果不是undefined,不是全新的项,旧虚拟节点中存在,但是四种匹配未匹配上
// 这里其实还有一种情况,就是虽然key相同,但是sel不同,这种情况这个弱化版先不考虑了。
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// 移动节点位置,移动到未开始匹配的旧虚拟节点前面,这样才和新虚拟节点的顺序一致。
// 将处理过的项设置为undefined,下次循环时跳过
oldCh[idxInOld] = undefined;
parentNode.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
testI++;
}
// 验证程序是否有问题,是因为testI跳出的循环就是存在问题的
console.log(testI, 'testI');
/* 循环结束了 */
if (newStartIdx <= newEndIdx) {
// 新虚拟dom有节点未被匹配,这些未被匹配的节点需要新增
/* 这里有一个疑问,新虚拟节点有可能没有elm属性?
解答上面的问题:因为newEndIdx只要不是最后一个,即末尾元素有被匹配到过,匹配到过就会进行patchVnode函数,就会带上elm节点
了。而如果是最后一个,从未被匹配到,before就是null,直接在最末尾新增
*/
const before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
for (let index = newStartIdx; index <= newEndIdx; index++) {
/* 以newEndIdx为标杆,在它之前插入,insertBefore如果第二个参数是null,就认为在父节点最后插入dom,
效果等同于appendChild */
parentNode.insertBefore(createEle(newCh[index]), before);
}
} else if (oldStartIdx <= oldEndIdx) {
// 旧虚拟dom有节点未被匹配,这些未被匹配的节点需要删除
for (let index = oldStartIdx; index <= oldEndIdx; index++) {
parentNode.removeChild(oldCh[index].elm);
}
}
};
好了,到此为止,本文中的双向绑定例子,总算可以全部由自己编写的代码验证了。上文展示的代码只是一部分。完整代码可以去这里查看 项目git地址。
五、结语
文章最后说明下,本文中有多处实现仅仅针对本例,做了很多简化处理或者特殊处理。个人觉得更重要的是从整体上掌握Vue的内部实现。
最后鉴于本人水平有限,如文章有错误之处,烦请指正,谢谢!