從敲下一行JS代碼到這行代碼被執行,中間發生了什麼?

blank

從敲下一行JS代碼到這行代碼被執行,中間發生了什麼?

前言

我們每天都在寫JS,你是否想過,計算機是怎麼識別你的這一行代碼,並且執行相應指令?本篇文章為你講述從敲下一行JS代碼到這行代碼可以被執行算出正確的結果,都經歷了什麼。

編譯

學過計算器基礎的,即使學的不好,大概都知道計算機跟人能讀懂的語言是不一樣的,它只認識0101的二進制數。也就是機器指令碼(machine instruction code ) 。一開始,人們都是用它來寫程序,可以想到最早的程序員有多痛苦。這種二進制碼不易被人類理解和記憶, 估計出錯太多,最後終於聰明的人類終於發明了適合自己學習記憶各種高級計算機語言,也包括JS。

但是機器並不能直接理解JS語言,所以這裡就需要一個中介幫忙程序解釋並且將其編譯成機器指令碼給計算機執行。這個過程就叫編譯

而我們chrome瀏覽器裡的V8引擎就是幫我們做這個事情的中介。但是並不是只有google一家在做瀏覽器啊,所以市面上還有很多JS引擎。下面是從網上趴的圖:

blank

而另前端痛苦不堪的瀏覽器兼容問題,就是因為使用的JS引擎不同,所以能夠理解的JS語法不同,我們就需要寫好幾種兼容語法。

所以終極解決兼容問題的方法就是:全部瀏覽器都用一種JS引擎,目前v8大有一統天下的趨勢,不過這個東西最終能不能實現今天就不討論了。

編譯原理

無論是哪種編譯器,原理都差不多。所以我們直接來看看編譯原理,就知道V8大概是如何工作的了。

編譯一般分為三個步驟:

  • 詞法分析(laxical Analysis)詞法分析的意思就是,將代碼塊切分成最小的單位。這些最小單位稱為token。比如var a = 2;可以切分成var,a,=,2。
  • 語法分析(Syntactic Analysis)將詞法單元轉換成一個有層級,代表程序語法結構的樹,這就是我們經常說的AST,抽象語法樹。

注意:詞法分析跟語法分析不是完全獨立的,而是交錯運行的。也就是說,並不是等所有的token都生成之後,才用語法分析器來處理。一般都是每取得一個token,就開始用語法分析器來處理了。

下面我們來看看一個add函數會生成怎樣的語法樹:

functionadd(a,b){returna+b}
blank

生成的樹太長了,截圖不完整,可以在AST Exploer看到最終的AST。

可以看到這就是這段函數的樹形展示,如果你沒看懂,可以看這篇文章。這裡就不具體解釋每個FunctionDeclaration Identifier BlockStatement的意思了。

AST可是所有編譯器以及轉換器的基礎核心,我們常用的babel轉碼過程就是先將ES6的代碼編成AST,然後轉換成ES5的AST,最後由這個AST還原出ES5代碼。有興趣的可以看這篇文章,這篇文章是將LISP-style代碼的轉成C-style代碼,不過原理都一樣。

blank

可以說基於AST,你可以隨意玩轉各種編程語言的相互轉換。

構建語法樹,還有一層作用,就是發現語法錯誤。當JS解析器發現無法構造這個抽象語法樹的時候,就會報語法錯誤,並結束整個代碼塊的解析。而對於一些強類型語言(也就是一開始就要定義這個變量是什麼類型,後面都不能改變),在構建出語法樹之後,還會有類型檢查。但是對於JS這種弱類型語言,就沒有這一步。當然TypeScipt為我們提供了類型檢查,並且可以將我們的typeScript代碼編譯成JS。

  • 代碼生成(Code Genaration)

最後一步就是將AST轉成計算機可以識別的機器指令碼。

V8引擎的編譯過程基本就是上面這個過程,但是它多了一步生成字節碼的過程。首先用解析器生成AST,然後用解釋器Ignition根據語法樹生成字節碼,最後再用TurboFan將字節碼生成機器指令碼。

為什麼要先轉成字節碼?是因為直接生成機器指令碼太佔內存了。

整個過程就是這麼簡單了。

V8 為什麼那麼快

因為JS是解釋型語言,支持動態類型,弱類型,那就沒辦法先編譯找到變量的地址跟類型,所以JS的編譯過程發生在執行前的那段時間,對JS引擎的性能要求特別高。

blank

那麼V8是如何做到的呢?

1、腳本流(script streaming)

以前的chrome裡,網絡拿到數據之後,必須經過chrome主線程轉發到流解析器。但是,當網絡數據到達之後,主線程有可能被其他事情佔住,比如HTML解析,佈局,其他JS執行。這樣這些數據就沒辦法被即使解析。

從Chrome 75開始,V8可以將腳本直接從網絡流傳輸到流解析器中,而無需等待chrome主線程。

這意味著腳本一旦開始加載,V8就會在單獨的線程上解析。這樣下載腳本完成後幾乎立即完成解析,從而縮短頁面加載時間。

2、字節碼緩存

首次訪問頁面的時候,JS代碼會被編譯成字節碼。當再次訪問同一個頁面的時候,會直接復用首次解析出來的字節碼。這樣就省去了下載,解析,編譯的步驟,可以使chrome節省大約40%的時間。

3、內聯

如果一個函數內部調用其他函數,那麼編譯器會直接函數中將要執行的內容放到主函數里。

functionadd(a,b){returna+b;}functioncalculateTwoPlusFive(){varsum;for(vari=0;i<=1000000000;i++){sum=add(2+5);}}varstart=newDate();calculateTwoPlusFive();varend=newDate();vartimeTaken=end.valueOf()-start.valueOf();console.log("Took "+timeTaken+"ms");

內聯屬性會將這個代碼編譯成

functionadd(a,b){returna+b;}functioncalculateTwoPlusFive(){varsum;for(vari=0;i<=1000000000;i++){sum=2+5;}}varstart=newDate();calculateTwoPlusFive();varend=newDate();vartimeTaken=end.valueOf()-start.valueOf();console.log("Took "+timeTaken+"ms");複製代碼

我把這段代碼放在safari上跑需要1454ms,而chrome只需要453ms,基本只有三分之一。

4、隱藏類

對於C++/Java,訪問指令可以在編譯階段生成。

因為它們的每一個變量都有指定的類型。所以一個對象包含什麼成員,這些成員是什麼類型,在對像中的偏移量都可以在編譯階段就確定了。那麼在CPU執行的時候就輕鬆了,要訪問這個對像中的某個變量的時候,直接用對象的首地址加偏移量就可以訪問到。

但是JS是動態語言,運行的時候不僅可以隨意換類型,還可以動態添加刪除屬性。所以訪問對象屬性完全得運行的時候才能決定。

如果JS引擎每次都需要進行動態查詢,會造成大量的性能損耗。所以V8引入了隱藏類機制。在初始化對象時候,會給他創建一個隱藏類,而後增刪屬性都會在創建一個隱藏類或者查找之前已經創建好的類。

那麼這些隱藏類裡的成員對於這個類來說就是固定的。所以他們的偏移量對於這個類來說也是固定的,那麼在後續再次調用的時候就能很快的定位到他的位置。下面來看個例子:

functionPerson(name,age){this.name=name;this.age=age;}vardaisy=newPerson("daisy",32);varalice=newPerson("alice",20);daisy.email="[email protected]";daisy.job="engineer";alice.job="engineer";alice.email="[email protected]";

對於這段代碼,它的隱藏類的生成過程如下:

blank

首先兩個new Person()的時候,生成的隱藏類為C0,因為此時沒有任何屬性。當執行this.name = name;的時候多了一個屬性,於是又生成了C1。後面同理,到C2生成的時候,daisy跟alice的隱藏類都是一樣的,就是C2,此時有兩個屬性。

但是後面由於動態添加屬性的順序不同,就造成了屬性在類中的偏移量不同,也會生成不同的隱藏類。這樣就沒辦法共享隱藏類,導致浪費資源生成新的隱藏類。

所以我們動態賦值的時候,盡量保證順序也是一致的。

5、熱點函數會被直接編譯成機器碼

v8在運行的時候,會採集JS代碼運行數據。當發現某個函數被頻繁調用,那麼就會將它標記成熱點函數,並且認為他是一個類型穩定的函數。這時候會將它生成更為高效的機器碼。

但是在後面的運行中,萬一類型發生變化,V8又要回退到字節碼。

比如:

functionadd(a,b){returna+b}// 这里使用add的时候一直传入number类型
for(vari=0;i<10000;++i){add(i,i);}// 最后却传了string,会退回到字节码,会使得性能受损
add('a','b');

同理,下面兩段代碼可以猜猜誰的執行效率高?

// 片段1 var person = { add : function ( a , b ){ return a + b ; } }; obj . name = 'li' ; // 片段2 var person = { add : function ( a , b ){ return a + b ;} name : 'li' };

答案是2。結合前面知識,我們可以知道,方法一中動態添加屬性會生成一個新的隱藏類。如果add函數此時已經被轉成機器碼,那麼對於方法一來說,就沒辦法復用了。因為類都是新的了。

所以函數參數類型越穩定,對象內部屬性越穩定,V8的效率越高。

總結

從敲下一段JS代碼到它最終被計算機理解並執行,中間經歷了詞法分析,語法分析,生成機器碼,執行機器碼的過程

當然這個編譯的過程是很複雜的,尤其js還是動態語言,對於js引擎的性能要求就很高了。 V8做了很多事情來提升瀏覽器的性能,其中包括但不限於:

  1. 腳本流

下載的同時就已經在解析,節省時間

2.字節碼緩存

訪問同一個頁面的時候直接復用之前的字節碼,不在重新編譯生成

3.內聯

將主函數中調用的函數,直接換成將要執行的語句

4.隱藏類

通過隱藏類快速定位到動態加入的屬性注意:動態加入的屬性順序不一樣,會造成生成不同的隱藏類,我們動態賦值同一個構造函數對象的時候,盡量保證順序也是一致的。

5.熱點函數編譯成機器碼

將常用的函數直接一步到位編成機器碼。注意:常用的函數傳入的類型保持固定。並且對象的屬性越穩定,越有利於性能。

參考文檔
1、 juejin.im/post/5ada727c

2、 the-super-tiny-compiler.glitch.me

3、 blog.csdn.net/weixin_34

4、 segmentfault.com/a/1190

5、 blog.csdn.net/weixin_34

6、 s0v80dev.icopy.site/blo

7、 blog.csdn.net/weixin_34

8、 juejin.im/post/5c36fc33

What do you think?

Written by marketer

blank

JS正則裡面“?”的用處

2019 前端之路