JS引擎深入分析


轉載自阮一峰:http://javascript.ruanyifeng.com/bom/engine.html

目錄

1.JavaScript代碼嵌入網頁的方法

JavaScript代碼只有嵌入網頁,才能在用戶瀏覽網頁時運行。

網頁中嵌入JavaScript代碼,主要有四種方法。

  • <script>標簽:代碼嵌入網頁
  • <script>標簽:加載外部腳本
  • 事件屬性:代碼寫入HTML元素的事件處理屬性,比如onclick或者onmouseover
  • URL協議:URL支持以javascript:協議的方式,執行JavaScript代碼

 

后兩種方法用得很少,常用的是前兩種方法。由於內容(HTML代碼)和行為代碼(JavaScript)應該分離,所以第一種方法應當謹慎使用。

1.1 script標簽:代碼嵌入網頁

通過<script>標簽,可以直接將JavaScript代碼嵌入網頁。

<script>
  console.log('Hello World');
</script>

<script>標簽有一個type屬性,用來指定腳本類型。對JavaScript腳本來說,type屬性可以設為兩種值。

  • text/javascript:這是默認值,也是歷史上一貫設定的值。如果你省略type屬性,默認就是這個值。對於老式瀏覽器,設為這個值比較好。
  • application/javascript:對於較新的瀏覽器,建議設為這個值。
<script type="application/javascript">
  console.log('Hello World');
</script>

由於<script>標簽默認就是JavaScript代碼。所以,嵌入JavaScript腳本時,type屬性也可以省略。

如果type屬性的值,瀏覽器不認識,那么它不會執行其中的代碼。利用這一點,可以在<script>標簽之中嵌入任意的文本內容,然后加上一個瀏覽器不認識的type屬性即可。

<script id="mydata" type="x-custom-data">
  console.log('Hello World');
</script>

上面的代碼,瀏覽器不會執行,也不會顯示它的內容,因為不認識它的type屬性。但是,這個<script>節點依然存在於DOM之中,可以使用<script>節點的text屬性讀出它的內容。

document.getElementById('mydata').text
// "
//   console.log('Hello World');
// "

1.2 script標簽:加載外部腳本

<script>標簽也可以指定加載外部的腳本文件。

<script src="example.js"></script>

如果腳本文件使用了非英語字符,還應該注明編碼。

<script charset="utf-8" src="example.js"></script>

所加載的腳本必須是純的 JavaScript 代碼,不能有HTML代碼和<script>標簽。

加載外部腳本和直接添加代碼塊,這兩種方法不能混用。下面代碼的console.log語句直接被忽略。

<script charset="utf-8" src="example.js">
  console.log('Hello World!');
</script>

為了防止攻擊者篡改外部腳本,script標簽允許設置一個integrity屬性,寫入該外部腳本的Hash簽名,用來驗證腳本的一致性。

<script src="/assets/application.js"
  integrity="sha256-TvVUHzSfftWg1rcfL6TIJ0XKEGrgLyEq6lEpcmrG9qs=">
</script>

上面代碼中,script標簽有一個integrity屬性,指定了外部腳本/assets/application.js的 SHA256 簽名。一旦有人改了這個腳本,導致 SHA256 簽名不匹配,瀏覽器就會拒絕加載。

1.3 事件屬性

某些HTML元素的事件屬性(比如onclickonmouseover),可以寫入JavaScript代碼。當指定事件發生時,就會調用這些代碼。

<div onclick="alert('Hello')"></div>

上面的事件屬性代碼只有一個語句。如果有多個語句,用分號分隔即可。

1.4 URL協議

URL支持javascript:協議,調用這個URL時,就會執行JavaScript代碼。

<a href="javascript:alert('Hello')"></a>

瀏覽器的地址欄也可以執行javascipt:協議。將javascript:alert('Hello')放入地址欄,按回車鍵,就會跳出提示框。

如果JavaScript代碼返回一個字符串,瀏覽器就會新建一個文檔,展示這個字符串的內容,原有文檔的內容都會消失。

<a href="javascript:new Date().toLocaleTimeString();">
  What time is it?
</a>

上面代碼中,用戶點擊鏈接以后,會打開一個新文檔,里面有當前時間。

如果返回的不是字符串,那么瀏覽器不會新建文檔,也不會跳轉。

<a href="javascript:console.log(new Date().toLocaleTimeString())">
What time is it?
</a>

上面代碼中,用戶點擊鏈接后,網頁不會跳轉,只會在控制台顯示當前時間。

javascript:協議的常見用途是書簽腳本Bookmarklet。由於瀏覽器的書簽保存的是一個網址,所以javascript:網址也可以保存在里面,用戶選擇這個書簽的時候,就會在當前頁面執行這個腳本。為了防止書簽替換掉當前文檔,可以在腳本最后返回void 0

2.script標簽

2.1 工作原理

瀏覽器加載JavaScript腳本,主要通過<script>標簽完成。正常的網頁加載流程是這樣的。

  1. 瀏覽器一邊下載HTML網頁,一邊開始解析
  2. 解析過程中,發現<script>標簽
  3. 暫停解析,網頁渲染的控制權轉交給JavaScript引擎
  4. 如果<script>標簽引用了外部腳本,就下載該腳本,否則就直接執行
  5. 執行完畢,控制權交還渲染引擎,恢復往下解析HTML網頁

加載外部腳本時,瀏覽器會暫停頁面渲染,等待腳本下載並執行完成后,再繼續渲染。原因是JavaScript可以修改DOM(比如使用document.write方法),所以必須把控制權讓給它,否則會導致復雜的線程競賽的問題。

如果外部腳本加載時間很長(比如一直無法完成下載),就會造成網頁長時間失去響應,瀏覽器就會呈現“假死”狀態,這被稱為“阻塞效應”。

為了避免這種情況,較好的做法是將<script>標簽都放在頁面底部,而不是頭部。這樣即使遇到腳本失去響應,網頁主體的渲染也已經完成了,用戶至少可以看到內容,而不是面對一張空白的頁面。

如果某些腳本代碼非常重要,一定要放在頁面頭部的話,最好直接將代碼嵌入頁面,而不是連接外部腳本文件,這樣能縮短加載時間。

將腳本文件都放在網頁尾部加載,還有一個好處。在DOM結構生成之前就調用DOM,JavaScript會報錯,如果腳本都在網頁尾部加載,就不存在這個問題,因為這時DOM肯定已經生成了。

<head>
  <script>
    console.log(document.body.innerHTML);
  </script>
</head>
<body>
</body>

上面代碼執行時會報錯,因為此時document.body元素還未生成。

一種解決方法是設定DOMContentLoaded事件的回調函數。

<head>
  <script>
    document.addEventListener(
      'DOMContentLoaded',
      function (event) {
        console.log(document.body.innerHTML);
      }
    );
  </script>
</head>

另一種解決方法是,使用<script>標簽的onload屬性。當<script>標簽指定的外部腳本文件下載和解析完成,會觸發一個load事件,可以把所需執行的代碼,放在這個事件的回調函數里面。

<script src="jquery.min.js" onload="console.log(document.body.innerHTML)">
</script>

但是,如果將腳本放在頁面底部,就可以完全按照正常的方式寫,上面兩種方式都不需要。

<body>
  <!-- 其他代碼  -->
  <script>
    console.log(document.body.innerHTML);
  </script>
</body>

如果有多個script標簽,比如下面這樣。

<script src="a.js"></script>
<script src="b.js"></script>

瀏覽器會同時並行下載a.jsb.js,但是,執行時會保證先執行a.js,然后再執行b.js,即使后者先下載完成,也是如此。也就是說,腳本的執行順序由它們在頁面中的出現順序決定,這是為了保證腳本之間的依賴關系不受到破壞。當然,加載這兩個腳本都會產生“阻塞效應”,必須等到它們都加載完成,瀏覽器才會繼續頁面渲染。

Gecko和Webkit引擎在網頁被阻塞后,會生成第二個線程解析文檔,下載外部資源,但是不會修改DOM,網頁還是處於阻塞狀態。

解析和執行CSS,也會產生阻塞。Firefox會等到腳本前面的所有樣式表,都下載並解析完,再執行腳本;Webkit則是一旦發現腳本引用了樣式,就會暫停執行腳本,等到樣式表下載並解析完,再恢復執行。

此外,對於來自同一個域名的資源,比如腳本文件、樣式表文件、圖片文件等,瀏覽器一般最多同時下載六個(IE11允許同時下載13個)。如果是來自不同域名的資源,就沒有這個限制。所以,通常把靜態文件放在不同的域名之下,以加快下載速度。

2.2 defer屬性

為了解決腳本文件下載阻塞網頁渲染的問題,一個方法是加入defer屬性。

<script src="a.js" defer></script>
<script src="b.js" defer></script>

上面代碼中,只有等到DOM加載完成后,才會執行a.jsb.js

defer的運行流程如下。

  1. 瀏覽器開始解析HTML網頁
  2. 解析過程中,發現帶有defer屬性的script標簽
  3. 瀏覽器繼續往下解析HTML網頁,同時並行下載script標簽中的外部腳本
  4. 瀏覽器完成解析HTML網頁,此時再執行下載的腳本

有了defer屬性,瀏覽器下載腳本文件的時候,不會阻塞頁面渲染。下載的腳本文件在DOMContentLoaded事件觸發前執行(即剛剛讀取完</html>標簽),而且可以保證執行順序就是它們在頁面上出現的順序。

對於內置而不是加載外部腳本的script標簽,以及動態生成的script標簽,defer屬性不起作用。另外,使用defer加載的外部腳本不應該使用document.write方法。

2.3 async屬性

解決“阻塞效應”的另一個方法是加入async屬性。

<script src="a.js" async></script>
<script src="b.js" async></script>

async屬性的作用是,使用另一個進程下載腳本,下載時不會阻塞渲染。

  1. 瀏覽器開始解析HTML網頁
  2. 解析過程中,發現帶有async屬性的script標簽
  3. 瀏覽器繼續往下解析HTML網頁,同時並行下載script標簽中的外部腳本
  4. 腳本下載完成,瀏覽器暫停解析HTML網頁,開始執行下載的腳本

腳本執行完畢,瀏覽器恢復解析HTML網頁

 

async屬性可以保證腳本下載的同時,瀏覽器繼續渲染。需要注意的是,一旦采用這個屬性,就無法保證腳本的執行順序。哪個腳本先下載結束,就先執行那個腳本。另外,使用async屬性的腳本文件中,不應該使用document.write方法。

defer屬性和async屬性到底應該使用哪一個?

一般來說,如果腳本之間沒有依賴關系,就使用async屬性,如果腳本之間有依賴關系,就使用defer屬性。如果同時使用asyncdefer屬性,后者不起作用,瀏覽器行為由async屬性決定。

2.4 腳本的動態加載

除了靜態的script標簽,還可以動態生成script標簽,然后加入頁面,從而實現腳本的動態加載。

['a.js', 'b.js'].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  document.head.appendChild(script);
});

這種方法的好處是,動態生成的script標簽不會阻塞頁面渲染,也就不會造成瀏覽器假死。但是問題在於,這種方法無法保證腳本的執行順序,哪個腳本文件先下載完成,就先執行哪個。

如果想避免這個問題,可以設置async屬性為false

['a.js', 'b.js'].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

上面的代碼依然不會阻塞頁面渲染,而且可以保證b.jsa.js后面執行。不過需要注意的是,在這段代碼后面加載的腳本文件,會因此都等待b.js執行完成后再執行。

我們可以把上面的寫法,封裝成一個函數。

(function() {
  var scripts = document.getElementsByTagName('script')[0];
  function load(url) {
    var script = document.createElement('script');
    script.async = true;
    script.src = url;
    scripts.parentNode.insertBefore(script, scripts);
  }
  load('//apis.google.com/js/plusone.js');
  load('//platform.twitter.com/widgets.js');
  load('//s.thirdpartywidget.com/widget.js');
}());

上面代碼中,async屬性設為true,是因為加載的腳本沒有互相依賴關系。而且,這樣就不會造成堵塞。

如果想為動態加載的腳本指定回調函數,可以使用下面的寫法。

function loadScript(src, done) {
  var js = document.createElement('script');
  js.src = src;
  js.onload = function() {
    done();
  };
  js.onerror = function() {
    done(new Error('Failed to load script ' + src));
  };
  document.head.appendChild(js);
}

此外,動態嵌入還有一個地方需要注意。動態嵌入必須等待CSS文件加載完成后,才會去下載外部腳本文件。靜態加載就不存在這個問題,script標簽指定的外部腳本文件,都是與CSS文件同時並發下載的。

2.5 加載使用的協議

如果不指定協議,瀏覽器默認采用HTTP協議下載。

<script src="example.js"></script>

上面的example.js默認就是采用HTTP協議下載,如果要采用HTTPS協議下載,必需寫明(假定服務器支持)。

<script src="https://example.js"></script>

但是有時我們會希望,根據頁面本身的協議來決定加載協議,這時可以采用下面的寫法。

<script src="//example.js"></script>

3.瀏覽器的組成

瀏覽器的核心是兩部分:渲染引擎和JavaScript解釋器(又稱JavaScript引擎)。

3.1 渲染引擎

渲染引擎的主要作用是,將網頁代碼渲染為用戶視覺可以感知的平面文檔。

不同的瀏覽器有不同的渲染引擎。

  1. Firefox:Gecko引擎
  2. Safari:WebKit引擎
  3. Chrome:Blink引擎
  4. IE: Trident引擎
  5. Edge: EdgeHTML引擎

 

渲染引擎處理網頁,通常分成四個階段。

  1. 解析代碼:HTML代碼解析為DOM,CSS代碼解析為CSSOM(CSS Object Model)
  2. 對象合成:將DOM和CSSOM合成一棵渲染樹(render tree)
  3. 布局:計算出渲染樹的布局(layout)
  4. 繪制:將渲染樹繪制到屏幕

以上四步並非嚴格按順序執行,往往第一步還沒完成,第二步和第三步就已經開始了。所以,會看到這種情況:網頁的HTML代碼還沒下載完,但瀏覽器已經顯示出內容了。

3.2 重流和重繪

渲染樹轉換為網頁布局,稱為“布局流”(flow);布局顯示到頁面的這個過程,稱為“繪制”(paint)。它們都具有阻塞效應,並且會耗費很多時間和計算資源。

頁面生成以后,腳本操作和樣式表操作,都會觸發重流(reflow)和重繪(repaint)。用戶的互動,也會觸發,比如設置了鼠標懸停(a:hover)效果、頁面滾動、在輸入框中輸入文本、改變窗口大小等等。

重流和重繪並不一定一起發生,重流必然導致重繪,重繪不一定需要重流。比如改變元素顏色,只會導致重繪,而不會導致重流;改變元素的布局,則會導致重繪和重流。

大多數情況下,瀏覽器會智能判斷,將重流和重繪只限制到相關的子樹上面,最小化所耗費的代價,而不會全局重新生成網頁。

作為開發者,應該盡量設法降低重繪的次數和成本。比如,盡量不要變動高層的DOM元素,而以底層DOM元素的變動代替;再比如,重繪table布局和flex布局,開銷都會比較大。

var foo = document.getElementById('foobar');

foo.style.color = 'blue';
foo.style.marginTop = '30px';

上面的代碼只會導致一次重繪,因為瀏覽器會累積DOM變動,然后一次性執行。

下面是一些優化技巧。

  • 讀取DOM或者寫入DOM,盡量寫在一起,不要混雜
  • 緩存DOM信息
  • 不要一項一項地改變樣式,而是使用CSS class一次性改變樣式
  • 使用document fragment操作DOM
  • 動畫時使用absolute定位或fixed定位,這樣可以減少對其他元素的影響
  • 只在必要時才顯示元素
  • 使用window.requestAnimationFrame(),因為它可以把代碼推遲到下一次重流時執行,而不是立即要求頁面重流
  • 使用虛擬DOM(virtual DOM)庫

 

下面是一個window.requestAnimationFrame()對比效果的例子。

// 重繪代價高
function doubleHeight(element) {
  var currentHeight = element.clientHeight;
  element.style.height = (currentHeight * 2) + 'px';
}

all_my_elements.forEach(doubleHeight);

// 重繪代價低
function doubleHeight(element) {
  var currentHeight = element.clientHeight;

  window.requestAnimationFrame(function () {
    element.style.height = (currentHeight * 2) + 'px';
  });
}

all_my_elements.forEach(doubleHeight);

3.3 JavaScript引擎

JavaScript引擎的主要作用是,讀取網頁中的JavaScript代碼,對其處理后運行。

JavaScript是一種解釋型語言,也就是說,它不需要編譯,由解釋器實時運行。這樣的好處是運行和修改都比較方便,刷新頁面就可以重新解釋;缺點是每次運行都要調用解釋器,系統開銷較大,運行速度慢於編譯型語言。

為了提高運行速度,目前的瀏覽器都將JavaScript進行一定程度的編譯,生成類似字節碼(bytecode)的中間代碼,以提高運行速度。

早期,瀏覽器內部對JavaScript的處理過程如下:

  • 讀取代碼,進行詞法分析(Lexical analysis),將代碼分解成詞元(token)。
  • 對詞元進行語法分析(parsing),將代碼整理成“語法樹”(syntax tree)。
  • 使用“翻譯器”(translator),將代碼轉為字節碼(bytecode)。
  • 使用“字節碼解釋器”(bytecode interpreter),將字節碼轉為機器碼。

逐行解釋將字節碼轉為機器碼,是很低效的。為了提高運行速度,現代瀏覽器改為采用“即時編譯”(Just In Time compiler,縮寫JIT),即字節碼只在運行時編譯,用到哪一行就編譯哪一行,並且把編譯結果緩存(inline cache)。通常,一個程序被經常用到的,只是其中一小部分代碼,有了緩存的編譯結果,整個程序的運行速度就會顯著提升。不同的瀏覽器有不同的編譯策略。有的瀏覽器只編譯最經常用到的部分,比如循環的部分;有的瀏覽器索性省略了字節碼的翻譯步驟,直接編譯成機器碼,比如chrome瀏覽器的V8引擎。

字節碼不能直接運行,而是運行在一個虛擬機(Virtual Machine)之上,一般也把虛擬機稱為JavaScript引擎。因為JavaScript運行時未必有字節碼,所以JavaScript虛擬機並不完全基於字節碼,而是部分基於源碼,即只要有可能,就通過JIT(just in time)編譯器直接把源碼編譯成機器碼運行,省略字節碼步驟。這一點與其他采用虛擬機(比如Java)的語言不盡相同。這樣做的目的,是為了盡可能地優化代碼、提高性能。下面是目前最常見的一些JavaScript虛擬機:

4.參考鏈接

  1. John Dalziel, The race for speed part 2: How JavaScript compilers work
  2. Jake Archibald,Deep dive into the murky waters of script loading
  3. Mozilla Developer Network, window.setTimeout
  4. Remy Sharp, Throttling function calls
  5. Ayman Farhat, An alternative to Javascript’s evil setInterval
  6. Ilya Grigorik, Script-injected “async scripts” considered harmful
  7. Axel Rauschmayer, ECMAScript 6 promises (1/2): foundations
  8. Daniel Imms, async vs defer attributes
  9. Craig Buckler, Load Non-blocking JavaScript with HTML5 Async and Defer
  10. Domenico De Felice, How browsers work

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM