先来看一道题:

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout(function() {
console.log(1)
}, 0)
new Promise(function(resolve) {
console.log(2)
resolve()
}).then(function() {
console.log(3)
})
console.log(4)

// 输出结果是 2 4 3 1

大致原因是,浏览器环境中有一个事件循环,它又包含多个任务队列,任务队列又分为两种:

  • macro-task

    像script(整体代码)、setTimeout、setInterval、I/O、UI rendering等。

  • micro-task

    如Promises(说的是原生的,polyfill在浏览器端通常是使用setTimeout实现的)、MutationObserver等。

JavaScript引擎首先从macrotask queue中取出第一个任务(通常是当前代码段),执行完毕后,将microtask queue中的所有任务取出,按顺序全部执行;然后再从macrotask queue中取下一个,执行完毕后,再次将microtask queue中的全部取出;循环往复,直到两个queue中的任务都取完。

为什么要说这些呢? 因为Vue文档里这么说了:

歪闹日志

正是因为Vue里做了这样的优化,当Model层数据发生改变时,dom不会立刻去响应变化。所以当需要正确地获取响应了数据变化之后的DOM时,就要在DOM更新之后去获取。

而如何在当前事件队列执行完毕后尽快响应回调,才是今天要说的内容。借助开头所说的两种任务队列,要实现这个功能不难。Vue中用到了Promise.then和MutationObserver,只有当执行环境不支持时,才降级使用setTimeout代替。

先来个简单的Promises版的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const nextTick = function (fn) {
let p = Promise.resolve()
p.then(fn).catch(err => {
console.error(err)
})
}

// 测试一下
setTimeout(function () {
console.log(1)
}, 0)
console.log(2)
nextTick(function() {console.log(3)})
console.log(4)

// 结果是 2 4 3 1

再看看MutationObserver,它需要监听DOM的变化,我们可以做点小动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const nextTick = function (fn) {
var counter = 1
var observer = new MutationObserver(fn) // 创建监听对象,并传入回调函数
var textNode = document.createTextNode(counter) // 创建一个文本接点
observer.observe(textNode, {
characterData: true // 监听文本接点的字符变化
})
counter = (counter + 1) % 2
textNode.data = counter // 改变文本接点的字符数据
}

// 需要在浏览器环境下测试
setTimeout(function () {
console.log(1)
}, 0)
console.log(2)
nextTick(function() {console.log(3)})
console.log(4)

// 结果是 2 4 3 1

以上实现的nextTick方法已经基本可以满足需求了。只是每次执行的时候都要重新建立一个新的microtask queue。我们可以对其做一个优化,使得同一队列的几个nextTick的回调在一个microtask queue里执行。

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
const nextTick = (function () {
const callbacks = []
let pending = false

function nextTickHandler () {
pending = false // 取消pending
const copies = callbacks.slice(0) // copy执行队列
callbacks.length = 0 // 清空执行队列
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}

let p = Promise.resolve()
let timerFunc = () => {
p.then(nextTickHandler).catch(err => { console.error(err) })
}

return function queueNextTick (cb, ctx) {
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
console.warn(`error in nextTick: "${e.toString()}"`, ctx)
}
}
})
if (!pending) {
pending = true
timerFunc()
}
}
})()

实现方式大概就是这样吧。Vue里还做了一些兼容处理,仅此而已。