❝古人云:知其然知其所以然
❞
前言
相信有很多开发第一次碰到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,对作者也是一种鼓励。