JavaScript 是怎么運(yùn)行起來的?

JavaScript 的運(yùn)行原理,是面試的時(shí)候經(jīng)常會(huì)問到的問題,但是根據(jù)過往的面試結(jié)果來看,這部分能理解的很清楚的不足 20%,大多數(shù)同學(xué)熱衷于去學(xué)習(xí)一些 Vue、React 這樣的框架,以及一些新的 API,卻忽視了語言的根本,這是個(gè)非常不好的現(xiàn)象。
今天就帶大家來一起回顧一下,JavaScript 的真正的工作原理,里面不涉及深入的源碼解析,只是希望能夠用最簡(jiǎn)單的描述讓大家弄明白整個(gè)過程,主要分為下面幾個(gè)部分:
- 解釋型和編譯型語言
- JavaScript 引擎
- EcmaScript 和 JavaScript 引擎的關(guān)系
- 運(yùn)行時(shí)環(huán)境
- 為啥是單線程
- 調(diào)用堆棧的執(zhí)行過程
- JavaScript 語言的解析過程
解釋型和編譯型語言
大家可能之前都聽說過,JavaScript 是一種解釋型的編程語言,那么啥叫解釋型語言呢?
編程語言是用來寫代碼的,代碼是給人看的。計(jì)算機(jī)只看得懂機(jī)器代碼(01010101),看不懂語言代碼。將我們能看得懂的代碼轉(zhuǎn)換為計(jì)算機(jī)可讀的機(jī)器代碼有兩種方式:解釋和編譯。

編譯型語言
編譯型語言直接可以轉(zhuǎn)換為計(jì)算機(jī)處理器可以執(zhí)行的機(jī)器代碼,運(yùn)行編譯型語言需要一個(gè) “構(gòu)建” 的步驟,每次更新了代碼你也要重新 “構(gòu)建” 。
它們會(huì)比解釋語言更快更高效地執(zhí)行。也可以更好的控制硬件,例如內(nèi)存管理和 CPU 使用率。但是,在完成整個(gè)編譯的步驟需要花費(fèi)額外的時(shí)間,生成的二進(jìn)制代碼對(duì)平臺(tái)有一定的依賴性。
常見的編譯型語言有 C、C ++、Erlang、Haskell、Rust 和 Go。
解釋型語言
解釋型語言 是通過一個(gè)解釋器逐行解釋并執(zhí)行程序的每個(gè)命令。
因?yàn)樵谶\(yùn)行時(shí)翻譯代碼的過程增加了開銷,解釋型語言曾經(jīng)比編譯型語言慢很多。但是,隨著即時(shí)編譯的發(fā)展,這種差距正在縮小。
但是,解釋型語言更靈活一點(diǎn),并且一般都能動(dòng)態(tài)植入,程序也比較小。另外,因?yàn)槭峭ㄟ^解釋器自己執(zhí)行源程序代碼的,所以代碼本身相對(duì)于平臺(tái)是獨(dú)立的。
常見的解釋型語言有 PHP、Ruby、Python 和 JavaScript。
最后再來看看,誰來編譯?誰來解釋?誰來執(zhí)行?
- 編譯型:編譯器來編譯,系統(tǒng)執(zhí)行。
- 解釋型:解釋器解釋并執(zhí)行。
JavaScript 引擎
JavaScript 是一種解釋型的編程語言,所以源代碼在執(zhí)行之前沒有被編譯成二進(jìn)制代碼。那么計(jì)算機(jī)是怎么理解和執(zhí)行純文本腳本的呢?
這就是 JavaScript 引擎的工作,也就是我們上面提到的解釋器。
JavaScript 引擎是一個(gè)執(zhí)行 JavaScript 代碼的計(jì)算機(jī)程序。基本上所有現(xiàn)代瀏覽器都內(nèi)置了 JavaScript 引擎。當(dāng)我們的瀏覽器中加載到 JavaScript 文件時(shí),JavaScript 引擎會(huì)從上到下解析(將其轉(zhuǎn)換為機(jī)器碼)并執(zhí)行文件的每一行。

每個(gè)瀏覽器都有自己的 JavaScript 引擎,其中最著名的引擎是 Google 的 V8。
Google Chrome 和 Node.js 的 JavaScript 引擎都是 V8。下面還有一些其他的常見引擎:
- SpiderMonkey:由 Firefox 開發(fā),第一款 JavaScript 引擎,用于Firefox。
- Chakra:由微軟開發(fā),用于 Microsoft Edge。
- JavaScriptCore:由蘋果開發(fā),用于 webkit 型瀏覽器,比如 Safari
所有的 JavaScript 引擎都會(huì)包含一個(gè)調(diào)用棧和一個(gè)堆:

- 內(nèi)存堆 - 這是內(nèi)存分配發(fā)生的地方,是一個(gè)非結(jié)構(gòu)化的內(nèi)存池,它存儲(chǔ)我們應(yīng)用程序需要的所有對(duì)象。
- 調(diào)用堆棧 - 是我們的代碼實(shí)際執(zhí)行的地方
EcmaScript 和 JavaScript 引擎的關(guān)系
ECMAScript 指的是 JavaScript 的語言標(biāo)準(zhǔn)及語言版本,比如 ES6 表示語言(標(biāo)準(zhǔn))的第 6 版。它由一個(gè)推動(dòng) JavaScript 發(fā)展的委員會(huì)制定,這個(gè)委員會(huì)指的是技術(shù)委員會(huì)( Technical Committee )第 39 號(hào),我們一般簡(jiǎn)稱 TC39,由各個(gè)主流瀏覽器廠商的代表以及一些互聯(lián)網(wǎng)大廠構(gòu)成。
JavaScript 引擎的核心就是實(shí)現(xiàn) ECMAScript 標(biāo)準(zhǔn),此外還提供一些額外的機(jī)制(例如 V8 提供的垃圾回收器)。
一些最新的 ECMAScript 提案,到達(dá) stage3 或 stage4 后,就會(huì)被 JavaScript 引擎實(shí)現(xiàn),例如 v8 會(huì)把它的一些對(duì)語言標(biāo)準(zhǔn)的實(shí)現(xiàn)更新在它的博客上:https://v8.dev/
運(yùn)行時(shí)環(huán)境
JavaScript 引擎并不能孤立運(yùn)行,它需要一個(gè)好的運(yùn)行時(shí)環(huán)境才能發(fā)揮更大的作用,例如 Node.js 就是一個(gè) JavaScript 運(yùn)行時(shí)環(huán)境,各種瀏覽器也是 JavaScript 的運(yùn)行時(shí)環(huán)境。
這些運(yùn)行時(shí)環(huán)境往往會(huì)提供諸如:事件處理、網(wǎng)絡(luò)請(qǐng)求 API、回調(diào)隊(duì)列或消息隊(duì)列、事件循環(huán) 這樣的附加能力。

那么 JavaScript 引擎怎么配合這些能力在運(yùn)行時(shí)環(huán)境中發(fā)揮作用呢?我們拿 Chrome 來舉個(gè)例子。

Chrome 是一個(gè)多進(jìn)程的架構(gòu),我們打開一個(gè)瀏覽器時(shí)會(huì)啟動(dòng)多個(gè)不同的進(jìn)程協(xié)助瀏覽器將頁面為我們呈現(xiàn)出來:
- 瀏覽器進(jìn)程:瀏覽器最核心的進(jìn)程,負(fù)責(zé)管理各個(gè)標(biāo)簽頁的創(chuàng)建和銷毀、頁面顯示和功能(前進(jìn),后退,收藏等)、網(wǎng)絡(luò)資源的管理,下載等。
- 插件進(jìn)程:負(fù)責(zé)每個(gè)第三方插件的使用,每個(gè)第三方插件使用時(shí)候都會(huì)創(chuàng)建一個(gè)對(duì)應(yīng)的進(jìn)程、這可以避免第三方插件crash影響整個(gè)瀏覽器、也方便使用沙盒模型隔離插件進(jìn)程,提高瀏覽器穩(wěn)定性。
- GPU進(jìn)程:負(fù)責(zé)3D繪制和硬件加速
- 渲染進(jìn)程:瀏覽器會(huì)為每個(gè)窗口分配一個(gè)渲染進(jìn)程、也就是我們常說的瀏覽器內(nèi)核,這可以避免單個(gè) page crash 影響整個(gè)瀏覽器。
我們常說的瀏覽器內(nèi)核,比如 webkit 內(nèi)核,就是瀏覽器的渲染進(jìn)程,從接收下載文件后再到呈現(xiàn)整個(gè)頁面的過程,由瀏覽器渲染進(jìn)程負(fù)責(zé)。瀏覽器內(nèi)核是多線程的,在內(nèi)核控制下各線程相互配合以保持同步,一個(gè)瀏覽器內(nèi)核通常由以下常駐線程組成:
- GUI 渲染線程:負(fù)責(zé)渲染瀏覽器界面 HTML 元素,當(dāng)界面需要重繪(Repaint)或由于某種操作引發(fā)回流(reflow)時(shí),該線程就會(huì)執(zhí)行。
- 定時(shí)觸發(fā)器線程:瀏覽器定時(shí)計(jì)數(shù)器并不是由 JavaScript 引擎計(jì)數(shù)的, 因?yàn)?JavaScript 引擎是單線程的, 如果處于阻塞線程狀態(tài)就會(huì)影響記計(jì)時(shí)的準(zhǔn)確, 因此通過單獨(dú)線程來計(jì)時(shí)并觸發(fā)定時(shí)是更為合理的方案。
- 事件觸發(fā)線程:當(dāng)一個(gè)事件被觸發(fā)時(shí)該線程會(huì)把事件添加到待處理隊(duì)列的隊(duì)尾,等待JS引擎的處理。這些事件可以是當(dāng)前執(zhí)行的代碼塊如定時(shí)任務(wù)、也可來自瀏覽器內(nèi)核的其他線程如鼠標(biāo)點(diǎn)擊、AJAX 異步請(qǐng)求等,但由于JS的單線程關(guān)系所有這些事件都得排隊(duì)等待JS引擎處理。
- 異步http請(qǐng)求線程:XMLHttpRequest 在連接后是通過瀏覽器新開一個(gè)線程請(qǐng)求, 將檢測(cè)到狀態(tài)變更時(shí),如果設(shè)置有回調(diào)函數(shù),異步線程就產(chǎn)生狀態(tài)變更事件放到 JavaScript 引擎的處理隊(duì)列中等待處理。
- JavaScript 引擎線程:解釋和執(zhí)行 JavaScript 代碼。
GUI 渲染線程與 JavaScript 引擎為互斥的關(guān)系,當(dāng) JavaScript 引擎執(zhí)行時(shí) GUI 線程會(huì)被掛起, GUI 更新會(huì)被保存在一個(gè)隊(duì)列中等到引擎線程空閑時(shí)立即被執(zhí)行。
JavaScript 是一種單線程編程語言,所以在瀏覽器內(nèi)核中只有一個(gè) JavaScript 引擎線程。
但是,在 JavaScript 的一個(gè)運(yùn)行環(huán)境中,因?yàn)榭赡苡卸鄠€(gè)渲染進(jìn)程,所以可能有多個(gè) JavaScript 引擎線程。
詳情可以見這篇文章:瀏覽器是如何調(diào)度進(jìn)程和線程的?
為啥是單線程
那么,為什么 JavaScript 不設(shè)計(jì)成多個(gè)線程呢?這樣不是效率更高?
作為瀏覽器腳本語言, JavaScript 的主要用途是與用戶互動(dòng),以及操作 DOM。這決定了它只能是單線程,否則會(huì)帶來很復(fù)雜的同步問題。比如,假定 JavaScript 同時(shí)有兩個(gè)線程,一個(gè)線程在某個(gè) DOM 節(jié)點(diǎn)上添加內(nèi)容,另一個(gè)線程刪除了這個(gè)節(jié)點(diǎn),這時(shí)瀏覽器應(yīng)該以哪個(gè)線程為準(zhǔn)?
所以,為了避免復(fù)雜性,從一誕生, JavaScript 就是單線程,這已經(jīng)成了這門語言的核心特征,將來也不會(huì)改變。
那么既然 JavaScript 本身被設(shè)計(jì)為單線程,為何還會(huì)有像 WebWorker 這樣的多線程 API 呢?我們來看一下 WebWorker 的核心特點(diǎn)就明白了:
- 創(chuàng)建 Worker 時(shí), JS 引擎向?yàn)g覽器申請(qǐng)開一個(gè)子線程(子線程是瀏覽器開的,完全受主線程控制,而且不能操作 DOM)
- JS 引擎線程與 Worker 線程間通過特定的方式通信(postMessage API,需要通過序列化對(duì)象來與線程交互特定的數(shù)據(jù))
所以 WebWorker 并不違背 JS引擎是單線程的 這一初衷,其主要用途是用來減輕 cpu 密集型計(jì)算類邏輯的負(fù)擔(dān)。
在單線程上運(yùn)行代碼非常容易,你不必處理多線程環(huán)境中出現(xiàn)的復(fù)雜場(chǎng)景 — 例如死鎖。
調(diào)用堆棧的執(zhí)行過程
JavaScript 是一種單線程編程語言,這意味著它有一個(gè)調(diào)用堆棧,一次只能做一件事。
調(diào)用堆棧是一種數(shù)據(jù)結(jié)構(gòu),它基本上記錄了我們?cè)诔绦蛑械奈恢谩H绻覀儓?zhí)行一個(gè)函數(shù),它放會(huì)放在棧頂。如果我們從一個(gè)函數(shù)返回,其會(huì)從棧頂彈出,這就是調(diào)用堆棧的執(zhí)行過程。下面這個(gè)動(dòng)圖很好的解釋了整個(gè)運(yùn)行過程:

調(diào)用堆棧中的每個(gè)條目被稱為 堆棧幀。當(dāng)調(diào)用堆棧中的一個(gè) 堆棧幀 需要大量時(shí)間才能被處理時(shí),就會(huì)產(chǎn)生卡頓,因?yàn)闉g覽器沒法做其他事情了。
JavaScript 代碼的執(zhí)行過程
我們從宏觀上看到了 JavaScript 調(diào)用堆棧是怎么執(zhí)行的,那么具體到每段代碼上是怎么解析執(zhí)行的呢?
下面我們就以 V8 為例,來看看一段 JavaScript 代碼的解析執(zhí)行過程。

上面的圖展示了 V8 大體的工作流程,畫的很復(fù)雜,我們簡(jiǎn)化一下,其實(shí)核心模塊是下面三個(gè):
- 解析器(Parser):負(fù)責(zé)將 JavaScript 代碼轉(zhuǎn)換成 AST 抽象語法樹。
- 解釋器(Ignition):負(fù)責(zé)將 AST 轉(zhuǎn)換為字節(jié)碼,并收集編譯器需要的優(yōu)化編譯信息。
- 編譯器(TurboFan):利用解釋器收集到的信息,將字節(jié)碼轉(zhuǎn)換為優(yōu)化的機(jī)器碼。
在執(zhí)行 JavaScript 代碼時(shí),首先解析器會(huì)將源碼解析為 AST 抽象語法樹,解釋器會(huì)將 AST 轉(zhuǎn)換為字節(jié)碼,一邊解釋一邊執(zhí)行。然后編譯器根據(jù)解釋器的反饋信息,優(yōu)化并編譯字節(jié)碼,最后生成優(yōu)化的機(jī)器碼,這就是 V8 大體的工作流程。
詞法分析和語法分析
我們常常提到的詞法分析和語法分析的過程就是發(fā)生在解析器(Parser)執(zhí)行階段。
詞法分析就是將字符序列轉(zhuǎn)換為標(biāo)記(token)序列的過程。
所謂 token ,就是源文件中不可再進(jìn)一步分割的一串字符,類似于英語中單詞,或漢語中的詞。
一般來說程序語言中的 token 有:常數(shù)(整數(shù)、小數(shù)、字符、字符串等),操作符(算術(shù)操作符、比較操作符、邏輯操作符),分隔符(逗號(hào)、分號(hào)、括號(hào)等),保留字,標(biāo)識(shí)符(變量名、函數(shù)名、類名等)等。
比如下面這段代碼:
const 公眾號(hào) = '微信公號(hào)名稱';
經(jīng)過詞法分析后,會(huì)被轉(zhuǎn)換為下面這些 token:
- const(保留字)
- 公眾號(hào)(變量名)
- =(賦值操操作算符)
- '微信公號(hào)名稱'(字符串常數(shù))
語法分析 將這些 token 根據(jù)語法規(guī)則轉(zhuǎn)換為 AST:
{
"type": "Program",
"start": 0,
"end": 23,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 23,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 22,
"id": {
"type": "Identifier",
"start": 6,
"end": 9,
"name": "公眾號(hào)"
},
"init": {
"type": "Literal",
"start": 12,
"end": 22,
"value": "微信公號(hào)名稱",
"raw": "'微信公號(hào)名稱'"
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
在生成 AST 的同時(shí),還會(huì)為代碼生成執(zhí)行上下文,在解析期間,所有函數(shù)體中聲明的變量和函數(shù)參數(shù),都被放進(jìn)作用域中,如果是普通變量,那么默認(rèn)值是 undefined,如果是函數(shù)聲明,那么將指向?qū)嶋H的函數(shù)對(duì)象。
字節(jié)碼和機(jī)器碼
有了 AST 和執(zhí)行上下文,解釋器會(huì)將 AST 轉(zhuǎn)換為字節(jié)碼并執(zhí)行,那么字節(jié)碼和機(jī)器碼的區(qū)別是啥呢?

- 機(jī)器碼(machine code),學(xué)名機(jī)器語言指令,有時(shí)也被稱為原生碼(Native Code),是電腦的 CPU 可直接解讀的數(shù)據(jù)(計(jì)算機(jī)只認(rèn)識(shí)0和1)。
- 字節(jié)碼(byte code)是一種包含執(zhí)行程序、由一序列 OP代碼(操作碼)/數(shù)據(jù)對(duì) 組成的二進(jìn)制文件。字節(jié)碼是一種中間碼,它比機(jī)器碼更抽象,需要直譯器轉(zhuǎn)譯后才能成為機(jī)器碼的中間代碼。
相比機(jī)器碼,字節(jié)碼不僅占用內(nèi)存少,而且生成字節(jié)碼的時(shí)間很快,提升了啟動(dòng)速度。那么機(jī)器碼什么時(shí)候用到呢?我們?cè)谖恼麻_頭提到,隨著即時(shí)編譯的發(fā)展,解釋型語言和編譯型語言的運(yùn)行速度的差距正在縮小。
同時(shí)采用了解釋執(zhí)行和編譯執(zhí)行這兩種方式,這種混合使用的方式就稱為 JIT (即時(shí)編譯),V8 采用的就是這種技術(shù)。
在解釋器執(zhí)行字節(jié)碼的過程中,如果發(fā)現(xiàn)有熱點(diǎn)代碼,比如一段代碼被重復(fù)執(zhí)行多次,這種就稱為熱點(diǎn)代碼,那么后臺(tái)的編譯器就會(huì)把該段熱點(diǎn)的字節(jié)碼編譯為高效的機(jī)器碼,然后當(dāng)再次執(zhí)行這段被優(yōu)化的代碼時(shí),只需要執(zhí)行編譯后的機(jī)器碼就可以了,這樣就大大提升了代碼的執(zhí)行效率。
最后
當(dāng)然,想要了解更詳細(xì)的執(zhí)行機(jī)制,可以去看看 V8 源碼,這篇文章主要帶大家捋清楚各種概念,讓你能夠知道運(yùn)行一段 JavaScript 背后的工作原理,想要更深入的了解,可以看看下面這些文章:。




























