前言
我們每天都在寫JS,你是否想過,計算機是怎么識別你的這一行代碼,并且執(zhí)行相應(yīng)指令?本篇文章為你講述從敲下一行JS代碼到這行代碼可以被執(zhí)行算出正確的結(jié)果,都經(jīng)歷了什么。
編譯
學(xué)過計算器基礎(chǔ)的,即使學(xué)的不好,大概都知道計算機跟人能讀懂的語言是不一樣的,它只認識0101的二進制數(shù)。也就是機器指令碼(machine code )。一開始,人們都是用它來寫程序,可以想到最早的程序員有多痛苦。這種二進制碼不易被人類理解和記憶, 估計出錯太多,最后終于聰明的人類終于發(fā)明了適合自己學(xué)習(xí)記憶各種高級計算機語言,也包括JS。
但是機器并不能直接理解JS語言,所以這里就需要一個中介幫忙程序解釋并且將其編譯成機器指令碼給計算機執(zhí)行。這個過程就叫編譯。
而我們chrome瀏覽器里的V8引擎就是幫我們做這個事情的中介。但是并不是只有g(shù)oogle一家在做瀏覽器啊,所以市面上還有很多 JS引擎。下面是從網(wǎng)上趴的圖:
而另前端痛苦不堪的瀏覽器兼容問題,就是因為使用的JS引擎不同,所以能夠理解的JS語法不同,我們就需要寫好幾種兼容語法。
所以終極解決兼容問題的方法就是:全部瀏覽器都用一種JS引擎,目前v8大有一統(tǒng)天下的趨勢,不過這個東西最終能不能實現(xiàn)今天就不討論了。
編譯原理
無論是哪種編譯器,原理都差不多。所以我們直接來看看編譯原理,就知道V8大概是如何工作的了。
編譯一般分為三個步驟:
注意:詞法分析跟語法分析不是完全獨立的,而是交錯運行的。也就是說,并不是等所有的token都生成之后,才用語法分析器來處理。一般都是每取得一個token,就開始用語法分析器來處理了。
下面我們來看看一個add函數(shù)會生成怎樣的語法樹:
function?add?(a,?b)?{
?return?a?+?b
}
生成的樹太長了,截圖不完整,可以在AST Exploer看到最終的AST。
可以看到這就是這段函數(shù)的樹形展示,如果你沒看懂,可以看這篇文章。這里就不具體解釋每個 的意思了。
AST可是所有編譯器以及轉(zhuǎn)換器的基礎(chǔ)核心,我們常用的babel轉(zhuǎn)碼過程就是先將ES6的代碼編成AST,然后轉(zhuǎn)換成ES5的AST,最后由這個AST還原出ES5代碼。有興趣的可以看這篇文章,這篇文章是將LISP-style代碼的轉(zhuǎn)成C-style代碼,不過原理都一樣。
可以說基于AST,你可以隨意玩轉(zhuǎn)各種編程語言的相互轉(zhuǎn)換。
構(gòu)建語法樹,還有一層作用,就是發(fā)現(xiàn)語法錯誤。當(dāng)JS解析器發(fā)現(xiàn)無法構(gòu)造這個抽象語法樹的時候,就會報語法錯誤,并結(jié)束整個代碼塊的解析。而對于一些強類型語言(也就是一開始就要定義這個變量是什么類型,后面都不能改變),在構(gòu)建出語法樹之后,還會有類型檢查。但是對于JS這種弱類型語言,就沒有這一步。當(dāng)然為我們提供了類型檢查,并且可以將我們的代碼編譯成JS。
最后一步就是將AST轉(zhuǎn)成計算機可以識別的機器指令碼。
V8引擎的編譯過程基本就是上面這個過程,但是它多了一步生成字節(jié)碼的過程。首先用解析器生成AST,然后用解釋器根據(jù)語法樹生成字節(jié)碼,最后再用將字節(jié)碼生成機器指令碼。
為什么要先轉(zhuǎn)成字節(jié)碼?是因為直接生成機器指令碼太占內(nèi)存了。
整個過程就是這么簡單了。
V8 為什么那么快
因為JS是解釋型語言,支持動態(tài)類型,弱類型,那就沒辦法先編譯找到變量的地址跟類型,所以JS的編譯過程發(fā)生在執(zhí)行前的那段時間,對JS引擎的性能要求特別高。
那么V8是如何做到的呢?
1、腳本流(script )
以前的chrome里,網(wǎng)絡(luò)拿到數(shù)據(jù)之后,必須經(jīng)過chrome主線程轉(zhuǎn)發(fā)到流解析器。但是,當(dāng)網(wǎng)絡(luò)數(shù)據(jù)到達之后,主線程有可能被其他事情占住,比如HTML解析,布局,其他JS執(zhí)行。這樣這些數(shù)據(jù)就沒辦法被即使解析。
從Chrome 75開始,V8可以將腳本直接從網(wǎng)絡(luò)流傳輸?shù)搅鹘馕銎髦校鵁o需等待chrome主線程。
這意味著腳本一旦開始加載,V8就會在單獨的線程上解析。這樣下載腳本完成后幾乎立即完成解析,從而縮短頁面加載時間。
2、字節(jié)碼緩存
首次訪問頁面的時候,JS代碼會被編譯成字節(jié)碼。當(dāng)再次訪問同一個頁面的時候,會直接復(fù)用首次解析出來的字節(jié)碼。這樣就省去了下載,解析,編譯的步驟,可以使chrome節(jié)省大約40%的時間。
3、內(nèi)聯(lián)
如果一個函數(shù)內(nèi)部調(diào)用其他函數(shù),那么編譯器會直接函數(shù)中將要執(zhí)行的內(nèi)容放到主函數(shù)里。
function?add(a,?b)?{
????return?a?+?b;
}
function?calculateTwoPlusFive()?{
????var?sum;
????for?(var?i?=?0;?i?<=?1000000000;?i++)?{
????????sum?=add(2+5);
????}
}
var?start?=?new?Date();
calculateTwoPlusFive();
var?end?=?new?Date();
var?timeTaken?=?end.valueOf()?-?start.valueOf();
console.log("Took?"?+?timeTaken?+?"ms");
內(nèi)聯(lián)屬性會將這個代碼編譯成
function?add(a,?b)?{
????return?a?+?b;
}
function?calculateTwoPlusFive()?{
???var?sum;
???for?(var?i=0;i<=1000000000;i++){
???????sum?=?2?+?5;
???}
}
var?start?=?new?Date();
calculateTwoPlusFive();
var?end?=?new?Date();
var?timeTaken?=?end.valueOf()?-?start.valueOf();
console.log("Took?"?+?timeTaken?+?"ms");復(fù)制代碼
我把這段代碼放在safari上跑需要1454ms,而chrome只需要453ms,基本只有三分之一。
4、隱藏類
對于C++/Java,訪問指令可以在編譯階段生成。
因為它們的每一個變量都有指定的類型。所以一個對象包含什么成員,這些成員是什么類型,在對象中的偏移量都可以在編譯階段就確定了。那么在CPU執(zhí)行的時候就輕松了,要訪問這個對象中的某個變量的時候,直接用對象的首地址加偏移量就可以訪問到。
但是JS是動態(tài)語言,運行的時候不僅可以隨意換類型,還可以動態(tài)添加刪除屬性。所以訪問對象屬性完全得運行的時候才能決定。
如果JS引擎每次都需要進行動態(tài)查詢,會造成大量的性能損耗。所以V8引入了隱藏類機制。在初始化對象時候,會給他創(chuàng)建一個隱藏類,而后增刪屬性都會在創(chuàng)建一個隱藏類或者查找之前已經(jīng)創(chuàng)建好的類。
那么這些隱藏類里的成員對于這個類來說就是固定的。所以他們的偏移量對于這個類來說也是固定的,那么在后續(xù)再次調(diào)用的時候就能很快的定位到他的位置。下面來看個例子:
function?Person(name,?age)?{
????this.name?=?name;
????this.age?=?age;
}
var?daisy?=?new?Person("daisy",?32);
var?alice?=?new?Person("alice",?20);
daisy.email?=?"daisy@qq.com";
daisy.job?=?"engineer";
alice.job?=?"engineer";
alice.email?=?"alice@qq.com";
對于這段代碼,它的隱藏類的生成過程如下:
首先兩個new Person()的時候,生成的隱藏類為C0,因為此時沒有任何屬性。當(dāng)執(zhí)行this.name = name;的時候多了一個屬性,于是又生成了C1。后面同理,到C2生成的時候,daisy跟alice的隱藏類都是一樣的,就是C2,此時有兩個屬性。
但是后面由于動態(tài)添加屬性的順序不同,就造成了屬性在類中的偏移量不同,也會生成不同的隱藏類。這樣就沒辦法共享隱藏類,導(dǎo)致浪費資源生成新的隱藏類。
所以我們動態(tài)賦值的時候,盡量保證順序也是一致的。
5、熱點函數(shù)會被直接編譯成機器碼
v8在運行的時候,會采集JS代碼運行數(shù)據(jù)。當(dāng)發(fā)現(xiàn)某個函數(shù)被頻繁調(diào)用,那么就會將它標(biāo)記成熱點函數(shù),并且認為他是一個類型穩(wěn)定的函數(shù)。這時候會將它生成更為高效的機器碼。
但是在后面的運行中,萬一類型發(fā)生變化,V8又要回退到字節(jié)碼。
比如:
function?add(a,?b){
return?a?+?b?
}?
//?這里使用add的時候一直傳入number類型
for(var?i=0;?i<10000;?++i){
add(i,?i);?
}?
//?最后卻傳了string,會退回到字節(jié)碼,會使得性能受損
add('a',?'b');
同理,下面兩段代碼可以猜猜誰的執(zhí)行效率高?
//?片段?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。結(jié)合前面知識,我們可以知道,方法一中動態(tài)添加屬性會生成一個新的隱藏類。如果add函數(shù)此時已經(jīng)被轉(zhuǎn)成機器碼,那么對于方法一來說,就沒辦法復(fù)用了。因為類都是新的了。
所以函數(shù)參數(shù)類型越穩(wěn)定,對象內(nèi)部屬性越穩(wěn)定,V8的效率越高。
總結(jié)
從敲下一段JS代碼到它最終被計算機理解并執(zhí)行,中間經(jīng)歷了詞法分析,語法分析,生成機器碼,執(zhí)行機器碼的過程。
當(dāng)然這個編譯的過程是很復(fù)雜的,尤其js還是動態(tài)語言,對于js引擎的性能要求就很高了。V8做了很多事情來提升瀏覽器的性能,其中包括但不限于:
腳本流
下載的同時就已經(jīng)在解析,節(jié)省時間
2.字節(jié)碼緩存
訪問同一個頁面的時候直接復(fù)用之前的字節(jié)碼,不在重新編譯生成
3.內(nèi)聯(lián)
將主函數(shù)中調(diào)用的函數(shù),直接換成將要執(zhí)行的語句
4.隱藏類
通過隱藏類快速定位到動態(tài)加入的屬性注意:動態(tài)加入的屬性順序不一樣,會造成生成不同的隱藏類,我們動態(tài)賦值同一個構(gòu)造函數(shù)對象的時候,盡量保證順序也是一致的。
5.熱點函數(shù)編譯成機器碼
將常用的函數(shù)直接一步到位編成機器碼。注意:常用的函數(shù)傳入的類型保持固定。并且對象的屬性越穩(wěn)定,越有利于性能。
分享前端好文,點亮?在看?
*請認真填寫需求信息,我們會在24小時內(nèi)與您取得聯(lián)系。