特別說明,為便于查閱,文章轉自https://github.com/getify/You-Dont-Know-JS
在前一章中,我介紹了編程的基本構建塊兒,比如變量,循環,條件,和函數。當然,所有被展示的代碼都是JavaScript。但是在這一章中,為了作為一個JS開發者入門和進階,我們想要特別集中于那些你需要知道的關于JavaScript的事情。
我們將在本章中介紹好幾個概念,它們將會在后續的 YDKJS 叢書中全面地探索。你可以將這一章看作是這個系列的其他書目中將要詳細講解的話題的一個概覽。
特別是如果你剛剛接觸JavaScript,那么你應當希望花相當一段時間來多次復習這里的概念和代碼示例。任何好的基礎都是一磚一瓦積累起來的,所以不要指望你會在第一遍通讀后就立即理解了全部內容。
你深入學習JavaScript的旅途從這里開始。
注意: 正如我在第一章中說過的,在你通讀這一章的同時,你絕對應該親自嘗試這里所有的代碼。要注意的是,這里的有些代碼假定最新版本的JavaScript(通常稱為“ES6”,ECMAScript的第六個版本 —— ECMAScript是JS語言規范的官方名稱)中引入的功能是存在的。如果你碰巧在使用一個老版本的,前ES6時代的瀏覽器,這些代碼可能不好用。應當使用一個更新版本的現代瀏覽器(比如Chrome,Firefox,或者IE)。
值與類型
正如我們在第一章中宣稱的,JavaScript擁有帶類型的值,沒有帶類型的變量。下面是可用的內建類型:
string
number
boolean
-
null
和undefined
object
-
symbol
(ES6新增類型)
JavaScript提供了一個typeof
操作符,它可以檢查一個值并告訴你它的類型是什么:
var a;
typeof a; // "undefined"
a = "hello world";
typeof a; // "string"
a = 42;
typeof a; // "number"
a = true;
typeof a; // "boolean"
a = null;
typeof a; // "object" -- 奇怪的bug
a = undefined;
typeof a; // "undefined"
a = { b: "c" };
typeof a; // "object"
來自typeof
的返回值總是六個(ES6中是七個! —— “symbol”類型)字符串值之一。也就是,typeof "abc"
返回"string"
,不是string
。
注意在這個代碼段中變量a
是如何持有每種不同類型的值的,而且盡管表面上看起來很像,但是typeof a
并不是在詢問“a
的類型”,而是“當前a
中的值的類型”。在JavaScript中只有值擁有類型;變量只是這些值的簡單容器。
typeof null
是一個有趣的例子,因為當你期望它返回"null"
時,它錯誤地返回了"object"
。
警告: 這是JS中一直存在的一個bug,但是看起來它永遠都不會被修復了。在網絡上有太多的代碼依存于這個bug,因此修復它將會導致更多的bug!
另外,注意a = undefined
。我們明確地將a
設置為值undefined
,但是在行為上這與一個還沒有被設定值的變量沒有區別,比如在這個代碼段頂部的var a;
。一個變量可以用好幾種不同的方式得到這樣的“undefined”值狀態,包括沒有返回值的函數和使用void
操作符。
對象
object
類型指的是一種復合值,你可以在它上面設定屬性(帶名稱的位置),每個屬性持有各自的任意類型的值。它也許是JavaScript中最有用的類型之一。
var obj = {
a: "hello world",
b: 42,
c: true
};
obj.a; // "hello world"
obj.b; // 42
obj.c; // true
obj["a"]; // "hello world"
obj["b"]; // 42
obj["c"]; // true
可視化地考慮這個obj
值可能會有所幫助:

屬性既可以使用 點號標記法(例如,obj.a
) 訪問,也可以使用 方括號標記法(例如,obj["a"]
) 訪問。點號標記法更短而且一般來說更易于閱讀,因此在可能的情況下它都是首選。
如果你有一個名稱中含有特殊字符的屬性名稱,方括號標記法就很有用,比如obj["hello world!"]
—— 當通過方括號標記法訪問時,這樣的屬性經常被稱為 鍵。[ ]
標記法要求一個變量(下一節講解)或者一個string
字面量(它需要包裝進" .. "
或' .. '
)。
當然,如果你想訪問一個屬性/鍵,但是它的名稱被存儲在另一個變量中時,方括號標記法也很有用。例如:
var obj = {
a: "hello world",
b: 42
};
var b = "a";
obj[b]; // "hello world"
obj["b"]; // 42
注意: 更多關于JavaScript的object
的信息,請參見本系列的 this與對象原型,特別是第三章。
在JavaScript程序中有另外兩種你將會經常打交道的值類型:數組 和 函數。但與其說它們是內建類型,這些類型應當被認為更像是子類型 —— object
類型的特化版本。
數組
一個數組是一個object
,它不使用特殊的帶名稱的屬性/鍵持有(任意類型的)值,而是使用數字索引的位置。例如:
var arr = [
"hello world",
42,
true
];
arr[0]; // "hello world"
arr[1]; // 42
arr[2]; // true
arr.length; // 3
typeof arr; // "object"
注意: 從零開始計數的語言,比如JS,在數組中使用0
作為第一個元素的索引。
可視化地考慮arr
很能會有所幫助:

因為數組是一種特殊的對象(正如typeof
所暗示的),所以它們可以擁有屬性,包括一個可以自動被更新的length
屬性。
理論上你可以使用你自己的命名屬性將一個數組用作一個普通對象,或者你可以使用一個object
但是給它類似于數組的數字屬性(0
,1
,等等)。然而,這么做一般被認為是分別誤用了這兩種類型。
最好且最自然的方法是為數字定位的值使用數組,而為命名屬性使用object
。
函數
另一個你將在JS程序中到處使用的object
子類型是函數:
function foo() {
return 42;
}
foo.bar = "hello world";
typeof foo; // "function"
typeof foo(); // "number"
typeof foo.bar; // "string"
同樣地,函數也是object
的子類型 —— typeof
返回"function"
,這暗示著"function"
是一種主要類型 —— 因此也可以擁有屬性,但是你一般僅會在有限情況下才使用函數對象屬性(比如foo.bar
)。
注意: 更多關于JS的值和它們的類型的信息,參見本系列的 類型與文法 的前兩章。
內建類型的方法
我們剛剛討論的內建類型和子類型擁有十分強大和有用的行為,它們作為屬性和方法暴露出來。
例如:
var a = "hello world";
var b = 3.14159;
a.length; // 11
a.toUpperCase(); // "HELLO WORLD"
b.toFixed(4); // "3.1416"
使調用a.toUpperCase()
成為可能的原因,要比這個值上存在這個方法的說法復雜一些。
簡而言之,有一個String
(S
大寫)對象包裝器形式,通常被稱為“原生類型”,與string
基本類型配成一對兒;正是這個對象包裝器的原型上定義了toUpperCase()
方法。
當你通過引用一個屬性或方法(例如,前一個代碼段中的a.toUpperCase()
)將一個像"hello world"
這樣的基本類型值當做一個object
來使用時,JS自動地將這個值“封箱”為它對應的對象包裝器(這個操作是隱藏在幕后的)。
一個string
值可以被包裝為一個String
對象,一個number
可以被包裝為一個Number
對象,而一個boolean
可以被包裝為一個Boolean
對象。在大多數情況下,你不擔心或者直接使用這些值的對象包裝器形式 —— 在所有實際情況中首選基本類型值形式,而JavaScript會幫你搞定剩下的一切。
注意: 關于JS原生類型和“封箱”的更多信息,參見本系列的 類型與文法 的第三章。要更好地理解對象原型,參見本系列的 this與對象原型 的第五章。
值的比較
在你的JS程序中你將需要進行兩種主要的值的比較:等價 和 不等價。任何比較的結果都是嚴格的boolean
值(true
或false
),無論被比較的值的類型是什么。
強制轉換
在第一章中我們簡單地談了一下強制轉換,我們在此回顧它。
在JavaScript中強制轉換有兩種形式:明確的 和 隱含的。明確的強制轉換比較簡單,因為你可以在代碼中明顯地看到一個類型轉換到另一個類型將會發生,而隱含的強制轉換更像是另外一些操作的不明顯的副作用引發的類型轉換。
你可能聽到過像“強制轉換是邪惡的”這樣情緒化的觀點,這是因為一個清楚的事實 —— 強制轉換在某些地方會產生一些令人吃驚的結果。也許沒有什么能比當一個語言嚇到開發者時更能喚起他們的沮喪心情了。
強制轉換并不邪惡,它也不一定是令人吃驚的。事實上,你使用類型強制轉換構建的絕大部分情況是十分合理和可理解的,而且它甚至可以用來 增強 你代碼的可讀性。但我們不會在這個話題上過度深入 —— 本系列的 類型與文法 的第四章將會進行全面講解。
這是一個 明確 強制轉換的例子:
var a = "42";
var b = Number( a );
a; // "42"
b; // 42 -- 數字!
而這是一個 隱含 強制轉換的例子:
var a = "42";
var b = a * 1; // 這里 "42" 被隱含地強制轉換為 42
a; // "42"
b; // 42 -- 數字!
Truthy 與 Falsy
在第一章中,我們簡要地提到了值的“truthy”和“falsy”性質:當一個非boolean
值被強制轉換為一個boolean
時,它是變成true
還是false
。
在JavaScript中“falsy”的明確列表如下:
-
""
(空字符串) -
0
,-0
,NaN
(非法的number
) -
null
,undefined
false
任何不在這個“falsy”列表中的值都是“truthy”。這是其中的一些例子:
"hello"
42
true
-
[ ]
,[ 1, "2", 3 ]
(數組) -
{ }
,{ a: 42 }
(對象) -
function foo() { .. }
(函數)
重要的是要記住,一個非boolean
值僅在實際上被強制轉換為一個boolean
時才遵循這個“truthy”/“falsy”強制轉換。把你搞糊涂并不困難 —— 當一個場景看起來像是將一個值強制轉換為boolean
,可其實它不是。
等價性
有四種等價性操作符:==
,===
,!=
,和!==
。!
形式當然是與它們相對應操作符平行的“不等”版本;不等(non-equality) 不應當與 不等價性(inequality) 相混淆。
==
和===
之間的不同通常被描述為,==
檢查值的等價性而===
檢查值和類型兩者的等價性。然而,這是不準確的。描述它們的合理方式是,==
在允許強制轉換的條件下檢查值的等價性,而===
是在不允許強制轉換的條件下檢查值的等價性;因此===
常被稱為“嚴格等價”。
考慮這個隱含強制轉換,它在==
寬松等價性比較中允許,而===
嚴格等價性比較中不允許:
var a = "42";
var b = 42;
a == b; // true
a === b; // false
在a == b
的比較中,JS注意到類型不匹配,于是它經過一系列有順序的步驟將一個值或者它們兩者強制轉換為一個不同的類型,直到類型匹配為止,然后就可以檢查一個簡單的值等價性。
如果你仔細想一想,通過強制轉換a == b
可以有兩種方式給出true
。這個比較要么最終成為42 == 42
,要么成為"42" == "42"
。那么是哪一種呢?
答案:"42"
變成42
,于是比較成為42 == 42
。在一個這樣簡單的例子中,只要最終結果是一樣的,處理的過程走哪一條路看起來并不重要。但在一些更復雜的情況下,這不僅對比較的最終結果很重要,而且對你 如何 得到這個結果也很重要。
a === b
產生false
,因為強制轉換是不允許的,所以簡單值的比較很明顯將會失敗。許多開發者感覺===
更可靠,所以他們提倡一直使用這種形式而遠離==
。我認為這種觀點是非常短視的。我相信==
是一種可以改進程序的強大工具,如果你花時間去學習它的工作方式。
我們不會詳細地講解強制轉換在==
比較中是如何工作的。它的大部分都是相當合理的,但是有一些重要的極端用例要小心。你可以閱讀ES5語言規范的11.9.3部分(http://www.ecma-international.org/ecma-262/5.1/)來了解確切的規則,而且與圍繞這種機制的所有負面炒作比起來,你會對這它是多么的直白而感到吃驚。
為了將這許多細節歸納為一個簡單的包裝,并幫助你在各種情況下判斷是否使用==
或===
,這是我的簡單規則:
- 如果一個比較的兩個值之一可能是
true
或false
值,避免==
而使用===
。 - 如果一個比較的兩個值之一可能是這些具體的值(
0
,""
,或[]
—— 空數組),避免==
而使用===
。 - 在 所有 其他情況下,你使用
==
是安全的。它不僅安全,而且在許多情況下它可以簡化你的代碼并改善可讀性。
這些規則歸納出來的東西要求你嚴謹地考慮你的代碼:什么樣的值可能通過這個被比較等價性的變量。如果你可以確定這些值,那么==
就是安全的,使用它!如果你不能確定這些值,就使用===
。就這么簡單。
!=
不等價形式對應于==
,而!==
形式對應于===
。我們剛剛討論的所有規則和注意點對這些非等價比較都是平行適用的。
如果你在比較兩個非基本類型值,比如object
(包括function
和array
),那么你應當特別小心==
和===
的比較規則。因為這些值實際上是通過引用持有的,==
和===
比較都將簡單地檢查這個引用是否相同,而不是它們底層的值。
例如?,array
默認情況下會通過使用逗號(,
)連接所有值來被強制轉換為string
。你可能認為兩個內容相同的array
將是==
相等的,但它們不是:
var a = [1,2,3];
var b = [1,2,3];
var c = "1,2,3";
a == c; // true
b == c; // true
a == b; // false
注意: 更多關于==
等價性比較規則的信息,參見ES5語言規范(11.9.3部分),和本系列的 類型與文法 的第四章;更多關于值和引用的信息,參見它的第二章。
不等價性
<
,>
,<=
,和>=
操作符用于不等價性比較,在語言規范中被稱為“關系比較”。一般來說它們將與number
這樣的可比較有序值一起使用。3 < 4
是很容易理解的。
但是JavaScriptstring
值也可進行不等價性比較,它使用典型的字母順序規則("bar" < "foo"
)。
那么強制轉換呢?與==
比較相似的規則(雖然不是完全相同!)也適用于不等價操作符。要注意的是,沒有像===
嚴格等價操作符那樣不允許強制轉換的“嚴格不等價”操作符。
考慮如下代碼:
var a = 41;
var b = "42";
var c = "43";
a < b; // true
b < c; // true
這里發生了什么?在ES5語言規范的11.8.5部分中,它說如果<
比較的兩個值都是string
,就像b < c
,那么這個比較將會以字典順序(也就是像字典中字母的排列順序)進行。但如果兩個值之一不是string
,就像a < b
,那么兩個值就將被強制轉換成number
,并進行一般的數字比較。
在可能不同類型的值之間進行比較時,你可能遇到的最大的坑 —— 記住,沒有“嚴格不等價”可用 —— 是其中一個值不能轉換為合法的數字,例如:
var a = 42;
var b = "foo";
a < b; // false
a > b; // false
a == b; // false
等一下,這三個比較怎么可能都是false
?因為在<
和>
的比較中,值b
被強制轉換為了“非法的數字值”,而且語言規范說NaN
既不大于其他值,也不小于其他值。
==
比較失敗于不同的原因。如果a == b
被解釋為42 == NaN
或者"42" == "foo"
都會失敗 —— 正如我們前面講過的,這里是前一種情況。
注意: 關于不等價比較規則的更多信息,參見ES5語言規范的11.8.5部分,和本系列的 類型與文法 第四章。
變量
在JavaScript中,變量名(包括函數名)必須是合法的 標識符(identifiers)。當你考慮非傳統意義上的字符時,比如Unicode,標識符中合法字符的嚴格和完整的規則就有點兒復雜。如果你僅考慮典型的ASCII字母數字的字符,那么這個規則還是很簡單的。
一個標識符必須以a
-z
,A
-Z
,$
,或_
開頭。它可以包含任意這些字符外加數字0
-9
。
一般來說,變量標識符的規則也通用適用于屬性名稱。然而,有一些不能用作變量名,但是可以用作屬性名的單詞。這些單詞被稱為“保留字(reserved words)”,包括JS關鍵字(for
,in
,if
,等等)和null
,true
和false
。
注意: 更多關于保留字的信息,參見本系列的 類型與文法 的附錄A。
函數作用域
你使用var
關鍵字聲明的變量將屬于當前的函數作用域,如果聲明位于任何函數外部的頂層,它就屬于全局作用域。
提升
無論var
出現在一個作用域內部的何處,這個聲明都被認為是屬于整個作用域,而且在作用域的所有位置都是可以訪問的。
這種行為稱為 提升,比喻一個var
聲明在概念上 被移動 到了包含它的作用域的頂端。技術上講,這個過程通過代碼的編譯方式進行解釋更準確,但是我們先暫且跳過那些細節。
考慮如下代碼:
var a = 2;
foo(); // 可以工作, 因為 `foo()` 聲明被“提升”了
function foo() {
a = 3;
console.log( a ); // 3
var a; // 聲明被“提升”到了 `foo()` 的頂端
}
console.log( a ); // 2
警告: 在一個作用域中依靠變量提升來在var
聲明出現之前使用一個變量是不常見的,也不是個好主意;它可能相當使人困惑。而使用被提升的函數聲明要常見得多,也更為人所接受,就像我們在foo()
正式聲明之前就調用它一樣。
嵌套的作用域
當你聲明了一個變量時,它就在這個作用域內的任何地方都是可用的,包括任何下層/內部作用域。例如:
function foo() {
var a = 1;
function bar() {
var b = 2;
function baz() {
var c = 3;
console.log( a, b, c ); // 1 2 3
}
baz();
console.log( a, b ); // 1 2
}
bar();
console.log( a ); // 1
}
foo();
注意c
在bar()
的內部是不可用的,因為它是僅在內部的baz()
作用域中被聲明的,并且b
因為同樣的原因在foo()
內是不可用的。
如果你試著在一個作用域內訪問一個不可用的變量的值,你就會得到一個被拋出的ReferenceError
。如果你試著為一個還沒有被聲明的變量賦值,那么根據“strict模式”的狀態,你會要么得到一個在頂層全局作用域中創建的變量(不好!),要么得到一個錯誤。讓我們看一下:
function foo() {
a = 1; // `a` 沒有被正式聲明
}
foo();
a; // 1 -- 噢,自動全局變量 :(
這是一種非常差勁兒的做法。別這么干!總是給你的變量進行正式聲明。
除了在函數級別為變量創建聲明,ES6允許你使用let
關鍵字聲明屬于個別塊兒(一個{ .. }
)的變量。除了一些微妙的細節,作用域規則將大致上與我們剛剛看到的函數相同:
function foo() {
var a = 1;
if (a >= 1) {
let b = 2;
while (b < 5) {
let c = b * 2;
b++;
console.log( a + c );
}
}
}
foo();
// 5 7 9
因為使用了let
而非var
,b
將僅屬于if
語句而不是整個foo()
函數的作用域。相似地,c
僅屬于while
循環。對于以更加細粒度的方式管理你的變量作用域來說,塊兒作用域是非常有用的,它將使你的代碼隨著時間的推移更加易于維護。
注意: 關于作用域的更多信息,參見本系列的 作用域與閉包。更多關于let
塊兒作用域的信息,參見本系列的 ES6與未來。
條件
除了我們在第一章中簡要介紹過的if
語句,JavaScript還提供了幾種其他值得我們一看的條件機制。
有時你可能發現自己在像這樣寫一系列的if..else..if
語句:
if (a == 2) {
// 做一些事情
}
else if (a == 10) {
// 做另一些事請
}
else if (a == 42) {
// 又是另外一些事情
}
else {
// 這里是備用方案
}
這種結構好用,但有一點兒繁冗,因為你需要為每一種情況都指明a
的測試。這里有另一種選項,switch
語句:
switch (a) {
case 2:
// 做一些事情
break;
case 10:
// 做另一些事請
break;
case 42:
// 又是另外一些事情
break;
default:
// 這里是備用方案
}
如果你想僅讓一個case
中的語句運行,break
是很重要的。如果你在一個case
中省略了break
,并且這個case
成立或運行,那么程序的執行將會不管下一個case
語句是否成立而繼續執行它。這種所謂的“掉落”有時是有用/期望的:
switch (a) {
case 2:
case 10:
// 一些很酷的事情
break;
case 42:
// 另一些事情
break;
default:
// 備用方案
}
這里,如果a
是2
或10
,它就會執行“一些很酷的事情”的代碼語句。
在JavaScript中的另一種條件形式是“條件操作符”,經常被稱為“三元操作符”。它像是一個單獨的if..else
語句的更簡潔的形式,比如:
var a = 42;
var b = (a > 41) ? "hello" : "world";
// 與此相似:
// if (a > 41) {
// b = "hello";
// }
// else {
// b = "world";
// }
如果測試表達式(這里是a > 41
)求值為true
,那么就會得到第一個子句("hello"
),否則得到第二個子句("world"
),而且無論結果為何都會被賦值給b
。
條件操作符不一定非要用于賦值,但是這絕對是最常見的用法。
注意: 關于測試條件和switch
與? :
的其他模式的更多信息,參見本系列的 類型與文法。
Strict模式
ES5在語言中加入了一個“strict模式”,它收緊了一些特定行為的規則。一般來說,這些限制被視為使代碼符合一組更安全和更合理的指導方針。另外,堅持strict模式一般會使你的代碼對引擎有更強的可優化性。strict模式對代碼有很大的好處,你應當在你所有的程序中使用它。
根據你擺放strict模式注解的位置,你可以為一個單獨的函數,或者是整個一個文件切換到strict模式:
function foo() {
"use strict";
// 這部分代碼是strict模式的
function bar() {
// 這部分代碼是strict模式的
}
}
// 這部分代碼不是strict模式的
將它與這個相比:
"use strict";
function foo() {
// 這部分代碼是strict模式的
function bar() {
// 這部分代碼是strict模式的
}
}
// 這部分代碼是strict模式的
使用strict模式的一個關鍵不同(改善!)是,它不允許因為省略了var
而進行隱含的自動全局變量聲明:
function foo() {
"use strict"; // 打開strict模式
a = 1; // 缺少`var`,ReferenceError
}
foo();
如果你在代碼中打開strict模式,并且得到錯誤,或者代碼開始變得有bug,這可能會誘使你避免使用strict模式。但是縱容這種直覺不是一個好主意。如果strict模式在你的程序中導致了問題,那么這標志著在你的代碼中幾乎可以肯定有應該修改的東西。
strict模式不僅將你的代碼保持在更安全的道路上,也不僅將使你的代碼可優化性更強,它還代表著這種語言未來的方向。對于你來說,現在就開始習慣于strict模式要比一直回避它容易得多 —— 以后再進行這種轉變只會更難!
注意: 關于strict模式的更多信息,參見本系列的 類型與文法 的第五章。
函數作為值
至此,我們已經將函數作為JavaScript中主要的 作用域 機制討論過了。你可以回想一下典型的function
聲明語法是這樣的:
function foo() {
// ..
}
雖然從這種語法中看起來不明顯,foo
基本上是一個位于外圍作用域的變量,它給了被聲明的function
一個引用。也就是說,function
本身是一個值,就像42
或[1,2,3]
一樣。
這可能聽起來像是一個奇怪的概念,所以花點兒時間仔細考慮一下。你不僅可以向一個function
傳遞一個值(參數值),而且 一個函數本身可以是一個值,它能夠賦值給變量,傳遞給其他函數,或者從其它函數中返回。
因此,一個函數值應當被認為是一個表達式,與任何其他的值或表達式很相似。
考慮如下代碼:
var foo = function() {
// ..
};
var x = function bar(){
// ..
};
第一個被賦值給變量foo
的函數表達式稱為 匿名 函數表達式,因為它沒有“名稱”。
第二個函數表達式是 命名的(bar
),它還被賦值給變量x
作為它的引用。命名函數表達式 一般來說更理想,雖然 匿名函數表達式 仍然極其常見。
更多信息參見本系列的 作用域與閉包。
立即被調用的函數表達式(IIFE)
在前一個代碼段中,哪一個函數表達式都沒有被執行 —— 除非我們使用了foo()
或x()
。
有另一種執行函數表達式的方法,它通常被稱為一個 立即被調用的函數表達式 (IIFE):
(function IIFE(){
console.log( "Hello!" );
})();
// "Hello!"
圍繞在函數表達式(function IIFE(){ .. })
外部的( .. )
只是一個微妙的JS文法,我們需要它來防止函數表達式被看作一個普通的函數聲明。
在表達式末尾的最后的()
—— })();
這一行 —— 才是實際立即執行它前面的函數表達式的東西。
這看起來可能很奇怪,但它不像第一眼看上去那么陌生。考慮這里的foo
和IIFE
之間的相似性:
function foo() { .. }
// `foo` 是函數引用表達式,然后用`()`執行它
foo();
// `IIFE` 是函數表達式,然后用`()`執行它
(function IIFE(){ .. })();
如你所見,在執行它的()
之前列出(function IIFE(){ .. })
,與在執行它的()
之前定義foo
實質上是相同的;在這兩種情況下,函數引用都使用立即在它后面的()
執行。
因為IIFE只是一個函數,而函數可以創建變量 作用域,以這樣的風格使用一個IIFE經常被用于定義變量,而這些變量將不會影響圍繞在IIFE外面的代碼:
var a = 42;
(function IIFE(){
var a = 10;
console.log( a ); // 10
})();
console.log( a ); // 42
IIFE還可以有返回值:
var x = (function IIFE(){
return 42;
})();
x; // 42
值42
從被執行的命名為IIFE
的函數中return
,然后被賦值給x
。
閉包
閉包 是JavaScript中最重要,卻又經常最少為人知的概念之一。我不會在這里涵蓋更深的細節,你可以參照本系列的 作用域與閉包。但我想說幾件關于它的事情,以便你了解它的一般概念。它將是你的JS技術結構中最重要的技術之一。
你可以認為閉包是這樣一種方法:即使函數已經完成了運行,它依然可以“記住”并持續訪問函數的作用域。
考慮如下代碼:
function makeAdder(x) {
// 參數 `x` 是一個內部變量
// 內部函數 `add()` 使用 `x`,所以它對 `x` 擁有一個“閉包”
function add(y) {
return y + x;
};
return add;
}
每次調用外部的makeAdder(..)
所返回的對內部add(..)
函數的引用可以記住被傳入makeAdder(..)
的x
值。現在,讓我們使用makeAdder(..)
:
// `plusOne` 得到一個指向內部函數 `add(..)` 的引用,
// `add()` 函數擁有對外部 `makeAdder(..)` 的參數 `x`
// 的閉包
var plusOne = makeAdder( 1 );
// `plusTen` 得到一個指向內部函數 `add(..)` 的引用,
// `add()` 函數擁有對外部 `makeAdder(..)` 的參數 `x`
// 的閉包
var plusTen = makeAdder( 10 );
plusOne( 3 ); // 4 <-- 1 + 3
plusOne( 41 ); // 42 <-- 1 + 41
plusTen( 13 ); // 23 <-- 10 + 13
這段代碼的工作方式是:
- 當我們調用
makeAdder(1)
時,我們得到一個指向它內部的add(..)
的引用,它記住了x
是1
。我們稱這個函數引用為plusOne(..)
。 - 當我們調用
makeAdder(10)
時,我們得到了另一個指向它內部的add(..)
引用,它記住了x
是10
。我們稱這個函數引用為plusTen(..)
。 - 當我們調用
plusOne(3)
時,它在3
(它內部的y
)上加1
(被x
記住的),于是我們得到結果4
。 - 當我們調用
plusTen(13)
時,它在13
(它內部的y
)上加10
(被x
記住的),于是我們得到結果23
。
如果這看起來很奇怪和令人困惑,不要擔心 —— 它確實是的!要完全理解它需要很多的練習。
但是相信我,一旦你理解了它,它就是編程中最強大最有用的技術之一。讓你的大腦在閉包中煎熬一會是絕對值得的。在下一節中,我們將進一步實踐閉包。
模塊
在JavaScript中閉包最常見的用法就是模塊模式。模塊讓你定義對外面世界不可見的私有實現細節(變量,函數),和對外面可訪問的公有API。
考慮如下代碼:
function User(){
var username, password;
function doLogin(user,pw) {
username = user;
password = pw;
// 做登錄的工作
}
var publicAPI = {
login: doLogin
};
return publicAPI;
}
// 創建一個 `User` 模塊的實例
var fred = User();
fred.login( "fred", "12Battery34!" );
函數User()
作為一個外部作用域持有變量username
和password
,以及內部doLogin()
函數;它們都是User
模塊內部的私有細節,是不能從外部世界訪問的。
警告: 我們在這里沒有調用new User()
,這是有意為之的,雖然對大多數讀者來說那可能更常見。User()
只是一個函數,不是一個要被初始化的對象,所以它只是被一般地調用了。使用new
將是不合適的,而且實際上會浪費資源。
執行User()
創建了User
模塊的一個 實例 —— 一個全新的作用域會被創建,而每個內部變量/函數的一個全新的拷貝也因此而被創建。我們將這個實例賦值給fred
。如果我們再次運行User()
,我們將會得到一個與fred
完全分離的新的實例。
內部的doLogin()
函數在username
和password
上擁有閉包,這意味著即便User()
函數已經完成了運行,它依然持有對它們的訪問權。
publicAPI
是一個帶有一個屬性/方法的對象,login
是一個指向內部doLogin()
函數的引用。當我們從User()
中返回publicAPI
時,它就變成了我們稱為fred
的實例。
在這個時候,外部的User()
函數已經完成了執行。一般說來,你會認為像username
和password
這樣的內部變量將會消失。但是在這里它們不會,因為在login()
函數里有一個閉包使它們繼續存活。
這就是為什么我們可以調用fred.login(..)
—— 和調用內部的doLogin(..)
一樣 —— 而且它依然可以訪問內部變量username
和password
。
這樣對閉包和模塊模式的簡單一瞥,你很有可能還是有點兒糊涂。沒關系!要把它裝進你的大腦確實需要花些功夫。
以此為起點,關于更多深入細節的探索可以去讀本系列的 作用域與閉包。
this
標識符
在JavaScript中另一個經常被誤解的概念是this
標識符。同樣,在本系列的 this與對象原型 中有好幾章關于它的內容,所以在這里我們只簡要地介紹一下概念。
雖然this
可能經常看起來是與“面向對象模式”有關的,但在JS中this
是一個不同的概念。
如果一個函數在它內部擁有一個this
引用,那么這個this
引用通常指向一個object
。但是指向哪一個object
要看這個函數是如何被調用的。
重要的是要理解this
不是 指函數本身,這是最常見的誤解。
這是一個快速的說明:
function foo() {
console.log( this.bar );
}
var bar = "global";
var obj1 = {
bar: "obj1",
foo: foo
};
var obj2 = {
bar: "obj2"
};
// --------
foo(); // "global"
obj1.foo(); // "obj1"
foo.call( obj2 ); // "obj2"
new foo(); // undefined
關于this
如何被設置有四個規則,它們被展示在這個代碼段的最后四行中:
-
foo()
最終在非strict模式中將this
設置為全局對象 —— 在strict模式中,this
將會是undefined
而且你會在訪問bar
屬性時得到一個錯誤 —— 所以this.bar
的值是global
。 -
obj1.foo()
將this
設置為對象obj1
。 -
foo.call(obj2)
將this
設置為對象obj2
。 -
new foo()
將this
設置為一個新的空對象。
底線:要搞清楚this
指向什么,你必須檢視當前的函數是如何被調用的。它將是我們剛剛看到的四種中的一種,而這將會回答this
是什么。
注意: 關于this
的更多信息,參見本系列的 this與對象原型 的第一和第二章。
原型
JavaScript中的原型機制十分復雜。我們在這里僅僅掃它一眼。要了解關于它的所有細節,你需要花相當的時間來學習本系列的 this與對象原型 的第四到六章。
當你引用一個對象上的屬性時,如果這個屬性不存在,JavaScript將會自動地使用這個對象的內部原型引用來尋找另外一個對象,在它上面查詢你想要的屬性。你可以認為它幾乎是在屬性缺失時的備用對象。
從一個對象到它備用對象的內部原型引用鏈接發生在這個對象被創建的時候。說明它的最簡單的方法是使用稱為Object.create(..)
的內建工具。
考慮如下代碼:
var foo = {
a: 42
};
// 創建 `bar` 并將它鏈接到 `foo`
var bar = Object.create( foo );
bar.b = "hello world";
bar.b; // "hello world"
bar.a; // 42 <-- 委托到 `foo`
將對象foo
和bar
以及它們的關系可視化也許會有所幫助:

屬性a
實際上不存在于對象bar
上,但是因為bar
被原型鏈接到foo
,JavaScript自動地退到對象foo
上去尋找a
,而且在這里找到了它。
這種鏈接看起來是語言的一種奇怪的特性。這種特性最常被使用的方式 —— 我會爭辯說這是一種濫用 —— 是用來模擬/模仿“類”機制的“繼承”。
使用原型的更自然的方式是一種稱為“行為委托”的模式,在這種模式中你有意地將你的被鏈接的對象設計為可以從一個委托到另一個的部分所需的行為中。
注意: 更多關于原型和行為委托的信息,參見本系列的 this與對象原型 的第四到六章。
舊的與新的
我們已經介紹過的JS特性,和將在這個系列的其他部分中講解的相當一部分特性都是新近增加的,不一定在老版本的瀏覽器中可用。事實上,語言規范中的一些最新特性甚至在任何穩定的瀏覽中都沒有被實現。
那么,你拿這些新東西怎么辦?你只能等上幾年或者十幾年直到老版本瀏覽器歸于塵土?
這確實是許多人認為的情況,但是它不是JS健康的進步方式。
有兩種主要的技術可以將新的JavaScript特性“帶到”老版本的瀏覽器中:填補和轉譯。
填補
“填補(Polyfilling)”是一個人為發明的詞(由Remy Sharp創造)(https://remysharp.com/2010/10/08/what-is-a-polyfill)。它是指拿來一個新特性的定義并制造一段行為等價的代碼,但是這段代碼可以運行在老版本的JS環境中。
例如,ES6定義了一個稱為Number.isNaN(..)
的工具,來為檢查NaN
值提供一種準確無誤的方法,同時廢棄原來的isNaN(..)
工具。這個工具可以很容易填補,因此你可開始在你的代碼中使用它,而不管最終用戶是否在一個ES6瀏覽器中。
考慮如下代碼:
if (!Number.isNaN) {
Number.isNaN = function isNaN(x) {
return x !== x;
};
}
if
語句決定著在這個工具已經存在的ES6環境中不再進行填補。如果它還不存在,我們就定義Number.isNaN(..)
。
注意: 我們在這里做的檢查利用了NaN
值的怪異之處,即它們是整個語言中唯一與自己不相等的值。所以NaN
是唯一可能使x !== x
為true
的值。
并不是所有的新特性都可以完全填補。有時一種特性的大部分行為可以被填補,但是仍然存在一些小的偏差。在實現你自己的填補時你應當非常非常小心,來確保你盡可能嚴格地遵循語言規范。
或者更好地,使用一組你信任的,經受過檢驗的填補,比如那些由ES5-Shim(https://github.com/es-shims/es5-shim)和ES6-Shim(https://github.com/es-shims/es6-shim)提供的。
轉譯
沒有任何辦法可以填補語言中新增加的語法。在老版本的JS引擎中新的語法將因為不可識別/不合法而拋出一個錯誤。
所以更好的選擇是使用一個工具將你的新版本代碼轉換為等價的老版本代碼。這個處理通常被稱為“轉譯(transpiling)”,表示轉換 + 編譯。
實質上,你的源代碼是使用新的語法形式編寫的,但是你向瀏覽器部署的是轉譯過的舊語法形式。你一般會將轉譯器插入到你的構建過程中,與你的代碼linter和代碼壓縮器類似。
你可能想知道為什么要麻煩地使用新語法編寫程序又將它轉譯為老版本代碼 —— 為什么不直接編寫老版本代碼呢?
關于轉譯你應當注意幾個重要的原因:
- 在語言中新加入的語法是為了使你的代碼更具可讀性和維護性而設計的。老版本的等價物經常會繞多得多的圈子。你應當首選編寫新的和干凈的語法,不僅為你自己,也為了開發團隊的其他的成員。
- 如果你僅為老版本瀏覽器轉譯,而給最新的瀏覽器提供新語法,那么你就可以利用瀏覽器對新語法進行的性能優化。這也讓瀏覽器制造商有更多真實世界的代碼來測試它們的實現和優化方法。
- 提早使用新語法可以允許它在真實世界中被測試得更加健壯,這給JavaScript委員會(TC39)提供了更早的反饋。如果問題被發現的足夠早,他們就可以在那些語言設計錯誤變得無法挽回之前改變/修改它。
這是一個轉譯的簡單例子。ES6增加了一個稱為“默認參數值”的新特性。它看起來像是這樣:
function foo(a = 2) {
console.log( a );
}
foo(); // 2
foo( 42 ); // 42
簡單,對吧?也很有用!但是這種新語法在前ES6引擎中是不合法的。那么轉譯器將會對這段代碼做什么才能使它在老版本環境中運行呢?
function foo() {
var a = arguments[0] !== (void 0) ? arguments[0] : 2;
console.log( a );
}
如你所見,它檢查arguments[0]
值是否是void 0
(也就是undefined
),而且如果是,就提供默認值2
;否則,它就賦值被傳遞的任何東西。
除了可以現在就在老版本瀏覽器中使用更好的語法以外,觀察轉譯后的代碼實際上更清晰地解釋了意圖中的行為。
僅從ES6版本的代碼看來,你可能還不理解undefined
是唯一不能作為參數默認值的明確傳遞的值,但是轉譯后的代碼使這一點清楚的多。
關于轉譯要強調的最后一個細節是,現在它們應當被認為是JS開發的生態系統和過程中的標準部分。JS將繼續以比以前快得多的速度進化,所以每幾個月就會有新語法和新特性被加入進來。
如果你默認地使用一個轉譯器,那么你將總是可以在發現新語法有用時,立即開始使用它,而不必為了讓今天的瀏覽器被淘汰而等上好幾年。
有好幾個了不起的轉譯器供你選擇。這是一些在本書寫作時存在的好選擇:
- Babel (https://babeljs.io) (前身為 6to5): 將 ES6+ 轉譯為 ES5
- Traceur (https://github.com/google/traceur-compiler): 將 ES6,ES7,和以后特性轉譯為 ES5
非JavaScript
至此,我們討論過的所有東西都限于JS語言本身。現實是大多數JS程序都是在瀏覽器這樣的環境中運行并與之互動的。你所編寫的很大一部分代碼,嚴格地說,不是直接由JavaScript控制的。這聽起來可能有點奇怪。
你將會遇到的最常見的非JavaScript程序是DOM API。例如:
var el = document.getElementById( "foo" );
當你的代碼運行在一個瀏覽器中時,變量document
作為一個全局變量存在。它不是由JS引擎提供的,也不為JavaScript語言規范所控制。它采取了某種與普通JS object
極其相似的形式,但它不是真正的object
。它是一種特殊的object
,經常被稱為“宿主對象”。
另外,document
上的getElementById(..)
方法看起來像一個普通的JS函數,但它只是一個微微暴露出來的接口,指向由瀏覽器DOM提供的內建方法。在一些(新一代的)瀏覽器中,這一層可能也是由JS實現的,但是傳統的DOM及其行為是由像C/C++這樣的語言實現的。
另一個例子是輸入/輸出(I/O)。
大家最喜愛的alert(..)
在用戶的瀏覽器窗口中彈出一個消息框。alert(..)
是由瀏覽器提供給你的JS程序的,而不是JS引擎本身。你進行的調用將消息發送給瀏覽器內部,它來處理消息框的繪制與顯示。
console.log()
也一樣;你的瀏覽器提供這樣的機制并將它們掛在開發者工具中。
這本書,和整個這個系列,聚焦于JavaScript語言本身。這就是為什么你看不到任何涵蓋這些非JavaScript機制的重要內容。不管怎樣,你需要小心它們,因為它們將在你寫的每一個JS程序中存在!
復習
學習JavaScript風格編程的第一步是對它的核心機制有一個基本的了解,比如:值,類型,函數閉包,this
,和原型。
當然,這些話題中的每一個都會衍生出比你在這里見到的多得多的內容,這也是為什么它們在這個系列剩下的部分中擁有自己的章節和書目。在你對本章中的概念和代碼示例感到相當適應之后,這個系列的其他部分正等著你真正地深入挖掘和了解這門語言。
這本書的最后一章將會對這個系列的每一卷的內容,以及它們所涵蓋的我們在這里還沒有探索過的概念,進行簡單地總結。