
寫在前面
從公眾號(hào)建號(hào)至今,發(fā)了不少技術(shù)文,基本上每篇都有提到基礎(chǔ)的重要性。
不久前我發(fā)了個(gè)朋友圈,內(nèi)容是這樣的「學(xué)習(xí),應(yīng)該先學(xué)習(xí)更好的思維,而不是更多的知識(shí),在一個(gè)落后的思維模式里,增加再多的信息量,也只是低水平的重復(fù)?!?/p>
我向來強(qiáng)調(diào)基礎(chǔ)知識(shí)的重要性,基礎(chǔ)打牢了,框架,真的非常容易學(xué)
好了,不啰嗦了,步入正題
作為JavaScript使用人員,V8引擎作為一個(gè)概念,我想大多數(shù)人都聽說過,而且絕大多數(shù)人也知道JavaScript是一個(gè)單線程語言,或者知道JavaScript使用的是回調(diào)隊(duì)列的形式
在這篇文章中,我們來詳細(xì)解釋這些概念,從而來解釋JavaScript是如何運(yùn)行的
希望詳細(xì)了解了這些內(nèi)容后,你可以寫出更好的非阻塞的代碼,并且可以正確的使用JavaScript的API
JavaScript引擎
Google的V8引擎是現(xiàn)在最流行的JavaScript引擎,以Chrome為代表的瀏覽器,還有Node.js都是使用的V8引擎。
那什么是JavaScript引擎呢?
JavaScript引擎是執(zhí)行JavaScript代碼的程序或解釋器。 JavaScript引擎可以實(shí)現(xiàn)為標(biāo)準(zhǔn)解釋器,或即時(shí)編譯器,它以某種形式將JavaScript編譯為字節(jié)碼。
咱們不糾結(jié)于這個(gè)概念,糾結(jié)概念不是咱們應(yīng)該做的事情。但是概念必須熟記,因?yàn)楦拍钍情L篇大論的論證后得出的精髓。后面的文章中咱們?cè)冁告傅纴?/p>
下面主要簡單描述下JavaScript引擎
一個(gè)非常簡單的視圖,看看引擎包含了什么:

V8引擎由兩個(gè)主要的組件組成
- Memory Heap(內(nèi)存堆)--內(nèi)存分配的任務(wù)就在這里面完成
- Call Stack(調(diào)用棧)--這是代碼執(zhí)行時(shí)堆棧調(diào)用的位置
JavaScript運(yùn)行時(shí)
幾乎所有的JavaScript開發(fā)人員都使用過瀏覽器中的API(例如:setTimeout)。
其實(shí)這些API都不是由引擎提供的。
那么,這些API是從哪里來的呢?是由宿主提供的。
宿主是指JavaScript的運(yùn)行環(huán)境。運(yùn)行在瀏覽器上那么宿主就是指瀏覽器,運(yùn)行在Node.js上宿主就是Node
就像前端界大牛winter說的那樣,我們應(yīng)該形成感性的認(rèn)知:一個(gè)JavaScript引擎會(huì)常駐在內(nèi)存中,它等待著宿主把JavaScript代碼或者函數(shù)傳遞給它執(zhí)行
那宿主把JavaScript傳遞給引擎后,引擎是怎么處理的呢?這就牽扯到調(diào)用棧與循環(huán)隊(duì)列了,繼續(xù)往下看,一個(gè)一個(gè)的說
調(diào)用棧(Call Stack)
先來說說調(diào)用棧
JavaScript是一個(gè)單線程語言,這意味著它只有一個(gè)堆棧,因此,它每次只能完成一件事情。
這個(gè)堆棧調(diào)用,是一種數(shù)據(jù)結(jié)構(gòu),它記錄了程序中的位置,如果我們進(jìn)入函數(shù),如果我們進(jìn)入一個(gè)函數(shù),就將這個(gè)函數(shù)放在這個(gè)棧的頂部,如果我們從函數(shù)返回(return),那么就將這個(gè)函數(shù)從棧的頂部彈出。
我們現(xiàn)在給引擎一串JavaScript代碼,看看引擎是怎么執(zhí)行我們的代碼的
function multiply(x, y) {
return x * y;
}
function printSquare(x) {
var s = multiply(x, x);
console.log(s);
}
printSquare(5);
當(dāng)JavaScript引擎開始執(zhí)行這個(gè)代碼時(shí),先會(huì)清空棧,棧將會(huì)如下圖執(zhí)行:

從圖中Step1,我們可以看出,我們之前定義的函數(shù)在沒有使用的時(shí)候,并沒有進(jìn)入棧中。所以未被調(diào)用的函數(shù)并不會(huì)存在棧中。
繼續(xù)看Step1,我們使用了printSquare,那么就要進(jìn)入這個(gè)函數(shù)進(jìn)去看看它里面到底實(shí)現(xiàn)了什么。此時(shí)我們就將我們進(jìn)入的這個(gè)函數(shù)放到棧的最上方(雖然只有它自己但我們也要這樣描述)
我們進(jìn)入這個(gè)函數(shù)后,發(fā)現(xiàn)這貨還調(diào)用了別的函數(shù)multiply,沒辦法,只好再進(jìn)入multiply,也就到了Step2,將multiply放入棧的最頂部
執(zhí)行完multiply后,將它從棧的頂部丟出去,繼續(xù)console。log(s),也就可以看到是Step3
以此類推,直到調(diào)用棧清空。JavaScript引擎則將我們的代碼執(zhí)行完畢。
上圖棧中的每一步,均被稱為堆棧幀(Stack Frame),每一幀都代表著棧的變動(dòng)
我們?cè)賮砜纯?,?dāng)拋出錯(cuò)誤時(shí),堆棧如何構(gòu)造跟蹤的
當(dāng)異常發(fā)生時(shí),基本上是調(diào)用堆棧的狀態(tài)
function foo() {
throw new Error('SessionStack will help you resolve crashes :)');
}
function bar() {
foo();
}
function start() {
bar();
}
start();
在Chrome瀏覽器中執(zhí)行上述代碼,將產(chǎn)生以下堆棧跟蹤

這個(gè)錯(cuò)誤信息怎么看呢?從上往下看,這就是一個(gè)調(diào)用棧的內(nèi)容,現(xiàn)在處于頂部的函數(shù)拋出異常,無法正常的從棧頂部彈出
再來說說堆棧溢出,當(dāng)達(dá)到最大調(diào)用棧大小時(shí),就非常容易發(fā)生「堆棧溢出」
function foo() {
foo();
}
foo();
當(dāng)JavaScript引擎開始執(zhí)行上述代碼時(shí),它開始調(diào)用函數(shù)foo,并且這個(gè)函數(shù)還會(huì)自己調(diào)用自己,還沒有任何終止條件。
所以,在執(zhí)行的每個(gè)步驟中,函數(shù)會(huì)一遍又一遍的添加到調(diào)用棧中,就像下圖這樣:

但是,有些時(shí)候,調(diào)用堆棧中的函數(shù)調(diào)用超過調(diào)用堆棧的實(shí)際大小時(shí),瀏覽器會(huì)采取措施,拋出錯(cuò)誤,如下圖這樣:

在單線程上運(yùn)行代碼非常簡單,因?yàn)椴槐乜紤]多線程環(huán)境中出現(xiàn)的復(fù)雜場景,比如死鎖、讀寫一致等
但是在單個(gè)線程上運(yùn)行也是非常有限的,由于JavaScript只有一個(gè)調(diào)用棧,當(dāng)調(diào)用的某個(gè)函數(shù),執(zhí)行的非常緩慢時(shí),我們又該怎么辦呢?
并發(fā)與事件循環(huán)
如果在調(diào)用堆棧中有的函數(shù)需要花費(fèi)大量的時(shí)間才能處理時(shí),那后面的內(nèi)容不就卡死了么?
比如說在JavaScript中進(jìn)行一些復(fù)雜的圖像處理。問題就在調(diào)用棧在執(zhí)行這個(gè)圖像處理函數(shù)時(shí),它是無法再做任何別的事情的。
這意味著瀏覽器無法渲染,無法運(yùn)行任何其它的代碼,看起來它就像是卡住了。
并且這還不是唯一的問題,一旦瀏覽器在調(diào)用棧中開始處理大量的任務(wù),瀏覽器可能就會(huì)停止響應(yīng),并且大量的瀏覽器會(huì)報(bào)錯(cuò),告訴你當(dāng)前頁面崩潰了
網(wǎng)頁都崩掉了,還有用戶體驗(yàn)可言么?
如果想網(wǎng)頁流程,那么就需要避免此類問題。
那么,我們?nèi)绾卧诓蛔枞鸘I并使瀏覽器無響應(yīng)的情況下執(zhí)行繁重的代碼呢?這依靠的就是JavaScript的「異步回調(diào)」
這時(shí)候就牽扯到JavaScript引擎的異步與事件循環(huán)
在這兒,我覺得GitHub上用戶「@Mavericker-1996」的回答已經(jīng)說的非常詳細(xì)到位,在此我就不重復(fù)造輪子了,只是略加修改
任務(wù)隊(duì)列
首先我們需要明白以下幾件事情:
- JS分為同步任務(wù)和異步任務(wù)
- 同步任務(wù)都在主線程上執(zhí)行,形成一個(gè)調(diào)用棧
- 主線程之外,事件觸發(fā)線程管理著一個(gè)任務(wù)隊(duì)列,只要異步任務(wù)有了運(yùn)行結(jié)果,就在任務(wù)隊(duì)列之中放置一個(gè)事件
- 一旦執(zhí)行棧中的所有同步任務(wù)執(zhí)行完畢(此時(shí)JS引擎空閑),系統(tǒng)就會(huì)讀取任務(wù)隊(duì)列,將可運(yùn)行的異步任務(wù)添加到可執(zhí)行棧中,開始執(zhí)行
根據(jù)規(guī)范,事件循環(huán)是通過任務(wù)隊(duì)列的機(jī)制來進(jìn)行協(xié)調(diào)的。
一個(gè) Event Loop 中,可以有一個(gè)或者多個(gè)任務(wù)隊(duì)列(task queue)。
一個(gè)任務(wù)隊(duì)列便是一系列有序任務(wù)(task)的集合,每個(gè)任務(wù)都有一個(gè)任務(wù)源(task source),源自同一個(gè)任務(wù)源的 task 必須放到同一個(gè)任務(wù)隊(duì)列,從不同源來的則被添加到不同隊(duì)列。 setTimeout/Promise 等API便是任務(wù)源,而進(jìn)入任務(wù)隊(duì)列的是他們指定的具體執(zhí)行任務(wù)。

宏任務(wù)
(macro)task(又稱之為宏任務(wù)),可以理解是每次執(zhí)行棧執(zhí)行的代碼就是一個(gè)宏任務(wù)(包括每次從事件隊(duì)列中獲取一個(gè)事件回調(diào)并放到執(zhí)行棧中執(zhí)行)。
瀏覽器為了能夠使得JS內(nèi)部(macro)task與DOM任務(wù)能夠有序的執(zhí)行,會(huì)在一個(gè)(macro)task執(zhí)行結(jié)束后,在下一個(gè)(macro)task 執(zhí)行開始前,對(duì)頁面進(jìn)行重新渲染,流程如下:
(macro)task->渲染->(macro)task->...
(macro)task主要包含:script(整體代碼)、setTimeout、setInterval、I/O、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 環(huán)境)
微任務(wù)
microtask(又稱為微任務(wù)),可以理解是在當(dāng)前 task 執(zhí)行結(jié)束后立即執(zhí)行的任務(wù)。也就是說,在當(dāng)前task任務(wù)后,下一個(gè)task之前,在渲染之前。
所以它的響應(yīng)速度相比setTimeout(setTimeout是task)會(huì)更快,因?yàn)闊o需等渲染。也就是說,在某一個(gè)macrotask執(zhí)行完后,就會(huì)將在它執(zhí)行期間產(chǎn)生的所有microtask都執(zhí)行完畢(在渲染前)。
microtask主要包含:Promise.then、MutaionObserver、process.nextTick(Node.js 環(huán)境)
運(yùn)行機(jī)制
在事件循環(huán)中,每進(jìn)行一次循環(huán)操作稱為 tick,每一次 tick 的任務(wù)處理模型是比較復(fù)雜的,但關(guān)鍵步驟如下:
- 執(zhí)行一個(gè)宏任務(wù)(棧中沒有就從事件隊(duì)列中獲取)
- 執(zhí)行過程中如果遇到微任務(wù),就將它添加到微任務(wù)的任務(wù)隊(duì)列中
- 宏任務(wù)執(zhí)行完畢后,立即執(zhí)行當(dāng)前微任務(wù)隊(duì)列中的所有微任務(wù)(依次執(zhí)行)
- 當(dāng)前宏任務(wù)執(zhí)行完畢,開始檢查渲染,然后GUI線程接管渲染
- 渲染完畢后,JS線程繼續(xù)接管,開始下一個(gè)宏任務(wù)(從事件隊(duì)列中獲取)
流程圖如下:

Promise和async中的立即執(zhí)行
我們知道Promise中的異步體現(xiàn)在then和catch中,所以寫在Promise中的代碼是被當(dāng)做同步任務(wù)立即執(zhí)行的。而在async/await中,在出現(xiàn)await出現(xiàn)之前,其中的代碼也是立即執(zhí)行的。那么出現(xiàn)了await時(shí)候發(fā)生了什么呢?
await做了什么?
從字面意思上看await就是等待,await 等待的是一個(gè)表達(dá)式,這個(gè)表達(dá)式的返回值可以是一個(gè)promise對(duì)象也可以是其他值。
很多人以為await會(huì)一直等待之后的表達(dá)式執(zhí)行完之后才會(huì)繼續(xù)執(zhí)行后面的代碼。
實(shí)際上await是一個(gè)讓出線程的標(biāo)志。await后面的表達(dá)式會(huì)先執(zhí)行一遍,將await后面的代碼加入到microtask中,然后就會(huì)跳出整個(gè)async函數(shù)來執(zhí)行后面的代碼。
由于因?yàn)閍sync await 本身就是promise+generator的語法糖。所以await后面的代碼是microtask。所以對(duì)于本題中的
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
等價(jià)于
async function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(() => {
console.log('async1 end');
})
}
寫在最后
了解原理,了解執(zhí)行機(jī)制不像學(xué)習(xí)框架的使用那樣簡單,是一件略微困難的事情。
因?yàn)榭蚣芏紝⑦@些內(nèi)容給封裝起來了,并不需要我們?nèi)ミM(jìn)行處理,但是就算我們使用框架,也需要了解執(zhí)行機(jī)制,這樣我們書寫的代碼邏輯順序才不會(huì)出錯(cuò),得到的結(jié)果才能與預(yù)期一致。
基于JavaScript的框架,均是建立在此基礎(chǔ)之上
本文牽扯內(nèi)容點(diǎn)較多,有些內(nèi)容一筆帶過了,但并不代表不重要,后面我會(huì)逐一細(xì)寫。
如果對(duì)后面的并發(fā)與事件循環(huán)覺得內(nèi)容較難理解,可以先看看我之前寫的白話入門篇:6分鐘看懂Node.js武功精髓

關(guān)注微信公眾號(hào)「鬧鬧吃魚」更多有趣的內(nèi)容等著你哦