瀏覽器事件迴圈機制
先上一段簡單的程式碼
console.log('aa');
setTimeout(() => {
console.log('bb')},
0);
Promise.resolve().then(() => console.log('cc'));
複製程式碼
執行結果總是如下:
aa
cc
bb
複製程式碼
為什麼呢?為什麼同樣是非同步,Promise.then 就是 比 setTimeout 先執行呢。
這就涉及到瀏覽器事件迴圈機制了。
- 以前瀏覽器只有一類事件迴圈,都是基於當前執行環境上下文, 官方用語叫
browsing-context
連結在此。我們可以理解為一個window
就是一個執行環境上下文,如果有iframe
, 那麼iframe
內就是另一個執行環境了。- 2017年新版的HTML規範新增了一個事件迴圈,就是web workers。這個暫時先不討論。
事件迴圈機制涉及到兩個知識點 macroTask
和 microTask
,一般我們會稱之為巨集任務
和微任務
。不管是macroTask
還是microTask
,他們都是以一種任務佇列
的形式存在。
macroTask
script
(整體程式碼),setTimeout
,setInterval
,setImmediate
(僅IE支援),I/O
,UI-rendering
注:此處的 I/O 是一個抽象的概念,並不是說一定指輸入/輸出,應該包括DOM事件的觸發,例如click事件,mouseover事件等等。這是我的理解,如果有誤,還請指出。
microTask
包括:
Promises
,process.nextTick
,Object.observe
(已廢棄),MutationObserver
(監聽DOM改變)
以下內容摘抄於知乎何幻的回答
一個瀏覽器環境(unit of related similar-origin browsing contexts.)只能有一個事件迴圈(Event loop),而一個事件迴圈可以多個任務佇列(Task queue),每個任務都有一個任務源(Task source)。
相同任務源的任務,只能放到一個任務佇列中。
不同任務源的任務,可以放到不同任務佇列中。
對上面的幾句話進行總結:事件迴圈只有一個,圍繞著呼叫棧,
macroTask
,microTask
。macroTask
和microTask
是一個大的任務容器,裡面可以有多個任務佇列。不同的任務源
,任務會被放置到不同的任務佇列
。那任務源是什麼呢,比如setTimeout
,setInterval
,setImmediate
,這都是不同的任務源,雖然都是在macroTask
中,但肯定是放置在不同的任務佇列中的。 最後,具體瀏覽器內部怎麼對不同任務源的任務佇列進行排序和取數,這個目前我還不清楚,如果正在看文章的你知道的話,請告訴下我。
接下來我們繼續分析macroTask
和 microTask
的執行順序,這兩個佇列的行為與瀏覽器具體的實現有關,這裡只討論被業界廣泛認同和接受的佇列執行行為。
macroTask
和 microTask
的迴圈順序如下:
注意: 整體程式碼算一個 macroTask
- 先執行一個
macroTask
任務(例如執行整個js檔案內的程式碼) - 執行完
macroTask
任務後,找到microTask
佇列內的所有
任務,按先後順序取出並執行 - 執行完
microTask
內的所有任務後,再從macroTask
取出一個
任務,執行。 - 重複:2,3 步驟。
現在,我們來解釋文章開始時的那串程式碼,為什麼Promise
總是優先於setTimeout
console.log('aa');
setTimeout(() => {
console.log('bb')},
0);
Promise.resolve().then(() => console.log('cc'));
複製程式碼
- 瀏覽器載入整體程式碼並執行算一個
macroTask
- 在執行這段程式碼的過程中,解析到
setTimeout
時,會將setTimeout內的程式碼
新增到macroTask
佇列中。 - 接下來,又解析到
Promise
, 於是將Promise.then()內的程式碼
新增到microTask
佇列中。 - 程式碼執行完畢,也就是第一個
macroTask
完成後,去microTask
任務佇列中,找出所有任務並執行, 此時執行了console.log('cc')
; microTask
任務佇列執行完畢後,又取出下一個macroTask
任務並執行,也就是執行setTimeout
內的程式碼console.log('bb')
從廣義上一句話總結: 一個
巨集任務
執行完後,會執行完所有的微任務
,再又執行一個巨集任務
。依此迴圈,這也就是事件迴圈。
如果對事件迴圈機制還是不怎麼理解的話,可以看下這篇文章,圖文並茂,講的挺細的。
Vue nextTick函式的實現
呼叫 nextTick
的方式
// 第一種,Vue全域性方法呼叫
Vue.nextTick(fn, context);
// 第二種,在例項化vue時,內部呼叫
this.$nextTick(fn);
複製程式碼
其實這兩種方式都是呼叫的 Vue 內部提供的一個nextTick 方法,Vue內部對這個方法做了些簡單的封裝
// src/core/instance/render.js ---- line 57
// 這裡呼叫 nextTick 時自動把當前vue例項物件作為第二個引數傳入,所以我們呼叫 this.$nextTick時,不需要傳第二個引數
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
};
// src/core/global-api/index.js ---- line 45
// 直接將 nextTick 暴露出去,作為Vue全域性方法
Vue.nextTick = nextTick;
複製程式碼
也就是說,這兩種呼叫方式,都是執行的Vue內部提供的nextTick
方法。這個nextTick
方法,Vue用了一個單獨的檔案維護。
檔案在vue專案下 src/core/util/next-tick.js
flushCallbacks - 執行回撥
首先檔案頭部,定義了一個觸發回撥的函式 flushCallbacks
。
這個flushCallbacks
永遠是被非同步執行的。至於為什麼,接下來會講到。
const callbacks = []
let pending = false
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
複製程式碼
這部分程式碼的意思,就是依次觸發 callbacks
內的函式。那麼 callbacks
陣列是存放什麼的?其實就是存放我們呼叫this.$nextTick(fn)
是傳入的fn
,只不過對它做了一層作用域包裝和異常捕獲。
nextTick 函式的定義
nextTick 函式 定義在檔案的末尾,程式碼如下。注意看我加的註釋。
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 將傳入的函式包裝一層,繫結作用域,並try-catch捕獲錯誤
// 如果沒傳入函式,且瀏覽器原生支援 Promise 的情況下,讓 Promise resolve;
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// pending 是一個開關,每次執行 flushCallbacks 後,會將 pending 重置為 fasle
if (!pending) {
pending = true
if (useMacroTask) {
// 以 macroTask 的方式,執行 flushCallbacks
// 這裡雖然程式碼是執行了,但是 macroTimerFunc 內部的程式碼是非同步執行,這個點很關鍵
macroTimerFunc()
} else {
// 以 microTask 的方式,執行 flushCallbacks
// 這裡雖然程式碼是執行了,但是 microTimerFunc 內部的程式碼是非同步執行,這個點很關鍵
microTimerFunc()
}
}
// $flow-disable-line
// 這裡返回一個 Promise, 所以我們可以這樣呼叫,$this.nextTick().then(xxx)
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
複製程式碼
上面的程式碼的 pending
有點意思, 它是Vue做的一個效能優化吧。用來處理同時呼叫多個 nextTick 的業務場景, 例如
new Vue({
// 省略
created() {
// 執行第一個時,首先 fn1 會被 push 進 callbacks,再往下走
// pending 為 false, 所以會進入 if (!pending),然後 pending 被設為true, 執行 macroTimerFunc 或 microTimerFunc
this.$nextTick(fn1);
// 執行第二 個時,pending為true,這時就不會進入 if (!pending) 了,
// 但是 callbacks.push 是會執行的,也就是說會把 fn2 push進 callbacks 陣列
this.$nextTick(fn2);
// 同第二個
this.$nextTick(fn3);
}
})
複製程式碼
如果是這樣呼叫, 那麼Vue會怎麼做呢,Vue是會將這三個fn
全部push
到callbacks
,在下次執行macroTask
或microTask
的任務時,一起執行的。
原因是因為第一次執行 this.$nextTick
時,無論是執行的macroTimerFunc
還是microTimerFunc
, flushCallbacks
都是被非同步執行,macroTimerFunc
是用macroTask
的方式,而microTimerFunc
是用microTask
的方式。例如:
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
microTimerFunc = () => {
Promise.resolve().then(flushCallbacks);
}
複製程式碼
所以這三個this.$nextTick
執行完後,其實就相當於往callbacks
內push
了三個fn
。在下次執行macroTask
或microTask
的任務時,flushCallbacks
內的程式碼才會執行,也就是執行我們傳入的fn
。
因為
一個
巨集任務
執行完後,會執行完所有的微任務
,再又執行一個巨集任務
。依此迴圈
看到這裡的同學估計會有個疑問點,useMacroTask
是什麼,macroTimerFunc
是什麼, microTimerFunc
又是什麼。接下來會一一解開。
useMacroTask
// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using (macro) tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use microtask by default, but expose a way to force (macro) task when
// needed (e.g. in event handlers attached by v-on).
let microTimerFunc
let macroTimerFunc
let useMacroTask = false
複製程式碼
這裡的註釋需要仔細看下,翻譯摘抄於這裡, 大致意思如下
在
Vue2.4
之前的版本中,nextTick
幾乎都是基於microTask
實現的,但是由於microTask
的執行優先順序非常高,在某些場景之下它甚至要比事件冒泡還要快,就會導致一些詭異的問題;但是如果全部都改成macroTask
,對一些有重繪和動畫的場景也會有效能的影響。所以最終nextTick
採取的策略是預設走microTask
,對於一些DOM
的互動事件,如v-on
繫結的事件回撥處理函式的處理,會強制走macroTask
。
useMacroTask
表示是否啟用 macroTask
的方式執行回撥。
macroTimerFunc
接下來,macroTimerFunc
的定義是,在 下一個macroTask
中執行 flushCallbacks
// 優先 setImmediate,
// 然後是 MessageChannel
// 最後才是 setTimeout
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
複製程式碼
為什麼採用的順序是 setImmediate --> MessageChannel --> setTimeout ? 原因是因為:
在支援
MessageChannel
和setImmediate
的情況下,他們的執行順序是優先於setTimeout
的(在IE11/Edge
中,setImmediate
延遲可以在1ms
以內,而setTimeout
有最低4ms
的延遲,所以setImmediate
比setTimeout(0)
更早執行回撥函式。
MessageChannel
的延遲也是會小於setTimeout
的, 有人比較過。 至於MessageChannel
和setImmediate
誰快誰慢,這個我不清楚。
microTimerFunc
再是microTimerFunc
的定義是,如果瀏覽器支援原生 Promise 的話,在 下一個microTask
中執行 flushCallbacks
// 如果瀏覽器支援原生 Promise 的話,把 flushCallbacks 放入 microTask 中執行
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
// 這時為了處理 iOS microtask 沒有被重新整理的bug
if (isIOS) setTimeout(noop)
}
} else {
// 如果沒有Promise,就把 macroTimerFunc 賦值 給 microTimerFunc, 也就是在 `macroTask` 中執行 `flushCallbacks`
microTimerFunc = macroTimerFunc
}
複製程式碼
withMacroTask
withMacroTask 是DOM事件函式的一個包裝器, Vue給DOM新增事件時,會用到它。
這個方法就是為了解決 Vue 2.4
版本之前 nextTick
的bug。
/**
* Wrap a function so that if any code inside triggers state change,
* the changes are queued using a (macro) task instead of a microtask.
*/
export function withMacroTask (fn: Function): Function {
return fn._withTask || (fn._withTask = function () {
// 注意這裡,這裡開啟了useMacroTask,
// 也就是說,如果是通過DOM事件新增的程式碼,程式碼內就算有nextTick,那nextTick內的程式碼也會被強制走 macroTask 方式
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
// 在這裡會被呼叫
// src/platforms/web/runtime/modules/events.js ---- line41
function add (
event: string,
handler: Function,
once: boolean,
capture: boolean,
passive: boolean
) {
handler = withMacroTask(handler)
if (once) handler = createOnceHandler(handler, event, capture)
target.addEventListener(
event,
handler,
supportsPassive
? { capture, passive }
: capture
)
}
複製程式碼
至此,Vue nextTick
的流程算是分析完了。
這些分析都是我看了原始碼和一些文章後的個人理解,如果有誤的話,請道友指出。謝謝。
最後上一段程式碼,出自Google 2018GDD大會,歡迎探討並說出原因。
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 1'))
console.log('listener 1')
})
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 2'))
console.log('listener 2')
})
1. 手動點選,結果是什麼
2. 用測試程式碼 button.click() 觸發,結果是什麼
複製程式碼
答案在這篇文章
參考並推薦幾篇好文: