Vue.js的异步更新DOM策略

前言

前面析我们了解了响应式数据依赖收集过程,收集的目的就是为了当我们修改数据的时候,可以对相关的依赖派发更新,那么这一节我们来详细分析这个过程。

操作 DOM

在使用 vue.js 的时候,有时候因为一些特定的业务场景,不得不去操作 DOM,比如这样:

1
2
3
4
5
6
<template>
<div>
<div ref="test">{{ test }}</div>
<button @click="handleClick">修改</button>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
data () {
return {
test: 'start'
};
},
methods () {
handleClick () {
this.test = 'end';
console.log(this.$refs.test.innerText);//start
}
}
}

在上面这个例子中,我们看到打印的结果是 start,为什么我们明明已经将 test 设置成了“end”,获取真实 DOM 节点的 innerText 却没有得到我们预期中的“end”,而是得到之前的值“start”呢?

Watcher 队列

通过前面的总结,我们已经知道,当某个响应式数据发生变化的时候,它的 setter 函数调用 Dep 实例中 dep.notify() 方法,然后在 Dep 类中则会调用它管理的所有 Watch 对象。触发 Watch 对象的 update 实现。我们来看一下 update 的实现。

1
2
3
4
5
6
7
8
9
10
11
12
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
/*同步则执行run直接渲染视图*/
this.run()
} else {
/*异步推送到观察者队列中,下一个tick时调用。*/
queueWatcher(this)
}
}

我们发现 Vue.js 默认是使用异步执行 DOM 更新。 当异步执行 update 的时候,会调用 queueWatcher 函数。queueWatcher 的定义在 src/core/observer/scheduler.js 中:

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
/*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/
export function queueWatcher(watcher: Watcher) {
/*获取watcher的id*/
const id = watcher.id;
/*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/
if (has[id] == null) {
has[id] = true;
if (!flushing) {
/*如果没有刷新,直接push到队列中即可*/
queue.push(watcher);
} else {
//如果已经刷新,则根据其id拼接监视器
//如果已经超过了它的id,它将立即运行。
let i = queue.length - 1;
while (i > index && queue[i].id > watcher.id) {
i--;
}
queue.splice(i + 1, 0, watcher);
}
// 队列刷新
if (!waiting) {
waiting = true;

if (process.env.NODE_ENV !== "production" && !config.async) {
flushSchedulerQueue();
return;
}
nextTick(flushSchedulerQueue);
}
}
}

这里引入了一个队列的概念,这也是 Vue 在做派发更新的时候的一个优化的点,它并不会每次数据改变都触发 watcher 的回调,而是把这些 watcher 先添加到一个队列里,然后在 nextTick 后执行 flushSchedulerQueue。

这里有几个细节要注意一下,首先用 has 对象保证同一个 Watcher 只添加一次;接着对 flushing 的判断,else 部分的逻辑稍后我会讲;最后通过 waiting 保证对 nextTick(flushSchedulerQueue) 的调用逻辑只有一次,另外 nextTick 的实现后面小节会专门去讲,目前就可以理解它是在下一个 tick,也就是异步的去执行 flushSchedulerQueue。

接下来我们来看 flushSchedulerQueue 的实现,它的定义在 src/core/observer/scheduler.js 中。

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
/**
* Flush both queues and run the watchers.
* nextTick的回调函数,在下一个tick时flush掉两个队列同时运行watchers
*/
function flushSchedulerQueue() {
currentFlushTimestamp = getNow();
flushing = true;
let watcher, id;

// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child)
// 2. A component's user watchers are run before its render watcher (because
// user watchers are created before the render watcher)
// 3. If a component is destroyed during a parent component's watcher run,
// its watchers can be skipped.
queue.sort((a, b) => a.id - b.id);

// do not cache length because more watchers might be pushed
// as we run existing watchers
/*这里不用index = queue.length;index > 0; index--的方式写是因为不要将length进行缓存,
因为在执行处理现有watcher对象期间,更多的watcher对象可能会被push进queue
*/
for (index = 0; index < queue.length; index++) {
watcher = queue[index];
if (watcher.before) {
watcher.before();
}
id = watcher.id;
/*将has的标记删除*/
has[id] = null;
/*执行watcher*/
watcher.run();
// in dev build, check and stop circular updates.
/*
在测试环境中,检测watch是否在死循环中
比如这样一种情况
watch: {
test () {
this.test++;
}
}
持续执行了一百次watch代表可能存在死循环
*/
if (process.env.NODE_ENV !== "production" && has[id] != null) {
circular[id] = (circular[id] || 0) + 1;
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
"You may have an infinite update loop " +
(watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`),
watcher.vm
);
break;
}
}
}

// keep copies of post queues before resetting state
/*得到队列的拷贝*/
const activatedQueue = activatedChildren.slice();
const updatedQueue = queue.slice();

/*重置调度者的状态*/
resetSchedulerState();

// call component updated and activated hooks
/*使子组件状态都改编成active同时调用activated钩子*/
callActivatedHooks(activatedQueue);
/*调用updated钩子*/
callUpdatedHooks(updatedQueue);

// devtool hook
/* istanbul ignore if */
if (devtools && config.devtools) {
devtools.emit("flush");
}
}

flushSchedulerQueue 是下一个 tick 时的回调函数,主要目的是执行 Watcher 的 run 函数,用来更新视图,这里需要注意以下几点:

  • 队列排序:
    queue.sort((a, b) => a.id - b.id) 对队列做了从小到大的排序,这么做主要有以下要确保以下几点:
    1. 组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。
    2. 用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。
    3. 如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。
  • 队列遍历
    在对 queue 排序后,接着就是要对它做遍历,拿到对应的 watcher,执行 watcher.run()。这里需要注意一个细节,在遍历的时候每次都会对 queue.length 求值,因为在 watcher.run() 的时候,很可能用户会再次添加新的 watcher,这样会再次执行到 queueWatcher,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function queueWatcher(watcher: Watcher) {
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
if (!flushing) {
queue.push(watcher);
} else {
let i = queue.length - 1;
while (i > index && queue[i].id > watcher.id) {
i--;
}
queue.splice(i + 1, 0, watcher);
}
// ...
}
}

可以看到,这时候 flushing 为 true,就会执行到 else 的逻辑,然后就会从后往前找,找到第一个待插入 watcher 的 id 比当前队列中 watcher 的 id 大的位置。把 watcher 按照 id 的插入到队列中,因此 queue 的长度发生了变化。

  • 状态恢复
    这个过程就是执行 resetSchedulerState 函数,它的定义在 src/core/observer/scheduler.js 中。
1
2
3
4
5
6
7
8
9
10
11
/**
* Reset the scheduler's state.
*/
function resetSchedulerState() {
index = queue.length = activatedChildren.length = 0;
has = {};
if (process.env.NODE_ENV !== "production") {
circular = {};
}
waiting = flushing = false;
}

逻辑非常简单,就是把这些控制流程状态的一些变量恢复到初始值,把 watcher 队列清空。

接下来我们继续分析 watcher.run() 的逻辑,它的定义在 src/core/observer/watcher.js 中。

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
class Watcher {
...
run() {
if (this.active) {
const value = this.get();
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value;
this.value = value;
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue);
} catch (e) {
handleError(
e,
this.vm,
`callback for watcher "${this.expression}"`
);
}
} else {
this.cb.call(this.vm, value, oldValue);
}
}
}
}
...
}

run 函数逻辑也很简单,先通过 this.get() 得到它当前的值,然后做判断,如果满足新旧值不等、新值是对象类型、deep 模式任何一个条件,则执行 watcher 的回调,注意回调函数执行的时候会把第一个和第二个参数传入新值 value 和旧值 oldValue,这就是当我们添加自定义 watcher 的时候能在回调函数的参数中拿到新旧值的原因。

那么对于渲染 watcher 而言,它在执行 this.get() 方法求值的时候,会执行 getter 方法:

1
2
3
updateComponent = () => {
vm._update(vm._render(), hydrating);
};

总结

通过这一节的分析,我们对 Vue 数据修改派发更新的过程也有了认识,实际上就是当数据发生变化的时候,触发 setter 逻辑,把在依赖过程中订阅的的所有观察者,也就是 watcher,都触发它们的 update 过程,这个过程又利用了队列做了进一步优化,在 nextTick 后执行所有 watcher 的 run,最后执行它们的回调函数。nextTick 是 Vue 一个比较核心的实现了,下一节我们来重点分析它的实现。

参考文献