你不知道的JavaScript 上卷 第一部分 作用域和閉包

第1章 作用域是什麼

幾乎所有編程語言最基本的功能之一,就是能夠儲存變量當中的值,並且能在之後對這個
值進行訪問或修改。事實上,正是這種儲存和訪問變量的值的能力將狀態帶給了程序。

若沒有了狀態這個概念,程序雖然也能夠執行一些簡單的任務,但它會受到高度限制,做
不到非常有趣。

但是將變量引入程序會引起幾個很有意思的問題,也正是我們將要討論的:這些變量住在
哪裏?換句話說,它們儲存在哪裏?最重要的是,程序需要時如何找到它們?

這些問題說明需要一套設計良好的規則來存儲變量,並且之後可以方便地找到這些變量。
這套規則被稱爲作用域。

1.1 編譯原理

儘管通常將JavaScript 歸類爲“動態”或“解釋執行”語言,但事實上它是一門編譯語言。
這個事實對你來說可能顯而易見,也可能你聞所未聞,取決於你接觸過多少編程語言,具
有多少經驗。但與傳統的編譯語言不同,它不是提前編譯的,編譯結果也不能在分佈式系
統中進行移植。
儘管如此,JavaScript 引擎進行編譯的步驟和傳統的編譯語言非常相似,在某些環節可能
比預想的要複雜。

在傳統編譯語言的流程中,程序中的一段源代碼在執行之前會經歷三個步驟,統稱爲“編
譯”。

  • 分詞/詞法分析(Tokenizing/Lexing)
    這個過程會將由字符組成的字符串分解成(對編程語言來說)有意義的代碼塊,這些代
    碼塊被稱爲詞法單元(token)。例如,考慮程序var a = 2;。這段程序通常會被分解成
    爲下面這些詞法單元:var、a、=、2 、;。空格是否會被當作詞法單元,取決於空格在
    這門語言中是否具有意義。
  • 解析/語法分析(Parsing)
    這個過程是將詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的代表了程序語法
    結構的樹。這個樹被稱爲“抽象語法樹”(Abstract Syntax Tree,AST)。
    var a = 2; 的抽象語法樹中可能會有一個叫作VariableDeclaration 的頂級節點,接下
    來是一個叫作Identifier(它的值是a)的子節點,以及一個叫作AssignmentExpression
    的子節點。AssignmentExpression 節點有一個叫作NumericLiteral(它的值是2)的子
    節點。
  • 代碼生成
    將AST 轉換爲可執行代碼的過程稱被稱爲代碼生成。這個過程與語言、目標平臺等息
    息相關。
    拋開具體細節,簡單來說就是有某種方法可以將var a = 2; 的AST 轉化爲一組機器指
    令,用來創建一個叫作a 的變量(包括分配內存等),並將一個值儲存在a 中。

比起那些編譯過程只有三個步驟的語言的編譯器,JavaScript 引擎要複雜得多。例如,在
語法分析和代碼生成階段有特定的步驟來對運行性能進行優化,包括對冗餘元素進行優化
等。
因此在這裏只進行宏觀、簡單的介紹,接下來你就會發現我們介紹的這些看起來有點高深
的內容與所要討論的事情有什麼關聯。

首先,JavaScript 引擎不會有大量的(像其他語言編譯器那麼多的)時間用來進行優化,因
爲與其他語言不同,JavaScript 的編譯過程不是發生在構建之前的。
對於JavaScript 來說,大部分情況下編譯發生在代碼執行前的幾微秒(甚至更短!)的時
間內。在我們所要討論的作用域背後,JavaScript 引擎用盡了各種辦法(比如JIT,可以延
遲編譯甚至實施重編譯)來保證性能最佳。
簡單地說,任何JavaScript 代碼片段在執行前都要進行編譯(通常就在執行前)。因此,
JavaScript 編譯器首先會對var a = 2; 這段程序進行編譯,然後做好執行它的準備,並且
通常馬上就會執行它。

1.2 理解作用域

我們學習作用域的方式是將這個過程模擬成幾個人物之間的對話。那麼,由誰進行這場對
話呢?

1.2.1 演員表

首先介紹將要參與到對程序var a = 2; 進行處理的過程中的演員們,這樣才能理解接下來
將要聽到的對話。

  • 引擎
    從頭到尾負責整個JavaScript 程序的編譯及執行過程。
  • 編譯器
    引擎的好朋友之一,負責語法分析及代碼生成等髒活累活(詳見前一節的內容)。
  • 作用域
    引擎的另一位好朋友,負責收集並維護由所有聲明的標識符(變量)組成的一系列查
    詢,並實施一套非常嚴格的規則,確定當前執行的代碼對這些標識符的訪問權限。

爲了能夠完全理解JavaScript 的工作原理,你需要開始像引擎(和它的朋友們)一樣思考,
從它們的角度提出問題,並從它們的角度回答這些問題。

1.2.2 對話

當你看見var a = 2; 這段程序時,很可能認爲這是一句聲明。但我們的新朋友引擎卻不這
麼看。事實上,引擎認爲這裏有兩個完全不同的聲明,一個由編譯器在編譯時處理,另一
個則由引擎在運行時處理。
下面我們將var a = 2; 分解,看看引擎和它的朋友們是如何協同工作的。
編譯器首先會將這段程序分解成詞法單元,然後將詞法單元解析成一個樹結構。但是當編
譯器開始進行代碼生成時,它對這段程序的處理方式會和預期的有所不同。
可以合理地假設編譯器所產生的代碼能夠用下面的僞代碼進行概括:“爲一個變量分配內
存,將其命名爲a,然後將值2 保存進這個變量。”然而,這並不完全正確。
事實上編譯器會進行如下處理。

  1. 遇到var a,編譯器會詢問作用域是否已經有一個該名稱的變量存在於同一個作用域的
    集合中。如果是,編譯器會忽略該聲明,繼續進行編譯;否則它會要求作用域在當前作
    用域的集合中聲明一個新的變量,並命名爲a。

  2. 接下來編譯器會爲引擎生成運行時所需的代碼,這些代碼被用來處理a = 2 這個賦值
    操作。引擎運行時會首先詢問作用域,在當前的作用域集合中是否存在一個叫作a 的
    變量。如果是,引擎就會使用這個變量;如果否,引擎會繼續查找該變量(查看1.3
    節)。

如果引擎最終找到了a 變量,就會將2 賦值給它。否則引擎就會舉手示意並拋出一個異
常!

總結:變量的賦值操作會執行兩個動作,首先編譯器會在當前作用域中聲明一個變量(如
果之前沒有聲明過),然後在運行時引擎會在作用域中查找該變量,如果能夠找到就會對
它賦值。

1.2.3 編譯器有話說

編譯器在編譯過程的第二步中生成了代碼,引擎執行它時,會通過查找變量a 來判斷它是
否已聲明過。查找的過程由作用域進行協助,但是引擎執行怎樣的查找,會影響最終的查
找結果。

在我們的例子中,引擎會爲變量a 進行LHS 查詢。另外一個查找的類型叫作RHS。

我打賭你一定能猜到“L”和“R”的含義,它們分別代表左側和右側。

什麼東西的左側和右側?是一個賦值操作的左側和右側。

換句話說,當變量出現在賦值操作的左側時進行LHS 查詢,出現在右側時進行RHS 查詢。
講得更準確一點,RHS 查詢與簡單地查找某個變量的值別無二致,而LHS 查詢則是試圖
找到變量的容器本身,從而可以對其賦值。從這個角度說,RHS 並不是真正意義上的“賦
值操作的右側”,更準確地說是“非左側”。

你可以將RHS 理解成retrieve his source value(取到它的源值),這意味着“得到某某的
值”。

考慮以下代碼:

console.log( a );

其中對a 的引用是一個RHS 引用,因爲這裏a 並沒有賦予任何值。相應地,需要查找並取
得a 的值,這樣才能將值傳遞給console.log(..)。

相比之下,例如:

a = 2;

這裏對a 的引用則是LHS 引用,因爲實際上我們並不關心當前的值是什麼,只是想要爲=
2 這個賦值操作找到一個目標。

LHS 和RHS 的含義是“賦值操作的左側或右側”並不一定意味着就是“=
賦值操作符的左側或右側”。賦值操作還有其他幾種形式,因此在概念上最
好將其理解爲“賦值操作的目標是誰(LHS)”以及“誰是賦值操作的源頭
(RHS)”。

考慮下面的程序,其中既有LHS 也有RHS 引用:

function foo(a) {
console.log( a ); // 2
}
foo( 2 );

最後一行foo(..) 函數的調用需要對foo 進行RHS 引用,意味着“去找到foo 的值,並把
它給我”。並且(..) 意味着foo 的值需要被執行,因此它最好真的是一個函數類型的值!
這裏還有一個容易被忽略卻非常重要的細節。
代碼中隱式的a=2 操作可能很容易被你忽略掉。這個操作發生在2 被當作參數傳遞給
foo(..) 函數時,2 會被分配給參數a。爲了給參數a(隱式地)分配值,需要進行一次
LHS 查詢。

這裏還有對a 進行的RHS 引用, 並且將得到的值傳給了console.log(..)。console.
log(..) 本身也需要一個引用才能執行,因此會對console 對象進行RHS 查詢,並且檢查
得到的值中是否有一個叫作log 的方法。

最後,在概念上可以理解爲在LHS 和RHS 之間通過對值2 進行交互來將其傳遞進log(..)
(通過變量a 的RHS 查詢)。假設在log(..) 函數的原生實現中它可以接受參數,在將2 賦
值給其中第一個(也許叫作arg1)參數之前,這個參數需要進行LHS 引用查詢。

你可能會傾向於將函數聲明function foo(a) {… 概念化爲普通的變量聲明
和賦值,比如var foo、foo = function(a) {…。如果這樣理解的話,這
個函數聲明將需要進行LHS 查詢。
然而還有一個重要的細微差別,編譯器可以在代碼生成的同時處理聲明和值
的定義,比如在引擎執行代碼時,並不會有線程專門用來將一個函數值“分
配給”foo。因此,將函數聲明理解成前面討論的LHS 查詢和賦值的形式並
不合適。

1.2.4 引擎和作用域的對話
function foo(a) {
console.log( a ); // 2
}
foo( 2 );

讓我們把上面這段代碼的處理過程想象成一段對話,這段對話可能是下面這樣的。

引擎:我說作用域,我需要爲foo 進行RHS 引用。你見過它嗎?
作用域:別說,我還真見過,編譯器那小子剛剛聲明瞭它。它是一個函數,給你。
引擎:哥們太夠意思了!好吧,我來執行一下foo。
引擎:作用域,還有個事兒。我需要爲a 進行LHS 引用,這個你見過嗎?
作用域:這個也見過,編譯器最近把它聲名爲foo 的一個形式參數了,拿去吧。
引擎:大恩不言謝,你總是這麼棒。現在我要把2 賦值給a。
引擎:哥們,不好意思又來打擾你。我要爲console 進行RHS 引用,你見過它嗎?
作用域:咱倆誰跟誰啊,再說我就是幹這個。這個我也有,console 是個內置對象。
給你。
引擎:麼麼噠。我得看看這裏面是不是有log(..)。太好了,找到了,是一個函數。
引擎:哥們,能幫我再找一下對a 的RHS 引用嗎?雖然我記得它,但想再確認一次。
作用域:放心吧,這個變量沒有變動過,拿走,不謝。
引擎:真棒。我來把a 的值,也就是2,傳遞進log(..)。
……

1.2.5 小測驗

檢驗一下到目前的理解程度。把自己當作引擎,並同作用域進行一次“對話”:

function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
  1. 找到其中所有的LHS 查詢。(這裏有3 處!)
  2. 找到其中所有的RHS 查詢。(這裏有4 處!)
1.3 作用域嵌套

我們說過,作用域是根據名稱查找變量的一套規則。實際情況中,通常需要同時顧及幾個
作用域。
當一個塊或函數嵌套在另一個塊或函數中時,就發生了作用域的嵌套。因此,在當前作用
域中無法找到某個變量時,引擎就會在外層嵌套的作用域中繼續查找,直到找到該變量,
或抵達最外層的作用域(也就是全局作用域)爲止。
考慮以下代碼:

function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4

對b 進行的RHS 引用無法在函數foo 內部完成,但可以在上一級作用域(在這個例子中就
是全局作用域)中完成。
因此,回顧一下引擎和作用域之間的對話,會進一步聽到:

引擎:foo 的作用域兄弟,你見過b 嗎?我需要對它進行RHS 引用。
作用域:聽都沒聽過,走開。
引擎:foo 的上級作用域兄弟,咦?有眼不識泰山,原來你是全局作用域大哥,
太好了。你見過b 嗎?我需要對它進行RHS 引用。
作用域:當然了,給你吧。

遍歷嵌套作用域鏈的規則很簡單:引擎從當前的執行作用域開始查找變量,如果找不到,
就向上一級繼續查找。當抵達最外層的全局作用域時,無論找到還是沒找到,查找過程都
會停止。

把作用域鏈比喻成一個建築

爲了將作用域處理的過程可視化,我希望你在腦中想象下面這個高大的建築:
這個建築代表程序中的嵌套作用域鏈。第一層樓代表當前的執行作用域,也就是你所處的
位置。建築的頂層代表全局作用域。
LHS 和RHS 引用都會在當前樓層進行查找,如果沒有找到,就會坐電梯前往上一層樓,
如果還是沒有找到就繼續向上,以此類推。一旦抵達頂層(全局作用域),可能找到了你
所需的變量,也可能沒找到,但無論如何查找過程都將停止。

1.4 異常

爲什麼區分LHS 和RHS 是一件重要的事情?

因爲在變量還沒有聲明(在任何作用域中都無法找到該變量)的情況下,這兩種查詢的行
爲是不一樣的。
考慮如下代碼:

function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );

第一次對b 進行RHS 查詢時是無法找到該變量的。也就是說,這是一個“未聲明”的變
量,因爲在任何相關的作用域中都無法找到它。
如果RHS 查詢在所有嵌套的作用域中遍尋不到所需的變量,引擎就會拋出ReferenceError
異常。值得注意的是,ReferenceError 是非常重要的異常類型。
相較之下,當引擎執行LHS 查詢時,如果在頂層(全局作用域)中也無法找到目標變量,
全局作用域中就會創建一個具有該名稱的變量,並將其返還給引擎,前提是程序運行在非
“嚴格模式”下。
“不,這個變量之前並不存在,但是我很熱心地幫你創建了一個。”
ES5 中引入了“嚴格模式”。同正常模式,或者說寬鬆/ 懶惰模式相比,嚴格模式在行爲上
有很多不同。其中一個不同的行爲是嚴格模式禁止自動或隱式地創建全局變量。因此,在
嚴格模式中LHS 查詢失敗時,並不會創建並返回一個全局變量,引擎會拋出同RHS 查詢
失敗時類似的ReferenceError 異常。
接下來,如果RHS 查詢找到了一個變量,但是你嘗試對這個變量的值進行不合理的操作,
比如試圖對一個非函數類型的值進行函數調用,或着引用null 或undefined 類型的值中的
屬性,那麼引擎會拋出另外一種類型的異常,叫作TypeError。
ReferenceError 同作用域判別失敗相關,而TypeError 則代表作用域判別成功了,但是對
結果的操作是非法或不合理的。

1.5 小結

作用域是一套規則,用於確定在何處以及如何查找變量(標識符)。如果查找的目的是對
變量進行賦值,那麼就會使用LHS 查詢;如果目的是獲取變量的值,就會使用RHS 查詢。

賦值操作符會導致LHS 查詢。=操作符或調用函數時傳入參數的操作都會導致關聯作用域
的賦值操作。

JavaScript 引擎首先會在代碼執行前對其進行編譯,在這個過程中,像var a = 2 這樣的聲
明會被分解成兩個獨立的步驟:

  • 首先,var a 在其作用域中聲明新變量。這會在最開始的階段,也就是代碼執行前進行。
  • 接下來,a = 2 會查詢(LHS 查詢)變量a 並對其進行賦值。

LHS 和RHS 查詢都會在當前執行作用域中開始,如果有需要(也就是說它們沒有找到所
需的標識符),就會向上級作用域繼續查找目標標識符,這樣每次上升一級作用域(一層
樓),最後抵達全局作用域(頂層),無論找到或沒找到都將停止。

不成功的RHS 引用會導致拋出ReferenceError 異常。不成功的LHS 引用會導致自動隱式
地創建一個全局變量(非嚴格模式下),該變量使用LHS 引用的目標作爲標識符,或者拋
出ReferenceError 異常(嚴格模式下)。

小測驗答案
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
  • 找出所有的LHS 查詢(這裏有3 處!)
    c = ..;、a = 2(隱式變量分配)、b = ..
  • 找出所有的RHS 查詢(這裏有4 處!)
    foo(2..、= a;、a ..、.. b

第2章 詞法作用域

在第1 章中,我們將“作用域”定義爲一套規則,這套規則用來管理引擎如何在當前作用
域以及嵌套的子作用域中根據標識符名稱進行變量查找。

作用域共有兩種主要的工作模型。第一種是最爲普遍的,被大多數編程語言所採用的詞法
作用域,我們會對這種作用域進行深入討論。另外一種叫作動態作用域,仍有一些編程語
言在使用(比如Bash 腳本、Perl 中的一些模式等)。

附錄A 中介紹了動態作用域,在這裏提到它只是爲了同JavaScript 所採用的作用域模型,
即詞法作用域模型進行對比。

2.1 詞法階段

第1 章介紹過,大部分標準語言編譯器的第一個工作階段叫作詞法化(也叫單詞化)。回
憶一下,詞法化的過程會對源代碼中的字符進行檢查,如果是有狀態的解析過程,還會賦
予單詞語義。
這個概念是理解詞法作用域及其名稱來歷的基礎。
簡單地說,詞法作用域就是定義在詞法階段的作用域。換句話說,詞法作用域是由你在寫
代碼時將變量和塊作用域寫在哪裏來決定的,因此當詞法分析器處理代碼時會保持作用域
不變(大部分情況下是這樣的)。

後面會介紹一些欺騙詞法作用域的方法,這些方法在詞法分析器處理過後依
然可以修改作用域,但是這種機制可能有點難以理解。事實上,讓詞法作用
域根據詞法關係保持書寫時的自然關係不變,是一個非常好的最佳實踐。

考慮以下代碼:

function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12

在這個例子中有三個逐級嵌套的作用域。爲了幫助理解,可以將它們想象成幾個逐級包含
的氣泡。

  • 包含着整個全局作用域,其中只有一個標識符:foo。
  • 包含着foo 所創建的作用域,其中有三個標識符:a、bar 和b。
  • 包含着bar 所創建的作用域,其中只有一個標識符:c。

作用域氣泡由其對應的作用域塊代碼寫在哪裏決定,它們是逐級包含的。下一章會討論不
同類型的作用域,但現在只要假設每一個函數都會創建一個新的作用域氣泡就好了。

bar 的氣泡被完全包含在foo 所創建的氣泡中,唯一的原因是那裏就是我們希望定義函數
bar 的位置。

注意,這裏所說的氣泡是嚴格包含的。我們並不是在討論文氏圖1 這種可以跨越邊界的氣
泡。換句話說,沒有任何函數的氣泡可以(部分地)同時出現在兩個外部作用域的氣泡
中,就如同沒有任何函數可以部分地同時出現在兩個父級函數中一樣。

查找

作用域氣泡的結構和互相之間的位置關係給引擎提供了足夠的位置信息,引擎用這些信息
來查找標識符的位置。
在上一個代碼片段中,引擎執行console.log(..) 聲明,並查找a、b 和c 三個變量的引
用。它首先從最內部的作用域,也就是bar(..) 函數的作用域氣泡開始查找。引擎無法在
這裏找到a,因此會去上一級到所嵌套的foo(..) 的作用域中繼續查找。在這裏找到了a,
因此引擎使用了這個引用。對b 來講也是一樣的。而對c 來說,引擎在bar(..) 中就找到
了它。
如果a、c 都存在於bar(..) 和foo(..) 的內部,console.log(..) 就可以直接使用bar(..)
中的變量,而無需到外面的foo(..) 中查找。
作用域查找會在找到第一個匹配的標識符時停止。在多層的嵌套作用域中可以定義同名的
標識符,這叫作“遮蔽效應”(內部的標識符“遮蔽”了外部的標識符)。拋開遮蔽效應,
作用域查找始終從運行時所處的最內部作用域開始,逐級向外或者說向上進行,直到遇見
第一個匹配的標識符爲止。
全局變量會自動成爲全局對象(比如瀏覽器中的window 對象)的屬性,因此
可以不直接通過全局對象的詞法名稱,而是間接地通過對全局對象屬性的引
用來對其進行訪問。
window.a
通過這種技術可以訪問那些被同名變量所遮蔽的全局變量。但非全局的變量
如果被遮蔽了,無論如何都無法被訪問到。
無論函數在哪裏被調用,也無論它如何被調用,它的詞法作用域都只由函數被聲明時所處
的位置決定。
詞法作用域查找只會查找一級標識符,比如a、b 和c。如果代碼中引用了foo.bar.baz,
詞法作用域查找只會試圖查找foo 標識符,找到這個變量後,對象屬性訪問規則會分別接
管對bar 和baz 屬性的訪問。

2.2 欺騙詞法

如果詞法作用域完全由寫代碼期間函數所聲明的位置來定義,怎樣才能在運行時來“修
改”(也可以說欺騙)詞法作用域呢?
JavaScript 中有兩種機制來實現這個目的。社區普遍認爲在代碼中使用這兩種機制並不是
什麼好注意。但是關於它們的爭論通常會忽略掉最重要的點:欺騙詞法作用域會導致性能
下降。
在詳細解釋性能問題之前,先來看看這兩種機制分別是什麼原理。

2.2.1 eval

JavaScript 中的eval(..) 函數可以接受一個字符串爲參數,並將其中的內容視爲好像在書
寫時就存在於程序中這個位置的代碼。換句話說,可以在你寫的代碼中用程序生成代碼並
運行,就好像代碼是寫在那個位置的一樣。
根據這個原理來理解eval(..),它是如何通過代碼欺騙和假裝成書寫時(也就是詞法期)
代碼就在那,來實現修改詞法作用域環境的,這個原理就變得清晰易懂了。
在執行eval(..) 之後的代碼時,引擎並不“知道”或“在意”前面的代碼是以動態形式插
入進來,並對詞法作用域的環境進行修改的。引擎只會如往常地進行詞法作用域查找。
考慮以下代碼:

function foo(str, a) {
eval( str ); // 欺騙!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

eval(..) 調用中的”var b = 3;” 這段代碼會被當作本來就在那裏一樣來處理。由於那段代
碼聲明瞭一個新的變量b,因此它對已經存在的foo(..) 的詞法作用域進行了修改。事實
上,和前面提到的原理一樣,這段代碼實際上在foo(..) 內部創建了一個變量b,並遮蔽
了外部(全局)作用域中的同名變量。
當console.log(..) 被執行時,會在foo(..) 的內部同時找到a 和b,但是永遠也無法找到
外部的b。因此會輸出“1, 3”而不是正常情況下會輸出的“1, 2”。

在這個例子中,爲了展示的方便和簡潔,我們傳遞進去的“代碼”字符串是
固定不變的。而在實際情況中,可以非常容易地根據程序邏輯動態地將字符
拼接在一起之後再傳遞進去。eval(..) 通常被用來執行動態創建的代碼,因
爲像例子中這樣動態地執行一段固定字符所組成的代碼,並沒有比直接將代
碼寫在那裏更有好處。

默認情況下,如果eval(..) 中所執行的代碼包含有一個或多個聲明(無論是變量還是函
數),就會對eval(..) 所處的詞法作用域進行修改。技術上,通過一些技巧(已經超出我
們的討論範圍)可以間接調用eval(..) 來使其運行在全局作用域中,並對全局作用域進行
修改。但無論何種情況,eval(..) 都可以在運行期修改書寫期的詞法作用域。

在嚴格模式的程序中,eval(..) 在運行時有其自己的詞法作用域,意味着其
中的聲明無法修改所在的作用域。

function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );

JavaScript 中還有其他一些功能效果和eval(..) 很相似。setTimeout(..) 和
setInterval(..) 的第一個參數可以是字符串,字符串的內容可以被解釋爲一段動態生成的
函數代碼。這些功能已經過時且並不被提倡。不要使用它們!
new Function(..) 函數的行爲也很類似,最後一個參數可以接受代碼字符串,並將其轉
化爲動態生成的函數(前面的參數是這個新生成的函數的形參)。這種構建函數的語法比
eval(..) 略微安全一些,但也要儘量避免使用。
在程序中動態生成代碼的使用場景非常罕見,因爲它所帶來的好處無法抵消性能上的損
失。

2.2.2 with

JavaScript 中另一個難以掌握(並且現在也不推薦使用)的用來欺騙詞法作用域的功能是
with 關鍵字。可以有很多方法來解釋with,在這裏我選擇從這個角度來解釋它:它如何同
被它所影響的詞法作用域進行交互。
with 通常被當作重複引用同一個對象中的多個屬性的快捷方式,可以不需要重複引用對象
本身。
比如:

var obj = {
a: 1,
b: 2,
c: 3
};
// 單調乏味的重複"obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 簡單的快捷方式
with (obj) {
a = 3;
b = 4;
c = 5;
}

但實際上這不僅僅是爲了方便地訪問對象屬性。考慮如下代碼:

function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!

這個例子中創建了o1 和o2 兩個對象。其中一個具有a 屬性,另外一個沒有。foo(..) 函
數接受一個obj 參數,該參數是一個對象引用,並對這個對象引用執行了with(obj) {..}。
在with 塊內部,我們寫的代碼看起來只是對變量a 進行簡單的詞法引用,實際上就是一個
LHS 引用(查看第1 章),並將2 賦值給它。
當我們將o1 傳遞進去,a=2 賦值操作找到了o1.a 並將2 賦值給它,這在後面的console.
log(o1.a) 中可以體現。而當o2 傳遞進去,o2 並沒有a 屬性,因此不會創建這個屬性,
o2.a 保持undefined。
但是可以注意到一個奇怪的副作用,實際上a = 2 賦值操作創建了一個全局的變量a。這
是怎麼回事?
with 可以將一個沒有或有多個屬性的對象處理爲一個完全隔離的詞法作用域,因此這個對
象的屬性也會被處理爲定義在這個作用域中的詞法標識符。

儘管with 塊可以將一個對象處理爲詞法作用域,但是這個塊內部正常的var
聲明並不會被限制在這個塊的作用域中,而是被添加到with 所處的函數作
用域中。

eval(..) 函數如果接受了含有一個或多個聲明的代碼,就會修改其所處的詞法作用域,而
with 聲明實際上是根據你傳遞給它的對象憑空創建了一個全新的詞法作用域。
可以這樣理解,當我們傳遞o1 給with 時,with 所聲明的作用域是o1,而這個作用域中含
有一個同o1.a 屬性相符的標識符。但當我們將o2 作爲作用域時,其中並沒有a 標識符,
因此進行了正常的LHS 標識符查找(查看第1 章)。
o2 的作用域、foo(..) 的作用域和全局作用域中都沒有找到標識符a,因此當a=2 執行
時,自動創建了一個全局變量(因爲是非嚴格模式)。
with 這種將對象及其屬性放進一個作用域並同時分配標識符的行爲很讓人費解。但爲了說
明我們所看到的現象,這是我能給出的最直白的解釋了。

另外一個不推薦使用eval(..) 和with 的原因是會被嚴格模式所影響(限
制)。with 被完全禁止,而在保留核心功能的前提下,間接或非安全地使用
eval(..) 也被禁止了。

2.2.3 性能

eval(..) 和with 會在運行時修改或創建新的作用域,以此來欺騙其他在書寫時定義的詞
法作用域。
你可能會問,那又怎樣呢?如果它們能實現更復雜的功能,並且代碼更具有擴展性,難道
不是非常好的功能嗎?答案是否定的。
JavaScript 引擎會在編譯階段進行數項的性能優化。其中有些優化依賴於能夠根據代碼的
詞法進行靜態分析,並預先確定所有變量和函數的定義位置,才能在執行過程中快速找到
標識符。

但如果引擎在代碼中發現了eval(..) 或with,它只能簡單地假設關於標識符位置的判斷
都是無效的,因爲無法在詞法分析階段明確知道eval(..) 會接收到什麼代碼,這些代碼會
如何對作用域進行修改,也無法知道傳遞給with 用來創建新詞法作用域的對象的內容到底
是什麼。
最悲觀的情況是如果出現了eval(..) 或with,所有的優化可能都是無意義的,因此最簡
單的做法就是完全不做任何優化。
如果代碼中大量使用eval(..) 或with,那麼運行起來一定會變得非常慢。無論引擎多聰
明,試圖將這些悲觀情況的副作用限制在最小範圍內,也無法避免如果沒有這些優化,代
碼會運行得更慢這個事實。

2.3 小結

詞法作用域意味着作用域是由書寫代碼時函數聲明的位置來決定的。編譯的詞法分析階段
基本能夠知道全部標識符在哪裏以及是如何聲明的,從而能夠預測在執行過程中如何對它
們進行查找。
JavaScript 中有兩個機制可以“欺騙”詞法作用域:eval(..) 和with。前者可以對一段包
含一個或多個聲明的“代碼”字符串進行演算,並藉此來修改已經存在的詞法作用域(在
運行時)。後者本質上是通過將一個對象的引用當作作用域來處理,將對象的屬性當作作
用域中的標識符來處理,從而創建了一個新的詞法作用域(同樣是在運行時)。
這兩個機制的副作用是引擎無法在編譯時對作用域查找進行優化,因爲引擎只能謹慎地認
爲這樣的優化是無效的。使用這其中任何一個機制都將導致代碼運行變慢。不要使用它們。

第3章 函數作用域和塊作用域

正如我們在第2 章中討論的那樣,作用域包含了一系列的“氣泡”,每一個都可以作爲容
器,其中包含了標識符(變量、函數)的定義。這些氣泡互相嵌套並且整齊地排列成蜂窩
型,排列的結構是在寫代碼時定義的。
但是,究竟是什麼生成了一個新的氣泡?只有函數會生成新的氣泡嗎? JavaScript 中的其
他結構能生成作用域氣泡嗎?

3.1 函數中的作用域

對於前面提出的問題,最常見的答案是JavaScript 具有基於函數的作用域,意味着每聲明
一個函數都會爲其自身創建一個氣泡,而其他結構都不會創建作用域氣泡。但事實上這並
不完全正確,下面我們來看一下。
首先需要研究一下函數作用域及其背後的一些內容。
考慮下面的代碼:

function foo(a) {
var b = 2;
// 一些代碼
function bar() {
// ...
}
// 更多的代碼
var c = 3;
}

在這個代碼片段中,foo(..) 的作用域氣泡中包含了標識符a、b、c 和bar。無論標識符
聲明出現在作用域中的何處,這個標識符所代表的變量或函數都將附屬於所處作用域的氣
泡。我們將在下一章討論具體的原理。
bar(..) 擁有自己的作用域氣泡。全局作用域也有自己的作用域氣泡,它只包含了一個標
識符:foo。
由於標識符a、b、c 和bar 都附屬於foo(..) 的作用域氣泡,因此無法從foo(..) 的外部
對它們進行訪問。也就是說,這些標識符全都無法從全局作用域中進行訪問,因此下面的
代碼會導致ReferenceError 錯誤:

bar(); // 失敗
console.log( a, b, c ); // 三個全都失敗

但是,這些標識符(a、b、c、foo 和bar)在foo(..) 的內部都是可以被訪問的,同樣在
bar(..) 內部也可以被訪問(假設bar(..) 內部沒有同名的標識符聲明)。
函數作用域的含義是指,屬於這個函數的全部變量都可以在整個函數的範圍內使用及復
用(事實上在嵌套的作用域中也可以使用)。這種設計方案是非常有用的,能充分利用
JavaScript 變量可以根據需要改變值類型的“動態”特性。
但與此同時,如果不細心處理那些可以在整個作用域範圍內被訪問的變量,可能會帶來意
想不到的問題。

3.2 隱藏內部實現

對函數的傳統認知就是先聲明一個函數,然後再向裏面添加代碼。但反過來想也可以帶來
一些啓示:從所寫的代碼中挑選出一個任意的片段,然後用函數聲明對它進行包裝,實際
上就是把這些代碼“隱藏”起來了。
實際的結果就是在這個代碼片段的周圍創建了一個作用域氣泡,也就是說這段代碼中的任
何聲明(變量或函數)都將綁定在這個新創建的包裝函數的作用域中,而不是先前所在的
作用域中。換句話說,可以把變量和函數包裹在一個函數的作用域中,然後用這個作用域
來“隱藏”它們。

爲什麼“隱藏”變量和函數是一個有用的技術?
有很多原因促成了這種基於作用域的隱藏方法。它們大都是從最小特權原則中引申出來
的,也叫最小授權或最小暴露原則。這個原則是指在軟件設計中,應該最小限度地暴露必
要內容,而將其他內容都“隱藏”起來,比如某個模塊或對象的API 設計。
這個原則可以延伸到如何選擇作用域來包含變量和函數。如果所有變量和函數都在全局作
用域中,當然可以在所有的內部嵌套作用域中訪問到它們。但這樣會破壞前面提到的最小
特權原則,因爲可能會暴漏過多的變量或函數,而這些變量或函數本應該是私有的,正確
的代碼應該是可以阻止對這些變量或函數進行訪問的。
例如:

function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15

在這個代碼片段中,變量b 和函數doSomethingElse(..) 應該是doSomething(..) 內部具體
實現的“私有”內容。給予外部作用域對b 和doSomethingElse(..) 的“訪問權限”不僅
沒有必要,而且可能是“危險”的,因爲它們可能被有意或無意地以非預期的方式使用,
從而導致超出了 doSomething(..) 的適用條件。更“合理”的設計會將這些私有的具體內
容隱藏在doSomething(..) 內部,例如:

function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15

現在,b 和doSomethingElse(..) 都無法從外部被訪問,而只能被doSomething(..) 所控制。
功能性和最終效果都沒有受影響,但是設計上將具體內容私有化了,設計良好的軟件都會
依此進行實現。

  • 規避衝突

“隱藏”作用域中的變量和函數所帶來的另一個好處,是可以避免同名標識符之間的衝突,
兩個標識符可能具有相同的名字但用途卻不一樣,無意間可能造成命名衝突。衝突會導致
變量的值被意外覆蓋。
例如:

function foo() {
function bar(a) {
i = 3; // 修改for 循環所屬作用域中的i
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 糟糕,無限循環了!
}
}
foo();

bar(..) 內部的賦值表達式i = 3 意外地覆蓋了聲明在foo(..) 內部for 循環中的i。在這
個例子中將會導致無限循環,因爲i 被固定設置爲3,永遠滿足小於10 這個條件。
bar(..) 內部的賦值操作需要聲明一個本地變量來使用,採用任何名字都可以,var i = 3;
就可以滿足這個需求(同時會爲i 聲明一個前面提到過的“遮蔽變量”)。另外一種方法是
採用一個完全不同的標識符名稱,比如var j = 3;。但是軟件設計在某種情況下可能自然
而然地要求使用同樣的標識符名稱,因此在這種情況下使用作用域來“隱藏”內部聲明是
唯一的最佳選擇。

  1. 全局命名空間

變量衝突的一個典型例子存在於全局作用域中。當程序中加載了多個第三方庫時,如果它
們沒有妥善地將內部私有的函數或變量隱藏起來,就會很容易引發衝突。
這些庫通常會在全局作用域中聲明一個名字足夠獨特的變量,通常是一個對象。這個對象
被用作庫的命名空間,所有需要暴露給外界的功能都會成爲這個對象(命名空間)的屬
性,而不是將自己的標識符暴漏在頂級的詞法作用域中。
例如:

var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
  1. 模塊管理

另外一種避免衝突的辦法和現代的模塊機制很接近,就是從衆多模塊管理器中挑選一個來
使用。使用這些工具,任何庫都無需將標識符加入到全局作用域中,而是通過依賴管理器
的機制將庫的標識符顯式地導入到另外一個特定的作用域中。
顯而易見,這些工具並沒有能夠違反詞法作用域規則的“神奇”功能。它們只是利用作用
域的規則強制所有標識符都不能注入到共享作用域中,而是保持在私有、無衝突的作用域
中,這樣可以有效規避掉所有的意外衝突。
因此,只要你願意,即使不使用任何依賴管理工具也可以實現相同的功效。第5 章會介紹
模塊模式的詳細內容。

3.3 函數作用域

我們已經知道,在任意代碼片段外部添加包裝函數,可以將內部的變量和函數定義“隱
藏”起來,外部作用域無法訪問包裝函數內部的任何內容。
例如:

var a = 2;
function foo() { // <-- 添加這一行
var a = 3;
console.log( a ); // 3
} // <-- 以及這一行
foo(); // <-- 以及這一行
console.log( a ); // 2

雖然這種技術可以解決一些問題,但是它並不理想,因爲會導致一些額外的問題。首先,
必須聲明一個具名函數foo(),意味着foo 這個名稱本身“污染”了所在作用域(在這個
例子中是全局作用域)。其次,必須顯式地通過函數名(foo())調用這個函數才能運行其
中的代碼。
如果函數不需要函數名(或者至少函數名可以不污染所在作用域),並且能夠自動運行,
這將會更加理想。
幸好,JavaScript 提供了能夠同時解決這兩個問題的方案、

var a = 2;
(function foo(){ // <-- 添加這一行
var a = 3;
console.log( a ); // 3
})(); // <-- 以及這一行
console.log( a ); // 2

接下來我們分別介紹這裏發生的事情。
首先,包裝函數的聲明以(function… 而不僅是以function… 開始。儘管看上去這並不
是一個很顯眼的細節,但實際上卻是非常重要的區別。函數會被當作函數表達式而不是一
個標準的函數聲明來處理。

區分函數聲明和表達式最簡單的方法是看function 關鍵字出現在聲明中的位
置(不僅僅是一行代碼,而是整個聲明中的位置)。如果function 是聲明中
的第一個詞,那麼就是一個函數聲明,否則就是一個函數表達式。

函數聲明和函數表達式之間最重要的區別是它們的名稱標識符將會綁定在何處。
比較一下前面兩個代碼片段。第一個片段中foo 被綁定在所在作用域中,可以直接通過
foo() 來調用它。第二個片段中foo 被綁定在函數表達式自身的函數中而不是所在作用域中。
換句話說,(function foo(){ .. }) 作爲函數表達式意味着foo 只能在.. 所代表的位置中
被訪問,外部作用域則不行。foo 變量名被隱藏在自身中意味着不會非必要地污染外部作
用域。

3.3.1 匿名和具名

對於函數表達式你最熟悉的場景可能就是回調參數了,比如:

setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );

這叫作匿名函數表達式,因爲function().. 沒有名稱標識符。函數表達式可以是匿名的,
而函數聲明則不可以省略函數名——在JavaScript 的語法中這是非法的。
匿名函數表達式書寫起來簡單快捷,很多庫和工具也傾向鼓勵使用這種風格的代碼。但是
它也有幾個缺點需要考慮。

  • 匿名函數在棧追蹤中不會顯示出有意義的函數名,使得調試很困難。
  • 如果沒有函數名,當函數需要引用自身時只能使用已經過期的arguments.callee 引用,
    比如在遞歸中。另一個函數需要引用自身的例子,是在事件觸發後事件監聽器需要解綁
    自身。
  • 匿名函數省略了對於代碼可讀性/ 可理解性很重要的函數名。一個描述性的名稱可以讓
    代碼不言自明。

行內函數表達式非常強大且有用——匿名和具名之間的區別並不會對這點有任何影響。給函
數表達式指定一個函數名可以有效解決以上問題。始終給函數表達式命名是一個最佳實踐:

setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
console.log( "I waited 1 second!" );
}, 1000 );
3.3.2 立即執行函數表達式
var a = 2;
(function foo() {
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2

由於函數被包含在一對( ) 括號內部,因此成爲了一個表達式,通過在末尾加上另外一個
( ) 可以立即執行這個函數,比如(function foo(){ .. })()。第一個( ) 將函數變成表
達式,第二個( ) 執行了這個函數。
這種模式很常見,幾年前社區給它規定了一個術語:IIFE,代表立即執行函數表達式
(Immediately Invoked Function Expression);
函數名對IIFE 當然不是必須的,IIFE 最常見的用法是使用一個匿名函數表達式。雖然使
用具名函數的IIFE 並不常見,但它具有上述匿名函數表達式的所有優勢,因此也是一個值
得推廣的實踐。

var a = 2;
(function IIFE() {
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2

相較於傳統的IIFE 形式,很多人都更喜歡另一個改進的形式:(function(){ .. }())。仔
細觀察其中的區別。第一種形式中函數表達式被包含在( ) 中,然後在後面用另一個() 括
號來調用。第二種形式中用來調用的() 括號被移進了用來包裝的( ) 括號中。
這兩種形式在功能上是一致的。選擇哪個全憑個人喜好。
IIFE 的另一個非常普遍的進階用法是把它們當作函數調用並傳遞參數進去。
例如:

var a = 2;
(function IIFE( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2

我們將window 對象的引用傳遞進去,但將參數命名爲global,因此在代碼風格上對全局
對象的引用變得比引用一個沒有“全局”字樣的變量更加清晰。當然可以從外部作用域傳
遞任何你需要的東西,並將變量命名爲任何你覺得合適的名字。這對於改進代碼風格是非
常有幫助的。
這個模式的另外一個應用場景是解決undefined 標識符的默認值被錯誤覆蓋導致的異常(雖
然不常見)。將一個參數命名爲undefined,但是在對應的位置不傳入任何值,這樣就可以
保證在代碼塊中undefined 標識符的值真的是undefined:

undefined = true; // 給其他代碼挖了一個大坑!絕對不要這樣做!
(function IIFE( undefined ) {
var a;
if (a === undefined) {
console.log( "Undefined is safe here!" );
}
})();

IIFE 還有一種變化的用途是倒置代碼的運行順序,將需要運行的函數放在第二位,在IIFE
執行之後當作參數傳遞進去。這種模式在UMD(Universal Module Definition)項目中被廣
泛使用。儘管這種模式略顯冗長,但有些人認爲它更易理解。

var a = 2;
(function IIFE( def ) {
def( window );
})(function def( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
});

函數表達式def 定義在片段的第二部分,然後當作參數(這個參數也叫作def)被傳遞進
IIFE 函數定義的第一部分中。最後,參數def(也就是傳遞進去的函數)被調用,並將
window 傳入當作global 參數的值。

3.4 塊作用域

儘管函數作用域是最常見的作用域單元,當然也是現行大多數JavaScript 中最普遍的設計
方法,但其他類型的作用域單元也是存在的,並且通過使用其他類型的作用域單元甚至可
以實現維護起來更加優秀、簡潔的代碼。
除JavaScript 外的很多編程語言都支持塊作用域,因此其他語言的開發者對於相關的思維
方式會很熟悉,但是對於主要使用JavaScript 的開發者來說,這個概念會很陌生。
儘管你可能連一行帶有塊作用域風格的代碼都沒有寫過,但對下面這種很常見的JavaScript
代碼一定很熟悉:

for (var i=0; i<10; i++) {
console.log( i );
}

我們在for 循環的頭部直接定義了變量i,通常是因爲只想在for 循環內部的上下文中使
用i,而忽略了i 會被綁定在外部作用域(函數或全局)中的事實。
這就是塊作用域的用處。變量的聲明應該距離使用的地方越近越好,並最大限度地本地
化。另外一個例子:

var foo = true;
if (foo) {
var bar = foo * 2;
bar = something( bar );
console.log( bar );
}

bar 變量僅在if 聲明的上下文中使用,因此如果能將它聲明在if 塊內部中會是一個很有
意義的事情。但是,當使用var 聲明變量時,它寫在哪裏都是一樣的,因爲它們最終都會
屬於外部作用域。這段代碼是爲了風格更易讀而僞裝出的形式上的塊作用域,如果使用這
種形式,要確保沒在作用域其他地方意外地使用bar 只能依靠自覺性。
塊作用域是一個用來對之前的最小授權原則進行擴展的工具,將代碼從在函數中隱藏信息
擴展爲在塊中隱藏信息。
再次考慮for 循環的例子:

for (var i=0; i<10; i++) {
console.log( i );
}

爲什麼要把一個只在for 循環內部使用(至少是應該只在內部使用)的變量i 污染到整個
函數作用域中呢?
更重要的是,開發者需要檢查自己的代碼,以避免在作用範圍外意外地使用(或複用)某
些變量,如果在錯誤的地方使用變量將導致未知變量的異常。變量i 的塊作用域(如果存
在的話)將使得其只能在for 循環內部使用,如果在函數中其他地方使用會導致錯誤。這
對保證變量不會被混亂地複用及提升代碼的可維護性都有很大幫助。
但可惜,表面上看JavaScript 並沒有塊作用域的相關功能。
除非你更加深入地研究。

3.4.1 with

我們在第2 章討論過with 關鍵字。它不僅是一個難於理解的結構,同時也是塊作用域的一
個例子(塊作用域的一種形式),用with 從對象中創建出的作用域僅在with 聲明中而非外
部作用域中有效。

3.4.2 try/catch

非常少有人會注意到JavaScript 的ES3 規範中規定try/catch 的catch 分句會創建一個塊作
用域,其中聲明的變量僅在catch 內部有效。
例如:

try {
undefined(); // 執行一個非法操作來強制製造一個異常
}
catch (err) {
console.log( err ); // 能夠正常執行!
}
console.log( err ); // ReferenceError: err not found

正如你所看到的,err 僅存在catch 分句內部,當試圖從別處引用它時會拋出錯誤。

儘管這個行爲已經被標準化,並且被大部分的標準JavaScript 環境(除了老
版本的IE 瀏覽器)所支持,但是當同一個作用域中的兩個或多個catch 分句
用同樣的標識符名稱聲明錯誤變量時,很多靜態檢查工具還是會發出警告。
實際上這並不是重複定義,因爲所有變量都被安全地限制在塊作用域內部,
但是靜態檢查工具還是會很煩人地發出警告。
爲了避免這個不必要的警告,很多開發者會將catch 的參數命名爲err1、
err2 等。也有開發者乾脆關閉了靜態檢查工具對重複變量名的檢查。

也許catch 分句會創建塊作用域這件事看起來像教條的學院理論一樣沒什麼用處,但是查
看附錄B 就會發現一些很有用的信息。

3.4.3 let

到目前爲止,我們知道JavaScript 在暴露塊作用域的功能中有一些奇怪的行爲。如果僅僅
是這樣,那麼JavaScript 開發者多年來也就不會將塊作用域當作非常有用的機制來使用了。
幸好,ES6 改變了現狀,引入了新的let 關鍵字,提供了除var 以外的另一種變量聲明方式。
let 關鍵字可以將變量綁定到所在的任意作用域中(通常是{ .. } 內部)。換句話說,let
爲其聲明的變量隱式地了所在的塊作用域。

var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError

用let 將變量附加在一個已經存在的塊作用域上的行爲是隱式的。在開發和修改代碼的過
程中,如果沒有密切關注哪些塊作用域中有綁定的變量,並且習慣性地移動這些塊或者將
其包含在其他的塊中,就會導致代碼變得混亂。
爲塊作用域顯式地創建塊可以部分解決這個問題,使變量的附屬關係變得更加清晰。通常
來講,顯式的代碼優於隱式或一些精巧但不清晰的代碼。顯式的塊作用域風格非常容易書
寫,並且和其他語言中塊作用域的工作原理一致:

var foo = true;
if (foo) {
{ // <-- 顯式的快
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}
console.log( bar ); // ReferenceError

只要聲明是有效的,在聲明中的任意位置都可以使用{ .. } 括號來爲let 創建一個用於綁
定的塊。在這個例子中,我們在if 聲明內部顯式地創建了一個塊,如果需要對其進行重
構,整個塊都可以被方便地移動而不會對外部if 聲明的位置和語義產生任何影響。

關於另外一種顯式的塊作用域表達式的內容,請查看附錄B。

在第4 章,我們會討論提升,提升是指聲明會被視爲存在於其所出現的作用域的整個範圍內。
但是使用let 進行的聲明不會在塊作用域中進行提升。聲明的代碼被運行之前,聲明並不
“存在”。

{
console.log( bar ); // ReferenceError!
let bar = 2;
}
  1. 垃圾收集

另一個塊作用域非常有用的原因和閉包及回收內存垃圾的回收機制相關。這裏簡要說明一
下,而內部的實現原理,也就是閉包的機制會在第5 章詳細解釋。
考慮以下代碼:

function process(data) {
// 在這裏做點有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
console.log("button clicked");
}, /*capturingPhase=*/false );

click 函數的點擊回調並不需要someReallyBigData 變量。理論上這意味着當process(..) 執
行後,在內存中佔用大量空間的數據結構就可以被垃圾回收了。但是,由於click 函數形成
了一個覆蓋整個作用域的閉包,JavaScript 引擎極有可能依然保存着這個結構(取決於具體
實現)。
塊作用域可以打消這種顧慮,可以讓引擎清楚地知道沒有必要繼續保存someReallyBigData 了:

function process(data) {
// 在這裏做點有趣的事情
}
// 在這個塊中定義的內容可以銷燬了!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked"

爲變量顯式聲明塊作用域,並對變量進行本地綁定是非常有用的工具,可以把它添加到你
的代碼工具箱中了。

  1. let循環

一個let 可以發揮優勢的典型例子就是之前討論的for 循環。

for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError

for 循環頭部的let 不僅將i 綁定到了for 循環的塊中,事實上它將其重新綁定到了循環
的每一個迭代中,確保使用上一個循環迭代結束時的值重新進行賦值。
下面通過另一種方式來說明每次迭代時進行重新綁定的行爲:

{
let j;
for (j=0; j<10; j++) {
let i = j; // 每個迭代重新綁定!
console.log( i );
}
}

每個迭代進行重新綁定的原因非常有趣,我們會在第5 章討論閉包時進行說明。
由於let 聲明附屬於一個新的作用域而不是當前的函數作用域(也不屬於全局作用域),
當代碼中存在對於函數作用域中var 聲明的隱式依賴時,就會有很多隱藏的陷阱,如果用
let 來替代var 則需要在代碼重構的過程中付出額外的精力。
考慮以下代碼:

var foo = true, baz = 10;
if (foo) {
var bar = 3;
if (baz > bar) {
console.log( baz );
}
// ...
}

這段代碼可以簡單地被重構成下面的同等形式:

var foo = true, baz = 10;
if (foo) {
var bar = 3;
// ...
}
if (baz > bar) {
console.log( baz );
}

但是在使用塊級作用域的變量時需要注意以下變化:

var foo = true, baz = 10;
if (foo) {
let bar = 3;
if (baz > bar) { // <-- 移動代碼時不要忘了bar!
console.log( baz );
}
}

參考附錄B,其中介紹了另外一種塊作用域形式,可以用更健壯的方式實現目的,並且寫
出的代碼更易維護和重構。

3.4.4 const

除了let 以外,ES6 還引入了const,同樣可以用來創建塊作用域變量,但其值是固定的
(常量)。之後任何試圖修改值的操作都會引起錯誤。

var foo = true;
if (foo) {
var a = 2;
const b = 3; // 包含在if 中的塊作用域常量
a = 3; // 正常!
b = 4; // 錯誤!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
3.5 小結

函數是JavaScript 中最常見的作用域單元。本質上,聲明在一個函數內部的變量或函數會
在所處的作用域中“隱藏”起來,這是有意爲之的良好軟件的設計原則。
但函數不是唯一的作用域單元。塊作用域指的是變量和函數不僅可以屬於所處的作用域,
也可以屬於某個代碼塊(通常指{ .. } 內部)。
從ES3 開始,try/catch 結構在catch 分句中具有塊作用域。
在ES6 中引入了let 關鍵字(var 關鍵字的表親),用來在任意代碼塊中聲明變量。if
(..) { let a = 2; } 會聲明一個劫持了if 的{ .. } 塊的變量,並且將變量添加到這個塊
中。
有些人認爲塊作用域不應該完全作爲函數作用域的替代方案。兩種功能應該同時存在,開
發者可以並且也應該根據需要選擇使用何種作用域,創造可讀、可維護的優良代碼。

第4章 提升

到現在爲止,你應該已經很熟悉作用域的概念,以及根據聲明的位置和方式將變量分配給
作用域的相關原理了。函數作用域和塊作用域的行爲是一樣的,可以總結爲:任何聲明在
某個作用域內的變量,都將附屬於這個作用域。
但是作用域同其中的變量聲明出現的位置有某種微妙的聯繫,而這個細節正是我們將要討
論的內容。

4.1 先有雞還是先有蛋

直覺上會認爲JavaScript 代碼在執行時是由上到下一行一行執行的。但實際上這並不完全
正確,有一種特殊情況會導致這個假設是錯誤的。
考慮以下代碼:

a = 2;
var a;
console.log( a );

你認爲console.log(..) 聲明會輸出什麼呢?
很多開發者會認爲是undefined,因爲var a 聲明在a = 2 之後,他們自然而然地認爲變量
被重新賦值了,因此會被賦予默認值undefined。但是,真正的輸出結果是2。
考慮另外一段代碼:

console.log( a );
var a = 2;

鑑於上一個代碼片段所表現出來的某種非自上而下的行爲特點,你可能會認爲這個代碼片
段也會有同樣的行爲而輸出2。還有人可能會認爲,由於變量a 在使用前沒有先進行聲明,
因此會拋出ReferenceError 異常。
不幸的是兩種猜測都是不對的。輸出來的會是undefined。
那麼到底發生了什麼?看起來我們面對的是一個先有雞還是先有蛋的問題。到底是聲明
(蛋)在前,還是賦值(雞)在前?

4.2 編譯器再度來襲

爲了搞明白這個問題,我們需要回顧一下第1 章中關於編譯器的內容。回憶一下,引擎會
在解釋JavaScript 代碼之前首先對其進行編譯。編譯階段中的一部分工作就是找到所有的
聲明,並用合適的作用域將它們關聯起來。第2 章中展示了這個機制,也正是詞法作用域
的核心內容。
因此,正確的思考思路是,包括變量和函數在內的所有聲明都會在任何代碼被執行前首先
被處理。
當你看到var a = 2; 時,可能會認爲這是一個聲明。但JavaScript 實際上會將其看成兩個
聲明:var a; 和a = 2;。第一個定義聲明是在編譯階段進行的。第二個賦值聲明會被留在
原地等待執行階段。
我們的第一個代碼片段會以如下形式進行處理:

var a;
a = 2;
console.log( a );

其中第一部分是編譯,而第二部分是執行。
類似地,我們的第二個代碼片段實際是按照以下流程處理的:

var a;
console.log( a );
a = 2;

因此,打個比方,這個過程就好像變量和函數聲明從它們在代碼中出現的位置被“移動”
到了最上面。這個過程就叫作提升。
換句話說,先有蛋(聲明)後有雞(賦值)。

只有聲明本身會被提升,而賦值或其他運行邏輯會留在原地。如果提升改變
了代碼執行的順序,會造成非常嚴重的破壞。

foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}

foo 函數的聲明(這個例子還包括實際函數的隱含值)被提升了,因此第一行中的調用可
以正常執行。

另外值得注意的是,每個作用域都會進行提升操作。儘管前面大部分的代碼片段已經簡化
了(因爲它們只包含全局作用域),而我們正在討論的foo(..) 函數自身也會在內部對var
a 進行提升(顯然並不是提升到了整個程序的最上方)。因此這段代碼實際上會被理解爲下
面的形式:

function foo() {
var a;
console.log( a ); // undefined
a = 2;
}
foo();

可以看到,函數聲明會被提升,但是函數表達式卻不會被提升。

foo(); // 不是ReferenceError, 而是TypeError!
var foo = function bar() {
// ...
};

這段程序中的變量標識符foo() 被提升並分配給所在作用域(在這裏是全局作用域),因此
foo() 不會導致ReferenceError。但是foo 此時並沒有賦值(如果它是一個函數聲明而不
是函數表達式,那麼就會賦值)。foo() 由於對undefined 值進行函數調用而導致非法操作,
因此拋出TypeError 異常。
同時也要記住,即使是具名的函數表達式,名稱標識符在賦值之前也無法在所在作用域中
使用:

foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};

這個代碼片段經過提升後,實際上會被理解爲以下形式:

var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
4.3 函數優先

函數聲明和變量聲明都會被提升。但是一個值得注意的細節(這個細節可以出現在有多個
“重複”聲明的代碼中)是函數會首先被提升,然後纔是變量。
考慮以下代碼:

foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};

會輸出1 而不是2 !這個代碼片段會被引擎理解爲如下形式:

function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};

注意,var foo 儘管出現在function foo()… 的聲明之前,但它是重複的聲明(因此被忽
略了),因爲函數聲明會被提升到普通變量之前。
儘管重複的var 聲明會被忽略掉,但出現在後面的函數聲明還是可以覆蓋前面的。

foo(); // 3
function foo() {
console.log( 1 );
}
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 3 );
}

雖然這些聽起來都是些無用的學院理論,但是它說明了在同一個作用域中進行重複定義是
非常糟糕的,而且經常會導致各種奇怪的問題。
一個普通塊內部的函數聲明通常會被提升到所在作用域的頂部,這個過程不會像下面的代
碼暗示的那樣可以被條件判斷所控制:

foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}

但是需要注意這個行爲並不可靠,在JavaScript 未來的版本中有可能發生改變,因此應該
儘可能避免在塊內部聲明函數。

4.4 小結

我們習慣將var a = 2; 看作一個聲明,而實際上JavaScript 引擎並不這麼認爲。它將var a
和a = 2 當作兩個單獨的聲明,第一個是編譯階段的任務,而第二個則是執行階段的任務。
這意味着無論作用域中的聲明出現在什麼地方,都將在代碼本身被執行前首先進行處理。
可以將這個過程形象地想象成所有的聲明(變量和函數)都會被“移動”到各自作用域的
最頂端,這個過程被稱爲提升。
聲明本身會被提升,而包括函數表達式的賦值在內的賦值操作並不會提升。
要注意避免重複聲明,特別是當普通的var 聲明和函數聲明混合在一起的時候,否則會引
起很多危險的問題!

第5章 作用域閉包

接下來的內容需要對作用域工作原理相關的基礎知識有非常深入的理解。
我們將注意力轉移到這門語言中一個非常重要但又難以掌握,近乎神話的概念上:閉包。
如果你瞭解了之前關於詞法作用域的討論,那麼閉包的概念幾乎是不言自明的。魔術師的
幕布後藏着一個人,我們將要揭開他的僞裝。我可沒說這個人是Crockford1 !
在繼續學習之前,如果你還是對詞法作用域相關內容有疑問,可以重新回顧一下第2 章中
的相關內容,現在是個好機會。

5.1 啓示

對於那些有一點JavaScript 使用經驗但從未真正理解閉包概念的人來說,理解閉包可以看
作是某種意義上的重生,但是需要付出非常多的努力和犧牲才能理解這個概念。
回憶我前幾年的時光,大量使用JavaScript 但卻完全不理解閉包是什麼。總是感覺這門語
言有其隱蔽的一面,如果能夠掌握將會功力大漲,但諷刺的是我始終無法掌握其中的門
道。還記得我曾經大量閱讀早期框架的源碼,試圖能夠理解閉包的工作原理。現在還能回
憶起我的腦海中第一次浮現出關於“模塊模式”相關概念時的激動心情。
那時我無法理解並且傾盡數年心血來探索的,也就是我馬上要傳授給你的祕訣:JavaScript
中閉包無處不在,你只需要能夠識別並擁抱它。 閉包並不是一個需要學習新的語法或模式
才能使用的工具,它也不是一件必須接受像Luke2 一樣的原力訓練才能使用和掌握的武器。
閉包是基於詞法作用域書寫代碼時所產生的自然結果,你甚至不需要爲了利用它們而有意
識地創建閉包。閉包的創建和使用在你的代碼中隨處可見。你缺少的是根據你自己的意願
來識別、擁抱和影響閉包的思維環境。
最後你恍然大悟:原來在我的代碼中已經到處都是閉包了,現在我終於能理解它們了。理
解閉包就好像Neo3 第一次見到矩陣4 一樣。

5.2 實質問題

好了,誇張和浮誇的電影比喻已經夠多了。
下面是直接了當的定義,你需要掌握它才能理解和識別閉包:
當函數可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函數是在當前詞法作用
域之外執行。
下面用一些代碼來解釋這個定義。

function foo() {
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();

這段代碼看起來和嵌套作用域中的示例代碼很相似。基於詞法作用域的查找規則,函數
bar() 可以訪問外部作用域中的變量a(這個例子中的是一個RHS 引用查詢)。
這是閉包嗎?
技術上來講,也許是。但根據前面的定義,確切地說並不是。我認爲最準確地用來解釋
bar() 對a 的引用的方法是詞法作用域的查找規則,而這些規則只是閉包的一部分。(但卻
是非常重要的一部分!)
從純學術的角度說,在上面的代碼片段中,函數bar() 具有一個涵蓋foo() 作用域的閉包
(事實上,涵蓋了它能訪問的所有作用域,比如全局作用域)。也可以認爲bar() 被封閉在
了foo() 的作用域中。爲什麼呢?原因簡單明瞭,因爲bar() 嵌套在foo() 內部。
但是通過這種方式定義的閉包並不能直接進行觀察,也無法明白在這個代碼片段中閉包是
如何工作的。我們可以很容易地理解詞法作用域,而閉包則隱藏在代碼之後的神祕陰影
裏,並不那麼容易理解。
下面我們來看一段代碼,清晰地展示了閉包:

function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,這就是閉包的效果。

函數bar() 的詞法作用域能夠訪問foo() 的內部作用域。然後我們將bar() 函數本身當作
一個值類型進行傳遞。在這個例子中,我們將bar 所引用的函數對象本身當作返回值。
在foo() 執行後,其返回值(也就是內部的bar() 函數)賦值給變量baz 並調用baz(),實
際上只是通過不同的標識符引用調用了內部的函數bar()。
bar() 顯然可以被正常執行。但是在這個例子中,它在自己定義的詞法作用域以外的地方
執行。
在foo() 執行後,通常會期待foo() 的整個內部作用域都被銷燬,因爲我們知道引擎有垃
圾回收器用來釋放不再使用的內存空間。由於看上去foo() 的內容不會再被使用,所以很
自然地會考慮對其進行回收。
而閉包的“神奇”之處正是可以阻止這件事情的發生。事實上內部作用域依然存在,因此
沒有被回收。誰在使用這個內部作用域?原來是bar() 本身在使用。
拜bar() 所聲明的位置所賜,它擁有涵蓋foo() 內部作用域的閉包,使得該作用域能夠一
直存活,以供bar() 在之後任何時間進行引用。
bar() 依然持有對該作用域的引用,而這個引用就叫作閉包。
因此,在幾微秒之後變量baz 被實際調用(調用內部函數bar),不出意料它可以訪問定義
時的詞法作用域,因此它也可以如預期般訪問變量a。
這個函數在定義時的詞法作用域以外的地方被調用。閉包使得函數可以繼續訪問定義時的
詞法作用域。
當然,無論使用何種方式對函數類型的值進行傳遞,當函數在別處被調用時都可以觀察到
閉包。

function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 媽媽快看呀,這就是閉包!
}

把內部函數baz 傳遞給bar,當調用這個內部函數時(現在叫作fn),它涵蓋的foo() 內部
作用域的閉包就可以觀察到了,因爲它能夠訪問a。
傳遞函數當然也可以是間接的。

var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 將baz 分配給全局變量
}
function bar() {
fn(); // 媽媽快看呀,這就是閉包!
}
foo();
bar(); // 2

無論通過何種手段將內部函數傳遞到所在的詞法作用域以外,它都會持有對原始定義作用
域的引用,無論在何處執行這個函數都會使用閉包。

5.3 現在我懂了

前面的代碼片段有點死板,並且爲了解釋如何使用閉包而人爲地在結構上進行了修飾。但
我保證閉包絕不僅僅是一個好玩的玩具。你已經寫過的代碼中一定到處都是閉包的身影。
現在讓我們來搞懂這個事實。

function wait(message) {
setTimeout( function timer() {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );

將一個內部函數(名爲timer)傳遞給setTimeout(..)。timer 具有涵蓋wait(..) 作用域
的閉包,因此還保有對變量message 的引用。
wait(..) 執行1000 毫秒後,它的內部作用域並不會消失,timer 函數依然保有wait(..)
作用域的閉包。
深入到引擎的內部原理中,內置的工具函數setTimeout(..) 持有對一個參數的引用,這個
參數也許叫作fn 或者func,或者其他類似的名字。引擎會調用這個函數,在例子中就是
內部的timer 函數,而詞法作用域在這個過程中保持完整。
這就是閉包。
或者,如果你很熟悉jQuery(或者其他能說明這個問題的JavaScript 框架),可以思考下面
的代碼:

function setupBot(name, selector) {
$( selector ).click( function activator() {
console.log( "Activating: " + name );
} );
}
setupBot( "Closure Bot 1", "#bot_1" );
setupBot( "Closure Bot 2", "#bot_2" );

我不知道你會寫什麼樣的代碼,但是我寫的代碼負責控制由閉包機器人組成的整個全球無
人機大軍,這是完全可以實現的!
玩笑開完了,本質上無論何時何地,如果將函數(訪問它們各自的詞法作用域)當作第一
級的值類型併到處傳遞,你就會看到閉包在這些函數中的應用。在定時器、事件監聽器、
Ajax 請求、跨窗口通信、Web Workers 或者任何其他的異步(或者同步)任務中,只要使
用了回調函數,實際上就是在使用閉包!

第3 章介紹了IIFE 模式。通常認爲IIFE 是典型的閉包例子,但根據先前對
閉包的定義,我並不是很同意這個觀點。

var a = 2;
(function IIFE() {
console.log( a );
})();

雖然這段代碼可以正常工作,但嚴格來講它並不是閉包。爲什麼?因爲函數(示例代碼中
的IIFE)並不是在它本身的詞法作用域以外執行的。它在定義時所在的作用域中執行(而
外部作用域,也就是全局作用域也持有a)。a 是通過普通的詞法作用域查找而非閉包被髮
現的。
儘管技術上來講,閉包是發生在定義時的,但並不非常明顯,就好像六祖慧能所說:“既
非風動,亦非幡動,仁者心動耳。”5。
儘管IIFE 本身並不是觀察閉包的恰當例子,但它的確創建了閉包,並且也是最常用來創建
可以被封閉起來的閉包的工具。因此IIFE 的確同閉包息息相關,即使本身並不會真的使用
閉包。
親愛的讀者,現在把書放下,我有一個任務要給你。打開你最近寫的JavaScript 代碼,找
到其中的函數類型的值並指出哪裏已經使用了閉包,即使你以前可能並不知道這就是
閉包。
等你呦!
現在你懂了吧!

5.4 循環和閉包

要說明閉包,for 循環是最常見的例子。

for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}

由於很多開發者對閉包的概念認識得並不是很清楚,因此當循環內部包含函
數定義時,代碼格式檢查器經常發出警告。我們在這裏介紹如何才能正確地
使用閉包併發揮它的威力,但是代碼格式檢查器並沒有那麼靈敏,它會假設
你並不真正瞭解自己在做什麼,所以無論如何都會發出警告。

正常情況下,我們對這段代碼行爲的預期是分別輸出數字1~5,每秒一次,每次一個。
但實際上,這段代碼在運行時會以每秒一次的頻率輸出五次6。

這是爲什麼?

首先解釋6 是從哪裏來的。這個循環的終止條件是i 不再<=5。條件首次成立時i 的值是
6。因此,輸出顯示的是循環結束時i 的最終值。
仔細想一下,這好像又是顯而易見的,延遲函數的回調會在循環結束時才執行。事實上,
當定時器運行時即使每個迭代中執行的是setTimeout(.., 0),所有的回調函數依然是在循
環結束後纔會被執行,因此會每次輸出一個6 出來。
這裏引伸出一個更深入的問題,代碼中到底有什麼缺陷導致它的行爲同語義所暗示的不一
致呢?
缺陷是我們試圖假設循環中的每個迭代在運行時都會給自己“捕獲”一個i 的副本。但是
根據作用域的工作原理,實際情況是儘管循環中的五個函數是在各個迭代中分別定義的,
但是它們都被封閉在一個共享的全局作用域中,因此實際上只有一個i。
這樣說的話,當然所有函數共享一個i 的引用。循環結構讓我們誤以爲背後還有更復雜的
機制在起作用,但實際上沒有。如果將延遲函數的回調重複定義五次,完全不使用循環,
那它同這段代碼是完全等價的。
下面回到正題。缺陷是什麼?我們需要更多的閉包作用域,特別是在循環的過程中每個迭
代都需要一個閉包作用域。
第3 章介紹過,IIFE 會通過聲明並立即執行一個函數來創建作用域。
我們來試一下:

for (var i=1; i<=5; i++) {
(function() {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
})();
}

這樣能行嗎?試試吧,我等着你。
我不賣關子了。這樣不行。但是爲什麼呢?我們現在顯然擁有更多的詞法作用域了。的確
每個延遲函數都會將IIFE 在每次迭代中創建的作用域封閉起來。
如果作用域是空的,那麼僅僅將它們進行封閉是不夠的。仔細看一下,我們的IIFE 只是一
個什麼都沒有的空作用域。它需要包含一點實質內容才能爲我們所用。
它需要有自己的變量,用來在每個迭代中儲存i 的值:

for (var i=1; i<=5; i++) {
(function() {
var j = i;
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})();
}

行了!它能正常工作了!。
可以對這段代碼進行一些改進:

for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})( i );
}

當然,這些IIFE 也不過就是函數,因此我們可以將i 傳遞進去,如果願意的話可以將變量
名定爲j,當然也可以還叫作i。無論如何這段代碼現在可以工作了。
在迭代內使用IIFE 會爲每個迭代都生成一個新的作用域,使得延遲函數的回調可以將新的
作用域封閉在每個迭代內部,每個迭代中都會含有一個具有正確值的變量供我們訪問。
問題解決啦!

  • 重返塊作用域

仔細思考我們對前面的解決方案的分析。我們使用IIFE 在每次迭代時都創建一個新的作用
域。換句話說,每次迭代我們都需要一個塊作用域。第3 章介紹了let 聲明,可以用來劫
持塊作用域,並且在這個塊作用域中聲明一個變量。
本質上這是將一個塊轉換成一個可以被關閉的作用域。因此,下面這些看起來很酷的代碼
就可以正常運行了:

for (var i=1; i<=5; i++) {
let j = i; // 是的,閉包的塊作用域!
setTimeout( function timer() {
console.log( j );
}, j*1000 );
}

但是,這還不是全部!(我用Bob Barker6 的聲音說道)for 循環頭部的let 聲明還會有一
個特殊的行爲。這個行爲指出變量在循環過程中不止被聲明一次,每次迭代都會聲明。隨
後的每個迭代都會使用上一個迭代結束時的值來初始化這個變量。

for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}

很酷是吧?塊作用域和閉包聯手便可天下無敵。不知道你是什麼情況,反正這個功能讓我
成爲了一名快樂的JavaScript 程序員。

5.5 模塊

還有其他的代碼模式利用閉包的強大威力,但從表面上看,它們似乎與回調無關。下面一
起來研究其中最強大的一個:模塊。

function foo() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
}

正如在這段代碼中所看到的,這裏並沒有明顯的閉包,只有兩個私有數據變量something
和another,以及doSomething() 和doAnother() 兩個內部函數,它們的詞法作用域(而這
就是閉包)也就是foo() 的內部作用域。
接下來考慮以下代碼:

function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

這個模式在JavaScript 中被稱爲模塊。最常見的實現模塊模式的方法通常被稱爲模塊暴露,
這裏展示的是其變體。
我們仔細研究一下這些代碼。
首先,CoolModule() 只是一個函數,必須要通過調用它來創建一個模塊實例。如果不執行
外部函數,內部作用域和閉包都無法被創建。
其次,CoolModule() 返回一個用對象字面量語法{ key: value, … } 來表示的對象。這
個返回的對象中含有對內部函數而不是內部數據變量的引用。我們保持內部數據變量是隱
藏且私有的狀態。可以將這個對象類型的返回值看作本質上是模塊的公共API。
這個對象類型的返回值最終被賦值給外部的變量foo,然後就可以通過它來訪問API 中的
屬性方法,比如foo.doSomething()。

從模塊中返回一個實際的對象並不是必須的,也可以直接返回一個內部函
數。jQuery 就是一個很好的例子。jQuery 和$ 標識符就是jQuery 模塊的公
共API,但它們本身都是函數(由於函數也是對象,它們本身也可以擁有屬
性)。

doSomething() 和doAnother() 函數具有涵蓋模塊實例內部作用域的閉包( 通過調用
CoolModule() 實現)。當通過返回一個含有屬性引用的對象的方式來將函數傳遞到詞法作
用域外部時,我們已經創造了可以觀察和實踐閉包的條件。
如果要更簡單的描述,模塊模式需要具備兩個必要條件。

  • 必須有外部的封閉函數,該函數必須至少被調用一次(每次調用都會創建一個新的模塊
    實例)。
  • 封閉函數必須返回至少一個內部函數,這樣內部函數才能在私有作用域中形成閉包,並
    且可以訪問或者修改私有的狀態。

一個具有函數屬性的對象本身並不是真正的模塊。從方便觀察的角度看,一個從函數調用
所返回的,只有數據屬性而沒有閉包函數的對象並不是真正的模塊。
上一個示例代碼中有一個叫作CoolModule() 的獨立的模塊創建器,可以被調用任意多次,
每次調用都會創建一個新的模塊實例。當只需要一個實例時,可以對這個模式進行簡單的
改進來實現單例模式:

var foo = (function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

我們將模塊函數轉換成了IIFE(參見第3 章),立即調用這個函數並將返回值直接賦值給
單例的模塊實例標識符foo。
模塊也是普通的函數,因此可以接受參數:

function CoolModule(id) {
function identify() {
console.log( id );
}
return {
identify: identify
};
}
var foo1 = CoolModule( "foo 1" );
var foo2 = CoolModule( "foo 2" );
foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"

模塊模式另一個簡單但強大的變化用法是,命名將要作爲公共API 返回的對象:

var foo = (function CoolModule(id) {
function change() {
// 修改公共API
publicAPI.identify = identify2;
}
function identify1() {
console.log( id );
}
function identify2() {
console.log( id.toUpperCase() );
}
var publicAPI = {
change: change,
identify: identify1
};
return publicAPI;
})( "foo module" );
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

通過在模塊實例的內部保留對公共API 對象的內部引用,可以從內部對模塊實例進行修
改,包括添加或刪除方法和屬性,以及修改它們的值。

5.5.1 現代的模塊機制

大多數模塊依賴加載器/ 管理器本質上都是將這種模塊定義封裝進一個友好的API。這裏
並不會研究某個具體的庫,爲了宏觀瞭解我會簡單地介紹一些核心概念:

var MyModules = (function Manager() {
var modules = {};
function define(name, deps, impl) {
for (var i=0; i<deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply( impl, deps );
}
function get(name) {
return modules[name];
}
return {
define: define,
get: get
};
})();

這段代碼的核心是modules[name] = impl.apply(impl, deps)。爲了模塊的定義引入了包裝
函數(可以傳入任何依賴),並且將返回值,也就是模塊的API,儲存在一個根據名字來管
理的模塊列表中。
下面展示瞭如何使用它來定義模塊:

MyModules.define( "bar", [], function() {
function hello(who) {
return "Let me introduce: " + who;
}
return {
hello: hello
};
} );
MyModules.define( "foo", ["bar"], function(bar) {
var hungry = "hippo";
function awesome() {
console.log( bar.hello( hungry ).toUpperCase() );
}
return {
awesome: awesome
};
} );
var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );
console.log(
bar.hello( "hippo" )
); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

“foo” 和”bar” 模塊都是通過一個返回公共API 的函數來定義的。”foo” 甚至接受”bar” 的
示例作爲依賴參數,並能相應地使用它。
爲我們自己着想,應該多花一點時間來研究這些示例代碼並完全理解閉包的作用吧。最重
要的是要理解模塊管理器沒有任何特殊的“魔力”。它們符合前面列出的模塊模式的兩個
特點:爲函數定義引入包裝函數,並保證它的返回值和模塊的API 保持一致。
換句話說,模塊就是模塊,即使在它們外層加上一個友好的包裝工具也不會發生任何變化。

5.5.2 未來的模塊機制

ES6 中爲模塊增加了一級語法支持。但通過模塊系統進行加載時,ES6 會將文件當作獨立
的模塊來處理。每個模塊都可以導入其他模塊或特定的API 成員,同樣也可以導出自己的
API 成員。

基於函數的模塊並不是一個能被穩定識別的模式(編譯器無法識別),它們
的API 語義只有在運行時纔會被考慮進來。因此可以在運行時修改一個模塊
的API(參考前面關於公共API 的討論)。
相比之下,ES6 模塊API 更加穩定(API 不會在運行時改變)。由於編輯器知
道這一點,因此可以在(的確也這樣做了)編譯期檢查對導入模塊的API 成
員的引用是否真實存在。如果API 引用並不存在,編譯器會在運行時拋出一
個或多個“早期”錯誤,而不會像往常一樣在運行期採用動態的解決方案。

ES6 的模塊沒有“行內”格式,必須被定義在獨立的文件中(一個文件一個模塊)。瀏覽
器或引擎有一個默認的“模塊加載器”(可以被重載,但這遠超出了我們的討論範圍)可
以在導入模塊時異步地加載模塊文件。
考慮以下代碼:

bar.js
function hello(who) {
return "Let me introduce: " + who;
}
export hello;
foo.js
// 僅從"bar" 模塊導入hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(
hello( hungry ).toUpperCase()
);
}
export awesome;
baz.js
// 導入完整的"foo""bar" 模塊
module foo from "foo";
module bar from "bar";
console.log(
bar.hello( "rhino" )
); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO

需要用前面兩個代碼片段中的內容分別創建文件foo.js 和bar.js。然後如第三
個代碼片段中展示的那樣,bar.js 中的程序會加載或導入這兩個模塊並使用
它們。

import 可以將一個模塊中的一個或多個API 導入到當前作用域中,並分別綁定在一個變量
上(在我們的例子裏是hello)。module 會將整個模塊的API 導入並綁定到一個變量上(在
我們的例子裏是foo 和bar)。export 會將當前模塊的一個標識符(變量、函數)導出爲公
共API。這些操作可以在模塊定義中根據需要使用任意多次。
模塊文件中的內容會被當作好像包含在作用域閉包中一樣來處理,就和前面介紹的函數閉
包模塊一樣。

5.6 小結

閉包就好像從JavaScript 中分離出來的一個充滿神祕色彩的未開化世界,只有最勇敢的人
才能夠到達那裏。但實際上它只是一個標準,顯然就是關於如何在函數作爲值按需傳遞的
詞法環境中書寫代碼的。

當函數可以記住並訪問所在的詞法作用域,即使函數是在當前詞法作用域之外執行,這時
就產生了閉包。

如果沒能認出閉包,也不瞭解它的工作原理,在使用它的過程中就很容易犯錯,比如在循
環中。但同時閉包也是一個非常強大的工具,可以用多種形式來實現模塊等模式。
模塊有兩個主要特徵:(1)爲創建內部作用域而調用了一個包裝函數;(2)包裝函數的返回
值必須至少包括一個對內部函數的引用,這樣就會創建涵蓋整個包裝函數內部作用域的閉
包。
現在我們會發現代碼中到處都有閉包存在,並且我們能夠識別閉包然後用它來做一些有用
的事!


《你不知道的JavaScript 上卷》下載地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章