前置知識:宏任務 macrotask 和 微任務 microtask
任務隊列分爲 microtask 和 macrotask,且在node和不同的瀏覽器環境執行方式不同。
宿主環境提供的叫宏任務,由語言標準提供的叫微任務。
宿主環境:
簡單來說就是能使javascript完美運行的環境,只要能完美運行javascript的載體就是javascript的宿主環境。目前我們常見的兩種宿主環境有瀏覽器和node。宿主環境內所有的內建或自定義的變量/函數都是 global/window 這個全局對象的屬性/方法,而由宿主環境提供的也叫宏任務。
語言標準:
我們都知道JavaScript是一種編程語言,但其實JavaScript由ECMA制定標準,稱之爲ECMAScript,所以由語言標準提供的就是微任務,比如ES6提供的promise。
宏任務:macrotask:setTimeout、setImmediate
微任務: microtask:Promise、MutationObserver
這裏只討論瀏覽器環境下接收範圍比較廣的,例如谷歌瀏覽器:
當調用棧空閒後每次事件循環只會從 macrotask 中讀取一個任務並執行,而在同一次事件循環內會將 microtask 隊列中所有的任務全部執行完畢,且要先於 macrotask。
- MutationObserver 簡單介紹
MutationObserver接口提供了監視對DOM樹所做更改的能力。它被設計爲舊的Mutation Events功能的替代品,該功能是DOM3 Events規範的一部分。
// MutationObserver 創建一個微任務
const observer = new MutationObserver(function(){console.log(666)})
let counter = 1
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
// 設爲true以監視指定目標節點或子節點樹中節點所包含的字符數據的變化
})
counter = (counter + 1) % 2
textNode.data = String(counter)
- macrotask 和 microtask 執行過程實例
console.log('1');
setTimeout(function() {
console.log('2');
new Promise(function(resolve) {
console.log('3');
resolve();
}).then(function() {
console.log('4')
})
setTimeout(()=>{
console.log('5')
})
// MutationObserver 創建一個微任務
let counter = 1
const observer = new MutationObserver(function(){console.log(6)})
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true // 設爲true以監視指定目標節點或子節點樹中節點所包含的字符數據的變化。
})
counter = (counter + 1) % 2
textNode.data = String(counter)
})
// new promise 會立即執行, then會分發到微任務
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
new Promise(function(resolve) {
console.log('10');
resolve();
}).then(function() {
console.log('11')
})
})
引出問題:爲什麼使用$nextTick
在vue項目中,可以用 setTimeout 替換 $nextTick,形如用 setTimeout(cb, 0) 代替 $nextTick(cb),既然可以使用 setTimeout 替換 $nextTick 那麼爲什麼不用 setTimeout 呢?
原因就在於 setTimeout 並不是最優的選擇,$nextTick 的意義就是它會選擇一條最優的解決方案,即優先選擇微任務。
在 macrotask 中兩個不同的任務之間可能穿插着UI的重渲染,那麼我們只需要在 microtask 中把所有在UI重渲染之前需要更新的數據全部更新,這樣只需要一次重渲染就能得到最新的DOM了,所以要優先選用 microtask 去更新數據狀態而不是 macrotask。
nextTick 的實現
- $nextTick
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this)
}
我們常用的 $nextTick 方法實際上就是對 nextTick 函數的封裝。
- 整體結構
打開/src/core/util/next-tick.js文件可以看到文件大體結構如下:
// 從外部導入一些方法 noop、handleError、isIE、isIOS、isNative
import ...
// 聲明一些變量
export let isUsingMicroTask = false // 導出一個變量,標誌是否使用微任務
const callbacks = [] // nextTick的回調函數隊列
let pending = false // 標誌回調隊列callbacks是否處於等待刷新的狀態,初始false,代表回調隊列爲空,不需要等待刷新
// 聲明 flushCallbacks 函數
function flushCallbacks () {...} // 清空回調隊列函數(先進先出),作爲setTimeout、setImmediate、Promise、MutationObserver等的回調函數
// 聲明 timerFunc 函數
let timerFunc // timerFunc 函數採用合適的策略將 flushCallbacks 作爲回調註冊一個微任務或宏任務
if(){...}else if(){...}else if(){...}else{...} // 採用合適的策略補充 timerFunc 函數
// 導出 nextTick 函數主體
export function nextTick (cb, ctx) {...}
- flushCallbacks 函數
// 清空回調隊列函數(先進先出)
// 作爲setTimeout、setImmediate、Promise、MutationObserver等的回調函數
function flushCallbacks () {
pending = false // 將變量 pending 重置爲 false
const copies = callbacks.slice(0) // 備份
callbacks.length = 0 // 清空
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
// 疑點:爲什麼備份並在遍歷 copies 數組之前將 callbacks 數組清空,將pending重置?
}
- timerFunc 函數 - 本部分即最優解的實現
// timerFunc 函數採用合適的策略將 flushCallbacks 作爲回調註冊一個微任務或宏任務
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout({})
}
isUsingMicroTask = true
}
if (isIOS) setTimeout({})
這一行是一個解決怪異問題的變通方法,在一些 UIWebViews 中存在很奇怪的問題,即 microtask 沒有被刷新,對於這個問題的解決方案就是讓瀏覽做一些其他的事情,比如註冊一個 (macro)task, 即使這個 (macro)task 什麼都不做,這樣就能夠間接觸發 microtask 的刷新。
如果宿主環境不支持 Promise,我們就需要降級處理
舊版本是 Promise > setImmediate > MessageChannel > setTimeout
現在最新的版本(dev分支)是 Promise > MutationObserver > setImmediate > setTimeout
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
}
setImmediate 擁有比 setTimeout 更好的性能,因爲 setTimeout 在將回調註冊爲 macrotask 之前要不停的做超時檢測,而 setImmediate 則不需要。
但是 setImmediate 的缺陷也很明顯,就是它的兼容性問題,到目前爲止只有IE瀏覽器實現了它。
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
- nextTick 主體
function nextTick (cb, ctx) {
let _resolve // 用於無回調函數時
//向回調函數隊列添加一個新的函數
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
//對於 $nextTick 方法來講,傳遞給 $nextTick 的回調函數的作用域,
//就是當前組件實例對象
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
// 當 flushCallbacks 函數開始執行 callbacks 數組中的函數時,
// 如果沒有傳遞 cb 參數,則直接調用 _resolve 函數
}
})
// 註冊微任務/宏任務
if (!pending) {
pending = true
timerFunc()
}
pending初始值爲false,第一次調用nextTick時,設pending爲true,代表此時回調隊列不爲空,正在等待刷新,後面再調用nextTick時,就不會再註冊新的微任務/宏任務。
調用timerFunc函數,將flushCallbacks註冊爲微任務/宏任務,但此時 flushCallbacks 函數並不會執行,需等待調用棧被清空之後纔會執行,即實現了等數據準備完(例1中可視爲data1改變後),再實行更新(打印data1)
// 無回調函數情況
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
在使用 $nextTick 方法時是可以省略回調函數這個參數的,這時 $nextTick 方法會返回一個 promise 實例對象。
當 nextTick 函數沒有接收到 cb 參數時,會檢測當前宿主環境是否支持 Promise,如果支持則直接返回一個 Promise 實例對象,並且將 resolve 函數賦值給 _resolve 變量
}
- 例1:解釋nextTick實現過程
var data1 = 'data1'
let cb1 = function(){
// do sth
console.log('this is callback: '+data1)
}
let cb2 = function(){
console.log('this is callback2')
}
console.log(data1)
nextTick(cb1,this)
nextTick(cb2,this) // 此時callback棧內已蒐集所有回調函數
data1="data1 changed"
let p = nextTick() // 無cb
- 例2:回答疑點:爲什麼備份並在遍歷 copies 數組之前將 callbacks 數組清空?
let cb3 = function(){
console.log('this is callback3')
}
var data2 = 'data2'
nextTick(function(){
data2 = 'new data2'
nextTick(cb3,this)
},this)
嵌套nextTick的時候(不推薦這麼寫),使子nextTick行爲不受影響。
第一次執行 flushCallbacks 時,先重置pending並清空callback,再執行函數:
function(){
data2 = 'new data2'
nextTick(cb3,this)
}
當nextTick(cb3,this)執行時,callback已清空,
所以cb3被push後回調隊列爲 [ cb3 ],
pending爲false,
所以會將 flushCallbacks 函數註冊爲一個新的 microtask