前言
首先來看一個JavaScript的代碼片段:
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3)
});
}, 0);
new Promise((resolve, reject) => {
console.log(4)
resolve(5)
}).then((data) => {
console.log(data);
})
setTimeout(() => {
console.log(6);
}, 0)
console.log(7);
如果你能知道正確的答案,那么后續的內容可以略過了;如果不能建議看看下面有關js異步的內容,百利無一害,😁😁。
任務隊列
js的一大特點是單線程,即同一個時間只能做一件事,這樣設計主要與其作為瀏覽器腳本語言有關,js主要用途是用戶交互以及操作dom,這決定其是單線程設計,否則會帶來復雜的同步問題。比如一個線程刪除一個節點,而另一個線程要操作該節點,瀏覽器不知以哪個線程為准。
單線程意味着任務需要排隊,如果前一個任務耗時長,那么就會阻塞后續任務的執行。為此js出現了同步和異步任務,二者都需要在主線程執行棧中執行;其中異步任務需要進入任務隊列(task queue)進行排隊,其具體運行機制如下:
-
同步任務在主線程上執行,形成一個執行棧
-
js會將主線程執行棧中的異步任務置於任務隊列排隊
-
一旦主線程執行棧同步任務執行完畢處於空閑狀態時,就會將任務隊列中任務入棧開始執行
還是先來看一個js片段:
console.log('script start')
setTimeout(function() {
console.log('timeout')
}, 0)
console.log('script end')
這段代碼在進入主線程執行時,當執行到setTimeout時會將其放置到異步任務隊列中,即使設置時間為0也不會馬上執行,必須等到主線程執行棧空閑時(執行完console.log('script end')語句后)才會讀取異步隊列的任務執行。
macrotask與microtask
二者任務都會被放置於任務隊列中等待某個時機被主線程入棧執行,其實任務隊列分為宏任務隊列和微任務隊列,其中放置的分別為宏任務和微任務。
-
macrotask(宏任務) 在瀏覽器端,其可以理解為該任務執行完后,在下一個macrotask執行開始前,瀏覽器可以進行頁面渲染。觸發macrotask任務的操作包括:
-
script(整體代碼)
-
setTimeout、setInterval、setImmediate
-
I/O、UI交互事件
-
postMessage、MessageChannel
-
-
microtask(微任務)可以理解為在macrotask任務執行后,頁面渲染前立即執行的任務。觸發microtask任務的操作包括:
-
Promise.then
-
MutationObserver
-
process.nextTick(Node環境)
-
下面通過例子來看看二者的不同:
console.log('script start');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
上面一段代碼輸出結果為:
script start > script end > promise1 > promise2 > timeout
具體的可視化操作演示可以參考Tasks, microtasks, queues and schedules。
上面代碼運行到最后一句console后,生成的任務隊列:
macrotasks:【setTimeout回調】
microtasks:【Promise.then回調1, Promise.then回調2】
兩種不同的任務隊列,為啥microtask的任務會先執行呢,這就要說說macrotask與microtask的運行機制[3]如下:
-
執行一個macrotask(包括整體script代碼),若js執行棧空閑則從任務隊列中取
-
執行過程中遇到microtask,則將其添加到micro task queue中;同樣遇到macrotask則添加到macro task queue中
-
macrotask執行完畢后,立即按序執行micro task queue中的所有microtask;如果在執行microtask的過程中,又產生了microtask,那么會加入到隊列的末尾,也會在這個周期被調用執行;
-
所有microtask執行完畢后,瀏覽器開始渲染,GUI線程接管渲染
-
渲染完畢,從macro task queue中取下一個macrotask開始執行
Event loop
在主線程執行棧空閑的情況下,從任務隊列中讀取任務入執行棧執行,這個過程是循環不斷進行的,所以又稱Event loop(事件循環)。
Event loop是一個js實現異步的規范,在不同環境下有不同的實現機制,例如瀏覽器和NodeJS實現機制不同:
-
瀏覽器的Event loop是按照html標准定義來實現,具體的實現留給各瀏覽器廠商
-
NodeJS中的Event loop是基於libuv實現
下面來說說瀏覽器環境下的Event loop,首先借用一幅圖:
根據HTML Standard - event loop processing model對Event loop規范描述來簡單說明事件循環模型:
-
按先進先出原則選擇最新進入Event loop任務隊列的一個macrotask,若沒有則直接進入第6步的microtask
-
設置Event loop的當前任務為上面一步選擇的任務
-
進棧運行所選的任務
-
運行完畢設置Event loop的當前任務為null
-
將第一步選擇的任務從任務隊列中刪除
-
執行microtask:perform a microtask checkpoint,具體執行步驟參考這里
-
更新並進行UI渲染
-
返回第一步執行
microtask的應用
根據Event loop機制,macrotask的一個任務執行完后就進行UI渲染,然后進行另一個macrotask任務執行,macrotask任務的應用就不做過多介紹。下面來說說microtask任務的應用場景,我們以vue的異步更新DOM來做說明,先看官網的說明:
Vue異步執行DOM更新,只要觀察到數據變化,Vue將開啟一個隊列,並緩沖在同一事件循環中發生的所有數據變更。
也就是說,Vue綁定的數據發生變化時,頁面視圖不會立即重新更新,需要等到當前任務執行完畢時進行更新。例如下面代碼:
<template>
<div>
<div ref="test">{{test}}</div>
<button @click="handleClick">tet</button>
</div>
</template>
export default {
data () {
return {
test: 'begin'
};
},
methods () {
handleClick () {
this.test = 'end';
console.log(this.$refs.test.innerText);//打印“begin”
}
}
}
上面代碼在執行this.test = 'end'后,頁面視圖綁定數據test發生變化,若按照同步執行代碼,視圖應該能馬上獲取到對應dom的內容,但是並沒有獲取到。這是因為Vue采用異步視圖更新的。具體來說就是Vue在偵聽到數據變化時,異步更新視圖最終是通過nextTick
來完成的,而該方法默認采用microtask任務來實現異步任務,具體的可以參考從Vue.js源碼看nextTick機制;這樣在 microtask 中就完成數據更新,task 結束就可以得到最新的 UI 了。上面代碼如下:
handleClick () {
this.test = 'end';
this.$nextTick(() => {
console.log(this.$refs.test.innerText);//打印"end"
});
}
按照HTML Standard描述,macrotask、microtask和UI 渲染的執行順序:
一個macrotask任務 --> 所有microtask任務 --> UI 渲染。
既然nextTick是按照microtask來實現異步的,那么microtask任務應該是在UI渲染前執行的,為什么表現的是microtask在UI 渲染之后執行的呢?可能有人對上面提出過質疑。猜測原因如下,具體原因可以參考這篇文章。
JS更新dom是同步完成的,但是UI渲染是異步的。
microtask跨瀏覽器實現
從Vue的nextTick
方法的實現以及immediate的實現可以看出,怎么實現Event loop中的microtask實現呢?那就是借助js原生支持的Promise、MutationObserver(瀏覽器)、process.nextTick(nodejs環境)來實現,均不支持時使用setTimeout(fn, 0)
來兜底降級實現。下面就來簡單說說microtask的實現思路:
-
瀏覽器是否原生實現Promise,有則使用Promise類似如下實現,否則走下一步。
if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { p.then(handle) }
-
瀏覽器環境是否原生支持MutationObserver,支持可以這么實現,否則走下一步。
function microFun(handle) { var observer = new MutationObserver(handle); var element = document.createTextNode(''); observer.observe(element, { characterData: true }); return function () { element.data = blabla; }; }
-
瀏覽器是否支持
onreadystatechange
事件,支持則創建一個空的script標簽,一旦插入到document中,其onreadystatechange事件將會異步地觸發,比setTimeout(fn,0)快,否則走下一步function microFun(handle) { return function () { var scriptEl = document.createElement('script'); scriptEl.onreadystatechange = function () { handle(); scriptEl.onreadystatechange = null; scriptEl.parentNode.removeChild(scriptEl); scriptEl = null; }; document.documentElement.appendChild(scriptEl); return handle; }; };
-
使用setTimeout(fn, 0)來兜底實現
下面看一下core-js模塊中Promise中對microtask的模擬實現,具體可以參考源碼:
module.exports = function () {
var head, last, notify;
var flush = function () {
var parent, fn;
if (isNode && (parent = process.domain)) parent.exit();
while (head) {
fn = head.fn;
head = head.next;
try {
fn();
} catch (e) {
if (head) notify();
else last = undefined;
throw e;
}
} last = undefined;
if (parent) parent.enter();
};
// Node.js
if (isNode) {
notify = function () {
process.nextTick(flush);
};
// browsers with MutationObserver
} else if (Observer) {
var toggle = true;
var node = document.createTextNode('');
new Observer(flush).observe(node, { characterData: true }); // eslint-disable-line no-new
notify = function () {
node.data = toggle = !toggle;
};
// environments with maybe non-completely correct, but existent Promise
} else if (Promise && Promise.resolve) {
var promise = Promise.resolve();
notify = function () {
promise.then(flush);
};
// for other environments - macrotask based on:
// - setImmediate
// - MessageChannel
// - window.postMessag
// - onreadystatechange
// - setTimeout
} else {
notify = function () {
// strange IE + webpack dev server bug - use .call(global)
macrotask.call(global, flush);
};
}
return function (fn) {
var task = { fn: fn, next: undefined };
if (last) last.next = task;
if (!head) {
head = task;
notify();
} last = task;
};
};
問題答案
對於文章開頭的js代碼,其最終輸出內容為:
1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6
可以從以下幾個步驟來簡單分析,具體執行步驟如下圖所示: