從 Event Loop 角度解讀 Vue NextTick 源碼
點(diǎn)擊上方 前端Q,關(guān)注公眾號(hào)
回復(fù)加群,加入前端Q技術(shù)交流群
來源:小卒先生
https://juejin.cn/post/6963542300073033764
解讀背景
在學(xué)習(xí) vue 源碼,nextTick 方法借助了瀏覽器的 event loop 事件循環(huán)做到了異步更新。 在公司面試的時(shí)候,筆試題最喜歡出關(guān)于 JavaScript 運(yùn)行機(jī)制,Promise/A+ 等關(guān)于 event loop 線程的題目。 學(xué)會(huì) nextTick 原理幫助定位 BUG , 使用 Vue 會(huì)更加靈活。
什么是 event loop
先看一張圖 (來自 mr.z 大佬)

先執(zhí)行同步阻塞任務(wù),同步任務(wù)會(huì)等待上一個(gè)執(zhí)行完畢以后執(zhí)行下一個(gè),當(dāng)同步任務(wù)執(zhí)行完畢,再執(zhí)行異步任務(wù),遇到異步任務(wù)會(huì)將異步任務(wù)的回調(diào)函數(shù)注冊(cè)在異步任務(wù)隊(duì)列里。注意,如果主線程上沒有同步任務(wù)會(huì)直接調(diào)用異步任務(wù)的微任務(wù)。 執(zhí)行宏任務(wù),遇到微任務(wù)將都添加到微任務(wù)隊(duì)列里。 開始執(zhí)行微任務(wù)隊(duì)列,當(dāng)宏任務(wù)執(zhí)行完后執(zhí)行微任務(wù)隊(duì)列,直到微任務(wù)隊(duì)列全部執(zhí)行完,微任務(wù)隊(duì)列為空。 執(zhí)行宏任務(wù),如果在執(zhí)行宏任務(wù)期間有微任務(wù),將微任務(wù)添加到微任務(wù)隊(duì)列里,執(zhí)行完宏任務(wù)之后執(zhí)行微任務(wù),直到微任務(wù)隊(duì)列全部執(zhí)行完。 繼續(xù)執(zhí)行宏任務(wù)隊(duì)列。
重復(fù)2, 3, 4,5……直到宏微任務(wù)為空。
$nextTick 的實(shí)現(xiàn)原理
從字面意思理解,next 下一個(gè),tick 滴答(鐘表)來源于定時(shí)器的周期性中斷(輸出脈沖),一次中斷表示一個(gè) tick,也被稱做一個(gè)“時(shí)鐘滴答”),nextTick 顧名思義就是下一個(gè)時(shí)鐘滴答??丛创a,在 Vue 2.x 版本中,nextTick 在 src\core\util 中的一個(gè)單獨(dú)的文件 next-tick.js ,可見 nextTick 的重要性,雖然短短 200 多行,尤大卻單獨(dú)創(chuàng)建一個(gè)文件去維護(hù)。
接下來我們來看整個(gè)文件。
聲明了三個(gè)全局變量,callbacks: [] ,pending: Boolean,timerFunc: undefined。 聲明了一個(gè)函數(shù) flushCallbacks。 一堆 **if,else if **判斷。 拋出了一個(gè)函數(shù) nextTick。
nextTick 函數(shù)

聲明一個(gè)局部變量 _resolve 。 把所有回調(diào)函數(shù)壓進(jìn) callbacks 中,以棧的形式的存儲(chǔ)所有 callback。 當(dāng) pending 為 false 時(shí),執(zhí)行 timerFunc 函數(shù)。 當(dāng)沒有 callback 的時(shí)候,返回一個(gè) Promise 的調(diào)用方式,可以用 .then 接收。
timerFunc 函數(shù)
我們開始說了,timerFunc 為全局變量,現(xiàn)在調(diào)用 timerFunc ,timerFunc 是什么時(shí)候被賦值為一個(gè)函數(shù),并且函數(shù)里執(zhí)行代碼又是什么?
我們看到,這段判斷代碼總共有四個(gè)分支,四個(gè)分支里對(duì) timerFunc 有不同的賦值,我們先來看第一個(gè)分支。
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
}
復(fù)制代碼
判斷環(huán)境是否支持 Promise 并且 Promise 是否為原生。 使用 Promise 異步調(diào)用 flushCallbacks 函數(shù)。 當(dāng)執(zhí)行環(huán)境是 iPhone 等,使用 setTimeout 異步調(diào)用 noop ,iOS 中在一些異常的webview 中,promise 結(jié)束后任務(wù)隊(duì)列并沒有刷新所以強(qiáng)制執(zhí)行 setTimeout 刷新任務(wù)隊(duì)列。
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)
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
}
復(fù)制代碼
對(duì)非IE瀏覽器和是否可以使用 HTML5 新特性 MutationObserver 進(jìn)行判斷。 實(shí)例一個(gè) MutationObserver 對(duì)象,這個(gè)對(duì)象主要是對(duì)瀏覽器 DOM 變化進(jìn)行監(jiān)聽,當(dāng)實(shí)例化 MutationObserver 對(duì)象并且執(zhí)行對(duì)象 observe,設(shè)置 DOM 節(jié)點(diǎn)發(fā)生改變時(shí)自動(dòng)觸發(fā)回調(diào)。 把 timerFunc 賦值為一個(gè)改變 DOM 節(jié)點(diǎn)的方法,當(dāng) DOM 節(jié)點(diǎn)發(fā)生改變,觸發(fā) flushCallbacks 。(這里其實(shí)就是想用利用 MutationObserver 的特性進(jìn)行異步操作)
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)
}
}
復(fù)制代碼
判斷 setImmediate 是否存在,setImmediate 是高版本 IE (IE10+) 和 edge 才支持的。 如果存在,傳入 flushCallbacks 執(zhí)行 setImmediate 。
setTimeout 分支
else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
復(fù)制代碼
當(dāng)以上所有分支異步 api 都不支持的時(shí)候,使用 macro task (宏任務(wù))的 setTimeout 執(zhí)行 flushCallbacks 。
執(zhí)行降級(jí)
我們可以發(fā)現(xiàn),給 timerFunc 賦值是一個(gè)降級(jí)的過程。為什么呢,因?yàn)?Vue 在執(zhí)行的過程中,執(zhí)行環(huán)境不同,所以要適配環(huán)境。

這張圖便于我們更清晰的了解到降級(jí)的過程。
flushCallbacks 函數(shù)
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
復(fù)制代碼
循環(huán)遍歷,按照 隊(duì)列 數(shù)據(jù)結(jié)構(gòu) “先進(jìn)先出” 的原則,逐一執(zhí)行所有 callback 。
總結(jié)
到這里就全部講完了,nextTick 的原理就是利用 Event loop 事件線程去異步重新渲染,分支判斷首要選擇 Promise 的原因是當(dāng)同步JS代碼執(zhí)行完畢,執(zhí)行棧清空會(huì)首先查看 micro task (微任務(wù))隊(duì)列是否為空,不為空首先執(zhí)行微任務(wù)。在我們 DOM 依賴數(shù)據(jù)發(fā)生變化的時(shí)候,會(huì)異步重新渲染 DOM ,但是比如像 echarts ,canvas……這些 Vue 無法在初始狀態(tài)下收集依賴的 DOM ,我們就需要手動(dòng)執(zhí)行 nextTick 方法使其重新渲染。

內(nèi)推社群
我組建了一個(gè)氛圍特別好的騰訊內(nèi)推社群,如果你對(duì)加入騰訊感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行面試相關(guān)的答疑、聊聊面試的故事、并且在你準(zhǔn)備好的時(shí)候隨時(shí)幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。
