一、官方定义
实际上在弄清楚浏览器的事件循环后,Vue的nextTick就非常好理解了,它就是利用了事件循环的机制。我们首先来看看nextTick在Vue官方文档中是如何描述的:
简单来说,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方法。接下来的步骤总结如下:
重点来到了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代码流程总结如下:
到这里我们明白了,原来在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更新后状态的。