1000字帶你掌握nextTick背后的原理

一、引言
在開發(fā)過程中,我們經(jīng)常遇到這樣的問題:我明明已經(jīng)更新了數(shù)據(jù),為什么當我獲取某個節(jié)點的數(shù)據(jù)時,卻還是更新前的數(shù)據(jù)?在視圖更新之后,怎么基于新的視圖進行操作?
舉一個簡單的場景:
<template>
<div>
<p ref="message">{{ msg }}</p>
<button @click="handleClick">updateMsg</button>
</div>
</template>
<script>
export default {
name: 'index',
data () {
return {
msg: 'hello'
}
},
methods: {
handleClick () {
this.msg = 'hello world';
console.log(this.$refs.message.innerText); // hello
}
}
}
</script>
運行上面代碼,可以看到,修改數(shù)據(jù)后并不會立即更新dom,dom的更新是異步的,無法通過同步代碼獲取。雖然此時this.msg已經(jīng)變了 但是dom節(jié)點的值沒有更新,也就是說,變的只是數(shù)據(jù),而視圖節(jié)點的值未更新。所以當這時去獲取節(jié)點的this.$refs.message.innerText時,拿到的還是原來的數(shù)據(jù)。那問題來了,我啥時候才能拿到更新的數(shù)據(jù)呢?????????
答:如果我們需要獲取數(shù)據(jù)更新后的dom信息,比如動態(tài)獲取dom的寬高、位置等,就需要使用nextTick。
handleClick () {
this.msg = 'hello world';
this.$nextTick(() => {
console.log(this.$refs.message.innerText) // hello world
})
}

如vue官網(wǎng)的描述:
Vue 在更新 DOM 時是異步執(zhí)行的。只要偵聽到數(shù)據(jù)變化,Vue 將開啟一個隊列,并緩沖在同一事件循環(huán)中發(fā)生的所有數(shù)據(jù)變更。如果同一個 watcher 被多次觸發(fā),只會被推入到隊列中一次。這種在緩沖時去除重復(fù)數(shù)據(jù)對于避免不必要的計算和 DOM 操作是非常重要的。然后,在下一個的事件循環(huán)“tick”中,Vue 刷新隊列并執(zhí)行實際 (已去重的) 工作。Vue 在內(nèi)部對異步隊列嘗試使用原生的
Promise.then、MutationObserver和setImmediate,如果執(zhí)行環(huán)境不支持,則會采用setTimeout(fn, 0)代替。
以上出現(xiàn)了事件循環(huán)的概念,其涉及到JS的運行機制,包括主線程的執(zhí)行棧、異步隊列、異步API、事件循環(huán)的協(xié)作,我們接下來先簡單了解一下 JS 的運行機制。
二、JS 運行機制
JS 執(zhí)行是單線程的,它是基于事件循環(huán)的。事件循環(huán)大致分為以下幾個步驟:
所有同步任務(wù)都在主線程上執(zhí)行,形成一個執(zhí)行棧(execution context stack)。 主線程之外,還存在一個"任務(wù)隊列"(task queue)。只要異步任務(wù)有了運行結(jié)果,就在"任務(wù)隊列"之中放置一個事件。 一旦"執(zhí)行棧"中的所有同步任務(wù)執(zhí)行完畢,系統(tǒng)就會讀取"任務(wù)隊列",看看里面有哪些事件。那些對應(yīng)的異步任務(wù),于是結(jié)束等待狀態(tài),進入執(zhí)行棧,開始執(zhí)行。 主線程不斷重復(fù)上面的第三步。
主線程的執(zhí)行過程就是一個 tick,而所有的異步結(jié)果都是通過 “任務(wù)隊列” 來調(diào)度。消息隊列中存放的是一個個的任務(wù)(task)。規(guī)范中規(guī)定 task 分為兩大類,分別是 macro task 和 micro task,并且每個 macro task 結(jié)束后,都要清空所有的 micro task。執(zhí)行順序如下:
for (macroTask of macroTaskQueue) {
// 1. Handle current MACRO-TASK
handleMacroTask();
// 2. Handle all MICRO-TASK
for (microTask of microTaskQueue) {
handleMicroTask(microTask);
}
}
接下來,我們來了解一下macro task和 micro task 的重要概念。
2.1 macro task
宏任務(wù),稱為task
macro task作用是為了讓瀏覽器能夠從內(nèi)部獲取javascript / dom的內(nèi)容并確保執(zhí)行棧能夠順序進行。 macro task調(diào)度是隨處可見的,例如解析HTML,獲得鼠標點擊的事件回調(diào)等等。
2.2 micro task
微任務(wù),也稱job
micro task通常用于在當前正在執(zhí)行的腳本之后直接發(fā)生的事情,比如對一系列的行為做出反應(yīng),或者做出一些異步的任務(wù),而不需要新建一個全新的task。 只要執(zhí)行棧沒有其他javascript在執(zhí)行,在每個task結(jié)束時,micro task隊列就會在回調(diào)后處理。在micro task期間排隊的任何其他micro task將被添加到這個隊列的末尾并進行處理。
在瀏覽器環(huán)境中,常見的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate;常見的 micro task 有 MutationObsever 和 Promise.then。
根據(jù) HTML Standard,在每個 task 運行完以后,UI 都會重渲染,那么在 micro task 中就完成數(shù)據(jù)更新,當前 task 結(jié)束就可以得到最新的 UI 了。反之如果新建一個 task 來做數(shù)據(jù)更新,那么渲染就會進行兩次。
micro task的這一特性是做隊列控制的最佳選擇,vue進行DOM更新內(nèi)部也是調(diào)用nextTick來做異步隊列控制。而當我們自己調(diào)用nextTick的時候,它就在更新DOM的那個micro task后追加了我們自己的回調(diào)函數(shù),從而確保我們的代碼在DOM更新后執(zhí)行。
比如一段時間內(nèi),你無意中修改了最初代碼片段中的 msg多次,其實只要最后一次修改后的值更新到DOM就可以了,假如是同步更新的,每次 msg 值發(fā)生變化,那么都要觸發(fā) setter->Dep->Watcher->update->patch ,這個過程非常消耗性能。
接下來我們就從源碼分析vue中nextTick的實現(xiàn)。
三、nextTick源碼解析及原理
/* @flow */
/* globals MutationObserver */
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'
export let isUsingMicroTask = false
const callbacks = []
let pending = false
/**
* 對所有callback進行遍歷,然后指向響應(yīng)的回調(diào)函數(shù)
* 使用 callbacks 保證了可以在同一個tick內(nèi)執(zhí)行多次 nextTick,不會開啟多個異步任務(wù),而把這些異步任務(wù)都壓成一個同步任務(wù),在下一個 tick 執(zhí)行完畢。
*/
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]( "i")
}
}
// 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).
let timerFunc
// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
/**
* timerFunc 實現(xiàn)的就是根據(jù)當前環(huán)境判斷使用哪種方式實現(xiàn)
* 就是按照 Promise.then和 MutationObserver以及setImmediate的優(yōu)先級來判斷,支持哪個就用哪個,如果執(zhí)行環(huán)境不支持,會采用setTimeout(fn, 0)代替;
*/
// 判斷是否支持原生 Promise
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)
}
isUsingMicroTask = true
// 不支持 Promise的話,再判斷是否原生支持 MutationObserver
} 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)
// 新建一個 textNode的DOM對象,使用 MutationObserver 綁定該DOM并傳入回調(diào)函數(shù),在DOM發(fā)生變化的時候會觸發(fā)回調(diào),該回調(diào)會進入主線程(比任務(wù)隊列優(yōu)先執(zhí)行)
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
// 此時便會觸發(fā)回調(diào)
textNode.data = String(counter)
}
isUsingMicroTask = true
// 不支持的 MutationObserver 的話,再去判斷是否原生支持 setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Promise,MutationObserver, setImmediate 都不支持的話,最后使用 setTimeout(fun, 0)
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
// 該函數(shù)的作用就是延遲 cb 到當前調(diào)用棧執(zhí)行完成之后執(zhí)行
export function nextTick (cb?: Function, ctx?: Object) {
// 傳入的回調(diào)函數(shù)會在callbacks中存起來
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// pending是一個狀態(tài)標記,保證timerFunc在下一個tick之前只執(zhí)行一次
if (!pending) {
pending = true
/**
* timerFunc 實現(xiàn)的就是根據(jù)當前環(huán)境判斷使用哪種方式實現(xiàn)
* 就是按照 Promise.then和 MutationObserver以及setImmediate的優(yōu)先級來判斷,支持哪個就用哪個,如果執(zhí)行環(huán)境不支持,會采用setTimeout(fn, 0)代替;
*/
timerFunc()
}
// 當nextTick不傳參數(shù)的時候,提供一個Promise化的調(diào)用
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
先來看 nextTick函數(shù)。傳入的回調(diào)函數(shù)會在callbacks中存起來,根據(jù)一個狀態(tài)標記 pending 來判斷當前是否要執(zhí)行 timerFunc()。
timerFunc() 是根據(jù)當前環(huán)境判斷使用哪種方式實現(xiàn),按照 Promise.then和 MutationObserver以及setImmediate的優(yōu)先級來判斷,支持哪個就用哪個,如果執(zhí)行環(huán)境不支持,就會降級為 setTimeout 0,盡管它有執(zhí)行延遲,可能造成多次渲染,算是沒有辦法的辦法了。timerFunc()函數(shù)中會執(zhí)行 flushCallbacks函數(shù)。
flushCallbacks 的邏輯非常簡單,對 callbacks遍歷,然后執(zhí)行相應(yīng)的回調(diào)函數(shù)。
Tips:這里使用
callbacks而不是直接在nextTick中執(zhí)行回調(diào)函數(shù)的原因是保證在同一個tick內(nèi)多次執(zhí)行nextTick,不會開啟多個異步任務(wù),而把這些異步任務(wù)都壓成一個同步任務(wù),在下一個 tick 執(zhí)行完畢。
當nextTick不傳cb參數(shù)時,會提供一個Promise化的調(diào)用,比如:
nextTick().then(() => {})
這是因為nextTick中有這樣一段邏輯:
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
當 _resolve 函數(shù)執(zhí)行,就會跳到 then 的邏輯中。
四、總結(jié)
以上就是vue的nextTick方法的實現(xiàn)原理了,總結(jié)一下就是:
vue用 異步隊列的方式來控制DOM更新和nextTick回調(diào)先后執(zhí)行microtask因為其高優(yōu)先級特性,能確保隊列中的微任務(wù)在一次事件循環(huán)前被執(zhí)行完畢因為兼容性問題,vue不得不做了 microtask向macrotask的降級方案
通俗來講,原理就是使用宏任務(wù)或微任務(wù)來完成事件調(diào)用的機制,讓自己的回調(diào)事件在一個eventloop的最后執(zhí)行。宏任務(wù)或微任務(wù)根據(jù)瀏覽器情況采取不同的api,在通俗一點 ,可以把nextTick想象成為setTimeout 你就是要把這個事件放到本次事件的循環(huán)末尾調(diào)用
Vue是異步更新DOM的,在平常的開發(fā)過程中,我們可能會需要基于更新后的 DOM 狀態(tài)來做點什么,比如后端接口數(shù)據(jù)發(fā)生了變化,某些方法是依賴于更新后的DOM變化,這時我們就可以使用 Vue.nextTick(callback)方法。
五、參考文獻
Vue.js 技術(shù)揭秘 全面解析Vue.nextTick實現(xiàn)原理 事件循環(huán):微任務(wù)與宏任務(wù)
