一、前言
大家都知道JavaScript是單線程的,單線程就意味著同一時間只能做一件事,那么有同學會問,為什么JavaScript的作者不把它設計成多線程的呢,那樣性能不是更好。為了回答這個問題,我們得從JavaScript的用途上來解釋了,由于JavaScript是一門腳本語言,被用于與用戶進行交互和操作DOM有關,如果是多線程的話, 會出現很多復雜的同步問題,讓JavaScript的操作變得難以控制。假如現在有一個線程A在dom上新增一個節(jié)點a,另一個線程又在dom上刪除了節(jié)點a,那么我們該以哪個線程為標準呢。所以,對于JavaScript單線程這一特點,未來也不會改變。對于一些JavaScript開發(fā)者來說,JavaScript的運行機制一直困擾著一些同學,比如異步請求的執(zhí)行問題,為什么js代碼會造成頁面渲染的阻塞,作用域中的變量提升等等到底做了什么,看完下面的文章你應該會對這些問題有清楚的了解。
二、進程與線程
我們經常說,JavaScript是單線程的,那到底什么是線程呢。官方的說法是,進程是CPU資源分配的最小單位,而線程是CPU調度的最小單位。
大家看到這句話可能有些懵。那以瀏覽器為例,當我們在瀏覽器中打開一個新的標簽頁Tab的時候,CPU會為瀏覽器分配一個新的進程,去渲染我們的網頁,而渲染網頁的工作是通過這個進程中的多個線程來配合完成的,包括瀏覽器的渲染線程、JS引擎線程、http異步請求線程等等。
所以,一個進程由多個線程組成,每個線程是進程的不同執(zhí)行路線。而進程與進程之間是相對獨立的,如:在瀏覽器打開兩個標簽頁Tab,就是兩個進程,這兩個標簽頁的運行是互不影響的。
三、瀏覽器內核
說到瀏覽器內核,就不得不提到五大主流瀏覽器:
IE(IE內核),
Chrome瀏覽器(以前是Webkit內核,現在是Blink內核),
Safari(Webkit內核),
Firefox(Gecko內核),
Opera(最開始是Pestro內核,然后是Webkit內核,最后是Blink內核),
也正是因為不同瀏覽器的內核不同,導致有些相同html元素在不同瀏覽器上的表現不同,這主要是由于瀏覽器內核中的GUI渲染線程不同所導致。
瀏覽器內核是多線程的,在內核的控制下,多個線程相互配合以保持同步,一個瀏覽器內核通常由以下幾個線程組成:
GUI渲染線程
JS引擎線程
定時器觸發(fā)線程
事件觸發(fā)線程
異步HTTP請求線程
1、GUI渲染線程
該線程主要負責解析HTML,CSS,構建DOM樹,布局和繪制等
當頁面需要重繪或者引起回流時,將會執(zhí)行該線程
注意,該線程是與JS引擎線程互斥的,當執(zhí)行JS引擎線程時,GUI渲染線程將會被掛起(凍結),等到任務隊列為空的時候,主線程才會去執(zhí)行GUI渲染線程
2、JS引擎線程
主要負責處理JavaScript腳本,執(zhí)行代碼
也負責執(zhí)行準備好執(zhí)行的事件,如定時器計時結束或異步請求成功并正確返回時,將依次進入任務隊列,等待JS引擎線程的執(zhí)行
當然,該線程是與GUI渲染引擎線程互斥的,當JS引擎執(zhí)行JavaScript代碼時間過長時會造成頁面的阻塞,也就是為什么我們要把script標簽在body的最后面引入
3、定時器觸發(fā)線程
負責執(zhí)行定時器一類函數的進程,如settimeout、setInterval
當主線程依次執(zhí)行代碼時,遇到計時器,會將計時器交給該線程處理,當計時完畢之后,定時器觸發(fā)線程會將計時完畢后的事件加入到事件隊列的尾部,等待JS引擎線程的執(zhí)行
4、事件觸發(fā)線程
主要負責將準備好執(zhí)行的事件交給JS引擎線程執(zhí)行,如計時器計時完畢后的事件,AJAX請求成功返回并觸發(fā)的回調函數和用戶觸發(fā)點擊事件時,事件觸發(fā)線程會將回調函數加入到任務隊列的尾部,等待JS引擎線程的執(zhí)行
5、異步HTTP請求線程
負責執(zhí)行異步請求一類的函數,如ajax,fetch,axios等
當主線程依次執(zhí)行代碼時,遇到異步請求,會將函數交給該線程處理,當監(jiān)聽狀態(tài)碼變更時,如果有回調函數,會將回調函數加入到任務隊列的尾部,等待JS引擎線程的執(zhí)行
四、任務隊列
單線程就意味著,所有任務的執(zhí)行都需要排隊,前一個任務結束,后一個任務才能執(zhí)行,如果一個任務耗時很長,后一個任務不得不一直等待著。
JavaScript的作者意識到這個問題,將所有任務劃分為兩種,一種是同步任務,一種是異步任務。
同步任務是指在主線程上排隊執(zhí)行的任務,前一個任務結束,后一個任務才能執(zhí)行。
異步任務是指不進入主線程執(zhí)行,而進入“任務隊列”的任務,只有當“任務隊列”通知主線程可以執(zhí)行了,該任務才會進入主線程。
異步任務分為兩種,宏任務和微任務(后面會重點介紹)。接下來通過兩個例子來說明同步任務和異步任務的主要區(qū)別:
console.log('a')
while (true) {
console.log('這里是while')
}
console.log('b')
最后打印的結果是a,還有無數個'這里是while',直到頁面卡死,因為上述代碼均屬于同步任務,由上到下依次執(zhí)行,當主線程執(zhí)行完console.log('a')之后,開始執(zhí)行while循環(huán),死循環(huán)導致調用棧會一直執(zhí)行while循環(huán)中代碼,阻塞了while循環(huán)以后的代碼執(zhí)行,導致while循環(huán)后面的任務就無法執(zhí)行了。
console.log('a')
settimeout(function () {
console.log('settimeout1')
},0)
while (true) {
console.log('這里是while')
}
最后的打印結果還是a,還有無數個'這里是while',直到頁面卡死,因為這段代碼中同時存在同步任務和異步任務,異步任務要等到主線程上所有的同步任務執(zhí)行完成之后才能執(zhí)行。上述代碼中的console.log('a')和while循環(huán)均屬于同步任務,而settimeout屬于異步任務(在后面的事件循環(huán)中會介紹哪些事件屬于異步任務),所以當執(zhí)行完console.log('a')之后,主線程將執(zhí)行while循環(huán),死循環(huán)導致調用棧會一直執(zhí)行while循環(huán)中代碼,阻塞了while循環(huán)以后的代碼執(zhí)行無法執(zhí)行下面的代碼了
五、事件循環(huán)(Event Loop)
下圖為一個完整的事件循環(huán)的過程:

事件循環(huán)的運行機制:
一開始執(zhí)行???我們可以把執(zhí)行棧認為是一個存儲函數調用的棧結構,遵循先進后出的原則。微任務隊列空,宏任務隊列里有且只有一個script腳本(整體代碼)。
全局上下文(script 標簽)被推入執(zhí)行棧,同步代碼執(zhí)行。在執(zhí)行的過程中,會判斷是同步任務還是異步任務,通過對一些接口的調用,可以產生新的宏任務與微任務,它們會分別被推入各自的任務隊列里。同步代碼執(zhí)行完了,全局script腳本會被移出宏隊列,這個過程本質上是隊列的宏任務的執(zhí)行和出隊的過程。
上一步我們出隊的是一個宏任務,這一步我們處理的是微任務。但需要注意的是:當宏任務出隊時,任務是一個一個執(zhí)行的;而微任務出隊時,任務是一隊一隊執(zhí)行的。因此,我們處理微任務隊列這一步,會逐個執(zhí)行隊列中的任務并把它出隊,直到隊列被清空。
主線程從“任務隊列”讀取事件這個過程,是循環(huán)不斷的,所以整個這種運行機制就叫做Event Loop(事件循環(huán))。每當主線程為空時,就會去讀取“事件隊列”,這就是JavaScript的運行機制
六、宏任務(Macrotask)和微任務(Microtask)
我們在上面提到,異步任務分為宏任務和微任務:
宏任務包括:全局script任務、setTimeout、setInterval、setImmediate、I/O操作、UI rendering
微任務包括:new Promise.then()、MutationObserver(HTML5新特性)等
當主線程上的所有同步任務執(zhí)行完之后,是先執(zhí)行宏任務還是先執(zhí)行微任務呢?
由于代碼入口都是全局任務script,而全局任務script屬于宏任務,所以當棧為空或者同步代碼執(zhí)行完之后,會先執(zhí)行微任務隊列里的任務
當微任務隊列里的所有任務都執(zhí)行完成之后,主線程會讀取宏任務最前面的任務
執(zhí)行宏任務的過程中,遇到微任務,依次加入微任務隊列
當前主線程上的調用棧為空時,再次讀取微任務隊列的任務,以此類推

以下通過一個例子來理解異步任務的運行機制:
Promise.resolve().then(() => {
console.log('Promse1')
setTimeout(function () {
console.log('setTimeout1')
}, 0)
})
setTimeout(function () {
console.log('setTimeout2')
Promise.resolve().then(() => {
console.log('Promise2')
})
}, 0)
最終打印結果依次為Promise1、setTimeout2、Promise2、setTimeout1
一開始執(zhí)行棧所有的同步任務執(zhí)行完成,主線程會去讀取微任務隊列(此時微任務隊列有且只有一個微任務),執(zhí)行微任務中的任務打印出Promise1,同時也會生成一個宏任務setTimeout1
當執(zhí)行棧為空時,主線程又會去讀取宏任務隊列最前面的任務。此時,宏任務隊列依次排列著[setTimeou2, setTimeout1],所以setTimeout2執(zhí)行打印setTimeout2,同時生成一個微任務Promise2加入微任務隊列
當主線程執(zhí)行完宏任務setTimeout2之后,調用棧為空,去讀取微任務隊列,此時,微任務隊列只有一個微任務Promise2,執(zhí)行微任務中的任務打印Promise2
當主線程執(zhí)行完微任務Promise2之后,調用棧為空,去讀取宏任務隊列,此時,宏任務隊列就只剩下setTimeout1了,執(zhí)行setTimeout1打印setTimeout1。