Vue nextTick 源码解读

Posted by Lxxyx on 2016-09-25

起因

自己第一次用 Vue 做项目时,经常遇到操作 DOM 的问题,但是很多时候因为 Vue 数据更新的特性,是不能在第一时间拿到更新后的 DOM。
后面才观察到,Vue 有一个nextTick方法。
nextTick 的 Api 如下:

对于这句话:

将回调延迟到下次 DOM 更新循环之后执行。在修改数据之后立即使用它,然后等待 DOM 更新。

自己感到十分好奇,因为之前我解决此类问题,使用的是setTimeout(fn, 0)的方式来的。
所以就继续打开 Vue 的源代码,细细研读。

异步更新队列

在 Vue 的文档中,异步更新队列部分有这么一段:

Vue.js 默认异步更新 DOM。每当观察到数据变化时,Vue 就开始一个队列,将同一事件循环内所有的数据变化缓存起来。如果一个 watcher 被多次触发,只会推入一次到队列中。等到下一次事件循环,Vue 将清空队列,只进行必要的 DOM 更新。在内部异步队列优先使用 MutationObserver,如果不支持则使用 setTimeout(fn, 0)。
例如,设置了 vm.someData = ‘new value’,DOM 不会立即更新,而是在下一次事件循环清空队列时更新。
为了在数据变化之后等待 Vue.js 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback) 。回调在 DOM 更新完成后调用。

那么由文档可知,异步更新队列的奥妙则在于MutationObserver
在 MDN 中,对MutationObserver的介绍如下:

MutationObserver 给开发者们提供了一种能在某个范围内的 DOM 树发生变化时作出适当反应的能力.

而在 Vue 的源代码中,则是:

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
/**
* Defer a task to execute it asynchronously. Ideally this
* should be executed as a microtask, so we leverage
* MutationObserver if it's available, and fallback to
* setTimeout(0).
*
* @param {Function} cb
* @param {Object} ctx
*/

export const nextTick = (function () {
var callbacks = [];
var pending = false;
var timerFunc;
function nextTickHandler() {
pending = false;
var copies = callbacks.slice(0);
callbacks = [];
for (var i = 0; i < copies.length; i++) {
copies[i]();
}
}

/* istanbul ignore if */
if (typeof MutationObserver !== 'undefined' && !hasMutationObserverBug) {
var counter = 1;
var observer = new MutationObserver(nextTickHandler);
var textNode = document.createTextNode(counter);
observer.observe(textNode, {
characterData: true,
});
timerFunc = function () {
counter = (counter + 1) % 2;
textNode.data = counter;
};
} else {
// webpack attempts to inject a shim for setImmediate
// if it is used as a global, so we have to work around that to
// avoid bundling unnecessary code.
const context = inBrowser ? window : typeof global !== 'undefined' ? global : {};
timerFunc = context.setImmediate || setTimeout;
}
return function (cb, ctx) {
var func = ctx
? function () {
cb.call(ctx);
}
: cb;
callbacks.push(func);
if (pending) return;
pending = true;
timerFunc(nextTickHandler, 0);
};
})();

解读一下,这是个自执行函数。在MutationObserver存在的情况下,则是这样的:

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
var callbacks = [];
var pending = false;
var timerFunc;
function nextTickHandler() {
pending = false;
var copies = callbacks.slice(0);
callbacks = [];
for (var i = 0; i < copies.length; i++) {
copies[i]();
}
}

var counter = 1;
var observer = new MutationObserver(nextTickHandler);
var textNode = document.createTextNode(counter);
observer.observe(textNode, {
characterData: true,
});
timerFunc = function () {
counter = (counter + 1) % 2;
textNode.data = counter;
};

const nextTick = function (cb, ctx) {
// 如果ctx参数存在,则为回调函数绑定this
var func = ctx
? function () {
cb.call(ctx);
}
: cb;
callbacks.push(func);
if (pending) return;
pending = true;
timerFunc(nextTickHandler, 0);
};

核心的部分为:

1
2
3
4
5
6
7
8
9
10
var counter = 1;
var observer = new MutationObserver(nextTickHandler);
var textNode = document.createTextNode(counter);
observer.observe(textNode, {
characterData: true,
});
timerFunc = function () {
counter = (counter + 1) % 2;
textNode.data = counter;
};

在调用observe时,传入的参数有:

因为 Mutation Observer 则是异步触发,DOM 发生变动以后,并不会马上触发,而是要等到当前所有 DOM 操作都结束后才触发。
调用 timerFunc 时,因为 DOM 操作已经结束,此刻触发注册的回调,就能获取到更新后的回调。

队列更新

在看文档时,也有注意这句话:

Vue.js 默认异步更新 DOM。每当观察到数据变化时,Vue 就开始一个队列,将同一事件循环内所有的数据变化缓存起来。如果一个 watcher 被多次触发,只会推入一次到队列中。等到下一次事件循环,Vue 将清空队列,只进行必要的 DOM 更新。

队列更新的实现则在于Mutation Observerpending状态的配合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var pending = false;

function nextTickHandler() {
pending = false;
var copies = callbacks.slice(0);
callbacks = [];
for (var i = 0; i < copies.length; i++) {
copies[i]();
}
}

const nextTick = function (cb, ctx) {
// 如果ctx参数存在,则为回调函数绑定this
var func = ctx
? function () {
cb.call(ctx);
}
: cb;
callbacks.push(func);
if (pending) return;
pending = true;
timerFunc(nextTickHandler, 0);
};

在这里,pending = true时代表正在等待所有的 DOM 操作结束,等待操作结束时调用nextTick传入的回调,将会被推入队列。从而实现 DOM 更新后,才触发某个队列的回调。
回调触发时,pending 将被设为 false, 队列也将被清空,从而继续实现队列功能。

setTimeout

既然看到了Mutation Observer,源代码中又有setTimeout(fn, 0)
就必须解释下setTimeout(fn, 0)的作用。这个涉及到了 JavaScript 的 EventLoop,还是挺有意思的。
继续看 MDN 的解释:

零延迟 (Zero delay) 并不是意味着回调会立即执行。在零延迟调用 setTimeout 时,其并不是过了给定的时间间隔后就马上执行回调函数。其等待的时间基于队列里正在等待的消息数量。

因此,每次调用setTimeout(fn, 0)时,DOM 的操作已经完成。确保获取的是更新后的 DOM。

setImmediate

在 Node.js 中,有个 setImmediate 的 Api。

在 Node.js 的 Api 中,解释如下:

1
2
3
4
setImmediate(callback[, ...arg])#

callback <Function> The function to call at the end of this turn of the Node.js Event Loop
[, ...arg] Optional arguments to pass when the callback is called.

作用也和setTimeout(fn, 0)类似。

结语

很早之前就想写这篇文章,但是因为各种事情,一直拖到了现在。
今天抽空,一口气写了出来。也算是自己对之前知识的总结。

参考资料:

Vue - 异步更新队列 > HTML5 新特性之 Mutation Observer > MDN - 事件循环 > Node.js - setImmediate


前端路漫漫,且行且歌