【譯】開發作了這麼多年,你真的瞭解 JS 工做機制嗎?

本文的主題是 JavaScript,但不是講它的功能,語法之類——相反,我要談的是JS 的工做機制,以及與此相關的一些基本術語。下面進入主題。javascript

相關術語

若是你曾看過 JS 的維基百科之類的資料,那麼確定會對一系列的術語印象深入,諸如高級(high-level)、解釋(interpreted)、JIT 編譯、動態類型、基於原型(prototype-based)等等。其中有些術語很好理解,有經驗的程序員確定早就熟悉了;但也有些看起來很陌生。並且就算你不須要了解全部這些術語也能寫代碼,這些知識也確定能夠幫助你更好地理解語言和編程。因此想要理解 JS 的工做機制,通常來講先要學習這些術語的含義…html

從更高層級入手

JS 開發者並不怎麼關心他們的代碼是如何工做的,或者至少沒這個必要。由於 JS 是一種高級語言。這意味着全部細節,例如數據如何存儲在存儲器(RAM)中或 CPU 執行指令的方式等,對程序員都是隱藏起來的。而「高」這個字表示的是語言提供的抽象或簡化級別。java

機器碼

最底層的是機器碼。不少人都知道,機器碼只是以特定方式排列的一組 0 和 1,不一樣的排列方式對機器來講有不一樣的含義。有些可能表示特定指令、有些表示數據,諸如此類。node

彙編語言

機器碼上面一級是彙編語言——也是最低級編程語言,只比機器碼高級。與機器碼相比,彙編代碼的形式能夠被人類理解。也就是說你能接觸到的最底層語言就是彙編(用不着看機器碼手冊也能理解)。儘管如此,就算彙編語言具備「可讀性」,使用 ADD 或MOV等指令實際編寫彙編代碼也是一項很是艱鉅的任務。甚至你須要爲各個不一樣的目標處理器架構編寫不一樣的彙編代碼(例如桌面上的x86-64架構和移動設備上的ARM架構)!連操做系統都須要分別考慮!顯然這和咱們熟知的 JS 徹底不是一回事吧。無論怎樣,因爲彙編代碼仍然只是一個抽象,爲了運行程序也要先編譯才行,或者用一個名爲彙編器的實用程序組裝成機器碼的形式。有意思的是許多彙編器甚至不是用純彙編語言寫的,頗有趣不是嗎。程序員

高級

從彙編語言往上走,咱們終於看到了許多人都很是熟悉的語言——最著名的是C 和 C++。在這個級別中,咱們編寫的代碼與咱們在 JS 中看到的代碼更像一些。但咱們仍然能夠訪問各類各樣的「低級」(與 JS 相比)工具,也仍然須要用這些工具本身管理(分配 / 釋放)內存。以後要經過名爲編譯器的程序將代碼(間接)編譯爲機器碼(中間會涉及彙編步驟)。注意彙編器和編譯器的區別——編譯器位於更高級別的抽象和機器代碼之間,它能作的事情比彙編器多得多。這就是爲何 C 代碼是「可移植的」,能夠編寫一次並編譯到不少平臺和架構中,相似的優點還有不少。編程

更高級

C++ 已經被認爲是一種高級語言了,那麼什麼語言更高級呢?沒錯,就是JavaScript。JS 是一種在其引擎中運行的語言,最流行的引擎是V8,這個引擎是用 C++ 編寫的。這也是爲何 JS 通常被看做是一種解釋性語言(不是徹底正確,後文會具體說明)。這意味着你編寫的 JS 代碼不會被編譯以後運行(像 C++ 那樣),而是由一個名爲解釋器的程序運行。api

如你所見,JS 確實是一種很是高級的語言。這有不少好處,主要優點在於程序員沒必要考慮那些當咱們「失敗」時就會變得可見的細節。這種高抽象級別的惟一缺點是性能損失。雖然 JS 速度很快,還在變得愈來愈快,可是你們都知道一段程序用 C++ 寫(假設它寫得很好)每每比用 JS 寫的更快。但更高層次的抽象仍是提升了開發人員的生產力,也讓編程更加輕鬆一些。這是一種折衷方案,從這裏也能看出爲何各類編程語言都有本身最適合的應用場景。瀏覽器

固然上面講的這些都只是底層機制的簡化描述,因此大概看一下就好。接下來咱們將繼續探索最高級別的抽象,也就是 JS 的工做機制。數據結構

設計

我在以前的 文章中提到過,全部 JS 實現(本質上只是不一樣的引擎,如V8和SpiderMonkey等)都要遵循同一份 ECMAScript 規範,以保持語言的完整兼容性。許多與 JS 相關的概念就源於這份規範。

動態類型和弱類型

在這份規範中有許多術語涉及到 JS 的設計及工做原理。咱們由規範得知,JS 是動態和弱類型的語言。這意味着 JS 變量的類型是隱式解析的,能夠在運行時更改(動態類型部分),而且它們不是很是嚴格地區分(弱類型部分)。所以存在像 TypeScript 這樣更高級別的抽象,而且咱們有了兩個相等運算符——一般(==)和嚴格運算符(===)。動態類型在解釋型語言中很是流行,而與之相反的靜態類型則在編譯語言中很受歡迎。多線程

多範式

關於 JS 的另外一個術語是多範式,JS 是一種多範式語言。這是由於 JS 容許你按照本身的方式編寫代碼。這意味着你的代碼能夠從聲明和函數式變爲命令式和麪向對象類型,甚至能夠混合使用這兩種範式。編程範式的話題很大,深刻探討就要另開新文了。

原型繼承

那麼 JS 是如何實現「多範式」的呢?這裏就要引入另外一個對 JS 相當重要的概念——原型繼承。如今你可能已經知道 JS 中的全部事物都是一個對象。你可能還知道面向對象編程和基於類的繼承這些術語都是什麼意思。接下來你必須知道,雖然原型繼承可能看起來和基於類的集成很像,但它們其實是徹底不一樣的。在基於原型的語言中,對象的行爲經過一個對象做爲另外一個對象的原型來複用。在這樣的原型鏈中,當給定對象沒有指定屬性時,它會在其原型中查找,找不到就繼續這個流程,直到它找到原型屬性,或者找遍底層原型也沒找到爲止。

const arr = [];
const arrPrototype = Object.getPrototypeOf(arr);
 
arr.push(1) // .push() originates in arrPrototype
複製代碼

你可能想知道基於原型的繼承是否已經被 ES6 中基於類的繼承取代(ES6 引入了類),答案是否認的。ES6 類只是基於原型繼承概念的一個很好的語法糖。

實現細節

咱們已經介紹了不少有趣的東西,但也只是剛剛觸及了皮毛而已。我剛纔提到的全部內容都是 ECMAScript 規範中的定義。但有趣的是,像事件循環甚至垃圾回收器這些都不在規範裏。ECMAScript 只關注 JS 自己,實現細節則留給其餘人解答(其餘人主要是瀏覽器廠商)。這就是爲何雖然全部 JS 引擎都遵循相同的規範,但它們管理內存的方式能夠不同,是否作 JIT 編譯也說不許,等等。那麼這一切意味着什麼呢?

JIT 編譯

咱們先來談談JIT。如前所述,將 JS 視爲一種解釋性語言是不對的。之前不少年 JS 的確是解釋性的,但最近出現了一些變化,這種假設也隨之過期了。許多流行的 JS 引擎爲了使 JS 執行更快,引入了一種稱爲 Just-In-Time 編譯的功能。簡而言之,這意味着 JS 代碼會在執行期間直接編譯成機器碼(至少 V8 是這樣作的),再也不有解釋這一步。這個流程耗時稍長,但輸出的結果性能更強。爲了在有限的時間內完成工做,V8 實際上有兩個編譯器(不算與WebAssembly 相關的內容)。其中一個是通用的,可以很是快地編譯任何 JS 代碼,但只輸出性能通常的結果;而另外一個編譯速度有點慢,是用來編譯經常使用代碼的,其輸出結果性能極高。固然,由於 JS 有動態類型的特性,這些編譯器也很差作。因此類型不變的狀況下第二個編譯器的效果最好,能讓你的代碼運行起來快得多。

但既然 JIT 這麼快的話,爲何 JS 一開始不用它呢?咱們也不太清楚,但我猜這是由於 JS 之前不須要那麼多的性能提高,並且標準解釋器更容易實現。在過去 JS 代碼通常也就那麼幾行,就算用了 JIT 也可能由於編譯開銷反而損失一些性能。但現在瀏覽器(以及許多其餘地方)使用的 JS 代碼數量顯著增長,JIT 編譯確定是走對了路。

事件循環

以前你可能據說過 JS 是在神祕的事件循環中運行的,但具體怎麼回事你還沒搞清楚。如今咱們終於要探討它的機制了,但首先須要瞭解一些背景知識。

調用棧和堆

在 JS 代碼的執行過程當中會分配兩個內存區域——調用棧。第一個性能很是高,所以用於連續執行所提供的函數。每一個函數調用在調用棧中建立一個所謂的「框架」,其中包含其局部變量的副本和 this。你能夠經過 Chrome 調試器查看它。就像在其餘與堆棧相似的數據結構中同樣,調用棧的幀被推送或彈出堆棧,具體取決於正在執行或終止的新函數。你可能見過調用棧上限溢出錯誤,一般是因爲某種形式的無限循環致使的。

談到堆,就像現實生活中同樣,JS 堆是存儲本地範圍以外對象的地方。它比調用棧慢得多。這就是爲何訪問本地變量時速度可能會快不少。堆也是存放未被訪問或使用的對象的地方,這種對象就是垃圾。有垃圾就要有垃圾回收器。須要時 JS 運行時的垃圾回收器就會激活,清理堆並釋放內存。

單線程

如今咱們知道了調用棧和堆都是什麼意思,而後就能夠討論事件循環了。你可能知道 JS 是一種單線程語言。這也不是實際規範中的定義,屬於實現細節的範疇。回顧歷史,全部 JS 實現都是單線程的。你可能瞭解瀏覽器的 Web WorkerNode.js子進程 之類的東西——但它們並不能真正使 JS 自己變成多線程的。這兩個功能確實提供了多線程能力,但它們都不是 JS 自己的一部分,而分別是 Web API 和 Node.js 運行時。

那麼事件循環是如何工做的呢?其實很簡單,JS 從不真正等待函數的返回值,而是監聽傳入的事件。這樣一來,一旦 JS 檢測到新發出的事件(好比說用戶單擊),就會調用指定的回調。而後 JS 只會等待同步代碼完成執行,全部這些都在永無止境的非阻塞循環,也就是事件循環中重複。這是很是簡化的解釋,但做爲基礎知識來講足夠了。

首先是同步

對事件循環來講,須要注意的是同步和異步代碼不會被平等對待。相反,JS 首先執行同步代碼,而後檢查任務隊列是否須要執行任何異步操做。下面是示例:

setTimeout(() => console.log("Second"), 0);
console.log("First");
/* Console: > "First" > "Second" */
複製代碼

執行上面的代碼片斷時,你應該注意到雖然 setTimeout 排在第一位,而且它的超時時間是 0,它仍然會在同步代碼以後執行。

若是你接觸過異步代碼,可能也瞭解過Promise。這裏要注意一個小細節,Promise 有本身的特殊隊列——微任務隊列。這裏只要記住這個微任務隊列比一般的任務隊列優先級更高。所以若是在隊列中有任何 Promise 在等待,它將在任何其餘異步操做(如 setTimeout)以前運行:

setTimeout(() => console.log("Third"), 0);
Promise.resolve().then(() => console.log("Second"));
console.log("First");
/* Console: > "First" > "Second" > "Third" */
複製代碼

知識好多!

如你所見,就算是基礎內容也沒那麼簡單。不過這些內容理解起來應該仍是比較容易的,並且就算你不瞭解這些東西也能編寫出優秀的 JS 代碼。我認爲只有事件循環的內容是必須瞭解的部分。但知識固然是越多越好。

英文原文

相關文章
相關標籤/搜索