javascript高級程式設計第三版書摘
- 2021 年 10 月 23 日
- 筆記
- javascript, 前端
在HTML 中使用JavaScript
<script>元素
在使用<script>元素嵌入 JavaScript 程式碼時,只須為<script>指定 type 屬性。然後,像下面這樣把 JavaScript 程式碼直接放在元素內部即可:
<script type="text/javascript">
function sayHi(){
alert("Hi!");
}
</script>
包含在<script>元素內部的 JavaScript 程式碼將被從上至下依次解釋。就拿前面這個例子來說,解釋器會解釋一個函數的定義,然後將該定義保存在自己的環境當中。在解釋器對<script>元素內部的所有程式碼求值完畢以前,頁面中的其餘內容都不會被瀏覽器載入或顯示。
如果要通過<script>元素來包含外部 JavaScript 文件,那麼 src 屬性就是必需的。這個屬性的值
是一個指向外部 JavaScript 文件的鏈接,例如:
<script type="text/javascript" src="example.js"></script>
在這個例子中,外部文件 example.js 將被載入到當前頁面中。外部文件只須包含通常要放在開始的<script>和結束的</script>之間的那些 JavaScript 程式碼即可。與解析嵌入式 JavaScript 程式碼一樣,在解析外部 JavaScript 文件(包括下載該文件)時,頁面的處理也會暫時停止。如果是在 XHTML 文檔中,也可以省略前面示例程式碼中結束的標籤,例如:
<script type="text/javascript" src="example.js" />
需要注意的是,帶有 src 屬性的<script>元素不應該在其<script>和</script>標籤之間再包含額外的 JavaScript 程式碼。如果包含了嵌入的程式碼,則只會下載並執行外部腳本文件,嵌入的程式碼會被忽略。
另外,通過<script>元素的 src 屬性還可以包含來自外部域的 JavaScript 文件。這一點既讓
<script>元素倍顯強大,又讓它備受爭議。在這一點上, <script>與<img>元素非常相似, 即它的 src
屬性可以是指向當前 HTML 頁面所在域之外的某個域中的完整 URL,例如:
<script type="text/javascript" src="//www.somewhere.com/afile.js"></script>
這樣,位於外部域中的程式碼也會被載入和解析,就像這些程式碼位於載入它們的頁面中一樣。利用這一點就可以在必要時通過不同的域來提供 JavaScript 文件。不過,在訪問自己不能控制的伺服器上的JavaScript 文件時則要多加小心。如果不幸遇到了懷有惡意的程式設計師,那他們隨時都可能替換該文件中的程式碼。因此,如果想包含來自不同域的程式碼,則要麼你是那個域的所有者,要麼那個域的所有者值得信賴。
標籤的位置
按照傳統的做法,所有<script>元素都應該放在頁面的<head>元素中。
這種做法的目的就是把所有外部文件(包括 CSS 文件和 JavaScript 文件)的引用都放在相同的地方。可是,在文檔的<head>元素中包含所有 JavaScript 文件,意味著必須等到全部 JavaScript 程式碼都被下載、解析和執行完成以後,才能開始呈現頁面的內容(瀏覽器在遇到<body>標籤時才開始呈現內容)。對於那些需要很多 JavaScript 程式碼的頁面來說,這無疑會導致瀏覽器在呈現頁面時出現明顯的延遲,而延遲期間的瀏覽器窗口中將是一片空白。為了避免這個問題,現代 Web 應用程式一般都把全部JavaScript 引用放在<body>元素中頁面內容的後面
延遲腳本
HTML 4.01 為<script>標籤定義了 defer 屬性。這個屬性的用途是表明腳本在執行時不會影響頁面的構造。也就是說,腳本會被延遲到整個頁面都解析完畢後再運行。因此,在<script>元素中設置defer 屬性,相當於告訴瀏覽器立即下載,但延遲執行。
但是這個腳本並不是所有的瀏覽器都支援,為此,把延遲腳本放在頁面底部仍然是最佳選擇。
非同步腳本
HTML5 為<script>元素定義了 async 屬性。這個屬性與 defer 屬性類似,都用於改變處理腳本的行為。同樣與 defer 類似, async 只適用於外部腳本文件,並告訴瀏覽器立即下載文件。但與defer不同的是,標記為 async 的腳本並不保證按照指定它們的先後順序執行。
基本概念
語法
區分大小寫
ECMAScript 中的一切(變數、函數名和操作符)都區分大小寫。這也就意味著,變數名 test 和變數名 Test 分別表示兩個不同的變數,而函數名不能使用 typeof,因它是一個關鍵字(3.2 節介紹關鍵字),但 typeOf 則完全可以是一個有效的函數名。
標誌符
所謂標識符,就是指變數、函數、屬性的名字,或者函數的參數。標識符可以是按照下列格式規則
組合起來的一或多個字元:
- 第一個字元必須是一個字母、下劃線(_)或一個美元符號($);
- 其他字元可以是字母、下劃線、美元符號或數字。
按照慣例, ECMAScript 標識符採用駝峰大小寫格式,也就是第一個字母小寫,剩下的每個單詞的首字母大寫
注釋
ECMAScript 使用 C 風格的注釋,包括單行注釋和塊級注釋。
// 單行注釋
/*
* 這是一個多行
* (塊級)注釋
*/
嚴格模式
ECMAScript 5 引入了嚴格模式(strict mode)的概念。嚴格模式是為 JavaScript 定義了一種不同的解析與執行模型。
要在整個腳本中啟用嚴格模式,可以在頂部添加如下程式碼:
"use strict";
在函數內部的上方包含這條編譯指示,也可以指定函數在嚴格模式下執行:
function doSomething(){
"use strict";
//函數體
}
語句
ECMAScript 中的語句以一個分號結尾;如果省略分號,則由解析器確定語句的結尾,如下例所示:
var sum = a + b // 即使沒有分號也是有效的語句——不推薦
var diff = a - b; // 有效的語句——推薦
雖然語句結尾的分號不是必需的,但我們建議任何時候都不要省略它。因為加上這個分號可以避免很多錯誤(例如不完整的輸入),開發人員也可以放心地通過刪除多餘的空格來壓縮 ECMAScript 程式碼(程式碼行結尾處沒有分號會導致壓縮錯誤)。另外,加上分號也會在某些情況下增進程式碼的性能,為這樣解析器就不必再花時間推測應該在哪裡插入分號了。
雖然條件控制語句(如 if 語句)只在執行多條語句的情況下才要求使用程式碼塊,但最佳實踐是始
終在控制語句中使用程式碼塊——即使程式碼塊中只有一條語句,例如:
if (test)
alert(test); // 有效但容易出錯,不要使用
if (test){ // 推薦使用
alert(test);
}
關鍵字和保留字
ECMA-262 描述了一組具有特定用途的關鍵字,這些關鍵字可用於表示控制語句的開始或結束,或
者用於執行特定操作等。按照規則,關鍵字也是語言保留的,不能用作標識符。以下就是 ECMAScript的全部關鍵字(帶*號上標的是第 5 版新增的關鍵字):
break do instanceof typeof
case else new var
catch finally return void
continue for switch while
debugger* function this with
default if throw
delete in try
ECMA-262 還描述了另外一組不能用作標識符的保留字。儘管保留字在這門語言中還沒有任何特定的用途,但它們有可能在將來被用作關鍵字。以下是 ECMA-262 第 3 版定義的全部保留字:
abstract enum int short
boolean export interface static
byte extends long super
char final native synchronized
class float package throws
const goto private transient
debugger implements protected volatile
double import public
變數
有一點必須注意,即用 var 操作符定義的變數將成為定義該變數的作用域中的局部變數。也就是說,如果在函數中使用 var 定義一個變數,那麼這個變數在函數退出後就會被銷毀,例如:
function test(){
var message = "hi"; // 局部變數
}
test();
alert(message); // 錯誤!
這裡,變數 message 是在函數中使用 var 定義的。當函數被調用時,就會創建該變數並為其賦值。而在此之後,這個變數又會立即被銷毀,因此例子中的下一行程式碼就會導致錯誤。不過,可以下面這樣省略 var 操作符,從而創建一個全局變數:
function test(){
message = "hi"; // 全局變數
}
test();
alert(message); // "hi
雖然省略 var 操作符可以定義全局變數,但這也不是我們推薦的做法。因為在局部作用域中定義的全局變數很難維護,而且如果有意地忽略了 var 操作符,也會由於相應變數不會馬上就有定義而導致不必要的混亂。給未經聲明的變數賦值在嚴格模式下會導致拋出 ReferenceError 錯誤。
數據類型
ECMAScript 中有 5 種簡單數據類型(也稱為基本數據類型):Undefined、Null、Boolean、Number和 String。還有 1 種複雜數據類型——Object,Object 本質上是由一組無序的名值對組成的。
typeof操作符
鑒於 ECMAScript 是鬆散類型的,因此需要有一種手段來檢測給定變數的數據類型——typeof 就
是負責提供這方面資訊的操作符。對一個值使用 typeof 操作符可能返回下列某個字元串:
- "undefined"——如果這個值未定義;
- "boolean"——如果這個值是布爾值;
- "string"——如果這個值是字元串;
- "number"——如果這個值是數值;
- "object"——如果這個值是對象或 null;
- "function"——如果這個值是函數。
undefined 類型
Undefined 類型只有一個值,即特殊的 undefined。在使用 var 聲明變數但未對其加以初始化時,這個變數的值就是 undefined
Null 類型
Null 類型是第二個只有一個值的數據類型,這個特殊的值是 null。從邏輯角度來看, null 值表示一個空對象指針,而這也正是使用 typeof 操作符檢測 null 值時會返回"object"的原因。
下表給出了各種數據類型及其對應的轉換規則。
| 數據類型 | 轉換為true的值 | 轉換為false的值 |
|---|---|---|
| Boolean | true | false |
| String | 任何非空字元串 | 「」(空字元串) |
| Number | 任何非零數字值(包括無窮大) | 0和NaN |
| Object | 任何對象 | null |
| Undefined | n/a | undefined |
數值
NaN
NaN,即非數值(Not a Number)是一個特殊的數值,這個數值用於表示一個本來要返回數值的操作數未返回數值的情況(這樣就不會拋出錯誤了)
NaN 本身有兩個非同尋常的特點。首先,任何涉及 NaN 的操作(例如 NaN/10)都會返回 NaN,這個特點在多步計算中有可能導致問題。其次, NaN 與任何值都不相等,包括 NaN 本身。
布爾操作符
邏輯與
邏輯與操作可以應用於任何類型的操作數,而不僅僅是布爾值。在有一個操作數不是布爾值的情況下,邏輯與操作就不一定返回布爾值;此時,它遵循下列規則:
- 如果第一個操作數是對象,則返回第二個操作數;
- 如果第二個操作數是對象,則只有在第一個操作數的求值結果為 true 的情況下才會返回該對象;
- 如果兩個操作數都是對象,則返回第二個操作數;
- 如果有一個操作數是 null,則返回 null;
- 如果有一個操作數是 NaN,則返回 NaN;
- 如果有一個操作數是 undefined,則返回 undefined。
邏輯與操作屬於短路操作,即如果第一個操作數能夠決定結果,那麼就不會再對第二個操作數求值。對於邏輯與操作而言,如果第一個操作數是 false,則無論第二個操作數是什麼值,結果都不可能是true 了。
邏輯或
與邏輯與操作相似,如果有一個操作數不是布爾值,邏輯或也不一定返回布爾值;此時,它遵循下列規則:
- 如果第一個操作數是對象,則返回第一個操作數;
- 如果第一個操作數的求值結果為 false,則返回第二個操作數;
- 如果兩個操作數都是對象,則返回第一個操作數;
- 如果兩個操作數都是 null,則返回 null;
- 如果兩個操作數都是 NaN,則返回 NaN;
- 如果兩個操作數都是 undefined,則返回 undefined。
與邏輯與操作符相似,邏輯或操作符也是短路操作符。也就是說,如果第一個操作數的求值結果為true,就不會對第二個操作數求值了。
乘性操作符
乘法
乘法操作符由一個星號(*)表示,用於計算兩個數值的乘積。其語法類似於 C,如下面的例子所示:
var result = 34 * 56;
在處理特殊值的情況下,乘法操作符遵循下列特殊的規則:
- 如果操作數都是數值,執行常規的乘法計算,即兩個正數或兩個負數相乘的結果還是正數,而如果只有一個操作數有符號,那麼結果就是負數。如果乘積超過了 ECMAScript 數值的表示範圍,則返回 Infinity 或-Infinity;
- 如果有一個操作數是 NaN,則結果是 NaN;
- 如果是 Infinity 與 0 相乘,則結果是 NaN;
- 如果是 Infinity 與非 0 數值相乘,則結果是 Infinity 或-Infinity,取決於有符號操作數
- 號;
- 如果是 Infinity 與 Infinity 相乘,則結果是 Infinity;
- 如果有一個操作數不是數值,則在後台調用 Number()將其轉換為數值,然後再應用上面的
規則
除法
除法操作符由一個斜線符號(/)表示,執行第二個操作數除第一個操作數的計算,如下面的例子所示:
var result = 66 / 11;
與乘法操作符類似,除法操作符對特殊的值也有特殊的處理規則。這些規則如下:
- 如果操作數都是數值,執行常規的除法計算,即兩個正數或兩個負數相除的結果還是正數,而如果只有一個操作數有符號,那麼結果就是負數。如果商超過了 ECMAScript 數值的表示範圍,則返回 Infinity 或-Infinity;
- 如果有一個操作數是 NaN,則結果是 NaN;
- 如果是 Infinity 被 Infinity 除,則結果是 NaN;
- 如果是零被零除,則結果是 NaN;
- 如果是非零的有限數被零除,則結果是 Infinity 或-Infinity,取決於有符號操作數的符號;
- 如果是 Infinity 被任何非零數值除,則結果是 Infinity 或-Infinity,取決於有符號操作數的符號;
- 如果有一個操作數不是數值,則在後台調用 Number()將其轉換為數值,然後再應用上面的規則
求模
求模(餘數)操作符由一個百分號(%)表示,用法如下:
var result = 26 % 5; // 等於 1
與另外兩個乘性操作符類似,求模操作符會遵循下列特殊規則來處理特殊的值:
- 如果操作數都是數值,執行常規的除法計算,返回除得的餘數;
- 如果被除數是無窮大值而除數是有限大的數值,則結果是 NaN;
- 如果被除數是有限大的數值而除數是零,則結果是 NaN;
- 如果是 Infinity 被 Infinity 除,則結果是 NaN;
- 如果被除數是有限大的數值而除數是無窮大的數值,則結果是被除數;
- 如果被除數是零,則結果是零;
- 如果有一個操作數不是數值,則在後台調用 Number()將其轉換為數值,然後再應用上面的規則
加性操作符
加法
加法操作符(+)的用法如下所示:
var result = 1 + 2;
如果兩個操作符都是數值,執行常規的加法計算,然後根據下列規則返回結果:
- 如果有一個操作數是 NaN,則結果是 NaN;
- 如果是 Infinity 加 Infinity,則結果是 Infinity;
- 如果是-Infinity 加-Infinity,則結果是-Infinity;
- 如果是 Infinity 加-Infinity,則結果是 NaN;
- 如果是+0 加+0,則結果是+0;
- 如果是-0 加-0,則結果是-0;
- 如果是+0 加-0,則結果是+0。
不過,如果有一個操作數是字元串,那麼就要應用如下規則: - 如果兩個操作數都是字元串,則將第二個操作數與第一個操作數拼接起來;
- 如果只有一個操作數是字元串,則將另一個操作數轉換為字元串,然後再將兩個字元串拼接
起來。 - 如果有一個操作數是對象、數值或布爾值,則調用它們的 toString()方法取得相應的字元串值,然後再應用前面關於字元串的規則。對於 undefined 和 null,則分別調用 String()函數並取得字元串"undefined"和"null"。
減法
減法操作符(-)是另一個極為常用的操作符,其用法如下所示:
var result = 2 - 1;
與加法操作符類似, ECMAScript 中的減法操作符在處理各種數據類型轉換時,同樣需要遵循一些特殊規則,如下所示:
- 如果兩個操作符都是數值,則執行常規的算術減法操作並返回結果;
- 如果有一個操作數是 NaN,則結果是 NaN;
- 如果是 Infinity 減 Infinity,則結果是 NaN;
- 如果是-Infinity 減-Infinity,則結果是 NaN;
- 如果是 Infinity 減-Infinity,則結果是 Infinity;
- 如果是-Infinity 減 Infinity,則結果是-Infinity;
- 如果是+0 減+0,則結果是+0;
- 如果是+0 減-0,則結果是-0;
- 如果是-0 減-0,則結果是+0;
- 如果有一個操作數是字元串、布爾值、 null 或 undefined,則先在後台調用 Number()函數將其轉換為數值,然後再根據前面的規則執行減法計算。如果轉換的結果是 NaN,則減法的結果就是 NaN;
- 如果有一個操作數是對象,則調用對象的 valueOf()方法以取得表示該對象的數值。如果得到的值是 NaN,則減法的結果就是 NaN。如果對象沒有 valueOf()方法,則調用其 toString()方法並將得到的字元串轉換為數值
關係操作符
小於(<)、大於(>)、小於等於(<=)和大於等於(>=)這幾個關係操作符用於對兩個值進行比較,比較的規則與我們在數學課上所學的一樣。這幾個操作符都返回一個布爾值,如下面的例子所示:
var result1 = 5 > 3; //true
var result2 = 5 < 3; //false
RelationalOperatorsExample01.htm 中包含本節所有的程式碼片段
與 ECMAScript 中的其他操作符一樣,當關係操作符的操作數使用了非數值時,也要進行數據轉換或完成某些奇怪的操作。以下就是相應的規則。
- 如果兩個操作數都是數值,則執行數值比較。
- 如果兩個操作數都是字元串,則比較兩個字元串對應的字元編碼值。
- 如果一個操作數是數值,則將另一個操作數轉換為一個數值,然後執行數值比較。
- 如果一個操作數是對象,則調用這個對象的 valueOf()方法,用得到的結果按照前面的規則執行比較。如果對象沒有 valueOf()方法,則調用 toString()方法,並用得到的結果根據前面的規則執行比較。
- 如果一個操作數是布爾值,則先將其轉換為數值,然後再執行比較
var result = "a" < 3; // false,因為"a"被轉換成了 NaN
由於字母"a"不能轉換成合理的數值,因此就被轉換成了 NaN。根據規則,任何操作數與 NaN 進行關係比較,結果都是 false。於是,就出現了下面這個有意思的現象:
var result1 = NaN < 3; //false
var result2 = NaN >= 3; //false
按照常理,如果一個值不小於另一個值,則一定是大於或等於那個值。然而,在與 NaN 進行比較時,這兩個比較操作的結果都返回了 false。
相等操作符
相等和不相等
在轉換不同的數據類型時,相等和不相等操作符遵循下列基本規則:
- 如果有一個操作數是布爾值,則在比較相等性之前先將其轉換為數值——false 轉換為 0,而true 轉換為 1;
- 如果一個操作數是字元串,另一個操作數是數值,在比較相等性之前先將字元串轉換為數值;
- 如果一個操作數是對象,另一個操作數不是,則調用對象的 valueOf()方法,用得到的基本類
型值按照前面的規則進行比較;
這兩個操作符在進行比較時則要遵循下列規則。
- null 和 undefined 是相等的。
- 要比較相等性之前,不能將 null 和 undefined 轉換成其他任何值。
- 如果有一個操作數是 NaN,則相等操作符返回 false,而不相等操作符返回 true。重要提示:即使兩個操作數都是 NaN,相等操作符也返回 false;因為按照規則, NaN 不等於 NaN。
- 如果兩個操作數都是對象,則比較它們是不是同一個對象。如果兩個操作數都指向同一個對象,則相等操作符返回 true;否則,返回 false。
除了在比較之前不轉換操作數之外,全等和不全等操作符與相等和不相等操作符沒有什麼區別。全等操作符由 3 個等於號(===)表示,它只在兩個操作數未經轉換就相等的情況下返回 true,如下面的例子所示:
var result1 = ("55" == 55); //true,因為轉換後相等
var result2 = ("55" === 55); //false,因為不同的數據類型不相等
break和continue語句
break 和 continue 語句都可以與 label 語句聯合使用,從而返回程式碼中特定的位置。這種聯合使用的情況多發生在循環嵌套的情況下,如下面的例子所示:
var num = 0;
outermost:
for (var i=0; i < 10; i++) {
for (var j=0; j < 10; j++) {
if (i == 5 && j == 5) {
break outermost;
}
num++;
}
}
alert(num); //55
在這個例子中, outermost 標籤表示外部的 for 語句。如果每個循環正常執行 10 次,則 num++語句就會正常執行 100 次。換句話說,如果兩個循環都自然結束, num 的值應該是 100。但內部循環中的 break 語句帶了一個參數:要返回到的標籤。添加這個標籤的結果將導致 break 語句不僅會退出內部的 for 語句(即使用變數 j 的循環),而且也會退出外部的 for 語句(即使用變數 i 的循環)。為此,當變數 i 和 j 都等於 5 時, num 的值正好是 55。
同樣, continue 語句也可以像這樣與 label 語句聯用,如下面的例子所示:
var num = 0;
outermost:
for (var i=0; i < 10; i++) {
for (var j=0; j < 10; j++) {
if (i == 5 && j == 5) {
continue outermost;
}
num++;
}
}
alert(num); //95
在這種情況下, continue 語句會強制繼續執行循環——退出內部循環,執行外部循環。當 j 是 5時, continue 語句執行,而這也就意味著內部循環少執行了 5 次,因此 num 的結果是 95。
ps:continue+label跳出一個循環,這和break的作用感覺是一樣的?,這種標籤顯示編碼中還是比較少用,表示看不懂。另外如果有>2層的玄幻,contine+label會出現什麼情況,不知道......
swith
var num = 25;
switch (true) {
case num < 0:
alert("Less than 0.");
break;
case num >= 0 && num <= 10:
alert("Between 0 and 10.");
break;
case num > 10 && num <= 20:
alert("Between 10 and 20.");
break;
default:
alert("More than 20.");
}
switch 語句在比較值時使用的是全等操作符,因此不會發生類型轉換(例如,字元串"10"不等於數值 10)。
ps:這語法糖厲害,還可以在case上做運算的,牛逼
函數
理解參數
ECMAScript 函數的參數與大多數其他語言中函數的參數有所不同。 ECMAScript 函數不介意傳遞進來多少個參數,也不在乎傳進來參數是什麼數據類型。也就是說,即便你定義的函數只接收兩個參數,在調用這個函數時也未必一定要傳遞兩個參數。可以傳遞一個、三個甚至不傳遞參數,而解析器永遠不會有什麼怨言。之所以會這樣,原因是 ECMAScript 中的參數在內部是用一個數組來表示的函數接收到的始終都是這個數組,而不關心數組中包含哪些參數(如果有參數的話)。如果這個數組中不包含任何元素,無所謂;如果包含多個元素,也沒有問題。實際上,在函數體內可以通過 argument對象來訪問這個參數數組,從而獲取傳遞給函數的每一個參數。
通過訪問 arguments 對象的 length 屬性可以獲知有多少個參數傳遞給了函數。下面這個函數會在每次被調用時,輸出傳入其中的參數個數:
function howManyArgs() {
alert(arguments.length);
}
howManyArgs("string", 45); //2
howManyArgs(); //0
howManyArgs(12); //1
另一個與參數相關的重要方面,就是 arguments 對象可以與命名參數一起使用,如下面的例子所示:
function doAdd(num1, num2) {
if(arguments.length == 1) {
alert(num1 + 10);
} else if (arguments.length == 2) {
alert(arguments[0] + num2);
}
}
在重寫後的這個 doAdd()函數中,兩個命名參數都與 arguments 對象一起使用。由於 num1 的值與 arguments[0]的值相同,因此它們可以互換使用(當然, num2 和 arguments[1]也是如此)。
沒有重載
ECMAScript 函數不能像傳統意義上那樣實現重載。而在其他語言(如 Java)中,可以為一個函數編寫兩個定義,只要這兩個定義的簽名(接受的參數的類型和數量)不同即可。如前所述, ECMAScirpt函數沒有簽名,因為其參數是由包含零或多個值的數組來表示的。而沒有函數簽名,真正的重載是不可能做到的。如果在 ECMAScript 中定義了兩個名字相同的函數,則該名字只屬於後定義的函數。請看下面的例子:
function addSomeNumber(num){
return num + 100;
}
function addSomeNumber(num) {
return num + 200;
}
var result = addSomeNumber(100); //300
ps:這兩個分明是相同的函數定義,在java中,是會拋異常的,只是在js中會覆蓋而已。另外如果我定義一個 addSomeNuber(num1,num2)的函數,按照作者的意思也不算是重載,因為對於js來說入參都是arguments數組,但是從使用者來看,就是重載?
變數、作用域和記憶體問題
基本類型和引用類型
ECMAScript 變數可能包含兩種不同數據類型的值:基本類型值和引用類型值。 基本類型值指的是簡單的數據段,而引用類型值指那些可能由多個值構成的對象。
動態的屬性
對於引用類型的值,我們可以為其添加屬性和方法,也可以改變和刪除其屬性和方法。
var person = new Object();
person.name = "Nicholas";
alert(person.name); //"Nicholas"
但是,我們不能給基本類型的值添加屬性,儘管這樣做不會導致任何錯誤。比如:
var name = "Nicholas";
name.age = 27;
alert(name.age); //undefined
在這個例子中,我們為字元串 name 定義了一個名為 age 的屬性,並為該屬性賦值 27。但在下一行訪問這個屬性時,發現該屬性不見了。這說明只能給引用類型值動態地添加屬性。
複製變數值
複製基本變數是兩個相互獨立的記憶體,操作互不影響。
當從一個變數向另一個變數複製引用類型的值時,同樣也會將存儲在變數對象中的值複製一份放到為新變數分配的空間中。不同的是,這個值的副本實際上是一個指針,而這個指針指向存儲在堆中的一個對象。複製操作結束後,兩個變數實際上將引用同一個對象。因此,改變其中一個變數,就會影響另一個變數。
傳遞參數
ECMAScript 中所有函數的參數都是按值傳遞的。也就是說,把函數外部的值複製給函數內部的參數,就和把值從一個變數複製到另一個變數一樣。基本類型值的傳遞如同基本類型變數的複製一樣,而引用類型值的傳遞,則如同引用類型變數的複製一樣。
function addTen(num) {
num += 10;
return num;
}
var count = 20;
var result = addTen(count);
alert(count); //20,沒有變化
alert(result); //30
這裡的函數 addTen()有一個參數 num,而參數實際上是函數的局部變數。在調用這個函數時,變數 count 作為參數被傳遞給函數,這個變數的值是 20。於是,數值 20 被複制給參數 num 以便在 addTen()中使用。在函數內部,參數 num 的值被加上了 10,但這一變化不會影響函數外部的 count 變數。參數num 與變數 count 互不相識,它們僅僅是具有相同的值。
function setName(obj) {
obj.name = "Nicholas";
}
var person = new Object();
setName(person);
alert(person.name); //"Nicholas"
以上程式碼中創建一個對象,並將其保存在了變數 person 中。然後,這個變數被傳遞到 setName()函數中之後就被複制給了 obj。在這個函數內部, obj 和 person 引用的是同一個對象。換句話說,即使這個變數是按值傳遞的, obj 也會按引用來訪問同一個對象。於是,當在函數內部為 obj 添加 name屬性後,函數外部的 person 也將有所反映;因為 person 指向的對象在堆記憶體中只有一個,而且是全局對象。
有很多開發人員錯誤地認為:在局部作用域中修改的對象會在全局作用域中反映出來,就說明
參數是按引用傳遞的。為了證明對象是按值傳遞的,我們再看一看下面這個經過修改的例子:
function setName(obj) {
obj.name = "Nicholas";
obj = new Object();
obj.name = "Greg";
}
var person = new Object();
setName(person);
alert(person.name); //"Nicholas"
這個例子與前一個例子的唯一區別,就是在 setName()函數中添加了兩行程式碼:一行程式碼為 obj重新定義了一個對象,另一行程式碼為該對象定義了一個帶有不同值的 name 屬性。在把 person 傳遞給setName()後,其 name 屬性被設置為"Nicholas"。然後,又將一個新對象賦給變數 obj,同時將其 name屬性設置為"Greg"。如果 person 是按引用傳遞的,那麼 person 就會自動被修改為指向其 name 屬性值為"Greg"的新對象。但是,當接下來再訪問 person.name 時,顯示的值仍然是"Nicholas"。這說明即使在函數內部修改了參數的值,但原始的引用仍然保持未變。實際上,當在函數內部重寫 obj 時,這個變數引用的就是一個局部對象了。而這個局部對象會在函數執行完畢後立即被銷毀。
執行環境和作用域
JavaScript 沒有塊級作用域經常會導致理解上的困惑。在其他類 C 的語言中,由花括弧封閉的程式碼塊都有自己的作用域(如果用 ECMAScript 的話來講,就是它們自己的執行環境),因而支援根據條件來定義變數。
for (var i=0; i < 10; i++){
doSomething(i);
}
alert(i); //10
對於有塊級作用域的語言來說, for 語句初始化變數的表達式所定義的變數,只會存在於循環的環境之中。而對於 JavaScript 來說,由 for 語句創建的變數 i 即使在 for 循環執行結束後,也依舊會存在於循環外部的執行環境中。
聲明變數
使用 var 聲明的變數會自動被添加到最接近的環境中。在函數內部,最接近的環境就是函數的局部環境;
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
var result = add(10, 20); //30
alert(sum); //由於 sum 不是有效的變數,因此會導致錯誤
垃圾收集
JavaScript 具有自動垃圾收集機制,也就是說,執行環境會負責管理程式碼執行過程中使用的記憶體
引用類型
Object
創建 Object 實例的方式有兩種。第一種是使用 new 操作符後跟 Object 構造函數,如下所示:
var person = new Object();
person.name = "Nicholas";
person.age = 29;
另一種方式是使用對象字面量表示法。對象字面量是對象定義的一種簡寫形式,目的在於簡化創建包含大量屬性的對象的過程。下面這個例子就使用了對象字面量語法定義了與前面那個例子中相同的person 對象:
var person = {
name : "Nicholas",
age : 29
};
一般來說,訪問對象屬性時使用的都是點表示法,這也是很多面向對象語言中通用的語法。不過,在 JavaScript 也可以使用方括弧表示法來訪問對象的屬性。在使用方括弧語法時,應該將要問的屬性以字元串的形式放在方括弧中,如下面的例子所示。
alert(person["name"]); //"Nicholas"
alert(person.name); //"Nicholas"
從功能上看,這兩種訪問對象屬性的方法沒有任何區別。但方括弧語法的主要優點是可以通過變數來訪問屬性,例如:
var propertyName = "name";
alert(person[propertyName]); //"Nicholas"
如果屬性名中包含會導致語法錯誤的字元,或者屬性名使用的是關鍵字或保留字,也可以使用方括弧表示法。例如:
person["first name"] = "Nicholas";
由於"first name"中包含一個空格,所以不能使用點表示法來訪問它。然而,屬性名中是可以包含非字母非數字的,這時候就可以使用方括弧表示法來訪問它們。通常,除非必須使用變數來訪問屬性,否則我們建議使用點表示法
Array類型
數組的 length 屬性很有特點——它不是只讀的。因此,通過設置這個屬性,可以從數組的末尾移除項或向數組中添加新項。請看下面的例子:
var colors = ["red", "blue", "green"]; // 創建一個包含 3 個字元串的數組
colors.length = 2;
alert(colors[2]); //undefined
這個例子中的數組 colors 一開始有 3 個值。將其 length 屬性設置為 2 會移除最後一項(位置為2 的那一項),結果再訪問 colors[2]就會顯示 undefined 了。如果將其 length 屬性設置為大於數組項數的值,則新增的每一項都會取得 undefined 值,如下所示:
var colors = ["red", "blue", "green"]; // 創建一個包含 3 個字元串的數組
colors.length = 4;
alert(colors[3]); //undefined
利用 length 屬性也可以方便地在數組末尾添加新項,如下所示:
var colors = ["red", "blue", "green"]; // 創建一個包含 3 個字元串的數組
colors[colors.length] = "black"; //(在位置 3)添加一種顏色
colors[colors.length] = "brown"; //(在位置 4)再添加一種顏色
當把一個值放在超出當前數組大小的位置上時,數組就會重新計算其長度值,即長度值等於最後一項的索引加 1,如下面的例子所示:
var colors = ["red", "blue", "green"]; // 創建一個包含 3 個字元串的數組
colors[99] = "black"; // (在位置 99)添加一種顏色
alert(colors.length); // 100
棧方法
ECMAScript 數組也提供了一種讓數組的行為類似於其他數據結構的方法。具體說來,數組可以表現得就像棧一樣,後者是一種可以限制插入和刪除項的數據結構。棧是一種 LIFO(Last-In-First-Out,後進先出)的數據結構,也就是最新添加的項最早被移除。而棧中項的插入(叫做推入)和移除(叫做彈出),只發生在一個位置——棧的頂部。 ECMAScript 為數組專門提供了 push()和 pop()方法,以便實現類似棧的行為
var colors = new Array(); // 創建一個數組
var count = colors.push("red", "green"); // 推入兩項
alert(count); //2
count = colors.push("black"); // 推入另一項
alert(count); //3
var item = colors.pop(); // 取得最後一項
alert(item); //"black"
alert(colors.length); //2
隊列方法
棧數據結構的訪問規則是 LIFO(後進先出),而隊列數據結構的訪問規則是 FIFO(First-In-First-Out,先進先出)。隊列在列表的末端添加項,從列表的前端移除項。由於 push()是向數組末端添加項的方法,因此要模擬隊列只需一個從數組前端取得項的方法。實現這一操作的數組方法就是 shift(),它能夠移除數組中的第一個項並返回該項,同時將數組長度減 1。結合使用 shift()和 push()方法,可以像使用隊列一樣使用數組。
var colors = new Array(); //創建一個數組
var count = colors.push("red", "green"); //推入兩項
alert(count); //2
count = colors.push("black"); //推入另一項
alert(count); //3
var item = colors.shift(); //取得第一項
alert(item); //"red"
alert(colors.length); //2
ECMAScript 還為數組提供了一個 unshift()方法。顧名思義,unshift()與 shift()的用途相反:它能在數組前端添加任意個項並返回新數組的長度。因此,同時使用 unshift()和 pop()方法,可以從相反的方向來模擬隊列,即在數組的前端添加項,從數組末端移除項,如下面的例子所示。
var colors = new Array(); //創建一個數組
var count = colors.unshift("red", "green"); //推入兩項
alert(count); //2
count = colors.unshift("black"); //推入另一項
alert(count); //3
var item = colors.pop(); //取得最後一項
alert(item); //"green"
alert(colors.length); //2
重排序方法
數組中已經存在兩個可以直接用來重排序的方法: reverse()和 sort()。
這個比較函數可以適用於大多數數據類型,只要將其作為參數傳遞給 sort()方法即可,如下面這
個例子所示。
var values = [0, 1, 5, 10, 15];
values.sort(compare);
alert(values); //0,1,5,10,15
迭代方法
以下是數組的 5 個迭代方法的作用。
- every():對數組中的每一項運行給定函數,如果該函數對每一項都返回 true,則返回 true。
- filter():對數組中的每一項運行給定函數,返回該函數會返回 true 的項組成的數組。
- forEach():對數組中的每一項運行給定函數。這個方法沒有返回值。
- map():對數組中的每一項運行給定函數,返回每次函數調用的結果組成的數組。
- some():對數組中的每一項運行給定函數,如果該函數對任一項返回 true,則返回 true。
Function類型
說起來 ECMAScript 中什麼最有意思,我想那莫過於函數了——而有意思的根源,則在於函數實際上是對象。每個函數都是 Function 類型的實例,而且都與其他引用類型一樣具有屬性和方法。由於函數是對象,因此函數名實際上也是一個指向函數對象的指針,不會與某個函數綁定。函數通常是使用函
數聲明語法定義的,如下面的例子所示。
function sum (num1, num2) {
return num1 + num2;
}
這與下面使用函數表達式定義函數的方式幾乎相差無幾。
var sum = function(num1, num2){
return num1 + num2;
};
函數聲明與函數表達式
解析器會率先讀取函數聲明,並使其在執行任何程式碼之前可用(可以訪問);至於函數表達式,則必須等到解析器執行到它所在的程式碼行,才會真正被解釋執行。請看下面的例子。
alert(sum(10,10));
function sum(num1, num2){
return num1 + num2;
}
alert(sum(10,10));
var sum = function(num1, num2){
return num1 + num2;
};
以上程式碼之所以會在運行期間產生錯誤,原因在於函數位於一個初始化語句中,而不是一個函數聲明。換句話說,在執行到函數所在的語句之前,變數 sum 中不會保存有對函數的引用;而且,由於第一行程式碼就會導致「unexpected identifier」(意外標識符)錯誤,實際上也不會執行到下一行。
作為值的函數
因為 ECMAScript 中的函數名本身就是變數,所以函數也可以作為值來使用。也就是說,不僅可以像傳遞參數一樣把一個函數傳遞給另一個函數,而且可以將一個函數作為另一個函數的結果返回。
函數內部屬性
雖然 arguments 的主要用途是保存函數參數,但這個對象還有一個名叫 callee 的屬性,該屬性是一個指針,指向擁有這個 arguments 對象的函數。
請看下面這個非常經典的階乘函數。
function factorial(num){
if (num <=1) {
return 1;
} else {
return num * factorial(num-1)
}
}
定義階乘函數一般都要用到遞歸演算法;如上面的程式碼所示,在函數有名字,而且名字以後也不會變的情況下,這樣定義沒有問題。但問題是這個函數的執行與函數名 factorial 緊緊耦合在了一起。為了消除這種緊密耦合的現象,可以像下面這樣使用 arguments.callee。
function factorial(num){
if (num <=1) {
return 1;
} else {
return num * arguments.callee(num-1)
}
}
函數屬性和方法
前面曾經提到過, ECMAScript 中的函數是對象,因此函數也有屬性和方法。每個函數都包含兩個屬性: length 和 prototype。其中, length 屬性表示函數希望接收的命名參數的個數,如下面的例子所示。
function sayName(name){
alert(name);
}
function sum(num1, num2){
return num1 + num2;
}
function sayHi(){
alert("hi");
}
alert(sayName.length); //1
alert(sum.length); //2
alert(sayHi.length); //0
在 ECMAScript 核心所定義的全部屬性中,最耐人尋味的就要數 prototype 屬性了。對於ECMAScript 中的引用類型而言, prototype 是保存它們所有實例方法的真正所在。換句話說,諸如toString()和 valueOf()等方法實際上都保存在 prototype 名下,只不過是通過各自對象的實例訪問罷了。在創建自定義引用類型以及實現繼承時, prototype 屬性的作用是極為重要的
每個函數都包含兩個非繼承而來的方法: apply()和 call()。這兩個方法的用途都是在特定的作用域中調用函數,實際上等於設置函數體內 this 對象的值。首先, apply()方法接收兩個參數:一個是在其中運行函數的作用域,另一個是參數數組。其中,第二個參數可以是 Array 的實例,也可以是arguments 對象。例如:
function sum(num1, num2){
return num1 + num2;
}
function callSum1(num1, num2){
return sum.apply(this, arguments); // 傳入 arguments 對象
}
function callSum2(num1, num2){
return sum.apply(this, [num1, num2]); // 傳入數組
}
alert(callSum1(10,10)); //20
alert(callSum2(10,10)); //20
call()方法與 apply()方法的作用相同,它們的區別僅在於接收參數的方式不同。對於 call()方法而言,第一個參數是 this 值沒有變化,變化的是其餘參數都直接傳遞給函數。換句話說,在使用call()方法時,傳遞給函數的參數必須逐個列舉出來,如下面的例子所示。
function sum(num1, num2){
return num1 + num2;
}
function callSum(num1, num2){
return sum.call(this, num1, num2);
}
alert(callSum(10,10)); //20
事實上,傳遞參數並非 apply()和 call()真正的用武之地;它們真正強大的地方是能夠擴充函數賴以運行的作用域。下面來看一個例子。
window.color = "red";
var o = { color: "blue" };
function sayColor(){
alert(this.color);
}
sayColor(); //red
sayColor.call(this); //red
sayColor.call(window); //red
sayColor.call(o); //blue
ECMAScript 5 還定義了一個方法: bind()。這個方法會創建一個函數的實例,其 this 值會被綁定到傳給 bind()函數的值。例如:
window.color = "red";
var o = { color: "blue" };
function sayColor(){
alert(this.color);
}
var objectSayColor = sayColor.bind(o);
objectSayColor(); //blue
Global對象
Global(全局)對象可以說是 ECMAScript 中最特別的一個對象了,因為不管你從什麼角度上看,這個對象都是不存在的。 ECMAScript 中的 Global 對象在某種意義上是作為一個終極的「兜底兒對象」來定義的。換句話說,不屬於任何其他對象的屬性和方法,最終都是它的屬性和方法。事實上,沒有全局變數或全局函數;所有在全局作用域中定義的屬性和函數,都是 Global 對象的屬性。本書前面介紹過的那些函數,諸如 isNaN()、isFinite()、parseInt()以及 parseFloat(),實際上全都是 Global對象方法。
**URI 編碼方法 **
encodeURI()主要用於整個 URI(例如, //www.wrox.com/illegal value.htm),而encodeURIComponent()主要用於對 URI 中的某一段(例如前面 URI 中的 illegal value.htm)進行編碼。它們的主要區別在於, encodeURI()不會對本身屬於 URI 的特殊字元進行編碼,例如冒號、正斜杠、問號和井字型大小;而 encodeURIComponent()則會對它發現的任何非標準字元進行編碼。
eval()方法
eval("alert('hi')");
這行程式碼的作用等價於下面這行程式碼:
alert("hi");
當解析器發現程式碼中調用 eval()方法時,它會將傳入的參數當作實際的 ECMAScript 語句來解析,然後把執行結果插入到原位置。通過 eval()執行的程式碼被認為是包含該次調用的執行環境的一部分,因此被執行的程式碼具有與該執行環境相同的作用域鏈。這意味著通過 eval()執行的程式碼可以引用在包含環境中定義的變數,舉個例子:
``js
var msg = "hello world";
eval("alert(msg)"); //"hello world"
可見,變數 msg 是在 eval()調用的環境之外定義的,但其中調用的 alert()仍然能夠顯示"helloworld"。這是因為上面第二行程式碼最終被替換成了一行真正的程式碼。同樣地,我們也可以在 eval()調用中定義一個函數,然後再在該調用的外部程式碼中引用這個函數:
```js
eval("function sayHi() { alert('hi'); }");
sayHi();
window對象
ECMAScript 雖然沒有指出如何直接訪問 Global 對象,但 Web 瀏覽器都是將這個全局對象作為window 對象的一部分加以實現的。因此,在全局作用域中聲明的所有變數和函數,就都成為了 window對象的屬性。
JavaScript中的 window 對象除了扮演 ECMAScript規定的 Global 對象的角色外,
還承擔了很多別的任務
面向對象的程式設計
理解對象
var person = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName: function(){
alert(this.name);
}
};
這個例子中的 person 對象與前面例子中的 person 對象是一樣的,都有相同的屬性和方法。
屬性類型
數據屬性
數據屬性包含一個數據值的位置。在這個位置可以讀取和寫入值。數據屬性有 4 個描述其行為的特性。
- [[Configurable]]:表示能否通過 delete 刪除屬性從而重新定義屬性,能否修改屬性的特性,或者能否把屬性修改為訪問器屬性。像前面例子中那樣直接在對象上定義的屬性,它們的這個特性默認值為 true。
- [[Enumerable]]:表示能否通過 for-in 循環返回屬性。像前面例子中那樣直接在對象上定義的屬性,它們的這個特性默認值為 true。
- [[Writable]]:表示能否修改屬性的值。像前面例子中那樣直接在對象上定義的屬性,它們的這個特性默認值為 true。
- [[Value]]:包含這個屬性的數據值。讀取屬性值的時候,從這個位置讀;寫入屬性值的時候,把新值保存在這個位置。這個特性的默認值為 undefined
要修改屬性默認的特性,必須使用 ECMAScript 5 的 Object.defineProperty()方法。這個方法接收三個參數:屬性所在的對象、屬性的名字和一個描述符對象。其中,描述符(descriptor)對象的屬性必須是: configurable、 enumerable、 writable 和 value。設置其中的一或多個值,可以修改對應的特性值。例如:
var person = {};
Object.defineProperty(person, "name", {
writable: false,
value: "Nicholas"
});
alert(person.name); //"Nicholas"
person.name = "Greg";
alert(person.name); //"Nicholas"
把 configurable 設置為 false,表示不能從對象中刪除屬性。如果對這個屬性調用 delete,則在非嚴格模式下什麼也不會發生,而在嚴格模式下會導致錯誤。而且,一旦把屬性定義為不可配置的,就不能再把它變回可配置了。
訪問器屬性
訪問器屬性不包含數據值;它們包含一對兒 getter 和 setter 函數(不過,這兩個函數都不是必需的)。在讀取訪問器屬性時,會調用 getter 函數,這個函數負責返回有效的值;在寫入訪問器屬性時,會調用setter 函數並傳入新值,這個函數負責決定如何處理數據。訪問器屬性有如下 4 個特性。
- [[Configurable]]:表示能否通過 delete 刪除屬性從而重新定義屬性,能否修改屬性的特
性,或者能否把屬性修改為數據屬性。對於直接在對象上定義的屬性,這個特性的默認值為
true。 - [[Enumerable]]:表示能否通過 for-in 循環返回屬性。對於直接在對象上定義的屬性,這
個特性的默認值為 true。 - [[Get]]:在讀取屬性時調用的函數。默認值為 undefined。
- [[Set]]:在寫入屬性時調用的函數。默認值為 undefined。
var book = {
_year: 2004,
edition: 1
};
Object.defineProperty(book, "year", {
get: function(){
return this._year;
},
set: function(newValue){
if (newValue > 2004) {
this._year = newValue;
this.edition += newValue - 2004;
}
}
});
book.year = 2005;
alert(book.edition); //2
定義多個屬性
ECMAScript 5 又定義了一個 Object.defineProperties()方法。利用這個方法可以通過描述符一次定義多個屬性
var book = {};
Object.defineProperties(book, {
_year: {
value: 2004
},
edition: {
value: 1
},
year: {
get: function(){
return this._year;
},
set: function(newValue){
if (newValue > 2004) {
this._year = newValue;
this.edition += newValue - 2004;
}
}
}
});
以上程式碼在 book 對象上定義了兩個數據屬性(_year 和 edition)和一個訪問器屬性(year)。最終的對象與上一節中定義的對象相同。唯一的區別是這裡的屬性都是在同一時間創建的。
創建對象
雖然 Object 構造函數或對象字面量都可以用來創建單個對象,但這些方式有個明顯的缺點:使用同一個介面創建很多對象,會產生大量的重複程式碼。為解決這個問題,人們開始使用工廠模式的種變體。
工廠模式
function createPerson(name, age, job){
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function(){
alert(this.name);
};
return o;
}
var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");
函數 createPerson()能夠根據接受的參數來構建一個包含所有必要資訊的 Person 對象。可以無數次地調用這個函數,而每次它都會返回一個包含三個屬性一個方法的對象。工廠模式雖然解決了創建多個相似對象的問題,但卻沒有解決對象識別的問題(即怎樣知道一個對象的類型)。
構造函數模式
例如,可以使用構造函數模式將前面的例子重寫如下。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function(){
alert(this.name);
};
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
在這個例子中, Person()函數取代了 createPerson()函數。我們注意到, Person()中的程式碼
除了與 createPerson()中相同的部分外,還存在以下不同之處:
- 沒有顯式地創建對象;
- 直接將屬性和方法賦給了 this 對象;
- 沒有 return 語句。
此外,還應該注意到函數名 Person 使用的是大寫字母 P。按照慣例,構造函數始終都應該以一個大寫字母開頭,而非構造函數則應該以一個小寫字母開頭。這個做法借鑒自其他 OO 語言,主要是為了區別於 ECMAScript 中的其他函數;因為構造函數本身也是函數,只不過可以用來創建對象而已。要創建 Person 的新實例,必須使用 new 操作符。以這種方式調用構造函數實際上會經歷以下 4個步驟:
- 一個新對象;
- 造函數的作用域賦給新對象(因此 this 就指向了這個新對象);
- 構造函數中的程式碼(為這個新對象添加屬性);
- 新對象。
在前面例子的最後, person1 和 person2 分別保存著 Person 的一個不同的實例。這兩個對象都有一個 constructor(構造函數)屬性,該屬性指向 Person,如下所示。
alert(person1.constructor == Person); //true
alert(person2.constructor == Person); //true
函數當作函數
構造函數與其他函數的唯一區別,就在於調用它們的方式不同。不過,構造函數畢竟也是函數,不存在定義構造函數的特殊語法。任何函數,只要通過 new 操作符來調用,那它就可以作為構造函數;而任何函數,如果不通過 new 操作符來調用,那它跟普通函數也不會有什麼兩樣。
// 當作構造函數使用
var person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); //"Nicholas"
// 作為普通函數調用
Person("Greg", 27, "Doctor"); // 添加到 window
window.sayName(); //"Greg"
// 在另一個對象的作用域中調用
var o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); //"Kristen"
函數的問題
構造函數模式雖然好用,但也並非沒有缺點。使用構造函數的主要問題,就是每個方法都要在每個實例上重新創建一遍。在前面的例子中, person1 和 person2 都有一個名為 sayName()的方法,但那兩個方法不是同一個 Function 的實例。不要忘了——ECMAScript 中的函數是對象,因此每定義一個函數,也就是實例化了一個對象。從邏輯角度講,此時的構造函數也可以這樣定義。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function("alert(this.name)"); // 與聲明函數在邏輯上是等價的
}
然而,創建兩個完成同樣任務的 Function 實例的確沒有必要;況且有 this 對象在,根本不用在執行程式碼前就把函數綁定到特定對象上面。因此,大可像下面這樣,通過把函數定義轉移到構造函數外部來解決這個問題。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName(){
alert(this.name);
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
由於 sayName 包含的是一個指向函數的指針,因此 person1 和 person2 對象就共享了在全局作用域中定義的同一個 sayName()函數。這樣做確實解決了兩個函數做同一件事的問題,可是新問題又來了:在全局作用域中定義的函數實際上只能被某個對象調用,這讓全局作用域有點名不副實。而更讓人無法接受的是:如果對象需要定義很多方法,那麼就要定義很多個全局函數,於是我們這個自定義的引用類型就絲毫沒有封裝性可言了。
原型模式
我們創建的每個函數都有一個 prototype(原型)屬性,這個屬性是一個指針,指向一個對象,而這個對象的用途是包含可以由特定類型的所有實例共享的屬性和方法。如果按照字面意思來理解,那麼 prototype 就是通過調用構造函數而創建的那個對象實例的原型對象。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
person1.sayName(); //"Nicholas"
var person2 = new Person();
person2.sayName(); //"Nicholas"
alert(person1.sayName == person2.sayName); //true
** 理解原型對象**
當為對象實例添加一個屬性時,這個屬性就會屏蔽原型對象中保存的同名屬性;換句話說,添加這個屬性只會阻止我們訪問原型中的那個屬性,但不會修改那個屬性。即使將這個屬性設置為 null,也只會在實例中設置這個屬性,而不會恢復其指向原型的連接。不過,使用 delete 操作符則可以完全刪除實例屬性,從而讓我們能夠重新訪問原型中的屬性,如下所示。
使用 hasOwnProperty()方法可以檢測一個屬性是存在於實例中,還是存在於原型中。這個方法(不要忘了它是從 Object 繼承來的)只在給定屬性存在於對象實例中時,才會返回 true。來看下面這個例子。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
var person2 = new Person();
alert(person1.hasOwnProperty("name")); //false
person1.name = "Greg";
alert(person1.name); //"Greg"—— 來自實例
alert(person1.hasOwnProperty("name")); //true
alert(person2.name); //"Nicholas"—— 來自原型
alert(person2.hasOwnProperty("name")); //false
delete person1.name;
alert(person1.name); //"Nicholas"—— 來自原型
alert(person1.hasOwnProperty("name")); //false
通過使用 hasOwnProperty()方法,什麼時候訪問的是實例屬性,什麼時候訪問的是原型屬性就一清二楚了。調用 person1.hasOwnProperty( "name")時,只有當 person1 重寫 name 屬性後才會返回 true,因為只有這時候 name 才是一個實例屬性,而非原型屬性。
原型對象的問題
原型中所有屬性是被很多實例共享的,這種共享對於函數非常合適。對於那些包含基本值的屬性倒也說得過去,畢竟(如前面的例子所示),通過在實例上添加一個同名屬性,可以隱藏原型中的對應屬性。然而,對於包含引用類型值的屬性來說,問題就比較突出了。來看下面的例子。
function Person(){
}
Person.prototype = {
constructor: Person,
name : "Nicholas",
age : 29,
job : "Software Engineer",
friends : ["Shelby", "Court"],
sayName : function () {
alert(this.name);
}
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push("Van");
alert(person1.friends); //"Shelby,Court,Van"
alert(person2.friends); //"Shelby,Court,Van"
alert(person1.friends === person2.friends); //true
在此, Person.prototype 對象有一個名為 friends 的屬性,該屬性包含一個字元串數組。然後,創建了 Person 的兩個實例。接著,修改了 person1.friends 引用的數組,向數組中添加了一個字元串。由於 friends 數組存在於 Person.prototype 而非 person1 中,所以剛剛提到的修改也會通過person2.friends(與 person1.friends 指向同一個數組)反映出來。假如我們的初衷就是像這樣在所有實例中共享一個數組,那麼對這個結果我沒有話可說。可是,實例一般都是要有屬於自己的全部屬性的。而這個問題正是我們很少看到有人單獨使用原型模式的原因所在。
組合使用構造函數模式和原型模式
創建自定義類型的最常見方式,就是組合使用構造函數模式與原型模式。構造函數模式用於定義實例屬性,而原型模式用於定義方法和共享的屬性。結果,每個實例都會有自己的一份實例屬性的副本,但同時又共享著對方法的引用,最大限度地節省了記憶體。另外,這種混成模式還支援向構造函數傳遞參數;可謂是集兩種模式之長。下面的程式碼重寫了前面的例子。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ["Shelby", "Court"];
}
Person.prototype = {
constructor : Person,
sayName : function(){
alert(this.name);
}
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
person1.friends.push("Van");
alert(person1.friends); //"Shelby,Count,Van"
alert(person2.friends); //"Shelby,Count"
alert(person1.friends === person2.friends); //false
alert(person1.sayName === person2.sayName); //true
動態原型模式
function Person(name, age, job){
//屬性
this.name = name;
this.age = age;
this.job = job;
//方法
if (typeof this.sayName != "function"){
Person.prototype.sayName = function(){
lert(this.name);
};
}
}
這裡只在 sayName()方法不存在的情況下,才會將它添加到原型中。這段程式碼只會在初次調用構造函數時才會執行。此後,原型已經完成初始化,不需要再做什麼修改了。不過要記住,這裡對原型所做的修改,能夠立即在所有實例中得到反映。因此,這種方法確實可以說非常完美。其中, if 語句檢查的可以是初始化之後應該存在的任何屬性或方法——不必用一大堆if 語句檢查每個屬性和每個方法;只要檢查其中一個即可。對於採用這種模式創建的對象,還可以使用 instanceof 操作符確定它的類型。
妥構造函數模式
道格拉斯·克羅克福德(Douglas Crockford)發明了 JavaScript 中的穩妥對象(durable objects)這個概念。所謂穩妥對象,指的是沒有公共屬性,而且其方法也不引用 this 的對象。穩妥對象最適合在一些安全的環境中(這些環境中會禁止使用 this 和 new),或者在防止數據被其他應用程式(如 Mashup程式)改動時使用。穩妥構造函數遵循與寄生構造函數類似的模式,但有兩點不同:一是新創建對象的實例方法不引用 this;二是不使用 new 操作符調用構造函數。按照穩妥構造函數的要求,可以將前面的 Person 構造函數重寫如下。
function Person(name, age, job){
//創建要返回的對象
var o = new Object();
//可以在這裡定義私有變數和函數
//添加方法
o.sayName = function(){
alert(name);
};
//返回對象
return o;
}
這樣,變數 friend 中保存的是一個穩妥對象,而除了調用 sayName()方法外,沒有別的方式可以訪問其數據成員。即使有其他程式碼會給這個對象添加方法或數據成員,但也不可能有別的辦法訪問傳入到構造函數中的原始數據。穩妥構造函數模式提供的這種安全性,使得它非常適合在某些安全執行環境——例如, ADsafe(www.adsafe.org)和 Caja(//code.google.com/p/google-caja/)提供的環境——下使用。
ps:一般場景下是用不到的吧
繼承
原型鏈
簡單回顧一下構造函數、原型和實例的關係:每個構造函數都有一個原型對象,原型對象都包含一個指向構造函數的指針,而實例都包含一個指向原型對象的內部指針。那麼,假如我們讓原型對象等於另一個類型的實例,結果會怎麼樣呢?顯然,此時的原型對象將包含一個指向另一個原型的指針,相應地,另一個原型中也包含著一個指向另一個構造函數的指針。假如另一個原型又是另一個類型的實例,那麼上述關係依然成立,如此層層遞進,就構成了實例與原型的鏈條。這就是所謂原型鏈的基本概念。
function SuperType(){
this.property = true;
}
SuperType.prototype.getSuperValue = function(){
return this.property;
};
function SubType(){
this.subproperty = false;
}
//繼承了 SuperType
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function (){
return this.subproperty;
};
var instance = new SubType();
alert(instance.getSuperValue()); //true
以上程式碼定義了兩個類型: SuperType 和 SubType。每個類型分別有一個屬性和一個方法。它們的主要區別是 SubType 繼承了 SuperType,而繼承是通過創建 SuperType 的實例,並將該實例賦給SubType.prototype 實現的。實現的本質是重寫原型對象,代之以一個新類型的實例。換句話說,原來存在於 SuperType 的實例中的所有屬性和方法,現在也存在於 SubType.prototype 中了。在確立了繼承關係之後,我們給 SubType.prototype 添加了一個方法,這樣就在繼承了 SuperType 的屬性和方法的基礎上又添加了一個新方法。
原型鏈的問題
最主要的問題來自包含引用類型值的原型。想必大家還記得,我們前面介紹過包含引用類型值的原型屬性會被所有實例共享;而這也正是為什麼要在構造函數中,而不是在原型對象中定義屬性的原因。在通過原型來實現繼承時,原型實際上會變成另一個類型的實例。於是,原先的實例屬性也就順理成章地變成了現在的原型屬性了。
原型鏈的第二個問題是:在創建子類型的實例時,不能向超類型的構造函數中傳遞參數。實際上,應該說是沒有辦法在不影響所有對象實例的情況下,給超類型的構造函數傳遞參數。
借用構造函數
開發人員開始使用一種叫做借用構造函數(constructor stealing)的技術(有時候也叫做偽造對象或經典繼承)。這種技術的基本思想相當簡單,即在子類型構造函數的內部調用超類型構造函數。
function SuperType(){
this.colors = ["red", "blue", "green"];
}
function SubType(){
//繼承了 SuperType
SuperType.call(this);
}
var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
var instance2 = new SubType();
alert(instance2.colors); //"red,blue,green"
程式碼中加粗的那一行程式碼「借調」了超類型的構造函數。通過使用 call()方法(或 apply()方法也可以),我們實際上是在(未來將要)新創建的 SubType 實例的環境下調用了 SuperType 構造函數。這樣一來,就會在新 SubType 對象上執行 SuperType()函數中定義的所有對象初始化程式碼。結果,SubType 的每個實例就都會具有自己的 colors 屬性的副本了。
構造函數的問題
如果僅僅是借用構造函數,那麼也將無法避免構造函數模式存在的問題——方法都在構造函數中定義,因此函數復用就無從談起了。而且,在超類型的原型中定義的方法,對子類型而言也是不可見的,結果所有類型都只能使用構造函數模式。考慮到這些問題,借用構造函數的技術也是很少單獨使的。
組合繼承
組合繼承(combination inheritance),有時候也叫做偽經典繼承,指的是將原型鏈和借用構造函數的技術組合到一塊,從而發揮二者之長的一種繼承模式。其背後的思路是使用原型鏈實現對原型屬性和方法的繼承,而通過借用構造函數來實現對實例屬性的繼承。
function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
alert(this.name);
};
function SubType(name, age){
//繼承屬性
SuperType.call(this, name);
this.age = age;
}
//繼承方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
alert(this.age);
};
var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29
var instance2 = new SubType("Greg", 27);
alert(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27
組合繼承避免了原型鏈和借用構造函數的缺陷,融合了它們的優點,成為 JavaScript 中最常用的繼承模式。而且, instanceof 和 isPrototypeOf()也能夠用於識別基於組合繼承創建的對象。
第7章 函數表達式
閉包
閉包是指有權訪問另一個函數作用域中的變數的函數。創建閉包的常見方式,就是在一個函數內部創建另一個函數。
由於閉包會攜帶包含它的函數的作用域,因此會比其他函數佔用更多的記憶體。過度使用閉包可能會導致記憶體佔用過多,我們建議讀者只在絕對必要時再考慮使用閉包。雖然像 V8 等優化後的 JavaScript 引擎會嘗試回收被閉包佔用的記憶體,但請大家還是要慎重使用閉包
閉包的副作用
function createFunctions(){
var result = new Array();
for (var i=0; i < 10; i++){
result[i] = function(){
return i;
};
}
return result;
}
表面上看,似乎每個函數都應該返自己的索引值,即位置 0 的函數返回 0,位置 1 的函數返回 1,以此類推。但實際上,每個函數都返回 10。因為每個函數的作用域鏈中都 保 存 著 createFunctions() 函 數 的 活 動 對 象 , 所 以 它 們 引 用 的 都 是 同 一 個 變 量 i 。 當createFunctions()函數返回後,變數 i 的值是 10,此時每個函數都引用著保存變數 i 的同一個變數對象,所以在每個函數內部 i 的值都是 10。
我們可以通過創建另一個匿名函數強制讓閉包的行為符合預期,如下所示。
function createFunctions(){
var result = new Array();
for (var i=0; i < 10; i++){
result[i] = function(num){
return function(){
return num;
};
}(i);
}
return result;
}
在這個版本中,我們沒有直接把閉包賦值給數組,而是定義了一個匿名函數,並將立即執行該匿名函數的結果賦給數組。這裡的匿名函數有一個參數 num,也就是最終的函數要返回的值。在調用每個匿名函數時,我們傳入了變數 i。由於函數參數是按值傳遞的,所以就會將變數 i 的當前值複製給參數 num。而在這個匿名函數內部,又創建並返回了一個訪問 num 的閉包。這樣一來, result 數組中的每個函數都有自己num 變數的一個副本,因此就可以返回各自不同的數值了。
關於this對象
var name = "The Window";
var object = {
name : "My Object",
getNameFunc : function(){
return function(){
return this.name;
};
}
};
alert(object.getNameFunc()()); //"The Window"(在非嚴格模式下)
每個函數在被調用時都會自動取得兩個特殊變數: this 和 arguments。內部函數在搜索這兩個變數時,只會搜索到其活動對象為止,因此永遠不可能直接訪問外部函數中的這兩個變數
ps:object.getNameFunc()(),認真一看就知道是先獲得了一個function,緊接著又在全局環境調用了該方法,所以this,肯定是指向window
不過,把外部作用域中的 this 對象保存在一個閉包能夠訪問到的變數里,就可以讓閉包訪問該對象了,如下所示。
var name = "The Window";
var object = {
name : "My Object",
getNameFunc : function(){
var that = this;
return function(){
return that.name;
};
}
};
alert(object.getNameFunc()()); //"My Object"
程式碼中突出的行展示了這個例子與前一個例子之間的不同之處。在定義匿名函數之前,我們把 this對象賦值給了一個名叫 that 的變數。而在定義了閉包之後,閉包也可以訪問這個變數,因為它是我們在包含函數中特意聲名的一個變數。即使在函數返回之後, that 也仍然引用著 object,所以調用object.getNameFunc()()就返回了"My Object"。
模仿塊級作用域
用作塊級作用域(通常稱為私有作用域)的匿名函數的語法如下所示。
(function(){
//這裡是塊級作用域
})();
以上程式碼定義並立即調用了一個匿名函數。將函數聲明包含在一對圓括弧中,表示它實際上是一個函數表達式。而緊隨其後的另一對圓括弧會立即調用這個函數。
ps:專業名稱是即時運行函數?
模組函數
模組模式(modulepattern)則是為單例創建私有變數和特權方法。如下就是一個單例的例子
var singleton = function(){
//私有變數和私有函數
var privateVariable = 10;
function privateFunction(){
return privateVariable;
}
//特權/公有方法和屬性
return {
publicProperty: true,
publicMethod : function(){
privateVariable++;
return privateFunction();
}
};
}();
第8章 BOM
window 對象
BOM 的核心對象是 window,它表示瀏覽器的一個實例。在瀏覽器中, window 對象有雙重角色,它既是通過 JavaScript 訪問瀏覽器窗口的一個介面,又是 ECMAScript 規定的 Global 對象。這意味著在網頁中定義的任何一個對象、變數和函數,都以 window 作為其 Global 對象,因此有權訪問parseInt()等方法。
全局作用域
由於 window 對象同時扮演著 ECMAScript 中 Global 對象的角色,因此所有在全局作用域中聲明的變數、函數都會變成 window 對象的屬性和方法。
拋開全局變數會成為 window 對象的屬性不談,定義全局變數與在 window 對象上直接定義屬性還是有一點差別:全局變數不能通過 delete 操作符刪除,而直接在 window 對象上的定義的屬性可以。例如:
var age = 29;
window.color = "red";
//在 IE < 9 時拋出錯誤,在其他所有瀏覽器中都返回 false
delete window.age;
//在 IE < 9 時拋出錯誤,在其他所有瀏覽器中都返回 true
delete window.color; //returns true
alert(window.age); //29
alert(window.color); //undefined
使用 var 語句添加的 window 屬性有一個名為[[Configurable]]的特性,這個特性的值被設置為false,因此這樣定義的屬性不可以通過delete 操作符刪除。
窗口關係及框架
如果頁面中包含框架,則每個框架都擁有自己的 window 對象,並且保存在 frames 集合中。在 frames集合中,可以通過數值索引(從 0 開始,從左至右,從上到下)或者框架名稱來訪問相應的 window 對象。每個 window 對象都有一個 name 屬性,其中包含框架的名稱。
<html>
<head>
<title>Frameset Example</title>
</head>
<frameset rows="160,*">
<frame src="frame.htm" name="topFrame">
<frameset cols="50%,50%">
<frame src="anotherframe.htm" name="leftFrame">
<frame src="yetanotherframe.htm" name="rightFrame">
</frameset>
</frameset>
</html>
以上程式碼創建了一個框架集,其中一個框架居上,兩個框架居下。對這個例子而言,可以通過window.frames[0]或者 window.frames["topFrame"]來引用上方的框架。不過,恐怕你最好使用top 而非 window 來引用這些框架(例如,通過 top.frames[0])。
top 對象始終指向最高(最外)層的框架,也就是瀏覽器窗口。使用它可以確保在一個框架中正確地訪問另一個框架。因為對於在一個框架中編寫的任何程式碼來說,其中的 window 對象指向的都是那個框架的特定實例,而非最高層的框架。
與 top 相對的另一個 window 對象是 parent。顧名思義, parent(父)對象始終指向當前框架的直接上層框架。在某些情況下, parent 有可能等於 top;但在沒有框架的情況下, parent 一定等於top(此時它們都等於 window)。
導航和打開窗口
//等同於< a href="//www.wrox.com" target="topFrame"></a>
window.open("//www.wrox.com/", "topFrame");
調用這行程式碼,就如同用戶單擊了 href 屬性為 //www.wrox.com/,target 屬性為"topFrame"
的鏈接。如果有一個名叫"topFrame"的窗口或者框架,就會在該窗口或框架載入這個 URL;否則,就會創建一個新窗口並將其命名為"topFrame"。此外,第二個參數也可以是下列任何一個特殊的窗口名稱: _self、 _parent、 _top 或_blank。
彈出窗口
如果給 window.open()傳遞的第二個參數並不是一個已經存在的窗口或框架,那麼該方法就會根據在第三個參數位置上傳入的字元串創建一個新窗口或新標籤頁。如果沒有傳入第三個參數,那麼就會打開一個帶有全部默認設置(工具欄、地址欄和狀態欄等)的新瀏覽器窗口(或者打開一個新標籤頁——根據瀏覽器設置)。在不打開新窗口的情況下,會忽略第三個參數。
window.open("//www.wrox.com/","wroxWindow",
"height=400,width=400,top=10,left=10,resizable=yes");
這行程式碼會打開一個新的可以調整大小的窗口,窗口初始大小為 400×400 像素,並且距螢幕上沿和左邊各 10 像素。
間歇調用與超時調用
//推薦的調用方式
setTimeout(function() {
alert("Hello world!");
}, 1000);
第二個參數是一個表示等待多長時間的毫秒數,但經過該時間後指定的程式碼不一定會執行。JavaScript 是一個單執行緒序的解釋器,因此一定時間內只能執行一段程式碼。為了控制要執行的碼,就有一個 JavaScript 任務隊列。這些任務會按照將它們添加到隊列的順序執行。 setTimeout()的第二個參數告訴 JavaScript 再過多長時間把當前任務添加到隊列中。如果隊列是空的,那麼添加的程式碼會立即執行;如果隊列不是空的,那麼它就要等前面的程式碼執行完了以後再執行。
調用 setTimeout()之後,該方法會返回一個數值 ID,表示超時調用。這個超時調用 ID 是計劃執行程式碼的唯一標識符,可以通過它來取消超時調用。要取消尚未執行的超時調用計劃,可以調用clearTimeout()方法並將相應的超時調用 ID 作為參數傳遞給它,如下所示。
//設置超時調用
var timeoutId = setTimeout(function() {
alert("Hello world!");
}, 1000);
//注意:把它取消
clearTimeout(timeoutId);
間歇調用與超時調用類似,只不過它會按照指定的時間間隔重複執行程式碼,直至間歇調用被取消或者頁面被卸載。
//推薦的調用方式
setInterval (function() {
alert("Hello world!");
}, 10000);
一般認為,使用超時調用來模擬間歇調用的是一種最佳模式。在開發環境下,很少使用真正的間歇調用,原因是後一個間歇調用可能會在前一個間歇調用結束之前啟動。而像前面示例中那樣使用超時調用,則完全可以避免這一點。所以,最好不要使用間歇調用。
var num = 0;
var max = 10;
function incrementNumber() {
num++;
//如果執行次數未達到 max 設定的值,則設置另一次超時調用
if (num < max) {
setTimeout(incrementNumber, 500);
} else {
alert("Done");
}
}
setTimeout(incrementNumber, 500);
系統對話框
瀏覽器通過 alert()、 confirm()和 prompt()方法可以調用系統對話框向用戶顯示消息。系統對話框與在瀏覽器中顯示的網頁沒有關係,也不包含 HTML。它們的外觀由作業系統及(或)瀏覽器設置決定,而不是由 CSS 決定。此外,通過這幾個方法打開的對話框都是同步和模態的。也就是說,顯示這些對話框的時候程式碼會停止執行,而關掉這些對話框後程式碼又會恢復執行。
location 對象
location 是最有用的 BOM 對象之一,它提供了與當前窗口中載入的文檔有關的資訊,還提供了一些導航功能。事實上, location 對象是很特別的一個對象,因為它既是 window 對象的屬性,也是document 對象的屬性;換句話說, window.location 和 document.location 引用的是同一個對象。location 對象的用處不只表現在它保存著當前文檔的資訊,還表現在它將 URL 解析為獨立的片段,讓開發人員可以通過不同的屬性訪問這些片段。
使用 location 對象可以通過很多方式來改變瀏覽器的位置。首先,也是最常用的方式,就是使用assign()方法並為其傳遞一個 URL,如下所示。
location.assign("//www.wrox.com");
這樣,就可以立即打開新 URL 並在瀏覽器的歷史記錄中生成一條記錄。如果是將 location.href或 window.location 設置為一個 URL 值,也會以該值調用 assign()方法。例如,下列兩行程式碼與顯式調用 assign()方法的效果完全一樣。
window.location = "//www.wrox.com";
location.href = "//www.wrox.com";
在這些改變瀏覽器位置的方法中,最常用的是設置 location.href 屬性。
每次修改 location 的屬性hash、search、hostname、pathname、port等(hash 除外),頁面都會以新 URL 重新載入。
與位置有關的最後一個方法是 reload(),作用是重新載入當前顯示的頁面。
location.reload(); //重新載入(有可能從快取中載入)
location.reload(true); //重新載入(從伺服器重新載入)
navigator 對象
最早由 Netscape Navigator 2.0 引入的 navigator 對象,現在已經成為識別客戶端瀏覽器的事實標準。雖然其他瀏覽器也通過其他方式提供了相同或相似的資訊(例如, IE 中的 window.clientInformation 和 Opera 中的 window.opera),但 navigator 對象卻是所有支援 JavaScript 的瀏覽器所共有的。
screen 對象
JavaScript 中有幾個對象在編程中用處不大,而 screen 對象就是其中之一。 screen 對象基本上只用來表明客戶端的能力,其中包括瀏覽器窗口外部的顯示器的資訊,如像素寬度和高度等。
history
history 對象保存著用戶上網的歷史記錄,從窗口被打開的那一刻算起。因為 history 是 window
對象的屬性,因此每個瀏覽器窗口、每個標籤頁乃至每個框架,都有自己的 history 對象與特定的window 對象關聯。出於安全方面的考慮,開發人員無法得知用戶瀏覽過的 URL。
使用 go()方法可以在用戶的歷史記錄中任意跳轉,可以向後也可以向前。
//後退一頁
history.go(-1);
//前進一頁
history.go(1);
//前進兩頁
history.go(2);
//跳轉到最近的 wrox.com 頁面
history.go("wrox.com");
//跳轉到最近的 nczonline.net 頁面
history.go("nczonline.net");
//後退一頁
history.back();
//前進一頁
history.forward();
客戶端檢測
檢測 Web 客戶端的手段很多,而且各有利弊。但最重要的還是要知道,不到萬不得已,就不要使用客戶端檢測。只要能找到更通用的方法,就應該優先採用更通用的方法。一言以蔽之,先設計最通用的方案,然後再使用特定於瀏覽器的技術增強該方案。
能力檢測
能力檢測的基本模式如下:
if (object.propertyInQuestion){
//使用 object.propertyInQuestion
}
舉例來說, IE5.0 之前的版本不支援 document.getElementById()這個 DOM 方法。儘管可以使用非標準的 document.all 屬性實現相同的目的,但 IE 的早期版本中確實不存在 document.getElementById()。於是,也就有了類似下面的能力檢測程式碼:
function getElement(id){
if (document.getElementById){
return document.getElementById(id);
} else if (document.all){
return document.all[id];
} else {
throw new Error("No way to retrieve element!");
}
}
第二個重要的概念就是必須測試實際要用到的特性。一個特性存在,不一定意味著另一個特性也存在。來看一個例子:
function getWindowWidth(){
if (document.all){ //假設是 IE
return document.documentElement.clientWidth; //錯誤的用法!!!
} else {
return window.innerWidth;
}
}
更可靠的能力檢測
利用類型轉換來確定某個對象成員是否存在,但這樣你還是不知道該成員是不是你想要的。來看下面的函數,它用來確定一個對象是否支援排序。
//不要這樣做!這不是能力檢測——只檢測了是否存在相應的方法
function isSortable(object){
return !!object.sort;
}
這個函數通過檢測對象是否存在 sort()方法,來確定對象是否支援排序。問題是,任何包含 sort
屬性的對象也會返回 true。
var result = isSortable({ sort: true });
檢測某個屬性是否存在並不能確定對象是否支援排序。更好的方式是檢測 sort 是不是一個函數。
//這樣更好:檢查 sort 是不是函數
function isSortable(object){
`return typeof object.sort == "function";
}
在瀏覽器環境下測試任何對象的某個特性是否存在,要使用下面這個函數。
//作者: Peter Michaux
function isHostMethod(object, property) {
var t = typeof object[property];
return t=='function' ||
(!!(t=='object' && object[property])) ||
t=='unknown';
}
可以像下面這樣使用這個函數:
result = isHostMethod(xhr, "open"); //true
result = isHostMethod(xhr, "foo"); //false
能力檢測,不是瀏覽器檢測
檢測某個或某幾個特性並不能夠確定瀏覽器。下面給出的這段程式碼(或與之差不多的程式碼)可以在許多網站中看到,這種「瀏覽器檢測」程式碼就是錯誤地依賴能力檢測的典型示例。
//錯誤!還不夠具體
var isFirefox = !!(navigator.vendor && navigator.vendorSub);
//錯誤!假設過頭了
var isIE = !!(document.all && document.uniqueID);
實際上,根據瀏覽器不同將能力組合起來是更可取的方式。如果你知道自己的應用程式需要使用某些特定的瀏覽器特性,那麼最好是一次性檢測所有相關特性,而不要分別檢測。看下面的例子。
//確定瀏覽器是否支援 Netscape 風格的插件
var hasNSPlugins = !!(navigator.plugins && navigator.plugins.length);
//確定瀏覽器是否具有 DOM1 級規定的能力
var hasDOM1 = !!(document.getElementById && document.createElement &&
document.getElementsByTagName);
怪癖檢測
與能力檢測類似, 怪癖檢測(quirks detection)的目標是識別瀏覽器的特殊行為。但與能力檢測確認瀏覽器支援什麼能力不同,怪癖檢測是想要知道瀏覽器存在什麼缺陷(「怪癖」也就是 bug)。這通常需要運行一小段程式碼,以確定某一特性不能正常工作。
一般來說,「怪癖」都是個別瀏覽器所獨有的,而且通常被歸為 bug。在相關瀏覽器的新版本中,這些問題可能會也可能不會被修復。由於檢測「怪癖」涉及運行程式碼,因此我們建議僅檢測那些對你有直接影響的「怪癖」,而且最好在腳本一開始就執行此類檢測。
用戶代理檢測
第三種,也是爭議最大的一種客戶端檢測技術叫做用戶代理檢測。用戶代理檢測通過檢測用戶代理字元串來確定實際使用的瀏覽器。在每一次 HTTP 請求過程中,用戶代理字元串是作為響應首部發送的,而且該字元串可以通過 JavaScript 的 navigator.userAgent 屬性訪問。在伺服器端,通過檢測用戶代理字元串來確定用戶使用的瀏覽器是一種常用而且廣為接受的做法。而在客戶端,用戶代理檢測一般被當作一種萬不得已才用的做法,其優先順序排在能力檢測和(或)怪癖檢測之後。
用戶代理字元串檢測技術
考慮到歷史原因以及現代瀏覽器中用戶代理字元串的使用方式,通過用戶代理字元串來檢測特定的瀏覽器並不是一件輕鬆的事。因此,首先要確定的往往是你需要多麼具體的瀏覽器資訊。一般情況下,知道呈現引擎和最低限度的版本就足以決定正確的操作方法了。例如,我們不推薦使用下列程式碼:
if (isIE6 || isIE7) { //不推薦!!!
//程式碼
}
這個例子是想要在瀏覽器為 IE6 或 IE7 時執行相應程式碼。這種程式碼其實是很脆弱的,因為它要依據特定的版本來決定做什麼。如果是 IE8 怎麼辦呢?只要 IE 有新版本出來,就必須更新這些程式碼。不過,像下面這樣使用相對版本號則可以避免此問題:
if (ieVer >=6){
//程式碼
}
這個例子首先檢測 IE 的版本號是否至少等於 6,如果是則執行相應操作。這樣就可以確保相應的程式碼將來照樣能夠起作用
** 識別呈現引擎**
如前所述,確切知道瀏覽器的名字和版本號不如確切知道它使用的是什麼呈現引擎。如果 Firefox、Camino 和 Netscape 都使用相同版本的 Gecko,那它們一定支援相同的特性。類似地,不管是什麼瀏覽器,只要它跟 Safari 3 使用的是同一個版本的 WebKit,那麼該瀏覽器也就跟 Safari 3 具備同樣的功能。因此,我們要編寫的腳本將主要檢測五大呈現引擎: IE、 Gecko、 WebKit、 KHTML 和 Opera。
這個網上應該已經有現成的完整程式碼了。不過如果要適配各大平台,各大系統,各級這開發量不少啊,很可能公司都是值適配某一特定的瀏覽器。
第10章 DOM
DOM(文檔對象模型)是針對 HTML 和 XML 文檔的一個 API(應用程式編程介面)。 DOM 描繪了一個層次化的節點樹,允許開發人員添加、移除和修改頁面的某一部分。
節點層次
DOM 可以將任何 HTML 或 XML 文檔描繪成一個由多層節點構成的結構。節點分為幾種不同的類型,每種類型分別表示文檔中不同的資訊及(或)標記。每個節點都擁有各自的特點、數據和方法,另外也與其他節點存在某種關係。節點之間的關係構成了層次,而所有頁面標記則表現為一個以特定節點為根節點的樹形結構。以下面的 HTML 為例:
<html>
<head>
<title>Sample Page</title>
</head>
<body>
<p>Hello World!</p>
</body>
</html>
Node 類型
DOM1 級定義了一個 Node 介面,該介面將由 DOM 中的所有節點類型實現。這個 Node 介面在JavaScript 中是作為 Node 類型實現的;除了 IE 之外,在其他所有瀏覽器中都可以訪問到這個類型。JavaScript 中的所有節點類型都繼承自 Node 類型,因此所有節點類型都共享著相同的基本屬性和方法。
每個節點都有一個 nodeType 屬性,用於表明節點的類型。節點類型由在 Node 類型中定義的下列
12 個數值常量來表示,任何節點類型必居其一:
- Node.ELEMENT_NODE(1);
- Node.ATTRIBUTE_NODE(2);
- Node.TEXT_NODE(3);
- Node.CDATA_SECTION_NODE(4);
- Node.ENTITY_REFERENCE_NODE(5);
- Node.ENTITY_NODE(6);
- Node.PROCESSING_INSTRUCTION_NODE(7);
- Node.COMMENT_NODE(8);
- Node.DOCUMENT_NODE(9);
- Node.DOCUMENT_TYPE_NODE(10);
- Node.DOCUMENT_FRAGMENT_NODE(11);
- Node.NOTATION_NODE(12)。
文檔寫入
這個能力體現在下列 4 個方法中: write()、 writeln()、 open()和 close()。其中, write()和 writeln()方法都接受一個字元串參數,即要寫入到輸出流中的文本。 write()會原樣寫入,而 writeln()則會在字元串的末尾添加一個換行符(\n)。如果在文檔載入結束後再調用 document.write(),那麼輸出的內容將會重寫整個頁面。
Element類型
創建元素
使用 document.createElement()方法可以創建新元素。這個方法只接受一個參數,即要創建元素的標籤名。使用下面的程式碼可以創建一個<div>元素。
var div = document.createElement("div");
由於新元素尚未被添加到文檔樹中,因此設置這些特性不會影響瀏覽器的顯示。要把新元素添加到文檔樹,可以使用 appendChild()、 insertBefore()或 replaceChild()方法。下面的程式碼會把新創建的元素添加到文檔的<body>元素中。
document.body.appendChild(div);
Text類型
文本節點由 Text 類型表示,包含的是可以照字面解釋的純文本內容。純文本中可以包含轉義後的HTML 字元,但不能包含 HTML 程式碼。
在默認情況下,每個可以包含內容的元素最多只能有一個文本節點,而且必須確實有內容存在。來看幾個例子。
<!-- 沒有內容,也就沒有文本節點 -->
<div></div>
<!-- 有空格,因而有一個文本節點 -->
<div> </div>
<!-- 有內容,因而有一個文本節點 -->
<div>Hello World!</div>
可以使用 document.createTextNode()創建新文本節點,這個方法接受一個參數——要插入節點中的文本。與設置已有文本節點的值一樣,作為參數的文本也將按照 HTML 或 XML 的格式進行編碼。
var textNode = document.createTextNode("\<strong>Hello\</strong> world!");
在創建新文本節點的同時,也會為其設置 ownerDocument 屬性。不過,除非把新節點添加到文檔樹中已經存在的節點中,否則我們不會在瀏覽器窗口中看到新節點。下面的程式碼會創建一個、<div>元素並向其中添加一條消息。
var element = document.createElement("div");
element.className = "message";
var textNode = document.createTextNode("Hello world!");
element.appendChild(textNode);
document.body.appendChild(element);
規範化文本節點
DOM 文檔中存在相鄰的同胞文本節點很容易導致混亂,因為分不清哪個文本節點表示哪個字元串。另外, DOM 文檔中出現相鄰文本節點的情況也不在少數,於是就催生了一個能夠將相鄰文本節點合併的方法。這個方法是由 Node 類型定義的(因而在所有節點類型中都存在),名叫 normalize()。如果在一個包含兩個或多個文本節點的父元素上調用 normalize()方法,則會將所有文本節點合併成一個節點,結果節點的 nodeValue 等於將合併前每個文本節點的 nodeValue 值拼接起來的值。
分割文本節點
Text 類型提供了一個作用與 normalize()相反的方法: splitText()。這個方法會將一個文本節點分成兩個文本節點,即按照指定的位置分割 nodeValue 值。原來的文本節點將包含從開始到指定位置之前的內容,新文本節點將包含剩下的文本。這個方法會返回一個新文本節點,該節點與原節點的parentNode 相同。
var element = document.createElement("div");
element.className = "message";
var textNode = document.createTextNode("Hello world!");
element.appendChild(textNode);
document.body.appendChild(element);
var newNode = element.firstChild.splitText(5);
alert(element.firstChild.nodeValue); //"Hello"
alert(newNode.nodeValue); //" world!"
alert(element.childNodes.length); //2
CDATASection類型
CDATASection 類型只針對基於 XML 的文檔,表示的是 CDATA 區域。與 Comment 類似,CDATASection 類型繼承自 Text 類型,因此擁有除 splitText()之外的所有字元串操作方法。
DocumentType類型
DocumentType 類型在 Web 瀏覽器中並不常用,僅有 Firefox、 Safari 和 Opera 支援它。 Document-Type 包含著與文檔的 doctype 有關的所有資訊。
DocumentFragment類型
在所有節點類型中,只有 DocumentFragment 在文檔中沒有對應的標記。 DOM 規定文檔片段(document fragment)是一種「輕量級」的文檔,可以包含和控制節點,但不會像完整的文檔那樣佔用額外的資源。
雖然不能把文檔片段直接添加到文檔中,但可以將它作為一個「倉庫」來使用,即可以在裡面保存將來可能會添加到文檔中的節點。
來看下面的 HTML 示例程式碼:
<ul id="myList"></ul>
假設我們想為這個<ul>元素添加 3 個列表項。如果逐個地添加列表項,將會導致瀏覽器反覆渲染(呈現)新資訊。為避免這個問題,可以像下面這樣使用一個文檔片段來保存創建的列表項,然後再一次性將它們添加到文檔中。
var fragment = document.createDocumentFragment();
var ul = document.getElementById("myList");
var li = null;
for (var i=0; i < 3; i++){
li = document.createElement("li");
li.appendChild(document.createTextNode("Item " + (i+1)));
fragment.appendChild(li);
}
ul.appendChild(fragment);
循環結束後,再調用appendChild()並傳入文檔片段,將所有列表項添加到\ul>元素中。此時,文檔片段的所有子節點都被刪除並轉移到了\ul>元素中。
attr類型
元素的特性在 DOM 中以 Attr 類型來表示。在所有瀏覽器中(包括 IE8),都可以訪問 Attr 類型
的構造函數和原型。從技術角度講,特性就是存在於元素的 attributes 屬性中的節點。
我們並不建議直接訪問特性節點。實際上,使用 getAttribute()、setAttribute()和 removeAttribute()方法遠比操作特性節點更為方便。
使用 document.createAttribute()並傳入特性的名稱可以創建新的特性節點。例如,要為元素添加 align 特性,可以使用下列程式碼:
var attr = document.createAttribute("align");
attr.value = "left";
element.setAttributeNode(attr);
alert(element.attributes["align"].value); //"left"
alert(element.getAttributeNode("align").value); //"left"
alert(element.getAttribute("align")); //"left"
由於在調用 createAttribute()時已經為 name 屬性賦了值,所以後面就不必給它賦值了。之後,又把 value 屬性的值設置為"left"。為了將新創建的特性添加到元素中,必須使用元素的 setAttributeNode()方法。添加特性之後,可以通過下列任何方式訪問該特性: attributes 屬性、 getAttributeNode()方法以及 getAttribute()方法。其中, attributes和 getAttributeNode()都會返回對應特性的 Attr 節點,而 getAttribute()則只返回特性的值。
DOM 操作技術
動態載入的外部 JavaScript 文件能夠立即運行,比如下面的<script>元素:
<script type="text/javascript" src="client.js"></script>
這個<script>元素包含了第 9 章的客戶端檢測腳本。而創建這個節點的 DOM 程式碼如下所示:
var script = document.createElement("script");
script.type = "text/javascript";
script.src = "client.js";
document.body.appendChild(script);
顯然,這裡的 DOM 程式碼如實反映了相應的 HTML 程式碼。不過,在執行最後一行程式碼把<script>元素添加到頁面中之前,是不會下載外部文件的。載入完成後,就可以在頁面中的其他地方使用這個腳本了。問題只有一個:怎麼知道腳本載入完成呢?遺憾的是,並沒有什麼標準方式來探知這一點。
function loadScriptString(code){
var script = document.createElement("script");
script.type = "text/javascript";
try {
script.appendChild(document.createTextNode(code));
} catch (ex){
script.text = code;
}
document.body.appendChild(script);
}
下面是調用這個函數的示例:
loadScriptString("function sayHi(){alert('hi');}");
以這種方式載入的程式碼會在全局作用域中執行,而且當腳本執行後將立即可用。實際上,這樣執行程式碼與在全局作用域中把相同的字元串傳遞給 eval()是一樣的。
動態樣式
能夠把 CSS 樣式包含到 HTML 頁面中的元素有兩個。其中, <link>元素用於包含來自外部的文件,而<style>元素用於指定嵌入的樣式。與動態腳本類似,所謂動態樣式是指在頁面剛載入時不存在的樣式;動態樣式是在頁面載入完成後動態添加到頁面中的。
我們以下面這個典型的<link>元素為例:
<link rel="stylesheet" type="text/css" href="styles.css">
使用 DOM 程式碼可以很容易地動態創建出這個元素:
var link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = "style.css";
var head = document.getElementsByTagName("head")[0];
head.appendChild(link);
需要注意的是,必須將<link>元素添加到<head>而不是<body>元素,才能保證在所有瀏覽器中的行為一致。整個過程可以用以下函數來表示:
function loadStyles(url){
var link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = url;
var head = document.getElementsByTagName("head")[0];
head.appendChild(link);
}
調用 loadStyles()函數的程式碼如下所示:
loadStyles("styles.css");
載入外部樣式文件的過程是非同步的,也就是載入樣式與執行 JavaScript 程式碼的過程沒有固定的次序。一般來說,知不知道樣式已經載入完成並不重要;
function loadStyleString(css){
var style = document.createElement("style");
style.type = "text/css";
try{
style.appendChild(document.createTextNode(css));
} catch (ex){
style.styleSheet.cssText = css;
}
var head = document.getElementsByTagName("head")[0];
head.appendChild(style);
}
調用這個函數的示例如下:
loadStyleString("body{background-color:red}");
這種方式會實時地向頁面中添加樣式,因此能夠馬上看到變化。
<table>元素是 HTML 中最複雜的結構之一。要想創建表格,一般都必須涉及表示表格行、 單元格、
表頭等方面的標籤。由於涉及的標籤多,因而使用核心 DOM 方法創建和修改表格往往都免不了要編寫
大量的程式碼。假設我們要使用 DOM 來創建下面的 HTML 表格。
<table border="1" width="100%">
<tbody>
<tr>
<td>Cell 1,1</td>
<td>Cell 2,1</td>
</tr>
<tr>
<td>Cell 1,2</td>
<td>Cell 2,2</td>
</tr>
</tbody>
</table>
要使用核心 DOM 方法創建這些元素,得需要像下面這麼多的程式碼:
//創建 table
var table = document.createElement("table");
table.border = 1;
table.width = "100%";
//創建 tbody
var tbody = document.createElement("tbody");
table.appendChild(tbody);
//創建第一行
var row1 = document.createElement("tr");
tbody.appendChild(row1);
var cell1_1 = document.createElement("td");
cell1_1.appendChild(document.createTextNode("Cell 1,1"));
row1.appendChild(cell1_1);
var cell2_1 = document.createElement("td");
cell2_1.appendChild(document.createTextNode("Cell 2,1"));
row1.appendChild(cell2_1);
//創建第二行
var row2 = document.createElement("tr");
tbody.appendChild(row2);
var cell1_2 = document.createElement("td");
cell1_2.appendChild(document.createTextNode("Cell 1,2"));
row2.appendChild(cell1_2);
var cell2_2= document.createElement("td");
cell2_2.appendChild(document.createTextNode("Cell 2,2"));
row2.appendChild(cell2_2);
//將表格添加到文檔主體中
document.body.appendChild(table);
使用這些屬性和方法,可以極大地減少創建表格所需的程式碼數量。例如,使用這些屬性和方法可以將前面的程式碼重寫如下。
//創建 table
var table = document.createElement("table");
table.border = 1;
table.width = "100%";
//創建 tbody
var tbody = document.createElement("tbody");
table.appendChild(tbody);
//創建第一行
tbody.insertRow(0);
tbody.rows[0].insertCell(0);
tbody.rows[0].cells[0].appendChild(document.createTextNode("Cell 1,1"));
tbody.rows[0].insertCell(1);
tbody.rows[0].cells[1].appendChild(document.createTextNode("Cell 2,1"));
//創建第二行
tbody.insertRow(1);
tbody.rows[1].insertCell(0);
tbody.rows[1].cells[0].appendChild(document.createTextNode("Cell 1,2"));
tbody.rows[1].insertCell(1);
tbody.rows[1].cells[1].appendChild(document.createTextNode("Cell 2,2"));
//將表格添加到文檔主體中
document.body.appendChild(table)
理解 NodeList 及其「近親」 NamedNodeMap 和 HTMLCollection,是從整體上透徹理解 DOM 的關鍵所在。這三個集合都是「動態的」;換句話說,每當文檔結構發生變化時,它們都會得到更新。因此,它們始終都會保存著最新、最準確的資訊。從本質上說,所有 NodeList 對象都是在訪問 DOM 文檔時實時運行的查詢。例如,下列程式碼會導致無限循環:
var divs = document.getElementsByTagName("div"),
i,
div;
for (i=0; i < divs.length; i++){
div = document.createElement("div");
document.body.appendChild(div);
}
瀏覽器不會將創建的所有集合都保存在一個列表中,而是在下一次訪問集合時再更新集合。
ps:意味著 divs.length 始終保存著最新的值。
如果想要迭代一個 NodeList,最好是使用 length 屬性初始化第二個變數,然後將迭代器與該變
量進行比較,如下面的例子所示:
var divs = document.getElementsByTagName("div"),
i,
len,
div;
for (i=0, len=divs.length; i < len; i++){
div = document.createElement("div");
document.body.appendChild(div);
}
第11章 DOM擴展
選擇符API
Selectors API(www.w3.org/TR/selectors-api/)是由 W3C 發起制定的一個標準,致力於讓瀏覽器原生支援 CSS 查詢。所有實現這一功能的 JavaScript 庫都會寫一個基礎的 CSS 解析器,然後再使用已有的DOM 方法查詢文檔並找到匹配的節點。儘管庫開發人員在不知疲倦地改進這一過程的性能,但到頭來都只能通過運行 JavaScript 程式碼來完成查詢操作。而把這個功能變成原生 API 之後,解析和樹查詢操作可以在瀏覽器內部通過編譯後的程式碼來完成,極大地改善了性能。
querySelector()方法
querySelector()方法接收一個 CSS 選擇符,返回與該模式匹配的第一個元素,如果沒有找到匹配的元素,返回 null。
//取得 body 元素
var body = document.querySelector("body");
//取得 ID 為"myDiv"的元素
var myDiv = document.querySelector("#myDiv");
//取得類為"selected"的第一個元素
var selected = document.querySelector(".selected");
//取得類為"button"的第一個影像元素
var img = document.body.querySelector("img.button");
querySelectorAll 方法
querySelectorAll()方法接收的參數與 querySelector()方法一樣,都是一個 CSS 選擇符,但返回的是所有匹配的元素而不僅僅是一個元素。這個方法返回的是一個 NodeList 的實例。具體來說,返回的值實際上是帶有所有屬性和方法的 NodeList,而其底層實現則類似於一組元素的快照,而非不斷對文檔進行搜索的動態查詢。這樣實現可以避免使用 NodeList 對象通常會引起的大多數性能問題。
//取得某<div>中的所有<em>元素(類似於 getElementsByTagName("em"))
var ems = document.getElementById("myDiv").querySelectorAll("em");
//取得類為"selected"的所有元素
var selecteds = document.querySelectorAll(".selected");
//取得所有<p>元素中的所有<strong>元素
var strongs = document.querySelectorAll("p strong");
matchesSelector 方法
Selectors API Level 2 規範為 Element 類型新增了一個方法 matchesSelector()。這個方法接收一個參數,即 CSS 選擇符,如果調用元素與該選擇符匹配,返回 true;否則,返回 false。看例子。
if (document.body.matchesSelector("body.page1")){
//true
}
在取得某個元素引用的情況下,使用這個方法能夠方便地檢測它是否會被 querySelector()或querySelectorAll()方法返回。
元素遍歷
Element Traversal API 為 DOM 元素添加了以下 5 個屬性。
- childElementCount:返回子元素(不包括文本節點和注釋)的個數。
- firstElementChild:指向第一個子元素; firstChild 的元素版。
- lastElementChild:指向最後一個子元素; lastChild 的元素版。
- previousElementSibling:指向前一個同輩元素; previousSibling 的元素版。
- nextElementSibling:指向後一個同輩元素; nextSibling 的元素版。
下面來看一個例子。過去,要跨瀏覽器遍歷某元素的所有子元素,需要像下面這樣寫程式碼。
var i,
len,
child = element.firstChild;
while(child != element.lastChild){
if (child.nodeType == 1){ //檢查是不是元素
processChild(child);
}
child = child.nextSibling;
}
而使用 Element Traversal 新增的元素,程式碼會更簡潔。
var i,
len,
child = element.firstElementChild;
while(child != element.lastElementChild){
processChild(child); //已知其是元素
child = child.nextElementSibling;
}
HTML5
對於傳統 HTML 而言, HTML5 是一個叛逆。所有之前的版本對 JavaScript 介面的描述都不過三言兩語,主要篇幅都用於定義標記,與 JavaScript 相關的內容一概交由 DOM 規範去定義。而 HTML5 規範則圍繞如何使用新增標記定義了大量 JavaScript API。其中一些 API 與 DOM 重疊,定義了瀏覽器應該支援的 DOM 擴展。
與類相關的擴充
HTML5 新增了很多 API,致力於簡化 CSS 類的用法。
getElementsByClassName方法
//取得所有類中包含"username"和"current"的元素,類名的先後順序無所謂
var allCurrentUsernames = document.getElementsByClassName("username current");
//取得 ID 為"myDiv"的元素中帶有類名"selected"的所有元素
var selected = document.getElementById("myDiv").getElementsByClassName("selected");
classList屬性
因為 className 中是一個字元串,所以即使只修改字元串一部分,也必須每次都設置整個字元串的值。比如,以下面的 HTML 程式碼為例。
<div class="bd user disabled">...</div>
這個<div>元素一共有三個類名。要從中刪除一個類名,需要把這三個類名拆開,刪除不想要的那個,然後再把其他類名拼成一個新字元串。請看下面的例子。
//刪除"user"類
//首先,取得類名字元串並拆分成數組
var classNames = div.className.split(/\s+/);
//找到要刪的類名
var pos = -1,
i,
len;
for (i=0, len=classNames.length; i < len; i++){
if (classNames[i] == "user"){
pos = i;
break;
}
}
//刪除類名
classNames.splice(i,1);
//把剩下的類名拼成字元串並重新設置
div.className = classNames.join(" ");
HTML5 新增了一種操作類名的方式,可以讓操作更簡單也更安全,那就是為所有元素添加classList 屬性。這個 classList 屬性是新集合類型 DOMTokenList 的實例。與其他 DOM 集合似,DOMTokenList 有一個表示自己包含多少元素的 length 屬性,而要取得每個元素可以使用 item()方法,也可以使用方括弧語法。
//刪除"disabled"類
div.classList.remove("disabled");
//添加"current"類
div.classList.add("current");
//切換"user"類
div.classList.toggle("user");
//確定元素中是否包含既定的類名
if (div.classList.contains("bd") && !div.classList.contains("disabled")){
//執行操作
)
//迭代類名
for (var i=0, len=div.classList.length; i < len; i++){
doSomething(div.classList[i]);
}
焦點管理
HTML5 也添加了輔助管理 DOM 焦點的功能。首先就是 document.activeElement 屬性,這個屬性始終會引用 DOM 中當前獲得了焦點的元素。元素獲得焦點的方式有頁面載入、用戶輸入(通常是通過按 Tab 鍵)和在程式碼中調用 focus()方法。
var button = document.getElementById("myButton");
button.focus();
alert(document.activeElement === button); //true
另外就是新增了 document.hasFocus()方法,這個方法用於確定文檔是否獲得了焦點。
var button = document.getElementById("myButton");
button.focus();
alert(document.hasFocus()); //true
通過檢測文檔是否獲得了焦點,可以知道用戶是不是正在與頁面交互。查詢文檔獲知哪個元素獲得了焦點,以及確定文檔是否獲得了焦點,這兩個功能最重要的用途是提高 Web 應用的無障礙性。無障礙 Web 應用的一個主要標誌就是恰當的焦點管理,而確切地知道哪個元素獲得了焦點是一個極大的進步,至少我們不用再像過去那樣靠猜測了。
HTMLDocument的變化
HTML5 擴展了 HTMLDocument,增加了新的功能。與 HTML5 中新增的其他 DOM 擴展類似,這些變化同樣基於那些已經得到很多瀏覽器完美支援的專有擴展。所以,儘管這些擴展被寫入標準的時間相對不長,但很多瀏覽器很早就已經支援這些功能了。
readyState屬性
Document 的 readyState 屬性有兩個可能的值:
- loading,正在載入文檔;
- complete,已經載入完文檔。
使用 document.readyState 的最恰當方式,就是通過它來實現一個指示文檔已經載入完成的指示器。
兼容模式
在標準模式下, document.compatMode 的值等於"CSS1Compat",而在混雜模式下, document.compatMode 的值等於"BackCompat"。
if (document.compatMode == "CSS1Compat"){
alert("Standards mode");
} else {
alert("Quirks mode");
}
head屬性
作為對 document.body 引用文檔的<body>元素的補充, HTML5 新增了 document.head 屬性,引用文檔的<head>元素。要引用文檔的<head>元素,可以結合使用這個屬性和另一種後備方法。
var head = document.head || document.getElementsByTagName("head")[0];
字符集屬性
HTML5 新增了幾個與文檔字符集有關的屬性。其中, charset 屬性表示文檔中實際使用的字符集,也可以用來指定新字符集。默認情況下,這個屬性的值為"UTF-16",但可以通過元素、響應頭部或直接設置 charset 屬性修改這個值。來看一個例子。
alert(document.charset); //"UTF-16"
document.charset = "UTF-8";
另一個屬性是 defaultCharset,表示根據默認瀏覽器及作業系統的設置,當前文檔默認的字符集應該是什麼。如果文檔沒有使用默認的字符集,那 charset 和 defaultCharset 屬性的值可能會不一樣,例如:
if (document.charset != document.defaultCharset){
alert("Custom character set being used.");
}
通過這兩個屬性可以得到文檔使用的字元編碼的具體資訊,也能對字元編碼進行準確地控制。運行適當的情況下,可以保證用戶正常查看頁面或使用應用。
自定義數據屬性
HTML5 規定可以為元素添加非標準的屬性,但要添加前綴 data-,目的是為元素提供與渲染無關的資訊,或者提供語義資訊。這些屬性可以任意添加、隨便命名,只要以 data-開頭即可。來看一個例子。
<div id="myDiv" data-appId="12345" data-myname="Nicholas"></div>
如果需要給元素添加一些不可見的數據以便進行其他處理,那就要用到自定義數據屬性。在跟蹤鏈接或混搭應用中,通過自定義數據屬性能方便地知道點擊來自頁面中的哪個部分。
//本例中使用的方法僅用於演示
var div = document.getElementById("myDiv");
//取得自定義屬性的值
var appId = div.dataset.appId;
var myName = div.dataset.myname;
//設置值
div.dataset.appId = 23456;
div.dataset.myname = "Michael";
//有沒有"myname"值呢?
if (div.dataset.myname){
alert("Hello, " + div.dataset.myname);
}
插入標記
雖然 DOM 為操作節點提供了細緻入微的控制手段,但在需要給文檔插入大量新 HTML 標記的情況下,通過 DOM 操作仍然非常麻煩,因為不僅要創建一系列 DOM 節點,而且還要小心地按照正確的順序把它們連接起來。相對而言,使用插入標記的技術,直接插入 HTML 字元串不僅更簡單,速度也更快。以下與插入標記相關的 DOM 擴展已經納入了 HTML5 規範。
innerHTML屬性
在讀模式下, innerHTML 屬性返回與調用元素的所有子節點(包括元素、注釋和文本節點)對應的 HTML 標記。在寫模式下, innerHTML 會根據指定的值創建新的 DOM 樹,然後用這個DOM 樹完全替換調用元素原先的所有子節點。下面是一個例子。
<div id="content">
<p>This is a <strong>paragraph</strong> with a list following it.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
</div>
對於上面的<div>元素來說,它的 innerHTML 屬性會返回如下字元串。
<p>This is a <strong>paragraph</strong> with a list following it.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
在寫模式下, innerHTML 的值會被解析為 DOM 子樹,替換調用元素原來的所有子節點。因為它的值被認為是 HTML,所以其中的所有標籤都會按照瀏覽器處理 HTML 的標準方式轉換為元素(同樣,這裡的轉換結果也因瀏覽器而異)。
用 innerHTML 屬性也有一些限制。比如,在大多數瀏覽器中,通過 innerHTML 插入<script>元素並不會執行其中的腳本。
outerHTML模式
在讀模式下, outerHTML 返回調用它的元素及所有子節點的 HTML 標籤。在寫模式下, outerHTML會根據指定的 HTML 字元串創建新的 DOM 子樹,然後用這個 DOM 子樹完全替換調用元素。下面是一個例子。
<div id="content">
<p>This is a <strong>paragraph</strong> with a list following it.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
</div>
如果在<div>元素上調用 outerHTML,會返回與上面相同的程式碼,包括<div>本身。
使用 outerHTML 屬性以下面這種方式設置值:
div.outerHTML = "<p>This is a paragraph.</p>";
這行程式碼完成的操作與下面這些 DOM 腳本程式碼一樣:
var p = document.createElement("p");
p.appendChild(document.createTextNode("This is a paragraph."));
div.parentNode.replaceChild(p, div);
結果,就是新創建的<p>元素會取代 DOM 樹中的<div>元素。
insertAdjacentHTML方法
- "beforebegin",在當前元素之前插入一個緊鄰的同輩元素;
- "afterbegin",在當前元素之下插入一個新的子元素或在第一個子元素之前再插入新的子元素;
- "beforeend",在當前元素之下插入一個新的子元素或在最後一個子元素之後再插入新的子元素;
- "afterend",在當前元素之後插入一個緊鄰的同輩元素。
//作為前一個同輩元素插入
element.insertAdjacentHTML("beforebegin", "<p>Hello world!</p>");
//作為第一個子元素插入
element.insertAdjacentHTML("afterbegin", "<p>Hello world!</p>");
//作為最後一個子元素插入
element.insertAdjacentHTML("beforeend", "<p>Hello world!</p>");
//作為後一個同輩元素插入
element.insertAdjacentHTML("afterend", "<p>Hello world!</p>");
記憶體與性能問題
使用本節介紹的方法替換子節點可能會導致瀏覽器的記憶體佔用問題,尤其是在 IE 中,問題更加明顯。在刪除帶有事件處理程式或引用了其他 JavaScript 對象子樹時,就有可能導致記憶體佔用問題。假設某個元素有一個事件處理程式(或者引用了一個 JavaScript 對象作為屬性),在使用前述某個屬性將該元素從文檔樹中刪除後,元素與事件處理程式(或 JavaScript 對象)之間的綁定關係在記憶體中並沒有一併刪除。如果這種情況頻繁出現,頁面佔用的記憶體數量就會明顯增加。因此,在使用 innerHTML、outerHTML 屬性和 insertAdjacentHTML()方法時,最好先手工刪除要被替換的元素的所有事件處理程式和 JavaScript 對象屬性。
scrollIntoView方法
如何滾動頁面也是 DOM 規範沒有解決的一個問題。為了解決這個問題,瀏覽器實現了一些方法,以方便開發人員更好地控制頁面滾動。在各種專有方法中, HTML5 最終選擇了 scrollIntoView()作為標準方法。
scrollIntoView()可以在所有 HTML 元素上調用,通過滾動瀏覽器窗口或某個容器元素,調用元素就可以出現在視口中。如果給這個方法傳入 true 作為參數,或者不傳入任何參數,那麼窗口滾動之後會讓調用元素的頂部與視口頂部儘可能平齊。如果傳入 false 作為參數,調用元素會儘可能全部出現在視口中,(可能的話,調用元素的底部會與視口頂部平齊。)不過頂部不一定平齊,例如:
//讓元素可見
document.forms[0].scrollIntoView();
當頁面發生變化時,一般會用這個方法來吸引用戶的注意力。實際上,為某個元素設置焦點也會導致瀏覽器滾動並顯示出獲得焦點的元素
專有擴展
雖然所有瀏覽器開發商都知曉堅持標準的重要性,但在發現某項功能缺失時,這些開發商都會一如既往地向 DOM 中添加專有擴展,以彌補功能上的不足。表面上看,這種各行其事的做法似乎不太好,但實際上專有擴展為 Web 開發領域提供了很多重要的功能,這些功能最終都在 HTML5 規範中得到了標準化。
文檔模式
E8 引入了一個新的概念叫「文檔模式」(document mode)。頁面的文檔模式決定了可以使用什麼功能。換句話說,文檔模式決定了你可以使用哪個級別的 CSS,可以在 JavaScript 中使用哪些 API,以及如何對待文檔類型(doctype)。
第12章 DOM2和DOM3
DOM1 級主要定義的是 HTML 和 XML 文檔的底層結構。 DOM2 和 DOM3 級則在這個結構的基礎上引入了更多的交互能力,也支援了更高級的 XML 特性。
DOM變化
類似地,「DOM2 級視圖」和「DOM2 級 HTML」模組也增強了 DOM 介面,提供了新的屬性和方法。由於這兩個模組很小,因此我們將把它們與「DOM2 級核心」放在一起,討論基本 JavaScript 對象的變化。可以通過下列程式碼來確定瀏覽器是否支援這些 DOM 模組。
var supportsDOM2Core = document.implementation.hasFeature("Core", "2.0");
var supportsDOM3Core = document.implementation.hasFeature("Core", "3.0");
var supportsDOM2HTML = document.implementation.hasFeature("HTML", "2.0");
var supportsDOM2Views = document.implementation.hasFeature("Views", "2.0");
var supportsDOM2XML = document.implementation.hasFeature("XML", "2.0");
元素大小
本節介紹的屬性和方法並不屬於「DOM2 級樣式」規範,但卻與 HTML 元素的樣式息息相關。 DOM中沒有規定如何確定頁面中元素的大小。 IE 為此率先引入了一些屬性,以便開發人員使用。目前,所有主要的瀏覽器都已經支援這些屬性。
偏移量
首先要介紹的屬性涉及偏移量(offset dimension),包括元素在螢幕上佔用的所有可見的空間。元素的可見大小由其高度、寬度決定,包括所有內邊距、滾動條和邊框大小(注意,不包括外邊距)。通過下列 4 個屬性可以取得元素的偏移量。
- offsetHeight:元素在垂直方向上佔用的空間大小,以像素計。包括元素的高度、(可見的)水平滾動條的高度、上邊框高度和下邊框高度。
- offsetWidth:元素在水平方向上佔用的空間大小,以像素計。包括元素的寬度、(可見的)垂直滾動條的寬度、左邊框寬度和右邊框寬度。
- offsetLeft:元素的左外邊框至包含元素的左內邊框之間的像素距離。
- offsetTop:元素的上外邊框至包含元素的上內邊框之間的像素距離。
要想知道某個元素在頁面上的偏移量,將這個元素的 offsetLeft 和 offsetTop 與其 offsetParent的相同屬性相加,如此循環直至根元素,就可以得到一個基本準確的值。以下兩個函數就可以用於分別取得元素的左和上偏移量。
function getElementLeft(element){
var actualLeft = element.offsetLeft;
var current = element.offsetParent;
while (current !== null){
actualLeft += current.offsetLeft;
current = current.offsetParent;
}
return actualLeft;
}
function getElementTop(element){
var actualTop = element.offsetTop;
var current = element.offsetParent;
while (current !== null){
actualTop += current. offsetTop;
current = current.offsetParent;
}
return actualTop;
}
客戶區大小
元素的客戶區大小(client dimension),指的是元素內容及其內邊距所佔據的空間大小。有關客戶區大小的屬性有兩個: clientWidth 和 clientHeight。其中, clientWidth 屬性是元素內容區寬度加上左右內邊距寬度; clientHeight 屬性是元素內容區高度加上上下內邊距高度。
滾動大小
滾動大小(scroll dimension),指的是包含滾動內容的元素的大小。有些元素(例如<html>元素),即使沒有執行任何程式碼也能自動地添加滾動條;但另外一些元素,則需要通過 CSS 的overflow 屬性進行設置才能滾動。以下是 4 個與滾動大小相關的屬性。
- scrollHeight:在沒有滾動條的情況下,元素內容的總高度。
- scrollWidth:在沒有滾動條的情況下,元素內容的總寬度。
- scrollLeft:被隱藏在內容區域左側的像素數。通過設置這個屬性可以改變元素的滾動位置。
- scrollTop:被隱藏在內容區域上方的像素數。通過設置這個屬性可以改變元素的滾動位置。
遍歷
如前所述, DOM 遍歷是深度優先的 DOM 結構遍歷,也就是說,移動的方向至少有兩個(取決於使用的遍歷類型)。遍歷以給定節點為根,不可能向上超出 DOM 樹的根節點。以下面的 HTML 頁面為例。
<!DOCTYPE html>
<html>
<head>
<title>Example</title>
</head>
<body>
<p><b>Hello</b> world!</p>
</body>
</html>
任何節點都可以作為遍歷的根節點。如果假設<body>元素為根節點,那麼遍歷的第一步就是訪問<p>元素,然後再訪問同為<body>元素後代的兩個文本節點。不過,這次遍歷永遠不會到達<html>、<head>元素,也不會到達不屬於<body>元素子樹的任何節點。而以 document 為根節點的遍歷則可以訪問到文檔中的全部節點。
從 document 開始依序向前,訪問的第一個節點是 document,訪問的最後一個節點是包含"world!"的文本節點。從文檔最後的文本節點開始,遍歷可以反向移動到 DOM 樹的頂端。此時,訪問的第一個節點是包含"Hello"的文本節點,訪問的最後一個節點是 document 節點。 NodeIterator和 TreeWalker 都以這種方式執行遍歷。
範圍
為了讓開發人員更方便地控制頁面,「DOM2 級遍歷和範圍」模組定義了「範圍」(range)介面。通過範圍可以選擇文檔中的一個區域,而不必考慮節點的界限(選擇在後台完成,對用戶是不可見的)。在常規的 DOM 操作不能更有效地修改文檔時,使用範圍往往可以達到目的。
第13章 事件
JavaScript 與 HTML 之間的交互是通過事件實現的。事件,就是文檔或瀏覽器窗口中發生的一些特定的交互瞬間。可以使用偵聽器(或處理程式)來預訂事件,以便事件發生時執行相應的程式碼。這種在傳統軟體工程中被稱為觀察員模式的模型,支援頁面的行為(JavaScript 程式碼)與頁面的外觀(HTML 和 CSS 程式碼)之間的鬆散耦合。
事件流
當瀏覽器發展到第四代時(IE4 及 Netscape Communicator 4),瀏覽器開發團隊遇到了一個很有意思的問題:頁面的哪一部分會擁有某個特定的事件?要明白這個問題問的是什麼,可以想像畫在一張紙上的一組同心圓。如果你把手指放在圓心上,那麼你的手指指向的不是一個圓,而是紙上的所有圓。兩家公司的瀏覽器開發團隊在看待瀏覽器事件方面還是一致的。如果你單擊了某個按鈕,他們都認為單擊事件不僅僅發生在按鈕上。換句話說,在單擊按鈕的同時,你也單擊了按鈕的容器元素,甚至也單擊了整個頁面。
事件流描述的是從頁面中接收事件的順序。但有意思的是, IE 和 Netscape 開發團隊居然提出了差不多是完全相反的事件流的概念。 IE 的事件流是事件冒泡流,而 Netscape Communicator 的事件流是事件捕獲流。
事件冒泡
IE 的事件流叫做事件冒泡(event bubbling),即事件開始時由最具體的元素(文檔中嵌套層次最深的那個節點)接收,然後逐級向上傳播到較為不具體的節點(文檔)。以下面的 HTML 頁面為例:
<!DOCTYPE html>
<html>
<head>
<title>Event Bubbling Example</title>
</head>
<body>
<div id="myDiv">Click Me</div>
</body>
</html>
如果你單擊了頁面中的<div>元素,那麼這個 click 事件會按照如下順序傳播:
(1) <div>
(2) <body>
(3) <html>
(4) document
事件捕獲
Netscape Communicator 團隊提出的另一種事件流叫做事件捕獲(event capturing)。事件捕獲的思想是不太具體的節點應該更早接收到事件,而最具體的節點應該最後接收到事件。事件捕獲的用意在於在事件到達預定目標之前捕獲它。如果仍以前面的 HTML 頁面作為演示事件捕獲的例子,那麼單擊<div>元素就會以下列順序觸發 click 事件。
(1) document
(2) <html>
(3) <body>
(4) <div>
在事件捕獲過程中, document 對象首先接收到 click 事件,然後事件沿 DOM 樹依次向下,一直傳播到事件的實際目標,即<div>元素。
DOM事件流
「DOM2級事件」規定的事件流包括三個階段:事件捕獲階段、處於目標階段和事件冒泡階段。首先發生的是事件捕獲,為截獲事件提供了機會。然後是實際的目標接收到事件。最後一個階段是冒泡階段,可以在這個階段對事件做出響應。
在 DOM 事件流中,實際的目標(<div>元素)在捕獲階段不會接收到事件。這意味著在捕獲階段,事件從 document 到<html>再到<body>後就停止了。下一個階段是「處於目標」階段,於是事件在<div>上發生,並在事件處理(後面將會討論這個概念)中被看成冒泡階段的一部分。然後,冒泡階段發生,事件又傳播迴文檔。
時間處理程式
事件就是用戶或瀏覽器自身執行的某種動作。諸如 click、 load 和 mouseover,都是事件的名字。而響應某個事件的函數就叫做事件處理程式(或事件偵聽器)。事件處理程式的名字以"on"開頭,因此click 事件的事件處理程式就是 onclick, load 事件的事件處理程式就是 onload。為事件指定處理程式的方式有好幾種。
HTML事件處理程式
某個元素支援的每種事件,都可以使用一個與相應事件處理程式同名的 HTML 特性來指定。這個特性的值應該是能夠執行的 JavaScript 程式碼。例如,要在按鈕被單擊時執行一些 JavaScript,可以像下面這樣編寫程式碼:
<input type="button" value="Click Me" onclick="alert('Clicked')" />
在 HTML 中定義的事件處理程式可以包含要執行的具體動作,也可以調用在頁面其他地方定義的腳本,如下面的例子所示:
<script type="text/javascript">
function showMessage(){
alert("Hello world!");
}
</script>
<input type="button" value="Click Me" onclick="showMessage()" />
這樣指定事件處理程式具有一些獨到之處。首先,這樣會創建一個封裝著元素屬性值的函數。這個函數中有一個局部變數 event,也就是事件對象:
<!-- 輸出 "click" -->
<input type="button" value="Click Me" onclick="alert(event.type)">
通過 event 變數,可以直接訪問事件對象,你不用自己定義它,也不用從函數的參數列表中讀取。在這個函數內部, this 值等於事件的目標元素,例如:
<!-- 輸出 "Click Me" -->
<input type="button" value="Click Me" onclick="alert(this.value)">
關於這個動態創建的函數,另一個有意思的地方是它擴展作用域的方式。在這個函數內部,可以像訪問局部變數一樣訪問 document 及該元素本身的成員。這個函數使用 with 像下面這樣擴展作用域:
function(){
with(document){
with(this){
//元素屬性值
}
}
}
如此一來,事件處理程式要訪問自己的屬性就簡單多了。下面這行程式碼與前面的例子效果相同:
<!-- 輸出 "Click Me" -->
<input type="button" value="Click Me" onclick="alert(value)">
如果當前元素是一個表單輸入元素,則作用域中還會包含訪問表單元素(父元素)的入口,這個函數就變成了如下所示:
function(){
with(document){
with(this.form){
with(this){
//元素屬性值
}
}
}
}
實際上,這樣擴展作用域的方式,無非就是想讓事件處理程式無需引用表單元素就能訪問其他表單欄位。例如:
<form method="post">
<input type="text" name="username" value="">
<input type="button" value="Echo Username" onclick="alert(username.value)">
</form>
在這個例子中,單擊按鈕會顯示文本框中的文本。值得注意的是,這裡直接引用了 username 元素。
不過,在 HTML 中指定事件處理程式有兩個缺點。首先,存在一個時差問題。因為用戶可能會在HTML 元素一出現在頁面上就觸發相應的事件,但當時的事件處理程式有可能尚不具備執行件。以前面的例子來說明,假設 showMessage()函數是在按鈕下方、頁面的最底部定義的。如果用戶在頁面解析 showMessage()函數之前就單擊了按鈕,就會引發錯誤。為此,很多 HTML 事件處理程式都會被封裝在一個 try-catch 塊中,以便錯誤不會浮出水面,如下面的例子所示:
<input type="button" value="Click Me" onclick="try{showMessage();}catch(ex){}">
另一個缺點是,這樣擴展事件處理程式的作用域鏈在不同瀏覽器中會導致不同結果。不同 JavaScript引擎遵循的標識符解析規則略有差異,很可能會在訪問非限定對象成員時出錯。
通過 HTML 指定事件處理程式的最後一個缺點是 HTML 與 JavaScript 程式碼緊密耦合。如果要更換事件處理程式,就要改動兩個地方: HTML 程式碼和 JavaScript 程式碼。而這正是許多開發人員摒棄 HTML 事件處理程式,轉而使用 JavaScript 指定事件處理程式的原因所在。
DOM0級事件處理程式
通過 JavaScript 指定事件處理程式的傳統方式,就是將一個函數賦值給一個事件處理程式屬性。原因一是簡單,二是具有跨瀏覽器的優勢。要使用 JavaScript 指定事件處理程式,首先必須取得一個要操作的對象的引用。
每個元素(包括 window 和 document)都有自己的事件處理程式屬性,這些屬性通常全部小寫,例如 onclick。將這種屬性的值設置為一個函數,就可以指定事件處理程式,如下所示:
var btn = document.getElementById("myBtn");
btn.onclick = function(){
alert("Clicked");
};
使用 DOM0 級方法指定的事件處理程式被認為是元素的方法。因此,這時候的事件處理程式是在元素的作用域中運行;換句話說,程式中的 this 引用當前元素。來看一個例子。
var btn = document.getElementById("myBtn");
btn.onclick = function(){
alert(this.id); //"myBtn"
};
也可以刪除通過 DOM0 級方法指定的事件處理程式,只要像下面這樣將事件處理程式屬性的值設置為 null 即可:
btn.onclick = null; //刪除事件處理程式
將事件處理程式設置為 null 之後,再單擊按鈕將不會有任何動作發生。
DOM2級事件處理程式
「DOM2 級事件」 定義了兩個方法,用於處理指定和刪除事件處理程式的操作: addEventListener()和 removeEventListener()。所有 DOM 節點中都包含這兩個方法,並且它們都接受 3 個參數:要處理的事件名、作為事件處理程式的函數和一個布爾值。最後這個布爾值參數如果是 true,表示在捕獲階段調用事件處理程式;如果是 false,表示在冒泡階段調用事件處理程式。
要在按鈕上為 click 事件添加事件處理程式,可以使用下列程式碼:
var btn = document.getElementById("myBtn");
btn.addEventListener("click", function(){
alert(this.id);
}, false);
使用 DOM2 級方法添加事件處理程式的主要好處是可以添加多個事件處理程式。來看下面的例子。
var btn = document.getElementById("myBtn");
btn.addEventListener("click", function(){
alert(this.id);
}, false);
btn.addEventListener("click", function(){
alert("Hello world!");
}, false);
這裡為按鈕添加了兩個事件處理程式。這兩個事件處理程式會按照添加它們的順序觸發,因此首先會顯示元素的 ID,其次會顯示"Hello world!"消息。
通過 addEventListener()添加的事件處理程式只能使用 removeEventListener()來移除;移除時傳入的參數與添加處理程式時使用的參數相同。這也意味著通過 addEventListener()添加的匿名函數將無法移除,如下面的例子所示。
var btn = document.getElementById("myBtn");
btn.addEventListener("click", function(){
alert(this.id);
}, false);
//這裡省略了其他程式碼
btn.removeEventListener("click", function(){ //沒有用!
alert(this.id);
}, false);
在這個例子中,我們使用 addEventListener()添加了一個事件處理程式。雖然調用 removeEventListener()時看似使用了相同的參數,但實際上,第二個參數與傳入 addEventListener()中的那一個是完全不同的函數。而傳入 removeEventListener()中的事件處理程式函數必須與傳入addEventListener()中的相同,如下面的例子所示。
var btn = document.getElementById("myBtn");
var handler = function(){
alert(this.id);
};
btn.addEventListener("click", handler, false);
//這裡省略了其他程式碼
btn.removeEventListener("click", handler, false); //有效!
大多數情況下,都是將事件處理程式添加到事件流的冒泡階段,這樣可以最大限度地兼容各種瀏覽器。最好只在需要在事件到達目標之前截獲它的時候將事件處理程式添加到捕獲階段。如果不是特別需要,我們不建議在事件捕獲階段註冊事件處理程式。
IE事件處理程式
IE 實現了與 DOM 中類似的兩個方法: attachEvent()和 detachEvent()。這兩個方法接受相同的兩個參數:事件處理程式名稱與事件處理程式函數。
要使用 attachEvent()為按鈕添加一個事件處理程式,可以使用以下程式碼。
var btn = document.getElementById("myBtn");
btn.attachEvent("onclick", function(){
alert("Clicked");
});
在 IE 中使用 attachEvent()與使用 DOM0 級方法的主要區別在於事件處理程式的作用域。在使用 DOM0 級方法的情況下,事件處理程式會在其所屬元素的作用域內運行;在使用 attachEvent()方法的情況下,事件處理程式會在全局作用域中運行,因此 this 等於 window。來看下面的例子。
var btn = document.getElementById("myBtn");
btn.attachEvent("onclick", function(){
alert(this === window); //true
});
與 addEventListener()類似, attachEvent()方法也可以用來為一個元素添加多個事件處理程
序。來看下面的例子。
var btn = document.getElementById("myBtn");
btn.attachEvent("onclick", function(){
alert("Clicked");
});
btn.attachEvent("onclick", function(){
alert("Hello world!");
});
這裡調用了兩次 attachEvent(),為同一個按鈕添加了兩個不同的事件處理程式。不過,與 DOM方法不同的是,這些事件處理程式不是以添加它們的順序執行,而是以相反的順序被觸發。單擊這個例子中的按鈕,首先看到的是"Hello world!",然後才是"Clicked"。
跨瀏覽器的時間處理程式
為了以跨瀏覽器的方式處理事件,不少開發人員會使用能夠隔離瀏覽器差異的 JavaScript 庫,還有一些開發人員會自己開發最合適的事件處理的方法。自己編寫程式碼其實也不難,只要恰當地使用能力檢測即可(能力檢測在第 9 章介紹過)。要保證處理事件的程式碼能在大多數瀏覽器下一致地運行,只需關注冒泡階段。
var EventUtil = {
addHandler: function(element, type, handler){
if (element.addEventListener){
element.addEventListener(type, handler, false);
} else if (element.attachEvent){
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
},
removeHandler: function(element, type, handler){
if (element.removeEventListener){
element.removeEventListener(type, handler, false);
} else if (element.detachEvent){
element.detachEvent("on" + type, handler);
} else {
element["on" + type] = null;
}
}
};
事件對象
在觸發 DOM 上的某個事件時,會產生一個事件對象 event,這個對象中包含著所有與事件有關的資訊。包括導致事件的元素、事件的類型以及其他與特定事件相關的資訊。
DOM中的事件對象
兼容 DOM 的瀏覽器會將一個 event 對象傳入到事件處理程式中。無論指定事件處理程式時使用什麼方法(DOM0 級或 DOM2 級),都會傳入 event 對象。來看下面的例子。
var btn = document.getElementById("myBtn");
btn.onclick = function(event){
alert(event.type); //"click"
};
btn.addEventListener("click", function(event){
alert(event.type); //"click"
}, false);
IE中的事件對象
與訪問 DOM 中的 event 對象不同,要訪問 IE 中的 event 對象有幾種不同的方式,取決於指定事件處理程式的方法。在使用 DOM0 級方法添加事件處理程式時, event 對象作為 window 對象的一個屬性存在。來看下面的例子。
var btn = document.getElementById("myBtn");
btn.onclick = function(){
var event = window.event;
alert(event.type); //"click"
};
如果事件處理程式是使用 attachEvent()添加的,那麼就會有一個 event 對象作為參數被傳入事件處理程式函數中,如下所示。
var btn = document.getElementById("myBtn");
btn.attachEvent("onclick", function(event){
alert(event.type); //"click"
});
在像這樣使用 attachEvent()的情況下,也可以通過 window 對象來訪問 event 對象,就像使用DOM0 級方法時一樣。如果是通過 HTML 特性指定的事件處理程式,那麼還可以通過一個名叫 event 的變數來訪問 event對象(與 DOM 中的事件模型相同)。再看一個例子。
<input type="button" value="Click Me" onclick="alert(event.type)">
跨瀏覽器的事件對象
雖然 DOM 和 IE 中的 event 對象不同,但基於它們之間的相似性依舊可以拿出跨瀏覽器的方案來。
var EventUtil = {
addHandler: function(element, type, handler){
//省略的程式碼
},
getEvent: function(event){
return event ? event : window.event;
},
getTarget: function(event){
return event.target || event.srcElement;
},
preventDefault: function(event){
if (event.preventDefault){
event.preventDefault();
} else {
event.returnValue = false;
}
},
removeHandler: function(element, type, handler){
//省略的程式碼
},
stopPropagation: function(event){
if (event.stopPropagation){
event.stopPropagation();
} else {
event.cancelBubble = true;
}
}
};
事件類型
Web 瀏覽器中可能發生的事件有很多類型。如前所述,不同的事件類型具有不同的資訊,而「DOM3級事件」規定了以下幾類事件。
- UI(User Interface,用戶介面)事件,當用戶與頁面上的元素交互時觸發;
- 焦點事件,當元素獲得或失去焦點時觸發;
- 滑鼠事件,當用戶通過滑鼠在頁面上執行操作時觸發;
- 滾輪事件,當使用滑鼠滾輪(或類似設備)時觸發;
- 文本事件,當在文檔中輸入文本時觸發;
- 鍵盤事件,當用戶通過鍵盤在頁面上執行操作時觸發;
- 合成事件,當為 IME(Input Method Editor,輸入法編輯器)輸入字元時觸發;
- 變動(mutation)事件,當底層 DOM 結構發生變化時觸發。
- 變動名稱事件,當元素或屬性名變動時觸發。此類事件已經被廢棄,沒有任何瀏覽器實現它們
UI事件
UI 事件指的是那些不一定與用戶操作有關的事件。這些事件在 DOM 規範出現之前,都是以這種或那種形式存在的,而在 DOM 規範中保留是為了向後兼容。現有的 UI 事件如下。
- DOMActivate:表示元素已經被用戶操作(通過滑鼠或鍵盤)激活。這個事件在 DOM3 級事件中被廢棄,但 Firefox 2+和 Chrome 支援它。考慮到不同瀏覽器實現的差異,不建議使用這個事件。
- load:當頁面完全載入後在 window 上面觸發,當所有框架都載入完畢時在框架集上面觸發,當影像載入完畢時在<img>元素上面觸發,或者當嵌入的內容載入完畢時在<object>元素上面觸發。
- unload:當頁面完全卸載後在 window 上面觸發,當所有框架都卸載後在框架集上面觸發,或者當嵌入的內容卸載完畢後在<object>元素上面觸發。
- abort:在用戶停止下載過程時,如果嵌入的內容沒有載入完,則在<object>元素上面觸發。
- error:當發生 JavaScript 錯誤時在 window 上面觸發,當無法載入影像時在<img>元素上面觸發,當無法載入嵌入內容時在<object>元素上面觸發,或者當有一或多個框架無法載入時在框架集上面觸發。第 17 章將繼續討論這個事件。
- select:當用戶選擇文本框(<input>或<texterea>)中的一或多個字元時觸發。第 14 章將繼續討論這個事件。
- resize:當窗口或框架的大小變化時在 window 或框架上面觸發。
- scroll:當用戶滾動帶滾動條的元素中的內容時,在該元素上面觸發。 <body>元素中包含所載入頁面的滾動條。
load事件
JavaScript 中最常用的一個事件就是 load。當頁面完全載入後(包括所有影像、 JavaScript 文件、CSS 文件等外部資源),就會觸發 window 上面的 load 事件。
unload事件
與 load 事件對應的是 unload 事件,這個事件在文檔被完全卸載後觸發。只要用戶從一個頁面切換到另一個頁面,就會發生 unload 事件。而利用這個事件最多的情況是清除引用,以避免記憶體泄漏。
resize事件
當瀏覽器窗口被調整到一個新的高度或寬度時,就會觸發 resize 事件。這個事件在 window(窗口)上面觸發,因此可以通過 JavaScript 或者<body>元素中的 onresize 特性來指定事件處理程式。
scroll事件
雖然 scroll 事件是在 window 對象上發生的,但它實際表示的則是頁面中相應元素的變化。在混雜模式下,可以通過<body>元素的 scrollLeft 和 scrollTop 來監控到這一變化;而在標準模式下,除 Safari 之外的所有瀏覽器都會通過<html>元素來反映這一變化
焦點事件
焦點事件會在頁面元素獲得或失去焦點時觸發。利用這些事件並與 document.hasFocus()方法及
document.activeElement 屬性配合,可以知曉用戶在頁面上的行蹤。有以下 6 個焦點事件。
- blur:在元素失去焦點時觸發。這個事件不會冒泡;所有瀏覽器都支援它。
- DOMFocusIn:在元素獲得焦點時觸發。這個事件與 HTML 事件 focus 等價,但它冒泡。只有Opera 支援這個事件。 DOM3 級事件廢棄了 DOMFocusIn,選擇了 focusin。
- DOMFocusOut:在元素失去焦點時觸發。這個事件是 HTML 事件 blur 的通用版本。只有 Opera支援這個事件。 DOM3 級事件廢棄了 DOMFocusOut,選擇了 focusout。
- focus:在元素獲得焦點時觸發。這個事件不會冒泡;所有瀏覽器都支援它。
- focusin:在元素獲得焦點時觸發。這個事件與 HTML 事件 focus 等價,但它冒泡。支援這個事件的瀏覽器有 IE5.5+、 Safari 5.1+、 Opera 11.5+和 Chrome。
- focusout:在元素失去焦點時觸發。這個事件是 HTML 事件 blur 的通用版本。支援這個事件的瀏覽器有 IE5.5+、 Safari 5.1+、 Opera 11.5+和 Chrome
當焦點從頁面中的一個元素移動到另一個元素,會依次觸發下列事件:
(1) focusout 在失去焦點的元素上觸發;
(2) focusin 在獲得焦點的元素上觸發;
(3) blur 在失去焦點的元素上觸發;
(4) DOMFocusOut 在失去焦點的元素上觸發;
(5) focus 在獲得焦點的元素上觸發;
(6) DOMFocusIn 在獲得焦點的元素上觸發。
滑鼠與滾動事件
- click:在用戶單擊主滑鼠按鈕(一般是左邊的按鈕)或者按下回車鍵時觸發。這一點對確保易訪問性很重要,意味著 onclick 事件處理程式既可以通過鍵盤也可以通過滑鼠執行。
- dblclick:在用戶雙擊主滑鼠按鈕(一般是左邊的按鈕)時觸發。從技術上說,這個事件並不是 DOM2 級事件規範中規定的,但鑒於它得到了廣泛支援,所以 DOM3 級事件將其納入了標準。
- mousedown:在用戶按下了任意滑鼠按鈕時觸發。不能通過鍵盤觸發這個事件。
- mouseenter:在滑鼠游標從元素外部首次移動到元素範圍之內時觸發。這個事件不冒泡,而且在游標移動到後代元素上不會觸發。 DOM2 級事件並沒有定義這個事件,但 DOM3 級事件將它納入了規範。 IE、 Firefox 9+和 Opera 支援這個事件。
- mouseleave:在位於元素上方的滑鼠游標移動到元素範圍之外時觸發。這個事件不冒泡,而且在游標移動到後代元素上不會觸發。 DOM2 級事件並沒有定義這個事件,但 DOM3 級事件將它納入了規範。 IE、 Firefox 9+和 Opera 支援這個事件。
- mousemove:當滑鼠指針在元素內部移動時重複地觸發。不能通過鍵盤觸發這個事件。
- mouseout:在滑鼠指針位於一個元素上方,然後用戶將其移入另一個元素時觸發。又移入的另一個元素可能位於前一個元素的外部,也可能是這個元素的子元素。不能通過鍵盤觸發這個事件。
- mouseover:在滑鼠指針位於一個元素外部,然後用戶將其首次移入另一個元素邊界之內時觸發。不能通過鍵盤觸發這個事件。
- mouseup:在用戶釋放滑鼠按鈕時觸發。不能通過鍵盤觸發這個事件。
只有在同一個元素上相繼觸發 mousedown 和 mouseup 事件,才會觸發 click 事件;如果mousedown 或 mouseup 中的一個被取消,就不會觸發 click 事件。類似地,只有觸發兩次 click 事件,才會觸發一次 dblclick 事件。如果有程式碼阻止了連續兩次觸發 click 事件(可能是直接取消 click事件,也可能通過取消 mousedown 或 mouseup 間接實現),那麼就不會觸發 dblclick 事件了。這 4個事件觸發的順序始終如下:
(1) mousedown
(2) mouseup
(3) click
(4) mousedown
(5) mouseup
(6) click
(7) dblclick
顯然, click 和 dblclick 事件都會依賴於其他先行事件的觸發;而 mousedown 和 mouseup 則不受其他事件的影響。IE8 及之前版本中的實現有一個小 bug,因此在雙擊事件中,會跳過第二個 mousedown 和 click事件,其順序如下:
(1) mousedown
(2) mouseup
(3) click
(4) mouseup
(5) dblclick
IE9 修復了這個 bug,之後順序就正確了。
滑鼠滾動事件
IE 6.0 首先實現了 mousewheel 事件。此後, Opera、 Chrome 和 Safari 也都實現了這個事件。當用戶通過滑鼠滾輪與頁面交互、在垂直方向上滾動頁面時(無論向上還是向下),就會觸發 mousewheel事件。這個事件可以在任何元素上面觸發,最終會冒泡到 document(IE8)或 window(IE9、 Opera、Chrome 及 Safari)對象。與 mousewheel 事件對應的 event 對象除包含滑鼠事件的所有標準資訊外,還包含一個特殊的 wheelDelta 屬性。當用戶向前滾動滑鼠滾輪時, wheelDelta 是 120 的倍數;當用戶向後滾動滑鼠滾輪時, wheelDelta 是-120 的倍數。
觸摸設備
iOS 和 Android 設備的實現非常特別,因為這些設備沒有滑鼠。在面向 iPhone 和 iPod 中的 Safari開發時,要記住以下幾點。
- 不支援 dblclick 事件。雙擊瀏覽器窗口會放大畫面,而且沒有辦法改變該行為。
- 輕擊可單擊元素會觸發 mousemove 事件。如果此操作會導致內容變化,將不再有其他事件發生;如果螢幕沒有因此變化,那麼會依次發生 mousedown、 mouseup 和 click 事件。輕擊不可單擊的元素不會觸發任何事件。可單擊的元素是指那些單擊可產生默認操作的元素(如鏈接),或者那些已經被指定了 onclick 事件處理程式的元素。
- mousemove 事件也會觸發 mouseover 和 mouseout 事件。
- 兩個手指放在螢幕上且頁面隨手指移動而滾動時會觸發 mousewheel 和 scroll 事件
鍵盤與文本事件
用戶在使用鍵盤時會觸發鍵盤事件。有 3 個鍵盤事件,簡述如下。
- keydown:當用戶按下鍵盤上的任意鍵時觸發,而且如果按住不放的話,會重複觸發此事件。
- keypress:當用戶按下鍵盤上的字元鍵時觸發,而且如果按住不放的話,會重複觸發此事件。
按下 Esc 鍵也會觸發這個事件。Safari 3.1 之前的版本也會在用戶按下非字元鍵時觸發 keypress事件。 - keyup:當用戶釋放鍵盤上的鍵時觸發。
只有一個文本事件: textInput。這個事件是對 keypress 的補充,用意是在將文本顯示給用戶之前更容易攔截文本。在文本插入文本框之前會觸發 textInput 事件。
在用戶按了一下鍵盤上的字元鍵時,首先會觸發 keydown 事件,然後緊跟著是 keypress 事件,最後會觸發 keyup 事件。 其中, keydown 和 keypress 都是在文本框發生變化之前被觸發的;而 keyup事件則是在文本框已經發生變化之後被觸發的。如果用戶按下了一個字元鍵不放,就會重複觸發keydown 和 keypress 事件,直到用戶鬆開該鍵為止。
如果用戶按下的是一個非字元鍵,那麼首先會觸發 keydown 事件,然後就是 keyup 事件。如果按住這個非字元鍵不放,那麼就會一直重複觸發 keydown 事件,直到用戶鬆開這個鍵,此時會觸發 keyup事件。
複合事件
複合事件(composition event)是 DOM3 級事件中新添加的一類事件,用於處理 IME 的輸入序列。IME(Input Method Editor,輸入法編輯器)可以讓用戶輸入在物理鍵盤上找不到的字元。例如,使用拉丁文鍵盤的用戶通過 IME 照樣能輸入日文字元。 IME 通常需要同時按住多個鍵,但最終只輸入一個字元。複合事件就是針對檢測和處理這種輸入而設計的。有以下三種複合事件。
變動事件
DOM2 級的變動(mutation)事件能在 DOM 中的某一部分發生變化時給出提示。變動事件是為 XML
或 HTML DOM 設計的,並不特定於某種語言。 DOM2 級定義了如下變動事件。
- DOMSubtreeModified:在 DOM 結構中發生任何變化時觸發。這個事件在其他任何事件觸發後都會觸發。
- DOMNodeInserted:在一個節點作為子節點被插入到另一個節點中時觸發。
- DOMNodeRemoved:在節點從其父節點中被移除時觸發。
- DOMNodeInsertedIntoDocument:在一個節點被直接插入文檔或通過子樹間接插入文檔之後觸發。這個事件在 DOMNodeInserted 之後觸發。
- DOMNodeRemovedFromDocument:在一個節點被直接從文檔中移除或通過子樹間接從文檔中移除之前觸發。這個事件在 DOMNodeRemoved 之後觸發。
- DOMAttrModified:在特性被修改之後觸發。
- DOMCharacterDataModified:在文本節點的值發生變化時觸發。
HTML5事件
contextmenu事件
Windows 95 在 PC 中引入了上下文菜單的概念,即通過單擊滑鼠右鍵可以調出上下文菜單。不久,這個概念也被引入了 Web 領域。為了實現上下文菜單,開發人員面臨的主要問題是如何確定應該顯示上下文菜單(在 Windows 中,是右鍵單擊;在 Mac 中,是 Ctrl+單擊),以及如何屏蔽與該操作關聯的默認上下文菜單。為解決這個問題,就出現了 contextmenu 這個事件,用以表示何時應該顯示上下文菜單,以便開發人員取消默認的上下文菜單而提供自定義的菜單。
beforeunload事件
之所以有發生在 window 對象上的 beforeunload 事件,是為了讓開發人員有可能在頁面卸載前阻止這一操作。這個事件會在瀏覽器卸載頁面之前觸發,可以通過它來取消卸載並繼續使用原有頁面。但是,不能徹底取消這個事件,因為那就相當於讓用戶無法離開當前頁面了。為此,這個事件的意圖是將控制權交給用戶。顯示的消息會告知用戶頁面行將被卸載(正因為如此才會顯示這個消息),詢問用戶是否真的要關閉頁面,還是希望繼續留下來。
DOMContentLoaded事件
如前所述, window 的 load 事件會在頁面中的一切都載入完畢時觸發,但這個過程可能會因為要載入的外部資源過多而頗費周折。而 DOMContentLoaded 事件則在形成完整的 DOM 樹之後就會觸發,不理會影像、 JavaScript 文件、 CSS 文件或其他資源是否已經下載完畢。與 load 事件不同,DOMContentLoaded 支援在頁面下載的早期添加事件處理程式,這也就意味著用戶能夠儘早地與頁面進行交互。
readystatechange事件
IE 為 DOM 文檔中的某些部分提供了 readystatechange 事件。這個事件的目的是提供與文檔或元素的載入狀態有關的資訊,但這個事件的行為有時候也很難預料。支援 readystatechange 事件的每個對象都有一個 readyState 屬性,可能包含下列 5 個值中的一個。
- uninitialized(未初始化):對象存在但尚未初始化。
- loading(正在載入):對象正在載入數據。
- loaded(載入完畢):對象載入數據完成。
- interactive(交互):可以操作對象了,但還沒有完全載入。
- complete(完成):對象已經載入完畢。
pageshow和pagehide事件
Firefox 和 Opera 有一個特性,名叫「往返快取」(back-forward cache,或 bfcache),可以在用戶使用瀏覽器的「後退」和「前進」按鈕時加快頁面的轉換速度。這個快取中不僅保存著頁面數據,還保存了 DOM 和 JavaScript 的狀態;實際上是將整個頁面都保存在了記憶體里。如果頁面位於 bfcache 中,那麼再次打開該頁面時就不會觸發 load 事件。儘管由於記憶體中保存了整個頁面的狀態,不觸發 load 事件也不應該會導致什麼問題,但為了更形象地說明 bfcache 的行為, Firefox 還是提供了一些新事件。
第一個事件就是 pageshow,這個事件在頁面顯示時觸發,無論該頁面是否來自 bfcache。在重新載入的頁面中, pageshow 會在 load 事件觸發後觸發;而對於 bfcache 中的頁面, pageshow 會在頁面狀態完全恢復的那一刻觸發。
hashchange事件
HTML5 新增了 hashchange 事件,以便在 URL 的參數列表(及 URL 中「#」號後面的所有字元串)發生變化時通知開發人員。之所以新增這個事件,是因為在 Ajax 應用中,開發人員經常要利用 URL 參數列表來保存狀態或導航資訊。
必須要把 hashchange 事件處理程式添加給 window 對象,然後 URL 參數列表只要變化就會調用它。此時的 event 對象應該額外包含兩個屬性: oldURL 和 newURL。這兩個屬性分別保存著參數列表變化前後的完整 URL。例如:
EventUtil.addHandler(window, "hashchange", function(event){
alert("Old URL: " + event.oldURL + "\nNew URL: " + event.newURL);
});
設備事件
orientationchange事件
蘋果公司為移動 Safari 中添加了 orientationchange 事件,以便開發人員能夠確定用戶何時將設備由橫向查看模式切換為縱向查看模式。
MozOrientation事件
Firefox 3.6 為檢測設備的方向引入了一個名為 MozOrientation 的新事件。(前綴 Moz 表示這是特定於瀏覽器開發商的事件,不是標準事件。)當設備的加速計檢測到設備方向改變時,就會觸發這個事件。
deviceorientation事件
本質上,DeviceOrientation Event 規範定義的 deviceorientation 事件與 MozOrientation 事件類似。它也是在加速計檢測到設備方向變化時在 window 對象上觸發,而且具有與 MozOrientation 事件相同的支援限制。
devicemotion事件
DeviceOrientation Event 規範還定義了一個 devicemotion 事件。這個事件是要告訴開發人員設備什麼時候移動,而不僅僅是設備方向如何改變。例如,通過 devicemotion 能夠檢測到設備是不是正在往下掉,或者是不是被走著的人拿在手裡。
觸摸與手勢事件
觸摸事件
包含 iOS 2.0 軟體的 iPhone 3G 發布時,也包含了一個新版本的 Safari 瀏覽器。這款新的移動 Safari提供了一些與觸摸(touch)操作相關的新事件。後來, Android 上的瀏覽器也實現了相同的事件。觸摸事件會在用戶手指放在螢幕上面時、在螢幕上滑動時或從螢幕上移開時觸發。
手勢事件
iOS 2.0 中的 Safari 還引入了一組手勢事件。當兩個手指觸控螢幕幕時就會產生手勢,手勢通常會改變顯示項的大小,或者旋轉顯示項。
記憶體和性能
首先,每個函數都是對象,都會佔用記憶體;記憶體中的對象越多,性能就越差。其次,必須事先指定所有事件處理程式而導致的 DOM 訪問次數,會延遲整個頁面的交互就緒時間。事實上,從如何利用好事件處理程式的角度出發,還是有一些方法能夠提升性能的。
事件委託
對「事件處理程式過多」問題的解決方案就是事件委託。事件委託利用了事件冒泡,只指定一個事件處理程式,就可以管理某一類型的所有事件。例如, click 事件會一直冒泡到 document 層次。也就是說,我們可以為整個頁面指定一個 onclick 事件處理程式,而不必給每個可單擊的元素分別添加事件處理程式。
以下面的 HTML 程式碼為例。
<ul id="myLinks">
<li id="goSomewhere">Go somewhere</li>
<li id="doSomething">Do something</li>
<li id="sayHi">Say hi</li>
</ul>
其中包含 3 個被單擊後會執行操作的列表項。按照傳統的做法,需要它們添加 3 個事件處理程式。
如果在一個複雜的 Web 應用程式中,對所有可單擊的元素都採用這種方式,那麼結果就會有數不清的程式碼用於添加事件處理程式。此時,可以利用事件委託技術解決這個問題。使用事件委託,只需在DOM 樹中盡量最高的層次上添加一個事件處理程式,如下面的例子所示。
var list = document.getElementById("myLinks");
EventUtil.addHandler(list, "click", function(event){
event = EventUtil.getEvent(event);
var target = EventUtil.getTarget(event);
switch(target.id){
case "doSomething":
document.title = "I changed the document's title";
break;
case "goSomewhere":
location.href = "//www.wrox.com";
break;
case "sayHi":
alert("hi");
break;
}
});
- document 對象很快就可以訪問,而且可以在頁面生命周期的任何時點上為它添加事件處理程式(無需等待 DOMContentLoaded 或 load 事件)。換句話說,只要可單擊的元素呈現在頁面上,就可以立即具備適當的功能。
- 在頁面中設置事件處理程式所需的時間更少。只添加一個事件處理程式所需的 DOM 引用更少,所花的時間也更少。
- 整個頁面佔用的記憶體空間更少,能夠提升整體性能。
最適合採用事件委託技術的事件包括 click、mousedown、mouseup、keydown、keyup 和 keypress。雖然 mouseover 和 mouseout 事件也冒泡,但要適當處理它們並不容易,而且經常需要計算元素的位置。(因為當滑鼠從一個元素移到其子節點時,或者當滑鼠移出該元素時,都會觸發 mouseout 事件。)
移除事件處理程式
在兩種情況下,可能會造成上述問題。第一種情況就是從文檔中移除帶有事件處理程式的元素時。這可能是通過純粹的 DOM 操作,例如使用 removeChild()和 replaceChild()方法,但更多地是發生在使用 innerHTML 替換頁面中某一部分的時候。如果帶有事件處理程式的元素被 innerHTML 刪除了,那麼原來添加到元素中的事件處理程式極有可能無法被當作垃圾回收。
<div id="myDiv">
<input type="button" value="Click Me" id="myBtn">
</div>
<script type="text/javascript">
var btn = document.getElementById("myBtn");
btn.onclick = function(){
//先執行某些操作
document.getElementById("myDiv").innerHTML = "Processing..."; //麻煩了!
};
</script>
在<div>元素上設置 innerHTML 可以把按鈕移走,但事件處理程式仍然與按鈕保持著引用關係。
<div id="myDiv">
<input type="button" value="Click Me" id="myBtn">
</div>
<script type="text/javascript">
var btn = document.getElementById("myBtn");
btn.onclick = function(){
//先執行某些操作
btn.onclick = null; //移除事件處理程式
document.getElementById("myDiv").innerHTML = "Processing...";
};
</script>
模擬事件
在測試 Web 應用程式,模擬觸發事件是一種極其有用的技術。
DOM中的事件模擬
可以在 document 對象上使用 createEvent()方法創建 event 對象。這個方法接收一個參數,即表示要創建的事件類型的字元串。在 DOM2 級中,所有這些字元串都使用英文複數形式,而在 DOM3級中都變成了單數。這個字元串可以是下列幾字元串之一。
- UIEvents:一般化的 UI 事件。 滑鼠事件和鍵盤事件都繼承自 UI 事件。 DOM3 級中是 UIEvent。
- MouseEvents:一般化的滑鼠事件。 DOM3 級中是 MouseEvent。
- MutationEvents:一般化的 DOM 變動事件。 DOM3 級中是 MutationEvent。
- HTMLEvents:一般化的 HTML 事件。沒有對應的 DOM3 級事件(HTML 事件被分散到其他類別中)
第14章 表單腳本
JavaScript 最初的一個應用,就是分擔伺服器處理表單的責任,打破處處依賴伺服器的局面。儘管目前的 Web 和 JavaScript 已經有了長足的發展,但 Web 表單的變化並不明顯。由於 Web 表單沒有為許多常見任務提供現成的解決手段,很多開發人員不僅會在驗證表單時使用 JavaScirpt,而且還增強了一些標準表單控制項的默認行為。
表單的基礎知識
在 HTML 中,表單是由<form>元素來表示的,而在 JavaScript 中,表單對應的則是 HTMLFormElement 類型。 HTMLFormElement 繼承了 HTMLElement,因而與其他 HTML 元素具有相同的默認屬性。不過, HTMLFormElement 也有它自己下列獨有的屬性和方法。
- acceptCharset:伺服器能夠處理的字符集;等價於 HTML 中的 accept-charset 特性。
- action:接受請求的 URL;等價於 HTML 中的 action 特性。
- elements:表單中所有控制項的集合(HTMLCollection)。
- enctype:請求的編碼類型;等價於 HTML 中的 enctype 特性。
- length:表單中控制項的數量。
- method:要發送的 HTTP 請求類型,通常是"get"或"post";等價於 HTML 的 method 特性。
- name:表單的名稱;等價於 HTML 的 name 特性。
- reset():將所有表單域重置為默認值。
- submit():提交表單。
- target:用於發送請求和接收響應的窗口名稱;等價於 HTML 的 target 特性。
取得<form>元素引用的方式有好幾種。其中最常見的方式就是將它看成與其他元素一樣,並為其添加 id 特性,然後再像下面這樣使用 getElementById()方法找到它
取得<form>元素引用的方式有好幾種。其中最常見的方式就是將它看成與其他元素一樣,並為其添加 id 特性,然後再像下面這樣使用 getElementById()方法找到它。
var form = document.getElementById("form1");
其次,通過 document.forms 可以取得頁面中所有的表單。在這個集合中,可以通過數值索引或name 值來取得特定的表單,如下面的例子所示。
var firstForm = document.forms[0]; //取得頁面中的第一個表單
var myForm = document.forms["form2"]; //取得頁面中名稱為"form2"的表單
用戶單擊提交按鈕或影像按鈕時,就會提交表單。使用<input>或<button>都可以定義提交按鈕,只要將其 type 特性的值設置為"submit"即可,而影像按鈕則是通過將<input>的 type 特性值設置為"image"來定義的。因此,只要我們單擊以下程式碼生成的按鈕,就可以提交表單。
<!-- 通用提交按鈕 -->
<input type="submit" value="Submit Form">
<!-- 自定義提交按鈕 -->
<button type="submit">Submit Form</button>
<!-- 影像按鈕 -->
<input type="image" src="graphic.gif">
只要表單中存在上面列出的任何一種按鈕,那麼在相應表單控制項擁有焦點的情況下,按回車鍵就可以提交該表單。(textarea 是一個例外,在文本區中回車會換行。)如果表單里沒有提交按鈕,按回車鍵不會提交表單。
以這種方式提交表單時,瀏覽器會在將請求發送給伺服器之前觸發 submit 事件。這樣,我們就有機會驗證表單數據,並據以決定是否允許表單提交。阻止這個事件的默認行為就可以取消表單提交。例如,下列程式碼會阻止表單提交。
var form = document.getElementById("myForm");
EventUtil.addHandler(form, "submit", function(event){
//取得事件對象
event = EventUtil.getEvent(event);
//阻止默認事件
EventUtil.preventDefault(event);
});
在 JavaScript 中,以編程方式調用 submit()方法也可以提交表單。而且,這種方式無需表單包含提交按鈕,任何時候都可以正常提交表單。來看一個例子。
var form = document.getElementById("myForm");
//提交表單
form.submit();
在以調用 submit()方法的形式提交表單時,不會觸發 submit 事件,因此要記得在調用此方法之前先驗證表單數據。
重置表單
在用戶單擊重置按鈕時,表單會被重置。使用 type 特性值為"reset"的<input>或<button>都可以創建重置按鈕,如下面的例子所示。
<!-- 通用重置按鈕 -->
<input type="reset" value="Reset Form">
<!-- 自定義重置按鈕 -->
<button type="reset">Reset Form</button>
這兩個按鈕都可以用來重置表單。在重置表單時,所有表單欄位都會恢復到頁面剛載入完畢時的初始值。如果某個欄位的初始值為空,就會恢復為空;而帶有默認值的欄位,也會恢復為默認值。
表單欄位
共有表單欄位屬性
表單欄位共有的屬性如下。
- disabled:布爾值,表示當前欄位是否被禁用。
- form:指向當前欄位所屬表單的指針;只讀。
- name:當前欄位的名稱。
- readOnly:布爾值,表示當前欄位是否只讀。
- tabIndex:表示當前欄位的切換(tab)序號。
- type:當前欄位的類型,如"checkbox"、 "radio",等等。
- value:當前欄位將被提交給伺服器的值。對文件欄位來說,這個屬性是只讀的,包含著文件在電腦中的路徑。
在涉及信用卡消費時,這就是個問題:因為會導致費用翻番。為此,最常見的解決方案,就是在第一次單擊後就禁用提交按鈕。只要偵聽 submit 事件,並在該事件發生時禁用提交按鈕即可。以下就是這樣一個例子。
//避免多次提交表單
EventUtil.addHandler(form, "submit", function(event){
event = EventUtil.getEvent(event);
var target = EventUtil.getTarget(event);
//取得提交按鈕
var btn = target.elements["submit-btn"];
//禁用它
btn.disabled = true;
});
以上程式碼為表單的 submit 事件添加了一個事件處理程式。事件觸發後,程式碼取得了提交按鈕並將其 disabled 屬性設置為 true。注意,不能通過 onclick 事件處理程式來實現這個功能,原因是不同瀏覽器之間存在「時差」:有的瀏覽器會在觸發表單的 submit 事件之前觸發 click 事件,而有的瀏覽器則相反。對於先觸發 click 事件的瀏覽器,意味著會在提交發生之前禁用按鈕,結果永遠都不會提交表單。
共有的表單欄位方法
每個表單欄位都有兩個方法: focus()和 blur()。其中, focus()方法用於將瀏覽器的焦點設置到表單欄位,即激活表單欄位,使其可以響應鍵盤事件。例如,接收到焦點的文本框會顯示插入符號,隨時可以接收輸入。使用 focus()方法,可以將用戶的注意力吸引到頁面中的某個部位。例如,在頁面載入完畢後,將焦點轉移到表單中的第一個欄位。為此,可以偵聽頁面的 load 事件,並在該事件發生時在表單的第一個欄位上調用 focus()方法,如下面的例子所示。
EventUtil.addHandler(window, "load", function(event){
document.forms[0].elements[0].focus();
});
與 focus()方法相對的是 blur()方法,它的作用是從元素中移走焦點。在調用 blur()方法時,並不會把焦點轉移到某個特定的元素上;僅僅是將焦點從調用這個方法的元素上面移走而已。
共有的表單欄位事件
除了支援滑鼠、鍵盤、更改和 HTML 事件之外,所有表單欄位都支援下列 3 個事件。
- blur:當前欄位失去焦點時觸發。
- change:對於<input>和<textarea>元素,在它們失去焦點且 value 值改變時觸發;對於<select>元素,在其選項改變時觸發。
- focus:當前欄位獲得焦點時觸發
對於<input>和<textarea>元素,當它們從獲得焦點到失去焦點且 value 值改變時,
才會觸發 change 事件。對於<select>元素,只要用戶選擇了不同的選項,就會觸發 change 事件;換句話說,不失去焦點也會觸發 change 事件。
文本框腳本
在 HTML 中,有兩種方式來表現文本框:一種是使用元素的單行文本框,另一種是使用<textarea>的多行文本框。
要表現文本框,必須將<input>元素的 type 特性設置為"text"。而通過設置 size 特性,可以指定文本框中能夠顯示的字元數。通過 value 特性,可以設置文本框的初始值,而 maxlength 特性則用於指定文本框可以接受的最大字元數。
選擇文本
在文本框獲得焦點時選擇其所有文本,這是一種非常常見的做法,特別是在文本框包含默認值的時候。因為這樣做可以讓用戶不必一個一個地刪除文本。
選擇(select)事件
與 select()方法對應的,是一個 select 事件。在選擇了文本框中的文本時,就會觸發 select事件。不過,到底什麼時候觸發 select 事件,還會因瀏覽器而異。另外,在調用 select()方法時也會觸發 select 事件。
取得選擇的文本
雖然通過 select 事件我們可以知道用戶什麼時候選擇了文本,但仍然不知道用戶選擇了什麼文本。HTML5 通過一些擴展方案解決了這個問題,以便更順利地取得選擇的文本。該規範採取的辦法是添加兩個屬性: selectionStart 和 selectionEnd。這兩個屬性中保存的是基於 0 的數值,表示所選擇文本的範圍(即文本選區開頭和結尾的偏移量)。因此,要取得用戶在文本框中選擇的文本,可以使用如下程式碼。
function getSelectedText(textbox){
return textbox.value.substring(textbox.selectionStart, textbox.selectionEnd);
}
因 為 substring() 方 法 基 於 字 符 串 的 偏 移 量 執 行 操 作 , 所 以 將 selectionStart 和selectionEnd 直接傳給它就可以取得選中的文本。
選擇部分文本
HTML5 也 為 選 擇 文 本 框 中 的 部 分 文 本 提 供 了 解 決 方 案 , 即 最 早 由 Firefox 引 入 的setSelectionRange()方法。現在除select()方法之外,所有文本框都有一個setSelectionRange()方法。這個方法接收兩個參數:要選擇的第一個字元的索引和要選擇的最後一個字元之後的字元的索引(類似於 substring()方法的兩個參數)。
過濾輸入
屏蔽字元
有時候,我們需要用戶輸入的文本中包含或不包含某些字元。例如,電話號碼中不能包含非數值字元。如前所述,響應向文本框中插入字元操作的是 keypress 事件。因此,可以通過阻止這個事件的默認行為來屏蔽此類字元。在極端的情況下,可以通過下列程式碼屏蔽所有按鍵操作。
EventUtil.addHandler(textbox, "keypress", function(event){
event = EventUtil.getEvent(event);
EventUtil.preventDefault(event);
});
如果只想屏蔽特定的字元,則需要檢測 keypress 事件對應的字元編碼,然後再決定如何響應。例如,下列程式碼只允許用戶輸入數值。
EventUtil.addHandler(textbox, "keypress", function(event){
event = EventUtil.getEvent(event);
var target = EventUtil.getTarget(event);
var charCode = EventUtil.getCharCode(event);
if (!/\d/.test(String.fromCharCode(charCode))){
EventUtil.preventDefault(event);
}
});
操作剪貼板
HTML 5 後來也把剪貼板事件納入了規範。下列就是 6 個剪貼板事件。
- beforecopy:在發生複製操作前觸發。
- copy:在發生複製操作時觸發。
- beforecut:在發生剪切操作前觸發。
- cut:在發生剪切操作時觸發。
- beforepaste:在發生粘貼操作前觸發。
- paste:在發生粘貼操作時觸發
自動切換焦點
使用 JavaScript 可以從多個方面增強表單欄位的易用性。其中,最常見的一種方式就是在用戶填寫完當前欄位時,自動將焦點切換到下一個欄位。通常,在自動切換焦點之前,必須知道用戶已經輸入了既定長度的數據(例如電話號碼)。
開始的 tabForward()函數是實現「自動切換焦點」的關鍵所在。這個函數通過比較用戶輸入的值與文本框的 maxlength 特性,可以確定是否已經達到最大長度。如果這兩個值相等(因為瀏覽器最終會強制它們相等,因此用戶絕不會多輸入字元),則需要查找表單欄位集合,直至找到下一個文本框。找到下一個文本框之後,則將焦點切換到該文本框。然後,我們把這個函數指定為每個文本框的 onkeyup事件處理程式。由於 keyup 事件會在用戶輸入了新字元之後觸發,所以此時是檢測文本框中內容長度的最佳時機。這樣一來,用戶在填寫這個簡單的表單時,就不必再通過按製表鍵切換表單欄位和提交表單了。
HTML5約束驗證API
為了在將表單提交到伺服器之前驗證數據,HTML5 新增了一些功能。
必填欄位
第一種情況是在表單欄位中指定了 required 屬性,如下面的例子所示:
<input type="text" name="username" required>
任何標註有 required 的欄位,在提交表單時都不能空著。這個屬性適用於<input>、 <textarea>和<select>欄位(Opera 11 及之前版本還不支援<select>的 required 屬性)。在 JavaScript 中,通過對應的 required 屬性,可以檢查某個表單欄位是否為必填欄位
其他輸入類型
HTML5 為<input>元素的 type 屬性又增加了幾個值。這些新的類型不僅能反映數據類型的資訊,而且還能提供一些默認的驗證功能。其中, "email"和"url"是兩個得到支援最多的類型,各瀏覽器也都為它們增加了訂製的驗證機制。
數值範圍
除了"email"和"url", HTML5 還定義了另外幾個輸入元素。這幾個元素都要求填寫某種基於數字的值: "number"、 "range"、 "datetime"、 "datetime-local"、 "date"、 "month"、 "week",還有"time"。瀏覽器對這幾個類型的支援情況並不好,因此如果真想選用的話,要特別小心。
輸入模式
HTML5 為文本欄位新增了 pattern 屬性。這個屬性的值是一個正則表達式,用於匹配文本框中的值。例如,如果只想允許在文本欄位中輸入數值,可以像下面的程式碼一樣應用約束:
<input type="text" pattern="\d+" name="count">
注意,模式的開頭和末尾不用加^和$符號(假定已經有了)。這兩個符號表示輸入的值必須從頭到尾都與模式匹配。
檢測有效性
使用 checkValidity()方法可以檢測表單中的某個欄位是否有效。所有表單欄位都有個方法,如果欄位的值有效,這個方法返回 true,否則返回 false。欄位的值是否有效的判斷依據是本節前面介紹過的那些約束。換句話說,必填欄位中如果沒有值就是無效的,而欄位中的值與 pattern 屬性不匹配也是無效的。
禁用驗證
通過設置 novalidate 屬性,可以告訴表單不進行驗證。
<form method="post" action="signup.php" novalidate>
<!--這裡插入表單元素-->
</form>
選擇框腳本
選擇框是通過<select>和<option>元素創建的。為了方便與這個控制項交互,除了所有表單欄位共有的屬性和方法外, HTMLSelectElement 類型還提供了下列屬性和方法。
- add(newOption, relOption):向控制項中插入新<option>元素,其位置在相關項(relOption)之前。
- multiple:布爾值,表示是否允許多項選擇;等價於 HTML 中的 multiple 特性。
- options:控制項中所有<option>元素的 HTMLCollection。
- remove(index):移除給定位置的選項。
- selectedIndex:基於 0 的選中項的索引,如果沒有選中項,則值為-1。對於支援多選的控制項,只保存選中項中第一項的索引。
- size:選擇框中可見的行數;等價於 HTML 中的 size 特性。
選擇框的 type 屬性不是"select-one",就是"select-multiple",這取決於 HTML 程式碼中有沒有 multiple 特性。選擇框的 value 屬性由當前選中項決定,相應規則如下。
- 如果沒有選中的項,則選擇框的 value 屬性保存空字元串。
- 如果有一個選中項,而且該項的 value 特性已經在 HTML 中指定,則選擇框的 value 屬性等於選中項的 value 特性。即使 value 特性的值是空字元串,也同樣遵循此條規則。
- 如果有一個選中項,但該項的 value 特性在 HTML 中未指定,則選擇框的 value 屬性等於該項的文本。
- 如果有多個選中項,則選擇框的 value 屬性將依據前兩條規則取得第一個選中項的值
添加選項
可以使用 JavaScript 動態創建選項,並將它們添加到選擇框中。添加選項的方式有很多,第一種方式就是使用如下所示的 DOM 方法。
var newOption = document.createElement("option");
newOption.appendChild(document.createTextNode("Option text"));
newOption.setAttribute("value", "Option value");
selectbox.appendChild(newOption);
var newOption = new Option("Option text", "Option value");
selectbox.add(newOption, undefined); //最佳方案
移除選項
與添加選項類似,移除選項的方式也有很多種。首先,可以使用 DOM 的 removeChild()方法,為其傳入要移除的選項。
可以使用選擇框的 remove()方法。這個方法接受一個參數,即要移除選項的索引。
就是將相應選項設置為 null。這種方式也是 DOM 出現之前瀏覽器的遺留機制。
移動和重排選項
在 DOM 標準出現之前,將一個選擇框中的選項移動到另一個選擇框中是非常麻煩的。整個過程要涉及從第一個選擇框中移除選項,然後以相同的文本和值創建新選項,最後再將新選項添加到第二個選擇框中。而使用 DOM 的 appendChild()方法,就可以將第一個選擇框中的選項直接移動到第二個選擇框中。我們知道,如果為 appendChild()方法傳入一個文檔中已有的元素,那麼就會先從該元素的父節點中移除它,再把它添加到指定的位置。下面的程式碼展示了將第一個選擇框中的第一個選項移動到第二個選擇框中的過程。
var selectbox1 = document.getElementById("selLocations1");
var selectbox2 = document.getElementById("selLocations2");
selectbox2.appendChild(selectbox1.options[0]);
重排選項次序的過程也十分類似,最好的方式仍然是使用 DOM 方法。要將選擇框中的某一項移動到特定位置,最合適的 DOM 方法就是 insertBefore(); appendChild()方法只適用於將選項添加到選擇框的最後。要在選擇框中向前移動一個選項的位置,可以使用以下程式碼:
var optionToMove = selectbox.options[1];
selectbox.insertBefore(optionToMove, selectbox.options[optionToMove.index-1]);
以上程式碼首先選擇了要移動的選項,然後將其插入到了排在它前面的選項之前。實際上,第二行程式碼對除第一個選項之外的其他選項是通用的。類似地,可以使用下列程式碼將選擇框中的選項向後移動一個位置。
var optionToMove = selectbox.options[1];
selectbox.insertBefore(optionToMove, selectbox.options[optionToMove.index+2]);
表單序列化
隨著 Ajax 的出現,表單序列化已經成為一種常見需求(第 21 章將討論 Ajax)。在 JavaScript 中,可以利用表單欄位的 type 屬性,連同 name 和 value 屬性一起實現對錶單的序列化。在編寫程式碼之前,有必須先搞清楚在表單提交期間,瀏覽器是怎樣將數據發送給伺服器的。
- 對錶單欄位的名稱和值進行 URL 編碼,使用和號(&)分隔。
- 不發送禁用的表單欄位。
- 只發送勾選的複選框和單選按鈕。
- 不發送 type 為"reset"和"button"的按鈕。
- 多選選擇框中的每個選中的值單獨一個條目。
- 在單擊提交按鈕提交表單的情況下,也會發送提交按鈕;否則,不發送提交按鈕。也包括 type為"image"的<input>元素。
- <select>元素的值,就是選中的<option>元素的 value 特性的值。如果<option>元素沒有value 特性,則是<option>元素的文本值。
富文本編輯
富文本編輯,又稱為 WYSIWYG(What You See Is What You Get,所見即所得)。在網頁中編輯富文本內容,是人們對 Web 應用程式最大的期待之一。雖然也沒有規範,但在 IE 最早引入的這一功能基礎上,已經出現了事實標準。而且, Opera、 Safari、 Chrome 和 Firefox 都已經支援這一功能。這一技術的本質,就是在頁面中嵌入一個包含空 HTML 頁面的 iframe。通過設置 designMode 屬性,這個空白的 HTML 頁面可以被編輯,而編輯對象則是該頁面<body>元素的 HTML 程式碼。 designMode 屬性有兩個可能的值: "off"(默認值)和"on"。在設置為"on"時,整個文檔都會變得可以編輯(顯示插入符號),然後就可以像使用字處理軟體一樣,通過鍵盤將文本內容加粗、變成斜體,等等。
第15章使用Canvas繪圖
HTML5 添加的最受歡迎的功能就是<canvas>元素。這個元素負責在頁面中設定一個區域,然後就可以通過 JavaScript 動態地在這個區域中繪製圖形。 <canvas>元素最早是由蘋果公司推出的,當時主要用在其 Dashboard 微件中。很快, HTML5 加入了這個元素。
基本用法
要使用<canvas>元素,必須先設置其 width 和 height 屬性,指定可以繪圖的區域大小。出現在開始和結束標籤中的內容是後備資訊,如果瀏覽器不支援<canvas>元素,就會顯示這些資訊。下面就是<canvas>元素的例子。
<canvas id="drawing" width=" 200" height="200">A drawing of something.</canvas>
與其他元素一樣, <canvas>元素對應的 DOM 元素對象也有 width 和 height 屬性,可以隨意修改。而且,也能通過 CSS 為該元素添加樣式,如果不添加任何樣式或者不繪製任何圖形,在頁面中是看不到該元素的。
ps:牛逼的技術,感覺用不到,跳過跳過
第16章 HTML5腳本編程
跨文檔消息傳遞
跨文檔消息傳送(cross-document messaging),有時候簡稱為 XDM,指的是在來自不同域的頁面間傳遞消息。例如, www.wrox.com 域中的頁面與位於一個內嵌框架中的 p2p.wrox.com 域中的頁面通訊。在 XDM 機制出現之前,要穩妥地實現這種通訊需要花很多工夫。 XDM 把這種機制規範化,讓我們能既穩妥又簡單地實現跨文檔通訊。
XDM 的核心是 postMessage()方法。在 HTML5 規範中,除了 XDM 部分之外的其他部分也會提到這個方法名,但都是為了同一個目的:向另一個地方傳遞數據。對於 XDM 而言, 「另一個地方」指的是包含在當前頁面中的<iframe>元素,或者由當前頁面彈出的窗口。
原生拖放
最早在網頁中引入 JavaScript 拖放功能的是 IE4。當時,網頁中只有兩種對象可以拖放:影像和某些文本。拖動影像時,把滑鼠放在影像上,按住滑鼠不放就可以拖動它。拖動文本時,要先選中文本,然後可以像拖動影像一樣拖動被選中的文本。在 IE 4 中,唯一有效的放置目標是文本框。到了 IE5,拖放功能得到擴展,添加了新的事件,而且幾乎網頁中的任何元素都可以作為放置目標。 IE5.5 更進一步,讓網頁中的任何元素都可以拖放。(IE6 同樣也支援這些功能。) HTML5 以 IE 的實例為基礎制定了拖放規範。 Firefox 3.5、 Safari 3+和 Chrome 也根據 HTML5 規範實現了原生拖放功能。
媒體元素
隨著音頻和影片在 Web 上的迅速流行,大多數提供富媒體內容的站點為了保證跨瀏覽器兼容性,不得不選擇使用 Flash。 HTML5 新增了兩個與媒體相關的標籤,讓開發人員不必依賴任何插件就能在網頁中嵌入跨瀏覽器的音頻和影片內容。這兩個標籤就是<audio>和<video>。
歷史狀態管理
歷史狀態管理是現代 Web 應用開發中的一個難點。在現代 Web 應用中,用戶的每次操作不一定會打開一個全新的頁面,因此「後退」和「前進」按鈕也就失去了作用,導致用戶很難在不同狀態間切換。要解決這個問題,首選使用 hashchange 事件(第 13 章曾討論過)。 HTML5 通過更新 history 對象為管理歷史狀態提供了方便。
第17章 錯誤處理與調試
瀏覽器報告的錯誤
IE、 Firefox、 Safari、 Chrome 和 Opera 等主流瀏覽器,都具有某種向用戶報告 JavaScript 錯誤的機制。默認情況下,所有瀏覽器都會隱藏此類資訊,畢竟除了開發人員之外,很少有人關心這些內容。因此,在基於瀏覽器編寫 JavaScript 腳本時,別忘了啟用瀏覽器的 JavaScript 報告功能,以便及時收到錯誤通知。
錯誤處理
try-catch語句
ECMA-262 第 3 版引入了 try-catch 語句,作為 JavaScript 中處理異常的一種標準方式。基本的語法如下所示,顯而易見,這與 Java 中的 try-catch 語句是完全相同的。
try{
// 可能會導致錯誤的程式碼
} catch(error){
// 在錯誤發生時怎麼處理
}
finally子句
雖然在 try-catch 語句中是可選的,但 finally 子句一經使用,其程式碼無論如何都會執行。換句話說, try 語句塊中的程式碼全部正常執行, finally 子句會執行;如果因為出錯而執行了 catch 語句塊, finally 子句照樣還會執行。只要程式碼中包含 finally 子句,則無論 try 或 catch 語句塊中包含什麼程式碼——甚至 return 語句,都不會阻止 finally 子句的執行。
錯誤類型
執行程式碼期間可能會發生的錯誤有多種類型。每種錯誤都有對應的錯誤類型,而當錯誤發生時,就會拋出相應類型的錯誤對象。 ECMA-262 定義了下列 7 種錯誤類型:
- Error
- EvalError
- RangeError
- ReferenceError
- SyntaxError
- TypeError
- URIError
利用不同的錯誤類型,可以獲悉更多有關異常的資訊,從而有助於對錯誤作出恰當的處理。要想知道錯誤的類型,可以像下面這樣在 try-catch 語句的 catch 語句中使用 instanceof 操作符。
try {
someFunction();
} catch (error){
if (error instanceof TypeError){
//處理類型錯誤
} else if (error instanceof ReferenceError){
//處理引用錯誤
} else {
//處理其他類型的錯誤
}
}
合理使用 try-catch
使用 try-catch 最適合處理那些我們無法控制的錯誤。假設你在使用一個大型 JavaScript 庫中的函數,該函數可能會有意無意地拋出一些錯誤。由於我們不能修改這個庫的源程式碼,所以大可將對該函數的調用放在 try-catch 語句當中,萬一有什麼錯誤發生,也好恰當地處理它們。
在明明白白地知道自己的程式碼會發生錯誤時,再使用 try-catch 語句就不太合適了。例如,如果傳遞給函數的參數是字元串而非數值,就會造成函數出錯,那麼就應該先檢查參數的類型,然後再決定如何去做。在這種情況下,不應用使用 try-catch 語句。
拋出錯誤
與 try-catch 語句相配的還有一個 throw 操作符,用於隨時拋出自定義錯誤。拋出錯誤時,必須要給 throw 操作符指定一個值,這個值是什麼類型,沒有要求。下列程式碼都是有效的。
throw 12345;
throw "Hello world!";
throw true;
throw { name: "JavaScript"};
拋出錯誤的時機
下面的函數會在參數不是數組的情況下失敗。
function process(values){
values.sort();
for (var i=0, len=values.length; i < len; i++){
if (values[i] > 100){
return values[i];
}
}
return -1;
}
如果執行這個函數時傳給它一個字元串參數,那麼對 sort()的調用就會失敗。對此,不同瀏覽器會給出不同的錯誤消息,但都不是特別明確,如下所示。
- IE:屬性或方法不存在。
- Firefox: values.sort()不是函數。
- Safari:值 undefined(表達式 values.sort 的結果)不是對象。
- Chrome:對象名沒有方法'sort'。
- Opera:類型不匹配(通常是在需要對象的地方使用了非對象值)。
儘管 Firefox、 Chrome 和 Safari 都明確指出了程式碼中導致錯誤的部分,但錯誤消息並沒有清楚地告訴我們到底出了什麼問題,該怎麼修復問題。在處理類似前面例子中的那個函數時,通過調試處理這些錯誤消息沒有什麼困難。但是,在面對包含數千行 JavaScript 程式碼的複雜的 Web 應用程式時,要想查找錯誤來源就沒有那麼容易了。這種情況下,帶有適當資訊的自定義錯誤能夠顯著提升程式碼的可維護性。來看下面的例子。
function process(values){
if (!(values instanceof Array)){
throw new Error("process(): Argument must be an array.");
}
values.sort();
for (var i=0, len=values.length; i < len; i++){
if (values[i] > 100){
return values[i];
}
}
return -1;
}
在重寫後的這個函數中,如果 values 參數不是數組,就會拋出一個錯誤。錯誤消息中包含了函數的名稱,以及為什麼會發生錯誤的明確描述。如果一個複雜的 Web 應用程式發生了這個錯誤,那麼查找問題的根源也就容易多了。
拋出錯誤與使用try-catch
關於何時該拋出錯誤,而何時該使用 try-catch 來捕獲它們,是一個老生常談的問題。一般來說,應用程式架構的較低層次中經常會拋出錯誤,但這個層次並不會影響當前執行的程式碼,因而錯誤通常得不到真正的處理。如果你打算編寫一個要在很多應用程式中使用的 JavaScript 庫,甚至只編寫一個可能會在應用程式內部多個地方使用的輔助函數,我都強烈建議你在拋出錯誤時提供詳盡的資訊。然後,即可在應用程式中捕獲並適當地處理這些錯誤。
說到拋出錯誤與捕獲錯誤,我們認為只應該捕獲那些你確切地知道該如何處理的錯誤。捕獲錯誤的目的在於避免瀏覽器以默認方式處理它們;而拋出錯誤的目的在於提供錯誤發生具體原因的消息
錯誤(error)事件
任何沒有通過 try-catch 處理的錯誤都會觸發 window 對象的 error 事件。這個事件是 Web 瀏覽器最早支援的事件之一, IE、 Firefox 和 Chrome 為保持向後兼容,並沒有對這個事件作任何修改(Opera和 Safari 不支援 error 事件)。在任何 Web 瀏覽器中, onerror 事件處理程式都不會創建 event 對象,但它可以接收三個參數:錯誤消息、錯誤所在的 URL 和行號。多數情況下,只有錯誤消息有用,因為URL 只是給出了文檔的位置,而行號所指的程式碼行既可能出自嵌入的 JavaScript 程式碼,也可能出自外部的文件。要指定 onerror 事件處理程式,必須使用如下所示的 DOM0 級技術,它沒有遵循「DOM2 級事件」的標準格式。
window.onerror = function(message, url, line){
alert(message);
};
常見的錯誤類型
錯誤處理的核心,是首先要知道程式碼里會發生什麼錯誤。由於 JavaScript 是鬆散類型的,而且也不會驗證函數的參數,因此錯誤只會在程式碼運行期間出現。一般來說,需要關注三種錯誤:
- 類型轉換錯誤
- 數據類型錯誤
- 通訊錯誤
以上錯誤分別會在特定的模式下或者沒有對值進行足夠的檢查的情況下發生。
類型轉換錯誤
容易發生類型轉換錯誤的另一個地方,就是流控制語句。像 if 之類的語句在確定下一步操作之前,會自動把任何值轉換成布爾值。尤其是 if 語句,如果使用不當,最容易出錯。來看下面的例子。
function concat(str1, str2, str3){
var result = str1 + str2;
if (str3){ //絕對不要這樣!!!
result += str3;
}
return result;
}
這個函數的用意是拼接兩或三個字元串,然後返回結果。其中,第三個字元串是可選的,因此必須要檢查。第 3 章曾經介紹過,未使用過的命名變數會自動被賦予 undefined 值。而 undefined 值可以被轉換成布爾值 false,因此這個函數中的 if 語句實際上只適用於提供了第三個參數的情況。問題在於,並不是只有 undefined 才會被轉換成 false,也不是只有字元串值才可以轉換為 true。例如,假設第三個參數是數值 0,那麼 if 語句的測試就會失敗,而對數值 1 的測試則會通過。
在流控制語句中使用非布爾值,是極為常見的一個錯誤來源。為避免此類錯誤,就要做到在條件比較時切實傳入布爾值。實際上,執行某種形式的比較就可以達到這個目的。例如,我們可以將前面的函數重寫如下。
function concat(str1, str2, str3){
var result = str1 + str2;
if (typeof str3 == "string"){ //恰當的比較
result += str3;
}
return result;
}
數據類型錯誤
JavaScript 是鬆散類型的,也就是說,在使用變數和函數參數之前,不會對它們進行比較以確保它們的數據類型正確。為了保證不會發生數據類型錯誤,只能依靠開發人員編寫適當的數據類型檢測程式碼。在將預料之外的值傳遞給函數的情況下,最容易發生數據類型錯誤。
//不安全的函數,任何非字元串值都會導致錯誤
function getQueryString(url){
var pos = url.indexOf("?");
if (pos > -1){
return url.substring(pos +1);
}
return "";
}
而添加一條簡單的類型檢測語句,就可以確保函數不那麼容易出錯。重寫後的這個函數首先檢查了傳入的值是不是字元串。這樣,就確保了函數不會因為接收到非字元串值而導致錯誤。
function getQueryString(url){
if (typeof url == "string"){ //通過檢查類型確保安全
var pos = url.indexOf("?");
if (pos > -1){
return url.substring(pos +1);
}
}
return "";
}
另一個常見的錯誤就是將參數與 null 值進行比較,如下所示。
//不安全的函數,任何非數組值都會導致錯誤
function reverseSort(values){
if (values != null){ //絕對不要這樣!!!
values.sort();
values.reverse();
}
}
與 null 進行比較只能確保相應的值不是 null 和 undefined(這就相當於使用相等和不相等操作)。要確保傳入的值有效,僅檢測 null 值是不夠的;因此,不應該使用這種技術。同樣,我們也不推薦將某個值與 undefined 作比較。另一種錯誤的做法,就是只針對要使用的某一個特性執行特性檢測。來看下面的例子。
//還是不安全,任何非數組值都會導致錯誤
function reverseSort(values){
if (typeof values.sort == "function"){ //絕對不要這樣!!!
values.sort();
values.reverse();
}
}
在這個例子中,程式碼首先檢測了參數中是否存在 sort()方法。這樣,如果傳入一個包含 sort()方法的對象(而不是數組)當然也會通過檢測,但在調用 reverse()函數時可能就會出錯了。在確切知道應該傳入什麼類型的情況下,最好是使用 instanceof 來檢測其數據類型,如下所示。
//安全,非數組值將被忽略
function reverseSort(values){
if (values instanceof Array){ //問題解決了
values.sort();
values.reverse();
}
}
通訊錯誤
隨著 Ajax 編程的興起(第 21 章討論 Ajax), Web 應用程式在其生命周期內動態載入資訊或功能,已經成為一件司空見慣的事。不過, JavaScript 與伺服器之間的任何一次通訊,都有可能會產生錯誤。
第一種通訊錯誤與格式不正確的 URL 或發送的數據有關。最常見的問題是在將數據發送給伺服器之前,沒有使用 encodeURIComponent()對數據進行編碼。 例如,下面這個 URL 的格式就是不正確的。
區分致命錯誤和非致命錯誤
區分非致命錯誤和致命錯誤的主要依據,就是看它們對用戶的影響。設計良好的程式碼,可以做到應用程式某一部分發生錯誤不會不必要地影響另一個實際上毫不相干的部分。
把錯誤記錄到伺服器
開發 Web 應用程式過程中的一種常見的做法,就是集中保存錯誤日誌,以便查找重要錯誤的原因。例如資料庫和伺服器錯誤都會定期寫入日誌,而且會按照常用 API 進行分類。在複雜的 Web 應用程式中,我們同樣推薦你把 JavaScript 錯誤也回寫到伺服器。換句話說,也要將這些錯誤寫入到保存伺服器端錯誤的地方,只不過要標明它們來自前端。把前後端的錯誤集中起來,能夠極大地方便對數據的分析。
第18章 JavaScript與XML
瀏覽器對XML DOM的支援
DOMParser類型
這個方法接受兩個參數:要解析的 XML 字元串和內容類型(內容類型始終都應該是"text/xml")。返回的值是一個 Document 的實例。來看下面的例子。
var parser = new DOMParser();
var xmldom = parser.parseFromString("<root><child/></root>", "text/xml");
alert(xmldom.documentElement.tagName); //"root"
alert(xmldom.documentElement.firstChild.tagName); //"child"
var anotherChild = xmldom.createElement("child");
xmldom.documentElement.appendChild(anotherChild);
var children = xmldom.getElementsByTagName("child");
alert(children.length); //2
DOMParser 只能解析格式良好的 XML,因而不能把 HTML 解析為 HTML 文檔。在發生解析錯誤時,仍然會從 parseFromString()中返回一個 Document 對象,但這個對象的文檔元素是<parsererror>,而文檔元素的內容是對解析錯誤的描述。
XMLSerializer類型
在引入 DOMParser 的同時, Firefox 還引入了 XMLSerializer 類型,提供了相反的功能:將 DOM文檔序列化為 XML 字元串。
var serializer = new XMLSerializer();
var xml = serializer.serializeToString(xmldom);
alert(xml);
XMLSerializer 可以序列化任何有效的 DOM 對象,不僅包括個別的節點,也包括 HTML 文檔。將 HTML 文檔傳入 serializeToString()以後, HTML 文檔將被視為 XML 文檔,因此得到的程式碼也將是格式良好的。
瀏覽器對XPath的支援
XPath 是設計用來在 DOM 文檔中查找節點的一種手段,因而對 XML 處理也很重要。但是, DOM3級以前的標準並沒有就 XPath 的 API 作出規定; XPath 是在 DOM3 級 XPath 模組中首次躋身推薦標準行列的。
瀏覽器對XSLT的支援
XSLT 是與 XML 相關的一種技術,它利用 XPath 將文檔從一種表現形式轉換成另一種表現形式。與XML 和 XPath 不同, XSLT 沒有正式的 API,在正式的 DOM 規範中也沒有它的位置。結果,只能依靠瀏覽器開發商以自己的方式來實現它。
第20章 JSON
關於 JSON,最重要的是要理解它是一種數據格式,不是一種程式語言。雖然具有相同的語法形式,但 JSON 並不從屬於 JavaScript。而且,並不是只有 JavaScript 才使用 JSON,畢竟 JSON 只是一種數據格式。很多程式語言都有針對 JSON 的解析器和序列化器。
JSON 的語法可以表示以下三種類型的值。
- 簡單值:使用與 JavaScript 相同的語法,可以在 JSON 中表示字元串、數值、布爾值和 null。但 JSON 不支援 JavaScript 中的特殊值 undefined。
- 對象:對象作為一種複雜數據類型,表示的是一組無序的鍵值對兒。而每個鍵值對兒中的值可以是簡單值,也可以是複雜數據類型的值。
- 數組:數組也是一種複雜數據類型,表示一組有序的值的列表,可以通過數值索引來訪問其中的值。數組的值也可以是任意類型——簡單值、對象或數組。
解析與序列化
JSON對象
JSON 對象有兩個方法: stringify()和 parse()。在最簡單的情況下,這兩個方法分別用於把JavaScript 對象序列化為 JSON 字元串和把 JSON 字元串解析為原生 JavaScript 值。例如:
var book = {
title: "Professional JavaScript",
authors: [
"Nicholas C. Zakas"
],
edition: 3,
year: 2011
};
var jsonText = JSON.stringify(book);
將 JSON 字元串直接傳遞給 JSON.parse()就可以得到相應的 JavaScript 值。例如,使用下列程式碼就可以創建與 book 類似的對象:
var bookCopy = JSON.parse(jsonText);
注意,雖然 book 與 bookCopy 具有相同的屬性,但它們是兩個獨立的、沒有任何關係的對象。如果傳給 JSON.parse()的字元串不是有效的 JSON,該方法會拋出錯誤。
序列化選項
實際上, JSON.stringify()除了要序列化的 JavaScript 對象外,還可以接收另外兩個參數,這兩個參數用於指定以不同的方式序列化 JavaScript 對象。第一個參數是個過濾器,可以是一個數組,也可以是一個函數;第二個參數是一個選項,表示是否在 JSON 字元串中保留縮進。單獨或組合使用這兩個參數,可以更全面深入地控制 JSON 的序列化。
過濾結果
JSON.stringify()的第二個參數是一個數組,其中包含兩個字元串: "title"和"edition"。這兩個屬性與將要序列化的對象中的屬性是對應的,因此在返回的結果字元串中,就只會包含這兩個屬性。
var book = {
"title": "Professional JavaScript",
"authors": [
"Nicholas C. Zakas"
],
edition: 3,
year: 2011
};
var jsonText = JSON.stringify(book, function(key, value){
switch(key){
case "authors":
return value.join(",")
case "year":
return 5000;
case "edition":
return undefined;
default:
return value;
}
});
函數過濾器根據傳入的鍵來決定結果。如果鍵為"authors",就將數組連接為一個字元串;如果鍵為"year",則將其值設置為 5000;如果鍵為"edition",通過返回 undefined 刪除該屬性。最後,一定要提供 default 項,此時返回傳入的值,以便其他值都能正常出現在結果中。
字元串縮進
JSON.stringify()方法的第三個參數用於控制結果中的縮進和空白符。如果這個參數是一個數值,那它表示的是每個級別縮進的空格數。
toJSON()方法
有時候, JSON.stringify()還是不能滿足對某些對象進行自定義序列化的需求。在這些情況下,
可以給對象定義 toJSON()方法,返回其自身的 JSON 數據格式。
var book = {
"title": "Professional JavaScript",
"authors": [
"Nicholas C. Zakas"
],
edition: 3,
year: 2011,
toJSON: function(){
return this.title;
}
};
var jsonText = JSON.stringify(book);
以上程式碼在 book 對象上定義了一個 toJSON()方法,該方法返回圖書的書名。與 Date 對象類似,這個對象也將被序列化為一個簡單的字元串而非對象。可以讓 toJSON()方法返回任何值,它都能正常工作。
toJSON()可以作為函數過濾器的補充,因此理解序列化的內部順序十分重要。假設把一個對象傳入 JSON.stringify(),序列化該對象的順序如下。
- 如果存在 toJSON()方法而且能通過它取得有效的值,則調用該方法。否則,返回對象本身。
- 如果提供了第二個參數,應用這個函數過濾器。傳入函數過濾器的值是第(1)步返回的值。
- 對第(2)步返回的每個值進行相應的序列化。
- 如果提供了第三個參數,執行相應的格式化。
解析選項
JSON.parse()方法也可以接收另一個參數,該參數是一個函數,將在每個鍵值對兒上調用。為了區別 JSON.stringify()接收的替換(過濾)函數(replacer),這個函數被稱為還原函數(reviver),但實際上這兩個函數的簽名是相同的——它們都接收兩個參數,一個鍵和一個值,而且都需要返回一個值。
第21章 Ajax 與 Comet
2005 年, Jesse James Garrett 發表了一篇在線文章,題為「Ajax: A new Approach to WebApplications」。他在這篇文章里介紹了一種技術,用他的話說,就叫 Ajax,是對 Asynchronous JavaScript + XML 的簡寫。這一技術能夠向伺服器請求額外的數據而無須卸載頁面,會帶來更好的用戶體驗。 Garrett 還解釋了怎樣使用這一技術改變自從 Web 誕生以來就一直沿用的「單擊,等待」的交互模式。
Ajax 技術的核心是 XMLHttpRequest 對象(簡稱 XHR),這是由微軟首先引入的一個特性,其他瀏覽器提供商後來都提供了相同的實現。在 XHR 出現之前, Ajax 式的通訊必須藉助一些 hack 手段來實現,大多數是使用隱藏的框架或內嵌框架。 XHR 為向伺服器發送請求和解析伺服器響應提供了流暢的介面。能夠以非同步方式從伺服器取得更多資訊,意味著用戶單擊後,可以不必刷新頁面也能取得新數據。也就是說,可以使用 XHR 對象取得新數據,然後再通過 DOM 將新數據插入到頁面中。另外,雖然名字中包含 XML 的成分,但 Ajax 通訊與數據格式無關;這種技術就是無須刷新頁面即可從伺服器取得數據,但不一定是 XML 數據。
XMLHttpRequest 對象
function createXHR(){
if (typeof XMLHttpRequest != "undefined"){
return new XMLHttpRequest();
} else if (typeof ActiveXObject != "undefined"){
if (typeof arguments.callee.activeXString != "string"){
var versions = [ "MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0",
"MSXML2.XMLHttp"],
i, len;
for (i=0,len=versions.length; i < len; i++){
try {
new ActiveXObject(versions[i]);
arguments.callee.activeXString = versions[i];
break;
} catch (ex){
//跳過
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
} else {
throw new Error("No XHR object available.");
}
}
這個函數中新增的程式碼首先檢測原生 XHR 對象是否存在,如果存在則返回它的新實例。如果原生對象不存在,則檢測 ActiveX 對象。如果這兩種對象都不存在,就拋出一個錯誤。然後,就可以使用下面的程式碼在所有瀏覽器中創建 XHR 對象了。
var xhr = createXHR();
XHR的用法
在使用 XHR 對象時,要調用的第一個方法是 open(),它接受 3 個參數:要發送的請求的類型("get"、 "post"等)、請求的 URL 和表示是否非同步發送請求的布爾值。下面就是調用這個方法的例子。
xhr.open("get", "example.php", false);
這行程式碼會啟動一個針對 example.php 的 GET 請求。有關這行程式碼,需要說明兩點:一是 URL相對於執行程式碼的當前頁面(當然也可以使用絕對路徑);二是調用 open()方法並不會真正發送請求,而只是啟動一個請求以備發送。
只能向同一個域中使用相同埠和協議的 URL 發送請求。如果 URL 與啟動請求的頁面有任何差別,都會引發安全錯誤。
要發送特定的請求,必須像下面這樣調用 send()方法:
xhr.open("get", "example.txt", false);
xhr.send(null);
這裡的 send()方法接收一個參數,即要作為請求主體發送的數據。如果不需要通過請求主體發送數據,則必須傳入 null,因為這個參數對有些瀏覽器來說是必需的。調用 send()之後,請求就會被分派到伺服器。
由於這次請求是同步的, JavaScript 程式碼會等到伺服器響應之後再繼續執行。在收到響應後,響應的數據會自動填充 XHR 對象的屬性,相關的屬性簡介如下。
- responseText:作為響應主體被返回的文本。
- responseXML:如果響應的內容類型是"text/xml"或"application/xml",這個屬性中將保存包含著響應數據的 XML DOM 文檔。
- status:響應的 HTTP 狀態。
- statusText: HTTP 狀態的說明。
在接收到響應後,第一步是檢查 status 屬性,以確定響應已經成功返回。一般來說,可以將 HTTP狀態程式碼為 200 作為成功的標誌。此時, responseText 屬性的內容已經就緒,而且在內容類型正確的情況下, responseXML 也應該能夠訪問了。此外,狀態程式碼為 304 表示請求的資源並沒有被修改,可以直接使用瀏覽器中快取的版本;當然,也意味著響應是有效的。為確保接收到適當的響應,應該像下面這樣檢查上述這兩種狀態程式碼。
xhr.open("get", "example.txt", false);
xhr.send(null);
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
根據返回的狀態程式碼,這個例子可能會顯示由伺服器返回的內容,也可能會顯示一條錯誤消息。我們建議讀者要通過檢測 status 來決定下一步的操作,不要依賴 statusText,因為後者在跨瀏覽器使用時不太可靠。另外,無論內容類型是什麼,響應主體的內容都會保存到 responseText 屬性中;而對於非 XML 數據而言, responseXML 屬性的值將為 null。
像前面這樣發送同步請求當然沒有問題,但多數情況下,我們還是要發送非同步請求,才能讓JavaScript 繼續執行而不必等待響應。此時,可以檢測 XHR 對象的 readyState 屬性,該屬性表示請求/響應過程的當前活動階段。這個屬性可取的值如下。
var xhr = createXHR();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4){
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
}
};
xhr.open("get", "example.txt", true);
xhr.send(null);
另外,在接收到響應之前還可以調用 abort()方法來取消非同步請求,如下所示:
xhr.abort();
調用這個方法後, XHR 對象會停止觸發事件,而且也不再允許訪問任何與響應有關的對象屬性。在終止請求之後,還應該對 XHR 對象進行解引用操作。由於記憶體原因,不建議重用 XHR 對象。
- 0:未初始化。尚未調用 open()方法。
- 1:啟動。已經調用 open()方法,但尚未調用 send()方法。
- 2:發送。已經調用 send()方法,但尚未接收到響應。
- 3:接收。已經接收到部分響應數據。
- 4:完成。已經接收到全部響應數據,而且已經可以在客戶端使用了。
只要 readyState 屬性的值由一個值變成另一個值,都會觸發一次 readystatechange 事件。可以利用這個事件來檢測每次狀態變化後 readyState 的值。通常,我們只對 readyState 值為 4 的階
段感興趣,因為這時所有數據都已經就緒。不過,必須在調用 open()之前指定onreadystatechange事件處理程式才能確保跨瀏覽器兼容性。
HTTP 頭部資訊
默認情況下,在發送 XHR 請求的同時,還會發送下列頭部資訊。
- Accept:瀏覽器能夠處理的內容類型。
- Accept-Charset:瀏覽器能夠顯示的字符集。
- Accept-Encoding:瀏覽器能夠處理的壓縮編碼。
- Accept-Language:瀏覽器當前設置的語言。
- Connection:瀏覽器與伺服器之間連接的類型。
- Cookie:當前頁面設置的任何 Cookie。
- Host:發出請求的頁面所在的域 。
- Referer:發出請求的頁面的 URI。注意, HTTP 規範將這個頭部欄位拼寫錯了,而為保證與規範一致,也只能將錯就錯了。(這個英文單詞的正確拼法應該是 referrer。)
- User-Agent:瀏覽器的用戶代理字元串。
使用 setRequestHeader()方法可以設置自定義的請求頭部資訊。這個方法接受兩個參數:頭部欄位的名稱和頭部欄位的值。要成功發送請求頭部資訊,必須在調用 open()方法之後且調用 send()方法之前調用 setRequestHeader()。
調用 XHR 對象的 getResponseHeader()方法並傳入頭部欄位名稱,可以取得相應的響應頭部資訊。而調用 getAllResponseHeaders()方法則可以取得一個包含所有頭部資訊的長字元串。
GET請求
GET 是最常見的請求類型,最常用於向伺服器查詢某些資訊。必要時,可以將查詢字元串參數追加到 URL 的末尾,以便將資訊發送給伺服器。對 XHR 而言,位於傳入 open()方法的 URL 末尾的查詢字元串必須經過正確的編碼才行。
使用 GET 請求經常會發生的一個錯誤,就是查詢字元串的格式有問題。查詢字元串中每個參數的名稱和值都必須使用 encodeURIComponent()進行編碼,然後才能放到 URL 的末尾;而且所有名-值對兒都必須由和號(&)分隔。
下面這個函數可以輔助向現有 URL 的末尾添加查詢字元串參數:
function addURLParam(url, name, value) {
url += (url.indexOf("?") == -1 ? "?" : "&");
url += encodeURIComponent(name) + "=" + encodeURIComponent(value);
return url;
}
這個 addURLParam()函數接受三個參數:要添加參數的 URL、參數的名稱和參數的值。這個函數首先檢查 URL 是否包含問號(以確定是否已經有參數存在)。如果沒有,就添加一個問號;否則,就添加一個和號。然後,將參數名稱和值進行編碼,再添加到 URL 的末尾。最後返回添加參數之後的 URL。
下面是使用這個函數來構建請求 URL 的示例。
var url = "example.php";
//添加參數
url = addURLParam(url, "name", "Nicholas");
url = addURLParam(url, "book", "Professional JavaScript");
//初始化請求
xhr.open("get", url, false);
在這裡使用 addURLParam()函數可以確保查詢字元串的格式良好,並可靠地用於 XHR 對象。
POST請求
默認情況下,伺服器對 POST 請求和提交 Web 表單的請求並不會一視同仁。因此,伺服器端必須有程式來讀取發送過來的原始數據,並從中解析出有用的部分。不過,我們可以使用 XHR 來模仿表單提交:首先將 Content-Type 頭部資訊設置為 application/x-www-form-urlencoded,也就是表單提交時的內容類型,其次是以適當的格式創建一個字元串。第 14 章曾經討論過, POST 數據的格式與查詢字元串格式相同。如果需要將頁面中表單的數據進行序列化,然後再通過 XHR 發送到伺服器,那麼就可以使用第 14 章介紹的 serialize()函數來創建這個字元串:
function submitData(){
var xhr = createXHR();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4){
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
}
};
xhr.open("post", "postexample.php", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
var form = document.getElementById("user-info");
xhr.send(serialize(form));
}
XMLHttpRequest 2級
鑒於 XHR 已經得到廣泛接受,成為了事實標準, W3C 也著手制定相應的標準以規範其行為。XMLHttpRequest 1 級只是把已有的 XHR 對象的實現細節描述了出來。而 XMLHttpRequest 2 級則進一步發展了 XHR。並非所有瀏覽器都完整地實現了 XMLHttpRequest 2 級規範,但所有瀏覽器都實現了它規定的部分內容。
FormData
下面的程式碼創建了一個 FormData 對象,並向其中添加了一些數據。
var data = new FormData();
data.append("name", "Nicholas");
這個 append()方法接收兩個參數:鍵和值,分別對應表單欄位的名字和欄位中包含的值。可以像這樣添加任意多個鍵值對兒。而通過向 FormData 構造函數中傳入表單元素,也可以用表單元素的數據預先向其中填入鍵值對兒:
var data = new FormData(document.forms[0]);
創建了 FormData 的實例後,可以將它直接傳給 XHR 的 send()方法,如下所示:
var xhr = createXHR();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4){
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
}
};
xhr.open("post","postexample.php", true);
var form = document.getElementById("user-info");
xhr.send(new FormData(form));
使用 FormData 的方便之處體現在不必明確地在 XHR 對象上設置請求頭部。 XHR 對象能夠識別傳入的數據類型是 FormData 的實例,並配置適當的頭部資訊。
超時設定
IE8 為 XHR 對象添加了一個 timeout 屬性,表示請求在等待響應多少毫秒之後就終止。在給timeout 設置一個數值後,如果在規定的時間內瀏覽器還沒有接收到響應,那麼就會觸發 timeout 事件,進而會調用 ontimeout 事件處理程式。這項功能後來也被收入了 XMLHttpRequest 2 級規範中。來看下面的例子。
var xhr = createXHR();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4){
try {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
} catch (ex){
//假設由 ontimeout 事件處理程式處理
}
}
};
xhr.open("get", "timeout.php", true);
xhr.timeout = 1000; //將超時設置為 1 秒鐘(僅適用於 IE8+)
xhr.ontimeout = function(){
alert("Request did not return in a second.");
};
xhr.send(null);
overrideMimeType()方法
Firefox 最早引入了 overrideMimeType()方法,用於重寫 XHR 響應的 MIME 類型。這個方法後來也被納入了 XMLHttpRequest 2 級規範。因為返迴響應的 MIME 類型決定了 XHR 對象如何處理它,所以提供一種方法能夠重寫伺服器返回的 MIME 類型是很有用的。比如,伺服器返回的 MIME 類型是 text/plain,但數據中實際包含的是 XML。根據 MIME 類型,即使數據是 XML, responseXML 屬性中仍然是 null。通過調用 overrideMimeType()方法,可以保證把響應當作 XML 而非純文本來處理。
var xhr = createXHR();
xhr.open("get", "text.php", true);
xhr.overrideMimeType("text/xml");
xhr.send(null);
這個例子強迫 XHR 對象將響應當作 XML 而非純文本來處理。調用 overrideMimeType()必須在send()方法之前,才能保證重寫響應的 MIME 類型。
進度事件
Progress Events 規範是 W3C 的一個工作草案,定義了與客戶端伺服器通訊有關的事件。這些事件最早其實只針對 XHR 操作,但目前也被其他 API 借鑒。有以下 6 個進度事件。
- loadstart:在接收到響應數據的第一個位元組時觸發。
- progress:在接收響應期間持續不斷地觸發。
- error:在請求發生錯誤時觸發。
- abort:在因為調用 abort()方法而終止連接時觸發。
- load:在接收到完整的響應數據時觸發。
- loadend:在通訊完成或者觸發 error、 abort 或 load 事件後觸發。
每個請求都從觸發 loadstart 事件開始,接下來是一或多個 progress 事件,然後觸發 error、abort 或 load 事件中的一個,最後以觸發 loadend 事件結束。目前還沒有瀏覽器支援 loadend 事件。
load事件
只要瀏覽器接收到伺服器的響應,不管其狀態如何,都會觸發 load 事件。而這意味著你必須要檢查 status 屬性,才能確定數據是否真的已經可用了。
var xhr = createXHR();
xhr.onload = function(){
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
};
xhr.open("get", "altevents.php", true);
xhr.send(null);
progress 事件
Mozilla 對 XHR 的另一個革新是添加了 progress 事件,這個事件會在瀏覽器接收新數據期間周期性地觸發。而 onprogress 事件處理程式會接收到一個 event 對象,其 target 屬性是 XHR 對象,但包含著三個額外的屬性: lengthComputable、 position 和 totalSize。其中, lengthComputable是一個表示進度資訊是否可用的布爾值, position 表示已經接收的位元組數, totalSize 表示根據Content-Length 響應頭部確定的預期位元組數。有了這些資訊,我們就可以為用戶創建一個進度指示器了。下面展示了為用戶創建進度指示器的一個示例。
var xhr = createXHR();
xhr.onload = function(event){
if ((xhr.status >= 200 && xhr.status < 300) ||
xhr.status == 304){
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
};
xhr.onprogress = function(event){
var divStatus = document.getElementById("status");
if (event.lengthComputable){
divStatus.innerHTML = "Received " + event.position + " of " +
event.totalSize +" bytes";
}
};
xhr.open("get", "altevents.php", true);
xhr.send(null);
跨源資源共享
通過 XHR 實現 Ajax 通訊的一個主要限制,來源於跨域安全策略。默認情況下, XHR 對象只能訪問與包含它的頁面位於同一個域中的資源。這種安全策略可以預防某些惡意行為。但是,實現合理的跨域請求對開發某些瀏覽器應用程式也是至關重要的。
CORS(Cross-Origin Resource Sharing,跨源資源共享)是 W3C 的一個工作草案,定義了在必須訪問跨源資源時,瀏覽器與伺服器應該如何溝通。 CORS 背後的基本思想,就是使用自定義的 HTTP 頭部讓瀏覽器與伺服器進行溝通,從而決定請求或響應是應該成功,還是應該失敗。
比如一個簡單的使用 GET 或 POST 發送的請求,它沒有自定義的頭部,而主體內容是 text/plain。在發送該請求時,需要給它附加一個額外的 Origin 頭部,其中包含請求頁面的源資訊(協議、域名和埠),以便伺服器根據這個頭部資訊來決定是否給予響應。下面是 Origin 頭部的一個示例:
Origin: //www.nczonline.net
如果伺服器認為這個請求可以接受,就在 Access-Control-Allow-Origin 頭部中回發相同的源
資訊(如果是公共資源,可以回發"*")。例如:
Access-Control-Allow-Origin: //www.nczonline.net
如果沒有這個頭部,或者有這個頭部但源資訊不匹配,瀏覽器就會駁回請求。正常情況下,瀏覽器會處理請求。注意,請求和響應都不包含 cookie 資訊
IE對CORS的實現
微軟在 IE8 中引入了 XDR(XDomainRequest)類型。這個對象與 XHR 類似,但能實現安全可靠的跨域通訊。 XDR 對象的安全機制部分實現了 W3C 的 CORS 規範。以下是 XDR 與 XHR 的一些不同之處。
- cookie 不會隨請求發送,也不會隨響應返回。
- 只能設置請求頭部資訊中的 Content-Type 欄位。
- 不能訪問響應頭部資訊。
- 只支援 GET 和 POST 請求。
這些變化使 CSRF(Cross-Site Request Forgery,跨站點請求偽造)和 XSS(Cross-Site Scripting,跨站點腳本)的問題得到了緩解。被請求的資源可以根據它認為合適的任意數據(用戶代理、來源頁面等)來決定是否設置 Access-Control- Allow-Origin 頭部。作為請求的一部分, Origin 頭部的值表示請求的來源域,以便遠程資源明確地識別 XDR 請求。
其他瀏覽器對CORS的實現
Firefox 3.5+、 Safari 4+、 Chrome、 iOS 版 Safari 和 Android 平台中的 WebKit 都通過 XMLHttpRequest對象實現了對 CORS 的原生支援。在嘗試打開不同來源的資源時,無需額外編寫程式碼就可以觸發這個行為。 要請求位於另一個域中的資源,使用標準的 XHR 對象並在 open()方法中傳入絕對 URL 即可,例如:
var xhr = createXHR();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4){
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
}
};
xhr.open("get", "//www.somewhere-else.com/page/", true);
xhr.send(null);
Preflighted Requests
CORS 通過一種叫做 Preflighted Requests 的透明伺服器驗證機制支援開發人員使用自定義的頭部、GET 或 POST 之外的方法,以及不同類型的主體內容。在使用下列高級選項來發送請求時,就會向伺服器發送一個 Preflight 請求。這種請求使用 OPTIONS 方法,發送下列頭部。
- Origin:與簡單的請求相同。
- Access-Control-Request-Method:請求自身使用的方法。
- Access-Control-Request-Headers:(可選)自定義的頭部資訊,多個頭部以逗號分隔。以下是一個帶有自定義頭部 NCZ 的使用 POST 方法發送的請求。
帶憑據的請求
默 認 情 況 下, 跨 源 請 求不 提 供 憑 據(cookie 、 HTTP 認 證 及 客戶 端 SSL 證明 等 )。 通 過 將withCredentials 屬性設置為 true,可以指定某個請求應該發送憑據。
如果發送的是帶憑據的請求,但伺服器的響應中沒有包含這個頭部,那麼瀏覽器就不會把響應交給JavaScript(於是, responseText 中將是空字元串, status 的值為 0,而且會調用 onerror()事件處理程式)。
跨瀏覽器的CORS
即使瀏覽器對 CORS 的支援程度並不都一樣,但所有瀏覽器都支援簡單的(非 Preflight 和不帶憑據的)請求,因此有必要實現一個跨瀏覽器的方案。檢測 XHR 是否支援 CORS 的最簡單方式,就是檢查是否存在 withCredentials 屬性。再結合檢測 XDomainRequest 對象是否存在,就可以兼顧所有瀏覽器了。
function createCORSRequest(method, url){
var xhr = new XMLHttpRequest();
if ("withCredentials" in xhr){
xhr.open(method, url, true);
} else if (typeof XDomainRequest != "undefined"){
vxhr = new XDomainRequest();
xhr.open(method, url);
} else {
xhr = null;
}
return xhr;
}
var request = createCORSRequest("get", "//www.somewhere-else.com/page/");
if (request){
request.onload = function(){
//對 request.responseText 進行處理
};
request.send();
}
其他跨域技術
在 CORS 出現以前,要實現跨域 Ajax 通訊頗費一些周折。開發人員想出了一些辦法,利用 DOM 中能夠執行跨域請求的功能,在不依賴 XHR 對象的情況下也能發送某種請求。
影像ping
上述第一種跨域請求技術是使用標籤。我們知道,一個網頁可以從任何網頁中載入影像,不用擔心跨域不跨域。這也是在線廣告跟蹤瀏覽量的主要方式。
動態創建影像經常用於影像 Ping。影像 Ping 是與伺服器進行簡單、單向的跨域通訊的一種方式。請求的數據是通過查詢字元串形式發送的,而響應可以是任意內容,但通常是像素圖或 204 響應。通過影像 Ping,瀏覽器得不到任何具體的數據,但通過偵聽 load 和 error 事件,它能知道響應是什麼時候接收到的。來看下面的例子。
var img = new Image();
img.onload = img.onerror = function(){
alert("Done!");
};
img.src = "//www.example.com/test?name=Nicholas";
這裡創建了一個 Image 的實例,然後將 onload 和 onerror 事件處理程式指定為同一個函數。這樣無論是什麼響應,只要請求完成,就能得到通知。請求從設置 src 屬性那一刻開始,而這個例子在請求中發送了一個 name 參數。影像 Ping 最常用於跟蹤用戶點擊頁面或動態廣告曝光次數。影像 Ping 有兩個主要的缺點,一是只能發送 GET 請求,二是無法訪問伺服器的響應文本。因此,影像 Ping 只能用於瀏覽器與伺服器間的單向通訊。
JSONP
JSONP 是 JSON with padding(填充式 JSON 或參數式 JSON)的簡寫,是應用 JSON 的一種新方法,在後來的 Web 服務中非常流行。 JSONP 看起來與 JSON 差不多,只不過是被包含在函數調用中的 JSON
JSONP 之所以在開發人員中極為流行,主要原因是它非常簡單易用。與影像 Ping 相比,它的優點在於能夠直接訪問響應文本,支援在瀏覽器與伺服器之間雙向通訊。不過, JSONP 也有兩點不足。
首先, JSONP 是從其他域中載入程式碼執行。如果其他域不安全,很可能會在響應中夾帶一些惡意程式碼,而此時除了完全放棄 JSONP 調用之外,沒有辦法追究。因此在使用不是你自己運維的 Web 服務時,一定得保證它安全可靠。
其次,要確定 JSONP 請求是否失敗並不容易。雖然 HTML5 給<script>元素新增了一個 onerror事件處理程式,但目前還沒有得到任何瀏覽器支援。為此,開發人員不得不使用計時器檢測指時間內是否接收到了響應。但就算這樣也不能盡如人意,畢竟不是每個用戶上網的速度和頻寬都一樣。
Comet
Comet 是 Alex Russell①發明的一個詞兒,指的是一種更高級的 Ajax 技術(經常也有人稱為「伺服器推送」)。 Ajax 是一種從頁面向伺服器請求數據的技術,而 Comet 則是一種伺服器向頁面推送數據的技術。 Comet 能夠讓資訊近乎實時地被推送到頁面上,非常適合處理體育比賽的分數和股票報價。
有兩種實現 Comet 的方式: 長輪詢和流。長輪詢是傳統輪詢(也稱為短輪詢)的一個翻版,即瀏覽器定時向伺服器發送請求,看有沒有更新的數據。
長輪詢把短輪詢顛倒了一下。頁面發起一個到伺服器的請求,然後伺服器一直保持連接打開,直到有數據可發送。發送完數據之後,瀏覽器關閉連接,隨即又發起一個到伺服器的新請求。這一過程在頁面打開期間一直持續不斷。
無論是短輪詢還是長輪詢,瀏覽器都要在接收數據之前,先發起對伺服器的連接。兩者最大的區別在於伺服器如何發送數據。短輪詢是伺服器立即發送響應,無論數據是否有效,而長輪詢是等待發送響應。輪詢的優勢是所有瀏覽器都支援,因為使用 XHR 對象和 setTimeout()就能實現。而你要做的就是決定什麼時候發送請求。
第二種流行的 Comet 實現是 HTTP 流。流不同於上述兩種輪詢,因為它在頁面的整個生命周期內只使用一個 HTTP 連接。具體來說,就是瀏覽器向伺服器發送一個請求,而伺服器保持連接打開,然後周期性地向瀏覽器發送數據。
伺服器發送事件
SSE(Server-Sent Events,伺服器發送事件)是圍繞只讀 Comet 交互推出的 API 或者模式。 SSE API用於創建到伺服器的單向連接,伺服器通過這個連接可以發送任意數量的數據。伺服器響應的 MIME類型必須是 text/event-stream,而且是瀏覽器中的 JavaScript API 能解析格式輸出。 SSE 支援短輪詢、長輪詢和 HTTP 流,而且能在斷開連接時自動確定何時重新連接。有了這麼簡單實用的 API,再實現 Comet 就容易多了。
Web Sockets
要說最令人津津樂道的新瀏覽器 API,就得數 Web Sockets 了。 Web Sockets 的目標是在一個單獨的持久連接上提供全雙工、雙向通訊。在 JavaScript 中創建了 Web Socket 之後,會有一個 HTTP 請求發送到瀏覽器以發起連接。在取得伺服器響應後,建立的連接會使用 HTTP 升級從 HTTP 協議交換為 WebSocket 協議。也就是說,使用標準的 HTTP 伺服器無法實現 Web Sockets,只有支援這種協議的專門伺服器才能正常工作。
由於 Web Sockets 使用了自定義的協議,所以 URL 模式也略有不同。未加密的連接不再是 //,而是 ws://;加密的連接也不是 //,而是 wss://。在使用 Web Socket URL 時,必須帶著這個模式,因為將來還有可能支援其他模式。
使用自定義協議而非 HTTP 協議的好處是,能夠在客戶端和伺服器之間發送非常少量的數據,而不必擔心 HTTP 那樣位元組級的開銷。由於傳遞的數據包很小,因此 Web Sockets 非常適合移動應用。畢竟對移動應用而言,頻寬和網路延遲都是關鍵問題。使用自定義協議的缺點在於,制定協議的時間比制定JavaScript API 的時間還要長。 Web Sockets 曾幾度擱淺,就因為不斷有人發現這個新協議存在一致性和安全性的問題。
同源策略對 Web Sockets 不適用,因此可以通過它打開到任何站點的連接。至於是否會與某個域中的頁面通訊,則完全取決於伺服器。
SSE與Web Sockets
面對某個具體的用例,在考慮是使用 SSE 還是使用 Web Sockets 時,可以考慮如下幾個因素。首先,你是否有自由度建立和維護 Web Sockets 伺服器?因為 Web Socket 協議不同於 HTTP,所以現有伺服器不能用於 Web Socket 通訊。 SSE 倒是通過常規 HTTP 通訊,因此現有伺服器就可以滿足需求。
第二個要考慮的問題是到底需不需要雙向通訊。如果用例只需讀取伺服器數據(如比賽成績),那麼 SSE 比較容易實現。如果用例必須雙向通訊(如聊天室),那麼 Web Sockets 顯然更好。別忘了,在不能選擇 Web Sockets 的情況下,組合 XHR 和 SSE 也是能實現雙向通訊的。
第22章 高級技巧
高級函數
安全的類型檢測
avaScript 內置的類型檢測機制並非完全可靠。事實上,發生錯誤否定及錯誤肯定的情況也不在少數。比如說 typeof 操作符吧,由於它有一些無法預知的行為,經常會導致檢測數據類型時得到不靠譜的結果。 Safari(直至第 4 版)在對正則表達式應用 typeof 操作符時會返回"function",因此很難確定某個值到底是不是函數。
再比如, instanceof 操作符在存在多個全局作用域(像一個頁面包含多個 frame)的情況下,也是問題多多。
大家知道,在任何值上調用 Object 原生的 toString()方法,都會返回一個[object NativeConstructorName]格式的字元串。每個類在內部都有一個[[Class]]屬性,這個屬性中就指定了上述字元串中的構造函數名。
alert(Object.prototype.toString.call(value)); //"[object Array]"
由於原生數組的構造函數名與全局作用域無關,因此使用 toString()就能保證返回一致的值。利用這一點,可以創建如下函數:
function isArray(value){
return Object.prototype.toString.call(value) == "[object Array]";
}
同樣,也可以基於這一思路來測試某個值是不是原生函數或正則表達式:
function isFunction(value){
return Object.prototype.toString.call(value) == "[object Function]";
}
function isRegExp(value){
return Object.prototype.toString.call(value) == "[object RegExp]";
}
不過要注意,對於在 IE 中以 COM 對象形式實現的任何函數, isFunction()都將返回 false,因為它們並非原生的 JavaScript 函數。
請注意, Object.prototpye.toString()本身也可能會被修改。本節討論的技巧假設 Object.prototpye.toString()是未被修改過的原生版本。
作用域安全的構造函數
構造函數其實就是一個使用 new 操作符調用的函數。當使用 new 調用時,構造函數內用到的 this 對象會指向新創建的對象實例,如下面的例子所示:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
}
var person = new Person("Nicholas", 29, "Software Engineer");
問題出在當沒有使用 new 操作符來調用該構造函數的情況上。由於該 this 對象是在運行時綁定的,所以直接調用 Person(),this 會映射到全局對象 window 上,導致錯誤對象屬性的意外增加。例如:
var person = Person("Nicholas", 29, "Software Engineer");
alert(window.name); //"Nicholas"
alert(window.age); //29
alert(window.job); //"Software Engineer"
這裡,原本針對 Person 實例的三個屬性被加到 window 對象上,因為構造函數是作為普通函數調用的, 忽略了 new 操作符。這個問題是由 this 對象的晚綁定造成的,在這裡 this 被解析成了 window對象。由於 window 的 name 屬性是用於識別鏈接目標和 frame 的,所以這裡對該屬性的偶然覆蓋可能會導致該頁面上出現其他錯誤。這個問題的解決方法就是創建一個作用域安全的構造函數。
function Person(name, age, job){
if (this instanceof Person){
this.name = name;
this.age = age;
this.job = job;
} else {
return new Person(name, age, job);
}
}
var person1 = Person("Nicholas", 29, "Software Engineer");
alert(window.name); //""
alert(person1.name); //"Nicholas"
var person2 = new Person("Shelby", 34, "Ergonomist");
alert(person2.name); //"Shelby"
實現這個模式後,你就鎖定了可以調用構造函數的環境。如果你使用構造函數竊取模式的繼承且不使用原型鏈,那麼這個繼承很可能被破壞。這裡有個例子:
function Polygon(sides){
if (this instanceof Polygon) {
this.sides = sides;
this.getArea = function(){
return 0;
};
} else {
return new Polygon(sides);
}
}
function Rectangle(width, height){
Polygon.call(this, 2);
this.width = width;
this.height = height;
this.getArea = function(){
return this.width * this.height;
};
}
var rect = new Rectangle(5, 10);
alert(rect.sides); //undefined
在這段程式碼中, Polygon 構造函數是作用域安全的,然而 Rectangle 構造函數則不是。新創建一個 Rectangle 實例之後,這個實例應該通過 Polygon.call()來繼承 Polygon 的 sides 屬性。但是,由於 Polygon 構造函數是作用域安全的, this 對象並非 Polygon 的實例,所以會創建並返回一個新的 Polygon 對象。 Rectangle 構造函數中的 this 對象並沒有得到增長,同時 Polygon.call()返回的值也沒有用到,所以 Rectangle 實例中就不會有 sides 屬性。
如果構造函數竊取結合使用原型鏈或者寄生組合則可以解決這個問題。考慮以下例子:
function Polygon(sides){
if (this instanceof Polygon) {
this.sides = sides;
this.getArea = function(){
return 0;
};
} else {
return new Polygon(sides);
}
}
function Rectangle(width, height){
Polygon.call(this, 2);
this.width = width;
this.height = height;
this.getArea = function(){
return this.width * this.height;
};
}
Rectangle.prototype = new Polygon();
var rect = new Rectangle(5, 10);
alert(rect.sides); //2
惰性載入函數
ps:不理解,跳過
函數綁定
另一個日益流行的高級技巧叫做函數綁定。函數綁定要創建一個函數,可以在特定的 this 環境中以指定參數調用另一個函數。該技巧常常和回調函數與事件處理程式一起使用,以便在將函數作為變數傳遞的同時保留程式碼執行環境。
var handler = {
message: "Event handled",
handleClick: function(event){
alert(this.message);
}
};
var btn = document.getElementById("my-btn");
EventUtil.addHandler(btn, "click", handler.handleClick);
在上面這個例子中,創建了一個叫做 handler 的對象。 handler.handleClick()方法被分配為一個 DOM 按鈕的事件處理程式。當按下該按鈕時,就調用該函數,顯示一個警告框。雖然貌似警告框應 該 顯 示 Event handled , 然 而 實 際 上 顯 示 的 是 undefiend 。 這 個 問 題 在 於 沒 有 保 存handler.handleClick()的環境,所以 this 對象最後是指向了 DOM 按鈕而非 handler(在 IE8 中,this 指向 window。)可以如下面例子所示,使用一個閉包來修正這個問題。
var handler = {
message: "Event handled",
handleClick: function(event){
alert(this.message);
}
};
var btn = document.getElementById("my-btn");
EventUtil.addHandler(btn, "click", function(event){
handler.handleClick(event);
});
這個解決方案在 onclick 事件處理程式內使用了一個閉包直接調用 handler.handleClick()。當然,這是特定於這段程式碼的解決方案。創建多個閉包可能會令程式碼變得難於理解和調試。因此,很多JavaScript 庫實現了一個可以將函數綁定到指定環境的函數。這個函數一般都叫 bind()。一個簡單的 bind()函數接受一個函數和一個環境,並返回一個在給定環境中調用給定函數的函數,並且將所有參數原封不動傳遞過去。語法如下:
function bind(fn, context){
return function(){
return fn.apply(context, arguments);
};
}
這個函數似乎簡單,但其功能是非常強大的。在 bind()中創建了一個閉包,閉包使用 apply()調用傳入的函數,並給 apply()傳遞 context 對象和參數。注意這裡使用的 arguments 對象是內部函數的,而非 bind()的。當調用返回的函數時,它會在給定環境中執行被傳入的函數並給出所有參數。bind()函數按如下方式使用:
var handler = {
message: "Event handled",
handleClick: function(event){
alert(this.message);
}
};
var btn = document.getElementById("my-btn");
EventUtil.addHandler(btn, "click", bind(handler.handleClick, handler));
ECMAScript 5 為所有函數定義了一個原生的 bind()方法,進一步簡單了操作。換句話說,你不用再自己定義 bind()函數了,而是可以直接在函數上調用這個方法。
var handler = {
message: "Event handled",
handleClick: function(event){
alert(this.message + ":" + event.type);
}
};
var btn = document.getElementById("my-btn");
EventUtil.addHandler(btn, "click", handler.handleClick.bind(handler));
科里化函數
ps:看不懂系列
防篡改對象
JavaScript 共享的本質一直是開發人員心頭的痛。因為任何對象都可以被在同一環境中運行的程式碼修改。開發人員很可能會意外地修改別人的程式碼,甚至更糟糕地,用不兼容的功能重寫原生對象。ECMAScript 5 致力於解決這個問題,可以讓開發人員定義防篡改對象(tamper-proof object)。
不過請注意:一旦把對象定義為防篡改,就無法撤銷了
不可擴展對象
默認情況下,所有對象都是可以擴展的。
使用
Object.preventExtensions()方法可以改變這個行為,讓你不能再給對象添加屬性和方法。例如:
var person = { name: "Nicholas" };
Object.preventExtensions(person);
person.age = 29;
alert(person.age); //undefined
密封的對象
ECMAScript 5 為對象定義的第二個保護級別是密封對象(sealed object)。密封對象不可擴展,而且已有成員的[[Configurable]]特性將被設置為 false。這就意味著不能刪除屬性和方法,因為不能使用 Object.defineProperty()把數據屬性修改為訪問器屬性,或者相反。屬性值是可以修改的。要密封對象,可以使用 Object.seal()方法。
var person = { name: "Nicholas" };
Object.seal(person);
person.age = 29;
alert(person.age); //undefined
delete person.name;
alert(person.name); //"Nicholas"
凍結的對象
最嚴格的防篡改級別是凍結對象(frozen object)。凍結的對象既不可擴展,又是密封的,而且對象數據屬性的[[Writable]]特性會被設置為 false。如果定義[[Set]]函數,訪問器屬性仍然是可寫的。ECMAScript 5 定義的 Object.freeze()方法可以用來凍結對象。
var person = { name: "Nicholas" };
Object.freeze(person);
person.age = 29;
alert(person.age); //undefined
delete person.name;
alert(person.name); //"Nicholas"
person.name = "Greg";
alert(person.name); //"Nicholas"
高級定時器
使用 setTimeout()和 setInterval()創建的定時器可以用於實現有趣且有用的功能。雖然人們對 JavaScript 的定時器存在普遍的誤解,認為它們是執行緒,其實 JavaScript 是運行於單執行緒的環境中的,而定時器僅僅只是計劃程式碼在未來的某個時間執行。執行時機是不能保證的,因為在頁面的生命周期中,不同時間可能有其他程式碼在控制 JavaScript 進程。在頁面下載完後的程式碼運行、事件處理程式、 Ajax 回調函數都必須使用同樣的執行緒來執行。實際上,瀏覽器負責進行排序,指派某段程式碼在某個時間點運行的優先順序。
可以把 JavaScript 想像成在時間線上運行的。當頁面載入時,首先執行是任何包含在<script>元素中的程式碼,通常是頁面生命周期後面要用到的一些簡單的函數和變數的聲明,不過有時候也包含一些初始數據的處理。在這之後, JavaScript 進程將等待更多程式碼執行。當進程空閑的時候,下一個程式碼會被觸發並立刻執行。例如,當點擊某個按鈕時, onclick 事件處理程式會立刻執行,只要 JavaScript 進程處於空閑狀態。
除了主 JavaScript 執行進程外,還有一個需要在進程下一次空閑時執行的程式碼隊列。隨著頁面在其生命周期中的推移,程式碼會按照執行順序添加入隊列。例如,當某個按鈕被按下時,它的事件處理程式程式碼就會被添加到隊列中,並在下一個可能的時間裡執行。當接收到某個 Ajax 響應時,回調函數的程式碼會被添加到隊列。在 JavaScript 中沒有任何程式碼是立刻執行的,但一旦進程空閑則儘快執行。
定時器對隊列的工作方式是,當特定時間過去後將程式碼插入。注意,給隊列添加程式碼並不意味著對它立刻執行,而只能表示它會儘快執行。設定一個 150ms 後執行的定時器不代表到了 150ms 程式碼就立刻執行,它表示程式碼會在 150ms 後被加入到隊列中。如果在這個時間點上,隊列中沒有其他東西,那麼這段程式碼就會被執行,表面上看上去好像程式碼就在精確指定的時間點上執行了。其他情況下,程式碼可能明顯地等待更長時間才執行。
關於定時器要記住的最重要的事情是,指定的時間間隔表示何時將定時器的程式碼添加到隊列,而不是何時實際執行程式碼。
重複的定時器
使用 setInterval()創建的定時器確保了定時器程式碼規則地插入隊列中。這個方式的問題在於,定時器程式碼可能在程式碼再次被添加到隊列之前還沒有完成執行,結果導致定時器程式碼連續運行好幾次,而之間沒有任何停頓。幸好, JavaScript 引擎夠聰明,能避免這個問題。當使用 setInterval()時,僅當沒有該定時器的任何其他程式碼實例時,才將定時器程式碼添加到隊列中。這確保了定時器程式碼入到隊列中的最小時間間隔為指定間隔。
這種重複定時器的規則有兩個問題: (1) 某些間隔會被跳過; (2) 多個定時器的程式碼執行之間的間隔可能會比預期的小。假設,某個 onclick 事件處理程式使用 setInterval()設置了一個 200ms 間隔的重複定時器。如果事件處理程式花了 300ms 多一點的時間完成,同時定時器程式碼也花了差不多的時間,就會同時出現跳過間隔且連續運行定時器程式碼的情況。
為了避免 setInterval()的重複定時器的這 2個缺點,你可以用如下模式使用鏈式 setTimeout()調用。
setTimeout(function(){
//處理中
setTimeout(arguments.callee, interval);
}, interval);
這個模式鏈式調用了 setTimeout(),每次函數執行的時候都會創建一個新的定時器。第二個setTimeout()調用使用了 arguments.callee 來獲取對當前執行的函數的引用,並為其設置另外一個定時器。這樣做的好處是,在前一個定時器程式碼執行完之前,不會向隊列插入新的定時器程式碼,確保不會有任何缺失的間隔。而且,它可以保證在下一次定時器程式碼執行之前,至少要等待指定的間隔,避免了連續的運行。這個模式主要用於重複定時器,如下例所示
setTimeout(function(){
var div = document.getElementById("myDiv");
left = parseInt(div.style.left) + 5;
div.style.left = left + "px";
if (left < 200){
setTimeout(arguments.callee, 50);
}
}, 50);
Yielding Processes
運行在瀏覽器中的 JavaScript 都被分配了一個確定數量的資源。不同於桌面應用往往能夠隨意控制他們要的記憶體大小和處理器時間, JavaScript 被嚴格限制了,以防止惡意的 Web 程式設計師把用戶的電腦搞掛了。其中一個限制是長時間運行腳本的制約,如果程式碼運行超過特定的時間或者特定語句數量就不讓它繼續執行。如果程式碼達到了這個限制,會彈出一個瀏覽器錯誤的對話框,告訴用戶某個腳本會用過長的時間執行,詢問是允許其繼續執行還是停止它。所有 JavaScript 開發人員的目標就是,確保用戶永遠不會在瀏覽器中看到這個令人費解的對話框。定時器是繞開此限制的方法之一。
腳本長時間運行的問題通常是由兩個原因之一造成的:過長的、過深嵌套的函數調用或者是進行大量處理的循環。這兩者中,後者是較為容易解決的問題。長時間運行的循環通常遵循以下模式
for (var i=0, len=data.length; i < len; i++){
process(data[i]);
}
這個模式的問題在於要處理的項目的數量在運行前是不可知的。如果完成 process()要花 100ms,只有 2 個項目的數組可能不會造成影響,但是 10 個的數組可能會導致腳本要運行一秒鐘才能完成。數組中的項目數量直接關係到執行完該循環的時間長度。同時由於 JavaScript 的執行是一個阻塞操作,腳本運行所花時間越久,用戶無法與頁面交互的時間也越久。在展開該循環之前,你需要回答以下兩個重要的問題。
- 該處理是否必須同步完成?如果這個數據的處理會造成其他運行的阻塞,那麼最好不要改動它。不過,如果你對這個問題的回答確定為「否」,那麼將某些處理推遲到以後是個不錯的備選項。
- 數據是否必須按順序完成?通常,數組只是對項目的組合和迭代的一種簡便的方法而無所謂順序。如果項目的順序不是非常重要,那麼可能可以將某些處理推遲到以後
當你發現某個循環佔用了大量時間,同時對於上述兩個問題,你的回答都是「否」,那麼你就可以使用定時器分割這個循環。這是一種叫做數組分塊(array chunking)的技術,小塊小塊地處理數組,通常每次一小塊。基本的思路是為要處理的項目創建一個隊列,然後使用定時器取出下一個要處理的項目進行處理,接著再設置另一個定時器。
setTimeout(function(){
//取出下一個條目並處理
var item = array.shift();
process(item);
//若還有條目,再設置另一個定時器
if(array.length > 0){
setTimeout(arguments.callee, 100);
}
}, 100);
在數組分塊模式中, array 變數本質上就是一個「待辦事宜」列表,它包含了要處理的項目。使用shift()方法可以獲取隊列中下一個要處理的項目,然後將其傳遞給某個函數。如果在隊列中還有其他項目,則設置另一個定時器,並通過 arguments.callee 調用同一個匿名函數。要實現數組分塊非常簡單,可以使用以下函數。
function chunk(array, process, context){
setTimeout(function(){
var item = array.shift();
process.call(context, item);
if (array.length > 0){
setTimeout(arguments.callee, 100);
}
}, 100);
}
函數節流
瀏覽器中某些計算和處理要比其他的昂貴很多。例如, DOM 操作比起非 DOM 交互需要更多的記憶體和 CPU 時間。連續嘗試進行過多的 DOM 相關操作可能會導致瀏覽器掛起,有時候甚至會崩潰。尤其在 IE 中使用 onresize 事件處理程式的時候容易發生,當調整瀏覽器大小的時候,該事件會連續觸發。在 onresize 事件處理程式內部如果嘗試進行 DOM 操作,其高頻率的更改可能會讓瀏覽器崩潰。為了繞開這個問題,你可以使用定時器對該函數進行節流。
函數節流背後的基本思想是指,某些程式碼不可以在沒有間斷的情況連續重複執行。第一次調用函數,創建一個定時器,在指定的時間間隔之後運行程式碼。當第二次調用該函數時,它會清除前一次的定時器並設置另一個。如果前一個定時器已經執行過了,這個操作就沒有任何意義。然而,如果前一個定時器尚未執行,其實就是將其替換為一個新的定時器。目的是只有在執行函數的請求停止了一段時間之後才執行。以下是該模式的基本形式:
var processor = {
timeoutId: null,
//實際進行處理的方法
performProcessing: function(){
//實際執行的程式碼
},
//初始處理調用的方法
process: function(){
clearTimeout(this.timeoutId);
var that = this;
this.timeoutId = setTimeout(function(){
that.performProcessing();
}, 100);
}
};
//嘗試開始執行
processor.process();
時間間隔設為了 100ms,這表示最後一次調用 process()之後至少 100ms 後才會調用 performProcessing()。所以如果 100ms 之內調用了 process()共 20 次, performanceProcessing()仍只會被調用一次。
這個模式可以使用 throttle()函數來簡化,這個函數可以自動進行定時器的設置和清除,如下例所示:
function throttle(method, context) {
clearTimeout(method.tId);
method.tId= setTimeout(function(){
method.call(context);
}, 100);
}
假設有一個<div/>元素需要保持它的高度始終等同於寬度。那麼實現這一功能的 JavaScript 可以如下編寫:
window.onresize = function(){
var div = document.getElementById("myDiv");
div.style.height = div. offsetWidth + "px";
};
這段非常簡單的例子有兩個問題可能會造成瀏覽器運行緩慢。首先,要計算 offsetWidth 屬性,如果該元素或者頁面上其他元素有非常複雜的 CSS 樣式,那麼這個過程將會很複雜。其次,設置某個元素的高度需要對頁面進行迴流來令改動生效。如果頁面有很多元素同時應用了相當數量的 CSS 的話,這又需要很多計算。這就可以用到 throttle()函數,如下例所示:
function resizeDiv(){
var div = document.getElementById("myDiv");
div.style.height = div.offsetWidth + "px";
}
window.onresize = function(){
throttle(resizeDiv);
};
自定義事件
ps:觀察者模式的js實現,跳過
拖放
ps:暫時用不到,跳過
第23章 離線應用於客戶端存儲
支援離線 Web 應用開發是 HTML5 的另一個重點。所謂離線 Web 應用,就是在設備不能上網的情況下仍然可以運行的應用。 HTML5 把離線應用作為重點,主要是基於開發人員的心愿。前端開發人員一直希望 Web 應用能夠與傳統的客戶端應用同場競技,起碼做到只要設備有電就能使用。
離線檢測
開發離線應用的第一步是要知道設備是在線還是離線,HTML5為此定義了一個navigator.onLine屬性,這個屬性值為 true 表示設備能上網,值為 false 表示設備離線。這個屬性的關鍵是瀏覽器必須知道設備能否訪問網路,從而返回正確的值。實際應用中, navigator.onLine 在不同瀏覽器間還有些小的差異。
除 navigator.onLine 屬性之外,為了更好地確定網路是否可用, HTML5 還定義了兩個事件:online 和 offline。當網路從離線變為在線或者從在線變為離線時,分別觸發這兩個事件。這兩個事件在 window 對象上觸發。
EventUtil.addHandler(window, "online", function(){
alert("Online");
});
EventUtil.addHandler(window, "offline", function(){
alert("Offline");
});
應用快取
HTML5 的應用快取(application cache),或者簡稱為 appcache,是專門為開發離線 Web 應用而設計的。 Appcache 就是從瀏覽器的快取中分出來的一塊快取區。要想在這個快取中保存數據,可以使用一個描述文件(manifest file),列出要下載和快取的資源。下面是一個簡單的描述文件示例。
CACHE MANIFEST
#Comment
file.js
file.css
在最簡單的情況下,描述文件中列出的都是需要下載的資源,以備離線時使用。
雖然應用快取的意圖是確保離線時資源可用,但也有相應的 JavaScript API 讓你知道它都在做什麼。
這個 API 的核心是 applicationCache 對象,這個對象有一個 status 屬性,屬性的值是常量,表示
應用快取的如下當前狀態。
- 0:無快取,即沒有與頁面相關的應用快取。
- 1:閑置,即應用快取未得到更新。
- 2:檢查中,即正在下載描述文件並檢查更新。
- 3:下載中,即應用快取正在下載描述文件中指定的資源。
- 4:更新完成,即應用快取已經更新了資源,而且所有資源都已下載完畢,可以通過 swapCache()來使用了。
- 5:廢棄,即應用快取的描述文件已經不存在了,因此頁面無法再訪問應用快取。應用快取還有很多相關的事件,表示其狀態的改變。以下是這些事件。
- checking:在瀏覽器為應用快取查找更新時觸發。
- error:在檢查更新或下載資源期間發生錯誤時觸發。
- noupdate:在檢查描述文件發現文件無變化時觸發。
- downloading:在開始下載應用快取資源時觸發。
- progress:在文件下載應用快取的過程中持續不斷地觸發。
- updateready:在頁面新的應用快取下載完畢且可以通過 swapCache()使用時觸發。
- cached:在應用快取完整可用時觸發。
一般來講,這些事件會隨著頁面載入按上述順序依次觸發。不過,通過調用 update()方法也可以手工干預,讓應用快取為檢查更新而觸發上述事件。
applicationCache.update();
update()一經調用,應用快取就會去檢查描述文件是否更新(觸發 checking 事件),然後就像頁面剛剛載入一樣,繼續執行後續操作。如果觸發了 cached 事件,就說明應用快取已經準備就緒,不會再發生其他操作了。如果觸發了 updateready 事件,則說明新版本的應用快取已經可用,而此時你需要調用 swapCache()來啟用新應用快取。
EventUtil.addHandler(applicationCache, "updateready", function(){
applicationCache.swapCache();
});
數據存儲
隨著 Web 應用程式的出現,也產生了對於能夠直接在客戶端上存儲用戶資訊能力的要求。想法很合乎邏輯,屬於某個特定用戶的資訊應該存在該用戶的機器上。無論是登錄資訊、偏好設定或其他數據,Web 應用提供者發現他們在找各種方式將數據存在客戶端上。這個問題的第一個方案是以 cookie 的形式出現的, cookie 是原來的網景公司創造的。
Cookie
HTTP Cookie,通常直接叫做 cookie,最初是在客戶端用於存儲會話資訊的。該標準要求伺服器對任意 HTTP 請求發送 Set-Cookie HTTP 頭作為響應的一部分,其中包含會話資訊。
限制
cookie 在性質上是綁定在特定的域名下的。當設定了一個 cookie 後,再給創建它的域名發送請求時,都會包含這個 cookie。這個限制確保了儲存在 cookie 中的資訊只能讓批准的接受者訪問,而無法被其他域訪問。
由於 cookie 是存在客戶端電腦上的,還加入了一些限制確保 cookie 不會被惡意使用,同時不會佔據太多磁碟空間。每個域的 cookie 總數是有限的,不過瀏覽器之間各有不同。
** cookie的構成**
- 名稱:一個唯一確定 cookie 的名稱。cookie 名稱是不區分大小寫的,所以 myCookie 和 MyCookie被認為是同一個 cookie。然而,實踐中最好將 cookie 名稱看作是區分大小寫的,因為某些伺服器會這樣處理 cookie。 cookie 的名稱必須是經過 URL 編碼的。
- 值:儲存在 cookie 中的字元串值。值必須被 URL 編碼
- 域: cookie 對於哪個域是有效的。所有向該域發送的請求中都會包含這個 cookie 資訊。這個值可以包含子域(subdomain,如 www.wrox.com),也可以不包含它(如.wrox.com,則對於 wrox.com的所有子域都有效)。如果沒有明確設定,那麼這個域會被認作來自設置 cookie 的那個域。
- 路徑:對於指定域中的那個路徑,應該向伺服器發送 cookie。例如,你可以指定 cookie 只有從//www.wrox.com/books/ 中才能訪問,那麼 //www.wrox.com 的頁面就不會發送 cookie 資訊,即使請求都是來自同一個域的
- 失效時間:表示 cookie 何時應該被刪除的時間戳(也就是,何時應該停止向伺服器發送這個cookie)。默認情況下,瀏覽器會話結束時即將所有 cookie 刪除;不過也可以自己設置刪除時間。這個值是個 GMT 格式的日期(Wdy, DD-Mon-YYYY HH:MM:SS GMT),用於指定應該刪除cookie 的準確時間。因此, cookie 可在瀏覽器關閉後依然保存在用戶的機器上。如果你設置的失效日期是個以前的時間,則 cookie 會被立刻刪除。
- 安全標誌:指定後, cookie 只有在使用 SSL 連接的時候才發送到伺服器。例如, cookie 資訊只能發送給 //www.wrox.com,而 //www.wrox.com 的請求則不能發送 cookie
每一段資訊都作為 Set-Cookie 頭的一部分,使用分號加空格分隔每一段,如下例所示。
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value; expires=Mon, 22-Jan-07 07:10:24 GMT; domain=.wrox.com
Other-header: other-header-value
secure 標誌是 cookie 中唯一一個非名值對兒的部分,直接包含一個 secure 單詞。如下:
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value; domain=.wrox.com; path=/; secure
Other-header: other-header-value
JavaScript中的cookie
在 JavaScript中處理 cookie有些複雜,因為其眾所周知的蹩腳的介面,即 BOM的document. cookie屬性。這個屬性的獨特之處在於它會因為使用它的方式不同而表現出不同的行為。當用來獲取屬性值時,document.cookie 返回當前頁面可用的(根據 cookie 的域、路徑、失效時間和安全設置)所有 cookie的字元串,一系列由分號隔開的名值對兒,如下例所示。
name1=value1;name2=value2;name3=value3
關於cookie的思考
還有一類 cookie 被稱為「HTTP 專有 cookie」。 HTTP 專有 cookie 可以從瀏覽器或者伺服器設置,但是只能從伺服器端讀取,因為 JavaScript 無法獲取 HTTP 專有 cookie 的值。由於所有的 cookie 都會由瀏覽器作為請求頭髮送,所以在 cookie 中存儲大量資訊會影響到特定域的請求性能。 cookie 資訊越大,完成對伺服器請求的時間也就越長。儘管瀏覽器對 cookie 進行了大小限制,不過最好還是儘可能在 cookie 中少存儲資訊,以避免影響性能。
一定不要在 cookie 中存儲重要和敏感的數據。 cookie 數據並非存儲在一個安全環境中,其中包含的任何數據都可以被他人訪問。所以不要在 cookie 中存儲諸如信用卡號或者個人地址之類的數據。
IE用戶數據
在 IE5.0 中,微軟通過一個自定義行為引入了持久化用戶數據的概念。用戶數據允許每個文檔最多128KB 數據,每個域名最多 1MB 數據。要使用持久化用戶數據,首先必須如下所示,使用 CSS 在某個元素上指定 userData 行為:
<div style="behavior:url(#default#userData)" id="dataStore"></div>
一旦該元素使用了 userData 行為,那麼就可以使用 setAttribute()方法在上面保存數據了。為了將數據提交到瀏覽器快取中,還必須調用 save()方法並告訴它要保存到的數據空間的名字。數據空間名字可以完全任意,僅用於區分不同的數據集。請看以下例子。
var dataStore = document.getElementById("dataStore");
dataStore.setAttribute("name", "Nicholas");
dataStore.setAttribute("book", "Professional JavaScript");
dataStore.save("BookInfo");
在這段程式碼中, <div>元素上存入了兩部分資訊。在用 setAttribute()存儲了數據之後,調用了
save()方法,指定了數據空間的名稱為 BookInfo。下一次頁面載入之後,可以使用 load()方法指定同樣的數據空間名稱來獲取數據,如下所示。
dataStore.load("BookInfo");
alert(dataStore.getAttribute("name")); //"Nicholas"
alert(dataStore.getAttribute("book")); //"Professional JavaScript"
對 IE 用戶數據的訪問限制和對 cookie 的限制類似。要訪問某個數據空間,腳本運行的頁面必須來自同一個域名,在同一個路徑下,並使用與進行存儲的腳本同樣的協議。和 cookie 不同的是,你無法將用戶數據訪問限制擴展到更多的客戶。還有一點不同,用戶數據默認是可以跨越會話持久存在的,同時也不會過期;數據需要通過 removeAttribute()方法專門進行刪除以釋放空間。
和 cookie 一樣, IE 用戶數據並非安全的,所以不能存放敏感資訊。
Web存儲機制
Web Storage 的目的是克服由 cookie 帶來的一些限制,當數據需要被嚴格控制在客戶端上時,無須持續地將數據發回伺服器。 Web Storage 的兩個主要目標是:
- 提供一種在 cookie 之外存儲會話數據的途徑;
- 提供一種存儲大量可以跨會話存在的數據的機制。
最初的 Web Storage 規範包含了兩種對象的定義: sessionStorage 和 globalStorage。這兩個對象在支援的瀏覽器中都是以 windows 對象屬性的形式存在的
Storage類型
Storage 類型提供最大的存儲空間(因瀏覽器而異)來存儲名值對兒。 Storage 的實例與其他對
象類似,有如下方法。
- clear(): 刪除所有值; Firefox 中沒有實現 。
- getItem(name):根據指定的名字 name 獲取對應的值。
- key(index):獲得 index 位置處的值的名字。
- removeItem(name):刪除由 name 指定的名值對兒。
- setItem(name, value):為指定的 name 設置一個對應的值。
sessionStroage對象
sessionStorage 對象存儲特定於某個會話的數據,也就是該數據只保持到瀏覽器關閉。這個對象就像會話 cookie,也會在瀏覽器關閉後消失。存儲在 sessionStorage 中的數據可以跨越頁面刷新而存在,同時如果瀏覽器支援,瀏覽器崩潰並重啟之後依然可用。
因為 seesionStorage 對象綁定於某個伺服器會話,所以當文件在本地運行的時候是不可用的。存儲在 sessionStorage 中的數據只能由最初給對象存儲數據的頁面訪問到,所以對多頁面應用有限制。
由於 sessionStorage 對象其實是 Storage 的一個實例,所以可以使用 setItem()或者直接設置新的屬性來存儲數據。下面是這兩種方法的例子。
//使用方法存儲數據
sessionStorage.setItem("name", "Nicholas");
//使用屬性存儲數據
sessionStorage.book = "Professional JavaScript";
globalStorage對象
作為最初的 Web Storage 規範的一部分,這個對象的目的是跨越會話存儲數據,但有特定的訪問限制。要使用 globalStorage,首先要指定哪些域可以訪問該數據。可以通過方括弧標記使用屬性來實現,如以下例子所示。
//保存數據
globalStorage["wrox.com"].name = "Nicholas";
//獲取數據
var name = globalStorage["wrox.com"].name;
localStorage對象
localStorage 對象在修訂過的 HTML 5 規範中作為持久保存客戶端數據的方案取代了globalStorage。與 globalStorage 不同,不能給 localStorage 指定任何訪問規則;規則事先就設定好了。要訪問同一個 localStorage 對象,頁面必須來自同一個域名(子域名無效),使用同一種協議,在同一個埠上。這相當於 globalStorage[location.host]。
storage事件
對 Storage 對象進行任何修改,都會在文檔上觸發 storage 事件。當通過屬性或 setItem()方法保存數據,使用 delete 操作符或 removeItem()刪除數據,或者調用 clear()方法時,都會發生該事件。這個事件的 event 對象有以下屬性。
- domain:發生變化的存儲空間的域名。
- key:設置或者刪除的鍵名。
- newValue:如果是設置值,則是新值;如果是刪除鍵,則是 null。
- oldValue:鍵被更改之前的值。
無論對 sessionStorage、 globalStorage 還是 localStorage 進行操作,都會觸發 storage事件,但不作區分。
限制
與其他客戶端數據存儲方案類似, Web Storage 同樣也有限制。這些限制因瀏覽器而異。一般來說,對存儲空間大小的限制都是以每個來源(協議、域和埠)為單位的。
IndexedDB
Indexed Database API,或者簡稱為 IndexedDB,是在瀏覽器中保存結構化數據的一種資料庫。IndexedDB 是為了替代目前已被廢棄的 Web SQL Database API(因為已廢棄,所以本書未介紹)而出現的。 IndexedDB 的思想是創建一套 API,方便保存和讀取 JavaScript 對象,同時還支援查詢及搜索。
IndexedDB 設計的操作完全是非同步進行的。因此,大多數操作會以請求方式進行,但這些操作會在後期執行,然後如果成功則返回結果,如果失敗則返回錯誤。差不多每一次 IndexedDB 操作,都需要你註冊 onerror 或 onsuccess 事件處理程式,以確保適當地處理結果。
資料庫
IndexedDB 就是一個資料庫,與 MySQL 或 Web SQL Database 等這些你以前可能用過的資料庫類似。IndexedDB 最大的特色是使用對象保存數據,而不是使用表來保存數據。一個 IndexedDB 資料庫,就是一組位於相同命名空間下的對象的集合。
使用 IndexedDB 的第一步是打開它,即把要打開的資料庫名傳給 indexDB.open()。如果傳入的資料庫已經存在,就會發送一個打開它的請求;如果傳入的資料庫還不存在,就會發送一個創建並打開它的請求。總之,調用 indexDB.open()會返回一個 IDBRequest 對象,在這個對象上可以添加 onerror和 onsuccess 事件處理程式。先來看一個例子。
var request, database;
request = indexedDB.open("admin");
request.onerror = function(event){
alert("Something bad happened while trying to open: " +
event.target.errorCode);
};
request.onsuccess = function(event){
database = event.target.result;
};
對象存儲空間
在建立了與資料庫的連接之後,下一步就是使用對象存儲空間①。如果資料庫的版本與你傳入的版本不匹配,那可能就需要創建一個新的對象存儲空間。在創建對象存儲空間之前,必須要想清楚你想要保存什麼數據類型。
假設你要保存的用戶記錄由用戶名、密碼等組成,那麼保存一條記錄的對象應該類似如下所示:
var user = {
username: "007",
firstName: "James",
lastName: "Bond",
password: "foo"
};
事務
在資料庫對象上調用 transaction()方法可以創建事務。任何時候,只要想讀取或修改數據,都要通過事務來組織所有操作。
使用游標查詢
使用事務可以直接通過已知的鍵檢索單個對象。而在需要檢索多個對象的情況下,則需要在事務內部創建游標。游標就是一指向結果集的指針。與傳統資料庫查詢不同,游標並不提前收集結果。游標指針會先指向結果中的第一項,在接到查找下一項的指令時,才會指向下一項。
鍵範圍
使用游標總讓人覺得不那麼理想,因為通過游標查找數據的方式太有限了。鍵範圍(key range)為使用游標增添了一些靈活性。
設定游標方向
正常情況下,游標都是從存儲空間的第一項開始,調用 continue()或 advance()前進到最後一項。游標的默認方向值是 IDBCursor.NEXT。如果對象存儲空間中有重複的項,而你想讓游標跳過那些重複的項,可以為 openCursor 傳入 IDBCursor.NEXT_NO_DUPLICATE 作為第二個參數:
var store = db.transaction("users").objectStore("users"),
request = store.openCursor(null, IDBCursor.NEXT_NO_DUPLICATE);
當然,也可以創建一個游標,讓它在對象存儲空間中向後移動,即從最後一個對象開始,逐個迭代,直至第一個對象。
var store = db.transaction("users").objectStore("users"),
request = store.openCursor(null, IDBCursor.NEXT_NO_DUPLICATE);
當然,也可以創建一個游標,讓它在對象存儲空間中向後移動,即從最後一個對象開始,逐個迭代,直至第一個對象。
索引
對於某些數據,可能需要為一個對象存儲空間指定多個鍵。比如,若要通過用戶 ID 和用戶名兩種方式來保存用戶資料,就需要通過這兩個鍵來存取記錄。為此,可以考慮將用戶 ID 作為主鍵,然後為用戶名創建索引。
並發問題
雖然網頁中的 IndexedDB 提供的是非同步 API,但仍然存在並發操作的問題。如果瀏覽器的兩個不同的標籤頁打開了同一個頁面,那麼一個頁面試圖更新另一個頁面尚未準備就緒的資料庫的問題就有可能發生。把資料庫設置為新版本有可能導致這個問題。因此,只有當瀏覽器中僅有一個標籤頁使用資料庫的情況下,調用 setVersion()才能完成操作。
剛打開資料庫時,要記著指定 onversionchange 事件處理程式。當同一個來源的另一個標籤頁調用 setVersion()時,就會執行這個回調函數。處理這個事件的最佳方式是立即關閉資料庫,從而保證版本更新順利完成。
限制
對 IndexedDB 的限制很多都與對 Web Storage 的類似。首先, IndexedDB 資料庫只能由同源(相同協議、域名和埠)頁面操作,因此不能跨域共享資訊。換句話說, www.wrox.com 與 p2p.wrox.com的資料庫是完全獨立的。
其次,每個來源的資料庫佔用的磁碟空間也有限制。
第24章 最佳時間
可維護性
在早期的網站中, JavaScript 主要是用於小特效或者是表單驗證。而今天的 Web 應用則會有成千上萬行 JavaScript 程式碼,執行各種複雜的過程。這種演化讓開發者必須得考慮到可維護性。
什麼是可維護的程式碼
可維護的程式碼有一些特徵。一般來說,如果說程式碼是可維護的,它需要遵循以下特點。
- 可理解性——其他人可以接手程式碼並理解它的意圖和一般途徑,而無需原開發人員的完整解釋。
- 直觀性——程式碼中的東西一看就能明白,不管其操作過程多麼複雜。
- 可適應性——程式碼以一種數據上的變化不要求完全重寫的方法撰寫。
- 可擴展性——在程式碼架構上已考慮到在未來允許對核心功能進行擴展。
- 可調試性——當有地方出錯時,程式碼可以給予你足夠的資訊來儘可能直接地確定問題所在
程式碼約定
可讀性
要讓程式碼可維護,首先它必須可讀。可讀性與程式碼作為文本文件的格式化方式有關。可讀性的大部分內容都是和程式碼的縮進相關的。當所有人都使用一樣的縮進方式時,整個項目中的程式碼都會更加易於閱讀。
可讀性的另一方面是注釋。一般而言,有如下一些地方需要進行注釋。
函數和方法——每個函數或方法都應該包含一個注釋,描述其目的和用於完成任務所可能使用
的演算法。陳述事先的假設也非常重要,如參數代表什麼,函數是否有返回值(因為這不能從函
數定義中推斷出來)。
- 大段程式碼——用於完成單個任務的多行程式碼應該在前面放一個描述任務的注釋。
- 複雜的演算法——如果使用了一種獨特的方式解決某個問題,則要在注釋中解釋你是如何做的。這不僅僅可以幫助其他瀏覽你程式碼的人,也能在下次你自己查閱程式碼的時候幫助理解。
- Hack——因為存在瀏覽器差異, JavaScript 程式碼一般會包含一些 hack。不要假設其他人在看程式碼的時候能夠理解 hack 所要應付的瀏覽器問題。如果因為某種瀏覽器無法使用普通的方法,所以你需要用一些不同的方法,那麼請將這些資訊放在注釋中。這樣可以減少出現這種情況的可能性:有人偶然看到你的 hack,然後「修正」了它,最後重新引入了你本來修正了的錯誤。
變數和函數命名
適當給變數和函數起名字對於增加程式碼可理解性和可維護性是非常重要的。
- 變數名應為名詞如 car 或 person。
- 函數名應該以動詞開始,如 getName()。返回布爾類型值的函數一般以 is 開頭,如isEnable()。
- 變數和函數都應使用合乎邏輯的名字,不要擔心長度。長度問題可以通過後處理和壓縮(本章後面會講到)來緩解
變數類型透明
由於在 JavaScript 中變數是鬆散類型的,很容易就忘記變數所應包含的數據類型。合適的命名方式可以一定程度上緩解這個問題。
第一種方式是初始化。當定義了一個變數後,它應該被初始化為一個值,來暗示它將來應該如何應用。例如,將來保存布爾類型值的變數應該初始化為 true 或者 false,將來保存數字的變數就應該初始化為一個數字,如以下例子所示:
//通過初始化指定變數類型
var found = false; //布爾型
var count = -1; //數字
var name = ""; //字元串
var person = null; //對象
第二種方法是使用匈牙利標記法來指定變數類型。匈牙利標記法在變數名之前加上一個或多個字元來表示數據類型。這個標記法在腳本語言中很流行,曾經很長時間也是 JavaScript 所推崇的方式。JavaScript 中最傳統的匈牙利標記法是用單個字元表示基本類型: "o"代表對象, "s"代表字元串, "i"代表整數, "f"代表浮點數, "b"代表布爾型。如下所示:
//用於指定數據類型的匈牙利標記法
var bFound; //布爾型
var iCount; //整數
var sName; //字元串
var oPerson; //對象
JavaScript 中用匈牙利標記法的好處是函數參數一樣可以使用。但它的缺點是讓程式碼某種程度上難以閱讀,阻礙了沒有用它時程式碼的直觀性和句子式的特質。因此,匈牙利標記法失去了一些開發者的寵愛。
最後一種指定變數類型的方式是使用類型注釋。類型注釋放在變數名右邊,但是在初始化前面。這種方式是在變數旁邊放一段指定類型的注釋,如下所示:
//用於指定類型的類型注釋
var found /*:Boolean*/ = false;
var count /*:int*/ = 10;
var name /*:String*/ = "Nicholas";
var person /*:Object*/ = null;
鬆散耦合
只要應用的某個部分過分依賴於另一部分,程式碼就是耦合過緊,難於維護。典型的問題如:對象直接引用另一個對象,並且當修改其中一個的同時需要修改另外一個。緊密耦合的軟體難於維護並且需要經常重寫。
解耦HTML/JavaScript
一種最常見的耦合類型是 HTML/JavaScript 耦合。在 Web 上, HTML 和 JavaScript 各自代表了解決方案中的不同層次: HTML 是數據, JavaScript 是行為。因為它們天生就需要交互,所以有多種不同的方法將這兩個技術關聯起來。但是,有一些方法會將 HTML 和 JavaScript 過於緊密地耦合在一起。直接寫在 HTML 中的 JavaScript,使用包含內聯程式碼的<script>元素或者是使用 HTML 屬性來分配事件處理程式,都是過於緊密的耦合。請看以下程式碼。
<!-- 使用了 <script> 的緊密耦合的 HTML/JavaScript -->
<script type="text/javascript">
document.write("Hello world!");
</script>
<!-- 使用事件處理程式屬性值的緊密耦合的 HTML/JavaScript -->
<input type="button" value="Click Me" onclick="doSomething()" />
雖然這些從技術上來說都是正確的,但是實踐中,它們將表示數據的 HTML 和定義行為的 JavaScript緊密耦合在了一起。理想情況是, HTML 和 JavaScript 應該完全分離,並通過外部文件和使用 DOM 附加行為來包含 JavaScript。
HTML 和 JavaScript 的緊密耦合也可以在相反的關係上成立: JavaScript 包含了 HTML。這通常會出現在使用 innerHTML 來插入一段 HTML 文本到頁面上這種情況中,如下面的例子所示:
//將 HTML 緊密耦合到 JavaScript
function insertMessage(msg){
var container = document.getElementById("container");
container.innerHTML = "<div class=\"msg\"><p class=\"post\">" + msg + "</p>" +
"<p><em>Latest message above.</em></p></div>";
}
一般來說,你應該避免在 JavaScript 中創建大量 HTML。再一次重申要保持層次的分離,這樣可以很容易的確定錯誤來源。當使用上面這個例子的時候,有一個頁面布局的問題,可能和動態創建的 HTML沒有被正確格式化有關。不過,要定位這個錯誤可能非常困難,因為你可能一般先看頁面的源程式碼來查找那段煩人的 HTML,但是卻沒能找到,因為它是動態生成的。對數據或者布局的更改也會要求更改JavaScript,這也表明了這兩個層次過於緊密地耦合了。
解耦CSS/JavaScript
另一個 Web 層則是 CSS,它主要負責頁面的顯示。 JavaScript 和 CSS 也是非常緊密相關的:他們都是 HTML 之上的層次,因此常常一起使用。但是,和 HTML 與 JavaScript 的情況一樣, CSS 和 JavaScript也可能會過於緊密地耦合在一起。最常見的緊密耦合的例子是使用 JavaScript 來更改某些樣式,如下所示:
//CSS 對 JavaScript 的緊密耦合
element.style.color = "red";
element.style.backgroundColor = "blue";
由於 CSS 負責頁面的顯示,當顯示出現任何問題時都應該只是查看 CSS 文件來解決。然而,當使用了 JavaScript 來更改某些樣式的時候,比如顏色,就出現了第二個可能已更改和必須檢查的地方。結果是 JavaScript 也在某種程度上負責了頁面的顯示,並與 CSS 緊密耦合了。如果未來需要更改樣式表,CSS 和 JavaScript 文件可能都需要修改。這就給開發人員造成了維護上的噩夢。所以在這兩個層次之間必須有清晰的劃分。
現代 Web 應用常常要使用 JavaScript 來更改樣式,所以雖然不可能完全將 CSS 和 JavaScript 解耦,
但是還是能讓耦合更鬆散的。這是通過動態更改樣式類而非特定樣式來實現的,如下例所示:
//CSS 對 JavaScript 的鬆散耦合
element.className = "edit";
通過只修改某個元素的 CSS 類,就可以讓大部分樣式資訊嚴格保留在 CSS 中。 JavaScript 可以更改樣式類,但並不會直接影響到元素的樣式。只要應用了正確的類,那麼任何顯示問題都可以直接追溯到CSS 而非 JavaScript。
解耦應用邏輯/事件處理程式
每個 Web 應用一般都有相當多的事件處理程式,監聽著無數不同的事件。然而,很少有能仔細得將應用邏輯從事件處理程式中分離的。請看以下例子:
function handleKeyPress(event){
event = EventUtil.getEvent(event);
if (event.keyCode == 13){
var target = EventUtil.getTarget(event);
var value = 5 * parseInt(target.value);
if (value > 10){
document.getElementById("error-msg").style.display = "block";
}
}
}
較好的方法是將應用邏輯和事件處理程式相分離,這樣兩者分別處理各自的東西。一個事件處理程式應該從事件對象中提取相關資訊,並將這些資訊傳送到處理應用邏輯的某個方法中。例如,前面的程式碼可以被重寫為:
function validateValue(value){
value = 5 * parseInt(value);
if (value > 10){
document.getElementById("error-msg").style.display = "block";
}
}
function handleKeyPress(event){
event = EventUtil.getEvent(event);
if (event.keyCode == 13){
var target = EventUtil.getTarget(event);
validateValue(target.value);
}
}
改動過的程式碼合理將應用邏輯從事件處理程式中分離了出來。 handleKeyPress() 函數確認是按下了 Enter 鍵(event.keyCode 為 13),取得了事件的目標並將 value 屬性傳遞給 validateValue()函數,這個函數包含了應用邏輯。注意 validateValue()中沒有任何東西會依賴於任何事件處理程式邏輯,它只是接收一個值,並根據該值進行其他處理。
ps:簡單理解,就是 validateValue() 函數可以實現復用了
以下是要牢記的應用和業務邏輯之間鬆散耦合的幾條原則:
- 勿將 event 對象傳給其他方法;只傳來自 event 對象中所需的數據;
- 任何可以在應用層面的動作都應該可以在不執行任何事件處理程式的情況下進行;
- 任何事件處理程式都應該處理事件,然後將處理轉交給應用邏輯。
ps:就是上面的小例子,大概就是 validateValue()函數可以復用了
編程實踐
尊重對象所有權
JavaScript 的動態性質使得幾乎任何東西在任何時間都可以修改。
也許在企業環境中最重要的編程實踐就是尊重對象所有權,它的意思是你不能修改不屬於你的對象。簡單地說,如果你不負責創建或維護某個對象、它的對象或者它的方法,那麼你就不能對它們進行修改。更具體地說:
- 不要為實例或原型添加屬性;
- 不要為實例或原型添加方法;
- 不要重定義已存在的方法。
問題在於開發人員會假設瀏覽器環境按照某個特定方式運行,而對於多個人都用到的對象進行改動就會產生錯誤。如果某人期望叫做 stopEvent()的函數能取消某個事件的默認行為,但是你對其進行了更改,然後它完成了本來的任務,後來還追加了另外的事件處理程式,那肯定會出現問題了。其他開發人員會認為函數還是按照原來的方式執行,所以他們的用法會出錯並有可能造成危害,因為他們並不知道有副作用。
所以,最佳的方法便是永遠不修改不是由你所有的對象。所謂擁有對象,就是說這個對象是你創建的,比如你自己創建的自定義類型或對象字面量。而 Array、 document 這些顯然不是你的,它們在你的程式碼執行前就存在了。你依然可以通過以下方式為對象創建新的功能:
- 創建包含所需功能的新對象,並用它與相關對象進行交互;
- 創建自定義類型,繼承需要進行修改的類型。然後可以為自定義類型添加額外功能
避免全局量
與尊重對象所有權密切相關的是儘可能避免全局變數和函數。這也關係到創建一個腳本執行的一致的和可維護的環境。最多創建一個全局變數,讓其他對象和函數存在其中。請看以下例子:
//兩個全局量——避免!!
var name = "Nicholas";
function sayName(){
alert(name);
}
這段程式碼包含了兩個全局量:變數 name 和函數 sayName()。其實可以創建一個包含兩者的對象,如下例所示:
//一個全局量——推薦
var MyApplication = {
name: "Nicholas",
sayName: function(){
alert(this.name);
}
};
在大多數情況下,可以是開發程式碼的公司的名字,例如 YAHOO 或者 Wrox。你可以如下例所示開始創建命名空間來組合功能。
//創建全局對象
var Wrox = {};
//為 Professional JavaScript 創建命名空間
Wrox.ProJS = {};
//將書中用到的對象附加上去
Wrox.ProJS.EventUtil = { ... };
Wrox.ProJS.CookieUtil = { ... };
避免與 null 進行比較
由於 JavaScript 不做任何自動的類型檢查,所有它就成了開發人員的責任。因此,在 JavaScript 程式碼中其實很少進行類型檢測。最常見的類型檢測就是查看某個值是否為 null。但是,直接將值與 null比較是使用過度的,並且常常由於不充分的類型檢查導致錯誤。看以下例子:
function sortArray(values){
if (values != null){ //避免!
values.sort(comparator);
}
}
在前面的範例中, values 參數應該是一個數組,那麼就要檢查它是不是一個數組,而不是檢查它是否非 null。函數按照下面的方式修改會更加合適:
function sortArray(values){
if (values instanceof Array){ //推薦
values.sort(comparator);
}
}
如果看到了與 null 比較的程式碼,嘗試使用以下技術替換:
- 如果值應為一個引用類型,使用 instanceof 操作符檢查其構造函數;
- 如果值應為一個基本類型,使用 typeof 檢查其類型;
- 如果是希望對象包含某個特定的方法名,則使用 typeof 操作符確保指定名字的方法存在於對象上。
程式碼中的 null 比較越少,就越容易確定程式碼的目的,並消除不必要的錯誤。
使用常量
關鍵在於將數據和使用它的邏輯進行分離。要注意的值的類型如下所示。
- 重複值——任何在多處用到的值都應抽取為一個常量。這就限制了當一個值變了而另一個沒變的時候會造成的錯誤。這也包含了 CSS 類名。
- 用戶介面字元串 —— 任何用於顯示給用戶的字元串,都應被抽取出來以方便國際化。
- URLs ——在 Web 應用中,資源位置很容易變更,所以推薦用一個公共地方存放所有的 URL。
- 任意可能會更改的值 —— 每當你在用到字面量值的時候,你都要問一下自己這個值在未來是不
是會變化。如果答案是「是」,那麼這個值就應該被提取出來作為一個常量。對於企業級的 JavaScript 開發而言,使用常量是非常重要的技巧,因為它能讓程式碼更容易維護,並且在數據更改的同時保護程式碼。
性能
Chrome 是第一款內置優化引擎,將 JavaScript 編譯成本地程式碼的瀏覽器。此後,主流瀏覽器紛紛效仿,陸續實現了 JavaScript 的編譯執行。
注意作用域
隨著作用域鏈中的作用域數量的增加,訪問當前作用域以外的變數的時間也在增加。訪問全局變數總是要比訪問局部變數慢,因為需要遍歷作用域鏈。只要能減少花費在作用域鏈上的時間,就能增加腳本的整體性能。
避免全局查找
可能優化腳本性能最重要的就是注意全局查找。使用全局變數和函數肯定要比局部的開銷更大,因為要涉及作用域鏈上的查找。請看以下函數:
function updateUI(){
var imgs = document.getElementsByTagName("img");
for (var i=0, len=imgs.length; i < len; i++){
imgs[i].title = document.title + " image " + i;
}
var msg = document.getElementById("msg");
msg.innerHTML = "Update complete.";
}
該函數可能看上去完全正常,但是它包含了三個對於全局 document 對象的引用。如果在頁面上有多個圖片,那麼 for 循環中的 document 引用就會被執行多次甚至上百次,每次都會要進行作用域鏈查找。通過創建一個指向 document 對象的局部變數,就可以通過限制一次全局查找來改進這個函數的性能:
function updateUI(){
var doc = document;
var imgs = doc.getElementsByTagName("img");
for (var i=0, len=imgs.length; i < len; i++){
imgs[i].title = doc.title + " image " + i;
}
var msg = doc.getElementById("msg");
msg.innerHTML = "Update complete.";
}
這裡,首先將 document 對象存在本地的 doc 變數中;然後在餘下的程式碼中替換原來的 document。與原來的的版本相比,現在的函數只有一次全局查找,肯定更快。將在一個函數中會用到多次的全局對象存儲為局部變數總是沒錯的
ps:這是什麼原理,完全不懂
避免使用with語句
在性能非常重要的地方必須避免使用 with 語句。和函數類似, with 語句會創建自己的作用域,因此會增加其中執行的程式碼的作用域鏈的長度。由於額外的作用域鏈查找,在 with 語句中執行的程式碼肯定會比外面執行的程式碼要慢。
選擇正確方法
避免不必要的屬性查找
ps:感覺吹毛求疵,沒什麼特別的用處
優化循環
循環是編程中最常見的結構,在 JavaScript 程式中同樣隨處可見。優化循環是性能優化過程中很重要的一個部分,由於它們會反覆運行同一段程式碼,從而自動地增加執行時間。在其他語言中對於循環優化有大量研究,這些技術也可以應用於 JavaScript。一個循環的基本優化步驟如下所示。
- 減值迭代——大多數循環使用一個從 0 開始、增加到某個特定值的迭代器。在很多情況下,從最大值開始,在循環中不斷減值的迭代器更加高效。
- 簡化終止條件——由於每次循環過程都會計算終止條件,所以必須保證它儘可能快。也就是說避免屬性查找或其他 O(n)的操作。
- 簡化循環體——循環體是執行最多的,所以要確保其被最大限度地優化。確保沒有某些可以被很容易移出循環的密集計算。
- 使用後測試循環——最常用 for 循環和 while 循環都是前測試循環。而如 do-while 這種後測試循環,可以避免最初終止條件的計算,因此運行更快。
展開循環
當循環的次數是確定的,消除循環並使用多次函數調用往往更快 。請看一下前面的例子。如果數組的長度總是一樣的,對每個元素都調用 process()可能更優,如以下程式碼所示:
//消除循環
process(values[0]);
process(values[1]);
process(values[2]);
這個例子假設 values 數組裡面只有 3 個元素,直接對每個元素調用 process()。這樣展開循環可以消除建立循環和處理終止條件的額外開銷,使程式碼運行得更快。
如果循環中的迭代次數不能事先確定,那可以考慮使用一種叫做 Duff 裝置的技術。這個技術是以其創建者 Tom Duff 命名的,他最早在 C 語言中使用這項技術。正是 Jeff Greenberg 用 JavaScript 實現了Duff 裝置。 Duff 裝置的基本概念是通過計算迭代的次數是否為 8 的倍數將一個循環展開為一系列語句。請看以下程式碼:
//credit: Jeff Greenberg for JS implementation of Duff』s Device
//假設 values.length > 0
var iterations = Math.ceil(values.length / 8);
var startAt = values.length % 8;
var i = 0;
do {
switch(startAt){
case 0: process(values[i++]);
case 7: process(values[i++]);
case 6: process(values[i++]);
case 5: process(values[i++]);
case 4: process(values[i++]);
case 3: process(values[i++]);
case 2: process(values[i++]);
case 1: process(values[i++]);
}
startAt = 0;
} while (--iterations > 0);
Duff 裝置的實現是通過將 values 數組中元素個數除以 8 來計算出循環需要進行多少次迭代的。然後使用取整的上限函數確保結果是整數。如果完全根據除 8 來進行迭代,可能會有一些不能被處理到的元素,這個數量保存在 startAt 變數中。首次執行該循環時,會檢查 StartAt 變數看有需要多少額外調用。
避免雙重解釋
當 JavaScript 程式碼想解析 JavaScript 的時候就會存在雙重解釋懲罰。當使用 eval()函數或者是Function 構造函數以及使用 setTimeout()傳一個字元串參數時都會發生這種情況。下面有一些例子:
//某些程式碼求值——避免!!
eval("alert('Hello world!')");
//創建新函數——避免!!
var sayHi = new Function("alert('Hello world!')");
//設置超時——避免!!
setTimeout("alert('Hello world!')", 500);
只有極少的情況下 eval()是絕對必須的,所以儘可能避免使用。在這個例子中,程式碼其實可以直接內嵌在原程式碼中。對於 Function 構造函數,完全可以直接寫成一般的函數,調用 setTimeout()可以傳入函數作為第一個參數。以下是一些例子:
//已修正
alert('Hello world!');
//創建新函數——已修正
var sayHi = function(){
alert('Hello world!');
};
//設置一個超時——已修正
setTimeout(function(){
alert('Hello world!');
}, 500);
性能的其他注意事項
當評估腳本性能的時候,還有其他一些可以考慮的東西。下面並非主要的問題,不過如果使用得當也會有相當大的提升。
- 原生方法較快——只要有可能,使用原生方法而不是自己用 JavaScript 重寫一個。原生方法是用諸如 C/C++之類的編譯型語言寫出來的,所以要比 JavaScript 的快很多很多。 JavaScript 中最容易被忘記的就是可以在 Math 對象中找到的複雜的數學運算;這些方法要比任何用 JavaScript 寫的同樣方法如正弦、餘弦快的多。
- itch 語句較快 —— 如果有一系列複雜的 if-else 語句,可以轉換成單個 switch 語句則可以得到更快的程式碼。還可以通過將 case 語句按照最可能的到最不可能的順序進行組織,來進一步優化 switch 語句。
- 算符較快 —— 當進行數學運算的時候,位運算操作要比任何布爾運算或者算數運算快。選擇性地用位運算替換算數運算可以極大提升複雜計算的性能。諸如取模,邏輯與和邏輯或都可以考慮用位運算來替換
最小化語句數
JavaScript 程式碼中的語句數量也影響所執行的操作的速度。完成多個操作的單個語句要比完成單個操作的多個語句快。所以,就要找出可以組合在一起的語句,以減少腳本整體的執行時間。
多變數聲明
有個地方很多開發人員都容易創建很多語句,那就是多個變數的聲明。很容易看到程式碼中由多個var 語句來聲明多個變數,如下所示:
//4 個語句—— 很浪費
var count = 5;
var color = "blue";
var values = [1,2,3];
var now = new Date();
在強類型語言中,不同的數據類型的變數必須在不同的語句中聲明。然而,在 JavaScript 中所有的變數都可以使用單個 var 語句來聲明。前面的程式碼可以如下重寫:
//一個語句
var count = 5,
color = "blue",
values = [1,2,3],
now = new Date();
此處,變數聲明只用了一個 var 語句,之間由逗號隔開。在大多數情況下這種優化都非常容易做,並且要比單個變數分別聲明快很多。
插入迭代值
當使用迭代值(也就是在不同的位置進行增加或減少的值)的時候,儘可能合併語句。請看以下程式碼:
var name = values[i];
i++;
前面這 2 句語句各只有一個目的:第一個從 values 數組中獲取值,然後存儲在 name 中;第二個
給變數 i 增加 1。這兩句可以通過迭代值插入第一個語句組合成一個語句,如下所示:
var name = values[i++];
使用數組和對象字面量
你可能看過兩種創建數組和對象的方法:使用構造函數或者是使用字面量。使用構造函數總是要用到更多的語句來插入元素或者定義屬性,而字面量可以將這些操作在一個語句中完成。請看以下例子:
//用 4 個語句創建和初始化數組——浪費
var values = new Array();
values[0] = 123;
values[1] = 456;
values[2] = 789;
//用 4 個語句創建和初始化對象——浪費
var person = new Object();
person.name = "Nicholas";
person.age = 29;
person.sayName = function(){
alert(this.name);
};
這段程式碼中,只創建和初始化了一個數組和一個對象。各用了 4 個語句:一個調用構造函數,其他3 個分配數據。其實可以很容易地轉換成使用字面量的形式,如下所示:
//只用一條語句創建和初始化數組
var values = [123, 456, 789];
//只用一條語句創建和初始化對象
var person = {
name : "Nicholas",
age : 29,
sayName : function(){
alert(this.name);
}
};
優化DOM交互
DOM 操作與交互要消耗大量時間,因為它們往往需要重新渲染整個頁面或者某一部分。進一步說,看似細微的操作也可能要花很久來執行,因為 DOM 要處理非常多的資訊。理解如何優化與 DOM 的交互可以極大得提高腳本完成速度。
最小化現場更新
一旦你需要訪問的 DOM 部分是已經顯示的頁面的一部分,那麼你就是在進行一個現場更新。之所以叫現場更新,是因為需要立即(現場)對頁面對用戶的顯示進行更新。每一個更改,不管是插入單個字元,還是移除整個片段,都有一個性能懲罰,因為瀏覽器要重新計算無數尺寸以進行更新。現場更新進行得越多,程式碼完成執行所花的時間就越長;完成一個操作所需的現場更新越少,程式碼就越快。請看以下例子:
var list = document.getElementById("myList"),
item,
i;
for (i=0; i < 10; i++) {
item = document.createElement("li");
list.appendChild(item);
item.appendChild(document.createTextNode("Item " + i));
}
這段程式碼為列表添加了 10 個項目。添加每個項目時,都有 2 個現場更新:一個添加<li>元素,另一個給它添加文本節點。這樣添加 10 個項目,這個操作總共要完成 20 個現場更新。要修正這個性能瓶頸,需要減少現場更新的數量。一般有 2 種方法。第一種是將列表從頁面上移除,最後進行更新,最後再將列表插回到同樣的位置。這個方法不是非常理想,因為在每次頁面更新的時候它會不必要的閃爍。第二個方法是使用文檔片段來構建 DOM 結構,接著將其添加到 List 元素中。這個方式避免了現場更新和頁面閃爍問題。請看下面內容:
var list = document.getElementById("myList"),
fragment = document.createDocumentFragment(),
item,
i;
for (i=0; i < 10; i++) {
item = document.createElement("li");
fragment.appendChild(item);
item.appendChild(document.createTextNode("Item " + i));
}
list.appendChild(fragment);
使用innerHTML
有兩種在頁面上創建 DOM 節點的方法:使用諸如 createElement()和 appendChild()之類的DOM 方法,以及使用 innerHTML。對於小的 DOM 更改而言,兩種方法效率都差不多。然而,對於大的 DOM 更改,使用 innerHTML 要比使用標準 DOM 方法創建同樣的 DOM 結構快得多。
使用 innerHTML 的關鍵在於(和其他 DOM 操作一樣)最小化調用它的次數。例如,下面的程式碼在這個操作中用到 innerHTML 的次數太多了:
var list = document.getElementById("myList"),
i;
for (i=0; i < 10; i++) {
list.innerHTML += "<li>Item " + i + "</li>"; //避免!!!
}
這段程式碼的問題在於每次循環都要調用 innerHTML,這是極其低效的。調用 innerHTML 實際上就是一次現場更新,所以也要如此對待。構建好一個字元串然後一次性調用 innerHTML 要比調用innerHTML 多次快得多
使用事件代理
大多數 Web 應用在用戶交互上大量用到事件處理程式。頁面上的事件處理程式的數量和頁面響應用戶交互的速度之間有個負相關。為了減輕這種懲罰,最好使用事件代理。
任何可以冒泡的事件都不僅僅可以在事件目標上進行處理,目標的任何祖先節點上也能處理。使用這個知識,就可以將事件處理程式附加到更高層的地方負責多個目標的事件處理。如果可能,在文檔級別附加事件處理程式,這樣可以處理整個頁面的事件。
注意HTMLCOllection
任何時候要訪問 HTMLCollection,不管它是一個屬性還是一個方法,都是在文檔上進行一個查詢,這個查詢開銷很昂貴。最小化訪問 HTMLCollection 的次數可以極大地改進腳本的性能。
也許優化 HTMLCollection 訪問最重要的地方就是循環了。前面提到過將長度計算移入 for 循環的初始化部分。現在看一下這個例子:
var images = document.getElementsByTagName("img"),
i, len;
for (i=0, len=images.length; i < len; i++){
//處理
}
這裡的關鍵在於長度 length 存入了 len 變數,而不是每次都去訪問 HTMLCollection 的 length屬性。當在循環中使用 HTMLCollection 的時候,下一步應該是獲取要使用的項目的引用,如下所示,以便避免在循環體內多次調用 HTMLCollection。
var images = document.getElementsByTagName("img"),
image,
i, len;
for (i=0, len=images.length; i < len; i++){
image = images[i];
//處理
}
這段程式碼添加了 image 變數,保存了當前的影像。這之後,在循環內就沒有理由再訪問 images 的HTMLCollection 了。
ps:為什麼加個中間變數就能夠加快性能?感覺就是個指針,指向了記憶體,調用的方法之類應該都是同一個對象吧?就很神奇。
部署
完備 JavaScript 程式碼可以用於部署的一件很重要的事情,就是給它開發某些類型的構建過程。軟體開發的典型模式是寫程式碼—編譯—測試,即首先書寫好程式碼,將其編譯通過,然後運行並確保其正常工作。由於 JavaScript 並非一個編譯型語言,模式變成了寫程式碼—測試,這裡你寫的程式碼就是你要在瀏覽器中測試的程式碼。這個方法的問題在於它不是最優的,你寫的程式碼不應該原封不動地放入瀏覽器中,理由如下所示。
- 知識產權問題 —— 如果把帶有完整注釋的程式碼放到線上,那別人就更容易知道你的意圖,對它再利用,並且可能找到安全漏洞。
- 文件大小 —— 書寫程式碼要保證容易閱讀,才能更好地維護,但是這對於性能是不利的。瀏覽器並不能從額外的空白字元或者是冗長的函數名和變數名中獲得什麼好處。
- 程式碼組織 —— 組織程式碼要考慮到可維護性並不一定是傳送給瀏覽器的最好方式
記住將程式碼分離成多個文件只是為了提高可維護性,並非為了部署。要進行部署的時候,需要將這些源程式碼合併為一個或幾個歸併文件。推薦 Web 應用中儘可能使用最少的 JavaScript 文件,是因為 HTTP請求是 Web 中的主要性能瓶頸之一。記住通過<script>標記引用 JavaScript 文件是一個阻塞操作,當程式碼下載並運行的時候會停止其他所有的下載。因此,盡量從邏輯上將 JavaScript 程式碼分組成部署文件。
驗證
首先,驗證過程難以自動化或者在不同系統間直接移植。其次,除了語法錯誤外,很多問題只有在執行程式碼的時候才會遇到,這給錯誤留下了空間;有些工具可以幫助確定 JavaScript 程式碼中潛在的問題,其中最著名的就是 Douglas Crockford 的 JSLint (www.jslint.com)。
壓縮
當談及 JavaScript 文件壓縮,其實在討論兩個東西:程式碼長度和配重(Wire weight)。程式碼長度指的是瀏覽器所需解析的位元組數,配重指的是實際從伺服器傳送到瀏覽器的位元組數。在 Web 開發的早期,這兩個數字幾乎是一樣的,因為從伺服器端到客戶端原封不動地傳遞了源文件。而在今天的 Web 上,這兩者很少相等,實際上也不應相等。
文件壓縮
因為 JavaScript 並非編譯為位元組碼,而是按照源程式碼傳送的,程式碼文件通常包含瀏覽器執行所不需要的額外的資訊和格式。注釋,額外的空白,以及長長的變數名和函數名雖然提高了可讀性,但卻是傳送給瀏覽器時不必要的位元組。不過,我們可以使用壓縮工具減少文件的大小。壓縮器一般進行如下一些步驟:
- 刪除額外的空白(包括換行);
- 刪除所有注釋;
- 縮短變數名。
HTTP壓縮
一個指定了文件使用了給定格式進行了壓縮的 HTTP 頭包含在了伺服器響應中。接著瀏覽器會查看該 HTTP 頭確定文件是否已被壓縮,然後使用合適的格式進行解壓縮。結果是和原來的程式碼量相比在網路中傳遞的位元組數量大大減少了。
第25章 新興的API
requestAnimationFrame()
這個方法會告訴瀏覽器:有一個動畫開始了。進而瀏覽器就可以確定重繪的最佳方式。
Page Visibility API
不知道用戶是不是正在與頁面交互,這是困擾廣大 Web 開發人員的一個主要問題。如果頁面最小化了或者隱藏在了其他標籤頁後面,那麼有些功能是可以停下來的,比如輪詢伺服器或者某些動畫效果。而 Page Visibility API(頁面可見性 API)就是為了讓開發人員知道頁面是否對用戶可見而推出的。
Geolocation API
地理定位(geolocation)是最令人興奮,而且得到了廣泛支援的一個新 API。 通過這套 API, JavaScript程式碼能夠訪問到用戶的當前位置資訊。當然,訪問之前必須得到用戶的明確許可,即同意在頁面中共享其位置資訊。如果頁面嘗試訪問地理定位資訊,瀏覽器就會顯示一個對話框,請求用戶許可共享其位置資訊。
File API
不能直接訪問用戶電腦中的文件,一直都是 Web 應用開發中的一大障礙。 2000 年以前,處理文件的唯一方式就是在表單中加入<input type="file">欄位,僅此而已。 File API(文件 API)的宗旨是為 Web 開發人員提供一種安全的方式,以便在客戶端訪問用戶電腦中的文件,並更好地對這些文件執行操作。支援 File API 的瀏覽器有 IE10+、 Firefox 4+、 Safari 5.0.5+、 Opera 11.1+和 Chrome。
Web計時
頁面性能一直都是 Web 開發人員最關注的領域。但直到最近,度量頁面性能指標的唯一方式,就是提高程式碼複雜程度和巧妙地使用 JavaScript 的 Date 對象。 Web Timing API 改變了這個局面,讓開發人員通過 JavaScript 就能使用瀏覽器內部的度量結果,通過直接讀取這些資訊可以做任何想做的分析。
Web Workers
隨著 Web 應用複雜性的與日俱增,越來越複雜的計算在所難免。長時間運行的 JavaScript 進程會導致瀏覽器凍結用戶介面,讓人感覺螢幕「凍結」了。 Web Workers 規範通過讓 JavaScript 在後台運行解決了這個問題。瀏覽器實現 Web Workers 規範的方式有很多種,可以使用執行緒、後台進程或者運行在其他處理器核心上的進程,等等。具體的實現細節其實沒有那麼重要,重要的是開發人員現在可以放心地運行 JavaScript,而不必擔心會影響用戶體驗了。
附錄C JavaScript庫
通用庫
YUI
它是一個開源 JavaScript 與 CSS 庫,以一種組件方式設計的。這個庫不只有一個文件;它包含了很多文件,提供各種不同的配置,讓你可以按需載入。 YUI(Yahoo!User Interface Library,雅虎用戶介面庫)涵蓋了 JavaScript 的所有方面,從基本的工具及幫助函數到完善的瀏覽器部件。
Prototype
它是一個提供了常見任務 API的開源庫。最初是針對 Ruby on Rails框架中的使用而開發的,Prototype是類驅動的,旨在為 JavaScript 提供類定義和繼承。因此, Prototype 提供了很多類,用於將常見或複雜功能封裝為簡單的 API 調用。 Prototype 只有一個單獨的文件,可以很容易地放入任意頁面。
Dojo Toolkit
Dojo Toolkit 開源庫基於一種包系統建模,一組功能組成一個包,可以按需載入。 Dojo 提供了範圍廣泛的選項和配置,幾乎涵蓋了你要用 JavaScript 做的任何事情。 Dojo Toolkit 由 Alex Russell 創建,並由 Dojo 基金會的僱員和志願者維護。
MooTools
MooTools 是一個為了精簡和優化而設計的開源庫,它為內置 JavaScript 對象添加了各種方法,以通過接近的介面提供新功能,或者直接提供新的對象。 MooTools 的短小精悍受到一些 Web 開發者的青睞。
JQuery
jQuery 是一個給 JavaScript 提供了函數式編程介面的開源庫。它是一個完整的庫,其核心是構建於CSS 選擇器上的,用來操作 DOM 元素。通過鏈式調用, jQuery 程式碼看上去更像是對於應該發生什麼的描述而不是 JavaScript 程式碼。這種程式碼風格在設計師和原型製作人中非常流行。
MochiKit
MochKit 是一個由一些小工具組成的開源庫,它以完善的文檔和完整的測試見長,擁有大量 API 及相關範例文檔以及數百個測試來確保品質。
Underscore.js
雖然嚴格來講 Underscore.js 並不是一個通用的庫,但它的確為 JavaScript 中的功能性編程提供了很多額外的功能。其文檔稱 Underscore.js 是對 jQuery 的補充,提供了操作對象、數組、函數和其他 JavaScript數據類型的更多的低級功能。
互聯網應用
互聯網應用庫是針對於簡化完整的 Web 應用開發而設計的。它們並不提供應用問題的小塊組件,而是提供了快速應用開發的整個概念框架。雖然這些庫也可能提供一些底層功能,但他們的目標是幫助用戶快速開發 Web 應用。
Backbone.js
Backbone.js 是構建於 Underscore.js 基礎之上的一個迷你 MVC 開源庫,它針對單頁應用進行優化,讓你能夠隨著應用狀態變化方便地更新頁面的任意部分。
Rico
Rico 是一個開源庫,旨在讓行為豐富的互聯網應用的開發更加簡單。它提供了 Ajax、動畫、樣式以及部件的工具。
qooxdoo
它是一個旨在為整個互聯網應用開發周期提供幫助的開源庫。 qooxdoo 實現了它自己的類和介面,用於創建類似於傳統面向對象語言的編程模型。這個庫包含了一個完整的 GUI 工具包以及用於簡化前端構建過程的編譯器。
動畫和特效
動畫和其他視覺特效也成為了 Web 開發的重要部分。在網頁上做出流暢的動畫是一個很重要的任務,一些開發者已經做出了易用的庫,提供流暢的動畫和特效。前面提到的很多通用 JavaScript 庫也有動畫功能。
script.aculo.us
cript.aculo.us 是 Prototype 的「同伴」,它提供了出色特效的簡單使用方式,使用的東西不超過是 CSS和 DOM。 Prototype 必須在使用 script.aculo.us 之前載入。 script.aculo.us 是最流行的特效庫之一,世界上很多網站和 Web 應用都在使用它。
moo.fx
moo.fx 開源動畫庫是設計在 Prototype 或者 MooTools 之上運行的。它的目標是儘可能小(最新的版本是 3KB),並支援開發人員用儘可能少的程式碼創建動畫。 MooTools 是默認包含 moo.fx 的,但也可以單獨下載用於 Prototype 中。
Lightbox
Lightbox 是一個用於在任意頁面上創建影像浮動層的 JavaScript 庫,依賴於 Prototype 和script.aculo.us 來實現它的視覺特效。基本的理念是讓用戶在一個浮動層中瀏覽一個或者一系列影像,而不必離開當前頁面。 Lightbox 浮動層無論是外觀還是過渡效果都可以自定義。
加密
隨著 Ajax 應用的流行,對於瀏覽器端加密以確保通訊安全的需求也越來越多。幸好,一些人已經在 JavaScript 中實現了常用的安全演算法。這些庫大部分並沒有其作者的正式支援,但還是被廣泛應用著。
JavaScript MD5
該開源庫實現了 MD4、 MD5 以及 SHA-1 安全散列函數。作者 Paul Johnston 和其他一些貢獻者將每個演算法作為一個文件,創建了這個用於 Web 應用的豐富的庫。主頁上提供了散列演算法的概述、對於其弱點的討論以及適當的使用方法。
JavaScrypt
該 JavaScript 庫實現了 MD5 和 AES(256 位)加密演算法。 JavaScrypt 的網站提供了很多關於密碼學歷史及其在電腦中應用的資訊。但是缺乏關於如何將該庫集成到 Web 應用中的基本文檔, JavaScrypt的程式碼裡面全都是深奧的數學處理和計算。
附錄D JavaScript工具
校驗器
JavaScript 調試有一個問題,很多 IDE 並不能在輸入的時候自動指出語法錯誤。大多數開發者寫了一部分程式碼之後要將其載入到瀏覽器中查找錯誤。你可以通過在部署之前校驗 JavaScript 程式碼,以便顯著地減少此類錯誤。校驗器提供了基本的語法檢查,並給出某些風格的警告。
JSLint
JSLint 是一個由 Douglas Crockford 撰寫的 JavaScript 校驗器。它通過跨瀏覽器問題的最小共同點檢查,能夠從核心層次上檢查語法錯誤。(它遵循最嚴格的規則來確保程式碼到處都能運行。)你可以啟用Crockford 對於程式碼風格的警告,包括程式碼格式、未聲明的全局變數的使用以及其他更多警告。儘管 JSLint是用 JavaScript 寫的,但是通過基於 Java 的 Rhino 解釋器,它可以在命令行中運行,或者通過 WScript或者其他 JavaScript 解釋器。網站上提供了針對各種命令行解釋器的自定義版本。
JSHint
JSHint 是 JSLint 的一個分支,為應用規則提供了更多的自定義功能。與 JSLint 類似,它首先檢查語法錯誤,然後檢查有問題的編碼模式。 JSLint 的每一項檢查 JSHint 都有,但開發人員可以更好地控制應用什麼規則。與 JSLint 一樣, JSHint 也能使用 Rhino 在命令行中運行。
JavaScript Lint
它和 JSLint 完全不相干, JavaScript Lint 是 Matthias Miller 寫的一個基於 C 的 JavaScript 校驗器。它使用了 SpiderMonkey(即 Firefox 所用的 JavaScript 解釋器)來分析程式碼並查找語法錯誤。這個工具包含大量選項,可以啟用額外關於編碼風格的警告,以及未聲明的變數和不可到達的程式碼警告。
壓縮器
JavaScript 構建過程中很重要的一部分,是壓縮輸出並移除多餘的字元。這樣做可以確保傳送到瀏覽器的位元組數最少,最終加速了用戶體驗。有幾種壓縮比率不同的工具可以選擇。
JSMin
JSMin 是由 Douglas Crockford 寫的一個基於 C 的壓縮器,進行最基本的 JavaScript 壓縮。它主要是移除空白和注釋,確保最終的程式碼依然可以被順利執行。
Dojo ShrinkSafe
負責 Dojo Toolkit 的同一批人開發了一個叫做 ShrinkSafe 的工具,它使用了 Rhino JavaScript 解釋器首先將 JavaScript 程式碼解析為記號流,然後用它們來安全壓縮程式碼。和 JSMin 一樣, ShrinkSafe 移除多餘的空白符(不包括換行)和注釋,但是還更進一步將局部變數替換為兩個字元長的變數名。
YUI Compressor
YUI 小組有一個叫做 YUI Compressor 的壓縮器。和 ShrinkSafe 類似, YUI Compressor 利用了 Rhino解釋器將 JavaScript 程式碼解析為記號流,並移除注釋和空白字元並替換變數名。與 ShrinkSafe 不同, YUICompressor 還移除換行並進行一些細微的優化進一步節省位元組數。
單元測試
JsUnit
最早的 JavaScript 單元測試框架,不綁定於任何特定的 JavaScript 庫。 JsUnit 是 Java 知名的 JUnit 測試框架的移植。測試在頁面中運行,並可以設置為自動測試並將結果提交到伺服器。
YUI Test
作為 YUI 的一部分, YUI Test 不僅可以用於測試使用 YUI 的程式碼,也可以測試網站或者應用中的任何程式碼。 YUI Test 包含了簡單和複雜的斷言,以及一種模擬簡單的滑鼠和鍵盤事件的方法。該框架在Yahoo! Developer Network 上有完整的文檔描述,包含了例子、 API 文檔和更多內容。
DOH
DOH(Dojo Object Harness)在發布給大家使用之前,最初是作為 Dojo 內部的單元測試工具出現的。和其他框架一樣,單元測試是在瀏覽器中運行的。
qUnit
qUnit 是為測試 jQuery 而開發的一個單元測試框架。 jQuery 本身的確使用 qUnit 進行各項測試。除此之外, qUnit 與 jQuery 並沒有綁定關係,也可以用它來測試所有 JavaScript 程式碼。
文檔生成器
JsSoc Toolkit
JsDoc Toolkit 是最早出現的 JavaScript文檔生成器之一。它要求你在程式碼中輸入類似 Javadoc的注釋,然後處理這些注釋並輸出為 HTML 文件。你可以自定義 HTML 的格式,這需使用預定義的 JsDoc 模板或者創建自己的模版。 JsDoc Toolkit 可以以 Java 包的形式獲得。
YUI Doc
YUI Doc 是 YUI 的文檔生成器。該生成器以 Python 書寫,所以它要求安裝有 Python 運行時環境。YUI Doc 可以輸出集成了屬性和方法搜索(用 YUI 的自動完成掛件實現的)的 HTML 文件。和 JsDoc一樣, YUI Doc 要求源程式碼中使用類似 Javadoc 的注釋。默認的 HTML 可以通過修改默認的 HTML 模板文件和相關的樣式表來更改。
AjaxDoc
AjaxDoc 的目標和前面提到的生成器有些差異。它不為 JavaScript 文檔生成 HTML 文件,而是創建與針對.NET 語言(如 C#和 Visual Basic. NET)所創建文件相同格式的 XML 文件。這樣做就可以由標準的.NET 文檔生成器創建 HTML 文件形式的文檔。 AjaxDoc 使用類似於所有.NET 語言用到的文檔注釋格式。創建 AjaxDoc 是針對 ASP.NET 的 Ajax 解決方案,但是它也可以用於單獨的項目。
安全執行環境
隨著 mashup 應用越來越流行,對於允許來自外界的 JavaScript 存在於同一個頁面上並執行有著越來越多的需求。這導致了一些訪問受限功能的安全問題。以下工具旨在創建安全的執行環境,其中不同來源的 JavaScript 可以共存,而不會互相影響。
ADsafe
由 Douglas Crockford 創建, ADsafe 是 JavaScript 的子集,這個子集被認為可以被第三方腳本安全訪問。對於用 ADsafe 運行的程式碼,頁面必須包含 ADsafe JavaScript 庫並標記為 ADsafe 掛件格式。因此,程式碼可以在任何頁面上安全執行。
Caja
Caja 用一種獨特的方式來確保 JavaScript 的安全執行。類似於 ADsafe, Caja 定義了 JavaScript 的一個可以用安全方式使用的子集。 Caja 繼而可以清理 JavaScript 程式碼並驗證它只按照預期的方式運行。作為該項目的一部分,有一種叫做 Cajita 的語言,它是 JavaScript 功能的一種更小的子集。 Caja 還處於幼年期,但是已經展示了很多前景,允許多個腳本在同一個頁面執行而沒有惡意活動的可能。

