轉載自阮一峰: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元素的事件屬性(比如onclick和onmouseover),可以寫入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>標簽完成。正常的網頁加載流程是這樣的。
- 瀏覽器一邊下載HTML網頁,一邊開始解析
- 解析過程中,發現
<script>標簽 - 暫停解析,網頁渲染的控制權轉交給JavaScript引擎
- 如果
<script>標簽引用了外部腳本,就下載該腳本,否則就直接執行 - 執行完畢,控制權交還渲染引擎,恢復往下解析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.js和b.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.js和b.js。
defer的運行流程如下。
- 瀏覽器開始解析HTML網頁
- 解析過程中,發現帶有
defer屬性的script標簽 - 瀏覽器繼續往下解析HTML網頁,同時並行下載
script標簽中的外部腳本 - 瀏覽器完成解析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屬性的作用是,使用另一個進程下載腳本,下載時不會阻塞渲染。
- 瀏覽器開始解析HTML網頁
- 解析過程中,發現帶有
async屬性的script標簽 - 瀏覽器繼續往下解析HTML網頁,同時並行下載
script標簽中的外部腳本 - 腳本下載完成,瀏覽器暫停解析HTML網頁,開始執行下載的腳本
腳本執行完畢,瀏覽器恢復解析HTML網頁
async屬性可以保證腳本下載的同時,瀏覽器繼續渲染。需要注意的是,一旦采用這個屬性,就無法保證腳本的執行順序。哪個腳本先下載結束,就先執行那個腳本。另外,使用async屬性的腳本文件中,不應該使用document.write方法。
defer屬性和async屬性到底應該使用哪一個?
一般來說,如果腳本之間沒有依賴關系,就使用async屬性,如果腳本之間有依賴關系,就使用defer屬性。如果同時使用async和defer屬性,后者不起作用,瀏覽器行為由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.js在a.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 渲染引擎
渲染引擎的主要作用是,將網頁代碼渲染為用戶視覺可以感知的平面文檔。
不同的瀏覽器有不同的渲染引擎。
- Firefox:Gecko引擎
- Safari:WebKit引擎
- Chrome:Blink引擎
- IE: Trident引擎
- Edge: EdgeHTML引擎
渲染引擎處理網頁,通常分成四個階段。
- 解析代碼:HTML代碼解析為DOM,CSS代碼解析為CSSOM(CSS Object Model)
- 對象合成:將DOM和CSSOM合成一棵渲染樹(render tree)
- 布局:計算出渲染樹的布局(layout)
- 繪制:將渲染樹繪制到屏幕
以上四步並非嚴格按順序執行,往往第一步還沒完成,第二步和第三步就已經開始了。所以,會看到這種情況:網頁的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虛擬機:
- Chakra(Microsoft Internet Explorer)
- Nitro/JavaScript Core (Safari)
- Carakan (Opera)
- SpiderMonkey (Firefox)
- V8 (Chrome, Chromium)
4.參考鏈接
- John Dalziel, The race for speed part 2: How JavaScript compilers work
- Jake Archibald,Deep dive into the murky waters of script loading
- Mozilla Developer Network, window.setTimeout
- Remy Sharp, Throttling function calls
- Ayman Farhat, An alternative to Javascript’s evil setInterval
- Ilya Grigorik, Script-injected “async scripts” considered harmful
- Axel Rauschmayer, ECMAScript 6 promises (1/2): foundations
- Daniel Imms, async vs defer attributes
- Craig Buckler, Load Non-blocking JavaScript with HTML5 Async and Defer
- Domenico De Felice, How browsers work
