本文通过结合官方文档、源码和其他文章整理后,对Vue的nextTick做深入解析。理解本文最好有浏览器事件循环的基础,建议先阅读上文《事件循环Event loop到底是什么》。
一、官方定义
实际上在弄清楚浏览器的事件循环后,Vue的nextTick就非常好理解了,它就是利用了事件循环的机制。我们首先来看看nextTick在Vue官方文档中是如何描述的:
Vue在更新DOM时是异步执行的,只要侦听到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个watcher被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和DOM操作是非常重要的。然后,在下一个事件循环“tick”中,Vue刷新队列并执行实际(已去重的)工作。Vue在内部对异步队列尝试使用原生的Promise.then、MutationObserver和setImmediate,如果执行环境不支持,则会采用setTimeout(fn,0)代替。
当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。
简单来说,Vue为了保证数据多次变化操作DOM更新的性能,采用了异步更新DOM的机制,且同一事件循环中同一个数据多次修改只会取最后一次修改结果。而这种方式产生一个问题,开发人员无法通过同步代码获取数据更新后的DOM状态,所以Vue就提供了Vue.nextTick方法,通过这个方法的回调就能获取当前DOM更新后的状态。
但只看官方解释可能还是会有些疑问,比如描述中说到的下一个事件循环“tick”是什么意思?为什么会是下一个事件循环?接下来我们看源码到底是怎么实现的。
二、源码解析
Vue.nextTick的源码部分主要分为Watcher部分和NextTick部分,由于Watcher部分的源码在前文《深入解析vue响应式原理》中,已经详细分析过了,所以这里关于Watcher的源码就直接分析触发update之后的部分。
update
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
queueWatcher
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}
flushSchedulerQueue
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
queue.sort((a, b) => a.id - b.id)
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
// in dev build, check and stop circular updates.
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
}
// keep copies of post queues before resetting state
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
resetSchedulerState()
// call component updated and activated hooks
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)
// devtool hook
/* istanbul ignore if */
if (devtools && config.devtools) {
devtools.emit('flush')
}
}
根据前文《深入解析vue响应式原理》可以知道,数据变化后会首先触发关联Dep的notify方法,然后会调用所有依赖该数据的Watcher.update方法。接下来的步骤总结如下:
- update又调用了queueWatcher方法;
- queueWatcher方法中使用静态全局Watcher数组queue来保存当前的watcher,并且如果Watcher重复,只会保留一次;
- 然后是flushSchedulerQueue方法,简单来说,flushSchedulerQueue方法中主要就是遍历queue数组,依次执行了所有的Watcher.run,操作DOM更新;
- 但flushSchedulerQueue并不会立即执行,而是作为nextTick参数进入下一层。
重点来到了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()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
timerFunc
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
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)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
flushCallbacks
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
nextTick代码流程总结如下:
- 结合前面代码分析来看,遍历Watcher执行DOM更新的方法传入了nextTick,在nextTick中被添加到了callbacks数组,随后执行了timerFunc方法;
- timerFunc方法使用了flushCallbacks方法,flushCallbacks执行了flushSchedulerQueue方法,即执行Watcher关联的DOM更新。
- 而timerFunc是根据浏览器支持情况,将flushCallbacks(DOM更新操作)作为参数传递给Promise.then、MutationObserver、setImmediate或setTimeout(fn,0)。
到这里我们明白了,原来在Vue中数据变更触发DOM更新操作也是使用了nextTick来实现异步执行的,而Vue提供给开发者使用的nextTick是同一个nextTick。所以官方文档强调了要在数据变化之后立即使用 Vue.nextTick(callback),这样就能保证callback是插入队列里DOM更新操作的后面,并在同一个事件循环中按顺序完成,因为开发者插入的callback在队尾,那么始终是在DOM操作后立即执行。
而针对官方文档“在下一个事件循环”tick”中,Vue刷新队列并执行实际(已去重的)工作”的描述我觉得是不够严谨的,原因在于,根据浏览器的支持情况,结合浏览器事件循环宏任务和微任务的概念,如果nextTick使用的是Promise.then或MutationObserver,那就是和script(整体代码)是同一个事件循环;如果使用的是setImmediate或setTimeout(fn,0)),那才在下一个事件循环。
同时,聪明的你或许已经想到了,那按这个原理实际我不需要使用nextTick好像也可以达到同样的效果,比如使用setTimeout(fn,0),那我们直接用一个例子来看一下吧。
<template>
<div class="box">{{msg}}</div>
</template>
<script>
export default {
name: 'index',
data () {
return {
msg: 'hello'
}
},
mounted () {
this.msg = 'world'
let box = document.getElementsByClassName('box')[0]
setTimeout(() => {
console.log(box.innerHTML) // world
})
}
}
结果确实符合我们的想象,不过仔细分析一下,虽然能达到同样的效果,但跟nextTick有点细微差异的。这个差异就在于,如果使用nextTick是能保证DOM更新操作和callback是放到同一种任务(宏/微任务)队列来执行的,但使用setTimeout(fn,0)就很可能跟DOM更新操作没有在同一个任务队列,而不在同一事件循环执行。不过这点细微差异目前还没发现有什么问题,反正是可以正确获取DOM更新后状态的。