JavaScript 是一種非常面向函數(shù)的語言。它給了我們很大的自由度。在 JavaScript 中,我們可以隨時創(chuàng)建函數(shù),可以將函數(shù)作為參數(shù)傳遞給另一個函數(shù),并在完全不同的代碼位置進行調(diào)用。
我們已經(jīng)知道函數(shù)可以訪問其外部的變量。
但是,如果在函數(shù)被創(chuàng)建之后,外部變量發(fā)生了變化會怎樣?函數(shù)會獲得新值還是舊值?
如果將函數(shù)作為參數(shù)(argument)傳遞并在代碼中的另一個位置調(diào)用它,該函數(shù)將訪問的是新位置的外部變量嗎?
讓我們通過本文來學(xué)習(xí)這些相關(guān)知識,以了解在這些場景以及更復(fù)雜的場景下到底會發(fā)生什么。
我們將在這探討一下 ?
let/const
?在 JavaScript 中,有三種聲明變量的方式:
let
,const
(現(xiàn)代方式),var
(過去留下來的方式)。
- 在本文的示例中,我們將使用 ?
let
? 聲明變量。- 用 ?
const
? 聲明的變量的行為也相同(譯注:與 ?let
? 在作用域等特性上是相同的),因此,本文也涉及用 ?const
? 進行變量聲明。- 舊的 ?
var
? 與上面兩個有著明顯的區(qū)別,我們將在 老舊的 "var" 中詳細(xì)介紹。
如果在代碼塊 ?{...}
? 內(nèi)聲明了一個變量,那么這個變量只在該代碼塊內(nèi)可見。
例如:
{
// 使用在代碼塊外不可見的局部變量做一些工作
let message = "Hello"; // 只在此代碼塊內(nèi)可見
alert(message); // Hello
}
alert(message); // Error: message is not defined
我們可以使用它來隔離一段代碼,該段代碼執(zhí)行自己的任務(wù),并使用僅屬于自己的變量:
{
// 顯示 message
let message = "Hello";
alert(message);
}
{
// 顯示另一個 message
let message = "Goodbye";
alert(message);
}
這里如果沒有代碼塊則會報錯
請注意,如果我們使用
let
對已存在的變量進行重復(fù)聲明,如果對應(yīng)的變量沒有單獨的代碼塊,則會出現(xiàn)錯誤:
// 顯示 message let message = "Hello"; alert(message); // 顯示另一個 message let message = "Goodbye"; // Error: variable already declared alert(message);
對于 if
,for
和 while
等,在 {...}
中聲明的變量也僅在內(nèi)部可見:
if (true) {
let phrase = "Hello!";
alert(phrase); // Hello!
}
alert(phrase); // Error, no such variable!
在這兒,當(dāng) if
執(zhí)行完畢,則下面的 alert
將看不到 phrase
,因此會出現(xiàn)錯誤。(譯注:就算下面的 alert
想在 if
沒執(zhí)行完成時去取 phrase
(雖然這種情況不可能發(fā)生)也是取不到的,因為 let
聲明的變量在代碼塊外不可見。)
太好了,因為這就允許我們創(chuàng)建特定于 if
分支的塊級局部變量。
對于 for
和 while
循環(huán)也是如此:
for (let i = 0; i < 3; i++) {
// 變量 i 僅在這個 for 循環(huán)的內(nèi)部可見
alert(i); // 0,然后是 1,然后是 2
}
alert(i); // Error, no such variable
從視覺上看,let i
位于 {...}
之外。但是 for
構(gòu)造在這里很特殊:在其中聲明的變量被視為塊的一部分。
如果一個函數(shù)是在另一個函數(shù)中創(chuàng)建的,該函數(shù)就被稱為“嵌套”函數(shù)。
在 JavaScript 中很容易實現(xiàn)這一點。
我們可以使用嵌套來組織代碼,比如這樣:
function sayHiBye(firstName, lastName) {
// 輔助嵌套函數(shù)使用如下
function getFullName() {
return firstName + " " + lastName;
}
alert( "Hello, " + getFullName() );
alert( "Bye, " + getFullName() );
}
這里創(chuàng)建的 嵌套 函數(shù) getFullName()
是為了更加方便。它可以訪問外部變量,因此可以返回全名。嵌套函數(shù)在 JavaScript 中很常見。
更有意思的是,可以返回一個嵌套函數(shù):作為一個新對象的屬性或作為結(jié)果返回。之后可以在其他地方使用。不論在哪里調(diào)用,它仍然可以訪問相同的外部變量。
下面的 ?makeCounter
? 創(chuàng)建了一個 “counter” 函數(shù),該函數(shù)在每次調(diào)用時返回下一個數(shù)字:
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
盡管很簡單,但稍加變型就具有很強的實際用途,比如,用作 隨機數(shù)生成器 以生成用于自動化測試的隨機數(shù)值。
這是如何運作的呢?如果我們創(chuàng)建多個計數(shù)器,它們會是獨立的嗎?這里的變量是怎么回事?
理解這些內(nèi)容對于掌握 JavaScript 的整體知識很有幫助,并且對于應(yīng)對更復(fù)雜的場景也很有益處。因此,讓我們繼續(xù)深入探究。
前方高能!
一大波深入的技術(shù)講解即將到來。
盡管我很想避免編程語言的一些底層細(xì)節(jié),但是如果沒有它們,我們就無法完整地理解詞法作用域,所以我們這就開始吧!
為了使內(nèi)容更清晰,這里將分步驟進行講解。
在 JavaScript 中,每個運行的函數(shù),代碼塊 {...}
以及整個腳本,都有一個被稱為 詞法環(huán)境(Lexical Environment) 的內(nèi)部(隱藏)的關(guān)聯(lián)對象。
詞法環(huán)境對象由兩部分組成:
this
? 的值)的對象。一個“變量”只是 環(huán)境記錄 這個特殊的內(nèi)部對象的一個屬性?!矮@取或修改變量”意味著“獲取或修改詞法環(huán)境的一個屬性”。
舉個例子,這段沒有函數(shù)的簡單的代碼中只有一個詞法環(huán)境:
這就是所謂的與整個腳本相關(guān)聯(lián)的 全局 詞法環(huán)境。
在上面的圖片中,矩形表示環(huán)境記錄(變量存儲),箭頭表示外部引用。全局詞法環(huán)境沒有外部引用,所以箭頭指向了 null
。
隨著代碼開始并繼續(xù)運行,詞法環(huán)境發(fā)生了變化。
這是更長的代碼:
右側(cè)的矩形演示了執(zhí)行過程中全局詞法環(huán)境的變化:
let
? 聲明前,不能引用它。幾乎就像變量不存在一樣。let phrase
? 定義出現(xiàn)了。它尚未被賦值,因此它的值為 ?undefined
?。從這一刻起,我們就可以使用變量了。phrase
? 被賦予了一個值。phrase
? 的值被修改。現(xiàn)在看起來都挺簡單的,是吧?
詞法環(huán)境是一個規(guī)范對象
“詞法環(huán)境”是一個規(guī)范對象(specification object):它只存在于 語言規(guī)范 的“理論”層面,用于描述事物是如何工作的。我們無法在代碼中獲取該對象并直接對其進行操作。
但 JavaScript 引擎同樣可以優(yōu)化它,比如清除未被使用的變量以節(jié)省內(nèi)存和執(zhí)行其他內(nèi)部技巧等,但顯性行為應(yīng)該是和上述的無差。
一個函數(shù)其實也是一個值,就像變量一樣。
不同之處在于函數(shù)聲明的初始化會被立即完成。
當(dāng)創(chuàng)建了一個詞法環(huán)境(Lexical Environment)時,函數(shù)聲明會立即變?yōu)榧从眯秃瘮?shù)(不像 let
那樣直到聲明處才可用)。
這就是為什么我們甚至可以在聲明自身之前調(diào)用一個以函數(shù)聲明(Function Declaration)的方式聲明的函數(shù)。
例如,這是添加一個函數(shù)時全局詞法環(huán)境的初始狀態(tài):
正常來說,這種行為僅適用于函數(shù)聲明,而不適用于我們將函數(shù)分配給變量的函數(shù)表達式,例如 let say = function(name)...
。
在一個函數(shù)運行時,在調(diào)用剛開始時,會自動創(chuàng)建一個新的詞法環(huán)境以存儲這個調(diào)用的局部變量和參數(shù)。
例如,對于 ?say("John")
?,它看起來像這樣(當(dāng)前執(zhí)行位置在箭頭標(biāo)記的那一行上):
在這個函數(shù)調(diào)用期間,我們有兩個詞法環(huán)境:內(nèi)部一個(用于函數(shù)調(diào)用)和外部一個(全局):
say
? 的當(dāng)前執(zhí)行相對應(yīng)。它具有一個單獨的屬性:?name
?,函數(shù)的參數(shù)。我們調(diào)用的是 ?say("John")
?,所以 ?name
? 的值為 ?"John"
?。phrase
? 變量和函數(shù)本身。內(nèi)部詞法環(huán)境引用了 outer
。
當(dāng)代碼要訪問一個變量時 —— 首先會搜索內(nèi)部詞法環(huán)境,然后搜索外部環(huán)境,然后搜索更外部的環(huán)境,以此類推,直到全局詞法環(huán)境。
如果在任何地方都找不到這個變量,那么在嚴(yán)格模式下就會報錯(在非嚴(yán)格模式下,為了向下兼容,給未定義的變量賦值會創(chuàng)建一個全局變量)。
在這個示例中,搜索過程如下:
name
? 變量,當(dāng) ?say
? 中的 ?alert
? 試圖訪問 ?name
? 時,會立即在內(nèi)部詞法環(huán)境中找到它。phrase
? 時,然而內(nèi)部沒有 ?phrase
?,所以它順著對外部詞法環(huán)境的引用找到了它。
讓我們回到 ?makeCounter
? 這個例子。
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
不同的是,在執(zhí)行 makeCounter()
的過程中創(chuàng)建了一個僅占一行的嵌套函數(shù):return count++
。我們尚未運行它,僅創(chuàng)建了它。
所有的函數(shù)在“誕生”時都會記住創(chuàng)建它們的詞法環(huán)境。從技術(shù)上講,這里沒有什么魔法:所有函數(shù)都有名為 [[Environment]]
的隱藏屬性,該屬性保存了對創(chuàng)建該函數(shù)的詞法環(huán)境的引用。
不同的是,在執(zhí)行 makeCounter()
的過程中創(chuàng)建了一個僅占一行的嵌套函數(shù):return count++
。我們尚未運行它,僅創(chuàng)建了它。
所有的函數(shù)在“誕生”時都會記住創(chuàng)建它們的詞法環(huán)境。從技術(shù)上講,這里沒有什么魔法:所有函數(shù)都有名為 [[Environment]]
的隱藏屬性,該屬性保存了對創(chuàng)建該函數(shù)的詞法環(huán)境的引用。
因此,counter.[[Environment]]
有對 {count: 0}
詞法環(huán)境的引用。這就是函數(shù)記住它創(chuàng)建于何處的方式,與函數(shù)被在哪兒調(diào)用無關(guān)。[[Environment]]
引用在函數(shù)創(chuàng)建時被設(shè)置并永久保存。
稍后,當(dāng)調(diào)用 counter()
時,會為該調(diào)用創(chuàng)建一個新的詞法環(huán)境,并且其外部詞法環(huán)境引用獲取于 counter.[[Environment]]
:
現(xiàn)在,當(dāng) counter()
中的代碼查找 count
變量時,它首先搜索自己的詞法環(huán)境(為空,因為那里沒有局部變量),然后是外部 makeCounter()
的詞法環(huán)境,并且在哪里找到就在哪里修改。
在變量所在的詞法環(huán)境中更新變量。
這是執(zhí)行后的狀態(tài):
如果我們調(diào)用 counter()
多次,count
變量將在同一位置增加到 2
,3
等。
閉包
開發(fā)者通常應(yīng)該都知道“閉包”這個通用的編程術(shù)語。
閉包 是指一個函數(shù)可以記住其外部變量并可以訪問這些變量。在某些編程語言中,這是不可能的,或者應(yīng)該以一種特殊的方式編寫函數(shù)來實現(xiàn)。但如上所述,在 JavaScript 中,所有函數(shù)都是天生閉包的(只有一個例外,將在 "new Function" 語法 中講到)。
也就是說:JavaScript 中的函數(shù)會自動通過隱藏的
[[Environment]]
屬性記住創(chuàng)建它們的位置,所以它們都可以訪問外部變量。
在面試時,前端開發(fā)者通常會被問到“什么是閉包?”,正確的回答應(yīng)該是閉包的定義,并解釋清楚為什么 JavaScript 中的所有函數(shù)都是閉包的,以及可能的關(guān)于
[[Environment]]
屬性和詞法環(huán)境原理的技術(shù)細(xì)節(jié)。
通常,函數(shù)調(diào)用完成后,會將詞法環(huán)境和其中的所有變量從內(nèi)存中刪除。因為現(xiàn)在沒有任何對它們的引用了。與 JavaScript 中的任何其他對象一樣,詞法環(huán)境僅在可達時才會被保留在內(nèi)存中。
但是,如果有一個嵌套的函數(shù)在函數(shù)結(jié)束后仍可達,則它將具有引用詞法環(huán)境的 [[Environment]]
屬性。
在下面這個例子中,即使在(外部)函數(shù)執(zhí)行完成后,它的詞法環(huán)境仍然可達。因此,此詞法環(huán)境仍然有效。
例如:
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // g.[[Environment]] 存儲了對相應(yīng) f() 調(diào)用的詞法環(huán)境的引用
請注意,如果多次調(diào)用 f()
,并且返回的函數(shù)被保存,那么所有相應(yīng)的詞法環(huán)境對象也會保留在內(nèi)存中。下面代碼中有三個這樣的函數(shù):
function f() {
let value = Math.random();
return function() { alert(value); };
}
// 數(shù)組中的 3 個函數(shù),每個都與來自對應(yīng)的 f() 的詞法環(huán)境相關(guān)聯(lián)
let arr = [f(), f(), f()];
當(dāng)詞法環(huán)境對象變得不可達時,它就會死去(就像其他任何對象一樣)。換句話說,它僅在至少有一個嵌套函數(shù)引用它時才存在。
在下面的代碼中,嵌套函數(shù)被刪除后,其封閉的詞法環(huán)境(以及其中的 ?value
?)也會被從內(nèi)存中刪除:
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // 當(dāng) g 函數(shù)存在時,該值會被保留在內(nèi)存中
g = null; // ……現(xiàn)在內(nèi)存被清理了
正如我們所看到的,理論上當(dāng)函數(shù)可達時,它外部的所有變量也都將存在。
但在實際中,JavaScript 引擎會試圖優(yōu)化它。它們會分析變量的使用情況,如果從代碼中可以明顯看出有未使用的外部變量,那么就會將其刪除。
在 V8(Chrome,Edge,Opera)中的一個重要的副作用是,此類變量在調(diào)試中將不可用。
打開 Chrome 瀏覽器的開發(fā)者工具,并嘗試運行下面的代碼。
當(dāng)代碼執(zhí)行暫停時,在控制臺中輸入 ?alert(value)
?。
function f() {
let value = Math.random();
function g() {
debugger; // 在 Console 中:輸入 alert(value); No such variable!
}
return g;
}
let g = f();
g();
正如你所見的 —— No such variable! 理論上,它應(yīng)該是可以訪問的,但引擎把它優(yōu)化掉了。
這可能會導(dǎo)致有趣的(如果不是那么耗時的)調(diào)試問題。其中之一 —— 我們可以看到的是一個同名的外部變量,而不是預(yù)期的變量:
let value = "Surprise!";
function f() {
let value = "the closest value";
function g() {
debugger; // 在 console 中:輸入 alert(value); Surprise!
}
return g;
}
let g = f();
g();
V8 引擎的這個特性你真的應(yīng)該知道。如果你要使用 Chrome/Edge/Opera 進行代碼調(diào)試,遲早會遇到這樣的問題。
這不是調(diào)試器的 bug,而是 V8 的一個特別的特性。也許以后會被修改。你始終可以通過運行本文中的示例來進行檢查。
函數(shù) sayHi 使用外部變量。當(dāng)函數(shù)運行時,將使用哪個值?
let name = "John";
function sayHi() {
alert("Hi, " + name);
}
name = "Pete";
sayHi(); // 會顯示什么:"John" 還是 "Pete"?
這種情況在瀏覽器和服務(wù)器端開發(fā)中都很常見。一個函數(shù)可能被計劃在創(chuàng)建之后一段時間后才執(zhí)行,例如在用戶行為或網(wǎng)絡(luò)請求之后。
因此,問題是:它會接收最新的修改嗎?
答案:Pete。
函數(shù)將從內(nèi)到外依次在對應(yīng)的詞法環(huán)境中尋找目標(biāo)變量,它使用最新的值。
舊變量值不會保存在任何地方。當(dāng)一個函數(shù)想要一個變量時,它會從自己的詞法環(huán)境或外部詞法環(huán)境中獲取當(dāng)前值。
下面的 makeWorker
函數(shù)創(chuàng)建了另一個函數(shù)并返回該函數(shù)??梢栽谄渌胤秸{(diào)用這個新函數(shù)。
它是否可以從它被創(chuàng)建的位置或調(diào)用位置(或兩者)訪問外部變量?
function makeWorker() {
let name = "Pete";
return function() {
alert(name);
};
}
let name = "John";
// 創(chuàng)建一個函數(shù)
let work = makeWorker();
// 調(diào)用它
work(); // 會顯示什么?
會顯示哪個值?“Pete” 還是 “John”?
答案:Pete.
下方代碼中的函數(shù) work()
在其被創(chuàng)建的位置通過外部詞法環(huán)境引用獲取 name
:
所以這里的結(jié)果是 "Pete"
。
但如果在 makeWorker()
中沒有 let name
,那么將繼續(xù)向外搜索并最終找到全局變量,正如我們可以從上圖中看到的那樣。在這種情況下,結(jié)果將是 "John"
。
在這兒我們用相同的 ?makeCounter
? 函數(shù)創(chuàng)建了兩個計數(shù)器(counters):?counter
? 和 ?counter2
?。
它們是獨立的嗎?第二個 counter 會顯示什么??0,1
? 或 ?2,3
? 還是其他?
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
let counter2 = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter2() ); // ?
alert( counter2() ); // ?
答案是:0,1。
函數(shù) counter
和 counter2
是通過 makeCounter
的不同調(diào)用創(chuàng)建的。
因此,它們具有獨立的外部詞法環(huán)境,每一個都有自己的 count
。
這里通過構(gòu)造函數(shù)創(chuàng)建了一個 counter 對象。
它能正常工作嗎?它會顯示什么呢?
function Counter() {
let count = 0;
this.up = function() {
return ++count;
};
this.down = function() {
return --count;
};
}
let counter = new Counter();
alert( counter.up() ); // ?
alert( counter.up() ); // ?
alert( counter.down() ); // ?
當(dāng)然行得通。
這兩個嵌套函數(shù)都是在同一個詞法環(huán)境中創(chuàng)建的,所以它們可以共享對同一個 count 變量的訪問:
function Counter() {
let count = 0;
this.up = function() {
return ++count;
};
this.down = function() {
return --count;
};
}
let counter = new Counter();
alert( counter.up() ); // 1
alert( counter.up() ); // 2
alert( counter.down() ); // 1
看看下面這個代碼。最后一行代碼的執(zhí)行結(jié)果是什么?
let phrase = "Hello";
if (true) {
let user = "John";
function sayHi() {
alert(`${phrase}, ${user}`);
}
}
sayHi();
答案:error。
函數(shù) sayHi
是在 if
內(nèi)聲明的,所以它只存在于 if
中。外部是沒有 sayHi
的。
編寫一個像 ?sum(a)(b) = a+b
? 這樣工作的 ?sum
? 函數(shù)。
是的,就是這種通過雙括號的方式(并不是錯誤)。
舉個例子:
sum(1)(2) = 3
sum(5)(-1) = 4
為了使第二個括號有效,第一個(括號)必須返回一個函數(shù)。
就像這樣:
function sum(a) {
return function(b) {
return a + b; // 從外部詞法環(huán)境獲得 "a"
};
}
alert( sum(1)(2) ); // 3
alert( sum(5)(-1) ); // 4
重要程度: 4
下面這段代碼的結(jié)果會是什么?
let x = 1;
function func() {
console.log(x); // ?
let x = 2;
}
func();
P.S. 這個任務(wù)有一個陷阱。解決方案并不明顯。
答案:error。
你運行一下試試:
let x = 1;
function func() {
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 2;
}
func();
在這個例子中,我們可以觀察到“不存在”的變量和“未初始化”的變量之間的特殊差異。
你可能已經(jīng)在 變量作用域,閉包 中學(xué)過了,從程序執(zhí)行進入代碼塊(或函數(shù))的那一刻起,變量就開始進入“未初始化”狀態(tài)。它一直保持未初始化狀態(tài),直至程序執(zhí)行到相應(yīng)的 let
語句。
換句話說,一個變量從技術(shù)的角度來講是存在的,但是在 let
之前還不能使用。
下面的這段代碼證實了這一點。
function func() {
// 引擎從函數(shù)開始就知道局部變量 x,
// 但是變量 x 一直處于“未初始化”(無法使用)的狀態(tài),直到結(jié)束 let(“死區(qū)”)
// 因此答案是 error
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 2;
}
變量暫時無法使用的區(qū)域(從代碼塊的開始到 let
)有時被稱為“死區(qū)”。
重要程度: 5
我們有一個內(nèi)建的數(shù)組方法 arr.filter(f)
。它通過函數(shù) f
過濾元素。如果它返回 true
,那么該元素會被返回到結(jié)果數(shù)組中。
制造一系列“即用型”過濾器:
inBetween(a, b)
? —— 在 ?a
? 和 ?b
? 之間或與它們相等(包括)。inArray([...])
? —— 包含在給定的數(shù)組中。用法如下所示:
arr.filter(inBetween(3,6))
? —— 只挑選范圍在 3 到 6 的值。arr.filter(inArray([1,2,3]))
? —— 只挑選與 ?[1,2,3]
? 中的元素匹配的元素。例如:
/* .. inBetween 和 inArray 的代碼 */
let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6
alert( arr.filter(inArray([1, 2, 10])) ); // 1,2
inBetween 篩選器
function inBetween(a, b) { return function(x) { return x >= a && x <= b; }; } let arr = [1, 2, 3, 4, 5, 6, 7]; alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6
inArray 篩選器
function inArray(arr) { return function(x) { return arr.includes(x); }; } let arr = [1, 2, 3, 4, 5, 6, 7]; alert( arr.filter(inArray([1, 2, 10])) ); // 1,2
我們有一組要排序的對象:
let users = [
{ name: "John", age: 20, surname: "Johnson" },
{ name: "Pete", age: 18, surname: "Peterson" },
{ name: "Ann", age: 19, surname: "Hathaway" }
];
通常的做法應(yīng)該是這樣的:
// 通過 name (Ann, John, Pete)
users.sort((a, b) => a.name > b.name ? 1 : -1);
// 通過 age (Pete, Ann, John)
users.sort((a, b) => a.age > b.age ? 1 : -1);
我們可以讓它更加簡潔嗎,比如這樣?
users.sort(byField('name'));
users.sort(byField('age'));
這樣我們就只需要寫 byField(fieldName)
,而不是寫一個函數(shù)。
編寫函數(shù) byField
來實現(xiàn)這個需求。
function byField(fieldName){
return (a, b) => a[fieldName] > b[fieldName] ? 1 : -1;
}
重要程度: 5
下列的代碼創(chuàng)建了一個 ?shooters
? 數(shù)組。
每個函數(shù)都應(yīng)該輸出其編號。但好像出了點問題……
function makeArmy() {
let shooters = [];
let i = 0;
while (i < 10) {
let shooter = function() { // 創(chuàng)建一個 shooter 函數(shù),
alert( i ); // 應(yīng)該顯示其編號
};
shooters.push(shooter); // 將此 shooter 函數(shù)添加到數(shù)組中
i++;
}
// ……返回 shooters 數(shù)組
return shooters;
}
let army = makeArmy();
// ……所有的 shooter 顯示的都是 10,而不是它們的編號 0, 1, 2, 3...
army[0](); // 編號為 0 的 shooter 顯示的是 10
army[1](); // 編號為 1 的 shooter 顯示的是 10
army[2](); // 10,其他的也是這樣。
為什么所有的 shooter 顯示的都是同樣的值?
修改代碼以使得代碼能夠按照我們預(yù)期的那樣工作。
讓我們檢查一下 makeArmy
內(nèi)部到底發(fā)生了什么,那樣答案就顯而易見了。
shooters
:let shooters = [];
shooters.push(function)
用函數(shù)填充它。每個元素都是函數(shù),所以數(shù)組看起來是這樣的:
shooters = [
function () { alert(i); },
function () { alert(i); },
function () { alert(i); },
function () { alert(i); },
function () { alert(i); },
function () { alert(i); },
function () { alert(i); },
function () { alert(i); },
function () { alert(i); },
function () { alert(i); }
];
然后,對數(shù)組中的任意數(shù)組項的調(diào)用,例如調(diào)用 army[5]()
(它是一個函數(shù)),將首先從數(shù)組中獲取元素 army[5]()
并調(diào)用它。
那么,為什么所有此類函數(shù)都顯示的是相同的值,10
呢?
這是因為 shooter
函數(shù)內(nèi)沒有局部變量 i
。當(dāng)一個這樣的函數(shù)被調(diào)用時,i
是來自于外部詞法環(huán)境的。
那么,i
的值是什么呢?
如果我們看一下源代碼:
function makeArmy() {
...
let i = 0;
while (i < 10) {
let shooter = function() { // shooter 函數(shù)
alert( i ); // 應(yīng)該顯示它自己的編號
};
shooters.push(shooter); // 將 shooter 函數(shù)添加到該數(shù)組中
i++;
}
...
}
……我們可以看到,所有的 shooter
函數(shù)都是在 makeArmy()
的詞法環(huán)境中被創(chuàng)建的。但當(dāng) army[5]()
被調(diào)用時,makeArmy
已經(jīng)運行完了,最后 i
的值為 10
(while
循環(huán)在 i=10
時停止)。
因此,所有的 shooter
函數(shù)獲得的都是外部詞法環(huán)境中的同一個值,即最后的 i=10
。
正如你在上邊所看到的那樣,在 while {...}
塊的每次迭代中,都會創(chuàng)建一個新的詞法環(huán)境。因此,要解決此問題,我們可以將 i
的值復(fù)制到 while {...}
塊內(nèi)的變量中,如下所示:
function makeArmy() {
let shooters = [];
let i = 0;
while (i < 10) {
let j = i;
let shooter = function() { // shooter 函數(shù)
alert( j ); // 應(yīng)該顯示它自己的編號
};
shooters.push(shooter);
i++;
}
return shooters;
}
let army = makeArmy();
// 現(xiàn)在代碼正確運行了
army[0](); // 0
army[5](); // 5
在這里,let j = i
聲明了一個“局部迭代”變量 j
,并將 i
復(fù)制到其中。原始類型是“按值”復(fù)制的,因此實際上我們得到的是屬于當(dāng)前循環(huán)迭代的獨立的 i
的副本。
shooter 函數(shù)正確運行了,因為 i
值的位置更近了(譯注:指轉(zhuǎn)到了更內(nèi)部的詞法環(huán)境)。不是在 makeArmy()
的詞法環(huán)境中,而是在與當(dāng)前循環(huán)迭代相對應(yīng)的詞法環(huán)境中:
如果我們一開始使用 for
循環(huán),也可以避免這樣的問題,像這樣:
function makeArmy() {
let shooters = [];
for(let i = 0; i < 10; i++) {
let shooter = function() { // shooter 函數(shù)
alert( i ); // 應(yīng)該顯示它自己的編號
};
shooters.push(shooter);
}
return shooters;
}
let army = makeArmy();
army[0](); // 0
army[5](); // 5
這本質(zhì)上是一樣的,因為 for
循環(huán)在每次迭代中,都會生成一個帶有自己的變量 i
的新詞法環(huán)境。因此,在每次迭代中生成的 shooter
函數(shù)引用的都是自己的 i
。
至此,你已經(jīng)花了很長時間來閱讀本文,發(fā)現(xiàn)最終的解決方案就這么簡單 — 使用 for
循環(huán),你可能會疑問 —— 我花了這么長時間讀這篇文章,值得嗎?
其實,如果你可以輕松地明白并答對本題目,你應(yīng)該就不會閱讀它的答案。所以,希望這個題目可以幫助你更好地理解閉包。
此外,確實存在有些人相較于 for
更喜歡 while
,以及其他情況。
更多建議: