每天进步一点点,今天我们一起来看看 nextTick
的源码。
概念
根据官网解释,nextTick 是下次 DOM 更新循环结束之后的回调,我们可以通过它拿到更新后的 DOM ,并做一些操作。
nextTick
有2个,一个是全局的 Vue.nextTick(callback)
,一个是实例上的 this.$nextTick(callback)
,它们的作用都是一样的,只是作用的范围不一样而已。
参数
-
对于全局的
nextTick
,有2个参数,回调函数 和 上下文对象,Vue.nextTick(callback, context)
-
对于实例方法
$nextTick
,只有1个回调函数,this.$nextTick(callback)
两者的参数都是非必填的,在没有提供回调且在支持 Promise
的环境中,会返回一个 Promise
。
用法
使用方法很简单,无非如下 3 种:
1、一般用法
Vue.nextTick(()=>{
// DOM 更新后的逻辑
})
this.$nextTick(()=>{
// DOM 更新后的逻辑
})
2、结合 async/await
,更加简洁
async getData(){
await this.$nextTick();
// DOM 更新后的逻辑
}
3、作为 Promise
使用
this.$nextTick().then(()=>{
// DOM 更新后的逻辑,不传回调函数,默认会返回一个 Promise
})
异步更新队列
在看源码之前,我们先来了解一下什么是 Vue 的异步更新队列。
可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中1次,最终只做1次Dom 更新,这极大的减少了性能开销。
在下一个的事件循环 tick
中,Vue 刷新队列并执行实际 (已去重的) 工作。
Vue 在内部对异步队列尝试使用原生的 Promise.then
、MutationObserver
和 setImmediate
,如果执行环境不支持,则会采用 setTimeout(fn, 0)
代替。
为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)
。这样回调函数将在 DOM 更新完成后被调用。
事件循环
另一个与 nextTick
关系密切的就是 JS 的 事件循环机制。
我们都知道 JS 是单线程语言,一次只能做一件事。为了提升事件处理效率,加入了事件循环机制。
一个简单的事件循环流程图如下:
事件分类
-
同步任务(一切同步的操作)
-
异步任务
- 宏任务(如 setTimeout、setInterval、setImmediate、I/O等)
- 微任务(如 Promise.then、MutationObserver、process.nextTick等)
主线程会优先执行同步任务,当所有同步任务执行完毕后,才会从任务队列里取出异步任务执行。
这里只需要知道以下几点即可:
- 每个宏任务都对应一个微任务队列,宏任务执行完后会立刻执行对应的微任务(当前循环);
- 微任务执行完后,会做更新一次UI,然后继续下一轮循环;
- 微任务的优先级很高,很适合做异步流程控制。
源码
首先,nextTick
源码位于 /src/core/util/next-tick.js
,整体代码很少,我们一起看一下。
初始变量
一开始定义了 如下 3 个变量:
isUsingMicroTask
:是否使用微任务callbacks
:回调函数pending
:是否正在处理
export let isUsingMicroTask = false
const callbacks = []
let pending = false
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)
}
})
if (!pending) {
pending = true
timerFunc()
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
看起来很简单是不是?
- 首先定义一个
_resolve
的标识用来判断回调是否结束; - 然后就是使用
callbacks
收集传入的回调函数,并对其做了异常处理; - 然后会进入
timerFunc
这个方法,我们先不看; - 最后,如果没有传回调函数并且当前环境支持
Promise
,它会返回一个Promise
对象,对应用法里的Promise
方式使用。
timerFunc
在上面,我们看到 nextTick
中间的一个环节调用了 timerFunc
这个方法,他究竟做了什么呢?
我们可以搜索该方法,发现这个方法有多处赋值,整体代码如下(官方注释已翻译):
let timerFunc
// 虽然 MutationObserver 支持度比 Promise 更高,但它在iOS系统( >= 9.3.3)中,触发 touch 事件时会有严重 bug,
// 多次触发甚至会导致无响应,所以优先使用 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
// <微任务> 使用原生 Promise
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// 但在部分IOS系统里,微任务队列不会及时清空,所以手动加个计时器清一下
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// <微任务> 不支持原生 Promise 则用 MutationObserver 代替,但是它在 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)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// <宏任务> 不支持 MutationObserver 则用 setImmediate 代替,虽然是宏任务,但效率优于 setTimeout
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// <宏任务>都不支持就只能用 setTimeout 代替
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
这个一个明显的分支判断,我们分析一下流程
- 最开始会判断当前环境是否原生支持
Promise
,支持的话优先用Promise
- 不支持
Promise
那就看是否支持MutationObserver
- 不支持
MutationObserver
那就看是否支持setImmediate
- 以上都不支持,就只能用
setTimeout
在使用 Promise
和 MutationObserver
时 isUsingMicroTask
会标记为 true,代表使用微任务来做异步流程控制。
而 timerFunc
的作用,只是为了执行回调函数而已,只是在使用不同方法时,需要加入一些特定的处理。
比如使用 MutationObserver
时,会通过监听一个文本节点的内容变化来触发清空操作,充分利用了微任务的高优先级特性。
总之,nextTick
会优先使用微任务(Promise、MutationObserver),不支持相应方法采用宏任务(setImmediate、setTimeout)代替。
flushCallbacks
这个就是清空回调函数队列的方法,它会执行 callbacks
数组里的所有回调并清空它。
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
通过源码里的大片注释,我们发现 nextTick
是个让作者很头痛的东西,在 2.5 版本的 vue 里,为了兼容性考虑,使用 宏任务 + 微任务 的方案来处理,相关的 issue 很多,2.6 版本才统一优先使用 微任务。
// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).
总结
-
Vue 中的Dom更新是异步的(基于事件循环),会等待所有数据都操作完毕后统一更新 UI;
-
在 Vue 2.6+ 中,优先使用 微任务 Promise 来做异步流程控制,实在不行才使用 宏任务 setTimeout 来控制
具体降级流程:
Promise > MutationObserver > SetImmediate > SetTimeoutVue 2.5 版本中则是:
SetImmediate > MessageChannel > SetTimeout -
微任务有很高的优先级,处于宏任务结束后,UI渲染之前,是做异步流程控制的最佳选择,特别是
Promise
。
好啦👌,相信到这里,你们已经对 nextTick
的原理有了一个更深入的认识,如果有对事件循环 EventLoop
不了解的,可以参考文末的相关文章。
评论区