Javascript 變量作用域,閉包

2023-02-17 10:50 更新

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);

對于 iffor 和 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ù)是在另一個函數(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ù)深入探究。

詞法環(huán)境

前方高能!

一大波深入的技術(shù)講解即將到來。

盡管我很想避免編程語言的一些底層細(xì)節(jié),但是如果沒有它們,我們就無法完整地理解詞法作用域,所以我們這就開始吧!

為了使內(nèi)容更清晰,這里將分步驟進行講解。

Step 1. 變量

在 JavaScript 中,每個運行的函數(shù),代碼塊 {...} 以及整個腳本,都有一個被稱為 詞法環(huán)境(Lexical Environment) 的內(nèi)部(隱藏)的關(guān)聯(lián)對象。

詞法環(huán)境對象由兩部分組成:

  1. 環(huán)境記錄(Environment Record) —— 一個存儲所有局部變量作為其屬性(包括一些其他信息,例如 ?this? 的值)的對象。
  2. 對 外部詞法環(huán)境 的引用,與外部代碼相關(guān)聯(lián)。

一個“變量”只是 環(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)境的變化:

  1. 當(dāng)腳本開始運行,詞法環(huán)境預(yù)先填充了所有聲明的變量。
    • 最初,它們處于“未初始化(Uninitialized)”狀態(tài)。這是一種特殊的內(nèi)部狀態(tài),這意味著引擎知道變量,但是在用 ?let? 聲明前,不能引用它。幾乎就像變量不存在一樣。
  2. 然后 ?let phrase? 定義出現(xiàn)了。它尚未被賦值,因此它的值為 ?undefined?。從這一刻起,我們就可以使用變量了。
  3. ?phrase? 被賦予了一個值。
  4. ?phrase? 的值被修改。

現(xiàn)在看起來都挺簡單的,是吧?

  • 變量是特殊內(nèi)部對象的屬性,與當(dāng)前正在執(zhí)行的(代碼)塊/函數(shù)/腳本有關(guān)。
  • 操作變量實際上是操作該對象的屬性。

詞法環(huán)境是一個規(guī)范對象

“詞法環(huán)境”是一個規(guī)范對象(specification object):它只存在于 語言規(guī)范 的“理論”層面,用于描述事物是如何工作的。我們無法在代碼中獲取該對象并直接對其進行操作。

但 JavaScript 引擎同樣可以優(yōu)化它,比如清除未被使用的變量以節(jié)省內(nèi)存和執(zhí)行其他內(nèi)部技巧等,但顯性行為應(yīng)該是和上述的無差。

Step 2. 函數(shù)聲明

一個函數(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)...。

Step 3. 內(nèi)部和外部的詞法環(huán)境

在一個函數(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)用)和外部一個(全局):

  • 內(nèi)部詞法環(huán)境與? say? 的當(dāng)前執(zhí)行相對應(yīng)。它具有一個單獨的屬性:?name?,函數(shù)的參數(shù)。我們調(diào)用的是 ?say("John")?,所以 ?name? 的值為 ?"John"?。
  • 外部詞法環(huán)境是全局詞法環(huán)境。它具有 ?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)境中找到它。
  • 當(dāng)它試圖訪問 ?phrase? 時,然而內(nèi)部沒有 ?phrase?,所以它順著對外部詞法環(huán)境的引用找到了它。


Step 4. 返回函數(shù)

讓我們回到 ?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)存被清理了

實際開發(fā)中的優(yōu)化

正如我們所看到的,理論上當(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 的一個特別的特性。也許以后會被修改。你始終可以通過運行本文中的示例來進行檢查。

任務(wù)


函數(shù)會選擇最新的內(nèi)容嗎?

重要程度: 5

函數(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)前值。


哪些變量可用呢?

重要程度: 5

下面的 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"。


Counter 是獨立的嗎?

重要程度: 5

在這兒我們用相同的 ?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


Counter 對象

重要程度: 5

這里通過構(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

if 內(nèi)的函數(shù)

重要程度: 5

看看下面這個代碼。最后一行代碼的執(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

重要程度: 4

編寫一個像 ?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ū)”。


通過函數(shù)篩選

重要程度: 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

按字段排序

重要程度: 5

我們有一組要排序的對象:

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;
}

函數(shù)大軍

重要程度: 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ā)生了什么,那樣答案就顯而易見了。

  1. 它創(chuàng)建了一個空數(shù)組 shooters
  2. let shooters = [];
  3. 在循環(huán)中,通過 shooters.push(function) 用函數(shù)填充它。
  4. 每個元素都是函數(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); }
    ];
  5. 該數(shù)組返回自函數(shù)。
  6. 然后,對數(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 的值為 10while 循環(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,以及其他情況。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號