引言
為什么會有這一篇“重新介紹”呢?因為 JavaScript 堪稱世界上被人誤解最深的編程語言。雖然常被嘲為“玩具語言”,但在它看似簡潔的外衣下,還隱藏著強大的語言特性。 JavaScript 目前廣泛應用于眾多知名應用中,對于網頁和移動開發者來說,深入理解 JavaScript 就尤有必要。
先從這門語言的歷史談起是有必要的。在1995 年 Netscape 一位名為 Brendan Eich 的工程師創造了 JavaScript,隨后在 1996 年初,JavaScript 首先被應用于 Netscape 2 瀏覽器上。最初的 JavaScript 名為 LiveScript,后來因為 Sun Microsystem 的 Java 語言的興起和廣泛使用,Netscape 出于宣傳和推廣的考慮,將它的名字從最初的 LiveScript 更改為 JavaScript——盡管兩者之間并沒有什么共同點。這便是之后混淆產生的根源。
幾個月后,Microsoft 隨著 IE 3 推出了一個與之基本兼容的語言 JScript。又幾個月后,Netscape 將 JavaScript 提交至 Ecma International(一個歐洲標準化組織), ECMAScript 標準第一版便在 1997 年誕生了,隨后在 1999 年以 ECMAScript 第三版的形式進行了更新,從那之后這個標準沒有發生過大的改動。由于委員會在語言特性的討論上發生分歧,ECMAScript 第四版尚未推出便被廢除,但隨后于 2009 年 12 月發布的 ECMAScript 第五版引入了第四版草案加入的許多特性。第六版標準已經于2015年六月發布。
注意: 為熟悉起見,從這里開始我們將用 “JavaScript” 替代 ECMAScript 。
與大多數編程語言不同,JavaScript 沒有輸入或輸出的概念。它是一個在宿主環境(host environment)下運行的腳本語言,任何與外界溝通的機制都是由宿主環境提供的。瀏覽器是最常見的宿主環境,但在非常多的其他程序中也包含 JavaScript 解釋器,如 Adobe Acrobat、Photoshop、SVG 圖像、Yahoo! 的 Widget 引擎,以及 Node.js 之類的服務器端環境。JavaScript 的實際應用遠不止這些,除此之外還有 NoSQL 數據庫(如開源的 Apache CouchDB)、嵌入式計算機,以及包括 GNOME (注:GNU/Linux 上最流行的 GUI 之一)在內的桌面環境等等。
概覽
JavaScript 是一種面向對象的動態語言,它包含類型、運算符、標準內置( built-in)對象和方法。它的語法來源于 Java 和 C,所以這兩種語言的許多語法特性同樣適用于 JavaScript。需要注意的一個主要區別是 JavaScript 不支持類,類這一概念在 JavaScript 通過對象原型(object prototype)得到延續(有關 ES6 類的內容參考https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Classes)。另一個主要區別是 JavaScript 中的函數也是對象,JavaScript 允許函數在包含可執行代碼的同時,能像其他對象一樣被傳遞。
先從任何編程語言都不可缺少的組成部分——“類型”開始。JavaScript 程序可以修改值(value),這些值都有各自的類型。JavaScript 中的類型包括:
Number(數字)
String(字符串)
Boolean(布爾)
Function(函數)
Object(對象)
Symbol (第六版新增)
還有看上去有些…奇怪的 undefined(未定義)類型和 null(空)類型。此外還有Array(數組)類型,以及分別用于表示日期和正則表達式的 Date(日期)和 RegExp(正則表達式),這三種類型都是特殊的對象。嚴格意義上說,Function(函數)也是一種特殊的對象。所以準確來說,JavaScript 中的類型應該包括這些:
Number(數字)
String(字符串)
Boolean(布爾)
Symbol(符號)(第六版新增)
Object(對象)
Function(函數)
Array(數組)
Date(日期)
RegExp(正則表達式)
Null(空)
Undefined(未定義)
JavaScript 還有一種內置Error(錯誤)類型,這個會在之后的介紹中提到;現在我們先討論下上面這些類型。
數字
根據語言規范,JavaScript 采用“IEEE 754 標準定義的雙精度64位格式”("double-precision 64-bit format IEEE 754 values")表示數字。據此我們能得到一個有趣的結論,和其他編程語言(如 C 和 Java)不同,JavaScript 不區分整數值和浮點數值,所有數字在 JavaScript 中均用浮點數值表示,所以在進行數字運算的時候要特別注意。看看下面的例子:
0.1 + 0.2 = 0.30000000000000004
在具體實現時,整數值通常被視為32位整型變量,在個別實現(如某些瀏覽器)中也以32位整型變量的形式進行存儲,直到它被用于執行某些32位整型不支持的操作,這是為了便于進行位操作。
JavaScript 支持標準的算術運算符,包括加法、減法、取模(或取余)等等。還有一個之前沒有提及的內置對象 Math(數學對象),用以處理更多的高級數學函數和常數:
Math.sin(3.5);
var d = Math.PI * (r + r);
你可以使用內置函數 parseInt() 將字符串轉換為整型。該函數的第二個參數表示字符串所表示數字的基(進制):
parseInt("123", 10); // 123
parseInt("010", 10); //10
如果調用時沒有提供第二個參數(字符串所表示數字的基),2013 年以前的 JavaScript 實現會返回一個意外的結果:
parseInt("010"); // 8
parseInt("0x10"); // 16
這是因為字符串以數字 0 開頭,parseInt()函數會把這樣的字符串視作八進制數字;同理,0x開頭的字符串則視為十六進制數字。
如果想把一個二進制數字字符串轉換成整數值,只要把第二個參數設置為 2 就可以了:
parseInt("11", 2); // 3
JavaScript 還有一個類似的內置函數 parseFloat(),用以解析浮點數字符串,與parseInt()不同的地方是,parseFloat()只應用于解析十進制數字。
單元運算符 + 也可以把數字字符串轉換成數值:
+ "42"; // 42
+ "010"; // 10
+ "0x10"; // 16
如果給定的字符串不存在數值形式,函數會返回一個特殊的值 NaN(Not a Number 的縮寫):
parseInt("hello", 10); // NaN
要小心NaN:如果把 NaN 作為參數進行任何數學運算,結果也會是 NaN:
NaN + 5; //NaN
可以使用內置函數 isNaN() 來判斷一個變量是否為 NaN:
isNaN(NaN); // true
JavaScript 還有兩個特殊值:Infinity(正無窮)和 -Infinity(負無窮):
1 / 0; // Infinity
-1 / 0; // -Infinity
可以使用內置函數 isFinite() 來判斷一個變量是否是一個有窮數, 如果類型為Infinity, -Infinity 或 NaN則返回false:
isFinite(1/0); // false
isFinite(Infinity); // false
isFinite(NaN); // false
isFinite(-Infinity); // false
isFinite(0); // true
isFinite(2e64); // true
isFinite("0"); // true
//如果是純數值類型的檢測,則返回false:Number.isFinite("0");
備注: parseInt() 和 parseFloat() 函數會嘗試逐個解析字符串中的字符,直到遇上一個無法被解析成數字的字符,然后返回該字符前所有數字字符組成的數字。使用運算符 "+" 將字符串轉換成數字,只要字符串中含有無法被解析成數字的字符,該字符串都將被轉換成 NaN。請你用這兩種方法分別解析“10.2abc”這一字符串,比較得到的結果,理解這兩種方法的區別。
字符串
JavaScript 中的字符串是一串Unicode 字符序列。這對于那些需要和多語種網頁打交道的開發者來說是個好消息。更準確地說,它們是一串UTF-16編碼單元的序列,每一個編碼單元由一個 16 位二進制數表示。每一個Unicode字符由一個或兩個編碼單元來表示。
如果想表示一個單獨的字符,只需使用長度為 1 的字符串。
通過訪問字符串的 長度(編碼單元的個數)屬性可以得到它的長度。
"hello".length; // 5
這是我們第一次碰到 JavaScript 對象。我們有沒有提過你可以像 objects 一樣使用字符串?是的,字符串也有methods(方法)能讓你操作字符串和獲取字符串的信息。
"hello".charAt(0); // "h"
"hello, world".replace("hello", "goodbye"); // "goodbye, world"
"hello".toUpperCase(); // "HELLO"
其他類型
JavaScript 中 null 和 undefined 是不同的,前者表示一個空值(non-value),必須使用null關鍵字才能訪問,后者是“undefined(未定義)”類型的對象,表示一個未初始化的值,也就是還沒有被分配的值。我們之后再具體討論變量,但有一點可以先簡單說明一下,JavaScript 允許聲明變量但不對其賦值,一個未被賦值的變量就是 undefined 類型。還有一點需要說明的是,undefined 實際上是一個不允許修改的常量。
JavaScript 包含布爾類型,這個類型的變量有兩個可能的值,分別是 true 和 false(兩者都是關鍵字)。根據具體需要,JavaScript 按照如下規則將變量轉換成布爾類型:
false、0、空字符串("")、NaN、null 和 undefined 被轉換為 false
所有其他值被轉換為 true
也可以使用 Boolean() 函數進行顯式轉換:
Boolean(""); // false
Boolean(234); // true
不過一般沒必要這么做,因為 JavaScript 會在需要一個布爾變量時隱式完成這個轉換操作(比如在 if 條件語句中)。所以,有時我們可以把轉換成布爾值后的變量分別稱為 真值(true values)——即值為 true 和 假值(false values)——即值為 false;也可以分別稱為“真的”(truthy)和“假的”(falsy)。
JavaScript 支持包括 &&(邏輯與)、|| (邏輯或)和!(邏輯非)在內的邏輯運算符。下面會有所提到。
變量
在 JavaScript 中聲明一個新變量的方法是使用關鍵字 var:
var a;
var name = "simon";
如果聲明了一個變量卻沒有對其賦值,那么這個變量的類型就是 undefined。
JavaScript 與其他語言的(如 Java)的重要區別是在 JavaScript 中語句塊(blocks)是沒有作用域的,只有函數有作用域。因此如果在一個復合語句中(如 if 控制結構中)使用 var 聲明一個變量,那么它的作用域是整個函數(復合語句在函數中)。 但是從 ECMAScript Edition 6 開始將有所不同的, let 和 const 關鍵字允許你創建塊作用域的變量。
運算符
JavaScript的算術操作符包括 +、-、*、/ 和 % ——求余(與模運算不同)。賦值使用 = 運算符,此外還有一些復合運算符,如 += 和 -=,它們等價于 x = x op y。
x += 5; // 等價于 x = x + 5;
可以使用 ++ 和 -- 分別實現變量的自增和自減。兩者都可以作為前綴或后綴操作符使用。
- 操作符還可以用來連接字符串:
"hello" + " world"; // hello world
如果你用一個字符串加上一個數字(或其他值),那么操作數都會被首先轉換為字符串。如下所示:
"3" + 4 + 5; // 345
3 + 4 + "5"; // 75
這里不難看出一個實用的技巧——通過與空字符串相加,可以將某個變量快速轉換成字符串類型。
JavaScript 中的比較操作使用 <、>、<= 和 >=,這些運算符對于數字和字符串都通用。相等的比較稍微復雜一些。由兩個“=(等號)”組成的相等運算符有類型自適應的功能,具體例子如下:
123 == "123" // true
1 == true; // true
如果在比較前不需要自動類型轉換,應該使用由三個“=(等號)”組成的相等運算符:
1 === true; //false
123 === "123"; // false
JavaScript 還支持 != 和 !== 兩種不等運算符,具體區別與兩種相等運算符的區別類似。
JavaScript 還提供了 位操作符。
控制結構
JavaScript 的控制結構與其他類 C 語言類似。可以使用 if 和 else 來定義條件語句,還可以連起來使用:
var name = "kittens";
if (name == "puppies") {
name += "!";
} else if (name == "kittens") {
name += "!!";
} else {
name = "!" + name;
}
name == "kittens!!"; // true
JavaScript 支持 while 循環和 do-while 循環。前者適合常見的基本循環操作,如果需要循環體至少被執行一次則可以使用 do-while:
while (true) {
// 一個無限循環!
}
var input;
do {
input = get_input();
} while (inputIsNotValid(input))
JavaScript 的 for 循環與 C 和 Java 中的相同,使用時可以在一行代碼中提供控制信息。
for (var i = 0; i < 5; i++) {
// 將會執行五次
}
&& 和 || 運算符使用短路邏輯(short-circuit logic),是否會執行第二個語句(操作數)取決于第一個操作數的結果。在需要訪問某個對象的屬性時,使用這個特性可以事先檢測該對象是否為空:
var name = o && o.getName();
或運算可以用來設置默認值:
var name = otherName || "default";
類似地,JavaScript 也有一個用于條件表達式的三元操作符:
var allowed = (age > 18) ? "yes" : "no";
在需要多重分支時可以使用 基于一個數字或字符串的switch 語句:
switch(action) {
case 'draw':
drawIt();
break;
case 'eat':
eatIt();
break;
default:
doNothing();
}
如果你不使用 break 語句,JavaScript 解釋器將會執行之后 case 中的代碼。除非是為了調試,一般你并不需要這個特性,所以大多數時候不要忘了加上 break。
switch(a) {
case 1: // 繼續向下
case 2:
eatIt();
break;
default:
doNothing();
}
default 語句是可選的。switch 和 case 都可以使用需要運算才能得到結果的表達式;在 switch 的表達式和 case 的表達式是使用 === 嚴格相等運算符進行比較的:
switch(1 + 3){
case 2 + 2:
yay();
break;
default:
neverhappens();
}
對象
JavaScript 中的對象可以簡單理解成“名稱-值”對,不難聯想 JavaScript 中的對象與下面這些概念類似:
Python 中的字典
Perl 和 Ruby 中的散列(哈希)
C/C++ 中的散列表
Java 中的 HashMap
PHP 中的關聯數組
這樣的數據結構設計合理,能應付各類復雜需求,所以被各類編程語言廣泛采用。正因為 JavaScript 中的一切(除了核心類型,core object)都是對象,所以 JavaScript 程序必然與大量的散列表查找操作有著千絲萬縷的聯系,而散列表擅長的正是高速查找。
“名稱”部分是一個 JavaScript 字符串,“值”部分可以是任何 JavaScript 的數據類型——包括對象。這使用戶可以根據具體需求,創建出相當復雜的數據結構。
有兩種簡單方法可以創建一個空對象:
var obj = new Object();
和:
var obj = {};
這兩種方法在語義上是相同的。第二種更方便的方法叫作“對象字面量(object literal)”法。這種也是 JSON 格式的核心語法,一般我們優先選擇第二種方法。
“對象字面量”也可以用來在對象實例中定義一個對象:
var obj = {
name: "Carrot",
"for": "Max",
details: {
color: "orange",
size: 12
}
}
對象的屬性可以通過鏈式(chain)表示方法進行訪問:
obj.details.color; // orange
obj["details"]["size"]; // 12
下面的例子創建了一個對象原型,Person,和這個原型的實例You。
function Person(name, age) {
this.name = name;
this.age = age;
}
// 定義一個對象
var You = new Person("You", 24);
// 我們創建了一個新的 Person,名稱是 "You"
// ("You" 是第一個參數, 24 是第二個參數..)
完成創建后,對象屬性可以通過如下兩種方式進行賦值和訪問:
obj.name = "Simon"
var name = obj.name;
和:
obj["name"] = "Simon";
var name = obj["name"];
這兩種方法在語義上也是相同的。第二種方法的優點在于屬性的名稱被看作一個字符串,這就意味著它可以在運行時被計算,缺點在于這樣的代碼有可能無法在后期被解釋器優化。它也可以被用來訪問某些以預留關鍵字作為名稱的屬性的值:
obj.for = "Simon"; // 語法錯誤,因為 for 是一個預留關鍵字
obj["for"] = "Simon"; // 工作正常
注意:從 EcmaScript 5 開始,預留關鍵字可以作為對象的屬性名(reserved words may be used as object property names "in the buff")。 這意味著當定義對象字面量時不需要用雙引號了。參見 ES5 Spec.
關于對象和原型的詳情參見: Object.prototype.
數組
JavaScript 中的數組是一種特殊的對象。它的工作原理與普通對象類似(以數字為屬性名,但只能通過[] 來訪問),但數組還有一個特殊的屬性——length(長度)屬性。這個屬性的值通常比數組最大索引大 1。
創建數組的傳統方法是:
var a = new Array();
a[0] = "dog";
a[1] = "cat";
a[2] = "hen";
a.length; // 3
使用數組字面量(array literal)法更加方便:
var a = ["dog", "cat", "hen"];
a.length; // 3
注意,Array.length 并不總是等于數組中元素的個數,如下所示:
var a = ["dog", "cat", "hen"];
a[100] = "fox";
a.length; // 101
記住:數組的長度是比數組最大索引值多一的數。
如果試圖訪問一個不存在的數組索引,會得到 undefined:
typeof(a[90]); // undefined
可以通過如下方式遍歷一個數組:
for (var i = 0; i < a.length; i++) {
// Do something with a[i]
}
遍歷數組的另一種方法是使用 for...in 循環。注意,如果有人向 Array.prototype 添加了新的屬性,使用這樣的循環這些屬性也同樣會被遍歷。所以并不推薦這種方法:
for (var i in a) {
// Do something with a[i]
}
ECMAScript 5 增加了遍歷數組的另一個方法 forEach():
["dog", "cat", "hen"].forEach(function(currentValue, index, array) {
// Do something with currentValue or array[index]
});
如果想在數組后追加元素,只需要:
a.push(item);
Array(數組)類自帶了許多方法。查看 array 方法的完整文檔。
函數
學習 JavaScript 最重要的就是要理解對象和函數兩個部分。最簡單的函數就像下面這個這么簡單:
function add(x, y) {
var total = x + y;
return total;
}
這個例子包括你需要了解的關于基本函數的所有部分。一個 JavaScript 函數可以包含 0 個或多個已命名的變量。函數體中的表達式數量也沒有限制。你可以聲明函數自己的局部變量。return 語句在返回一個值并結束函數。如果沒有使用 return 語句,或者一個沒有值的 return 語句,JavaScript 會返回 undefined。
已命名的參數更像是一個指示而沒有其他作用。如果調用函數時沒有提供足夠的參數,缺少的參數會被 undefined 替代。
add(); // NaN
// 不能在 undefined 對象上進行加法操作
你還可以傳入多于函數本身需要參數個數的參數:
add(2, 3, 4); // 5
// 將前兩個值相加,4被忽略了
這看上去有點蠢。函數實際上是訪問了函數體中一個名為 arguments 的內部對象,這個對象就如同一個類似于數組的對象一樣,包括了所有被傳入的參數。讓我們重寫一下上面的函數,使它可以接收任意個數的參數:
function add() {
var sum = 0;
for (var i = 0, j = arguments.length; i < j; i++) {
sum += arguments[i];
}
return sum;
}
add(2, 3, 4, 5); // 14
這跟直接寫成 2 + 3 + 4 + 5 也沒什么區別。接下來創建一個求平均數的函數:
function avg() {
var sum = 0;
for (var i = 0, j = arguments.length; i < j; i++) {
sum += arguments[i];
}
return sum / arguments.length;
}
avg(2, 3, 4, 5); // 3.5
這個很有用,但是卻帶來了新的問題。avg() 函數處理一個由逗號連接的變量串,但如果想得到一個數組的平均值該怎么辦呢?可以這么修改函數:
function avgArray(arr) {
var sum = 0;
for (var i = 0, j = arr.length; i < j; i++) {
sum += arr[i];
}
return sum / arr.length;
}
avgArray([2, 3, 4, 5]); // 3.5
但如果能重用我們已經創建的那個函數不是更好嗎?幸運的是 JavaScript 允許使用任意函數對象的apply() 方法來調用該函數,并傳遞給它一個包含了參數的數組。
avg.apply(null, [2, 3, 4, 5]); // 3.5
傳給 apply() 的第二個參數是一個數組,它將被當作 avg() 的參數使用,至于第一個參數 null,我們將在后面討論。這也正說明一個事實——函數也是對象。
JavaScript 允許你創建匿名函數:
var avg = function() {
var sum = 0;
for (var i = 0, j = arguments.length; i < j; i++) {
sum += arguments[i];
}
return sum / arguments.length;
};
這個函數在語義上與 function avg() 相同。你可以在代碼中的任何地方定義這個函數,就像寫普通的表達式一樣。基于這個特性,有人發明出一些有趣的技巧。與 C 中的塊級作用域類似,下面這個例子隱藏了局部變量:
var a = 1;
var b = 2;
(function() {
var b = 3;
a += b;
})();
a; // 4
b; // 2
JavaScript 允許以遞歸方式調用函數。遞歸在處理樹形結構(比如瀏覽器 DOM)時非常有用。
function countChars(elm) {
if (elm.nodeType == 3) { // 文本節點
return elm.nodeValue.length;
}
var count = 0;
for (var i = 0, child; child = elm.childNodes[i]; i++) {
count += countChars(child);
}
return count;
}
這里需要說明一個潛在問題——既然匿名函數沒有名字,那該怎么遞歸調用它呢?在這一點上,JavaScript 允許你命名這個函數表達式。你可以命名立即調用的函數表達式(IIFES——Immediately Invoked Function Expressions),如下所示:
var charsInBody = (function counter(elm) {
if (elm.nodeType == 3) { // 文本節點
return elm.nodeValue.length;
}
var count = 0;
for (var i = 0, child; child = elm.childNodes[i]; i++) {
count += counter(child);
}
return count;
})(document.body);
如上所提供的函數表達式的名稱的作用域僅僅是該函數自身。這允許引擎去做更多的優化,并且這種實現更可讀、友好。該名稱也顯示在調試器和一些堆棧跟蹤中,節省了調試時的時間。
需要注意的是 JavaScript 函數是它們本身的對象——就和 JavaScript 其他一切一樣——你可以給它們添加屬性或者更改它們的屬性,這與前面的對象部分一樣。
自定義對象
備注:關于 JavaScript 中面向對象編程更詳細的信息,請參考 JavaScript 面向對象簡介。
在經典的面向對象語言中,對象是指數據和在這些數據上進行的操作的集合。與 C++ 和 Java 不同,JavaScript 是一種基于原型的編程語言,并沒有 class 語句,而是把函數用作類。那么讓我們來定義一個人名對象,這個對象包括人的姓和名兩個域(field)。名字的表示有兩種方法:“名 姓(First Last)”或“姓, 名(Last, First)”。使用我們前面討論過的函數和對象概念,可以像這樣完成定義:
function makePerson(first, last) {
return {
first: first,
last: last
}
}
function personFullName(person) {
return person.first + ' ' + person.last;
}
function personFullNameReversed(person) {
return person.last + ', ' + person.first
}
s = makePerson("Simon", "Willison");
personFullName(s); // Simon Willison
personFullNameReversed(s); // Willison, Simon
上面的寫法雖然可以滿足要求,但是看起來很麻煩,因為需要在全局命名空間中寫很多函數。既然函數本身就是對象,如果需要使一個函數隸屬于一個對象,那么不難得到:
function makePerson(first, last) {
return {
first: first,
last: last,
fullName: function() {
return this.first + ' ' + this.last;
},
fullNameReversed: function() {
return this.last + ', ' + this.first;
}
}
}
s = makePerson("Simon", "Willison");
s.fullName(); // Simon Willison
s.fullNameReversed(); // Willison, Simon
上面的代碼里有一些我們之前沒有見過的東西:關鍵字 this。當使用在函數中時,this 指代當前的對象,也就是調用了函數的對象。如果在一個對象上使用點或者方括號來訪問屬性或方法,這個對象就成了 this。如果并沒有使用“點”運算符調用某個對象,那么 this 將指向全局對象(global object)。這是一個經常出錯的地方。例如:
s = makePerson("Simon", "Willison");
var fullName = s.fullName;
fullName(); // undefined undefined
當我們調用 fullName() 時,this 實際上是指向全局對象的,并沒有名為 first 或 last 的全局變量,所以它們兩個的返回值都會是 undefined。
下面使用關鍵字 this 改進已有的 makePerson函數:
function Person(first, last) {
this.first = first;
this.last = last;
this.fullName = function() {
return this.first + ' ' + this.last;
}
this.fullNameReversed = function() {
return this.last + ', ' + this.first;
}
}
var s = new Person("Simon", "Willison");
我們引入了另外一個關鍵字:new,它和 this 密切相關。它的作用是創建一個嶄新的空對象,然后使用指向那個對象的 this 調用特定的函數。注意,含有 this 的特定函數不會返回任何值,只會修改 this 對象本身。new 關鍵字將生成的 this 對象返回給調用方,而被 new 調用的函數成為構造函數。習慣的做法是將這些函數的首字母大寫,這樣用 new 調用他們的時候就容易識別了。
不過這個改進的函數還是和上一個例子一樣,單獨調用fullName() 時會產生相同的問題。
我們的 Person 對象現在已經相當完善了,但還有一些不太好的地方。每次我們創建一個 Person 對象的時候,我們都在其中創建了兩個新的函數對象——如果這個代碼可以共享不是更好嗎?
function personFullName() {
return this.first + ' ' + this.last;
}
function personFullNameReversed() {
return this.last + ', ' + this.first;
}
function Person(first, last) {
this.first = first;
this.last = last;
this.fullName = personFullName;
this.fullNameReversed = personFullNameReversed;
}
這種寫法的好處是,我們只需要創建一次方法函數,在構造函數中引用它們。那是否還有更好的方法呢?答案是肯定的。
function Person(first, last) {
this.first = first;
this.last = last;
}
Person.prototype.fullName = function() {
return this.first + ' ' + this.last;
}
Person.prototype.fullNameReversed = function() {
return this.last + ', ' + this.first;
}
Person.prototype 是一個可以被Person的所有實例共享的對象。它是一個名叫原型鏈(prototype chain)的查詢鏈的一部分:當你試圖訪問一個 Person 沒有定義的屬性時,解釋器會首先檢查這個 Person.prototype 來判斷是否存在這樣一個屬性。所以,任何分配給 Person.prototype 的東西對通過 this 對象構造的實例都是可用的。
這個特性功能十分強大,JavaScript 允許你在程序中的任何時候修改原型(prototype)中的一些東西,也就是說你可以在運行時(runtime)給已存在的對象添加額外的方法:
s = new Person("Simon", "Willison");
s.firstNameCaps(); // TypeError on line 1: s.firstNameCaps is not a function
Person.prototype.firstNameCaps = function() {
return this.first.toUpperCase()
}
s.firstNameCaps(); // SIMON
有趣的是,你還可以給 JavaScript 的內置函數原型(prototype)添加東西。讓我們給 String 添加一個方法用來返回逆序的字符串:
var s = "Simon";
s.reversed(); // TypeError on line 1: s.reversed is not a function
String.prototype.reversed = function() {
var r = "";
for (var i = this.length - 1; i >= 0; i--) {
r += this[i];
}
return r;
}
s.reversed(); // nomiS
定義新方法也可以在字符串字面量上用(string literal)。
"This can now be reversed".reversed(); // desrever eb won nac sihT
正如我前面提到的,原型組成鏈的一部分。那條鏈的根節點是 Object.prototype,它包括 toString() 方法——將對象轉換成字符串時調用的方法。這對于調試我們的 Person 對象很有用:
var s = new Person("Simon", "Willison");
s; // [object Object]
Person.prototype.toString = function() {
return '<Person: ' + this.fullName() + '>';
}
s.toString(); // <Person: Simon Willison>
你是否還記得之前我們說的 avg.apply() 中的第一個參數 null?現在我們可以回頭看看這個東西了。apply() 的第一個參數應該是一個被當作 this 來看待的對象。下面是一個 new 方法的簡單實現:
function trivialNew(constructor, ...args) {
var o = {}; // 創建一個對象
constructor.apply(o, args);
return o;
}
這并不是 new 的完整實現,因為它沒有創建原型(prototype)鏈。想舉例說明 new 的實現有些困難,因為你不會經常用到這個,但是適當了解一下還是很有用的。在這一小段代碼里,...args(包括省略號)叫作剩余參數(rest arguments)。如名所示,這個東西包含了剩下的參數。
因此調用
var bill = trivialNew(Person, "William", "Orange");
可認為和調用如下語句是等效的
var bill = new Person("William", "Orange");
apply() 有一個姐妹函數,名叫 call,它也可以允許你設置 this,但它帶有一個擴展的參數列表而不是一個數組。
function lastNameCaps() {
return this.last.toUpperCase();
}
var s = new Person("Simon", "Willison");
lastNameCaps.call(s);
// 和以下方式等價
s.lastNameCaps = lastNameCaps;
s.lastNameCaps();
內部函數
JavaScript 允許在一個函數內部定義函數,這一點我們在之前的 makePerson() 例子中也見過。關于 JavaScript 中的嵌套函數,一個很重要的細節是它們可以訪問父函數作用域中的變量:
function betterExampleNeeded() {
var a = 1;
function oneMoreThanA() {
return a + 1;
}
return oneMoreThanA();
}
如果某個函數依賴于其他的一兩個函數,而這一兩個函數對你其余的代碼沒有用處,你可以將它們嵌套在會被調用的那個函數內部,這樣做可以減少全局作用域下的函數的數量,這有利于編寫易于維護的代碼。
這也是一個減少使用全局變量的好方法。當編寫復雜代碼時,程序員往往試圖使用全局變量,將值共享給多個函數,但這樣做會使代碼很難維護。內部函數可以共享父函數的變量,所以你可以使用這個特性把一些函數捆綁在一起,這樣可以有效地防止“污染”你的全局命名空間——你可以稱它為“局部全局(local global)”。雖然這種方法應該謹慎使用,但它確實很有用,應該掌握。
閉包
下面我們將看到的是 JavaScript 中必須提到的功能最強大的抽象概念之一:閉包。但它可能也會帶來一些潛在的困惑。那它究竟是做什么的呢?
function makeAdder(a) {
return function(b) {
return a + b;
}
}
var x = makeAdder(5);
var y = makeAdder(20);
x(6); // ?
y(7); // ?
makeAdder 這個名字本身應該能說明函數是用來做什么的:它創建了一個新的 adder 函數,這個函數自身帶有一個參數,它被調用的時候這個參數會被加在外層函數傳進來的參數上。
這里發生的事情和前面介紹過的內嵌函數十分相似:一個函數被定義在了另外一個函數的內部,內部函數可以訪問外部函數的變量。唯一的不同是,外部函數被返回了,那么常識告訴我們局部變量“應該”不再存在。但是它們卻仍然存在——否則 adder 函數將不能工作。也就是說,這里存在 makeAdder 的局部變量的兩個不同的“副本”——一個是 a 等于5,另一個是 a 等于20。那些函數的運行結果就如下所示:
x(6); // 返回 11
y(7); // 返回 27
下面來說說到底發生了什么。每當 JavaScript 執行一個函數時,都會創建一個作用域對象(scope object),用來保存在這個函數中創建的局部變量。它和被傳入函數的變量一起被初始化。這與那些保存的所有全局變量和函數的全局對象(global object)類似,但仍有一些很重要的區別,第一,每次函數被執行的時候,就會創建一個新的,特定的作用域對象;第二,與全局對象(在瀏覽器里面是當做 window 對象來訪問的)不同的是,你不能從 JavaScript 代碼中直接訪問作用域對象,也沒有可以遍歷當前的作用域對象里面屬性的方法。
所以當調用 makeAdder 時,解釋器創建了一個作用域對象,它帶有一個屬性:a,這個屬性被當作參數傳入 makeAdder 函數。然后 makeAdder 返回一個新創建的函數。通常 JavaScript 的垃圾回收器會在這時回收 makeAdder 創建的作用域對象,但是返回的函數卻保留一個指向那個作用域對象的引用。結果是這個作用域對象不會被垃圾回收器回收,直到指向 makeAdder 返回的那個函數對象的引用計數為零。
作用域對象組成了一個名為作用域鏈(scope chain)的鏈。它類似于原型(prototype)鏈一樣,被 JavaScript 的對象系統使用。
一個閉包就是一個函數和被創建的函數中的作用域對象的組合。
閉包允許你保存狀態——所以它們通常可以代替對象來使用。這里有一些關于閉包的詳細介紹。
內存泄露
使用閉包的一個壞處是,在 IE 瀏覽器中它會很容易導致內存泄露。JavaScript 是一種具有垃圾回收機制的語言——對象在被創建的時候分配內存,然后當指向這個對象的引用計數為零時,瀏覽器會回收內存。宿主環境提供的對象都是按照這種方法被處理的。
瀏覽器主機需要處理大量的對象來描繪一個正在被展現的 HTML 頁面——DOM 對象。瀏覽器負責管理它們的內存分配和回收。
IE 瀏覽器有自己的一套垃圾回收機制,這套機制與 JavaScript 提供的垃圾回收機制進行交互時,可能會發生內存泄露。
在 IE 中,每當在一個 JavaScript 對象和一個本地對象之間形成循環引用時,就會發生內存泄露。如下所示:
function leakMemory() {
var el = document.getElementById('el');
var o = { 'el': el };
el.o = o;
}
這段代碼的循環引用會導致內存泄露:IE 不會釋放被 el 和 o 使用的內存,直到瀏覽器被徹底關閉并重啟后。
這個例子往往無法引起人們的重視:一般只會在長時間運行的應用程序中,或者因為巨大的數據量和循環中導致內存泄露發生時,內存泄露才會引起注意。
不過一般也很少發生如此明顯的內存泄露現象——通常泄露的數據結構有多層的引用(references),往往掩蓋了循環引用的情況。
閉包很容易發生無意識的內存泄露。如下所示:
function addHandler() {
var el = document.getElementById('el');
el.onclick = function() {
el.style.backgroundColor = 'red';
}
}
這段代碼創建了一個元素,當它被點擊的時候變紅,但同時它也會發生內存泄露。為什么?因為對 el 的引用不小心被放在一個匿名內部函數中。這就在 JavaScript 對象(這個內部函數)和本地對象之間(el)創建了一個循環引用。
這個問題有很多種解決方法,最簡單的一種是不要使用 el 變量:
function addHandler(){
document.getElementById('el').onclick = function(){
this.style.backgroundColor = 'red';
};
}
另外一種避免閉包的好方法是在 window.onunload 事件發生期間破壞循環引用。很多事件庫都能完成這項工作。注意這樣做將使 Firefox 中的 bfcache 無法工作。所以除非有其他必要的原因,最好不要在 Firefox 中注冊一個onunload 的監聽器。