❝古人云:知其然知其所以然
❞
前言
相信有很多开发第一次碰到vue中的$nextTick()时都会把它当作一次setTimeout()调用。这个理解是对的吗?
1. 前置知识
这个方法理解起来并不难,但需要知道下面的概念:
- 调用栈
- 任务队列
- 事件循环
下面假设大家对这些概念已经非常清楚了。
$nextTick()
1. 概念
掌握一个知识点的背后原理,就必须对它的使用要非常熟悉。来看官方的介绍。
❝vm.$nextTick([callback])
❞
- 参数:{function} [callback]
- 用法:将回调延迟到下次
DOM更新循环之后执行。在修改数据之后立即使用它,然后等待DOM更新。回调的this自动绑定到调用它的实例上。
new Vue({
// ...
methods: {
// ...
example: function () {
// 修改数据
this.message = 'changed'
// DOM 还没有更新
this.$nextTick(function () {
// DOM 现在更新了
// `this` 绑定到当前实例
this.doSomethingElse()
})
}
}
})
上面有一句话说"「将回调延迟到下次DOM更新循环之后在执行」"。这句话的意思是:
Vue在更新DOM时是异步执行的。只要侦听到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个watcher被多次触发,只会被推入到队列中一次。然后,在下一个的事件循环中,Vue刷新队列并执行实际的工作。
例如,当设置vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环中更新。但是如果想基于更新后的DOM状态来做点什么,这就有点难办了。所以Vue就推出了$nextTick(),此方法接收的回调函数将在DOM更新完成后被调用。
Vue文档上也写了:
❝❞
Vue在内部对异步队列尝试使用原生的Promise.then、MutationObserver和setImmediate,如果执行环境不支持,则会采用setTimeout(fn, 0)代替。
所以将$nextTick()当作一次setTimeout()调用,并不能说是错的。只是没有那么准确。
2. 源码解读
$nextTick()是在/src/core/instance/render.js中定义的:
export function renderMixin (Vue: Class<Component>) {
...
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this)
}
...
}
$nextTick()方法是在renderMixin函数中挂载到Vue原型上的。可以看出$nextTick()是对nextTick函数的简单封装。
而nextTick函数是在/src/core/util/next-tick.js中定义的。next-tick.js文件中主体是一段4层if else语句。
if () {
...
} else if () {
...
} else if () {
...
} else {
...
}
「第一层」
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// In problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
}
首先判断当前环境支不支持Promise,如果支持则优先使用Promise。
总所周知,任务队列并非只有一个队列,总的来说可以将其分为微任务--microtask和宏任务--macrotask。当调用栈空闲后,事件循环就会在宏任务消息队列中读取一个任务并执行。宏任务执行的过程中,有时候会产生多个微任务,将其保存在微任务队列中。也就是说每个宏任务都关联了一个微任务队列。当主函数执行结束之后、当前宏任务结束之前,事件循环就会将当前微任务队列执行并清空。
另外两个宏任务之间可能穿插着UI的重渲染,那么只需要在微任务中把所有UI重渲染之前把需要更新的数据全部更新,这样只需要一次重渲染就能得到最新的DOM了。
对于vue来说,vue是一个数据驱动的框架,要是能在UI重渲染之前更新所有数据状态,这对性能的提升是一个非常大的帮助,所以要优先使用微任务去更新数据状态而不是宏任务,这就是为什么优先使用promise,而不是setTimeout的原因。
「接着解读:」
首先定义常量 p 它的值是一个立即 resolve 的 Promise 实例对象。
接着将变量 timerFunc 定义为一个函数,这个函数的执行将会把 flushCallbacks 函数注册为微任务。
「接着」
// In problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
注释说,在一些UIWebViews中微任务没有被刷新,解决方案就是让浏览器做一些其他的事件,比如注册一个宏任务,即使这个宏任务什么都不做,这样就能间接触发微任务的刷新。
「第二层」
如果当前环境不支持Promise(IE:看我干嘛),走到第二层else if语句中。
else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
// (#6466 MutationObserver is unreliable in IE11)
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
}
判断当前环境是否支持MutationObserver。
MutationObserver也是一个微任务,提供了监视对DOM树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是DOM3 Events规范的一部分。
首先将flushCallbacks传入MutationObserver构造函数中,创建并返回一个新的MutationObserver它会在指定的DOM发生变化时被调用。
const observer = new MutationObserver(flushCallbacks)
然后创建一个根据counter变量的文本DOM节点,并配置MutationObserver订阅此DOM节点,所以当这个DOM节点变化时,flushCallbacks就会注册在微任务队列。
let counter = 1
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
最后将timeFun注册为一个函数,当timeFun执行时,立即更改counter的值,从而引起MutationObserver的更改,将flushCallbacks注册在微任务队列中。
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
「第三层」
接着解读第三层else if语句
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Techinically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks)
}
}
可以看出setImmediate函数优先setTimeout函数。这是因为setImmediate比setTimeout有更好的性能。
setTimeout将回调函数注册在宏任务队列中之前要不断的做超时检测,而setImmediate不需要。但是setImmediate有明显的缺陷,只有IE实现了它。
「第四层」
最后第四层else语句,就轮到setTimeout出场了。
else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
3. 非浏览器环境
翻查源码的过程中,发现nextTick函数的定义不只有一处地方。在packages/weex-vue-framework/factory.js中也定义了。
也不难理解,因为上面介绍的都是基于是浏览器环境的,weex是运行在node环境下的。
也来看看这个nextTick定义与上面的有什么不同。
主体是围绕着mircotask与marcotask进行,也就是分别定义宏任务与微任务。
var macroTimerFunc;
if () {
macroTimerFunc = function () {...}
} else if () {
macroTimerFunc = function () {...}
} else {...}
var microTimerFunc;
if () {
microTimerFunc = function () {...}
} else {
microTimerFunc = macroTimerFunc
}
可以看到macroTimerFunc有三层if判断,microTimerFunc有两层。
「macroTimerFunc」
先看macroTimerFunc
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = function () {
setImmediate(flushCallbacks);
};
}
优先判断当前环境是否支持setImmediate,最后的else才是使用setTimeout。
else {
/* istanbul ignore next */
macroTimerFunc = function () {
setTimeout(flushCallbacks, 0);
};
}
「MessageChannel」
而中间的else if是判断是否支持MessageChannel
else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = flushCallbacks;
macroTimerFunc = function () {
port.postMessage(1);
};
}
了解过Web Workers都知道,Web Workers的内部实现就是用到MessageChannel。一个MessageChannel实例对象拥有两个属性port1和port2,只要让port1监听onmessage事件,然后使用port2的postMessage向port1发送消息即可,这样port1的onmessage回调就会被注册为宏任务,由于它也不需要任何检测工作,所以性能也比setTimeout要好。
总之macroTimerFunc函数的作用就是将flushCallbacks注册为宏任务。
「microTimerFunc」
举一反三,microTimerFunc函数的作用就是将flushCallbacks注册为微任务。
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
microTimerFunc = function () {
p.then(flushCallbacks);
// in problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) { setTimeout(noop); }
};
} else {
// fallback to macro
microTimerFunc = macroTimerFunc;
}
如果不支持Promise,那么microTimerFunc = macroTimerFunc;。
4. nextTick
最后,真正的看一下nextTick函数的主体
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// 忽略
}
nextTick函数会在callbacks数组中添加一个新的函数,callbacks数组定义在文件头部:const callback = []。注意并不是将cb回调函数直接添加到callbacks数组中,会使用一个新的函数包裹回调函数并将新函数添加到callbacks数组中。但这个被添加到callbacks数组中的函数执行会间接调用cd回调函数,并且可以看到cb函数时使用.call方法将函数cb的作用域设置为ctx,也就是nextTick函数的第二个参数。所以对于$nextTick方法来讲,传递给$nextTick方法的回调函数的作用域是当前组件实例对象,前提是回调函数不能是箭头函数,其实在平时的使用中,回调函数使用箭头函数也没关系,只要达到目的就行。
「继续看源码」
export function nextTick (cb?: Function, ctx?: Object) {
...
if (!pending) {
pending = true
timerFunc()
}
...
}
进行一个if条件判断,判断pending的真假,pending变量定义在文件头部:let pending = false,它是一个标识,它的真假代表回调队列是否处于等待刷新的状态,初始值为false代表回调队列为空不需要等待刷新。假如此时在某个地方调用了$nextTick方法,那么if语句块内的代码将会被执行,在if语句块内优先将变量pending的值设置为true,代表着此时回调队列不为空,正在等待刷新。既然等待刷新,那么当然要刷新回调队列。这时就用到前面的timerFunc函数。在week中则是micTimeFunc或者marcoTaskFunc。无论是哪种任务类型,它们都将会等待调用栈清空之后才执行。
「举例:」
created () {
this.$nextTick( () => {console.log(1)})
this.$nextTick( () => {console.log(2)})
this.$nextTick( () => {console.log(3)})
}
在created钩子函数中连续调用三次$nextTick方法,但只有第一次调用$nextTick方法时才会执行timerFunc函数将flushCallbacks注册为微任务,但此时flushCallbacks函数并不会执行,因为它要等待接下来的两次$nextTick方法的调用语句执行完后才会执行,准确的说等待调用栈被清空之后才会进行。也就是说flushCallbacks函数执行的时候,callbacks回调队列中将包含本次事件循环所收集的所有通过$nextTick方法注册的回调,而接下来的任务就是在flushCallbacks函数内将这些回调全部执行并清空。
「下面是flushCallbacks的源码」
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
首先将变量pending设置为false,接着开始执行回调,但需要注意的是在执行callbacks队列中的回调函数时并没有直接遍历callbacks数组,而是使用copies常量保存一份复制品。然后遍历copies数组,并且在遍历copies数值之前将callbacks数组清空:callbacks.length = 0。
为什么要这样做呢?「举个例子」
created () {
this.age = 20
this.$nextTick(() => {
this.age = 21
this.$nextTick(() => { console.log('第二个nextTick')})
})
}
在$nextTick()的回调函数中再次调用了$nextTick方法,理论上外层$nextTick方法的回调函数不应该与内层$nextTick的回调函数在同一个微任务中被执行,而是两个不同的微任务,虽然在结果上看或许没什么差别,但从设计角度就应该这样做。
上面代码修改了两次age属性的值,首先将age的值修改为20,上面说到Vue在更新DOM时也是异步执行的,这个过程中就是将flushSchedulerQueue添加到callbacks数组中
callbacks = [
flushSchedulerQueue
]
同时将flushCallbacks函数注册为微任务,所以微任务队列为
// 微任务队列
[
flushCallbacks
]
接着调用第一个$nextTick方法,$nextTick会将回调函数添加到callbacks数组中,那么此时的callbacks数组如下:
callbacks = [
flushSchedulerQueue,
() => {
this.age = 21
this.$nextTick(() => {console.log('第二个$nextTick')})
}
]
接下来主线程出于空闲状态,开始执行微任务队列,即执行flushCallbacks函数,flushCallbacks函数会按照顺序执行callbacks数组中的函数,首先会执行flushSchedulerQueue函数,这个函数会遍历queue中所有观察者并重新求值。接着执行如下函数:
() => {
this.age = 21
this.$nextTick(() => {console.log('第二个$nextTick')})
}
这个函数是第一个$nextTick的回调函数,由于在执行该回调函数之前已经完成了重新求值,所以该回调函数内的代码是能够访问更新后的值。在该回调函数内再次修改age属性的值后,同样会调用nextTick函数将flushSchedulerQueue添加到callbacks数组中,但是由于在执行flushCallbacks函数时优先将pending的设置为false,所以nextTick函数会将flushCallbacks函数注册为一个新的微任务。此时目的就达成了,队列包含两个微任务。
// 此时微任务队列
[
flushCallbacks,
flushCallbacks
]
第二个flushCallbacks函数的一切流程与第一个flushCallbacks是完全相同的。
以上,$nextTick()就介绍完毕。
结尾
更多文章请移步楼主github,如果喜欢请点一下star,对作者也是一种鼓励。