引言
為什麼會有這一篇“重新介紹”呢?因為 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 第五版引入了第四版草案加入的許多特性。標準的第六次修訂目前處於草案階段。
JavaScript 能有這樣的穩定性(譯者注:指語言標準在相當長的時間內沒有發生過大的改動)對於開發者是個好訊息,因為它給了開發者充足的時間修改已有的程式碼適應新版本的變化。以下介紹的語言特性基於 ECMAScript 第三版,為了帶來不必要的誤會,也將使用 JavaScript 這個名稱。
與大多數程式語言不同,JavaScript 沒有輸入或輸出的概念。它是一個在宿主環境(host environment)下執行的指令碼語言,任何與外界溝通的機制都是由宿主環境提供的。瀏覽器是最常見的宿主環境,但是其他程式中也包含 JavaScript 直譯器,如 Adobe Acrobat、Photoshop、SVG 影像、Yahoo! 的 widget ,以及 node.js之類的伺服器端環境。JavaScript 的實際應用遠不止這些,除此之外還有 NoSQL 資料庫(如開源的 Apache CouchDB)、嵌入式計算機,以及包括 GNOME (注:GNU/Linux 上最流行的 GUI 之一)在內的桌面環境等等。
概覽
JavaScript 是一種物件導向的動態語言,它包含型別、運算子、核心物件(core objects)和方法。它的語法來源於 Java 和 C,所以這兩種語言的許多語法特性同樣適用於 JavaScript。需要注意的一個主要區別是 JavaScript 不支援類,類這一概念在 JavaScript 通過物件原型(object prototype)得到延續。另一個主要區別是 JavaScript 中的函式也是物件,JavaScript 允許函式在包含可執行程式碼的同時,能像其他物件一樣被傳遞。
先從任何程式語言都不可缺少的組成部分——“型別”開始。JavaScript 程式可以修改值(value),這些值都有各自的型別。JavaScript 中的型別包括:
…哦,還有看上去有些…奇怪的 Undefined(未定義)型別和 Null (空)型別。此外還有 Array (陣列)型別,以及分別用於表示日期和正規表示式的 Date(日期)和 Regular Expression(正規表示式),這三種型別都是特殊的物件。嚴格意義上說,Function(函式)也是一種特殊的物件。所以準確來說,JavaScript 中的型別應該包括這些:
- Number(數字)
- String(字串)
- Boolean(布林)
- Symbol(符號)
- Object(物件)
- Function(函式)
- Array(陣列)
- Date(日期)
- RegExp(正規表示式)
- Null(空)
- Undefined(未定義)
還有一種 Error(錯誤)型別,這個會在之後的介紹中提到。
數字
根據語言規範,JavaScript 採用“IEEE 754 標準定義的雙精度64位格式”(”double-precision 64-bit format IEEE 754 values”)表示數字。據此我們能得到一個有趣的結論,和其他程式語言(如 C 和 Java)不同,JavaScript 不區分整數值和浮點數值,所有數字在 JavaScript 中均用浮點數值表示,所以在進行數字運算的時候要特別注意。看看下面的例子:
1 |
0.1 + 0.2 = 0.30000000000000004 |
但具體實現時為了便於進行位操作,整數值通常被視為32位整型變數,在個別實現(如某些瀏覽器)中也以32位整型變數的形式進行儲存。進一步的詳細資料可參考 The Complete JavaScript Number Reference。
JavaScript 支援標準的算術運算子,包括加法、減法、取模(或取餘)等等。還有一個之前沒有提及的內建物件 Math(數學),用以處理更多的高階數學函式和常數:
1 2 |
Math.sin(3.5); var d = Math.PI * r * r; |
你可以使用內建函式 parseInt()
將字串轉換為整型。該函式的第二個參數列示字串所表示數字的基(進位制):
1 2 3 4 |
> parseInt("123", 10) 123 > parseInt("010", 10) 10 |
如果呼叫時沒有提供第二個引數(字串所表示數字的基),2013 年以前的 JavaScript 實現會返回一個意外的結果:
1 2 |
> parseInt("010") 8 |
這是因為字串以數字 0 開頭,parseInt()
函式會把這樣的字串視作八進位制數字。
如果想把一個二進位制數字字串轉換成整數值,只要把第二個引數設定為 2 就可以了:
1 2 |
> parseInt("11", 2) 3 |
JavaScript 還有一個類似的內建函式 parseFloat()
,用以解析浮點數字符串,跟 parseInt()
不同的地方是parseFloat()
只能解析十進位制數字。
單元運算子 + 也可以把數字字串轉換成數值:
1 2 |
> + "42" 42 |
如果給定的字串不存在數值形式,函式會返回一個特殊的值 NaN
(Not a Number 的縮寫):
1 2 |
> parseInt("hello", 10) NaN |
如果把 NaN 作為引數進行數學運算,結果也會是
NaN:
1 2 |
> NaN + 5 NaN |
可以使用內建函式 isNaN()
來判斷一個變數是否為 NaN:
1 2 |
> isNaN(NaN) true |
JavaScript 還有兩個特殊值:Infinity
(正無窮)和 -Infinity(負無窮):
1 2 3 4 |
> 1 / 0 Infinity > -1 / 0 -Infinity |
可以使用內建函式 isFinite()
來判斷一個變數是否為 Infinity,
-Infinity 或
NaN:
1 2 3 4 5 6 |
> isFinite(1/0) false > isFinite(-Infinity) false > isFinite(NaN) false |
parseInt()
和 parseFloat()
函式會嘗試逐個解析字串中的字元,直到遇上一個無法被解析成數字的字元,然後返回該字元前所有數字字元組成的數字。使用運算子 + 將字串轉換成數字,只要字串中含有無法被解析成數字的字元,該字串都將被轉換成 NaN。請你用這兩種方法解析“10.2abc”這一字串,比較得到的結果,理解這兩種方法的區別。
字串
JavaScript 中的字串是一串字元序列。更準確地說,它們是一串由Unicode 字元構成的字元序列,每一個字元由一個 16 位二進位制數表示。這對於那些需要和多語種網頁打交道的開發者來說是個好訊息。
如果想表示一個單獨的字元,只需使用長度為 1 的字串。
通過訪問字串的 length
(長度)屬性可以得到它的長度。
1 2 |
> "hello".length 5 |
這就是 JavaScript 物件。前面已經提過,字串實際上也是物件,所以它們也有方法:
1 2 3 4 5 6 |
> "hello".charAt(0) h > "hello, world".replace("hello", "goodbye") goodbye, world > "hello".toUpperCase() HELLO |
其他型別
JavaScript 中 null 和
undefined 是不同的,前者表示一個空值(non-value),後者是“undefined(未定義)”型別的物件,表示一個還沒有被分配的值。我們之後再具體討論變數,但有一點可以先簡單說明一下,JavaScript 允許宣告變數但不對其賦值,一個未被賦值的變數就是
undefined 型別。還有一點需要說明的是,
undefined 是一個不允許修改的常量。
JavaScript 包含布林型別,這個型別的變數有兩個可能的值,分別是 true 和
false(兩者都是關鍵字)。根據具體需要,JavaScript 按照如下規則將變數轉換成布林型別:
false、
0、空字串(
"")、
NaN、
null 和
undefined 被轉換為
false
- 其他值被轉換為
true
也可以使用 Boolean() 函式進行顯式轉換:
1 2 3 4 |
> Boolean("") false > Boolean(234) true |
不過一般沒必要這麼做,因為 JavaScript 會在需要一個布林變數時隱式完成這個轉換操作(比如在 if 條件語句中)。通常我們可以把轉換成布林值後分別為
true 和
false 的變數分別稱為“真值(true values)”和“假值(false values)”。
JavaScript 支援包括 &&(邏輯與)、
|| (邏輯或)和
!(邏輯非)在內的邏輯運算子。
變數
在 JavaScript 中宣告一個新變數的方法是使用關鍵字 var
:
1 2 |
var a; var name = "simon"; |
如果宣告瞭一個變數卻沒有對其賦值,那麼這個變數的型別就是 undefined。
運算子
JavaScript的算術操作符包括 +、
-、
*、
/ 和
%(求餘)。賦值使用
= 運算子,此外還有一些複合運算子,如
+= 和
-=,它們等價於
x = xop y。
1 2 |
x += 5 x = x + 5 |
可以使用 ++ 和
-- 分別實現變數的自增和自減。兩者都可以作為字首或字尾操作符使用。
+ 操作符
還可以用來連線字串:
1 2 |
> "hello" + " world" hello world |
如果你用一個字串加上一個數字(或其他值),那麼運算元都會被首先轉換為字串。如下所示:
1 2 3 4 |
> "3" + 4 + 5 345 > 3 + 4 + "5" 75 |
這裡不難看出一個實用的技巧——通過與空字串相加,可以將某個變數快速轉換成字串型別。
JavaScript 中的比較操作使用 <、
>、
<= 和
>=,這些運算子對於數字和字串都通用。相等的比較稍微複雜一些。由兩個“
=(等號)”組成的相等運算子有型別自適應的功能,具體例子如下:
1 2 3 4 |
> "dog" == "dog" true > 1 == true true |
如果在比較前不需要自動型別轉換,應該使用由三個“=(等號)”組成的相等運算子:
1 2 3 4 |
> 1 === true false > true === true true |
JavaScript 還支援 != 和
!== 兩種不等運算子,具體區別與兩種相等運算子的區別類似。
JavaScript 還提供了 位操作符(Bitwise operator)。
控制結構
JavaScript 的控制結構與其他類 C 語言類似。可以使用 if 和
else 來定義條件語句,還可以將這兩者組合使用:
1 2 3 4 5 6 7 8 9 |
var name = "kittens"; if (name == "puppies") { name += "!"; } else if (name == "kittens") { name += "!!"; } else { name = "!" + name; } name == "kittens!!" |
JavaScript 支援 while 迴圈和
do-while 迴圈。前者適合常見的基本迴圈操作,如果需要迴圈體至少被執行一次則可以使用
do-while:
1 2 3 4 5 6 7 |
while (true) { // 一個無限迴圈! } do { var input = get_input(); } while (inputIsNotValid(input)) |
JavaScript 的 for 迴圈與 C 和 Java 中的相同,使用時可以在一行程式碼中提供控制資訊。
1 2 3 |
for (var i = 0; i < 5; i++) { // 將會執行五次 } |
&& 和
|| 運算子使用短路邏輯(short-circuit logic),是否會執行第二個語句(運算元)取決於第一個運算元的值。在需要訪問某個物件的屬性時,使用這個特性可以事先檢測該物件是否為空:
1 |
var name = o && o.getName(); |
或運算可以用來設定預設值:
1 |
var name = otherName || "default"; |
類似地,JavaScript 也有一個三元操作符:
1 |
var allowed = (age > 18) ? "yes" : "no"; |
在需要多重分支時可以使用 switch 語句:
1 2 3 4 5 6 7 8 9 10 |
switch(action) { case 'draw': drawit(); break; case 'eat': eatit(); break; default: donothing(); } |
如果你不使用 break 語句,JavaScript 直譯器將會執行之後
case 中的程式碼。除非是為了除錯,一般你並不需要這個特性,所以大多數時候不要忘了加上
break。
1 2 3 4 5 6 7 8 |
switch(a) { case 1: // 繼續向下 case 2: eatit(); break; default: donothing(); } |
default 語句是可選的。
switch 和
case 都可以使用需要運算才能得到結果的表示式;在
switch 的表示式和
case 的表示式是使用
=== 嚴格相等運算子進行比較的:
1 2 3 4 5 6 7 |
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 的資料型別——包括物件。這使使用者可以根據具體需求,建立出相當複雜的資料結構。
有兩種簡單方法可以建立一個空物件:
1 |
var obj = new Object(); |
和:
1 |
var obj = {}; |
這兩種方法在語義上是相同的。第二種更方便的方法叫作“物件字面量(object literal)”法。這種語法在 JSON 格式中被廣泛採用,一般我們優先選擇第二種方法。
完成建立後,物件屬性可以通過如下兩種方式進行賦值:
1 2 |
obj.name = "Simon" var name = obj.name; |
和:
1 2 |
obj["name"] = "Simon"; var name = obj["name"]; |
這兩種方法在語義上也是相同的。第二種方法的優點在於屬性的名稱被看作一個字串,這就意味著它可以在執行時被計算,缺點在於這樣的程式碼有可能無法在後期被直譯器優化。它也可以被用來訪問某些以預留關鍵字作為名稱的屬性的值:
1 2 |
obj.for = "Simon"; // 語法錯誤,因為 for 是一個預留關鍵字 obj["for"] = "Simon"; // 工作正常 |
“物件字面量”也可以用來在物件中定義一個物件:
1 2 3 4 5 6 7 8 |
var obj = { name: "Carrot", "for": "Max", details: { color: "orange", size: 12 } } |
物件的屬性可以通過鏈式(chain)表示方法進行訪問:
1 2 3 4 |
> obj.details.color orange > obj["details"]["size"] 12 |
陣列
JavaScript 中的陣列是一種特殊的物件。它的工作原理與普通物件類似(比如陣列中的元素可以視作陣列的屬性,可以像訪問物件屬性那樣通過使用[]進行訪問),但陣列還有一個特殊的屬性——length(長度)屬性。這個屬性的值通常比陣列最大索引大 1。
建立陣列的傳統方法是:
1 2 3 4 5 6 |
> var a = new Array(); > a[0] = "dog"; > a[1] = "cat"; > a[2] = "hen"; > a.length 3 |
使用陣列字面量(array literal)法更加方便:
1 2 3 |
> var a = ["dog", "cat", "hen"]; > a.length 3 |
使用字面量法建立物件或陣列時在括號末尾加上逗號在不同瀏覽器中可能導致不同的結果,所以暫時不推薦這麼做。
注意,Array.length並不總是陣列中元素的個數,如下所示:
1 2 3 4 |
> var a = ["dog", "cat", "hen"]; > a[100] = "fox"; > a.length 101 |
記住:陣列的長度是比陣列最高索引值大 1 的數。
如果試圖訪問一個不存在的陣列索引,會得到 undefined:
1 2 |
> typeof(a[90]) undefined |
可以通過如下方式遍歷一個陣列:
1 2 3 |
for (var i = 0; i < a.length; i++) { // Do something with a[i] } |
這麼做效率不太好,因為每迴圈一次都要計算一次長度。改進的版本是:
1 2 3 |
for (var i = 0, len = a.length; i < len; i++) { // Do something with a[i] } |
還有一種更好的寫法是:
1 2 3 |
for (var i = 0, item; item = a[i++];) { // Do something with item } |
這裡我們使用了兩個變數。for 迴圈中間部分的表示式仍然用來判斷是否為真——如果為真,那麼迴圈繼續。因為
i每次遞增 1,這個陣列的元素會被逐個傳遞給 item 變數。當遇到一個假值元素(如
undefined)時,迴圈結束。
注意這個技巧只能在你知道陣列中不含假值時才可以使用。如果想要遍歷可能包含 0 或空字串的陣列,應該使用i, len的寫法。
遍歷陣列的另一種方法是使用 for...in
迴圈。注意,如果有人向 Array.prototype 新增了新的屬性,使用這樣的迴圈這些屬性也同樣會被遍歷:
1 2 3 |
for (var i in a) { // Do something with a[i] } |
如果想在陣列後追加元素,只需要:
1 |
a.push(item); |
Array(陣列)類自帶了許多方法:
方法名稱 | 描述 |
---|---|
a.toString() |
返回一個包含陣列中所有元素的字串,每個元素通過逗號分隔。 |
a.toLocaleString() |
根據宿主環境的區域設定,返回一個包含陣列中所有元素的字串,每個元素通過逗號分隔。 |
a.concat(item1[, item2[, ...[, itemN]]]) |
返回一個陣列,這個陣列包含原先 a 和 |
a.join(sep) |
返回一個包含陣列中所有元素的字串,每個元素通過指定的sep 分隔。 |
a.pop() |
刪除並返回陣列中的最後一個元素。 |
a.push(item1, ..., itemN) |
將 item1、item2、……、itemN 追加至陣列 |
a.reverse() |
陣列逆序(會更改原陣列 a)。 |
a.shift() |
刪除並返回陣列中第一個元素。 |
a.slice(start, end) |
返回子陣列,以 a[start] 開頭,以 |
a.sort([cmpfn]) |
依據 cmpfn 返回的結果進行排序,如果未指定比較函式則按字元順序比較(即使元素是數字)。 |
a.splice(start, delcount[, item1[, ...[, itemN]]]) |
從 start 開始,刪除 |
a.unshift([item]) |
將 item 插入陣列頭部,返回陣列新長度(考慮 |
函式
學習 JavaScript 最重要的就是要理解物件和函式兩個部分。最簡單的函式就像下面這個這麼簡單:
1 2 3 4 |
function add(x, y) { var total = x + y; return total; } |
這個例子包括你需要了解的關於基本函式的所有部分。一個 JavaScript 函式可以包含 0 個或多個已命名的變數。函式體中的表示式數量也沒有限制。你可以宣告函式自己的區域性變數。return 語句在返回一個值並結束函式。如果沒有使用
return 語句,或者一個沒有值的
return 語句,JavaScript 會返回
undefined。
已命名的引數更像是一個指示而沒有其他作用。如果呼叫函式時沒有提供足夠的引數,缺少的引數會被undefined 替代。
1 2 |
> add() NaN // 不能在 undefined 物件上進行加法操作 |
你還可以傳入多於函式本身需要引數個數的引數:
1 2 |
> add(2, 3, 4) 5 // 將前兩個值相加,4被忽略了 |
這看上去有點蠢。函式實際上是訪問了函式體中一個名為 arguments
的內部物件,這個物件就如同一個類似於陣列的物件一樣,包括了所有被傳入的引數。讓我們重寫一下上面的函式,使它可以接收任意個數的引數:
1 2 3 4 5 6 7 8 9 10 |
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 也沒什麼區別。接下來建立一個求平均數的函式:
1 2 3 4 5 6 7 8 9 |
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() 函式處理一個由逗號連線的變數串,但如果想得到一個陣列的平均值該怎麼辦呢?可以這麼修改函式:
1 2 3 4 5 6 7 8 9 |
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()
來呼叫一個函式,並傳遞給它一個包含了引數的陣列。
1 2 |
> avg.apply(null, [2, 3, 4, 5]) 3.5 |
傳給 apply() 的第二個引數是一個陣列,它將被當作
avg() 的引數使用,至於第一個引數
null,我們將在後面討論。這也正說明了事實上函式也是一種物件。
JavaScript 允許你建立匿名函式:
1 2 3 4 5 6 7 |
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 中的塊級作用域類似,下面這個例子隱藏了區域性變數:
1 2 3 4 5 6 7 8 9 10 |
> var a = 1; > var b = 2; > (function() { var b = 3; a += b; })(); > a 4 > b 2 |
JavaScript 允許以遞迴方式呼叫函式。遞迴在處理樹形結構(比如瀏覽器 DOM)時非常有用。
1 2 3 4 5 6 7 8 9 10 |
function countChars(elm) { if (elm.nodeType == 3) { // TEXT_NODE return elm.nodeValue.length; } var count = 0; for (var i = 0, child; child = elm.childNodes[i]; i++) { count += countChars(child); } return count; } |
這裡需要說明一個潛在問題——既然匿名函式沒有名字,那該怎麼遞迴呼叫它呢?答案是通過 arguments 物件,就是之前提過那個用來包含一系列引數的物件,它還提供了一個名為
arguments.callee 的屬性。這個屬性通常指向當前的(呼叫)函式,因此它可以用來進行遞迴呼叫:
1 2 3 4 5 6 7 8 9 10 |
var charsInBody = (function(elm) { if (elm.nodeType == 3) { // TEXT_NODE return elm.nodeValue.length; } var count = 0; for (var i = 0, child; child = elm.childNodes[i]; i++) { count += arguments.callee(child); } return count; })(document.body); |
由於 arguments.callee 指向當前函式,而且所有的函式都是物件,不難想象可以使用
arguments.callee儲存對這同一個函式的多重呼叫的一些資訊。下面是一個可以記住它被呼叫了多少次的函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function counter() { if (!arguments.callee.count) { arguments.callee.count = 0; } return arguments.callee.count++; } > counter() 0 > counter() 1 > counter() 2 |
自定義物件
在經典的面嚮物件語言中,物件是指資料和在這些資料上進行的操作的集合。與 C++ 和 Java 不同,JavaScript 是一種基於原型的程式語言,並沒有 class 語句,而是把函式用作類。那麼讓我們來定義一個人名物件,這個物件包括人的姓和名兩個域(field)。名字的表示有兩種方法:“名 姓(First Last)”或“姓, 名(Last, First)”。使用我們前面討論過的函式和物件概念,可以像這樣完成定義:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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 |
上面的寫法雖然可以滿足要求,但是看起來很麻煩,因為需要在全域性名字空間中寫很多函式。既然函式本身就是物件,如果需要使一個函式隸屬於一個物件,那麼不難得到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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)。這是一個經常出錯的地方。例如:
1 2 3 4 |
> s = makePerson("Simon", "Willison") > var fullName = s.fullName; > fullName() undefined undefined |
當我們呼叫 fullName() 時,
this 實際上是指向全域性物件的,並沒有名為
first 或
last 的全域性變數,所以它們兩個的返回值都會是
undefined。
下面使用關鍵字 this 改進已有的
makePerson函式:
1 2 3 4 5 6 7 8 9 10 11 |
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 呼叫他們的時候就容易識別了。
我們的人名物件現在已經相當完善了,但還有一些不太好的地方。每次我們建立一個人名物件的時候,我們都在其中建立了兩個新的函式物件——如果這個程式碼可以共享不是更好嗎?
1 2 3 4 5 6 7 8 9 10 11 12 |
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; } |
這種寫法的好處是,我們只需要建立一次方法函式,在建構函式中引用它們。那是否還有更好的方法呢?答案是肯定的。
1 2 3 4 5 6 7 8 9 10 |
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)中的一些東西,也就是說你可以在執行時給已存在的物件新增額外的方法:
1 2 3 4 5 6 7 8 |
> 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 新增一個方法用來返回逆序的字串:
1 2 3 4 5 6 7 8 9 10 11 12 |
> 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)。
1 2 |
> "This can now be reversed".reversed() desrever eb won nac sihT |
正如我前面提到的,原型組成鏈的一部分。那條鏈的根節點是 Object.prototype,它包括
toString() 方法——將物件轉換成字串時呼叫的方法。這對於除錯我們的
Person 物件很有用:
1 2 3 4 5 6 7 8 |
> var s = new Person("Simon", "Willison"); > s [object Object] > Person.prototype.toString = function() { return '<Person: ' + this.fullName() + '>'; } > s <Person: Simon Willison> |
你是否還記得之前我們說的 avg.apply() 中的第一個引數
null?現在我們可以回頭看看這個東西了。
apply() 的第一個引數應該是一個被當作
this 來看待的物件。下面是一個
new 方法的簡單實現:
1 2 3 4 5 |
function trivialNew(constructor, ...args) { var o = {}; // Create an object constructor.apply(o, args); return o; } |
這並不是一個 new 的精確副本,因為它沒有建立原型(prototype)鏈。想舉例說明
apply() 有些困難,因為你不會經常用到這個函式,但是適當瞭解一下還是很有用的。在這一小段程式碼裡,
...args(包括省略號)叫作剩餘引數(rest arguments)。如名所示,這個東西包含了剩下的引數。目前這個功能還處於試驗階段,僅在 Firefox 中提供支援,目前仍然建議使用
argument。
因此呼叫
1 |
var bill = trivialNew(Person, "William", "Orange"); |
可認為和呼叫如下語句是等效的
1 |
var bill = new Person("William", "Orange"); |
apply() 有一個姐妹函式,名叫
call
,它也可以允許你設定 this,但它帶有一個擴充套件的引數列表而不是一個陣列。
1 2 3 4 5 6 7 8 |
function lastNameCaps() { return this.last.toUpperCase(); } var s = new Person("Simon", "Willison"); lastNameCaps.call(s); // Is the same as: s.lastNameCaps = lastNameCaps; s.lastNameCaps(); |
內部函式
JavaScript 允許在一個函式內部定義函式,這一點我們在之前的 makePerson() 例子中也見過。關於 JavaScript 中的巢狀函式,一個很重要的細節是它們可以訪問父函式作用域中的變數:
1 2 3 4 5 6 7 |
function betterExampleNeeded() { var a = 1; function oneMoreThanA() { return a + 1; } return oneMoreThanA(); } |
如果某個函式依賴於其他的一兩個函式,而這一兩個函式對你其餘的程式碼沒有用處,你可以將它們巢狀在會被呼叫的那個函式內部,這樣做可以減少全域性作用域下的函式的數量,這有利於編寫易於維護的程式碼。
這也是一個減少使用全域性變數的好方法。當編寫複雜程式碼時,程式設計師往往試圖使用全域性變數,將值共享給多個函式,但這樣做會使程式碼很難維護。內部函式可以共享父函式的變數,所以你可以使用這個特性把一些函式捆綁在一起,這樣可以有效地防止“汙染”你的全域性空間——你可以稱它為“區域性全域性(local global)”。雖然這種方法應該謹慎使用,但它確實很有用,應該掌握。
閉包
下面我們將看到的是 JavaScript 中必須提到的功能最強大的結構之一:閉包。但它可能也會帶來一些潛在的麻煩。那它究竟是做什麼的呢?
1 2 3 4 5 6 7 8 9 10 11 |
function makeAdder(a) { return function(b) { return a + b; } } x = makeAdder(5); y = makeAdder(20); x(6) ? y(7) ? |
makeAdder 這個名字本身應該能說明函式是用來做什麼的:它建立了一個新的
adder 函式,這個函式自身帶有一個引數,它被呼叫的時候這個引數會被加在外層函式傳進來的引數上。
這裡發生的事情和前面介紹過的內嵌函式十分相似:一個函式被定義在了另外一個函式的內部,內部函式可以訪問外部函式的變數。唯一的不同是,外部函式被返回了,那麼常識告訴我們區域性變數“應該”不再存在。但是它們卻仍然存在——否則 adder 函式將不能工作。也就是說,這裡存在
makeAdder 的區域性變數的兩個不同的“副本”——一個是
a 等於5,另一個是
a 等於20。那些函式的執行結果就如下所示:
1 2 |
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 物件和一個本地物件之間形成迴圈引用時,就會發生記憶體洩露。如下所示:
1 2 3 4 5 |
function leakMemory() { var el = document.getElementById('el'); var o = { 'el': el }; el.o = o; } |
這段程式碼的迴圈引用會導致記憶體洩露:IE 不會釋放被 el 和
o 使用的記憶體,直到瀏覽器被徹底關閉並重啟後。
這個例子往往無法引起人們的重視:一般只會在長時間執行的應用程式中,或者因為巨大的資料量和迴圈中導致記憶體洩露發生時,記憶體洩露才會引起注意。
不過一般也很少發生如此明顯的記憶體洩露現象——通常洩露的資料結構有多層的引用(references),這種情況下迴圈引用不會導致過於嚴重的後果。
閉包很容易發生無意識的記憶體洩露。如下所示:
1 2 3 4 5 6 |
function addHandler() { var el = document.getElementById('el'); el.onclick = function() { el.style.backgroundColor = 'red'; } } |
這段程式碼建立了一個元素,當它被點選的時候變紅,但同時它也會發生記憶體洩露。為什麼?因為對 el 的引用不小心被放在一個匿名內部函式中。這就在 JavaScript 物件(這個內部函式)和本地物件之間(
el)建立了一個迴圈引用。
這個問題有很多種解決方法,最簡單的一種是不要使用 el 變數:
1 2 3 4 5 |
function addHandler(){ document.getElementById('el').onclick = function(){ this.style.backgroundColor = 'red'; }; } |
有趣的是,有一種破壞因為閉包引入迴圈引用的竅門是新增另外一個閉包:
1 2 3 4 5 6 7 8 9 |
function addHandler() { var clickHandler = function() { this.style.backgroundColor = 'red'; }; (function() { var el = document.getElementById('el'); el.onclick = clickHandler; })(); } |
內部函式被直接執行,並在 clickHandler 建立的閉包中隱藏了它的內容。
另外一種避免閉包的好方法是在 window.onunload 事件發生期間破壞迴圈引用。很多庫都能完成這項工作。注意這樣做將使 Firefox 1.5 中的 bfcache 無法工作。所以除非有其他必要的原因,最好不要在 Firefox 中註冊一個
unload 的監聽器。
備註:在這篇文件完成後 JavaScript 加入了其他特性。儘管如此,這篇文件還是很有價值的參考文件。