JavaScript 是一種非常以函式為導向的語言。它給了我們很大的自由度。函式可以在任何時候建立,傳遞給另一個函式作為參數,然後在稍後從程式碼中完全不同的位置呼叫。
我們已經知道函式可以存取函式外部的變數(「外部」變數)。
但是,如果函式建立之後外部變數發生變更,會怎麼樣?函式會取得較新的值還是舊值?
如果函式作為參數傳遞並從程式碼中的另一個位置呼叫,它會在新的位置存取外部變數嗎?
讓我們擴充我們的知識,以了解這些情況和更複雜的情況。
let/const
變數在 JavaScript 中,有 3 種宣告變數的方法:let
、const
(現代方法)和 var
(過去的殘餘)。
- 在本文中,我們將在範例中使用
let
變數。 - 使用
const
宣告的變數行為相同,因此本文也包含const
。 - 舊的
var
有些顯著的差異,它們將在文章 舊的「var」 中介紹。
程式碼區塊
如果變數在程式碼區塊 {...}
內宣告,它只會在該區塊內可見。
例如
{
// do some job with local variables that should not be seen outside
let message = "Hello"; // only visible in this block
alert(message); // Hello
}
alert(message); // Error: message is not defined
我們可以使用它來隔離執行其自身任務的程式碼片段,並使用只屬於它的變數
{
// show message
let message = "Hello";
alert(message);
}
{
// show another message
let message = "Goodbye";
alert(message);
}
請注意,如果沒有單獨的區塊,如果我們對現有變數名稱使用 let
,將會產生錯誤
// show message
let message = "Hello";
alert(message);
// show another message
let message = "Goodbye"; // Error: variable already declared
alert(message);
對於 if
、for
、while
等,在 {...}
中宣告的變數也只在內部可見
if (true) {
let phrase = "Hello!";
alert(phrase); // Hello!
}
alert(phrase); // Error, no such variable!
這裡,在 if
結束後,下面的 alert
將不會看到 phrase
,因此會產生錯誤。
這很好,因為它允許我們建立區塊局部變數,特定於 if
分支。
類似的概念也適用於 for
和 while
迴圈
for (let i = 0; i < 3; i++) {
// the variable i is only visible inside this for
alert(i); // 0, then 1, then 2
}
alert(i); // Error, no such variable
在視覺上,let i
在 {...}
之外。但 for
結構在這裡很特別:在其中宣告的變數被視為區塊的一部分。
巢狀函式
當函式在另一個函式內建立時,稱為「巢狀」。
使用 JavaScript 就可以輕鬆做到這一點。
我們可以使用它來整理我們的程式碼,如下所示
function sayHiBye(firstName, lastName) {
// helper nested function to use below
function getFullName() {
return firstName + " " + lastName;
}
alert( "Hello, " + getFullName() );
alert( "Bye, " + getFullName() );
}
這裡的巢狀函式 getFullName()
是為了方便而建立的。它可以存取外部變數,因此可以傳回完整名稱。巢狀函式在 JavaScript 中相當常見。
更有趣的是,巢狀函式可以傳回:作為新物件的屬性或作為結果本身。然後可以在其他地方使用它。無論在哪裡,它都可以存取相同的外部變數。
在下方,makeCounter
建立「計數器」函式,在每次呼叫時傳回下一個數字
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
儘管很簡單,但稍微修改後的程式碼變體有實際用途,例如,作為 偽亂數產生器,為自動化測試產生亂數值。
這是如何運作的?如果我們建立多個計數器,它們會獨立運作嗎?這裡的變數發生了什麼事?
了解這些事情對於 JavaScript 的整體知識很有幫助,並且對於更複雜的場景也很有益。所以讓我們深入探討一下。
詞法環境
深入的技術說明在前面。
儘管我想避免低階語言細節,但沒有它們的任何理解都是有缺陷和不完整的,所以做好準備。
為求清楚,說明分為多個步驟。
步驟 1. 變數
在 JavaScript 中,每個執行中的函式、程式碼區塊 {...}
,以及整體腳本都有一個內部(隱藏)關聯物件,稱為「詞法環境」。
詞法環境物件包含兩部分
- 環境記錄 – 一個物件,將所有區域變數儲存在其屬性中(以及一些其他資訊,例如
this
的值)。 - 對外部詞法環境的參考,與外部程式碼相關聯。
「變數」只是一個特殊內部物件的屬性,即「環境記錄」。「取得或變更變數」表示「取得或變更該物件的屬性」。
在這個沒有函式的簡單程式碼中,只有一個詞法環境
這是所謂的全域詞法環境,與整個腳本相關聯。
在上面的圖片中,矩形表示環境記錄(變數儲存),箭頭表示外部參考。全域詞法環境沒有外部參考,這就是箭頭指向 null
的原因。
隨著程式碼開始執行並繼續執行,詞法環境會改變。
以下是一個稍長的程式碼
右側的矩形展示了全域詞法環境在執行期間如何改變
- 當腳本開始時,詞法環境會預先填入所有宣告的變數。
- 最初,它們處於「未初始化」狀態。這是一個特殊的內部狀態,表示引擎知道變數,但直到使用
let
宣告變數之前,都無法參考它。這幾乎就像變數不存在一樣。
- 最初,它們處於「未初始化」狀態。這是一個特殊的內部狀態,表示引擎知道變數,但直到使用
- 然後出現
let phrase
定義。還沒有指定,因此其值為undefined
。我們可以從此處開始使用變數。 - 將值指定給
phrase
。 phrase
變更值。
到目前為止,一切都看起來很簡單,對吧?
- 變數是特殊內部物件的屬性,與目前執行的區塊/函式/腳本相關聯。
- 使用變數實際上是使用該物件的屬性。
「詞法環境」是一個規格物件:它只在 語言規格 中「理論上」存在,用來描述事物如何運作。我們無法在程式碼中取得這個物件,也無法直接操作它。
JavaScript 引擎也可以最佳化它,丟棄未使用的變數以節省記憶體,並執行其他內部技巧,只要可見行為保持如說明所示。
步驟 2. 函式宣告
函式也是一個值,就像變數一樣。
不同的是,函式宣告會立即完全初始化。
當建立詞法環境時,函式宣告會立即成為可用的函式(不像 let
,在宣告之前無法使用)。
這就是為什麼我們可以在函式宣告宣告函式之前,就使用宣告為函式宣告的函式。
例如,以下是新增函式時,全域詞法環境的初始狀態
當然,這種行為只適用於函式宣告,不適用於函式運算式,例如我們將函式指定給變數,例如 let say = function(name)...
。
步驟 3. 內部和外部詞法環境
當函式執行時,在呼叫的開頭,會自動建立一個新的詞法環境,用來儲存呼叫的區域變數和參數。
例如,對於 say("John")
,它看起來像這樣(執行在標記有箭頭的行)
在函式呼叫期間,我們有兩個詞法環境:內部環境(用於函式呼叫)和外部環境(全域)
- 內部詞法環境對應於
say
的目前執行。它有一個屬性:name
,函式引數。我們呼叫say("John")
,所以name
的值是"John"
。 - 外部詞法環境是全域詞法環境。它有
phrase
變數和函式本身。
內部詞法環境有一個指向 外部
環境的參考。
當程式碼想要存取變數時,會先搜尋內部詞法環境,然後是外部環境,然後是更外部的環境,以此類推,直到全域環境。
如果在任何地方都找不到變數,則在嚴格模式下會產生錯誤(沒有 use strict
,對不存在變數的指定會建立一個新的全域變數,以與舊程式碼相容)。
在此範例中,搜尋會按以下方式進行
- 對於
name
變數,say
內部的alert
會立即在內部詞法環境中找到它。 - 當它想要存取
phrase
時,則在本地沒有phrase
,因此它會遵循對外部詞法環境的參考,並在那裡找到它。
步驟 4. 傳回函式
讓我們回到 makeCounter
範例。
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
在每次呼叫 makeCounter()
的開頭,會建立一個新的詞彙環境物件,用來儲存此 makeCounter
執行階段的變數。
因此,我們有兩個巢狀詞彙環境,就像上面的範例一樣
不同的是,在執行 makeCounter()
時,會建立一個僅有一行的微小巢狀函式:return count++
。我們尚未執行它,僅建立它。
所有函式都會記住建立它們的詞彙環境。技術上來說,這裡沒有魔法:所有函式都有名為 [[Environment]]
的隱藏屬性,用來保留建立函式的詞彙環境的參考。
因此,counter.[[Environment]]
具有 {count: 0}
詞彙環境的參考。這就是函式記住建立位置的方式,無論在何處呼叫它。[[Environment]]
參考會在函式建立時設定一次,並永久保留。
稍後,當呼叫 counter()
時,會為呼叫建立一個新的詞彙環境,並從 counter.[[Environment]]
取得其外部詞彙環境參考
現在,當 counter()
內部的程式碼尋找 count
變數時,它會先搜尋自己的詞彙環境(為空,因為沒有任何區域變數),然後搜尋外部 makeCounter()
呼叫的詞彙環境,並在其中找到並變更它。
變數會在它所在的詞彙環境中更新。
以下是執行後的狀態
如果我們多次呼叫 counter()
,count
變數會在同一個地方增加到 2
、3
,以此類推。
有一個通用的程式設計術語「閉包」,開發人員通常應該知道它。
閉包是一種記住其外部變數並可以存取它們的函式。在某些語言中,這是不可能的,或者函式應該以特殊方式撰寫才能做到這一點。但如上所述,在 JavaScript 中,所有函式都是天生的閉包(只有一個例外,會在 「new Function」語法 中涵蓋)。
也就是說:它們會自動使用隱藏的 [[Environment]]
屬性記住建立位置,然後其程式碼可以存取外部變數。
在面試時,如果前端開發人員被問到「什麼是閉包?」,一個有效的回答會是閉包的定義,以及解釋 JavaScript 中的所有函式都是閉包,並可能再說明一些關於技術細節的內容:[[Environment]]
屬性以及詞彙環境如何運作。
垃圾回收
通常,在函式呼叫結束後,詞法環境會連同所有變數從記憶體中移除。這是因為沒有任何參考指向它。如同任何 JavaScript 物件,它只會在可存取時保留在記憶體中。
然而,如果有一個巢狀函式在函式結束後仍然可存取,那麼它具有參考詞法環境的 [[Environment]]
屬性。
在這種情況下,即使函式完成,詞法環境仍然可存取,因此它會保持存在。
例如
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // g.[[Environment]] stores a reference to the Lexical Environment
// of the corresponding f() call
請注意,如果 f()
被呼叫多次,並且儲存了結果函式,那麼所有對應的詞法環境物件也會保留在記憶體中。在以下程式碼中,所有 3 個
function f() {
let value = Math.random();
return function() { alert(value); };
}
// 3 functions in array, every one of them links to Lexical Environment
// from the corresponding f() run
let arr = [f(), f(), f()];
詞法環境物件在它變得不可存取時會消失(就像任何其他物件一樣)。換句話說,它只會在至少有一個巢狀函式參考它時存在。
在以下程式碼中,在巢狀函式被移除後,它的封閉詞法環境(因此 value
)會從記憶體中清除
function f() {
let value = 123;
return function() {
alert(value);
}
}
let g = f(); // while g function exists, the value stays in memory
g = null; // ...and now the memory is cleaned up
實際最佳化
正如我們所見,理論上,當函式存在時,所有外部變數也會被保留。
但在實際上,JavaScript 引擎會嘗試最佳化它。它們會分析變數使用,如果從程式碼中很明顯外部變數未被使用,它就會被移除。
V8(Chrome、Edge、Opera)中的一個重要副作用是,此類變數在除錯時將不可用。
請在開啟開發人員工具的情況下,在 Chrome 中執行以下範例。
當它暫停時,在主控台中輸入 alert(value)
。
function f() {
let value = Math.random();
function g() {
debugger; // in console: type alert(value); No such variable!
}
return g;
}
let g = f();
g();
正如你所見,沒有這樣的變數!理論上,它應該是可存取的,但引擎將它最佳化掉了。
這可能會導致有趣(如果不是那麼耗時的)除錯問題。其中之一,我們可以看到同名的外部變數,而不是預期的變數
let value = "Surprise!";
function f() {
let value = "the closest value";
function g() {
debugger; // in console: type alert(value); Surprise!
}
return g;
}
let g = f();
g();
了解 V8 的此功能是很好的。如果你正在使用 Chrome/Edge/Opera 除錯,遲早會遇到它。
這不是除錯器中的錯誤,而是 V8 的一個特殊功能。也許它會在某個時候被更改。你隨時可以透過執行此頁面上的範例來檢查它。
留言
<code>
標籤,若要插入多行,請將它們包覆在<pre>
標籤中,若要插入超過 10 行,請使用沙盒 (plnkr、jsbin、codepen…)