首先,JavaScript是一個單線程的腳本語言。
所以就是說在一行代碼執行的過程中,必然不會存在同時執行的另一行代碼,就像使用alert()
以後進行瘋狂console.log
,如果沒有關閉彈框,控制檯是不會顯示出一條log
信息的。
亦或者有些代碼執行了大量計算,比方說在前端暴力破解密碼之類的鬼操作,這就會導致後續代碼一直在等待,頁面處於假死狀態,因爲前邊的代碼並沒有執行完。
所以如果全部代碼都是同步執行的,這會引發很嚴重的問題,比方說我們要從遠端獲取一些數據,難道要一直循環代碼去判斷是否拿到了返回結果麼?就像去飯店點餐,肯定不能說點完了以後就去後廚催着人炒菜的,會被揍的。
於是就有了異步事件的概念,註冊一個回調函數,比如說發一個網絡請求,我們告訴主程序等到接收到數據後通知我,然後我們就可以去做其他的事情了。
然後在異步完成後,會通知到我們,但是此時可能程序正在做其他的事情,所以即使異步完成了也需要在一旁等待,等到程序空閒下來纔有時間去看哪些異步已經完成了,可以去執行。
比如說打了個車,如果司機先到了,但是你手頭還有點兒事情要處理,這時司機是不可能自己先開着車走的,一定要等到你處理完事情上了車才能走。
微任務與宏任務的區別
這個就像去銀行辦業務一樣,先要取號進行排號。
一般上邊都會印着類似:“您的號碼爲XX,前邊還有XX人。”之類的字樣。
因爲櫃員同時職能處理一個來辦理業務的客戶,這時每一個來辦理業務的人就可以認爲是銀行櫃員的一個宏任務來存在的,當櫃員處理完當前客戶的問題以後,選擇接待下一位,廣播報號,也就是下一個宏任務的開始。
所以多個宏任務合在一起就可以認爲說有一個任務隊列在這,裏邊是當前銀行中所有排號的客戶。
任務隊列中的都是已經完成的異步操作,而不是說註冊一個異步任務就會被放在這個任務隊列中,就像在銀行中排號,如果叫到你的時候你不在,那麼你當前的號牌就作廢了,櫃員會選擇直接跳過進行下一個客戶的業務處理,等你回來以後還需要重新取號
而且一個宏任務在執行的過程中,是可以添加一些微任務的,就像在櫃檯辦理業務,你前邊的一位老大爺可能在存款,在存款這個業務辦理完以後,櫃員會問老大爺還有沒有其他需要辦理的業務,這時老大爺想了一下:“最近P2P爆雷有點兒多,是不是要選擇穩一些的理財呢”,然後告訴櫃員說,要辦一些理財的業務,這時候櫃員肯定不能告訴老大爺說:“您再上後邊取個號去,重新排隊”。
所以本來快輪到你來辦理業務,會因爲老大爺臨時添加的“理財業務”而往後推。
也許老大爺在辦完理財以後還想 再辦一個信用卡?或者 再買點兒紀念幣?
無論是什麼需求,只要是櫃員能夠幫她辦理的,都會在處理你的業務之前來做這些事情,這些都可以認爲是微任務。
這就說明:你大爺永遠是你大爺
在當前的微任務沒有執行完成時,是不會執行下一個宏任務的。
所以就有了那個經常在面試題、各種博客中的代碼片段:
setTimeout(_ => console.log(4))
new Promise(resolve => {
resolve()
console.log(1)
}).then(_ => {
console.log(3)
})
console.log(2)
setTimeout
就是作爲宏任務來存在的,而Promise.then
則是具有代表性的微任務,上述代碼的執行順序就是按照序號來輸出的。
所有會進入的異步都是指的事件回調中的那部分代碼
也就是說new Promise
在實例化的過程中所執行的代碼都是同步進行的,而then
中註冊的回調纔是異步執行的。
在同步代碼執行完成後纔回去檢查是否有異步任務完成,並執行對應的回調,而微任務又會在宏任務之前執行。
所以就得到了上述的輸出結論1、2、3、4
。
+部分表示同步執行的代碼
+setTimeout(_ => {
- console.log(4)
+})
+new Promise(resolve => {
+ resolve()
+ console.log(1)
+}).then(_ => {
- console.log(3)
+})
+console.log(2)
本來setTimeout
已經先設置了定時器(相當於取號),然後在當前進程中又添加了一些Promise
的處理(臨時添加業務)。
所以進階的,即便我們繼續在Promise
中實例化Promise
,其輸出依然會早於setTimeout
的宏任務:
setTimeout(_ => console.log(4))
new Promise(resolve => {
resolve()
console.log(1)
}).then(_ => {
console.log(3)
Promise.resolve().then(_ => {
console.log('before timeout')
}).then(_ => {
Promise.resolve().then(_ => {
console.log('also before timeout')
})
})
})
console.log(2)
當然了,實際情況下很少會有簡單的這麼調用Promise
的,一般都會在裏邊有其他的異步操作,比如fetch
、fs.readFile
之類的操作。
而這些其實就相當於註冊了一個宏任務,而非是微任務。
P.S. 在Promise/A+的規範中,Promise
的實現可以是微任務,也可以是宏任務,但是普遍的共識表示(至少Chrome
是這麼做的),Promise
應該是屬於微任務陣營的
所以,明白哪些操作是宏任務、哪些是微任務就變得很關鍵,這是目前業界比較流行的說法:
宏任務
# | 瀏覽器 | Node |
---|---|---|
I/O |
✅ | ✅ |
setTimeout |
✅ | ✅ |
setInterval |
✅ | ✅ |
setImmediate |
❌ | ✅ |
requestAnimationFrame |
✅ | ❌ |
有些地方會列出來UI Rendering
,說這個也是宏任務,可是在讀了HTML規範文檔以後,發現這很顯然是和微任務平行的一個操作步驟requestAnimationFrame
姑且也算是宏任務吧,requestAnimationFrame
在MDN的定義爲,下次頁面重繪前所執行的操作,而重繪也是作爲宏任務的一個步驟來存在的,且該步驟晚於微任務的執行
微任務
# | 瀏覽器 | Node |
---|---|---|
process.nextTick |
❌ | ✅ |
MutationObserver |
✅ | ❌ |
Promise.then catch finally |
✅ | ✅ |
Event-Loop是個啥
上邊一直在討論 宏任務、微任務,各種任務的執行。
但是回到現實,JavaScript
是一個單進程的語言,同一時間不能處理多個任務,所以何時執行宏任務,何時執行微任務?我們需要有這樣的一個判斷邏輯存在。
每辦理完一個業務,櫃員就會問當前的客戶,是否還有其他需要辦理的業務。(檢查還有沒有微任務需要處理)
而客戶明確告知說沒有事情以後,櫃員就去查看後邊還有沒有等着辦理業務的人。(結束本次宏任務、檢查還有沒有宏任務需要處理)
這個檢查的過程是持續進行的,每完成一個任務都會進行一次,而這樣的操作就被稱爲Event Loop
。(這是個非常簡易的描述了,實際上會複雜很多)
而且就如同上邊所說的,一個櫃員同一時間只能處理一件事情,即便這些事情是一個客戶所提出的,所以可以認爲微任務也存在一個隊列,大致是這樣的一個邏輯:
const macroTaskList = [
['task1'],
['task2', 'task3'],
['task4'],
]
for (let macroIndex = 0; macroIndex < macroTaskList.length; macroIndex++) {
const microTaskList = macroTaskList[macroIndex]
for (let microIndex = 0; microIndex < microTaskList.length; microIndex++) {
const microTask = microTaskList[microIndex]
// 添加一個微任務
if (microIndex === 1) microTaskList.push('special micro task')
// 執行任務
console.log(microTask)
}
// 添加一個宏任務
if (macroIndex === 2) macroTaskList.push(['special macro task'])
}
// > task1
// > task2
// > task3
// > special micro task
// > task4
// > special macro task
之所以使用兩個for
循環來表示,是因爲在循環內部可以很方便的進行push
之類的操作(添加一些任務),從而使迭代的次數動態的增加。
以及還要明確的是,Event Loop
只是負責告訴你該執行那些任務,或者說哪些回調被觸發了,真正的邏輯還是在進程中執行的。
在瀏覽器中的表現
在上邊簡單的說明了兩種任務的差別,以及Event Loop
的作用,那麼在真實的瀏覽器中是什麼表現呢?
首先要明確的一點是,宏任務必然是在微任務之後才執行的(因爲微任務實際上是宏任務的其中一個步驟)
I/O
這一項感覺有點兒籠統,有太多的東西都可以稱之爲I/O
,點擊一次button
,上傳一個文件,與程序產生交互的這些都可以稱之爲I/O
。
假設有這樣的一些DOM
結構:
<style>
#outer {
padding: 20px;
background: #616161;
}
#inner {
width: 100px;
height: 100px;
background: #757575;
}
</style>
<div id="outer">
<div id="inner"></div>
</div>
const $inner = document.querySelector('#inner')
const $outer = document.querySelector('#outer')
function handler () {
console.log('click') // 直接輸出
Promise.resolve().then(_ => console.log('promise')) // 註冊微任務
setTimeout(_ => console.log('timeout')) // 註冊宏任務
requestAnimationFrame(_ => console.log('animationFrame')) // 註冊宏任務
$outer.setAttribute('data-random', Math.random()) // DOM屬性修改,觸發微任務
}
new MutationObserver(_ => {
console.log('observer')
}).observe($outer, {
attributes: true
})
$inner.addEventListener('click', handler)
$outer.addEventListener('click', handler)
如果點擊#inner
,其執行順序一定是:click
-> promise
-> observer
-> click
-> promise
-> observer
-> animationFrame
-> animationFrame
-> timeout
-> timeout
。
因爲一次I/O
創建了一個宏任務,也就是說在這次任務中會去觸發handler
。
按照代碼中的註釋,在同步的代碼已經執行完以後,這時就會去查看是否有微任務可以執行,然後發現了Promise
和MutationObserver
兩個微任務,遂執行之。
因爲click
事件會冒泡,所以對應的這次I/O
會觸發兩次handler
函數(一次在inner
、一次在outer
),所以會優先執行冒泡的事件(早於其他的宏任務),也就是說會重複上述的邏輯。
在執行完同步代碼與微任務以後,這時繼續向後查找有木有宏任務。
需要注意的一點是,因爲我們觸發了setAttribute
,實際上修改了DOM
的屬性,這會導致頁面的重繪,而這個set
的操作是同步執行的,也就是說requestAnimationFrame
的回調會早於setTimeout
所執行。
一些小驚喜
使用上述的示例代碼,如果將手動點擊DOM
元素的觸發方式變爲$inner.click()
,那麼會得到不一樣的結果。
在Chrome
下的輸出順序大致是這樣的:click
-> click
-> promise
-> observer
-> promise
-> animationFrame
-> animationFrame
-> timeout
-> timeout
。
與我們手動觸發click
的執行順序不一樣的原因是這樣的,因爲並不是用戶通過點擊元素實現的觸發事件,而是類似dispatchEvent
這樣的方式,我個人覺得並不能算是一個有效的I/O
,在執行了一次handler
回調註冊了微任務、註冊了宏任務以後,實際上外邊的$inner.click()
並沒有執行完。
所以在微任務執行之前,還要繼續冒泡執行下一次事件,也就是說觸發了第二次的handler
。
所以輸出了第二次click
,等到這兩次handler
都執行完畢後纔會去檢查有沒有微任務、有沒有宏任務。
兩點需要注意的:
.click()
的這種觸發事件的方式個人認爲是類似dispatchEvent
,可以理解爲同步執行的代碼
document.body.addEventListener('click', _ => console.log('click'))
document.body.click()
document.body.dispatchEvent(new Event('click'))
console.log('done')
// > click
// > click
// > done
MutationObserver
的監聽不會說同時觸發多次,多次修改只會有一次回調被觸發。
new MutationObserver(_ => {
console.log('observer')
// 如果在這輸出DOM的data-random屬性,必然是最後一次的值,不解釋了
}).observe(document.body, {
attributes: true
})
document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())
// 只會輸出一次 ovserver
這就像去飯店點餐,服務員喊了三次,XX號的牛肉麪,不代表她會給你三碗牛肉麪。
上述觀點參閱自Tasks, microtasks, queues and schedules,文中有動畫版的講解
在Node中的表現
Node也是單線程,但是在處理Event Loop
上與瀏覽器稍微有些不同,這裏是Node官方文檔的地址。
就單從API層面上來理解,Node新增了兩個方法可以用來使用:微任務的process.nextTick
以及宏任務的setImmediate
。
setImmediate與setTimeout的區別
在官方文檔中的定義,setImmediate
爲一次Event Loop
執行完畢後調用。setTimeout
則是通過計算一個延遲時間後進行執行。
但是同時還提到了如果在主進程中直接執行這兩個操作,很難保證哪個會先觸發。
因爲如果主進程中先註冊了兩個任務,然後執行的代碼耗時超過XXs
,而這時定時器已經處於可執行回調的狀態了。
所以會先執行定時器,而執行完定時器以後纔是結束了一次Event Loop
,這時纔會執行setImmediate
。
setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))
有興趣的可以自己試驗一下,執行多次真的會得到不同的結果。
但是如果後續添加一些代碼以後,就可以保證setTimeout
一定會在setImmediate
之前觸發了:
setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))
let countdown = 1e9
while(countdonn--) { } // 我們確保這個循環的執行速度會超過定時器的倒計時,導致這輪循環沒有結束時,setTimeout已經可以執行回調了,所以會先執行`setTimeout`再結束這一輪循環,也就是說開始執行`setImmediate`
如果在另一個宏任務中,必然是setImmediate
先執行:
require('fs').readFile(__dirname, _ => {
setTimeout(_ => console.log('timeout'))
setImmediate(_ => console.log('immediate'))
})
// 如果使用一個設置了延遲的setTimeout也可以實現相同的效果
process.nextTick
就像上邊說的,這個可以認爲是一個類似於Promise
和MutationObserver
的微任務實現,在代碼執行的過程中可以隨時插入nextTick
,並且會保證在下一個宏任務開始之前所執行。
在使用方面的一個最常見的例子就是一些事件綁定類的操作:
class Lib extends require('events').EventEmitter {
constructor () {
super()
this.emit('init')
}
}
const lib = new Lib()
lib.on('init', _ => {
// 這裏將永遠不會執行
console.log('init!')
})
因爲上述的代碼在實例化Lib
對象時是同步執行的,在實例化完成以後就立馬發送了init
事件。
而這時在外層的主程序還沒有開始執行到lib.on('init')
監聽事件的這一步。
所以會導致發送事件時沒有回調,回調註冊後事件不會再次發送。
我們可以很輕鬆的使用process.nextTick
來解決這個問題:
class Lib extends require('events').EventEmitter {
constructor () {
super()
process.nextTick(_ => {
this.emit('init')
})
// 同理使用其他的微任務
// 比如Promise.resolve().then(_ => this.emit('init'))
// 也可以實現相同的效果
}
}
這樣會在主進程的代碼執行完畢後,程序空閒時觸發Event Loop
流程查找有沒有微任務,然後再發送init
事件。
關於有些文章中提到的,循環調用process.nextTick
會導致報警,後續的代碼永遠不會被執行,這是對的,參見上邊使用的雙重循環實現的loop
即可,相當於在每次for
循環執行中都對數組進行了push
操作,這樣循環永遠也不會結束
多提一嘴async/await函數
因爲,async/await
本質上還是基於Promise
的一些封裝,而Promise
是屬於微任務的一種。所以在使用await
關鍵字與Promise.then
效果類似:
setTimeout(_ => console.log(4))
async function main() {
console.log(1)
await Promise.resolve()
console.log(3)
}
main()
console.log(2)
async函數在await之前的代碼都是同步執行的,可以理解爲await之前的代碼屬於new Promise
時傳入的代碼,await之後的所有代碼都是在Promise.then
中的回調