面試和筆試題目中,經常會出現'promise','setTimeout'等函式混合出現時候的執行順序問題。 我們都知道這些非同步的方法會在當前任務執行結束之後呼叫,但為什麼'promise'會在'setTimeout'之前執行? 具體的實現原理是什麼?
有和我一樣正在為秋招offer奮鬥的小夥伴,歡迎到github獲取更多我的總結和踩過的坑,一起進步→→→→傳送門
問題的提出
上面問題的答案,都在文章《Tasks, microtasks, queues and schedules》講的非常透徹。 建議英文可以的同學直接看這篇文章,就不要看我這個“筆記”了。( 之所以叫筆記,因為大部分內容出自文章,但是又不是按字翻譯 )
以下的題目是我們刷題可以經常看到的一個常規題目:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
複製程式碼
幾乎每個前端er都可以毫不猶豫的給出答案:
script start
script end
promise1
promise2
setTimeout
複製程式碼
問題來了,為什麼promise
的非同步執行會在setTimeout
之前,甚至setTimeout
設定的延時是0都不行。 還有在Vue中,我們常用的nextTick()函式原理中,說的microtasks是什麼東西? 一切的解釋都在開頭給的文章中。
ps: 再次再次宣告,這篇文章仍然是我記得筆記,原文比我寫的好得多,英文可以的小夥伴強烈推薦看原文。
js非同步實現原理
我們多多少少都應該聽說過event loop,js是單執行緒的,通過非同步它變得非常強大,而實現非同步主要就是通過將非同步的內容壓入tasks,當前任務執行結束之後,再執行tasks中的callback。
Tasks,是一個任務佇列,Js在執行同步任務的時候,只要遇到了非同步執行和函式,都會把這個內容壓入Tasks中,然後在當前同步任務完成後,再去Tasks中執行相應的回撥。 舉個例子,比如剛才程式碼中的setTimeout
,當遇到這個函式,總會跟一個非同步執行的任務(callback),那麼這個時候,Tasks佇列裡,除了當前正在執行的script之外,會在後面壓入一個setTimeout callback
, 而這個callback的呼叫時機,就是在當前同步任務完成之後,才會呼叫。這就是為什麼,'setTimeout' 會出現在'script end'之後了。
MicroTasks,說一些這個,這個和setTimeout
不同,因為它是在當前Task完成後,就立即執行的,或者可以理解成,'microTasks總是在當前任務的最後執行'。 另外,還有一個非常重要的特性是: 如果當前JS stack如果為空的時候(比如我們繫結了click事件後,等待和監聽click時間的時候,JS stack就是空的),一會立即執行。 關於這一點,之後有個例子會具體說明,先往下看。
那麼MicroTasks佇列主要是promise和mutation observer 的回掉函式生成
用新的理論來解釋下
好了,剛才大概說了幾個概念,那麼一開始的例子,到底發生了什麼?
talk is cheap, show me a animation!!
---我自己說的
下面的動畫說明對整個過程進行了說明:
1、 程式執行 log: script start
- Tasks: Run script
- JS stack: script
2、 遇到setTimeout log: script start
- Tasks: Run script | setTimeout callback
- JS stack: script
3、 遇到Promise
- Tasks: Run script | setTimeout callback
- Microtasks: promise then
- JS stack: script
4、 執行最後一行 log: script start | script end
- Tasks: Run script | setTimeout callback
- Microtasks: promise then
- JS stack: script
4、 同步任務執行完畢,彈出相應的stack log: script start | script end
- Tasks: Run script | setTimeout callback
- Microtasks: promise then
- JS stack:
5、 同步任務最後是microTasks,JS stack壓入callback log: script start | script end | promise1
- Tasks: Run script | setTimeout callback
- Microtasks: promise then | promise then
- JS stack: promise1 calback
6、 promise返回新的promise,壓入microTasks,繼續執行
log: script start | script end | promise1 | promise2
- Tasks: Run script | setTimeout callback
- Microtasks: promise then
- JS stack: promise2 calback
8、 第一個Tasks結束,彈出 log: script start | script end | promise1 | promise2
- Tasks: setTimeout callback
- Microtasks:
- JS stack:
9、 下一個Tasks log: script start | script end | promise1 | promise2 | setTimeout
- Tasks: setTimeout callback
- Microtasks:
- JS stack: setTimeout callback
好了,結束了,這就比之前的理解"promise比setTimeout快,非同步先執行promise,再執行setTimeout"就深刻的多。 因為promise所建立的回掉函式是壓入了mircroTasks
佇列中,它仍然屬於當前的Task,而setTimeout
則是相當於在Task序列中新增了新的任務
一個更復雜的例子
好了,有了剛才的認識和鋪墊,接下來通過一個更加複雜的例子來熟悉JS事件處理的一個過程。
現在有這樣一個頁面結構:
<div class="outer">
<div class="inner"></div>
</div>
複製程式碼
js程式碼如下,現在如果點選裡面的方塊,控制檯會輸出什麼呢?線上例項
// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
console.log('mutate');
}).observe(outer, {
attributes: true
});
// Here's a click listener…
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
outer.setAttribute('data-random', Math.random());
}
// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
複製程式碼
這裡先把正確答案公佈,按照之前的理論,正確答案應該是:
click
promise
mutate
click
promise
mutate
timeout
timeout
複製程式碼
當然,不同瀏覽器,對於event loop的實現會稍有不同,這個是chrome下列印出來的結果,具體的其他形式還是推薦大家看原文了。
下面分析下,為什麼是上面的順序呢?
程式碼分析
按照剛才的結論:
click事件顯然是一個Task,Mutation observer和Promise是在microTasks佇列中的,而setTimeout會被安排在Tasks之中。 因此
1、點選事件觸發
- Tasks: Dispatch click
- Microtasks:
- JS stack:
2、觸發點選事件的函式,函式執行,壓入JS stack
- Tasks: Dispatch click
- Microtasks:
- JS stack: onClick
- Log: 'click'
3、遇到setTimeout,壓入Tasks佇列
- Tasks: Dispatch click | setTimeout callBack
- Microtasks:
- JS stack: onClick
- Log: 'click'
4、遇到promise,壓入Microtasks
- Tasks: Dispatch click | setTimeout callBack
- Microtasks: Promise.then
- JS stack: onClick
- Log: 'click'
5、遇到 outer.setAttribute,觸發mutation
- Tasks: Dispatch click | setTimeout callBack
- Microtasks: Promise.then | Mutation observers
- JS stack: onClick
- Log: 'click'
6、onclick函式執行完畢,出JS stack
- Tasks: Dispatch click | setTimeout callBack
- Microtasks: Promise.then | Mutation observers
- JS stack:
- Log: 'click'
7、這個時候,JS stack為空,執行Microtasks
- Tasks: Dispatch click | setTimeout callBack
- Microtasks: Promise.then | Mutation observers
- JS stack: PromiseCallback
- Log: 'click' 'promise'
8、microtasks順序執行
- Tasks: Dispatch click | setTimeout callBack
- Microtasks: Mutation observers
- JS stack: Mutation callback
- Log: 'click' 'promise' 'mutate'
接下來是重點,當microtasks為空,該執行下一個Tasks(setTimeout)了嗎?並沒有,因為js事件流中的冒泡被觸發,也就是在外面的一層Div也會觸發click函式,因此我們把剛才的步驟再走一遍。
過程省略,結果為 9、冒泡走一遍的結果為
- Tasks: Dispatch click | setTimeout callBack | setTmeout callback(outer)
- Microtasks: Mutation observers
- JS stack: Mutation callback
- Log:
click
promise
mutate
click
promise
mutate
10、 第一個Tasks完成,出棧
- Tasks: setTimeout callBack | setTmeout callback(outer)
- Microtasks:
- JS stack: setTimeout callback
- Log:
click
promise
mutate
click
promise
mutate
timeout
11、 第二個Tasks完成,出棧
- Tasks: setTmeout callback(outer)
- Microtasks:
- JS stack: setTimeout(outer) callback
- Log:
click
promise
mutate
click
promise
mutate
timeout
timeout
結束了
所以這裡的重點是什麼? 是MicroTasks的執行時機: 見縫插針,它不一定就必須在Tasks的最後,只要JS stack為空,就可以執行 這條規則出處在
If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3
另一方面,ECMA也對此有過說明
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
— ECMAScript: Jobs and Job Queues
但是對於其他瀏覽器(firefox safari ie)同樣的程式碼,得出的結果是不同的哦。關鍵在於,對與 job
和microTasks
之間的一個聯絡是很模糊的。 但是我們就按照Chrome的實現來理解吧。
最後一關
還是剛才那道題,只不過,我不用滑鼠點選了,而是直接執行函式
inner.click()
複製程式碼
如果這樣,結果會一樣嗎?
答案是:
click
click
promise
mutate
promise
timeout
timeout
複製程式碼
What!!??我怎麼感覺我白學了? 不著急,看下這次的過程是這樣的,首先最大的不同在於,我們在函式最底部加了一個執行inner.click()
,這樣子,這個函式執行的過程,都是同步序列裡的,所以這次的task的起點就在Run scripts:
1、不同與滑鼠點選,我們執行函式後,進入函式內部執行
- Tasks: Run scripts
- Microtasks:
- JS stack: script | onClick
- Log:
click
2、遇到setTimeout和promise&mutation
- Tasks: Run scripts | setTimeout callback
- Microtasks: Promise.then | Mutation Observers
- JS stack: script | onClick
- Log:
click
3、接下來關鍵,冒泡的時候,因為我們並沒有執行完當前的script,還在inner.click()
這個函式執行之中,因此當onclick
結束,開始冒泡時,script並沒有結束
- Tasks: Run scripts | setTimeout callback
- Microtasks: Promise.then | Mutation Observers
- JS stack: script | onClick(這是冒泡的click,第一次click已經結束)
- Log:
click
click
4、冒泡階段重複之前內容
- Tasks: Run scripts | setTimeout callback |setTimeout callback(outer)
- Microtasks: Promise.then | Mutation Observers |promise.then
- JS stack: script | onClick(這是冒泡的click,第一次click已經結束)
- Log:
click
click
注意第二次沒有增加mutation,因為已經有一個在渲染的了
5、inner.click()執行完畢,執行Microtasks
- Tasks: Run scripts | setTimeout callback |setTimeout callback(outer)
- Microtasks: Promise.then | Mutation Observers |promise.then
- JS stack:
- Log:
click
click
promise
6、按理論執行
- Tasks: Run scripts | setTimeout callback |setTimeout callback(outer)
- Microtasks: Mutation Observers |promise.then
- JS stack:
- Log:
click
click
promise
mutate
....
後面的就不解釋了,Microtasks依次出棧,接著Tasks順序執行。
總結
Jake老師的文章,對這個的解析和深入實在令人佩服,我也在面試中因把event loop解釋的較為詳盡而被面試官肯定,所以如果對非同步以及event loop有疑惑的,可以好好的消化下這個內容,一起進步!