Vue 变化生命周期 使Object数据变得“可观测”
数据的每次读和写能够被我们看的见,即我们能够知道数据什么时候被读取了或数据什么时候被改写了,我们将其称为数据变的‘可观测’。
要将数据变的‘可观测’,我们就要借助前言中提到的Object.defineProperty方法了,在本文中,我们就使用这个方法使数据变得“可观测”。
首先,我们定义一个数据对象car:
let car = {
'brand':'BMW',
'price':3000
}
我们定义了这个car的品牌brand是BMW,价格price是3000。现在我们可以通过car.brand和car.price直接读写这个car对应的属性值。但是,当这个car的属性被读取或修改时,我们并不知情。那么应该如何做才能够让car主动告诉我们,它的属性被修改了呢?
接下来,我们使用Object.defineProperty()改写上面的例子:
let car = {
}
let val = 3000
Object.defineProperty(car, 'price', {
enumerable: true,
configurable: true,
get(){
console.log('price属性被读取了')
return val
},
set(newVal){
console.log('price属性被修改了')
val = newVal
}
})
通过Object.defineProperty()方法给car定义了一个price属性,并把这个属性的读和写分别使用get()和set()进行拦截,每当该属性进行读或写操作的时候就会触发get()和set()。如下图:
可以看到,car已经可以主动告诉我们它的属性的读写情况了,这也意味着,这个car的数据对象已经是“可观测”的了。
为了把car的所有属性都变得可观测,我们可以编写如下代码:
// 源码位置:src/core/observer/index.js
/**
* Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象
*/
export class Observer {
constructor (value) {
this.value = value
// 给value新增一个__ob__属性,值为该value的Observer实例
// 相当于为value打上标记,表示它已经被转化成响应式了,避免重复操作
def(value,'__ob__',this)
if (Array.isArray(value)) {
// 当value为数组时的逻辑
// ...
} else {
this.walk(value)
}
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
}
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive (obj,key,val) {
// 如果只传了obj和key,那么val = obj[key]
if (arguments.length === 2) {
val = obj[key]
}
if(typeof val === 'object'){
new Observer(val)
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
console.log(`${
key}属性被读取了`);
return val;
},
set(newVal){
if(val === newVal){
return
}
console.log(`${
key}属性被修改了`);
val = newVal;
}
})
}
在上面的代码中,我们定义了observer类,它用来将一个正常的object转换成可观测的object。
并且给value新增一个__ob__属性,值为该value的Observer实例。这个操作相当于为value打上标记,表示它已经被转化成响应式了,避免重复操作
然后判断数据的类型,只有object类型的数据才会调用walk将每一个属性转换成getter/setter的形式来侦测变化。 最后,在defineReactive中当传入的属性值还是一个object时使用new observer(val)来递归子属性,这样我们就可以把obj中的所有属性(包括子属性)都转换成getter/seter的形式来侦测变化。 也就是说,只要我们将一个object传到observer中,那么这个object就会变成可观测的、响应式的object。
observer类位于源码的src/core/observer/index.js中。
那么现在,我们就可以这样定义car:
let car = new Observer({
'brand':'BMW',
'price':3000
})
这样,car的两个属性都变得可观测了。
依赖收集
什么是依赖收集?在上一章中,我们迈出了第一步:让object数据变的可观测。变的可观测以后,我们就能知道数据什么时候发生了变化,那么当数据发生变化时,我们去通知视图更新就好了。那么问题又来了,视图那么大,我们到底该通知谁去变化?总不能一个数据变化了,把整个视图全部更新一遍吧,这样显然是不合理的。此时,你肯定会想到,视图里谁用到了这个数据就更新谁呗。对!你想的没错,就是这样。
视图里谁用到了这个数据就更新谁,我们换个优雅说法:我们把"谁用到了这个数据"称为"谁依赖了这个数据",我们给每个数据都建一个依赖数组(因为一个数据可能被多处使用),谁依赖了这个数据(即谁用到了这个数据)我们就把谁放入这个依赖数组中,那么当这个数据发生变化的时候,我们就去它对应的依赖数组中,把每个依赖都通知一遍,告诉他们:“你们依赖的数据变啦,你们该更新啦!”。这个过程就是依赖收集。
何时收集依赖?何时通知依赖更新?
明白了什么是依赖收集后,那么我们到底该在何时收集依赖?又该在何时通知依赖更新?
其实这个问题在上一小节中已经回答了,我们说过:谁用到了这个数据,那么当这个数据变化时就通知谁。所谓谁用到了这个数据,其实就是谁获取了这个数据,而可观测的数据被获取时会触发getter属性,那么我们就可以在getter中收集这个依赖。同样,当这个数据变化时会触发setter属性,那么我们就可以在setter中通知依赖更新。
总结一句话就是:在getter中收集依赖,在setter中通知依赖更新。
把依赖收集到哪里
明白了什么是依赖收集以及何时收集何时通知后,那么我们该把依赖收集到哪里?
我们给每个数据都建一个依赖数组,谁依赖了这个数据我们就把谁放入这个依赖数组中。单单用一个数组来存放依赖的话,功能好像有点欠缺并且代码过于耦合。我们应该将依赖数组的功能扩展一下,更好的做法是我们应该为每一个数据都建立一个依赖管理器,把这个数据所有的依赖都管理起来。OK,到这里,我们的依赖管理器Dep类应运而生,代码如下:
// 源码位置:src/core/observer/dep.js
export default class Dep {
constructor () {
this.subs = []
}
addSub (sub) {
this.subs.push(sub)
}
// 删除一个依赖
removeSub (sub) {
remove(this.subs, sub)
}
// 添加一个依赖
depend () {
if (window.target) {
this.addSub(window.target)
}
}
// 通知所有依赖更新
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
/**
* Remove an item from an array
*/
export function remove (arr, item) {
if (arr.length) {
const index = arr.indexOf(item)
if (index > -1) {
return arr.splice(index, 1)
}
}
}
在上面的依赖管理器Dep类中,我们先初始化了一个subs数组,用来存放依赖,并且定义了几个实例方法用来对依赖进行添加,删除,通知等操作。
有了依赖管理器后,我们就可以在getter中收集依赖,在setter中通知依赖更新了,代码如下:
function defineReactive (obj,key,val) {
if (arguments.length === 2) {
val = obj[key]
}
if(typeof val === 'object'){
new Observer(val)
}
const dep = new Dep() //实例化一个依赖管理器,生成一个依赖管理数组dep
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
dep.depend() // 在getter中收集依赖
return val;
},
set(newVal){
if(val === newVal){
return
}
val = newVal;
dep.notify() // 在setter中通知依赖更新
}
})
}
在上述代码中,我们在getter中调用了dep.depend()方法收集依赖,在setter中调用dep.notify()方法通知所有依赖更新。
依赖到底是谁
通过上一章节,我们明白了什么是依赖?何时收集依赖?以及收集的依赖存放到何处?那么我们收集的依赖到底是谁?
虽然我们一直在说”谁用到了这个数据谁就是依赖“,但是这仅仅是在口语层面上,那么反应在代码上该如何来描述这个”谁“呢?
其实在Vue中还实现了一个叫做Watcher的类,而Watcher类的实例就是我们上面所说的那个"谁"。换句话说就是:谁用到了数据,谁就是依赖,我们就为谁创建一个Watcher实例。在之后数据变化时,我们不直接去通知依赖更新,而是通知依赖对应的Watch实例,由Watcher实例去通知真正的视图。
Watcher类的具体实现如下:
export default class Watcher {
constructor (vm,expOrFn,cb) {
this.vm = vm;
this.cb = cb;
this.getter = parsePath(expOrFn)
this.value = this.get()
}
get () {
window.target = this;
const vm = this.vm
let value = this.getter.call(vm, vm)
window.target = undefined;
return value
}
update () {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
/**
* Parse simple path.
* 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
* 例如:
* data = {a:{b:{c:2}}}
* parsePath('a.b.c')(data) // 2
*/
const bailRE = /[^\w.$]/
export function parsePath (path) {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
谁用到了数据,谁就是依赖,我们就为谁创建一个Watcher实例,在创建Watcher实例的过程中会自动的把自己添加到这个数据对应的依赖管理器中,以后这个Watcher实例就代表这个依赖,当数据变化时,我们就通知Watcher实例,由Watcher实例再去通知真正的依赖。
那么,在创建Watcher实例的过程中它是如何的把自己添加到这个数据对应的依赖管理器中呢?
下面我们分析Watcher类的代码实现逻辑:
当实例化Watcher类时,会先执行其构造函数;
在构造函数中调用了this.get()实例方法;
在get()方法中,首先通过window.target = this把实例自身赋给了全局的一个唯一对象window.target上,然后通过let value = this.getter.call(vm, vm)获取一下被依赖的数据,获取被依赖数据的目的是触发该数据上面的getter,上文我们说过,在getter里会调用dep.depend()收集依赖,而在dep.depend()中取到挂载window.target上的值并将其存入依赖数组中,在get()方法最后将window.target释放掉。
而当数据变化时,会触发数据的setter,在setter中调用了dep.notify()方法,在dep.notify()方法中,遍历所有依赖(即watcher实例),执行依赖的update()方法,也就是Watcher类中的update()实例方法,在update()方法中调用数据变化的更新回调函数,从而更新视图。
简单总结一下就是:Watcher先把自己设置到全局唯一的指定位置(window.target),然后读取数据。因为读取了数据,所以会触发这个数据的getter。接着,在getter中就会从全局唯一的那个位置读取当前正在读取数据的Watcher,并把这个watcher收集到Dep中去。收集好之后,当数据发生变化时,会向Dep中的每个Watcher发送通知。通过这样的方式,Watcher可以主动去订阅任意一个数据的变化。为了便于理解,我们画出了其关系流程图,如下图:
以上,就彻底完成了对Object数据的侦测,依赖收集,依赖的更新等所有操作。
不足之处
虽然我们通过Object.defineProperty方法实现了对object数据的可观测,但是这个方法仅仅只能观测到object数据的取值及设置值,当我们向object数据里添加一对新的key/value或删除一对已有的key/value时,它是无法观测到的,导致当我们对object数据添加或删除值时,无法通知依赖,无法驱动视图进行响应式更新。
当然,Vue也注意到了这一点,为了解决这一问题,Vue增加了两个全局API:Vue.set和Vue.delete,这两个API的实现原理将会在后面学习全局API的时候说到。
总结
首先,我们通过Object.defineProperty方法实现了对object数据的可观测,并且封装了Observer类,让我们能够方便的把object数据中的所有属性(包括子属性)都转换成getter/seter的形式来侦测变化。
接着,我们学习了什么是依赖收集?并且知道了在getter中收集依赖,在setter中通知依赖更新,以及封装了依赖管理器Dep,用于存储收集到的依赖。
最后,我们为每一个依赖都创建了一个Watcher实例,当数据发生变化时,通知Watcher实例,由Watcher实例去做真实的更新操作。
其整个流程大致如下:
Data通过observer转换成了getter/setter的形式来追踪变化。
当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。
当数据发生了变化时,会触发setter,从而向Dep中的依赖(即Watcher)发送通知。
Watcher接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数等。
为什么Object数据和Array型数据会有两种不同的变化侦测方式?
这是因为对于Object数据我们使用的是JS提供的对象原型上的方法Object.defineProperty,而这个方法是对象原型上的,所以Array无法使用这个方法,所以我们需要对Array型数据设计一套另外的变化侦测机制。万变不离其宗,虽然对Array型数据设计了新的变化侦测机制,但是其根本思路还是不变的。那就是:还是在获取数据时收集依赖,数据变化时通知依赖更新。
data() {
return {
arr:[1,2,3]
}
}
想想看,arr这个数据始终都存在于一个object数据对象中,而且我们也说了,谁用到了数据谁就是依赖,那么要用到arr这个数据,是不是得先从
object数据对象中获取一下arr数据,而从object数据对象中获取arr数据自然就会触发arr的getter,所以我们就可以在getter中收集依赖。
let arr = [1,2,3]
arr.push(4)
Array.prototype.newPush = function(val){
console.log('arr被修改了')
this.push(val)
}
arr.newPush(4)
在Vue中创建了一个数组方法拦截器,它拦截在数组实例与Array.prototype之间,在拦截器内重写了操作数组的一些方法,当数组实例使用操作数组方法时
其实使用的是拦截器中重写的方法,而不再使用Array.prototype上的原生方法
function defineReactive (obj,key,val) {
let childOb = observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get(){
if (childOb) {
childOb.dep.depend()
}
return val;
},
set(newVal){
if(val === newVal){
return
}
val = newVal;
dep.notify() // 在setter中通知依赖更新
}
})
}
尝试为value创建一个0bserver实例,如果创建成功,直接返回新创建的Observer实例。如果 Value 已经存在一个Observer实例,则直接返回它
如何通知依赖
到现在为止,依赖已经收集好了,并且也已经存放好了,那么我们该如何通知依赖呢?
其实不难,在前文说过,我们应该在拦截器里通知依赖,要想通知依赖,首先要能访问到依赖。要访问到依赖也不难,因为我们只要能访问到被转化成响应式的数据value即可,因为vaule上的__ob__就是其对应的Observer类实例,有了Observer类实例我们就能访问到它上面的依赖管理器,然后只需调用依赖管理器的dep.notify()方法,让它去通知依赖更新即可。源码如下:
methodsToPatch.forEach(function (method) {
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
const ob = this.__ob__
// notify change
ob.dep.notify()
return result
})
})
上面代码中,由于我们的拦截器是挂载到数组数据的原型上的,所以拦截器中的this就是数据value,拿到value上的Observer类实例,从而你就可以调用Observer类实例上面依赖管理器的dep.notify()方法,以达到通知依赖的目的。
OK,以上就基本完成了Array数据的变化侦测。
深度侦测
在前文所有讲的Array型数据的变化侦测都仅仅说的是数组自身变化的侦测,比如给数组新增一个元素或删除数组中一个元素,而在Vue中,不论是Object型数据还是Array型数据所实现的数据变化侦测都是深度侦测,所谓深度侦测就是不但要侦测数据自身的变化,还要侦测数据中所有子数据的变化。举个例子:
let arr = [
{
name:'NLRX',
age:'18'
}
]
数组中包含了一个对象,如果该对象的某个属性发生了变化也应该被侦测到,这就是深度侦测。
这个实现起来比较简单,源码如下:
export class Observer {
value: any;
dep: Dep;
constructor (value: any) {
this.value = value
this.dep = new Dep()
def(value, '__ob__', this)
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value) // 将数组中的所有元素都转化为可被侦测的响应式
} else {
this.walk(value)
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
export function observe (value, asRootData){
if (!isObject(value) || value instanceof VNode) {
return
}
let ob
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else {
ob = new Observer(value)
}
return ob
}
在上面代码中,对于Array型数据,调用了observeArray()方法,该方法内部会遍历数组中的每一个元素,然后通过调用observe函数将每一个元素都转化成可侦测的响应式数据。
而对应object数据,在上一篇文章中我们已经在defineReactive函数中进行了递归操作。
数组新增元素的侦测
对于数组中已有的元素我们已经可以将其全部转化成可侦测的响应式数据了,但是如果向数组里新增一个元素的话,我们也需要将新增的这个元素转化成可侦测的响应式数据。
这个实现起来也很容易,我们只需拿到新增的这个元素,然后调用observe函数将其转化即可。我们知道,可以向数组内新增元素的方法有3个,分别是:push、unshift、splice。我们只需对这3中方法分别处理,拿到新增的元素,再将其转化即可。源码如下:
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args // 如果是push或unshift方法,那么传入参数就是新增的元素
break
case 'splice':
inserted = args.slice(2) // 如果是splice方法,那么传入参数列表中下标为2的就是新增的元素
break
}
if (inserted) ob.observeArray(inserted) // 调用observe函数将新增的元素转化成响应式
// notify change
ob.dep.notify()
return result
})
})
在上面拦截器定义代码中,如果是push或unshift方法,那么传入参数就是新增的元素;如果是splice方法,那么传入参数列表中下标为2的就是新增的元素,拿到新增的元素后,就可以调用observe函数将新增的元素转化成响应式的了。
前文中我们说过,对于数组变化侦测是通过拦截器实现的,也就是说只要是通过数组原型上的方法对数组进行操作就都可以侦测到,但是别忘了,我们在日常开发中,还可以通过数组的下标来操作数据,如下:
let arr = [1,2,3]
arr[0] = 5; // 通过数组下标修改数组中的数据
arr.length = 0 // 通过修改数组长度清空数组
而使用上述例子中的操作方式来修改数组是无法侦测到的。 同样,Vue也注意到了这个问题, 为了解决这一问题,Vue增加了两个全局API:Vue.set和Vue.delete,这两个API的实现原理将会在后面学习全局API的时候说到。
在本篇文章中,首先我们分析了对于Array型数据也在getter中进行依赖收集;其次我们发现,当数组数据被访问时我们轻而易举可以知道,但是被修改时我们却很难知道,为了解决这一问题,我们创建了数组方法拦截器,从而成功的将数组数据变的可观测。接着我们对数组的依赖收集及数据变化如何通知依赖进行了深入分析;最后我们发现Vue不但对数组自身进行了变化侦测,还对数组中的每一个元素以及新增的元素都进行了变化侦测,我们也分析了其实现原理。
以上就是对Array型数据的变化侦测分析。
所谓虚拟DOM,就是用一个JS对象来描述一个DOM节点,像如下示例:
<
div class="a" id="b">我是内容</div>
{
tag:'div', // 元素标签
attrs:{
// 属性
class:'a',
id:'b'
},
text:'我是内容', // 文本内容
children:[] // 子元素
}
我们把组成一个DOM节点的必要东西通过一个JS对象表示出来,那么这个JS对象就可以用来描述这个DOM节点,我们把这个JS对象就称为是这个真实DOM节点的虚拟DOM节点。
为什么要有虚拟DOM?
我们知道,Vue是数据驱动视图的,数据发生变化视图就要随之更新,在更新视图的时候难免要操作DOM,而操作真实DOM又是非常耗费性能的,这是因为浏览器的标准就把 DOM 设计的非常复杂,所以一个真正的 DOM 元素是非常庞大的,如下所示:
let div = document.createElement('div')
let str = ''
for (const key in div) {
str += key + ''
}
console.log(str)
上图中我们打印一个简单的空div标签,就打印出这么多东西,更不用说复杂的、深嵌套的DOM节点了。由此可见,直接操作真实DOM是非常消耗性能的。
那么有没有什么解决方案呢?当然是有的。我们可以用JS的计算性能来换取操作DOM所消耗的性能。
既然我们逃不掉操作DOM这道坎,但是我们可以尽可能少的操作DOM。那如何在更新视图的时候尽可能少的操作DOM呢?最直观的思路就是我们不要盲目的去更新视图,而是通过对比数据变化前后的状态,计算出视图中哪些地方需要更新,只更新需要更新的地方,而不需要更新的地方则不需关心,这样我们就可以尽可能少的操作DOM了。这也就是上面所说的用JS的计算性能来换取操作DOM的性能。
我们可以用JS模拟出一个DOM节点,称之为虚拟DOM节点。当数据发生变化时,我们对比变化前后的虚拟DOM节点,通过DOM-Diff算法计算出需要更新的地方,然后去更新需要更新的视图。
这就是虚拟DOM产生的原因以及最大的用途。
Vue中的虚拟DOM
前文我们介绍了虚拟DOM的概念以及为什么要有虚拟DOM,那么在Vue中虚拟DOM是怎么实现的呢?接下来,我们从源码出发,深入学习一下。
我们说了,虚拟DOM就是用JS来描述一个真实的DOM节点。而在Vue中就存在了一个VNode类,通过这个类,我们就可以实例化出不同类型的虚拟DOM节点,源码如下:
// 源码位置:src/core/vdom/vnode.js
export default class VNode {
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag /*当前节点的标签名*/
this.data = data /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
this.children = children /*当前节点的子节点,是一个数组*/
this.text = text /*当前节点的文本*/
this.elm = elm /*当前虚拟节点对应的真实dom节点*/
this.ns = undefined /*当前节点的名字空间*/
this.context = context /*当前组件节点对应的Vue实例*/
this.fnContext = undefined /*函数式组件对应的Vue实例*/
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key /*节点的key属性,被当作节点的标志,用以优化*/
this.componentOptions = componentOptions /*组件的option选项*/
this.componentInstance = undefined /*当前节点对应的组件的实例*/
this.parent = undefined /*当前节点的父节点*/
this.raw = false /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
this.isStatic = false /*静态节点标志*/
this.isRootInsert = true /*是否作为跟节点插入*/
this.isComment = false /*是否为注释节点*/
this.isCloned = false /*是否为克隆节点*/
this.isOnce = false /*是否有v-once指令*/
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
get child (): Component | void {
return this.componentInstance
}
}
从上面的代码中可以看出:VNode类中包含了描述一个真实DOM节点所需要的一系列属性,如tag表示节点的标签名,text表示节点中包含的文本,children表示该节点包含的子节点等。通过属性之间不同的搭配,就可以描述出各种类型的真实DOM节点。
上一小节最后我们说了,通过属性之间不同的搭配,VNode类可以描述出各种类型的真实DOM节点。那么它都可以描述出哪些类型的节点呢?通过阅读源码,可以发现通过不同属性的搭配,可以描述出以下几种类型的节点。
注释节点
文本节点
元素节点
组件节点
函数式组件节点
克隆节点
接下来,我们就把这几种类型的节点描述方式从源码中一一对应起来。
注释节点描述起来相对就非常简单了,它只需两个属性就够了,源码如下:
// 创建注释节点
export const createEmptyVNode = (text: string = '') => {
const node = new VNode()
node.text = text
node.isComment = true
return node
}
从上面代码中可以看到,描述一个注释节点只需两个属性,分别是:text和isComment。其中text属性表示具体的注释信息,isComment是一个标志,用来标识一个节点是否是注释节点。
文本节点描述起来比注释节点更简单,因为它只需要一个属性,那就是text属性,用来表示具体的文本信息。源码如下:
// 创建文本节点
export function createTextVNode (val: string | number) {
return new VNode(undefined, undefined, undefined, String(val))
}
克隆节点就是把一个已经存在的节点复制一份出来,它主要是为了做模板编译优化时使用,这个后面我们会说到。关于克隆节点的描述,源码如下:
// 创建克隆节点
export function cloneVNode (vnode: VNode): VNode {
const cloned = new VNode(
vnode.tag,
vnode.data,
vnode.children,
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
)
cloned.ns = vnode.ns
cloned.isStatic = vnode.isStatic
cloned.key = vnode.key
cloned.isComment = vnode.isComment
cloned.fnContext = vnode.fnContext
cloned.fnOptions = vnode.fnOptions
cloned.fnScopeId = vnode.fnScopeId
cloned.asyncMeta = vnode.asyncMeta
cloned.isCloned = true
return cloned
}
从上面代码中可以看到,克隆节点就是把已有节点的属性全部复制到新节点中,而现有节点和新克隆得到的节点之间唯一的不同就是克隆得到的节点isCloned为true。
相比之下,元素节点更贴近于我们通常看到的真实DOM节点,它有描述节点标签名词的tag属性,描述节点属性如class、attributes等的data属性,有描述包含的子节点信息的children属性等。由于元素节点所包含的情况相比而言比较复杂,源码中没有像前三种节点一样直接写死(当然也不可能写死),那就举个简单例子说明一下:
// 真实DOM节点
<div id='a'><span>难凉热血</span></div>
// VNode节点
{
tag:'div',
data:{
},
children:[
{
tag:'span',
text:'难凉热血'
}
]
}
我们可以看到,真实DOM节点中:div标签里面包含了一个span标签,而span标签里面有一段文本。反应到VNode节点上就如上所示:tag表示标签名,data表示标签的属性id等,children表示子节点数组。
组件节点除了有元素节点具有的属性之外,它还有两个特有的属性:
componentOptions :组件的option选项,如组件的props等
componentInstance :当前组件节点对应的Vue实例
函数式组件节点相较于组件节点,它又有两个特有的属性:
fnContext:函数式组件对应的Vue实例
fnOptions: 组件的option选项
以上就是VNode可以描述的多种节点类型,它们本质上都是VNode类的实例,只是在实例化的时候传入的属性参数不同而已。
说了这么多,那么VNode在Vue的整个虚拟DOM过程起了什么作用呢?
其实VNode的作用是相当大的。我们在视图渲染之前,把写好的template模板先编译成VNode并缓存下来,等到数据发生变化页面需要重新渲染的时候,我们把数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差异,然后有差异的VNode对应的真实DOM节点就是需要重新渲染的节点,最后根据有差异的VNode创建出真实的DOM节点再插入到视图中,最终完成一次视图更新。
首先介绍了虚拟DOM的一些基本概念和为什么要有虚拟DOM,其实说白了就是以JS的计算性能来换取操作真实DOM所消耗的性能。接着从源码角度我们知道了在Vue中是通过VNode类来实例化出不同类型的虚拟DOM节点,并且学习了不同类型节点生成的属性的不同,所谓不同类型的节点其本质还是一样的,都是VNode类的实例,只是在实例化时传入的属性参数不同罢了。最后探究了VNode的作用,有了数据变化前后的VNode,我们才能进行后续的DOM-Diff找出差异,最终做到只更新有差异的视图,从而达到尽可能少的操作真实DOM的目的,以节省性能。
在介绍VNode的时候我们说了,VNode最大的用途就是在数据变化前后生成真实DOM对应的虚拟DOM节点,然后就可以对比新旧两份VNode,找出差异所在,然后更新有差异的DOM节点,最终达到以最少操作真实DOM更新视图的目的。而对比新旧两份VNode并找出差异的过程就是所谓的DOM-Diff过程。DOM-Diff算法是整个虚拟DOM的核心所在,那么接下来,我们就以源码出发,深入研究一下Vue中的DOM-Diff过程是怎样的。
在Vue中,把 DOM-Diff过程叫做patch过程。patch,意为“补丁”,即指对旧的VNode修补,打补丁从而得到新的VNode,非常形象哈。那不管叫什么,其本质都是把对比新旧两份VNode的过程。我们在下面研究patch过程的时候,一定把握住这样一个思想:所谓旧的VNode(即oldVNode)就是数据变化之前视图所对应的虚拟DOM节点,而新的VNode是数据变化之后将要渲染的新的视图所对应的虚拟DOM节点,所以我们要以生成的新的VNode为基准,对比旧的oldVNode,如果新的VNode上有的节点而旧的oldVNode上没有,那么就在旧的oldVNode上加上去;如果新的VNode上没有的节点而旧的oldVNode上有,那么就在旧的oldVNode上去掉;如果某些节点在新的VNode和旧的oldVNode上都有,那么就以新的VNode为准,更新旧的oldVNode,从而让新旧VNode相同。
可能你感觉有点绕,没关系,我们在说的通俗一点,你可以这样理解:假设你电脑上现在有一份旧的电子版文档,此时老板又给了你一份新的纸质板文档,并告诉你这两份文档内容大部分都是一样的,让你以新的纸质版文档为准,把纸质版文档做一份新的电子版文档发给老板。对于这个任务此时,你应该有两种解决方案:一种方案是不管它旧的文档内容是什么样的,统统删掉,然后对着新的纸质版文档一个字一个字的敲进去,这种方案就是不用费脑,就是受点累也能解决问题。而另外一种方案是以新的纸质版文档为基准,对比看旧的电子版文档跟新的纸质版文档有什么差异,如果某些部分在新的文档里有而旧的文档里没有,那就在旧的文档里面把这些部分加上;如果某些部分在新的文档里没有而旧的文档里有,那就在旧的文档里把这些部分删掉;如果某些部分在新旧文档里都有,那就对比看有没有需要更新的,最后在旧的文档里更新一下,最终达到把旧的文档变成跟手里纸质版文档一样,完美解决。
对比以上两种方案,显然你和Vue一样聪明,肯定会选择第二种方案。第二种方案里的旧的电子版文档对应就是已经渲染在视图上的oldVNode,新的纸质版文档对应的是将要渲染在视图上的新的VNode。总之一句话:以新的VNode为基准,改造旧的oldVNode使之成为跟新的VNode一样,这就是patch过程要干的事。
说了这么多,听起来感觉好像很复杂的样子,其实不然,我们仔细想想,整个patch无非就是干三件事:
创建节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。
删除节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。
更新节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode。
OK,到这里,你就对Vue中的patch过程理解了一半了,接下来,我们就逐个分析,看Vue对于以上三件事都是怎么做的。
在我们分析了,VNode类可以描述6种类型的节点,而实际上只有3种类型的节点能够被创建并插入到DOM中,它们分别是:元素节点、文本节点、注释节点。所以Vue在创建节点的时候会判断在新的VNode中有而旧的oldVNode中没有的这个节点是属于哪种类型的节点,从而调用不同的方法创建并插入到DOM中。
其实判断起来也不难,因为这三种类型的节点其特点非常明显,在源码中是怎么判断的:
// 源码位置: /src/core/vdom/patch.js
function createElm (vnode, parentElm, refElm) {
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
vnode.elm = nodeOps.createElement(tag, vnode) // 创建元素节点
createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text) // 创建注释节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else {
vnode.elm = nodeOps.createTextNode(vnode.text) // 创建文本节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
}
}
从上面代码中,我们可以看出:
判断是否为元素节点只需判断该VNode节点是否有tag标签即可。如果有tag属性即认为是元素节点,则调用createElement方法创建元素节点,通常元素节点还会有子节点,那就递归遍历创建所有子节点,将所有子节点创建好之后insert插入到当前元素节点里面,最后把当前元素节点插入到DOM中。
判断是否为注释节点,只需判断VNode的isComment属性是否为true即可,若为true则为注释节点,则调用createComment方法创建注释节点,再插入到DOM中。
如果既不是元素节点,也不是注释节点,那就认为是文本节点,则调用createTextNode方法创建文本节点,再插入到DOM中。
代码中的nodeOps是Vue为了跨平台兼容性,对所有节点操作进行了封装,例如nodeOps.createTextNode()在浏览器端等同于document.createTextNode()
如果某些节点再新的VNode中没有而在旧的oldVNode中有,那么就需要把这些节点从旧的oldVNode中删除。删除节点非常简单,只需在要删除节点的父元素上调用removeChild方法即可。源码如下:
function removeNode (el) {
const parent = nodeOps.parentNode(el) // 获取父节点
if (isDef(parent)) {
nodeOps.removeChild(parent, el) // 调用父节点的removeChild方法
}
}
创建节点和删除节点都比较简单,而更新节点就相对较为复杂一点了,其实也不算多复杂,只要理清逻辑就能理解了。
更新节点就是当某些节点在新的VNode和旧的oldVNode中都有时,我们就需要细致比较一下,找出不一样的地方进行更新。
介绍更新节点之前,我们先介绍一个小的概念,就是什么是静态节点?我们看个例子:
<p>我是不会变化的文字</p>
上面这个节点里面只包含了纯文字,没有任何可变的变量,这也就是说,不管数据再怎么变化,只要这个节点第一次渲染了,那么它以后就永远不会发生变化,这是因为它不包含任何变量,所以数据发生任何变化都与它无关。我们把这种节点称之为静态节点。
OK,有了这个概念以后,我们开始更新节点。更新节点的时候我们需要对以下3种情况进行判断并分别处理:
如果VNode和oldVNode均为静态节点
我们说了,静态节点无论数据发生任何变化都与它无关,所以都为静态节点的话则直接跳过,无需处理。
如果VNode是文本节点
如果VNode是文本节点即表示这个节点内只包含纯文本,那么只需看oldVNode是否也是文本节点,如果是,那就比较两个文本是否不同,如果不同则把oldVNode里的文本改成跟VNode的文本一样。如果oldVNode不是文本节点,那么不论它是什么,直接调用setTextNode方法把它改成文本节点,并且文本内容跟VNode相同。
如果VNode是元素节点
如果VNode是元素节点,则又细分以下两种情况:
该节点包含子节点
如果新的节点内包含了子节点,那么此时要看旧的节点是否包含子节点,如果旧的节点里也包含了子节点,那就需要递归对比更新子节点;如果旧的节点里不包含子节点,那么这个旧节点有可能是空节点或者是文本节点,如果旧的节点是空节点就把新的节点里的子节点创建一份然后插入到旧的节点里面,如果旧的节点是文本节点,则把文本清空,然后把新的节点里的子节点创建一份然后插入到旧的节点里面。
该节点不包含子节点
如果该节点不包含子节点,同时它又不是文本节点,那就说明该节点是个空节点,那就好办了,不管旧节点之前里面都有啥,直接清空即可。
OK,处理完以上3种情况,更新节点就算基本完成了,接下来我们看下源码中具体是怎么实现的,源码如下:
// 更新节点
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// vnode与oldVnode是否完全一样?若是,退出程序
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
// vnode与oldVnode是否都是静态节点?若是,退出程序
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
return
}
const oldCh = oldVnode.children
const ch = vnode.children
// vnode有text属性?若没有:
if (isUndef(vnode.text)) {
// vnode的子节点与oldVnode的子节点是否都存在?
if (isDef(oldCh) && isDef(ch)) {
// 若都存在,判断子节点是否相同,不同则更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
// 若只有vnode的子节点存在
else if (isDef(ch)) {
/**
* 判断oldVnode是否有文本?
* 若没有,则把vnode的子节点添加到真实DOM中
* 若有,则清空Dom中的文本,再把vnode的子节点添加到真实DOM中
*/
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
// 若只有oldnode的子节点存在
else if (isDef(oldCh)) {
// 清空DOM中的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 若vnode和oldnode都没有子节点,但是oldnode中有文本
else if (isDef(oldVnode.text)) {
// 清空oldnode文本
nodeOps.setTextContent(elm, '')
}
// 上面两个判断一句话概括就是,如果vnode中既没有text,也没有子节点,那么对应的oldnode中有什么就清空什么
}
// 若有,vnode的text属性与oldVnode的text属性是否相同?
else if (oldVnode.text !== vnode.text) {
// 若不相同:则用vnode的text替换真实DOM的文本
nodeOps.setTextContent(elm, vnode.text)
}
}
上面代码里注释已经写得很清晰了,接下来我们画流程图来梳理一下整个过程,流程图如下:
通过对照着流程图以及代码,相信更新节点这部分逻辑你很容易就能理解了。
另外,你可能注意到了,如果新旧VNode里都包含了子节点,那么对于子节点的更新在代码里调用了updateChildren方法,而这个方法的逻辑到底是怎样的。
在我们介绍了Vue中的DOM-Diff算法:patch过程。我们先介绍了算法的整个思想流程,然后通过梳理算法思想,了解了整个patch过程干了三件事,分别是:创建节点,删除节点,更新节点。并且对每件事情都对照源码展开了细致的学习,画出了其逻辑流程图。另外对于更新节点中,如果新旧VNode里都包含了子节点,我们就需要细致的去更新子节点。
我们了解了Vue中的patch过程,即DOM-Diff算法。并且知道了在patch过程中基本会干三件事,分别是:创建节点,删除节点和更新节点。创建节点和删除节点都比较简单,而更新节点因为要处理各种可能出现的情况所以逻辑略微复杂一些,但是没关系,我们通过分析过程,对照源码,画逻辑流程图来帮助我们理解了其中的过程。最后我们还遗留了一个问题,那就是在更新节点过程中,新旧VNode可能都包含有子节点,对于子节点的对比更新会有额外的一些逻辑,那么在本篇文章中我们就来学习在Vue中是怎么对比更新子节点的。
当新的VNode与旧的oldVNode都是元素节点并且都包含子节点时,那么这两个节点的VNode实例上的children属性就是所包含的子节点数组。我们把新的VNode上的子节点数组记为newChildren,把旧的oldVNode上的子节点数组记为oldChildren,我们把newChildren里面的元素与oldChildren里的元素一一进行对比,对比两个子节点数组肯定是要通过循环,外层循环newChildren数组,内层循环oldChildren数组,每循环外层newChildren数组里的一个子节点,就去内层oldChildren数组里找看有没有与之相同的子节点,伪代码如下:
for (let i = 0; i < newChildren.length; i++) {
const newChild = newChildren[i];
for (let j = 0; j < oldChildren.length; j++) {
const oldChild = oldChildren[j];
if (newChild === oldChild) {
// ...
}
}
}
那么以上这个过程将会存在以下四种情况:
创建子节点
如果newChildren里面的某个子节点在oldChildren里找不到与之相同的子节点,那么说明newChildren里面的这个子节点是之前没有的,是需要此次新增的节点,那么就创建子节点。
删除子节点
如果把newChildren里面的每一个子节点都循环完毕后,发现在oldChildren还有未处理的子节点,那就说明这些未处理的子节点是需要被废弃的,那么就将这些节点删除。
移动子节点
如果newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,但是所处的位置不同,这说明此次变化需要调整该子节点的位置,那就以newChildren里子节点的位置为基准,调整oldChildren里该节点的位置,使之与在newChildren里的位置相同。
更新节点
如果newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,并且所处的位置也相同,那么就更新oldChildren里该节点,使之与newChildren里的该节点相同。
OK,到这里,逻辑就相对清晰了,接下来我们只需分门别类的处理这四种情况就好了。
创建子节点
如果newChildren里面的某个子节点在oldChildren里找不到与之相同的子节点,那么说明newChildren里面的这个子节点是之前没有的,是需要此次新增的节点,那么我们就创建这个节点,创建好之后再把它插入到DOM中合适的位置。
创建节点这个很容易,我们在上一篇文章的第三章已经介绍过了,这里就不再赘述了。
那么创建好之后如何插入到DOM中的合适的位置呢?显然,把节点插入到DOM中是很容易的,找到合适的位置是关键。接下来我们分析一下如何找这个合适的位置。我们看下面这个图:
上图中左边是新的VNode,右边是旧的oldVNode,同时也是真实的DOM。这个图意思是当我们循环newChildren数组里面的子节点,前两个子节点都在oldChildren里找到了与之对应的子节点,那么我们将其处理,处理过后把它们标志为已处理,当循环到newChildren数组里第三个子节点时,发现在oldChildren里找不到与之对应的子节点,那么我们就需要创建这个节点,创建好之后我们发现这个节点本是newChildren数组里左起第三个子节点,那么我们就把创建好的节点插入到真实DOM里的第三个节点位置,也就是所有已处理节点之后,OK,此时我们拍手称快,所有已处理节点之后就是我们要找的合适的位置,但是真的是这样吗?我们再来看下面这个图:
假如我们按照上面的方法把第三个节点插入到所有已处理节点之后,此时如果第四个节点也在oldChildren里找不到与之对应的节点,也是需要创建的节点,那么当我们把第四个节点也按照上面的说的插入到已处理节点之后,发现怎么插入到第三个位置了,可明明这个节点在newChildren数组里是第四个啊!
这就是问题所在,其实,我们应该把新创建的节点插入到所有未处理节点之前,这样以来逻辑才正确。后面不管有多少个新增的节点,每一个都插入到所有未处理节点之前,位置才不会错。
所以,合适的位置是所有未处理节点之前,而并非所有已处理节点之后。
删除子节点
如果把newChildren里面的每一个子节点都循环一遍,能在oldChildren数组里找到的就处理它,找不到的就新增,直到把newChildren里面所有子节点都过一遍后,发现在oldChildren还存在未处理的子节点,那就说明这些未处理的子节点是需要被废弃的,那么就将这些节点删除。
删除节点这个也很容易,我们在上一篇文章的第四章已经介绍过了,这里就不再赘述了。
更新子节点
如果newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,并且所处的位置也相同,那么就更新oldChildren里该节点,使之与newChildren里的该节点相同。
关于更新节点,我们在上一篇文章的第五章已经介绍过了,这里就不再赘述了。
移动子节点
如果newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,但是所处的位置不同,这说明此次变化需要调整该子节点的位置,那就以newChildren里子节点的位置为基准,调整oldChildren里该节点的位置,使之与在newChildren里的位置相同。
同样,移动一个节点不难,关键在于该移动到哪,或者说关键在于移动到哪个位置,这个位置才是关键。我们看下图:
在上图中,绿色的两个节点是相同节点但是所处位置不同,即newChildren里面的第三个子节点与真实DOM即oldChildren里面的第四个子节点相同但是所处位置不同,按照上面所说的,我们应该以newChildren里子节点的位置为基准,调整oldChildren里该节点的位置,所以我们应该把真实DOM即oldChildren里面的第四个节点移动到第三个节点的位置,通过上图中的标注我们不难发现,所有未处理节点之前就是我们要移动的目的位置。如果此时你说那可不可以移动到所有已处理节点之后呢?那就又回到了更新节点时所遇到的那个问题了:如果前面有新增的节点呢?
回到源码
OK,以上就是更新子节点时所要考虑的所有情况了,分析完以后,我们回到源码里看看实际情况是不是我们分析的这样子的,源码如下:
// 源码位置: /src/core/vdom/patch.js
if (isUndef(idxInOld)) {
// 如果在oldChildren里找不到当前循环的newChildren里的子节点
// 新增节点并插入到合适位置
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 如果在oldChildren里找到了当前循环的newChildren里的子节点
vnodeToMove = oldCh[idxInOld]
// 如果两个节点相同
if (sameVnode(vnodeToMove, newStartVnode)) {
// 调用patchVnode更新节点
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
// canmove表示是否需要移动节点,如果为true表示需要移动,则移动节点,如果为false则不用移动
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
}
以上代码中,首先判断在oldChildren里能否找到当前循环的newChildren里的子节点,如果找不到,那就是新增节点并插入到合适位置;如果找到了,先对比两个节点是否相同,若相同则先调用patchVnode更新节点,更新完之后再看是否需要移动节点,注意,源码里在判断是否需要移动子节点时用了简写的方式,下面这两种写法是等价的:
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
// 等同于
if(canMove){
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
我们看到,源码里的实现跟我们分析的是一样一样的。
我们分析了Vue在更新子节点时是外层循环newChildren数组,内层循环oldChildren数组,把newChildren数组里的每一个元素分别与oldChildren数组里的每一个元素匹配,根据不同情况作出创建子节点、删除子节点、更新子节点以及移动子节点的操作。并且我们对不同情况的不同操作都进行了深入分析,分析之后我们回到源码验证我们分析的正确性,发现我们的分析跟源码的实现是一致的。
最后,我们再思考一个问题:这样双层循环虽然能解决问题,但是如果节点数量很多,这样循环算法的时间复杂度会不会很高?有没有什么可以优化的办法?答案当然是有的,并且Vue也意识到了这点,也进行了优化,那么下面我们就来分析当节点数量很多时Vue是怎么优化算法的。
我们介绍了当新的VNode与旧的oldVNode都是元素节点并且都包含子节点时,Vue对子节点是
先外层循环newChildren数组,再内层循环oldChildren数组,每循环外层newChildren数组里的一个子节点,就去内层oldChildren数组里找看有没有与之相同的子节点,最后根据不同的情况作出不同的操作。
在上一篇文章的结尾我们也说了,这种方法虽然能够解决问题,但是还存在可优化的地方。比如当包含的子节点数量很多时,这样循环算法的时间复杂度就会变的很大,不利于性能提升。当然,Vue也意识到了这点,并对此也进行了优化,那么本篇文章,就来学习一下关于子节点更新的优化问题Vue是如何做的。
假如我们现有一份新的newChildren数组和旧的oldChildren数组,如下所示:
newChildren = ['新子节点1','新子节点2','新子节点3','新子节点4']
oldChildren = ['旧子节点1','旧子节点2','旧子节点3','旧子节点4']
如果按照优化之前的解决方案,那么我们接下来的操作应该是这样的:先循环newChildren数组,拿到第一个新子节点1,然后用第一个新子节点1去跟oldChildren数组里的旧子节点逐一对比,如果运气好一点,刚好oldChildren数组里的第一个旧子节点1与第一个新子节点1相同,那就皆大欢喜,直接处理,不用再往下循环了。那如果运气坏一点,直到循环到oldChildren数组里的第四个旧子节点4才与第一个新子节点1相同,那此时就会多循环了4次。我们不妨把情况再设想的极端一点,如果newChildren数组和oldChildren数组里前三个节点都没有变化,只是第四个节点发生了变化,那么我们就会循环16次,只有在第16次循环的时候才发现新节点4与旧节点4相同,进行更新,如下图所示:
上面例子中只有四个子节点,好像还看不出来有什么缺陷,但是当子节点数量很多的时候,算法的时间复杂度就会非常高,很不利于性能提升。
那么我们该怎么优化呢?其实我们可以这样想,我们不要按顺序去循环newChildren和oldChildren这两个数组,可以先比较这两个数组里特殊位置的子节点,比如:
先把newChildren数组里的所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那就直接进入更新节点的操作;
如果不同,再把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作;
如果不同,再把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;
如果不同,再把newChildren数组里所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;
最后四种情况都试完如果还不同,那就按照之前循环的方式来查找节点。
其过程如下图所示:
在上图中,我们把:
newChildren数组里的所有未处理子节点的第一个子节点称为:新前;
newChildren数组里的所有未处理子节点的最后一个子节点称为:新后;
oldChildren数组里的所有未处理子节点的第一个子节点称为:旧前;
oldChildren数组里的所有未处理子节点的最后一个子节点称为:旧后;
OK,有了以上概念以后,下面我们就来看看其具体是如何实施的。
新前与旧前
把newChildren数组里的所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那好极了,直接进入之前文章中说的更新节点的操作并且由于新前与旧前两个节点的位置也相同,无需进行节点移动操作;如果不同,没关系,再尝试后面三种情况。
新后与旧后
把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作并且由于新后与旧后两个节点的位置也相同,无需进行节点移动操作;如果不同,继续往后尝试。
新后与旧前
把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;
此时,出现了移动节点的操作,移动节点最关键的地方在于找准要移动的位置。我们一再强调,更新节点要以新VNode为基准,然后操作旧的oldVNode,使之最后旧的oldVNode与新的VNode相同。那么现在的情况是:newChildren数组里的最后一个子节点与oldChildren数组里的第一个子节点相同,那么我们就应该在oldChildren数组里把第一个子节点移动到最后一个子节点的位置,如下图:
从图中不难看出,我们要把oldChildren数组里把第一个子节点移动到数组中所有未处理节点之后。
如果对比之后发现这两个节点仍不是同一个节点,那就继续尝试最后一种情况。
新前与旧后
把newChildren数组里所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;
同样,这种情况的节点移动位置逻辑与“新后与旧前”的逻辑类似,那就是newChildren数组里的第一个子节点与oldChildren数组里的最后一个子节点相同,那么我们就应该在oldChildren数组里把最后一个子节点移动到第一个子节点的位置,如下图:
从图中不难看出,我们要把oldChildren数组里把最后一个子节点移动到数组中所有未处理节点之前。
OK,以上就是子节点对比更新优化策略种的4种情况,如果以上4种情况逐个试遍之后要是还没找到相同的节点,那就再通过之前的循环方式查找。
回到源码
思路分析完,逻辑理清之后,我们再回到源码里看看,验证一下源码实现的逻辑是否跟我们分析的一样。源码如下:
// 循环更新子节点
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // oldChildren开始索引
let oldEndIdx = oldCh.length - 1 // oldChildren结束索引
let oldStartVnode = oldCh[0] // oldChildren中所有未处理节点中的第一个
let oldEndVnode = oldCh[oldEndIdx] // oldChildren中所有未处理节点中的最后一个
let newStartIdx = 0 // newChildren开始索引
let newEndIdx = newCh.length - 1 // newChildren结束索引
let newStartVnode = newCh[0] // newChildren中所有未处理节点中的第一个
let newEndVnode = newCh[newEndIdx] // newChildren中所有未处理节点中的最后一个
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
// 以"新前"、"新后"、"旧前"、"旧后"的方式开始比对节点
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // 如果oldStartVnode不存在,则直接跳过,比对下一个
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 如果新前与旧前节点相同,就把两个节点进行patch更新
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 如果新后与旧后节点相同,就把两个节点进行patch更新
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
// 如果新后与旧前节点相同,先把两个节点进行patch更新,然后把旧前节点移动到oldChilren中所有未处理节点之后
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
// 如果新前与旧后节点相同,先把两个节点进行patch更新,然后把旧后节点移动到oldChilren中所有未处理节点之前
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 如果不属于以上四种情况,就进行常规的循环比对patch
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果在oldChildren里找不到当前循环的newChildren里的子节点
if (isUndef(idxInOld)) {
// New element
// 新增节点并插入到合适位置
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 如果在oldChildren里找到了当前循环的newChildren里的子节点
vnodeToMove = oldCh[idxInOld]
// 如果两个节点相同
if (sameVnode(vnodeToMove, newStartVnode)) {
// 调用patchVnode更新节点
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
// canmove表示是否需要移动节点,如果为true表示需要移动,则移动节点,如果为false则不用移动
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
/**
* 如果oldChildren比newChildren先循环完毕,
* 那么newChildren里面剩余的节点都是需要新增的节点,
* 把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中
*/
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
/**
* 如果newChildren比oldChildren先循环完毕,
* 那么oldChildren里面剩余的节点都是需要删除的节点,
* 把[oldStartIdx, oldEndIdx]之间的所有节点都删除
*/
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
读源码之前,我们先有这样一个概念:那就是在我们前面所说的优化策略中,节点有可能是从前面对比,也有可能是从后面对比,对比成功就会进行更新处理,也就是说我们有可能处理第一个,也有可能处理最后一个,那么我们在循环的时候就不能简单从前往后或从后往前循环,而是要从两边向中间循环。
那么该如何从两边向中间循环呢?请看下图:
首先,我们先准备4个变量:
newStartIdx:newChildren数组里开始位置的下标;
newEndIdx:newChildren数组里结束位置的下标;
oldStartIdx:oldChildren数组里开始位置的下标;
oldEndIdx:oldChildren数组里结束位置的下标;
在循环的时候,每处理一个节点,就将下标向图中箭头所指的方向移动一个位置,开始位置所表示的节点被处理后,就向后移动一个位置;结束位置所表示的节点被处理后,就向前移动一个位置;由于我们的优化策略都是新旧节点两两更新的,所以一次更新将会移动两个节点。说的再直白一点就是:newStartIdx和oldStartIdx只能往后移动(只会加),newEndIdx和oldEndIdx只能往前移动(只会减)。
当开始位置大于结束位置时,表示所有节点都已经遍历过了。
OK,有了这个概念后,我们开始读源码:
如果oldStartVnode不存在,则直接跳过,将oldStartIdx加1,比对下一个
// 以"新前"、“新后”、“旧前”、"旧后"的方式开始比对节点
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]
}
}
如果oldEndVnode不存在,则直接跳过,将oldEndIdx减1,比对前一个
else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[–oldEndIdx]
}
如果新前与旧前节点相同,就把两个节点进行patch更新,同时oldStartIdx和newStartIdx都加1,后移一个位置
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
如果新后与旧后节点相同,就把两个节点进行patch更新,同时oldEndIdx和newEndIdx都减1,前移一个位置
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[–oldEndIdx]
newEndVnode = newCh[–newEndIdx]
}
如果新后与旧前节点相同,先把两个节点进行patch更新,然后把旧前节点移动到oldChilren中所有未处理节点之后,最后把oldStartIdx加1,后移一个位置,newEndIdx减1,前移一个位置
else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[–newEndIdx]
}
如果新前与旧后节点相同,先把两个节点进行patch更新,然后把旧后节点移动到oldChilren中所有未处理节点之前,最后把newStartIdx加1,后移一个位置,oldEndIdx减1,前移一个位置
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[–oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
如果不属于以上四种情况,就进行常规的循环比对patch
如果在循环中,oldStartIdx大于oldEndIdx了,那就表示oldChildren比newChildren先循环完毕,那么newChildren里面剩余的节点都是需要新增的节点,把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
}
如果在循环中,newStartIdx大于newEndIdx了,那就表示newChildren比oldChildren先循环完毕,那么oldChildren里面剩余的节点都是需要删除的节点,把[oldStartIdx, oldEndIdx]之间的所有节点都删除
else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
OK,处理完毕,可见源码中的处理逻辑跟我们之前分析的逻辑是一样的。
总结
我们介绍了Vue中子节点更新的优化策略,发现Vue为了避免双重循环数据量大时间复杂度升高带来的性能问题,而选择了从子节点数组中的4个特殊位置互相比对,分别是:新前与旧前,新后与旧后,新后与旧前,新前与旧后。对于每一种情况我们都通过图文的形式对其逻辑进行了分析。最后我们回到源码,通过阅读源码来验证我们分析的是否正确。幸运的是我们之前每一步的分析都在源码中找到了相应的实现,得以验证我们的分析没有错。以上就是Vue中的patch过程,即DOM-Diff算法所有内容了,到这里相信你再读这部分源码的时候就有比较清晰的思路了。
你可以这么理解:把用户写的模板进行编译,就会产生VNode。
我们知道,在日常开发中,我们把写在标签中的类似于原生HTML的内容称之为模板。这时你可能会问了,为什么说是“类似于原生HTML的内容”而不是“就是HTML的内容”?因为我们在开发中,在标签中除了写一些原生HTML的标签,我们还会写一些变量插值,如,或者写一些Vue指令,如v-on、v-if等。而这些东西都是在原生HTML语法中不存在的,不被接受的。但是事实上我们确实这么写了,也被正确识别了,页面也正常显示了,这又是为什么呢?
这就归功于Vue的模板编译了,Vue会把用户在标签中写的类似于原生HTML的内容进行编译,把原生HTML的内容找出来,再把非原生HTML找出来,经过一系列的逻辑处理生成渲染函数,也就是render函数,而render函数会将模板内容生成对应的VNode,而VNode再经过前几篇文章介绍的patch过程从而得到将要渲染的视图中的VNode,最后根据VNode创建真实的DOM节点并插入到视图中, 最终完成视图的渲染更新。
而把用户在标签中写的类似于原生HTML的内容进行编译,把原生HTML的内容找出来,再把非原生HTML找出来,经过一系列的逻辑处理生成渲染函数,也就是render函数的这一段过程称之为模板编译过程。
所谓渲染流程,就是把用户写的类似于原生HTML的模板经过一系列处理最终反应到视图中称之为整个渲染流程。这个流程在上文中其实已经说到了,下面我们以流程图的形式宏观的了解一下,流程图如下:
从图中我们也可以看到,模板编译过程就是把用户写的模板经过一系列处理最终生成render函数的过程。
模板编译内部流程
那么模板编译内部是怎么把用户写的模板经过处理最终生成render函数的呢?这内部的过程是怎样的呢?
抽象语法树AST
我们知道,用户在标签中写的模板对Vue来说就是一堆字符串,那么如何解析这一堆字符串并且从中提取出元素的标签、属性、变量插值等有效信息呢?这就需要借助一个叫做抽象语法树的东西。
所谓抽象语法树,在计算机科学中,抽象语法树(AbstractSyntaxTree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于if-condition-then这样的条件跳转语句,可以使用带有两个分支的节点来表示。——来自百度百科
我就知道,这段话贴出来也是白贴,因为看了也看不懂,哈哈。那么我们就以最直观的例子来理解什么是抽象语法树。请看下图:
从图中我们可以看到,一个简单的HTML标签的代码被转换成了一个JS对象,而这个对象中的属性代表了这个标签中一些关键有效信息。如图中标识。 有兴趣的同学可以在这个网站在线转换试试:https://astexplorer.net/
具体流程
将一堆字符串模板解析成抽象语法树AST后,我们就可以对其进行各种操作处理了,处理完后用处理后的AST来生成render函数。其具体流程可大致分为三个阶段:
1.模板解析阶段:将一堆模板字符串用正则等方式解析成抽象语法树AST;
2.优化阶段:遍历AST,找出其中的静态节点,并打上标记;
3.代码生成阶段:将AST转换成渲染函数;
这三个阶段在源码中分别对应三个模块,下面给出三个模块的源代码在源码中的路径:
模板解析阶段——解析器——源码路径:src/compiler/parser/index.js;
优化阶段——优化器——源码路径:src/compiler/optimizer.js;
代码生成阶段——代码生成器——源码路径:src/compiler/codegen/index.js; 其对应的源码如下:
// 源码位置: /src/complier/index.js
export const createCompiler = createCompilerCreator(function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
// 模板解析阶段:用正则等方式解析 template 模板中的指令、class、style等数据,形成AST
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
// 优化阶段:遍历AST,找出其中的静态节点,并打上标记;
optimize(ast, options)
}
// 代码生成阶段:将AST转换成渲染函数;
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
})
可以看到 baseCompile 的代码非常的简短主要核心代码。
const ast =parse(template.trim(), options):parse 会用正则等方式解析 template 模板中的指令、class、style等数据,形成AST。
optimize(ast, options): optimize 的主要作用是标记静态节点,这是 Vue 在编译过程中的一处优化,挡在进行patch 的过程中, DOM-Diff 算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch 的性能。
const code =generate(ast, options): 将 AST 转化成 render函数字符串的过程,得到结果是 render函数 的字符串以及 staticRenderFns 字符串。
最终 baseCompile 的返回值
{
ast: ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
最终返回了抽象语法树( ast ),渲染函数( render ),静态渲染函数( staticRenderFns ),且render 的值为code.render,staticRenderFns 的值为code.staticRenderFns,也就是说通过 generate处理 ast之后得到的返回值 code 是一个对象。
下面再给出模板编译内部具体流程图,便于理解。流程图如下:
首先引出了为什么会有模板编译,因为有了模板编译,才有了虚拟DOM,才有了后续的视图更新。接着介绍了什么是模板编译,以及介绍了把用户所写的模板经过层层处理直到最终渲染的视图中这个整体的渲染流程;最后介绍了模板编译过程中所需要使用的抽象语法树的概念以及分析了模板编译的具体实施流程,其流程大致分为三个阶段,分别是模板解析阶段、优化阶段和代码生成阶段。那么接下来会把这三个阶段逐一进行分析介绍。
在模板解析阶段主要做的工作是把用户在标签内写的模板使用正则等方式解析成抽象语法树(AST)。而这一阶段在源码中对应解析器(parser)模块。
解析器,顾名思义,就是把用户所写的模板根据一定的解析规则解析出有效的信息,最后用这些信息形成AST。我们知道在模板内,除了有常规的HTML标签外,用户还会一些文本信息以及在文本信息中包含过滤器。而这些不同的内容在解析起来肯定需要不同的解析规则,所以解析器不可能只有一个,它应该除了有解析常规HTML的HTML解析器,还应该有解析文本的文本解析器以及解析文本中如果包含过滤器的过滤器解析器。
另外,文本信息和标签属性信息却又是存在于HTML标签之内的,所以在解析整个模板的时候它的流程应该是这样子的:HTML解析器是主线,先用HTML解析器进行解析整个模板,在解析过程中如果碰到文本内容,那就调用文本解析器来解析文本,如果碰到文本中包含过滤器那就调用过滤器解析器来解析。如下图所示:
解析器的源码位于/src/complier/parser文件夹下,其主线代码如下:
// 代码位置:/src/complier/parser/index.js
/**
* Convert HTML string to AST.
*/
export function parse(template, options) {
// ...
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
start (tag, attrs, unary) {
},
end () {
},
chars (text: string) {
},
comment (text: string) {
}
})
return root
}
从上面代码中可以看到,parse 函数就是解析器的主函数,在parse 函数内调用了parseHTML 函数对模板字符串进行解析,在parseHTML 函数解析模板字符串的过程中,如果遇到文本信息,就会调用文本解析器parseText函数进行文本解析;如果遇到文本中包含过滤器,就会调用过滤器解析器parseFilters函数进行解析。
模板解析的整体运行流程,模板解析其实就是根据被解析内容的特点使用正则等方式将有效信息解析提取出来,根据解析内容的不同分为HTML解析器,文本解析器和过滤器解析器。而文本信息与过滤器信息又存在于HTML标签中,所以在解析器主线函数parse中先调用HTML解析器parseHTML 函数对模板字符串进行解析,如果在解析过程中遇到文本或过滤器信息则再调用相应的解析器进行解析,最终完成对整个模板字符串的解析。
了解了模板解析阶段的整体运行流程后,接下来,我们就对流程中所涉及到的三种解析器分别深入分析,逐个击破。
在三个不同的解析器中最主要的当属HTML解析器,为什么这么说呢?因为HTML解析器主要负责解析出模板字符串中有哪些内容,然后根据不同的内容才能调用其他的解析器以及做相应的处理。那么本篇文章就来介绍一下HTML解析器是如何解析出模板字符串中包含的不同的内容的。
HTML解析器内部运行流程
在源码中,HTML解析器就是parseHTML函数,在模板解析主线函数parse中调用了该函数,并传入两个参数,代码如下:
// 代码位置:/src/complier/parser/index.js
/**
* Convert HTML string to AST.
* 将HTML模板字符串转化为AST
*/
export function parse(template, options) {
// ...
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
// 当解析到开始标签时,调用该函数
start (tag, attrs, unary) {
},
// 当解析到结束标签时,调用该函数
end () {
},
// 当解析到文本时,调用该函数
chars (text) {
},
// 当解析到注释时,调用该函数
comment (text) {
}
})
return root
}
从代码中我们可以看到,调用parseHTML函数时为其传入的两个参数分别是:
template:待转换的模板字符串;
options:转换时所需的选项;
第一个参数是待转换的模板字符串,无需多言;重点看第二个参数,第二个参数提供了一些解析HTML模板时的一些参数,同时还定义了4个钩子函数。这4个钩子函数有什么作用呢?我们说了模板编译阶段主线函数parse会将HTML模板字符串转化成AST,而parseHTML是用来解析模板字符串的,把模板字符串中不同的内容出来之后,那么谁来把提取出来的内容生成对应的AST呢?答案就是这4个钩子函数。
把这4个钩子函数作为参数传给解析器parseHTML,当解析器解析出不同的内容时调用不同的钩子函数从而生成不同的AST。
当解析到开始标签时调用start函数生成元素类型的AST节点,代码如下;
// 当解析到标签的开始位置时,触发start
start (tag, attrs, unary) {
let element = createASTElement(tag, attrs, currentParent)
}
export function createASTElement (tag,attrs,parent) {
return {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent,
children: []
}
}
从上面代码中我们可以看到,start函数接收三个参数,分别是标签名tag、标签属性attrs、标签是否自闭合unary。当调用该钩子函数时,内部会调用createASTElement函数来创建元素类型的AST节点
当解析到结束标签时调用end函数;
当解析到文本时调用chars函数生成文本类型的AST节点;
// 当解析到标签的文本时,触发chars
chars (text) {
if(text是带变量的动态文本){
let element = {
type: 2,
expression: res.expression,
tokens: res.tokens,
text
}
} else {
let element = {
type: 3,
text
}
}
}
当解析到标签的文本时,触发chars钩子函数,在该钩子函数内部,首先会判断文本是不是一个带变量的动态文本,如“hello ”。如果是动态文本,则创建动态文本类型的AST节点;如果不是动态文本,则创建纯静态文本类型的AST节点。
当解析到注释时调用comment函数生成注释类型的AST节点;
// 当解析到标签的注释时,触发comment
comment (text: string) {
let element = {
type: 3,
text,
isComment: true
}
}
当解析到标签的注释时,触发comment钩子函数,该钩子函数会创建一个注释类型的AST节点。
一边解析不同的内容一边调用对应的钩子函数生成对应的AST节点,最终完成将整个模板字符串转化成AST,这就是HTML解析器所要做的工作。
如何解析不同的内容
要从模板字符串中解析出不同的内容,那首先要知道模板字符串中都会包含哪些内容。那么通常我们所写的模板字符串中都会包含哪些内容呢?经过整理,通常模板内会包含如下内容:
文本,例如“难凉热血”
HTML注释,例如<!-- 我是注释 -->
条件注释,例如<!-- [if !IE]> -->我是注释<!--< ![endif] -->
DOCTYPE,例如<!DOCTYPE html>
开始标签,例如<div>
结束标签,例如</div>
这几种内容都有其各自独有的特点,也就是说我们要根据不同内容所具有的不同的的特点通过编写不同的正则表达式将这些内容从模板字符串中一一解析出来,然后再把不同的内容做不同的处理。
下面,我们就来分别看一下HTML解析器是如何从模板字符串中将以上不同种类的内容进行解析出来。
解析HTML注释
解析注释比较简单,我们知道HTML注释是以 结尾,这两者中间的内容就是注释内容,那么我们只需用正则判断待解析的模板字符串html是否以 ,如果找到了,OK,注释就被解析出来了。代码如下:
const comment = /^
if (comment.test(html)) {
// 若为注释,则继续查找是否存在'-->'
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
// 若存在 '-->',继续判断options中是否保留注释
if (options.shouldKeepComment) {
// 若保留注释,则把注释截取出来传给options.comment,创建注释类型的AST节点
options.comment(html.substring(4, commentEnd))
}
// 若不保留注释,则将游标移动到'-->'之后,继续向后解析
advance(commentEnd + 3)
continue
}
}
在上面代码中,如果模板字符串html符合注释开始的正则,那么就继续向后查找是否存在–>,若存在,则把html从第4位(" 处,截取得到的内容就是注释的真实内容,然后调用4个钩子函数中的comment函数,将真实的注释内容传进去,创建注释类型的AST节点。
上面代码中有一处值得注意的地方,那就是我们平常在模板中可以在标签上配置comments选项来决定在渲染模板时是否保留注释,对应到上面代码中就是options.shouldKeepComment,如果用户配置了comments选项为true,则shouldKeepComment为true,则创建注释类型的AST节点,如不保留注释,则将游标移动到’–>'之后,继续向后解析。
advance函数是用来移动解析游标的,解析完一部分就把游标向后移动一部分,确保不会重复解析,其代码如下:
function advance (n) {
index += n // index为解析游标
html = html.substring(n)
}
为了更加直观地说明 advance 的作用,请看下图:
调用 advance 函数:
advance(3)
从图中可以看到,解析游标index最开始在模板字符串的位置0处,当调用了advance(3)之后,解析游标到了位置3处,每次解析完一段内容就将游标向后移动一段,接着再从解析游标往后解析,这样就保证了解析过的内容不会被重复解析。
解析条件注释
解析条件注释也比较简单,其原理跟解析注释相同,都是先用正则判断是否是以条件注释特有的开头标识开始,然后寻找其特有的结束标识,若找到,则说明是条件注释,将其截取出来即可,由于条件注释不存在于真正的DOM树中,所以不需要调用钩子函数创建AST节点。代码如下:
/
/ 解析是否是条件注释
const conditionalComment = /^
if (conditionalComment.test(html)) {
// 若为条件注释,则继续查找是否存在']>'
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
// 若存在 ']>',则从原本的html字符串中把条件注释截掉,
// 把剩下的内容重新赋给html,继续向后匹配
advance(conditionalEnd + 2)
continue
}
}
解析DOCTYPE
解析DOCTYPE的原理同解析条件注释完全相同,此处不再赘述,代码如下:
const doctype = /^]+>/i
// 解析是否是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
advance(doctypeMatch[0].length)
continue
}
解析开始标签
相较于前三种内容的解析,解析开始标签会稍微复杂一点,但是万变不离其宗,它的原理还是相通的,都是使用正则去匹配提取。
首先使用开始标签的正则去匹配模板字符串,看模板字符串是否具有开始标签的特征,如下:
/**
* 匹配开始标签的正则
*/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${
ncname}\\:)?${
ncname})`
const startTagOpen = new RegExp(`^<${
qnameCapture}`)
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
}
// 以开始标签开始的模板:
''.match(startTagOpen) => [','div',index:0,input:'']
// 以结束标签开始的模板:
''.match(startTagOpen) => null
// 以文本开始的模板:
'我是文本'.match(startTagOpen) => null
在上面代码中,我们用不同类型的内容去匹配开始标签的正则,发现只有
的字符串可以正确匹配,并且返回一个数组。在前文中我们说到,当解析到开始标签时,会调用4个钩子函数中的start函数,而start函数需要传递3个参数,分别是标签名tag、标签属性attrs、标签是否自闭合unary。标签名通过正则匹配的结果就可以拿到,即上面代码中的start[1],而标签属性attrs以及标签是否自闭合unary需要进一步解析。
解析标签属性
我们知道,标签属性一般是写在开始标签的标签名之后的,如下:
<div class="a" id="b"></div>
另外,我们在上面匹配是否是开始标签的正则中已经可以拿到开始标签的标签名,即上面代码中的start[0],那么我们可以将这一部分先从模板字符串中截掉,则剩下的部分如下:
class=“a” id=“b”>
那么我们只需用剩下的这部分去匹配标签属性的正则,就可以将标签属性提取出来了,如下:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = 'class="a" id="b">
可以看到,第一个标签属性class="a"已经被拿到了。另外,标签属性有可能有多个也有可能没有,如果没有的话那好办,匹配标签属性的正则就会匹配失败,标签属性就为空数组;而如果标签属性有多个的话,那就需要循环匹配了,匹配出第一个标签属性后,就把该属性截掉,用剩下的字符串继续匹配,直到不再满足正则为止,代码如下:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const startTagClose = /^\s*(\/?)>/
const match = {
tagName: start[1],
attrs: [],
start: index
}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
在上面代码的while循环中,如果剩下的字符串不符合开始标签的结束特征(startTagClose)并且符合标签属性的特征的话,那就说明还有未提取出的标签属性,那就进入循环,继续提取,直到把所有标签属性都提取完毕。
所谓不符合开始标签的结束特征是指当前剩下的字符串不是以开始标签结束符开头的,我们知道一个开始标签的结束符有可能是一个>(非自闭合标签),也有可能是/>(自闭合标签),如果剩下的字符串(如>)以开始标签的结束符开头,那么就表示标签属性已经被提取完毕了。
解析标签是否是自闭合
在HTML中,有自闭合标签(如)也有非自闭合标签(如
),这两种类型的标签在创建AST节点是处理方式是有区别的,所以我们需要解析出当前标签是否是自闭合标签。解析的方式很简单,我们知道,经过标签属性提取之后,那么剩下的字符串无非就两种,如下:
<!--非自闭合标签-->
></div>
或
<!--自闭合标签-->
/>
所以我们可以用剩下的字符串去匹配开始标签结束符正则,如下:
const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
'>
经过以上两步,开始标签就已经解析完毕了,完整源码如下:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${
ncname}\\:)?${
ncname})`
const startTagOpen = new RegExp(`^<${
qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
function parseStartTag () {
const start = html.match(startTagOpen)
// ''.match(startTagOpen) => ['']
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
let end, attr
/**
*
* 从'之前,一直匹配属性attrs
* 所有属性匹配完之后,html字符串还剩下
* 自闭合标签剩下:'/>'
* 非自闭合标签剩下:'>'
*/
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
/**
* 这里判断了该标签是否为自闭合标签
* 自闭合标签如:
* 非自闭合标签如:
* '>
通过源码可以看到,调用parseStartTag函数,如果模板字符串符合开始标签的特征,则解析开始标签,并将解析结果返回,如果不符合开始标签的特征,则返回undefined。
解析完毕后,就可以用解析得到的结果去调用start钩子函数去创建元素型的AST节点了。
在源码中,Vue并没有直接去调start钩子函数去创建AST节点,而是调用了handleStartTag函数,在该函数内部才去调的start钩子函数,为什么要这样做呢?这是因为虽然经过parseStartTag函数已经把创建AST节点必要信息提取出来了,但是提取出来的标签属性数组还是需要处理一下,下面我们就来看一下handleStartTag函数都做了些什么事。handleStartTag函数源码如下:
function handleStartTag (match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
if (expectHTML) {
// ...
}
const unary = isUnaryTag(tagName) || !!unarySlash
const l = match.attrs.length
const attrs = new Array(l)
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
const value = args[3] || args[4] || args[5] || ''
const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
}
}
if (!unary) {
stack.push({
tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
lastTag = tagName
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
handleStartTag函数用来对parseStartTag函数的解析结果进行进一步处理,它接收parseStartTag函数的返回值作为参数。
handleStartTag函数的开始定义几个常量:
const tagName = match.tagName // 开始标签的标签名
const unarySlash = match.unarySlash // 是否为自闭合标签的标志,自闭合为"",非自闭合为"/"
const unary = isUnaryTag(tagName) || !!unarySlash // 布尔值,标志是否为自闭合标签
const l = match.attrs.length // match.attrs 数组的长度
const attrs = new Array(l) // 一个与match.attrs数组长度相等的数组
接下来是循环处理提取出来的标签属性数组match.attrs,如下:
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
const value = args[3] || args[4] || args[5] || ''
const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
}
}
上面代码中,首先定义了 args常量,它是解析出来的标签属性数组中的每一个属性对象,即match.attrs 数组中每个元素对象。 它长这样:
const args = ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b">
接着定义了value,用于存储标签属性的属性值,我们可以看到,在代码中尝试取args的args[3]、args[4]、args[5],如果都取不到,则给value复制为空
const value = args[3] || args[4] || args[5] || ''
接着定义了shouldDecodeNewlines,这个常量主要是做一些兼容性处理, 如果 shouldDecodeNewlines 为 true,意味着 Vue 在编译模板的时候,要对属性值中的换行符或制表符做兼容处理。而shouldDecodeNewlinesForHref为true 意味着Vue在编译模板的时候,要对a标签的 href属性值中的换行符或制表符做兼容处理。
const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlinesconst value = args[3] || args[4] || args[5] || ''
最后将处理好的结果存入之前定义好的与match.attrs数组长度相等的attrs数组中,如下:
attrs[i] = {
name: args[1], // 标签属性的属性名,如class
value: decodeAttr(value, shouldDecodeNewlines) // 标签属性的属性值,如class对应的a
}
最后,如果该标签是非自闭合标签,则将标签推入栈中(关于栈这个概念后面会说到),如下:
if (!unary) {
stack.push({
tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
lastTag = tagName
}
如果该标签是自闭合标签,现在就可以调用start钩子函数并传入处理好的参数来创建AST节点了,如下:
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
以上就是开始标签的解析以及调用start钩子函数创建元素型的AST节点的所有过程。
#3.5 解析结束标签
结束标签的解析要比解析开始标签容易多了,因为它不需要解析什么属性,只需要判断剩下的模板字符串是否符合结束标签的特征,如果是,就将结束标签名提取出来,再调用4个钩子函数中的end函数就好了。
首先判断剩余的模板字符串是否符合结束标签的特征,如下:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${
ncname}\\:)?${
ncname})`
const endTag = new RegExp(`^<\\/${
qnameCapture}[^>]*>`)
const endTagMatch = html.match(endTag)
'
上面代码中,如果模板字符串符合结束标签的特征,则会获得匹配结果数组;如果不合符,则得到null。
接着再调用end钩子函数,如下:
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
在上面代码中,没有直接去调用end函数,而是调用了parseEndTag函数,关于parseEndTag函数内部的作用我们后面会介绍到,在这里你暂时可以理解为该函数内部就是去调用了end钩子函数。
解析文本
终于到了解析最后一种文本类型的内容了,为什么要把解析文本类型放在最后一个介绍呢?我们仔细想一下,前面五种类型都是以<
开头的,只有文本类型的内容不是以<
开头的,所以我们在解析模板字符串的时候可以先判断一下字符串是不是以<
开头的,如果是则继续判断是以上五种类型的具体哪一种,而如果不是的话,那它肯定就是文本了。
解析文本也比较容易,在解析模板字符串之前,我们先查找一下第一个<出现在什么位置,如果第一个<在第一个位置,那么说明模板字符串是以其它5种类型开始的;如果第一个<不在第一个位置而在模板字符串中间某个位置,那么说明模板字符串是以文本开头的,那么从开头到第一个<出现的位置就都是文本内容了;如果在整个模板字符串里没有找到<,那说明整个模板字符串都是文本。这就是解析思路,接下来我们对照源码来了解一下实际的解析过程,源码如下:
let textEnd = html.indexOf('<')
// '<' 在第一个位置,为其余5种类型
if (textEnd === 0) {
// ...
}
// '<' 不在第一个位置,文本开头
if (textEnd >= 0) {
// 如果html字符串不是以'<'开头,说明'<'前面的都是纯文本,无需处理
// 那就把'<'以后的内容拿出来赋给rest
rest = html.slice(textEnd)
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
/**
* 用'<'以后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
* 如果都匹配不上,表示'<'是属于文本本身的内容
*/
// 在'<'之后查找是否还有'<'
next = rest.indexOf('<', 1)
// 如果没有了,表示'<'后面也是文本
if (next < 0) break
// 如果还有,表示'<'是文本中的一个字符
textEnd += next
// 那就把next之后的内容截出来继续下一轮循环匹配
rest = html.slice(textEnd)
}
// '<'是结束标签的开始 ,说明从开始到'<'都是文本,截取出来
text = html.substring(0, textEnd)
advance(textEnd)
}
// 整个模板字符串里没有找到`<`,说明整个模板字符串都是文本
if (textEnd < 0) {
text = html
html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
options.chars(text)
}
源码的逻辑很清晰,根据<在不在第一个位置以及整个模板字符串里没有<都分别进行了处理。
值得深究的是如果<不在第一个位置而在模板字符串中间某个位置,那么说明模板字符串是以文本开头的,那么从开头到第一个<出现的位置就都是文本内容了,接着我们还要从第一个<的位置继续向后判断,因为还存在这样一种情况,那就是如果文本里面本来就包含一个<,例如1<2。为了处理这种情况,我们把从第一个<的位置直到模板字符串结束都截取出来记作rest,如下:
let rest = html.slice(textEnd)
接着用rest去匹配以上5种类型的正则,如果都匹配不上,则表明这个<是属于文本本身的内容,如下:
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
}
如果都匹配不上,则表明这个<是属于文本本身的内容,接着以这个<的位置继续向后查找,看是否还有<,如果没有了,则表示后面的都是文本;如果后面还有下一个<,那表明至少在这个<到下一个<中间的内容都是文本,至于下一个<以后的内容是什么,则还需要重复以上的逻辑继续判断。代码如下:
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
/**
* 用'<'以后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
* 如果都匹配不上,表示'<'是属于文本本身的内容
*/
// 在'<'之后查找是否还有'<'
next = rest.indexOf('<', 1)
// 如果没有了,表示'<'后面也是文本
if (next < 0) break
// 如果还有,表示'<'是文本中的一个字符
textEnd += next
// 那就把next之后的内容截出来继续下一轮循环匹配
rest = html.slice(textEnd)
}
最后截取文本内容text并调用4个钩子函数中的chars函数创建文本型的AST节点。
#4. 如何保证AST节点层级关系
上一章节我们介绍了HTML解析器是如何解析各种不同类型的内容并且调用钩子函数创建不同类型的AST节点。此时你可能会有个疑问,我们上面创建的AST节点都是单独创建且分散的,而真正的DOM节点都是有层级关系的,那如何来保证AST节点的层级关系与真正的DOM节点相同呢?
关于这个问题,Vue也注意到了。Vue在HTML解析器的开头定义了一个栈stack,这个栈的作用就是用来维护AST节点层级的,那么它是怎么维护的呢?通过前文我们知道,HTML解析器在从前向后解析模板字符串时,每当遇到开始标签时就会调用start钩子函数,那么在start钩子函数内部我们可以将解析得到的开始标签推入栈中,而每当遇到结束标签时就会调用end钩子函数,那么我们也可以在end钩子函数内部将解析得到的结束标签所对应的开始标签从栈中弹出。请看如下例子:
加入有如下模板字符串:
<div><p><span></span></p></div>
当解析到开始标签
时,再把p推入栈中,同理,再把span推入栈中,当解析到结束标签时,此时栈顶的标签刚好是span的开始标签,那么就用span的开始标签和结束标签构建AST节点,并且从栈中把span的开始标签弹出,那么此时栈中的栈顶标签p就是构建好的span的AST节点的父节点,如下图:
这样我们就找到了当前被构建节点的父节点。这只是栈的一个用途,它还有另外一个用途,我们再看如下模板字符串:
<div><p><span></p></div>
按照上面的流程解析这个模板字符串时,当解析到结束标签
时,此时栈顶的标签应该是p才对,而现在是span,那么就说明span标签没有被正确闭合,此时控制台就会抛出警告:‘tag has no matching end tag.’相信这个警告你一定不会陌生。这就是栈的第二个用途: 检测模板字符串中是否有未正确闭合的标签。OK,有了这个栈的概念之后,我们再回看上一章HTML解析器解析不同内容的代码。
HTML解析器源码
以上内容都了解了之后,我们回归源码,逐句分析HTML解析器parseHTML函数,函数定义如下:
function parseHTML(html, options) {
var stack = [];
var expectHTML = options.expectHTML;
var isUnaryTag$$1 = options.isUnaryTag || no;
var canBeLeftOpenTag$$1 = options.canBeLeftOpenTag || no;
var index = 0;
var last, lastTag;
// 开启一个 while 循环,循环结束的条件是 html 为空,即 html 被 parse 完毕
while (html) {
last = html;
// 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
/**
* 如果html字符串是以'<'开头,则有以下几种可能
* 开始标签:
* 结束标签:
* 注释:
* 条件注释:
* DOCTYPE:
* 需要一一去匹配尝试
*/
if (textEnd === 0) {
// 解析是否是注释
if (comment.test(html)) {
}
// 解析是否是条件注释
if (conditionalComment.test(html)) {
}
// 解析是否是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
}
// 解析是否是结束标签
const endTagMatch = html.match(endTag)
if (endTagMatch) {
}
// 匹配是否是开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
}
}
// 如果html字符串不是以'<'开头,则解析文本类型
let text, rest, next
if (textEnd >= 0) {
}
// 如果在html字符串中没有找到'<',表示这一段html字符串都是纯文本
if (textEnd < 0) {
text = html
html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
options.chars(text)
}
} else {
// 父元素为script、style、textarea时,其内部的内容全部当做纯文本处理
}
//将整个字符串作为文本对待
if (html === last) {
options.chars && options.chars(html);
if (!stack.length && options.warn) {
options.warn(("Mal-formatted tag at end of template: \"" + html + "\""));
}
break
}
}
// Clean up any remaining tags
parseEndTag();
//parse 开始标签
function parseStartTag() {
}
//处理 parseStartTag 的结果
function handleStartTag(match) {
}
//parse 结束标签
function parseEndTag(tagName, start, end) {
}
}
上述代码中大致可分为三部分:
定义的一些常量和变量
while 循环
解析过程中用到的辅助函数
我们一一来分析:
首先定义了几个常量,如下
const stack = [] // 维护AST节点层级的栈
const expectHTML = options.expectHTML
const isUnaryTag = options.isUnaryTag || no
const canBeLeftOpenTag = options.canBeLeftOpenTag || no //用来检测一个标签是否是可以省略闭合标签的非自闭合标签
let index = 0 //解析游标,标识当前从何处开始解析模板字符串
let last, // 存储剩余还未解析的模板字符串
lastTag // 存储着位于 stack 栈顶的元素
接着开启while 循环,循环的终止条件是 模板字符串html为空,即模板字符串被全部编译完毕。在每次while循环中, 先把 html的值赋给变量 last,如下:
last = html
这样做的目的是,如果经过上述所有处理逻辑处理过后,html字符串没有任何变化,即表示html字符串没有匹配上任何一条规则,那么就把html字符串当作纯文本对待,创建文本类型的AST节点并且如果抛出异常:模板字符串中标签格式有误。如下:
//将整个字符串作为文本对待
if (html === last) {
options.chars && options.chars(html);
if (!stack.length && options.warn) {
options.warn(("Mal-formatted tag at end of template: \"" + html + "\""));
}
break
}
接着,我们继续看while循环体内的代码:
while (html) {
// 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
if (!lastTag || !isPlainTextElement(lastTag)) {
} else {
// parse 的内容是在纯文本标签里 (script,style,textarea)
}
}
在循环体内,首先判断了待解析的html字符串是否在纯文本标签里,如script,style,textarea,因为在这三个标签里的内容肯定不会有HTML标签,所以我们可直接当作文本处理,判断条件如下:
!lastTag || !isPlainTextElement(lastTag)
前面我们说了,lastTag为栈顶元素,!lastTag即表示当前html字符串没有父节点,而isPlainTextElement(lastTag) 是检测 lastTag 是否为是那三个纯文本标签之一,是的话返回true,不是返回fasle。
也就是说当前html字符串要么没有父节点要么父节点不是纯文本标签,则接下来就可以依次解析那6种类型的内容了,关于6种类型内容的处理方式前文已经逐个介绍过,此处不再重复。
parseEndTag函数源码
接下来我们看一下之前在解析结束标签时遗留的parseEndTag函数,该函数定义如下:
function parseEndTag (tagName, start, end) {
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index
if (tagName) {
lowerCasedTagName = tagName.toLowerCase()
}
// Find the closest opened tag of the same type
if (tagName) {
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
// If no tag name is provided, clean shop
pos = 0
}
if (pos >= 0) {
// Close all the open elements, up the stack
for (let i = stack.length - 1; i >= pos; i--) {
if (process.env.NODE_ENV !== 'production' &&
(i > pos || !tagName) &&
options.warn
) {
options.warn(
`tag <${
stack[i].tag}> has no matching end tag.`
)
}
if (options.end) {
options.end(stack[i].tag, start, end)
}
}
// Remove the open elements from the stack
stack.length = pos
lastTag = pos && stack[pos - 1].tag
} else if (lowerCasedTagName === 'br') {
if (options.start) {
options.start(tagName, [], true, start, end)
}
} else if (lowerCasedTagName === 'p') {
if (options.start) {
options.start(tagName, [], false, start, end)
}
if (options.end) {
options.end(tagName, start, end)
}
}
}
}
该函数接收三个参数,分别是结束标签名tagName、结束标签在html字符串中的起始和结束位置start和end。
这三个参数其实都是可选的,根据传参的不同其功能也不同。
第一种是三个参数都传递,用于处理普通的结束标签
第二种是只传递tagName
第三种是三个参数都不传递,用于处理栈中剩余未处理的标签
如果tagName存在,那么就从后往前遍历栈,在栈中寻找与tagName相同的标签并记录其所在的位置pos,如果tagName不存在,则将pos置为0。如下:
if (tagName) {
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
// If no tag name is provided, clean shop
pos = 0
}
接着当pos>=0时,开启一个for循环,从栈顶位置从后向前遍历直到pos处,如果发现stack栈中存在索引大于pos的元素,那么该元素一定是缺少闭合标签的。这是因为在正常情况下,stack栈的栈顶元素应该和当前的结束标签tagName 匹配,也就是说正常的pos应该是栈顶位置,后面不应该再有元素,如果后面还有元素,那么后面的元素就都缺少闭合标签 那么这个时候如果是在非生产环境会抛出警告,告诉你缺少闭合标签。除此之外,还会调用 options.end(stack[i].tag, start, end)立即将其闭合,这是为了保证解析结果的正确性。
if (pos >= 0) {
// Close all the open elements, up the stack
for (var i = stack.length - 1; i >= pos; i--) {
if (i > pos || !tagName ) {
options.warn(
("tag <" + (stack[i].tag) + "> has no matching end tag.")
);
}
if (options.end) {
options.end(stack[i].tag, start, end);
}
}
// Remove the open elements from the stack
stack.length = pos;
lastTag = pos && stack[pos - 1].tag;
}
最后把pos位置以后的元素都从stack栈中弹出,以及把lastTag更新为栈顶元素:
stack.length = pos;
lastTag = pos && stack[pos - 1].tag;
接着,如果pos没有大于等于0,即当 tagName 没有在 stack 栈中找到对应的开始标签时,pos 为 -1 。那么此时再判断 tagName 是否为br 或p标签,为什么要单独判断这两个标签呢?这是因为在浏览器中如果我们写了如下HTML:
<div>
</br>
</p>
</div>
浏览器会自动把br标签解析为正常的br标签,而对于p浏览器则自动将其补全为pp,所以Vue为了与浏览器对这两个标签的行为保持一致,故对这两个便签单独判断处理,如下:
if (lowerCasedTagName === 'br') {
if (options.start) {
options.start(tagName, [], true, start, end) // 创建
AST节点
}
}
// 补全p标签并创建AST节点
if (lowerCasedTagName === 'p') {
if (options.start) {
options.start(tagName, [], false, start, end)
}
if (options.end) {
options.end(tagName, start, end)
}
}
以上就是对结束标签的解析与处理。
另外,在while循环后面还有一行代码:
parseEndTag()
这行代码执行的时机是html === last,即html字符串中的标签格式有误时会跳出while循环,此时就会执行这行代码,这行代码是调用parseEndTag函数并不传递任何参数,前面我们说过如果parseEndTag函数不传递任何参数是用于处理栈中剩余未处理的标签。这是因为如果不传递任何函数,此时parseEndTag函数里的pos就为0,那么pos>=0就会恒成立,那么就会逐个警告缺少闭合标签,并调用 options.end将其闭合。
HTML解析器的工作流程,一句话概括就是:一边解析不同的内容一边调用对应的钩子函数生成对应的AST节点,最终完成将整个模板字符串转化成AST。
接着介绍了HTML解析器是如何解析用户所写的模板字符串中各种类型的内容的,把各种类型的解析方式都分别进行了介绍。
其次,介绍了在解析器内维护了一个栈,用来保证构建的AST节点层级与真正DOM层级一致。
当HTML解析器解析到文本内容时会调用4个钩子函数中的chars函数来创建文本型的AST节点,并且也说了在chars函数中会根据文本内容是否包含变量再细分为创建含有变量的AST节点和不包含变量的AST节点,如下:
// 当解析到标签的文本时,触发chars
chars (text) {
if(res = parseText(text)){
let element = {
type: 2,
expression: res.expression,
tokens: res.tokens,
text
}
} else {
let element = {
type: 3,
text
}
}
}
从上面代码中可以看到,创建含有变量的AST节点时节点的type属性为2,并且相较于不包含变量的AST节点多了两个属性:expression和tokens。那么如何来判断文本里面是否包含变量以及多的那两个属性是什么呢?这就涉及到文本解析器了,当Vue用HTML解析器解析出文本时,再将解析出来的文本内容传给文本解析器,最后由文本解析器解析该段文本里面是否包含变量以及如果包含变量时再解析expression和tokens。那么接下来,本篇文章就来分析一下文本解析器都干了些什么。
结果分析
研究文本解析器内部原理之前,我们先来看一下由HTML解析器解析得到的文本内容经过文本解析器后输出的结果是什么样子的,这样对我们后面分析文本解析器内部原理会有很大的帮助。
从上面chars函数的代码中可以看到,把HTML解析器解析得到的文本内容text传给文本解析器parseText函数,根据parseText函数是否有返回值判断该文本是否包含变量,以及从返回值中取到需要的expression和tokens。那么我们就先来看一下parseText函数如果有返回值,那么它的返回值是什么样子的。
假设现有由HTML解析器解析得到的文本内容如下:
let text = "我叫{
{name}},我今年{
{age}}岁了"
经过文本解析器解析后得到:
let res = parseText(text)
res = {
expression:"我叫"+_s(name)+",我今年"+_s(age)+"岁了",
tokens:[
"我叫",
{
'@binding': name },
",我今年"
{
'@binding': age },
"岁了"
]
}
从上面的结果中我们可以看到,expression属性就是把文本中的变量和非变量提取出来,然后把变量用_s()包裹,最后按照文本里的顺序把它们用+连接起来。而tokens是个数组,数组内容也是文本中的变量和非变量,不一样的是把变量构造成{’@binding’: xxx}。
那么这样做有什么用呢?这主要是为了给后面代码生成阶段的生成render函数时用的,这个我们在后面介绍代码生成阶段是会详细说明,此处暂可理解为单纯的在构造形式。
OK,现在我们就可以知道文本解析器内部就干了三件事:
判断传入的文本是否包含变量
构造expression
构造tokens
那么接下来我们就通过阅读源码,逐行分析文本解析器内部工作原理。
文本解析器的源码位于 src/compiler/parser/text-parsre.js 中,代码如下:
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
const buildRegex = cached(delimiters => {
const open = delimiters[0].replace(regexEscapeRE, '\\$&')
const close = delimiters[1].replace(regexEscapeRE, '\\$&')
return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
})
export function parseText (text,delimiters) {
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
if (!tagRE.test(text)) {
return
}
const tokens = []
const rawTokens = []
/**
* let lastIndex = tagRE.lastIndex = 0
* 上面这行代码等同于下面这两行代码:
* tagRE.lastIndex = 0
* let lastIndex = tagRE.lastIndex
*/
let lastIndex = tagRE.lastIndex = 0
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
index = match.index
// push text token
if (index > lastIndex) {
// 先把'{
{'前面的文本放入tokens中
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
// 取出'{
{ }}'中间的变量exp
const exp = parseFilters(match[1].trim())
// 把变量exp改成_s(exp)形式也放入tokens中
tokens.push(`_s(${
exp})`)
rawTokens.push({
'@binding': exp })
// 设置lastIndex 以保证下一轮循环时,只从'}}'后面再开始匹配正则
lastIndex = index + match[0].length
}
// 当剩下的text不再被正则匹配上时,表示所有变量已经处理完毕
// 此时如果lastIndex < text.length,表示在最后一个变量后面还有文本
// 最后将后面的文本再加入到tokens中
if (lastIndex < text.length) {
rawTokens.push(tokenValue = text.slice(lastIndex))
tokens.push(JSON.stringify(tokenValue))
}
// 最后把数组tokens中的所有元素用'+'拼接起来
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
我们看到,除开我们自己加的注释,代码其实不复杂,我们逐行分析。
parseText函数接收两个参数,一个是传入的待解析的文本内容text,一个包裹变量的符号delimiters。第一个参数好理解,那第二个参数是干什么的呢?别急,我们看函数体内第一行代码:
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
函数体内首先定义了变量tagRE,表示一个正则表达式。这个正则表达式是用来检查文本中是否包含变量的。我们知道,通常我们在模板中写变量时是这样写的:hello 。这里用{ {}}包裹的内容就是变量。所以我们就知道,tagRE是用来检测文本内是否有{ {}}。而tagRE又是可变的,它是根据是否传入了delimiters参数从而又不同的值,也就是说如果没有传入delimiters参数,则是检测文本是否包含{ {}},如果传入了值,就会检测文本是否包含传入的值。换句话说在开发Vue项目中,用户可以自定义文本内包含变量所使用的符号,例如你可以使用%包裹变量如:hello %name%。
接下来用tagRE去匹配传入的文本内容,判断是否包含变量,若不包含,则直接返回,如下:
if (!tagRE.test(text)) {
return
}
如果包含变量,那就继续往下看:
const tokens = []
const rawTokens = []
let lastIndex = tagRE.lastIndex = 0
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
}
接下来会开启一个while循环,循环结束条件是tagRE.exec(text)的结果match是否为null,exec( )方法是在一个字符串中执行匹配检索,如果它没有找到任何匹配就返回null,但如果它找到了一个匹配就返回一个数组。例如:
tagRE.exec("hello {
{name}},I am {
{age}}")
//返回:["{
{name}}", "name", index: 6, input: "hello {
{name}},I am {
{age}}", groups: undefined]
tagRE.exec("hello")
//返回:null
可以看到,当匹配上时,匹配结果的第一个元素是字符串中第一个完整的带有包裹的变量,第二个元素是第一个被包裹的变量名,第三个元素是第一个变量在字符串中的起始位置。
接着往下看循环体内:
while ((match = tagRE.exec(text))) {
index = match.index
if (index > lastIndex) {
// 先把'{
{'前面的文本放入tokens中
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
// 取出'{
{ }}'中间的变量exp
const exp = match[1].trim()
// 把变量exp改成_s(exp)形式也放入tokens中
tokens.push(`_s(${
exp})`)
rawTokens.push({
'@binding': exp })
// 设置lastIndex 以保证下一轮循环时,只从'}}'后面再开始匹配正则
lastIndex = index + match[0].length
}
上面代码中,首先取得字符串中第一个变量在字符串中的起始位置赋给index,然后比较index和lastIndex的大小,此时你可能有疑问了,这个lastIndex是什么呢?在上面定义变量中,定义了let lastIndex = tagRE.lastIndex = 0,所以lastIndex就是tagRE.lastIndex,而tagRE.lastIndex又是什么呢?当调用exec( )的正则表达式对象具有修饰符g时,它将把当前正则表达式对象的lastIndex属性设置为紧挨着匹配子串的字符位置,当同一个正则表达式第二次调用exec( ),它会将从lastIndex属性所指示的字符串处开始检索,如果exec( )没有发现任何匹配结果,它会将lastIndex重置为0。示例如下:
const tagRE = /\{\{((?:.|\n)+?)\}\}/g
tagRE.exec("hello {
{name}},I am {
{age}}")
tagRE.lastIndex // 14
从示例中可以看到,tagRE.lastIndex就是第一个包裹变量最后一个}所在字符串中的位置。lastIndex初始值为0。
那么接下里就好理解了,当index>lastIndex时,表示变量前面有纯文本,那么就把这段纯文本截取出来,存入rawTokens中,同时再调用JSON.stringify给这段文本包裹上双引号,存入tokens中,如下:
if (index > lastIndex) {
// 先把'{
{'前面的文本放入tokens中
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
如果index不大于lastIndex,那说明index也为0,即该文本一开始就是变量,例如:hello。那么此时变量前面没有纯文本,那就不用截取,直接取出匹配结果的第一个元素变量名,将其用_s()包裹存入tokens中,同时再把变量名构造成{’@binding’: exp}存入rawTokens中,如下:
// 取出'{
{ }}'中间的变量exp
const exp = match[1].trim()
// 把变量exp改成_s(exp)形式也放入tokens中
tokens.push(`_s(${
exp})`)
rawTokens.push({
'@binding': exp })
接着,更新lastIndex以保证下一轮循环时,只从}}后面再开始匹配正则,如下:
lastIndex = index + match[0].length
接着,当while循环完毕时,表明文本中所有变量已经被解析完毕,如果此时lastIndex < text.length,那就说明最后一个变量的后面还有纯文本,那就将其再存入tokens和rawTokens中,如下:
// 当剩下的text不再被正则匹配上时,表示所有变量已经处理完毕
// 此时如果lastIndex < text.length,表示在最后一个变量后面还有文本
// 最后将后面的文本再加入到tokens中
if (lastIndex < text.length) {
rawTokens.push(tokenValue = text.slice(lastIndex))
tokens.push(JSON.stringify(tokenValue))
}
最后,把tokens数组里的元素用+连接,和rawTokens一并返回,如下:
return {
expression: tokens.join('+'),
tokens: rawTokens
}
以上就是文本解析器parseText函数的所有逻辑了。
总结
文本解析器的作用就是将HTML解析器解析得到的文本内容进行二次解析,解析文本内容中是否包含变量,如果包含变量,则将变量提取出来进行加工,为后续生产render函数做准备。
在这一阶段主要做的工作是用解析器将用户所写的模板字符串解析成AST抽象语法树,理论上来讲,有了AST就可直接进入第三阶段生成render函数了。其实不然,Vue还是很看重性能的,只要有一点可以优化的地方就要将其进行优化。在之前介绍虚拟DOM的时候我们说过,有一种节点一旦首次渲染上了之后不管状态再怎么变化它都不会变了,这种节点叫做静态节点,如下:
<ul>
<li>我是文本信息</li>
<li>我是文本信息</li>
<li>我是文本信息</li>
<li>我是文本信息</li>
<li>我是文本信息</li>
</ul>
在上面代码中,ul标签下面有5个li标签,每个li标签里的内容都是不含任何变量的纯文本,也就是说这种标签一旦第一次被渲染成DOM节点以后,之后不管状态再怎么变化它都不会变了,我们把像li的这种节点称之为静态节点。而这5个li节点的父节点是ul节点,也就是说ul节点的所有子节点都是静态节点,那么我们把像ul的这种节点称之为静态根节点。
OK,有了静态节点和静态根节点这两个概念之后,我们再仔细思考,模板编译的最终目的是用模板生成一个render函数,而用render函数就可以生成与模板对应的VNode,之后再进行patch算法,最后完成视图渲染。这中间的patch算法又是用来对比新旧VNode之间存在的差异。在上面我们还说了,静态节点不管状态怎么变化它是不会变的,基于此,那我们就可以在patch过程中不用去对比这些静态节点了,这样不就又可以提高一些性能了吗?
所以我们在模板编译的时候就先找出模板中所有的静态节点和静态根节点,然后给它们打上标记,用于告诉后面patch过程打了标记的这些节点是不需要对比的,你只要把它们克隆一份去用就好啦。这就是优化阶段存在的意义。
上面也说了,优化阶段其实就干了两件事:
在AST中找出所有静态节点并打上标记;
在AST中找出所有静态根节点并打上标记;
优化阶段的源码位于src/compiler/optimizer.js中,如下:
export function optimize (root: ?ASTElement, options: CompilerOptions) {
if (!root) return
isStaticKey = genStaticKeysCached(options.staticKeys || '')
isPlatformReservedTag = options.isReservedTag || no
// 标记静态节点
markStatic(root)
// 标记静态根节点
markStaticRoots(root, false)
}
接下来,我们就对所干的这两件事逐个分析。
#2. 标记静态节点
从AST中找出所有静态节点并标记其实不难,我们只需从根节点开始,先标记根节点是否为静态节点,然后看根节点如果是元素节点,那么就去向下递归它的子节点,子节点如果还有子节点那就继续向下递归,直到标记完所有节点。代码如下:
function markStatic (node: ASTNode) {
node.static = isStatic(node)
if (node.type === 1) {
// do not make component slot content static. this avoids
// 1. components not able to mutate slot nodes
// 2. static slot content fails for hot-reloading
if (
!isPlatformReservedTag(node.tag) &&
node.tag !== 'slot' &&
node.attrsMap['inline-template'] == null
) {
return
}
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i]
markStatic(child)
if (!child.static) {
node.static = false
}
}
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
const block = node.ifConditions[i].block
markStatic(block)
if (!block.static) {
node.static = false
}
}
}
}
}
在上面代码中,首先调用isStatic函数标记节点是否为静态节点,该函数若返回true表示该节点是静态节点,若返回false表示该节点不是静态节点,函数实现如下:
function isStatic (node: ASTNode): boolean {
if (node.type === 2) {
// 包含变量的动态文本节点
return false
}
if (node.type === 3) {
// 不包含变量的纯文本节点
return true
}
return !!(node.pre || (
!node.hasBindings && // no dynamic bindings
!node.if && !node.for && // not v-if or v-for or v-else
!isBuiltInTag(node.tag) && // not a built-in
isPlatformReservedTag(node.tag) && // not a component
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey)
))
}
该函数的实现过程其实也说明了如何判断一个节点是否为静态节点。还记得在HTML解析器在调用钩子函数创建AST节点时会根据节点类型的不同为节点加上不同的type属性,用type属性来标记AST节点的节点类型,其对应关系如下:
type取值 对应的AST节点类型
1 元素节点
2 包含变量的动态文本节点
3 不包含变量的纯文本节点
所以在判断一个节点是否为静态节点时首先会根据type值判断节点类型,如果type值为2,那么该节点是包含变量的动态文本节点,它就肯定不是静态节点,返回false;
if (node.type === 2) {
// 包含变量的动态文本节点
return false
}
如果type值为2,那么该节点是不包含变量的纯文本节点,它就肯定是静态节点,返回true;
if (node.type === 3) {
// 不包含变量的纯文本节点
return true
}
如果type值为1,说明该节点是元素节点,那就需要进一步判断。
node.pre ||
(
!node.hasBindings && // no dynamic bindings
!node.if && !node.for && // not v-if or v-for or v-else
!isBuiltInTag(node.tag) && // not a built-in
isPlatformReservedTag(node.tag) && // not a component
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey)
)
如果元素节点是静态节点,那就必须满足以下几点要求:
如果节点使用了v-pre指令,那就断定它是静态节点;
如果节点没有使用v-pre指令,那它要成为静态节点必须满足:
不能使用动态绑定语法,即标签上不能有v-、@、:开头的属性;
不能使用v-if、v-else、v-for指令;
不能是内置组件,即标签名不能是slot和component;
标签名必须是平台保留标签,即不能是组件;
当前节点的父节点不能是带有 v-for 的 template 标签;
节点的所有属性的 key 都必须是静态节点才有的 key,注:静态节点的key是有限的,它只能是type,tag,attrsList,attrsMap,plain,parent,children,attrs之一;
标记完当前节点是否为静态节点之后,如果该节点是元素节点,那么还要继续去递归判断它的子节点,如下:
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i]
markStatic(child)
if (!child.static) {
node.static = false
}
}
注意,在上面代码中,新增了一个判断:
if (!child.static) {
node.static = false
}
这个判断的意思是如果当前节点的子节点有一个不是静态节点,那就把当前节点也标记为非静态节点。为什么要这么做呢?这是因为我们在判断的时候是从上往下判断的,也就是说先判断当前节点,再判断当前节点的子节点,如果当前节点在一开始被标记为了静态节点,但是通过判断子节点的时候发现有一个子节点却不是静态节点,这就有问题了,我们之前说过一旦标记为静态节点,就说明这个节点首次渲染之后不会再发生任何变化,但是它的一个子节点却又是可以变化的,就出现了自相矛盾,所以我们需要当发现它的子节点中有一个不是静态节点的时候,就得把当前节点重新设置为非静态节点。
循环node.children后还不算把所有子节点都遍历完,因为如果当前节点的子节点中有标签带有v-if、v-else-if、v-else等指令时,这些子节点在每次渲染时都只渲染一个,所以其余没有被渲染的肯定不在node.children中,而是存在于node.ifConditions,所以我们还要把node.ifConditions循环一遍,如下:
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
const block = node.ifConditions[i].block
markStatic(block)
if (!block.static) {
node.static = false
}
}
}
同理,如果当前节点的node.ifConditions中有一个子节点不是静态节点也要将当前节点设置为非静态节点。
以上就是标记静态节点的全部逻辑。
#3. 标记静态根节点
寻找静态根节点根寻找静态节点的逻辑类似,都是从AST根节点递归向下遍历寻找,其代码如下:
function markStaticRoots (node: ASTNode, isInFor: boolean) {
if (node.type === 1) {
if (node.static || node.once) {
node.staticInFor = isInFor
}
// For a node to qualify as a static root, it should have children that
// are not just static text. Otherwise the cost of hoisting out will
// outweigh the benefits and it's better off to just always render it fresh.
if (node.static && node.children.length && !(
node.children.length === 1 &&
node.children[0].type === 3
)) {
node.staticRoot = true
return
} else {
node.staticRoot = false
}
if (node.children) {
for (let i = 0, l = node.children.length; i < l; i++) {
markStaticRoots(node.children[i], isInFor || !!node.for)
}
}
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
markStaticRoots(node.ifConditions[i].block, isInFor)
}
}
}
}
上面代码中,首先markStaticRoots 第二个参数是 isInFor,对于已经是 static 的节点或者是 v-once 指令的节点,node.staticInFor = isInFor,如下:
if (node.static || node.once) {
node.staticInFor = isInFor
}
接着判断该节点是否为静态根节点,如下:
// For a node to qualify as a static root, it should have children that
// are not just static text. Otherwise the cost of hoisting out will
// outweigh the benefits and it's better off to just always render it fresh.
// 为了使节点有资格作为静态根节点,它应具有不只是静态文本的子节点。 否则,优化的成本将超过收益,最好始终将其更新。
if (node.static && node.children.length && !(
node.children.length === 1 &&
node.children[0].type === 3
)) {
node.staticRoot = true
return
} else {
node.staticRoot = false
}
从代码和注释中我们可以看到,一个节点要想成为静态根节点,它必须满足以下要求:
节点本身必须是静态节点;
必须拥有子节点 children;
子节点不能只是只有一个文本节点;
否则的话,对它的优化成本将大于优化后带来的收益。
如果当前节点不是静态根节点,那就继续递归遍历它的子节点node.children和node.ifConditions,如下:
if (node.children) {
for (let i = 0, l = node.children.length; i < l; i++) {
markStaticRoots(node.children[i], isInFor || !!node.for)
}
}
if (node.ifConditions) {
for (let i = 1, l = node.ifConditions.length; i < l; i++) {
markStaticRoots(node.ifConditions[i].block, isInFor)
}
}
这里的原理跟寻找静态节点相同,此处就不再重复。
为什么要有优化阶段,是为了提高虚拟DOM中patch过程的性能。在优化阶段将所有静态节点都打上标记,这样在patch过程中就可以跳过对比这些节点。
接着,介绍了优化阶段主要干了两件事情,分别是从构建出的AST中找出并标记所有静态节点和所有静态根节点。
最后,分别通过逐行分析源码的方式分析了这两件事具体的内部工作原理。
我们把用户所写的模板字符串先经过解析阶段解析生成对应的抽象语法树AST,接着再经过优化阶段将AST中的静态节点及静态根节点都打上标记,现在终于到了模板编译三大阶段的最后一个阶段了——代码生成阶段。所谓代码生成阶段,到底是要生成什么代码?答:要生成render函数字符串。
我们知道,Vue实例在挂载的时候会调用其自身的render函数来生成实例上的template选项所对应的VNode,简单的来说就是Vue只要调用了render函数,就可以把模板转换成对应的虚拟DOM。那么Vue要想调用render函数,那必须要先有这个render函数,那这个render函数又是从哪来的呢?是用户手写的还是Vue自己生成的?答案是都有可能。我们知道,我们在日常开发中是可以在Vue组件选项中手写一个render选项,其值对应一个函数,那这个函数就是render函数,当用户手写了render函数时,那么Vue在挂载该组件的时候就会调用用户手写的这个render函数。那如果用户没有写呢?那这个时候Vue就要自己根据模板内容生成一个render函数供组件挂载的时候调用。而Vue自己根据模板内容生成render函数的过程就是本篇文章所要介绍的代码生成阶段。
现在我们知道了,所谓代码生成其实就是根据模板对应的抽象语法树AST生成一个函数,通过调用这个函数就可以得到模板对应的虚拟DOM。
如何根据AST生成render函数
通过上文我们知道了,代码生成阶段主要的工作就是根据已有的AST生成对应的render函数供组件挂载时调用,组件只要调用的这个render函数就可以得到AST对应的虚拟DOM的VNode。那么如何根据AST生成render函数呢?这其中是怎样一个过程呢?接下来我们就来细细剖析一下。
假设现有如下模板:
<div id="NLRX"><p>Hello {
{
name}}</p></div>
该模板经过解析并优化后对应的AST如下:
ast = {
'type': 1,
'tag': 'div',
'attrsList': [
{
'name':'id',
'value':'NLRX',
}
],
'attrsMap': {
'id': 'NLRX',
},
'static':false,
'parent': undefined,
'plain': false,
'children': [{
'type': 1,
'tag': 'p',
'plain': false,
'static':false,
'children': [
{
'type': 2,
'expression': '"Hello "+_s(name)',
'text': 'Hello {
{name}}',
'static':false,
}
]
}]
}
下面我们就来根据已有的这个AST来生成对应的render函数。生成render函数的过程其实就是一个递归的过程,从顶向下依次递归AST中的每一个节点,根据不同的AST节点类型创建不同的VNode类型。接下来我们就来对照已有的模板和AST实际演示一下生成render函数的过程。
首先,根节点div是一个元素型AST节点,那么我们就要创建一个元素型VNode,我们把创建元素型VNode的方法叫做_c(tagName,data,children)。我们暂且不管_c()是什么,只需知道调用_c()就可以创建一个元素型VNode。那么就可以生成如下代码:
_c(‘div’,{attrs:{“id”:“NLRX”}},[/子节点列表/])
根节点div有子节点,那么我们进入子节点列表children里遍历子节点,发现子节点p也是元素型的,那就继续创建元素型VNode并将其放入上述代码中根节点的子节点列表中,如下:
_c(‘div’,{attrs:{“id”:“NLRX”}},[_c(‘p’),[/子节点列表/]])
同理,继续遍历p节点的子节点,发现是一个文本型节点,那就创建一个文本型VNode并将其插入到p节点的子节点列表中,同理,创建文本型VNode我们调用_v()方法,如下:
_c(‘div’,{attrs:{“id”:“NLRX”}},[_c(‘p’),[_v("Hello "+_s(name))]])
到此,整个AST就遍历完毕了,我们将得到的代码再包装一下,如下:
`
with(this){
reurn _c(
'div',
{
attrs:{"id":"NLRX"},
}
[
_c('p'),
[
_v("Hello "+_s(name))
]
])
}
`
最后,我们将上面得到的这个函数字符串传递给createFunction函数(关于这个函数在后面会介绍到),createFunction函数会帮我们把得到的函数字符串转换成真正的函数,赋给组件中的render选项,从而就是render函数了。如下:
res.render = createFunction(compiled.render, fnGenErrors)
function createFunction (code, errors) {
try {
return new Function(code)
} catch (err) {
errors.push({
err, code })
return noop
}
}
以上就是根据一个简单的模板所对应的AST生成render函数的过程,理论过程我们已经了解了,那么在源码中实际是如何实现的呢?下面我们就回归源码分析其具体实现过程。
回归源码
代码生成阶段的源码位于src/compiler/codegen/index.js 中,源码虽然很长,但是逻辑不复杂,核心逻辑如下:
export function generate (ast,option) {
const state = new CodegenState(options)
const code = ast ? genElement(ast, state) : ‘_c(“div”)’
return {
render: with(this){return ${code}}
,
staticRenderFns: state.staticRenderFns
}
}
const code = generate(ast, options)
调用generate函数并传入优化后得到的ast,在generate函数内部先判断ast是否为空,不为空则调用genElement(ast, state)函数创建VNode,为空则创建一个空的元素型div的VNode。然后将得到的结果用with(this){return ${code}}包裹返回。可以看出,真正起作用的是genElement函数,下面我们继续来看一下genElement函数内部是怎样的。
genElement函数定义如下:
export function genElement (el: ASTElement, state: CodegenState): string {
if (el.staticRoot && !el.staticProcessed) {
return genStatic(el, state)
} else if (el.once && !el.onceProcessed) {
return genOnce(el, state)
} else if (el.for && !el.forProcessed) {
return genFor(el, state)
} else if (el.if && !el.ifProcessed) {
return genIf(el, state)
} else if (el.tag === 'template' && !el.slotTarget) {
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') {
return genSlot(el, state)
} else {
// component or element
let code
if (el.component) {
code = genComponent(el.component, el, state)
} else {
const data = el.plain ? undefined : genData(el, state)
const children = el.inlineTemplate ? null : genChildren(el, state, true)
code = `_c('${
el.tag}'${
data ? `,${
data}` : '' // data
}${
children ? `,${
children}` : '' // children
})`
}
// module transforms
for (let i = 0; i < state.transforms.length; i++) {
code = state.transforms[i](el, code)
}
return code
}
}
genElement函数逻辑很清晰,就是根据当前 AST 元素节点属性的不同从而执行不同的代码生成函数。虽然元素节点属性的情况有很多种,但是最后真正创建出来的VNode无非就三种,分别是元素节点,文本节点,注释节点。接下来我们就着重分析一下如何生成这三种节点类型的render函数的。
元素节点
生成元素型节点的render函数代码如下:
const data = el.plain ? undefined : genData(el, state)
const children = el.inlineTemplate ? null : genChildren(el, state, true)
code = `_c('${
el.tag}'${
data ? `,${
data}` : '' // data
}${
children ? `,${
children}` : '' // children
})`
生成元素节点的render函数就是生成一个_c()函数调用的字符串,上文提到了_c()函数接收三个参数,分别是节点的标签名tagName,节点属性data,节点的子节点列表children。那么我们只需将这三部分都填进去即可。
获取节点属性data
首先判断plain属性是否为true,若为true则表示节点没有属性,将data赋值为undefined;如果不为true则调用genData函数获取节点属性data数据。genData函数定义如下:
export function genData (el: ASTElement, state: CodegenState): string {
let data = '{'
const dirs = genDirectives(el, state)
if (dirs) data += dirs + ','
// key
if (el.key) {
data += `key:${
el.key},`
}
// ref
if (el.ref) {
data += `ref:${
el.ref},`
}
if (el.refInFor) {
data += `refInFor:true,`
}
// pre
if (el.pre) {
data += `pre:true,`
}
// 篇幅所限,省略其他情况的判断
data = data.replace(/,$/, '') + '}'
return data
}
我们看到,源码中genData虽然很长,但是其逻辑非常简单,就是在拼接字符串,先给data赋值为一个{,然后判断存在哪些属性数据,就将这些数据拼接到data中,最后再加一个},最终得到节点全部属性data。
获取子节点列表children
获取子节点列表children其实就是遍历AST的children属性中的元素,然后根据元素属性的不同生成不同的VNode创建函数调用字符串,如下:
export function genChildren (el): {
if (children.length) {
return `[${
children.map(c => genNode(c, state)).join(',')}]`
}
}
function genNode (node: ASTNode, state: CodegenState): string {
if (node.type === 1) {
return genElement(node, state)
} if (node.type === 3 && node.isComment) {
return genComment(node)
} else {
return genText(node)
}
}
上面两步完成之后,生成_c()函数调用字符串,如下:
code = `_c('${
el.tag}'${
data ? `,${
data}` : '' // data
}${
children ? `,${
children}` : '' // children
})`
文本节点
文本型的VNode可以调用_v(text)函数来创建,所以生成文本节点的render函数就是生成一个_v(text)函数调用的字符串。_v()函数接收文本内容作为参数,如果文本是动态文本,则使用动态文本AST节点的expression属性,如果是纯静态文本,则使用text属性。其生成代码如下:
export function genText (text: ASTText | ASTExpression): string {
return `_v(${
text.type === 2
? text.expression // no need for () because already wrapped in _s()
: transformSpecialNewlines(JSON.stringify(text.text))
})`
}
注释节点
注释型的VNode可以调用_e(text)函数来创建,所以生成注释节点的render函数就是生成一个_e(text)函数调用的字符串。_e()函数接收注释内容作为参数,其生成代码如下:
export function genComment (comment: ASTText): string {
return `_e(${
JSON.stringify(comment.text)})`
}
为什么要有代码生成阶段以及代码生成阶段主要干什么。我们知道了,代码生成其实就是根据模板对应的抽象语法树AST生成一个函数供组件挂载时调用,通过调用这个函数就可以得到模板对应的虚拟DOM。
接着,我们通过一个简单的模板演示了把模板经过递归遍历最后生成render函数的过程。
首先,我们需要搞清楚模板编译的最终目的是什么,它的最终目的就是:把用户所写的模板转化成供Vue实例在挂载时可调用的render函数。或者你可以这样简单的理解为:模板编译就是一台机器,给它输入模板字符串,它就输出对应的render函数。
我们把模板编译的最终目的只要牢记在心以后,那么模板编译中间的所有的变化都是在为达到这个目的而努力。
接下来我们就以宏观角度来梳理一下模板编译的整个流程。
整体流程
上文说了,模板编译就是把模板转化成供Vue实例在挂载时可调用的render函数。那么我们就从Vue实例挂载时入手,一步一步从后往前推。我们知道,Vue实例在挂载时会调用全局实例方法—— m o u n t 方 法 ( 关 于 该 方 法 后 面 会 详 细 介 绍 ) 。 那 么 我 们 就 先 看 一 下 mount方法(关于该方法后面会详细介绍)。那么我们就先看一下 mount方法(关于该方法后面会详细介绍)。那么我们就先看一下mount方法,如下:
Vue.prototype.$mount = function (el){
const options = this.$options
// 如果用户没有手写render函数
if (!options.render) {
// 获取模板,先尝试获取内部模板,如果获取不到则获取外部模板
let template = options.template
if (template) {
} else {
template = getOuterHTML(el)
}
const {
render, staticRenderFns } = compileToFunctions(template, {
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
}
}
从上述代码中可以看到,首先从Vue实例的属性选项中获取render选项,如果没有获取到,说明用户没有手写render函数,那么此时,就像上一篇文章中说的,需要Vue自己将模板转化成render函数。接着获取模板,先尝试获取内部模板,如果获取不到则获取外部模板。最后,调用compileToFunctions函数将模板转化成render函数,再将render函数赋值给options.render。
显然,上面代码中的核心部分是调用compileToFunctions函数生成render函数的部分,如下:
const {
render, staticRenderFns } = compileToFunctions(template, {
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
将模板template传给compileToFunctions函数就可以得到render函数,那这个compileToFunctions函数是怎么来的呢?
我们通过代码跳转发现compileToFunctions函数的出处如下:
const { compile, compileToFunctions } = createCompiler(baseOptions)
我们发现,compileToFunctions函数是 createCompiler 函数的返回值对象中的其中一个,createCompiler 函数顾名思义他的作用就是创建一个编译器。那么我们再继续往前推,看看createCompiler 函数又是从哪来的。
createCompiler 函数出处位于源码的src/complier/index.js文件中,如下:
export const createCompiler = createCompilerCreator(function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
// 模板解析阶段:用正则等方式解析 template 模板中的指令、class、style等数据,形成AST
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
// 优化阶段:遍历AST,找出其中的静态节点,并打上标记;
optimize(ast, options)
}
// 代码生成阶段:将AST转换成渲染函数;
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
})
可以看到,createCompiler函数是又 调用createCompilerCreator 函数返回得到的,createCompilerCreator 函数接收一个baseCompile函数作为参数。我们仔细看这个baseCompile函数,这个函数就是我们所说的模板编译三大阶段的主函数。将这个函数传给createCompilerCreator 函数就可以得到createCompiler函数,那么我们再往前推,看一下createCompilerCreator 函数又是怎么定义的。
createCompilerCreator 函数的定义位于源码的src/complier/create-compiler.js文件中,如下:
export function createCompilerCreator (baseCompile) {
return function createCompiler (baseOptions) {
}
}
可以看到,调用createCompilerCreator 函数会返回createCompiler函数,同时我们也可以看到createCompiler函数的定义,如下:
function createCompiler (baseOptions) {
function compile (){
}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
在createCompiler函数的内部定义了一个子函数compile,同时返回一个对象,其中这个对象的第二个属性就是我们在开头看到的compileToFunctions,其值对应的是createCompileToFunctionFn(compile)函数的返回值,那么我们再往前推,看看createCompileToFunctionFn(compile)函数又是怎么样的。
createCompileToFunctionFn(compile)函数的出处位于源码的src/complier/to-function.js文件中,如下:
export function createCompileToFunctionFn (compile) {
return function compileToFunctions (){
// compile
const res = {
}
const compiled = compile(template, options)
res.render = createFunction(compiled.render, fnGenErrors)
res.staticRenderFns = compiled.staticRenderFns.map(code => {
return createFunction(code, fnGenErrors)
})
return res
}
}
function createFunction (code, errors) {
try {
return new Function(code)
} catch (err) {
errors.push({
err, code })
return noop
}
}
可以看到,调用createCompileToFunctionFn函数就可以得到compileToFunctions函数了,终于推到头了,原来最开始调用compileToFunctions函数是在这里定义的,那么我们就来看一下compileToFunctions函数内部都干了些什么。
compileToFunctions函数内部会调用传入的compile函数,而这个compile函数是createCompiler函数内部定义的子函数,如下:
function compile (template,options) {
const compiled = baseCompile(template, finalOptions)
compiled.errors = errors
compiled.tips = tips
return compiled
}
在compile函数内部又会调用传入的baseCompile函数,而这个baseCompile函数就是我们所说的模板编译三大阶段的主线函数,如下:
function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
// 模板解析阶段:用正则等方式解析 template 模板中的指令、class、style等数据,形成AST
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
// 优化阶段:遍历AST,找出其中的静态节点,并打上标记;
optimize(ast, options)
}
// 代码生成阶段:将AST转换成渲染函数;
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
那么现在就清晰了,最开始调用的compileToFunctions函数内部调用了compile函数,在compile函数内部又调用了baseCompile函数,而baseCompile函数返回的是代码生成阶段生成好的render函数字符串。所以在compileToFunctions函数内部调用compile函数就可以拿到生成好的render函数字符串,然后在compileToFunctions函数内部将render函数字符串传给createFunction函数从而变成真正的render函数返回出去,最后将其赋值给options.render。为了便于更好的理解,我们画出了其上述过程的流程图,如下:
以上,就是模板编译的整体流程。
在Vue中,每个Vue实例从被创建出来到最终被销毁都会经历一个过程,就像人一样,从出生到死亡。在这一过程里会发生许许多多的事,例如设置数据监听,编译模板,组件挂载等。在Vue中,把Vue实例从被创建出来到最终被销毁的这一过程称为Vue实例的生命周期,同时,在Vue实例生命周期的不同阶段Vue还提供了不同的钩子函数,以方便用户在不同的生命周期阶段做一些额外的事情。那么,接下来我们就从源码角度深入剖析一下一个Vue实例在从生到死的生命周期里到底都经历了些什么,每个阶段都做了哪些事情。
从图中我们可以看到,Vue实例的生命周期大致可分为4个阶段:
1:初始化阶段:为Vue实例上初始化一些属性,事件以及响应式数据;
2:模板编译阶段:将模板编译成渲染函数;
3:挂载阶段:将实例挂载到指定的DOM上,即将模板渲染到真实DOM中;
4:销毁阶段:将实例自身从父组件中删除,并取消依赖追踪及事件监听器;
借用Vue官网的生命周期流程图介绍了一个Vue实例的生命周期大致可分为四个阶段,分别是初始化阶段、模板编译阶段、挂载阶段、销毁阶段。接下来的几篇文章我们就从这个流程图为基础,自上到下,从每个阶段入手,深入分析在每个阶段里都干了些什么。
初始化阶段所做的第一件事就是new Vue()创建一个Vue实例,那么new Vue()的内部都干了什么呢? 我们知道,new 关键字在 JS中表示从一个类中实例化出一个对象来,由此可见, Vue 实际上是一个类。所以new Vue()实际上是执行了Vue类的构造函数,那么我们来看一下Vue类是如何定义的,Vue类的定义是在源码的src/core/instance/index.js 中,如下:
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
可以看到,Vue类的定义非常简单,其构造函数核心就一行代码:
this._init(options)
调用原型上的_init(options)方法并把用户所写的选项options传入。那这个_init方法是从哪来的呢?在Vue类定义的下面还有几行代码,其中之一就是:
initMixin(Vue)
这一行代码执行了initMixin函数,那initMixin函数又是从哪儿来的呢?该函数定义位于源码的src/core/instance/init.js 中,如下:
export function initMixin (Vue) {
Vue.prototype._init = function (options) {
const vm = this
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {
},
vm
)
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
可以看到,在initMixin函数内部就只干了一件事,那就是给Vue类的原型上绑定_init方法,同时_init方法的定义也在该函数内部。现在我们知道了,new Vue()会执行Vue类的构造函数,构造函数内部会执行_init方法,所以new Vue()所干的事情其实就是_init方法所干的事情,那么我们着重来分析下_init方法都干了哪些事情。
首先,把Vue实例赋值给变量vm,并且把用户传递的options选项与当前构造函数的options属性及其父级构造函数的options属性进行合并(关于属性如何合并的问题下面会介绍),得到一个新的options选项赋值给 o p t i o n s 属 性 , 并 将 options属性,并将 options属性,并将options属性挂载到Vue实例上,如下:
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {
},
vm
)
接着,通过调用一些初始化函数来为Vue实例初始化一些属性,事件,响应式数据等,如下:
initLifecycle(vm) // 初始化生命周期
initEvents(vm) // 初始化事件
initRender(vm) // 初始化渲染
callHook(vm, 'beforeCreate') // 调用生命周期钩子函数
initInjections(vm) //初始化injections
initState(vm) // 初始化props,methods,data,computed,watch
initProvide(vm) // 初始化 provide
callHook(vm, 'created') // 调用生命周期钩子函数
可以看到,除了调用初始化函数来进行相关数据的初始化之外,还在合适的时机调用了callHook函数来触发生命周期的钩子,关于callHook函数是如何触发生命周期的钩子会在下面介绍,我们先继续往下看:
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
在所有的初始化工作都完成以后,最后,会判断用户是否传入了el选项,如果传入了则调用 m o u n t 函 数 进 入 模 板 编 译 与 挂 载 阶 段 , 如 果 没 有 传 入 e l 选 项 , 则 不 进 入 下 一 个 生 命 周 期 阶 段 , 需 要 用 户 手 动 执 行 v m . mount函数进入模板编译与挂载阶段,如果没有传入el选项,则不进入下一个生命周期阶段,需要用户手动执行vm. mount函数进入模板编译与挂载阶段,如果没有传入el选项,则不进入下一个生命周期阶段,需要用户手动执行vm.mount方法才进入下一个生命周期阶段。
以上就是new Vue()所做的所有事情,可以看到,整个初始化阶段都是在new Vue()里完成的,关于new Vue()里调用的一些初始化函数具体是如何进行初始化的,我们将在接下来的几篇文章里逐一介绍。下面我们先来看看上文中遗留的属性合并及callHook函数是如何触发生命周期的钩子的问题。
在上文中,_init方法里首先会调用mergeOptions函数来进行属性合并,如下:
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {
},
vm
)
它实际上就是把 resolveConstructorOptions(vm.constructor) 的返回值和 options 做合并,resolveConstructorOptions 的实现先不考虑,可简单理解为返回 vm.constructor.options,相当于 Vue.options,那么这个 Vue.options又是什么呢,其实在 initGlobalAPI(Vue) 的时候定义了这个值,代码在 src/core/global-api/index.js 中:
export function initGlobalAPI (Vue: GlobalAPI) {
// ...
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
Vue.options[type + 's'] = Object.create(null)
})
extend(Vue.options.components, builtInComponents)
// ...
}
首先通过 Vue.options = Object.create(null) 创建一个空对象,然后遍历 ASSET_TYPES,ASSET_TYPES 的定义在 src/shared/constants.js 中
export const ASSET_TYPES = [
'component',
'directive',
'filter'
]
所以上面遍历 ASSET_TYPES 后的代码相当于:
Vue.options.components = {
}
Vue.options.directives = {
}
Vue.options.filters = {
}
最后通过 extend(Vue.options.components, builtInComponents) 把一些内置组件扩展到 Vue.options.components 上,Vue 的内置组件目前 有、 和 组件,这也就是为什么我们在其它组件中使用这些组件不需要注册的原因。
那么回到 mergeOptions 这个函数,它的定义在 src/core/util/options.js 中:
/**
* Merge two option objects into a new one.
* Core utility used in both instantiation and inheritance.
*/
export function mergeOptions (
parent: Object,
child: Object,
vm?: Component
): Object {
if (typeof child === 'function') {
child = child.options
}
const extendsFrom = child.extends
if (extendsFrom) {
parent = mergeOptions(parent, extendsFrom, vm)
}
if (child.mixins) {
for (let i = 0, l = child.mixins.length; i < l; i++) {
parent = mergeOptions(parent, child.mixins[i], vm)
}
}
const options = {
}
let key
for (key in parent) {
mergeField(key)
}
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key)
}
}
function mergeField (key) {
const strat = strats[key] || defaultStrat
options[key] = strat(parent[key], child[key], vm, key)
}
return options
}
可以看出,mergeOptions函数的 主要功能是把 parent 和 child 这两个对象根据一些合并策略,合并成一个新对象并返回。首先递归把 extends 和 mixins 合并到 parent 上,
const extendsFrom = child.extends
if (extendsFrom) {
parent = mergeOptions(parent, extendsFrom, vm)
}
if (child.mixins) {
for (let i = 0, l = child.mixins.length; i < l; i++) {
parent = mergeOptions(parent, child.mixins[i], vm)
}
}
然后创建一个空对象options,遍历 parent,把parent中的每一项通过调用 mergeField函数合并到空对象options里,
const options = {
}
let key
for (key in parent) {
mergeField(key)
}
接着再遍历 child,把存在于child里但又不在 parent中 的属性继续调用 mergeField函数合并到空对象options里,
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key)
}
}
最后,options就是最终合并后得到的结果,将其返回。
这里值得一提的是 mergeField 函数,它不是简单的把属性从一个对象里复制到另外一个对象里,而是根据被合并的不同的选项有着不同的合并策略。例如,对于data有data的合并策略,即该文件中的strats.data函数;对于watch有watch的合并策略,即该文件中的strats.watch函数等等。这就是设计模式中非常典型的策略模式。
关于这些合并策略都很简单,我们不一一展开介绍,仅介绍生命周期钩子函数的合并策略,因为我们后面会用到。生命周期钩子函数的合并策略如下
/**
* Hooks and props are merged as arrays.
*/
function mergeHook (parentVal,childVal): {
return childVal
? parentVal
? parentVal.concat(childVal)
: Array.isArray(childVal)
? childVal
: [childVal]
: parentVal
}
LIFECYCLE_HOOKS.forEach(hook => {
strats[hook] = mergeHook
})
这其中的 LIFECYCLE_HOOKS 的定义在 src/shared/constants.js 中:
export const LIFECYCLE_HOOKS = [
'beforeCreate',
'created',
'beforeMount',
'mounted',
'beforeUpdate',
'updated',
'beforeDestroy',
'destroyed',
'activated',
'deactivated',
'errorCaptured'
]
这里定义了所有钩子函数名称,所以对于钩子函数的合并策略都是 mergeHook 函数。mergeHook 函数的实现用了一个多层嵌套的三元运算符,如果嵌套太深不好理解的话我们可以将其展开,如下:
function mergeHook (parentVal,childVal): {
if (childVal) {
if (parentVal) {
return parentVal.concat(childVal)
} else {
if (Array.isArray(childVal)) {
return childVal
} else {
return [childVal]
}
}
} else {
return parentVal
}
}
从展开后的代码中可以看到,它的合并策略是这样子的:如果 childVal不存在,就返回 parentVal;否则再判断是否存在 parentVal,如果存在就把 childVal 添加到 parentVal 后返回新数组;否则返回 childVal 的数组。所以回到 mergeOptions 函数,一旦 parent 和 child 都定义了相同的钩子函数,那么它们会把 2 个钩子函数合并成一个数组。
那么问题来了,为什么要把相同的钩子函数转换成数组呢?这是因为Vue允许用户使用Vue.mixin方法(关于该方法会在后面章节中介绍)向实例混入自定义行为,Vue的一些插件通常都是这么做的。所以当Vue.mixin和用户在实例化Vue时,如果设置了同一个钩子函数,那么在触发钩子函数时,就需要同时触发这个两个函数,所以转换成数组就是为了能在同一个生命周期钩子列表中保存多个钩子函数。
关于callHook函数如何触发钩子函数的问题,我们只需看一下该函数的实现源码即可,该函数的源码位于src/core/instance/lifecycle.js 中,如下:
export function callHook (vm: Component, hook: string) {
const handlers = vm.$options[hook]
if (handlers) {
for (let i = 0, j = handlers.length; i < j; i++) {
try {
handlers[i].call(vm)
} catch (e) {
handleError(e, vm, `${
hook} hook`)
}
}
}
}
可以看到,callHook函数逻辑非常简单。首先从实例的$options中获取到需要触发的钩子名称所对应的钩子函数数组handlers,我们说过,每个生命周期钩子名称都对应了一个钩子函数数组。然后遍历该数组,将数组中的每个钩子函数都执行一遍。
首先,分析了new Vue()时其内部都干了些什么。其主要逻辑就是:合并配置,调用一些初始化函数,触发生命周期钩子函数,调用$mount开启下一个阶段。
接着,就合并属性进行了详细介绍,知道了对于不同的选项有着不同的合并策略,并挑出钩子函数的合并策略进行了分析。
最后,分析了callHook函数的源码,知道了callHook函数如何触发钩子函数的。
let Vue;
class VueStore {
constructor(options) {
this.$options = options;
this._mutations = options.mutations;
this._actions = options.actions;
this._wappedGetters = options.wappedGetters
this._vm = new Vue({
$$state: options.state
})
this.commit = this.commit.bind(this);
this.dispatch = this.dispatch.bind(this)
this._wappedGetters = this.wappedGetters.bind(this)
const computed = {
};
this.getters = {
};
const store = this;
Object.keys(this.wappedGetters).forEach(key => {
//获取用户定义等getter
const fn = store._wappedGetters[key];
//转换为computed可以无参形式
computed[key] = function () {
return fn(store.state)
}
Object.defineProperty(store.getters, key, {
get: () => store._vm[key],
set: () => {
console.error('error of to exid') }
})
})
}
get state() {
console.log(this._vm)
return this._vm.data.$$state;
}
set state(v) {
console.error("")
}
commit(type, payload) {
const entry = this._mutations[type];
if (!entry) {
console.error("undefined mutations"); return;
}
entry(this.state, payload)
}
dispatch(type, payload) {
const entry = this._actions[type];
if (!entry) {
console.error("undefined actions"); return;
}
entry(this.state, payload)
}
}
function install(_vue) {
Vue = _vue;
Vue.minix({
beforeCreate() {
if (this.$options.store) {
Vue.prototype.$store = this.$options.store;
}
}
})
}
export default {
VueStore, install }
废话不多说 直接上代码
// 1.实现一个插件
// 2.实现VueRouter: 处理选项、监控url变化,动态渲染
let Vue;
class VueRouter {
// Vue要在这里用
constructor(options) {
// 1.处理选项
this.$options = options;
// 2.需要响应式的current
this.current = window.location.hash.slice(1) || '/';
Vue.util.defineReactive(this, 'matched', [])
// match 方法可以递归遍历路由表 获取匹配关系数组
this.match()
window.addEventListener("hashchange", this.onHashChange.bind(this))
}
onHashChange() {
this.current = window.location.hash.slice(1);
this.matched = []
this.match();
}
match(routes) {
//
routes = routes || this.$options.routes;
//递归遍历
for (const route of routes) {
if (route.path === '/' && this.current === '/') {
this.matched.push(route);
return;
}
// /about/info/detail
if (route.path !== '/' && this.current.indexOf(route.path) != -1) {
this.matched.path(route);
if (route.children) {
this.match(route.children)
}
return;
}
}
}
}
// 插件要求实现install(Vue)
VueRouter.install = function (_Vue) {
Vue = _Vue;
// 利用全局混入延迟调用后续代码
Vue.mixin({
beforeCreate() {
// 任务1:挂载$router
// 以后每个组件都会调用该方法
if (this.$options.router) {
// 此时的上下文this是当前组件实例
Vue.prototype.$router = this.$options.router;
}
},
});
// 任务2:注册两个全局组件
Vue.component("router-link", {
props: {
to: {
type: String,
required: true,
},
},
render(h) {
// abc
// abc
// return {this.$slots.default};
return h("a", {
attrs: {
href: "#" + this.to } }, this.$slots.default);
},
});
Vue.component("router-view", {
render(h) {
this.$vnode.data.routerView = true;
let depth = 0;
let parent = this.$parent;
while (parent) {
const vnodeData = parent.$vnode && parent.$vnode.data;
if (vnodeData) {
if (vnodeData.routerView) {
depth++
}
}
this.parent = parent.$parent;
}
let Component = null;
let route = this.$router.matched[depth];
if (route) {
Component = route.component;
}
return h(Component)
}
})
};
export default VueRouter;