前言
nextTick是Vue的一個核心功能,在Vue內部實現中也經常用到nextTick。但是,很多新手不理解nextTick的原理,甚至不清楚nextTick的作用。
那麼,我們就先來看看nextTick是什麼。
nextTick功能
看看官方文件的描述:
在下次 DOM 更新迴圈結束之後執行延遲迴調。在修改資料之後立即使用這個方法,獲取更新後的 DOM。
再看看官方示例:
// 修改資料
vm.msg = 'Hello'
// DOM 還沒有更新
Vue.nextTick(function () {
// DOM 更新了
})
// 作為一個 Promise 使用 (2.1.0 起新增,詳見接下來的提示)
Vue.nextTick()
.then(function () {
// DOM 更新了
})
複製程式碼
2.1.0 起新增:如果沒有提供回撥且在支援 Promise 的環境中,則返回一個 Promise。請注意 Vue 不自帶 Promise 的 polyfill,所以如果你的目標瀏覽器不原生支援 Promise (IE:你們都看我幹嘛),你得自己提供 polyfill。
可以看到,nextTick主要功能就是改變資料後讓回撥函式作用於dom更新後。很多人一看到這裡就懵逼了,為什麼需要在dom更新後再執行回撥函式,我修改了資料後,不是dom自動就更新了嗎?
這個和JS中的Event Loop有關,網上教程不計其數,在此就不再贅述了。建議明白Event Loop後再繼續向下閱讀本文。
舉個實際的例子:
我們有個帶有分頁器的表格,每次翻頁需要選中第一項。正常情況下,我們想的是點選翻頁器,向後臺獲取資料,更新表格資料,操縱表格API選中第一項。
但是,你會發現,表格資料是更新了,但是並沒有選中第一項。因為,你選中第一項時,雖然資料更新了,但是DOM並沒有更新。此時,你可以使用nextTick,在DOM更新後再操縱表格第一項的選中。
那麼,nextTick到底做了什麼了才能實現在DOM更新後執行回撥函式?
原始碼分析
nextTick的原始碼位於src/core/util/next-tick.js,總計118行,十分的短小精悍,十分適合初次閱讀原始碼的同學。
nextTick原始碼主要分為兩塊:
1.能力檢測
2.根據能力檢測以不同方式執行回撥佇列
能力檢測
這一塊其實很簡單,眾所周知,Event Loop分為巨集任務(macro task)以及微任務( micro task),不管執行巨集任務還是微任務,完成後都會進入下一個tick,並在兩個tick之間執行UI渲染。
但是,巨集任務耗費的時間是大於微任務的,所以在瀏覽器支援的情況下,優先使用微任務。如果瀏覽器不支援微任務,使用巨集任務;但是,各種巨集任務之間也有效率的不同,需要根據瀏覽器的支援情況,使用不同的巨集任務。
nextTick在能力檢測這一塊,就是遵循的這種思想。
// Determine (macro) task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
// 如果瀏覽器不支援Promise,使用巨集任務來執行nextTick回撥函式佇列
// 能力檢測,測試瀏覽器是否支援原生的setImmediate(setImmediate只在IE中有效)
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// 如果支援,巨集任務( macro task)使用setImmediate
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
// 同上
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
/* istanbul ignore next */
// 都不支援的情況下,使用setTimeout
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
複製程式碼
首先,檢測瀏覽器是否支援setImmediate,不支援就使用MessageChannel,再不支援只能使用效率最差但是相容性最好的setTimeout了。
之後,檢測瀏覽器是否支援Promise,如果支援,則使用Promise來執行回撥函式佇列,畢竟微任務速度大於巨集任務。如果不支援的話,就只能使用巨集任務來執行回撥函式佇列。
執行回撥函式佇列
執行回撥函式佇列的程式碼剛好在一頭一尾
// 回撥函式佇列
const callbacks = []
// 非同步鎖
let pending = false
// 執行回撥函式
function flushCallbacks () {
// 重置非同步鎖
pending = false
// 防止出現nextTick中包含nextTick時出現問題,在執行回撥函式佇列前,提前複製備份,清空回撥函式佇列
const copies = callbacks.slice(0)
callbacks.length = 0
// 執行回撥函式佇列
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
...
// 我們呼叫的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
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
// $flow-disable-line
// 2.1.0新增,如果沒有提供回撥,並且支援Promise,返回一個Promise
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
複製程式碼
總體流程就是,接收回撥函式,將回撥函式推入回撥函式佇列中。
同時,在接收第一個回撥函式時,執行能力檢測中對應的非同步方法(非同步方法中呼叫了回撥函式佇列)。
如何保證只在接收第一個回撥函式時執行非同步方法?
nextTick原始碼中使用了一個非同步鎖的概念,即接收第一個回撥函式時,先關上鎖,執行非同步方法。此時,瀏覽器處於等待執行完同步程式碼就執行非同步程式碼的情況。
打個比喻:相當於一群旅客準備上車,當第一個旅客上車的時候,車開始發動,準備出發,等到所有旅客都上車後,就可以正式開車了。
當然執行flushCallbacks函式時有個難以理解的點,即:為什麼需要備份回撥函式佇列?執行的也是備份的回撥函式佇列?
因為,會出現這麼一種情況:nextTick套用nextTick。如果flushCallbacks不做特殊處理,直接迴圈執行回撥函式,會導致裡面nextTick中的回撥函式會進入回撥佇列。這就相當於,下一個班車的旅客上了上一個班車。
實現一個簡易的nextTick
說了這麼多,我們來實現一個簡單的nextTick:
let callbacks = []
let pending = false
function nextTick (cb) {
callbacks.push(cb)
if (!pending) {
pending = true
setTimeout(flushCallback, 0)
}
}
function flushCallback () {
pending = false
let copies = callbacks.slice()
callbacks.length = 0
copies.forEach(copy => {
copy()
})
}
複製程式碼
可以看到,在簡易版的nextTick中,通過nextTick接收回撥函式,通過setTimeout來非同步執行回撥函式。通過這種方式,可以實現在下一個tick中執行回撥函式,即在UI重新渲染後執行回撥函式。