摘要:本文通過結合官方文件、原始碼和其他文章整理後,對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更新後狀態的。