Vue源码学习-从keep-alive的bug讲起

Posted by Yeoman on 2018-08-25

背景

嗯,这个月的主题是Vue。想着正好趁着这个时间好好撸一撸Vue源码,说到源码阅读,先聊聊我觉得读源码几种比较好的方式吧。

1.1 系统性阅读

通常一个成熟的框架源码代码量都是比较多的,也有比较多的复杂模块,这时候我们要找到一条主线,先不要去阅读细节的代码。然后庖丁解牛,逐个模块击破。比如我们先大致捋一捋Vue源码的主线,看过一些Vue源码的同学对于下面这张图应该都比较熟悉:

Vue的源码核心概念可以分为:

1.1.1 - 编译

图中的左边部分,解析template模版到抽象语法树(AST),然后再用抽象语法树生成Render函数。这部分我们称之为编译(compiler),代码部分对应Vue源码目录下的src/compiler目录下。(如果直接自定义Render函数则没有compiler这一步)

1.1.2 - 数据响应系统

图中的右边部分,这部分也是源码中被讲的最多的,通过WatcherDepObserver这几个核心类实现订阅发布者模式来构造了Vue的数据响应系统。这部分的核心代码在src/core/observer 目录下。

1.1.3 - Virtual DOM

从功能模块上来讲,其实虚拟DOM也是属于数据响应系统的一部分-更新UI。Vue 2.0借鉴了React的Virtual DOM的思想,来实现视图的局部更新。Virtual DOM的概念本身是比较简单的,就是用对象去描述真实DOM树,但是在更新视图的时候就需要用diff算法来实现patch。所以这一块也可以拎出来单独看。这部分代码在src/core/vdom目录下。

1.1.4 - SSR

这张图中并没有体现的服务端渲染,但是服务端渲染也是SPA应用比较重要的一个特性了,这一块的代码应该可以结合Nuxt.js一起看,代码在src/server目录。

1.2 - 带着问题去读

通常我们都是先学会用,再去理解原理。这时候如果在用的过程中发现了一些问题的话就可以去找对应的源码来精度了。比如说:

  1. template是如何编译成AST的?
  2. 双向绑定中不同类型的数据是如何实现变化侦查的?
  3. this.$nextTick的实现原理是什么?
  4. keep-alive组件的实现原理?
  5. Vue的生命周期在源码中的体现是怎样的?
  6. 等等…

1.3 - 知己知彼

这种阅读方式要求就比较高了,通过去阅读不通框架对于相同功能的实现方式,来思考实现方式的优劣和使用场景。(反正我不会,我只会YY- -)比如说:

  1. React和Vue在做变化侦查的实现方式上有什么区别?(被动pull还是主动push)
  2. Vue和React的Virtual Dom实现上有哪些差异?
  3. Vue的声明式组件和React的JSX有什么优劣,各自的实现方式如何?
  4. React生态中为什么在Redux(对应vuex)之后又出现了Mobx,两者的核心思路有哪些区别?
  5. 等等…

问题

2.1 提出问题

好像废话太多了,这片文章会试着从遇到的问题来讲讲Vue的部分源码,内容会比较杂,基本按照我的思考路径来记述。

问题的发现者是P同学(感谢提供了素材!!!),他发现了一个疑似keep-alive组件的bug,keep-alive组件暴露了两个API,分别是includeexclude,用于控制哪些组件需要被缓存。但是实际的实现效果是被include剔除的组件并没有被销毁,我们可以通过vue-tools看下,实际效果如图:

应P同学建议贴一下工程里对应的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<template>
<div>
<keep-alive :include="include">
<router-view />
</keep-alive>
</div>
</template>
<script>
import {
mapState,
mapMutations
} from 'vuex';

export default {
computed: {
...mapState({
tabList: state => state.tabList
}),
include() {
return this.tabList.map(tab => tab.key).join(',');
}
},
methods: {
...mapMutations(['SWITCHTAB_REQUEST']),
handleSwitchTab(item) {
if (item.route) {
this.SWITCHTAB_REQUEST({
key: item.route, name: item.name, muti: false
});
}
}
}
};
</script>

页面中的拖拽左侧菜单栏对应加入到include操作,关闭tab对应剔除出include操作。可以发现,如果不断重复加入include和剔除include操作的话,通过Vue devtools工具可以看到重复的组件会不断累加,之前的组件不会被销毁。

查看当前的Vue版本是v2.5.2,查看了之前的老工程v2.2.6版本不会存在这个问题,看来小右哥哥又悄悄写bug了……emmm让我们来看看这个bug悄悄写到哪里了。这部分对应的代码非常好找,就在src/core/components/keep-alive.js里面,先来分析下keep-alive组件的实现,可以看到还是比较简单的。

  1. keep-alive组件提供了Render函数,cache的逻辑基本就是这个函数来控制,获取到$slots的结点作为自己的vnode(这里注意,如果没有配置组件的name属性的话,keep-alive会用组件的tag作为name,因此如果要配合includeexclude使用的话,组件必须配置name属性)。
  2. 判断组件是否在include列表,如果没有就直接返回这个vnode节点
  3. 如果组件在include列表内,判断组件之前是否被缓存过,如果没有,则缓存到cache对象里,如果有,则从cache对象里取出返回。

然后对比两个版本的代码差异,很快就可以发现问题代码了,下面是关键伪代码:

这是v2.5.2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// v2.5.2

function pruneCacheEntry (
cache: VNodeCache,
key: string,
keys: Array<string>,
current?: VNode
) {
const cached = cache[key]
if (cached && (!current || cached.tag !== current.tag)) {
cached.componentInstance.$destroy()
}
cache[key] = null
remove(keys, key)
}

这是v2.2.6:

1
2
3
4
5
6
7
8
9
10
// v2.2.6

function pruneCacheEntry (vnode: ?VNode) {
if (vnode) {
if (!vnode.componentInstance._inactive) {
callHook(vnode.componentInstance, 'deactivated')
}
vnode.componentInstance.$destroy()
}
}

可以发现,两者的区别在于,v2.5.2版本在destroy组件的时候加了判断,如果是当前渲染的组件,不能被销毁。再结合我们刚才看到的gif图就比较好理解了,我们关闭一个tab的时候,这个视图还是当前被渲染的,这时候就不会被destroy了。而且这个key还从keys数组中移除了,下一次遍历缓存的时候就不会遍历到这个组件了,就导致了刚才看到的那个bug。

2.2 解决问题

知道了问题原因之后,开始思考如何解决这个问题。我们先把Vue更新到当前最新的v2.5.16版本,看看尤大是不是已经偷偷修复了这个bug。

蛤?还真的修了呢……

让我来看看大佬是怎么修bug的,经过反复侦查,发现了这条可疑提交:

经过切换版本测试,发现确实是这个提交修复了这个bug,把watch放到mounted函数里就解决这个问题了???

我感觉自己被降维打击了orz

2.3 分析问题

通常我们都是先分析问题再解决问题,既然尤大已经解决了,那就回过来分析下是怎么解决的。

要解释这个问题,本质就是搞清楚在构造器里传递watch和在mounted中调用vm.$watch的区别。单纯看代码diff,一下子真的看不出这个解决问题的思路,我需要debug这部分代码进去看看!这时候一个新的坑开始了(不想看这个坑的可以直接跳过这段- -

2.3.1 调试Vue代码

要在自己的工程里debug Vue的源码,我的思路很简单(如果不是调试自己工程,Vue工程官方就提供了一些example):

  1. Vue工程clone下来,运行工程的dev模式(会监听源码变化生成dist目录)。
  2. 把工程里import Vue from 'vue'的地方全部替换成从这个工程里引入。

现实总是骨感的,做完上面两步,页面妥妥打不开了- -

查看调用关系,发现this.$router变成undefined了。我换个Vue的dist文件关你vue-router什么事咯(摔

经过一通理(xia)性(bi)猜测和调试,还是没有解决问题,我生气了,今天不解决这个问题不回家了……

既然是this.$router这个对象找不到,那就先去vue-route的源码里看看这个对象是怎么放到vm实例上的,看vue的插件肯定先从插件的install.js看起了。

1
2
3
Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})

因吹斯汀,和大部分的Vue实例属性一样,这个$router也是直接放在了Vue这个构造函数的原型链上,这样在new Vue()的时候,每一个组件都会拥有这个对象,按照这样的思路,不应该出现$router找不到情况才对,只能继续看_routerRoot从哪里来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Vue.mixin({
beforeCreate () {
if (isDef(this.$options.router)) {
this._routerRoot = this
this._router = this.$options.router
this._router.init(this)
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
}
registerInstance(this, this)
},
destroyed () {
registerInstance(this)
}
})

我们在工程里的main.js里初始化根结点组件的时候都会这么写

1
2
3
4
new Vue({
el: '#app',
router
});

这里提供的router对象就被设置在了这个应用的根节点组件,然后子节点依次去父节点获取。

我在应用内输出根节点的$router对象,发现是存在的,但是输出子节点就发现不存在了,真相只有一个,那就是子节点不是亲生的!皮一下很开心。

这时候基本想清楚原因了,其实是因为子节点的构造函数和父节点不是同一个,原型链上并没有$router对象。我们知道根节点的构造函数就是通过import Vue from 'vue'引入的,那么子节点呢?我们知道子节点都是vue单文件,是由vue-loader去解析的,猛然想起webpack有一处配置。

1
2
3
alias: {
vue$: 'vue/dist/vue.esm.js'
}

没错,只要把这一处的路径也替换就好了- -

这个别名不确定会在哪里会引用到,没有深入探究,有熟悉的同学可以告知下~

2.3.2 发现真相

——————————————重点分割线—————————————

废话了这么久,回过头再看keep-alive这段简化后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
function pruneCache (keepAliveInstance: any, filter: Function) {
const { cache, keys, _vnode } = keepAliveInstance
for (const key in cache) {
const cachedNode: ?VNode = cache[key]
if (cachedNode) {
const name: ?string = getComponentName(cachedNode.componentOptions)
if (name && !filter(name)) {
pruneCacheEntry(cache, key, keys, _vnode)
}
}
}
}

function pruneCacheEntry (
cache: VNodeCache,
key: string,
keys: Array<string>,
current?: VNode
) {
const cached = cache[key]
if (cached && (!current || cached.tag !== current.tag)) {
cached.componentInstance.$destroy()
}
cache[key] = null
remove(keys, key)
}

const patternTypes: Array<Function> = [String, RegExp, Array]

export default {
name: 'keep-alive',
abstract: true,
mounted () {
this.$watch('include', val => {
pruneCache(this, name => matches(val, name))
})
this.$watch('exclude', val => {
pruneCache(this, name => !matches(val, name))
})
},

render () {
const slot = this.$slots.default
const vnode: VNode = getFirstComponentChild(slot)
const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
if (componentOptions) {
// check pattern
const name: ?string = getComponentName(componentOptions)
const { include, exclude } = this
if (
// not included
(include && (!name || !matches(include, name))) ||
// excluded
(exclude && name && matches(exclude, name))
) {
return vnode
}

const { cache, keys } = this
const key: ?string = vnode.key == null
// same constructor may get registered as different local components
// so cid alone is not enough (#3269)
? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
: vnode.key
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance
// make current key freshest
remove(keys, key)
keys.push(key)
} else {
cache[key] = vnode
keys.push(key)
// prune oldest entry
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
}

vnode.data.keepAlive = true
}
return vnode || (slot && slot[0])
}
}

查看调用关系之后,我们发现判断那里增加的current参数就是this._vnode。而每次切换路由的时候,keep-alive的render函数都会重新调用渲染,this._vnode都会变成路由切换后的页面节点。

断点调试之后,发现如果是通过在watch选项中监听include参数的话,会先调用include变化的回调,然后再调用Render函数,这样的话,在调用pruneCache修整缓存的时候,因为Render函数还没有被调用,也就是路由切换前节点还被认为是current,也就没办法销毁了。

相反的,如果是在mounted函数中通过this.$watch监听的话,会先调用Render函数再回调include变化的回调,这样在调用pruneCache的时候this._vnode已经指向了路由切换后节点,那么老页面就可以正常销毁了。

这篇文章中,我们梳理过,如果以watch选项的方式来监听的话,会在created阶段创建Watcher来进行监听,那么问题就简化成了,created中的回调,mounted中的回调,Render()这三个函数的调用时机是怎样的。

看似问题已经简化了,但其实这才刚开始进入重点,我们还有几个核心问题没有理清楚,也是我在看源码过程中花费时间最久的过程:

  1. new Wathcer()到底做了什么事情?
  2. 为什么每次include变化的时候Render函数都会被重新调用?
  3. 如果有多个Watcher,调用顺序是怎样的?

Vue的响应式系统

要解释上面这些问题,涉及到的源码文件比较多,调用关系也比较复杂,我发现贴代码来描述不如画图来的直观,我根据自己理解画了下面这张图,这张图只是响应式系统的一部分,但是用来解释上面这几个问题足够了,我们假设我们同时在createdmounted阶段都监听了include字段。

我们来按照代码的执行逻辑分析下这张图:

  1. 首先在生命周期那边文章中我们知道,在_initData的过程中,会对所有的data进行递归调用defineReactive,这一步甚至早于_initWatcher,这样我们的include字段就变成了一个响应式数据,拥有getset方法
  2. 下一步就是_initWatch的时候,也就是处理Vue实例的watch选项,对应上面的created阶段,在new Watcher()的过程中,会对这个监听的表达式进行求值,这时候就触发了get函数中的依赖收集,依赖被收集到Depsubs中对应图中的Watcher1(Watcher的uid是一个递增字段,用来作为Watcher的唯一标志),这里解释了第一个问题,重点在于触发依赖收集。
  3. 生命周期那篇文章中提过,在初始化完Vue实例的各种属性之后就会调用$mount函数,这个函数最重要的作用就是去调用_render函数,同样这个函数还有一个非常重要的作用就是会调用new Watcher(),可以理解为_render就是这个watcher的回调函数,就这样,图中的Watcher2也被收集到了Dep中。
  4. _render函数执行完之后,就会调用mounted这个生命周期的钩子函数,其实从函数名也能看出来。这时候我们调用this.$watch,$watch只是对Watcher的一个封装,同样的,和第二步一样,这个Watcher3就收集到依赖中。
  5. 这时候我们去关闭tab的时候,也就触发了includeset方法(实际上数组的变化监听是通过代理掉数组的原生方法),这个时候就会在set中调用Depnotify方法,notify会被遍历调用之前收集到的Watcher,那么调用的顺序是什么样的呢,在调用之前,会先按照Watcher的id进行排序,这里就解释了上面提到的第三个问题,所以会先调用createdwatcher,然后是Render的,最后是mounted的。
  6. 那么Watcher的调用是如何执行的呢,我们知道我们在写$watch的时候,会传入一个回调函数,这种Watcher在调用的时候就是直接调用了这个回调函数,那么问题来了,图中的Watcher2是Vue内部生成的,没有回调,怎么办?实际上Vue内部会把updateComponent作为这种RenderWatcher的回调,这个函数会重新触发_render函数。这就解释了上面的第二个问题,所以我们的调用顺序就是created中的回调,Render(),最后是mounted中的回调。

Vue中的Watcher有两种类型,一种是UserWatcher,也就是用户调用$watch的时候生成的,还有一种是RenderWatcher,Vue内部监听响应式数据用的。

至此,虽然这中间我们漏掉了一些很有意思的细节代码,但是也可以说是完美解释清楚了尤大解决这个bug的思路。

总结

这篇文章主要就是通过用前文提到的第二种阅读源码的方式来带着问题去读,我觉得还是颇有收获的。看似一个很普通的bug,但是如果深入去分析作者解决这个bug的思路的时候,会发现Vue内部确实做了很多有意思的事情。

其实在上文我已经列了一些我觉得比较有意思的可以去深入学习研究的点,这个时候就可以像前文说的那样去逐个击破啦,苟…利国家…….