vue源代码学习-mvvm2

之前一篇博客说了关于vue是如何实现mvvm的,如observe过程、diff算法等。但是感觉还有一些地方也值得看看,所以这篇作为一个补充吧。

render函数

在上一篇博客里只是粗略的介绍了render函数的作用,但是我们还没有介绍render函数是如何生成的。见下图:

我们这里只看run-time时编译template的场景,vue的主要相关代码都在complier目录下。

首先, Vue会把template模板转换为ast节点(Abstract Syntax Tree)。说是抽象语法树,其实可以理解为vue自己解析构造了一个dom树就行,每个节点有自己的attrs(指令, html属性等), type, tag, parent, children等,类型定义可以参考flow/compiler.js。

在拿到template模板字符串后,vue会遍历字符串,来生成ast。至于怎么遍历的,主要就是基于关键字符的定位,如tag的起始:<, 和结束:>,然后判断节点是否为unary节点,然后用一个栈来push新的节点,pop出遍历到末尾的节点。当然,说起来简单,肯定还有很多兼容浏览器、各种特殊姿势的处理,具体大家可以去看源代码compiler/parser/index.js里。

生成ast后,vue就会根据其来生成对应的render函数了。render函数最终是一堆嵌套的createElement的方法,里面再把各种指令语法转化为vue对象的内置转换函数(run-time时调用), 主要代码在compiler/codegen/index.js里。

何时触发render函数?

既然数据驱动视图,那么何时更新视图呢?这是一个很有意思的问题。

我们假想,如果每次一修改数据我们就去更新视图会出现什么情况?

1
2
3
vue.dataA = 'update view'
vue.dataB = 'update view2'
vue.dataC = 'update view3'

假设如此,那以上3个连续的针对数据的操作,将会触发三次render函数执行,以及对应的三次diff过程,这样肯定是浪费的。

Vue并没有采取像react那样显式的调用setState策略,而是为watcher的执行,创建一个macro task或者micro task,将数据触发的watcher放入到一个队列里,并且watcher id一样的会进行过滤,以此来尽可能的减少重复的操作。

我们暂时不去考虑watcher的lazy、sync情况。当vue的数据更新时,dep.notify会调用,那么对应的render watcher实例就会被push到一个队列里, 然后再调用nextTick(flushSchedulerQueue)。对应的,在nextTick里,Vue会决定使用micro task来异步完成这个回调,抑或是macro task(主要是v-on绑定的event handler)。下面这个方法就是添加watcher到队列

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
/**
* Push a watcher into the watcher queue.
* Jobs with duplicate IDs will be skipped unless it's
* pushed when the queue is being flushed.
*/
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) { // 若已经有watcher了,不再重复执行
has[id] = true
if (!flushing) { // 没有flushing
queue.push(watcher)
} else { // 这种情况应该是在user watcher中又改变了一个被watch的值时会触发
//
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue) // 等待nextTick
}
}
}

flushSchedulerQueue函数就是将queue里的watcher拿出来挨个执行,在这个方法里,vue会先将watcher按照id由小到大排序,然后也同时会检测是否有死循环。

关于为什么要按照id大小排序执行,vue有如下解释:

  • vue组件应该是从parent到child来逐步更新(parent的watcher id肯定比child更靠前)
  • 组件的user watcher(vue用户的watcher)比组件的render watcher更靠前
  • 如果一个组件在父组件的watcher run过程中被销毁,那么这个组件本来准备执行的render watcher也可以被略过

接下来再看看nextTick的实现:

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
export function nextTick (cb?: Function, ctx?: Object) { // Vue.nextTick和vue.$nextTick的本体
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) { // cb为空的情况, 把promise fulfilled
_resolve(ctx)
}
})
if (!pending) { // 若在pending,说明在这个loop里,已经要准备flushCallbacks了,不重复调用
pending = true
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') { // 如果回调为空,返回一个Promise对象, 状态将在micro queue执行的时候(flushCallbacks) fulfilled
return new Promise(resolve => {
_resolve = resolve
})
}
}

flushCallbacks就是把callbacks一个一个取出来挨个执行,但是这里区分使用了macro task(setImmediate(IE独有)、MessageChannel、 setTimeout按优先级依次使用)和micro task(原生Promise、setTimeout按优先级依次使用)。

按照vue的解释, 在2.4版本之前都用的micro task, 但是这导致了优先级太高, 会在本该连续的事件中:比如冒泡过程中,夹杂micro task的执行导致某些异常现象。并且有几个相关issue:#4521, #6690, #6566。

在这之后,尝试过所有都用macro task,但是也导致了一些问题如#6813,但是我看了下 = = , 感觉这不就是一段很奇怪的代码强行试出了这个bug。这个例子里,在屏宽小于1000px时,将ul隐藏, 但是css media中同时页配置里当宽度大于等于1000px时,li为inline。当都采用macro task的情况下,会出现闪烁(https://youtu.be/9BRqQa2Q9a4), 其实就是首先css生效li变回block, ul块拉长,然后紧接着通过v-show将ul隐藏,出现闪烁…讲道理,直接把li永远设成inline不就行了…

所以最终,vue采用了默认micro task的方式处理,但是在某些情况下使用macro task(如v-on绑定的事件)

至于具体啥是micro task, macro task, 这里放几个资料,感觉讲的非常清楚了。

Tasks, microtasks, queues and schedules

chrome的开发者Jake在2015年写的

In The Loop - JSConf.Asia 2018

这个需要翻墙了,同样是Jake,这次是在新加坡jsconf现场讲的, 30多分钟视频,例子十分生动形象。

HTML关于event loop的规范

whatwg HTML living standard

Shared Event-loop for Same-Origin Windows

我才知道同源窗口共享event loop,汗。

下面弄个栗子来看看,在一次点击事件里,到底发生了什么,顺序又是什么。源代码如下:

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
<div id = "app">
<h1>{{ title }}</h1>
<template v-for = "radio in radios">
<label :for="radio">{{radio}}</label>
<input :id="radio" type = "radio" name = "radio" :value = "radio" v-model = "radioValue" @click = "click1">
</template>
</div>
<script>
demo = new Vue({
el: '#app',
data: {
title: 0,
radios: ["radio1", "radio2"],
radioValue: ''
},
methods: {
click1: function(){
console.log('click1 input')
Promise.resolve().then(() => {
console.log('click1 promise')
this.title += 1
})
this.title += 1
}
},
watch: {
title: function() {
console.log('title watcher')
console.log(this.title)
},
radioValue: function(){
console.log('radio v-model')
this.title += 1
}
}
})
</script>

我这里也仿照做了一个demo:

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
demo = new Vue({
el: '#app',
data: {
title: 0,
radios: ["radio1", "radio2"],
radioValue: ''
},
methods: {
click1: function(){
console.log('click1 input')
Promise.resolve().then(() => {
console.log('click1 promise')
this.title += 1
})
this.title += 1
}
},
watch: {
title: function() {
console.log('title watcher')
},
radioValue: function(){
console.log('radio v-model')
this.title += 1
}
}
})
Macro tasks
Dispatch Click
Dispatch Change
flushCallbacks
Micro tasks
Promise then
JS stack
click1
Promise callback
v-model change callback(radioValue = value)
run watcher queue
Watcher queue
title watcher
radioValue watcher
title watcher
render watcher
Log
click1 input
click1 promise
title watcher
radio v-model
title watcher
tip msg

从上面的栗子里,可以看到几个关键点:

  1. v-on回调里触发的watcher是放在macro task里的
  2. watcher queue里不会重复执行watcher(过滤重复)
  3. user watcher执行时再度触发另一个user watcher的情况下,按照id优先的顺序排到尽快执行的位置

总结

总结就是,看了这部分代码,学到很多、很多,哈哈