# 瀏覽器環境概述
JavaScript 是瀏覽器的內置腳本語言。也就是說,瀏覽器內置了 JavaScript 引擎,并且提供各種接口,讓 JavaScript 腳本可以控制瀏覽器的各種功能。一旦網頁內嵌了 JavaScript 腳本,瀏覽器加載網頁,就會去執行腳本,從而達到操作瀏覽器的目的,實現網頁的各種動態效果。
本章開始介紹瀏覽器提供的各種 JavaScript 接口。首先,介紹 JavaScript 代碼嵌入網頁的方法。
## 代碼嵌入網頁的方法
網頁中嵌入 JavaScript 代碼,主要有四種方法。
- `<script>`元素直接嵌入代碼。
- `<script>`標簽加載外部腳本
- 事件屬性
- URL 協議
### script 元素嵌入代碼
`<script>`元素內部可以直接寫入 JavaScript 代碼。
```html
<script>
var x = 1 + 5;
console.log(x);
</script>
```
`<script>`標簽有一個`type`屬性,用來指定腳本類型。對 JavaScript 腳本來說,`type`屬性可以設為兩種值。
- `text/javascript`:這是默認值,也是歷史上一貫設定的值。如果你省略`type`屬性,默認就是這個值。對于老式瀏覽器,設為這個值比較好。
- `application/javascript`:對于較新的瀏覽器,建議設為這個值。
```html
<script type="application/javascript">
console.log('Hello World');
</script>
```
由于`<script>`標簽默認就是 JavaScript 代碼。所以,嵌入 JavaScript 腳本時,`type`屬性可以省略。
如果`type`屬性的值,瀏覽器不認識,那么它不會執行其中的代碼。利用這一點,可以在`<script>`標簽之中嵌入任意的文本內容,只要加上一個瀏覽器不認識的`type`屬性即可。
```html
<script id="mydata" type="x-custom-data">
console.log('Hello World');
</script>
```
上面的代碼,瀏覽器不會執行,也不會顯示它的內容,因為不認識它的`type`屬性。但是,這個`<script>`節點依然存在于 DOM 之中,可以使用`<script>`節點的`text`屬性讀出它的內容。
```javascript
document.getElementById('mydata').text
// console.log('Hello World');
```
### script 元素加載外部腳本
`<script>`標簽也可以指定加載外部的腳本文件。
```html
<script src="https://www.example.com/script.js"></script>
```
如果腳本文件使用了非英語字符,還應該注明字符的編碼。
```html
<script charset="utf-8" src="https://www.example.com/script.js"></script>
```
所加載的腳本必須是純的 JavaScript 代碼,不能有`HTML`代碼和`<script>`標簽。
加載外部腳本和直接添加代碼塊,這兩種方法不能混用。下面代碼的`console.log`語句直接被忽略。
```html
<script charset="utf-8" src="example.js">
console.log('Hello World!');
</script>
```
為了防止攻擊者篡改外部腳本,`script`標簽允許設置一個`integrity`屬性,寫入該外部腳本的 Hash 簽名,用來驗證腳本的一致性。
```html
<script src="/assets/application.js"
integrity="sha256-TvVUHzSfftWg1rcfL6TIJ0XKEGrgLyEq6lEpcmrG9qs=">
</script>
```
上面代碼中,`script`標簽有一個`integrity`屬性,指定了外部腳本`/assets/application.js`的 SHA256 簽名。一旦有人改了這個腳本,導致 SHA256 簽名不匹配,瀏覽器就會拒絕加載。
### 事件屬性
網頁元素的事件屬性(比如`onclick`和`onmouseover`),可以寫入 JavaScript 代碼。當指定事件發生時,就會調用這些代碼。
```html
<button id="myBtn" onclick="console.log(this.id)">點擊</button>
```
上面的事件屬性代碼只有一個語句。如果有多個語句,使用分號分隔即可。
### URL 協議
URL 支持`javascript:`協議,即在 URL 的位置寫入代碼,使用這個 URL 的時候就會執行 JavaScript 代碼。
```html
<a href="javascript:console.log('Hello')">點擊</a>
```
瀏覽器的地址欄也可以執行`javascript:`協議。將`javascript:console.log('Hello')`放入地址欄,按回車鍵也會執行這段代碼。
如果 JavaScript 代碼返回一個字符串,瀏覽器就會新建一個文檔,展示這個字符串的內容,原有文檔的內容都會消失。
```html
<a href="javascript: new Date().toLocaleTimeString();">點擊</a>
```
上面代碼中,用戶點擊鏈接以后,會打開一個新文檔,里面有當前時間。
如果返回的不是字符串,那么瀏覽器不會新建文檔,也不會跳轉。
```javascript
<a href="javascript: console.log(new Date().toLocaleTimeString())">點擊</a>
```
上面代碼中,用戶點擊鏈接后,網頁不會跳轉,只會在控制臺顯示當前時間。
`javascript:`協議的常見用途是書簽腳本 Bookmarklet。由于瀏覽器的書簽保存的是一個網址,所以`javascript:`網址也可以保存在里面,用戶選擇這個書簽的時候,就會在當前頁面執行這個腳本。為了防止書簽替換掉當前文檔,可以在腳本前加上`void`,或者在腳本最后加上`void 0`。
```html
<a href="javascript: void new Date().toLocaleTimeString();">點擊</a>
<a href="javascript: new Date().toLocaleTimeString();void 0;">點擊</a>
```
上面這兩種寫法,點擊鏈接后,執行代碼都不會網頁跳轉。
## script 元素
### 工作原理
瀏覽器加載 JavaScript 腳本,主要通過`<script>`元素完成。正常的網頁加載流程是這樣的。
1. 瀏覽器一邊下載 HTML 網頁,一邊開始解析。也就是說,不等到下載完,就開始解析。
2. 解析過程中,瀏覽器發現`<script>`元素,就暫停解析,把網頁渲染的控制權轉交給 JavaScript 引擎。
3. 如果`<script>`元素引用了外部腳本,就下載該腳本再執行,否則就直接執行代碼。
4. JavaScript 引擎執行完畢,控制權交還渲染引擎,恢復往下解析 HTML 網頁。
加載外部腳本時,瀏覽器會暫停頁面渲染,等待腳本下載并執行完成后,再繼續渲染。原因是 JavaScript 代碼可以修改 DOM,所以必須把控制權讓給它,否則會導致復雜的線程競賽的問題。
如果外部腳本加載時間很長(一直無法完成下載),那么瀏覽器就會一直等待腳本下載完成,造成網頁長時間失去響應,瀏覽器就會呈現“假死”狀態,這被稱為“阻塞效應”。
為了避免這種情況,較好的做法是將`<script>`標簽都放在頁面底部,而不是頭部。這樣即使遇到腳本失去響應,網頁主體的渲染也已經完成了,用戶至少可以看到內容,而不是面對一張空白的頁面。如果某些腳本代碼非常重要,一定要放在頁面頭部的話,最好直接將代碼寫入頁面,而不是連接外部腳本文件,這樣能縮短加載時間。
腳本文件都放在網頁尾部加載,還有一個好處。因為在 DOM 結構生成之前就調用 DOM 節點,JavaScript 會報錯,如果腳本都在網頁尾部加載,就不存在這個問題,因為這時 DOM 肯定已經生成了。
```html
<head>
<script>
console.log(document.body.innerHTML);
</script>
</head>
<body>
</body>
```
上面代碼執行時會報錯,因為此時`document.body`元素還未生成。
一種解決方法是設定`DOMContentLoaded`事件的回調函數。
```html
<head>
<script>
document.addEventListener(
'DOMContentLoaded',
function (event) {
console.log(document.body.innerHTML);
}
);
</script>
</head>
```
上面代碼中,指定`DOMContentLoaded`事件發生后,才開始執行相關代碼。`DOMContentLoaded`事件只有在 DOM 結構生成之后才會觸發。
另一種解決方法是,使用`<script>`標簽的`onload`屬性。當`<script>`標簽指定的外部腳本文件下載和解析完成,會觸發一個`load`事件,可以把所需執行的代碼,放在這個事件的回調函數里面。
```html
<script src="jquery.min.js" onload="console.log(document.body.innerHTML)">
</script>
```
但是,如果將腳本放在頁面底部,就可以完全按照正常的方式寫,上面兩種方式都不需要。
```html
<body>
<!-- 其他代碼 -->
<script>
console.log(document.body.innerHTML);
</script>
</body>
```
如果有多個`script`標簽,比如下面這樣。
```html
<script src="a.js"></script>
<script src="b.js"></script>
```
瀏覽器會同時并行下載`a.js`和`b.js`,但是,執行時會保證先執行`a.js`,然后再執行`b.js`,即使后者先下載完成,也是如此。也就是說,腳本的執行順序由它們在頁面中的出現順序決定,這是為了保證腳本之間的依賴關系不受到破壞。當然,加載這兩個腳本都會產生“阻塞效應”,必須等到它們都加載完成,瀏覽器才會繼續頁面渲染。
解析和執行 CSS,也會產生阻塞。Firefox 瀏覽器會等到腳本前面的所有樣式表,都下載并解析完,再執行腳本;Webkit則是一旦發現腳本引用了樣式,就會暫停執行腳本,等到樣式表下載并解析完,再恢復執行。
此外,對于來自同一個域名的資源,比如腳本文件、樣式表文件、圖片文件等,瀏覽器一般有限制,同時最多下載6~20個資源,即最多同時打開的 TCP 連接有限制,這是為了防止對服務器造成太大壓力。如果是來自不同域名的資源,就沒有這個限制。所以,通常把靜態文件放在不同的域名之下,以加快下載速度。
### defer 屬性
為了解決腳本文件下載阻塞網頁渲染的問題,一個方法是對`<script>`元素加入`defer`屬性。它的作用是延遲腳本的執行,等到 DOM 加載生成后,再執行腳本。
```html
<script src="a.js" defer></script>
<script src="b.js" defer></script>
```
上面代碼中,只有等到 DOM 加載完成后,才會執行`a.js`和`b.js`。
`defer`屬性的運行流程如下。
1. 瀏覽器開始解析 HTML 網頁。
2. 解析過程中,發現帶有`defer`屬性的`<script>`元素。
3. 瀏覽器繼續往下解析 HTML 網頁,同時并行下載`<script>`元素加載的外部腳本。
4. 瀏覽器完成解析 HTML 網頁,此時再回過頭執行已經下載完成的腳本。
有了`defer`屬性,瀏覽器下載腳本文件的時候,不會阻塞頁面渲染。下載的腳本文件在`DOMContentLoaded`事件觸發前執行(即剛剛讀取完`</html>`標簽),而且可以保證執行順序就是它們在頁面上出現的順序。
對于內置而不是加載外部腳本的`script`標簽,以及動態生成的`script`標簽,`defer`屬性不起作用。另外,使用`defer`加載的外部腳本不應該使用`document.write`方法。
### async 屬性
解決“阻塞效應”的另一個方法是對`<script>`元素加入`async`屬性。
```html
<script src="a.js" async></script>
<script src="b.js" async></script>
```
`async`屬性的作用是,使用另一個進程下載腳本,下載時不會阻塞渲染。
1. 瀏覽器開始解析 HTML 網頁。
2. 解析過程中,發現帶有`async`屬性的`script`標簽。
3. 瀏覽器繼續往下解析 HTML 網頁,同時并行下載`<script>`標簽中的外部腳本。
4. 腳本下載完成,瀏覽器暫停解析 HTML 網頁,開始執行下載的腳本。
5. 腳本執行完畢,瀏覽器恢復解析 HTML 網頁。
`async`屬性可以保證腳本下載的同時,瀏覽器繼續渲染。需要注意的是,一旦采用這個屬性,就無法保證腳本的執行順序。哪個腳本先下載結束,就先執行那個腳本。另外,使用`async`屬性的腳本文件里面的代碼,不應該使用`document.write`方法。
`defer`屬性和`async`屬性到底應該使用哪一個?
一般來說,如果腳本之間沒有依賴關系,就使用`async`屬性,如果腳本之間有依賴關系,就使用`defer`屬性。如果同時使用`async`和`defer`屬性,后者不起作用,瀏覽器行為由`async`屬性決定。
### 腳本的動態加載
`<script>`元素還可以動態生成,生成后再插入頁面,從而實現腳本的動態加載。
```javascript
['a.js', 'b.js'].forEach(function(src) {
var script = document.createElement('script');
script.src = src;
document.head.appendChild(script);
});
```
這種方法的好處是,動態生成的`script`標簽不會阻塞頁面渲染,也就不會造成瀏覽器假死。但是問題在于,這種方法無法保證腳本的執行順序,哪個腳本文件先下載完成,就先執行哪個。
如果想避免這個問題,可以設置async屬性為`false`。
```javascript
['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`執行完成后再執行。
如果想為動態加載的腳本指定回調函數,可以使用下面的寫法。
```javascript
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);
}
```
### 加載使用的協議
如果不指定協議,瀏覽器默認采用 HTTP 協議下載。
```html
<script src="example.js"></script>
```
上面的`example.js`默認就是采用 HTTP 協議下載,如果要采用 HTTPS 協議下載,必需寫明。
```html
<script src="https://example.js"></script>
```
但是有時我們會希望,根據頁面本身的協議來決定加載協議,這時可以采用下面的寫法。
```html
<script src="//example.js"></script>
```
## 瀏覽器的組成
瀏覽器的核心是兩部分:渲染引擎和 JavaScript 解釋器(又稱 JavaScript 引擎)。
### 渲染引擎
渲染引擎的主要作用是,將網頁代碼渲染為用戶視覺可以感知的平面文檔。
不同的瀏覽器有不同的渲染引擎。
- Firefox:Gecko 引擎
- Safari:WebKit 引擎
- Chrome:Blink 引擎
- IE: Trident 引擎
- Edge: EdgeHTML 引擎
渲染引擎處理網頁,通常分成四個階段。
1. 解析代碼:HTML 代碼解析為 DOM,CSS 代碼解析為 CSSOM(CSS Object Model)。
2. 對象合成:將 DOM 和 CSSOM 合成一棵渲染樹(render tree)。
3. 布局:計算出渲染樹的布局(layout)。
4. 繪制:將渲染樹繪制到屏幕。
以上四步并非嚴格按順序執行,往往第一步還沒完成,第二步和第三步就已經開始了。所以,會看到這種情況:網頁的 HTML 代碼還沒下載完,但瀏覽器已經顯示出內容了。
### 重流和重繪
渲染樹轉換為網頁布局,稱為“布局流”(flow);布局顯示到頁面的這個過程,稱為“繪制”(paint)。它們都具有阻塞效應,并且會耗費很多時間和計算資源。
頁面生成以后,腳本操作和樣式表操作,都會觸發“重流”(reflow)和“重繪”(repaint)。用戶的互動也會觸發重流和重繪,比如設置了鼠標懸停(`a:hover`)效果、頁面滾動、在輸入框中輸入文本、改變窗口大小等等。
重流和重繪并不一定一起發生,重流必然導致重繪,重繪不一定需要重流。比如改變元素顏色,只會導致重繪,而不會導致重流;改變元素的布局,則會導致重繪和重流。
大多數情況下,瀏覽器會智能判斷,將重流和重繪只限制到相關的子樹上面,最小化所耗費的代價,而不會全局重新生成網頁。
作為開發者,應該盡量設法降低重繪的次數和成本。比如,盡量不要變動高層的 DOM 元素,而以底層 DOM 元素的變動代替;再比如,重繪`table`布局和`flex`布局,開銷都會比較大。
```javascript
var foo = document.getElementById('foobar');
foo.style.color = 'blue';
foo.style.marginTop = '30px';
```
上面的代碼只會導致一次重繪,因為瀏覽器會累積 DOM 變動,然后一次性執行。
下面是一些優化技巧。
- 讀取 DOM 或者寫入 DOM,盡量寫在一起,不要混雜。不要讀取一個 DOM 節點,然后立刻寫入,接著再讀取一個 DOM 節點。
- 緩存 DOM 信息。
- 不要一項一項地改變樣式,而是使用 CSS class 一次性改變樣式。
- 使用`documentFragment`操作 DOM
- 動畫使用`absolute`定位或`fixed`定位,這樣可以減少對其他元素的影響。
- 只在必要時才顯示隱藏元素。
- 使用`window.requestAnimationFrame()`,因為它可以把代碼推遲到下一次重繪之前執行,而不是立即要求頁面重繪。
- 使用虛擬 DOM(virtual DOM)庫。
下面是一個`window.requestAnimationFrame()`對比效果的例子。
```javascript
// 重流代價高
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);
```
上面的第一段代碼,每讀一次 DOM,就寫入新的值,會造成不停的重排和重流。第二段代碼把所有的寫操作,都累積在一起,從而 DOM 代碼變動的代價就最小化了。
### JavaScript 引擎
JavaScript 引擎的主要作用是,讀取網頁中的 JavaScript 代碼,對其處理后運行。
JavaScript 是一種解釋型語言,也就是說,它不需要編譯,由解釋器實時運行。這樣的好處是運行和修改都比較方便,刷新頁面就可以重新解釋;缺點是每次運行都要調用解釋器,系統開銷較大,運行速度慢于編譯型語言。
為了提高運行速度,目前的瀏覽器都將 JavaScript 進行一定程度的編譯,生成類似字節碼(bytecode)的中間代碼,以提高運行速度。
早期,瀏覽器內部對 JavaScript 的處理過程如下:
1. 讀取代碼,進行詞法分析(Lexical analysis),將代碼分解成詞元(token)。
2. 對詞元進行語法分析(parsing),將代碼整理成“語法樹”(syntax tree)。
3. 使用“翻譯器”(translator),將代碼轉為字節碼(bytecode)。
4. 使用“字節碼解釋器”(bytecode interpreter),將字節碼轉為機器碼。
逐行解釋將字節碼轉為機器碼,是很低效的。為了提高運行速度,現代瀏覽器改為采用“即時編譯”(Just In Time compiler,縮寫 JIT),即字節碼只在運行時編譯,用到哪一行就編譯哪一行,并且把編譯結果緩存(inline cache)。通常,一個程序被經常用到的,只是其中一小部分代碼,有了緩存的編譯結果,整個程序的運行速度就會顯著提升。
字節碼不能直接運行,而是運行在一個虛擬機(Virtual Machine)之上,一般也把虛擬機稱為 JavaScript 引擎。并非所有的 JavaScript 虛擬機運行時都有字節碼,有的 JavaScript 虛擬機基于源碼,即只要有可能,就通過 JIT(just in time)編譯器直接把源碼編譯成機器碼運行,省略字節碼步驟。這一點與其他采用虛擬機(比如 Java)的語言不盡相同。這樣做的目的,是為了盡可能地優化代碼、提高性能。下面是目前最常見的一些 JavaScript 虛擬機:
- [Chakra](https://en.wikipedia.org/wiki/Chakra_(JScript_engine)) (Microsoft Internet Explorer)
- [Nitro/JavaScript Core](http://en.wikipedia.org/wiki/WebKit#JavaScriptCore) (Safari)
- [Carakan](http://dev.opera.com/articles/view/labs-carakan/) (Opera)
- [SpiderMonkey](https://developer.mozilla.org/en-US/docs/SpiderMonkey) (Firefox)
- [V8](https://en.wikipedia.org/wiki/Chrome_V8) (Chrome, Chromium)
## 參考鏈接
- John Dalziel, [The race for speed part 2: How JavaScript compilers work](http://creativejs.com/2013/06/the-race-for-speed-part-2-how-javascript-compilers-work/)
- Jake Archibald, [Deep dive into the murky waters of script loading](http://www.html5rocks.com/en/tutorials/speed/script-loading/)
- Mozilla Developer Network, [window.setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/window.setTimeout)
- Remy Sharp, [Throttling function calls](http://remysharp.com/2010/07/21/throttling-function-calls/)
- Ayman Farhat, [An alternative to JavaScript's evil setInterval](http://www.thecodeship.com/web-development/alternative-to-javascript-evil-setinterval/)
- Ilya Grigorik, [Script-injected "async scripts" considered harmful](https://www.igvita.com/2014/05/20/script-injected-async-scripts-considered-harmful/)
- Axel Rauschmayer, [ECMAScript 6 promises (1/2): foundations](http://www.2ality.com/2014/09/es6-promises-foundations.html)
- Daniel Imms, [async vs defer attributes](http://www.growingwiththeweb.com/2014/02/async-vs-defer-attributes.html)
- Craig Buckler, [Load Non-blocking JavaScript with HTML5 Async and Defer](http://www.sitepoint.com/non-blocking-async-defer/)
- Domenico De Felice, [How browsers work](http://domenicodefelice.blogspot.sg/2015/08/how-browsers-work.html?t=2)
- 前言
- 入門篇
- 導論
- 歷史
- 基本語法
- 數據類型
- 概述
- null,undefined 和布爾值
- 數值
- 字符串
- 對象
- 函數
- 數組
- 運算符
- 算術運算符
- 比較運算符
- 布爾運算符
- 二進制位運算符
- 其他運算符,運算順序
- 語法專題
- 數據類型的轉換
- 錯誤處理機制
- 編程風格
- console 對象與控制臺
- 標準庫
- Object 對象
- 屬性描述對象
- Array 對象
- 包裝對象
- Boolean 對象
- Number 對象
- String 對象
- Math 對象
- Date 對象
- RegExp 對象
- JSON 對象
- 面向對象編程
- 實例對象與 new 命令
- this 關鍵字
- 對象的繼承
- Object 對象的相關方法
- 嚴格模式
- 異步操作
- 概述
- 定時器
- Promise 對象
- DOM
- 概述
- Node 接口
- NodeList 接口,HTMLCollection 接口
- ParentNode 接口,ChildNode 接口
- Document 節點
- Element 節點
- 屬性的操作
- Text 節點和 DocumentFragment 節點
- CSS 操作
- Mutation Observer API
- 事件
- EventTarget 接口
- 事件模型
- Event 對象
- 鼠標事件
- 鍵盤事件
- 進度事件
- 表單事件
- 觸摸事件
- 拖拉事件
- 其他常見事件
- GlobalEventHandlers 接口
- 瀏覽器模型
- 瀏覽器模型概述
- window 對象
- Navigator 對象,Screen 對象
- Cookie
- XMLHttpRequest 對象
- 同源限制
- CORS 通信
- Storage 接口
- History 對象
- Location 對象,URL 對象,URLSearchParams 對象
- ArrayBuffer 對象,Blob 對象
- File 對象,FileList 對象,FileReader 對象
- 表單,FormData 對象
- IndexedDB API
- Web Worker
- 附錄:網頁元素接口
- a
- img
- form
- input
- button
- option
- video,audio