第 16 章。變數:範圍、環境和封閉
目錄
購買書籍
(廣告,請不要封鎖。)

第 16 章。變數:範圍、環境和封閉

本章節首先說明如何使用變數,然後詳細說明它們的工作原理(環境、封閉等)。

宣告變數

在 JavaScript 中,您在使用變數之前透過 var 陳述式宣告變數:

var foo;
foo = 3; // OK, has been declared
bar = 5; // not OK, an undeclared variable

您也可以結合宣告和指定,立即初始化變數

var foo = 3;

未初始化變數的值為 undefined

> var x;
> x
undefined

背景:靜態與動態

您可以從兩個角度檢查程式的工作原理:

靜態(或詞彙)

您在程式碼中檢查程式,而不會執行它。給定以下程式碼,我們可以做出靜態斷言,函式 g 嵌套在函式 f

function f() {
    function g() {
    }
}

形容詞 詞彙靜態 同義,因為兩者都與程式的 詞彙(字詞、來源)有關。

動態

您檢查執行程式時發生的情況(「在執行階段」)。給定以下程式碼

function g() {
}
function f() {
    g();
}

當我們呼叫 f() 時,它會呼叫 g()。在執行階段,gf 呼叫表示動態關係。

背景:變數的範圍

在本章節的其餘部分,您應該了解以下概念:

變數的範圍

變數的範圍是它可以存取的位置。例如

function foo() {
    var x;
}

在此,x直接範圍 是函式 foo()

詞彙範圍
JavaScript 中的變數是 詞彙範圍,因此程式的靜態結構會決定變數的範圍(不受函式呼叫位置影響)。
巢狀範圍

如果範圍嵌套在變數的直接範圍中,則變數在所有這些範圍中都可以存取

function foo(arg) {
    function bar() {
        console.log('arg: '+arg);
    }
    bar();
}
console.log(foo('hello')); // arg: hello

arg 的直接範圍是 foo(),但它也可以在巢狀範圍 bar() 中存取。關於巢狀,foo()外部範圍,而 bar()內部範圍

遮蔽

如果範圍宣告一個與周圍範圍中變數同名的變數,則在內部範圍和所有嵌套在其中的範圍中,將會封鎖對外部變數的存取。對內部變數的變更不會影響外部變數,而外部變數在離開內部範圍後又會再次可以存取:

var x = "global";
function f() {
    var x = "local";
    console.log(x); // local
}
f();
console.log(x); // global

在函式 f() 內,全域的 x 被區域的 x 遮蔽。

變數是函式範圍的

大多數主流 語言都是 區塊範圍的:變數「存活於」最內層的周圍程式碼區塊中。以下是 Java 的範例:

public static void main(String[] args) {
    { // block starts
        int foo = 4;
    } // block ends
    System.out.println(foo); // Error: cannot find symbol
}

在前面的程式碼中,變數 foo 只能在直接圍繞它的區塊中存取。如果我們嘗試在區塊結束後存取它,我們會得到一個編譯錯誤。

相反地,JavaScript 的變數是 函式範圍的只有函式會引入新的範圍;在範圍方面,區塊會被忽略。例如:

function main() {
    { // block starts
        var foo = 4;
    } // block ends
    console.log(foo); // 4
}

換句話說,foo 可以存取 main() 中的所有內容,而不仅仅是區塊內部。

變數宣告會提升

JavaScript 會 提升所有變數宣告,將它們移到直接範圍的開頭。這可以清楚說明如果在宣告變數之前存取它會發生什麼事:

function f() {
    console.log(bar);  // undefined
    var bar = 'abc';
    console.log(bar);  // abc
}

我們可以看到變數 bar 已經存在於 f() 的第一行,但它還沒有值;也就是說,宣告已經提升,但賦值還沒有。JavaScript 會執行 f(),就像它的程式碼是

function f() {
    var bar;
    console.log(bar);  // undefined
    bar = 'abc';
    console.log(bar);  // abc
}

如果您宣告一個已經宣告過的變數,什麼事都不會發生(變數的值不變)

> var x = 123;
> var x;
> x
123

每個函式宣告也會提升,但方式略有不同。提升的是完整的函式,不只是儲存函式的變數的建立(請參閱 提升)。

最佳實務:知道提升,但不要害怕它

有些 JavaScript 風格指南建議您只在函式的開頭放置變數宣告,以避免被提升所欺騙。如果您的函式相對較小(無論如何都應該是這樣),那麼您可以放寬這個規則,並在變數使用的地方附近宣告變數(例如,在 for 迴圈內)。這樣可以更好地封裝程式碼片段。顯然,您應該知道這種封裝只是概念性的,因為函式範圍提升仍然會發生。

透過 IIFE 引入新的範圍

您通常會引入新的範圍來限制變數的生命週期。 您可能想要這樣做的範例之一是 if 陳述式的「then」部分:它僅在條件成立時執行;如果它只使用輔助變數,我們不希望它們「洩漏」到周圍的範圍:

function f() {
    if (condition) {
        var tmp = ...;
        ...
    }
    // tmp still exists here
    // => not what we want
}

如果您想為 then 區塊引入新的範圍,您可以定義一個函式並立即呼叫它。這是一個解決方法,一個 區塊範圍模擬:

function f() {
    if (condition) {
        (function () {  // open block
            var tmp = ...;
            ...
        }());  // close block
    }
}

這是 JavaScript 中常見的模式。Ben Alman 建議將其稱為 立即呼叫的函式表達式 (IIFE,發音為「iffy」)。一般來說,IIFE 如下所示

(function () { // open IIFE
    // inside IIFE
}()); // close IIFE

以下是關於 IIFE 的一些注意事項

它會立即呼叫
函式閉合大括號後的括號會立即呼叫它。這表示它的主體會立即執行。
它必須是一個表達式
如果一個陳述句以關鍵字 function 開頭,解析器會預期它是一個函式宣告(請參閱 Expressions Versus Statements)。但函式宣告無法立即呼叫。因此,我們透過以開啟括號開始陳述句,告訴解析器關鍵字 function 是函式運算式的開頭。在括號內,只能有運算式。
需要尾隨分號

如果您忘記在兩個 IIFE 之間加上分號,您的程式碼將無法再運作:

(function () {
    ...
}()) // no semicolon
(function () {
    ...
}());

前述程式碼會被解釋為函式呼叫,第一個 IIFE(包括括號)是要呼叫的函式,而第二個 IIFE 是參數。

注意

IIFE 會產生成本(認知和效能方面),因此在 if 陳述句中使用它幾乎沒有意義。前述範例是基於教學目的而選擇的。

IIFE 變異:前置運算子

您也可以透過前置運算子來強制執行運算式內容。 例如,您可以透過邏輯 Not 運算子來執行:

!function () { // open IIFE
    // inside IIFE
}(); // close IIFE

或透過 void 運算子(請參閱 The void Operator

void function () { // open IIFE
    // inside IIFE
}(); // close IIFE

使用前置運算子的優點是,忘記終止分號不會造成問題。

IIFE 變異:已在運算式內容中

請注意,如果您已在運算式內容中,則不需要強制執行 IIFE 的運算式內容。這樣您不需要括號或前置運算子。例如:

var File = function () { // open IIFE
    var UNTITLED = 'Untitled';
    function File(name) {
        this.name = name || UNTITLED;
    }
    return File;
}(); // close IIFE

在前述範例中,有兩個不同的變數具有名稱 File。一方面,有僅可在 IIFE 內直接存取的函式。另一方面,有在第一行中宣告的變數。它會指派在 IIFE 中傳回的值。

IIFE 變異:具有參數的 IIFE

你可以使用參數來定義 IIFE 內部的變數:

var x = 23;
(function (twice) {
    console.log(twice);
}(x * 2));

這類似於

var x = 23;
(function () {
    var twice = x * 2;
    console.log(twice);
}());

IIFE 應用

IIFE 讓你能夠將私有資料附加到函式。然後你就不必宣告全域變數,並能緊密封裝函式及其狀態。你避免了汙染全域命名空間:

var setValue = function () {
    var prevValue;
    return function (value) { // define setValue
        if (value !== prevValue) {
            console.log('Changed: ' + value);
            prevValue = value;
        }
    };
}();

IIFE 的其他應用在本書的其他地方有提到

全域變數

包含所有程式碼的範圍稱為全域範圍程式碼範圍這是你在進入腳本時所在的範圍(無論是網頁中的<script>標籤,還是.js檔案)。在全域範圍內,你可以透過定義函式來建立巢狀範圍。在這樣的函式內,你可以再次巢狀範圍。每個範圍都可以存取自己的變數,以及周圍範圍內的變數。由於全域範圍包含所有其他範圍,因此其變數可以在任何地方存取:

// here we are in global scope
var globalVariable = 'xyz';
function f() {
    var localVariable = true;
    function g() {
        var anotherLocalVariable = 123;

        // All variables of surround scopes are accessible
        localVariable = false;
        globalVariable = 'abc';
    }
}
// here we are again in global scope

最佳實務:避免建立全域變數

全域變數有兩個缺點。首先,依賴全域變數的軟體會受到副作用的影響;它們的健壯性較差,行為較難預測,而且較難重複使用。

其次,網頁上的所有 JavaScript 共享相同的全域變數:您的程式碼、內建函式、分析程式碼、社群媒體按鈕等等。這表示名稱衝突可能會成為問題。因此,最好盡可能隱藏全域範圍內的許多變數。例如,請勿執行此操作

<!-- Don’t do this -->
<script>
    // Global scope
    var tmp = generateData();
    processData(tmp);
    persistData(tmp);
</script>

變數 tmp 會變成全域變數,因為其宣告是在全域範圍內執行的。但它只會在區域內使用。因此,我們可以使用 IIFE(請參閱 透過 IIFE 介紹新的範圍)將其隱藏在巢狀範圍內

<script>
    (function () {  // open IIFE
        // Local scope
        var tmp = generateData();
        processData(tmp);
        persistData(tmp);
    }());  // close IIFE
</script>

模組系統導致全域變數減少

值得慶幸的是,模組系統(請參閱 模組系統)幾乎消除了全域變數的問題,因為模組不會透過全域範圍進行介面,而且每個模組都有自己的範圍來處理模組全域變數。

全域物件

ECMAScript 規範使用內部資料結構 環境 來儲存變數(請參閱 環境:管理變數)。此語言具有相當不尋常的功能,可透過物件存取全域變數的環境,也就是所謂的 全域物件全域物件可 used 用於建立、讀取和變更全域變數。在全域範圍內,this 指向它:

> var foo = 'hello';
> this.foo  // read global variable
'hello'

> this.bar = 'world';  // create global variable
> bar
'world'

請注意,全域物件有原型。如果您要列出其所有(自有和繼承的)屬性,您需要一個函式,例如 列出所有屬性金鑰 中的 getAllPropertyNames()

> getAllPropertyNames(window).sort().slice(0, 5)
[ 'AnalyserNode', 'Array', 'ArrayBuffer', 'Attr', 'Audio' ]

JavaScript 建立者 Brendan Eich 認為全域物件是他 「最大的遺憾」 之一。它會對效能產生負面影響,讓變數範圍的實作變得更複雜,而且會導致模組化程式碼減少。

跨平台考量

瀏覽器和 Node.js 有全域變數來參照全域物件。很不幸的是,它們不同:

在兩個平台上,this 指的是全域物件,但僅限於在全域範圍內。這在 Node.js 中幾乎從未發生。如果你想要跨平台存取全域物件,你可以使用以下模式

(function (glob) {
    // glob points to global object
}(typeof window !== 'undefined' ? window : global));

從現在開始,我使用 window 來指稱全域物件,但在跨平台程式碼中,你應該使用前述模式和 glob

window 的使用案例

本節說明透過 window 存取全域變數的使用案例。但一般規則是:盡可能避免這麼做。

使用案例:標記全域變數

前綴 window 是個視覺線索,表示程式碼指涉的是全域變數,而不是區域變數:

var foo = 123;
(function () {
    console.log(window.foo);  // 123
}());

然而,這會讓你的程式碼變得脆弱。一旦你將 foo 從全域範圍移到另一個周圍範圍,它就會停止運作

(function () {
    var foo = 123;
    console.log(window.foo);  // undefined
}());

因此,最好將 foo 視為變數,而不是 window 的屬性。如果你想要明確表示 foo 是全域或類似全域的變數,你可以新增名稱前綴,例如 g_

var g_foo = 123;
(function () {
    console.log(g_foo);
}());

使用案例:內建函式

我比較不喜歡透過 window 來指涉內建全域變數。它們是眾所周知的名稱,因此你從表示它們是全域的指標中獲得的幫助很小。而且加上前綴的 window 會增加雜訊:

window.isNaN(...)  // no
isNaN(...)  // yes

使用案例:樣式檢查器

當你使用 JSLint 和 JSHint 等樣式檢查工具時,使用 window 表示你指涉未在目前檔案中宣告的全域變數時,不會收到錯誤訊息。然而,這兩個工具都提供方法讓你可以告知它們這些變數並防止此類錯誤(在它們的文件中搜尋「全域變數」)。

使用案例:檢查全域變數是否存在

這不是一個常見的使用案例,但特別是 shim 和 polyfill(請參閱 Shims Versus Polyfills)需要檢查全域變數 someVariable 是否存在。在這種情況下,window 有幫助:

if (window.someVariable) { ... }

這是執行此檢查的安全方法。如果 someVariable 尚未宣告,下列陳述式會擲回例外

// Don’t do this
if (someVariable) { ... }

你可以透過 window 檢查的另外兩種方法;它們大致相當,但更明確一點

if (window.someVariable !== undefined) { ... }
if ('someVariable' in window) { ... }

檢查變數是否存在(且有值)的常見方式是透過 typeof(請參閱 typeof:分類基本型別)

if (typeof someVariable !== 'undefined') { ... }

使用案例:在全域範圍建立物件

window 讓您可以在 全域範圍新增物件(即使您在巢狀範圍中),而且讓您有條件地執行此動作:

if (!window.someApiFunction) {
    window.someApiFunction = ...;
}

通常最好在全域範圍中透過 var 新增物件,但 window 提供一種乾淨的方式,讓您可以有條件地新增物件。

環境:管理變數

當程式執行進入變數的範圍時,變數就會產生。然後,它們需要儲存空間。在 JavaScript 中,提供該儲存空間的資料結構稱為 環境。它會將變數名稱對應到值。它的結構與 JavaScript 物件非常類似。環境有時會在您離開其範圍後繼續存在。因此,它們會儲存在堆疊中,而不是佇列中。

變數有兩種傳遞方式。如果您願意,可以將它們視為兩個面向

動態面向:呼叫函式

每次 呼叫函式時,函式都需要為其參數和變數建立新的儲存空間。函式執行完畢後,通常可以回收該儲存空間。舉例來說,以下是如何實作階乘函式。它會遞迴呼叫函式本身好幾次,每次都需要為 n 建立新的儲存空間:

function fac(n) {
    if (n <= 1) {
        return 1;
    }
    return n * fac(n - 1);
}
字彙(靜態)面向:與周圍範圍保持連線

不論函式呼叫的次數為何,它總是需要存取它自己的(新鮮的)區域變數和周圍範圍的變數。例如,以下函式 doNTimes,在其內部有一個輔助函式 doNTimesRec。當 doNTimesRec 多次呼叫它自己時,每次都會建立一個新的環境。然而,在那些呼叫期間,doNTimesRec 也會持續連接到 doNTimes 的單一環境(類似於所有函式共用一個單一全域環境)。doNTimesRec 需要該連線才能在第 (1) 行存取 action

function doNTimes(n, action) {
    function doNTimesRec(x) {
        if (x >= 1) {
            action();  // (1)
            doNTimesRec(x-1);
        }
    }
    doNTimesRec(n);
}

這兩個面向的處理方式如下

我們來看一個範例

function myFunction(myParam) {
    var myVar = 123;
    return myFloat;
}
var myFloat = 1.3;
// Step 1
myFunction('abc');  // Step 2

圖 16-1 說明執行前述程式碼時會發生什麼事

  1. myFunctionmyFloat 已儲存在全域環境 (#0) 中。請注意 myFunction 參照的 function 物件會透過內部屬性 [[Scope]] 指向其範圍 (全域範圍)。
  2. 為了執行 myFunction('abc'),會建立一個新的環境 (#1) 來存放參數和區域變數。它會透過 outer (已從 myFunction.[[Scope]] 初始化) 參照其外部環境。多虧了外部環境,myFunction 可以存取 myFloat

封閉:函式保持與其建立範圍的連線

如果函式離開建立它的範圍,它會保持與該範圍 (和周圍範圍) 的變數連線。例如:

function createInc(startValue) {
    return function (step) {
        startValue += step;
        return startValue;
    };
}

createInc() 傳回的函式不會失去與 startValue 的連線,這個變數會提供函式一個在函式呼叫間持續存在的狀態

> var inc = createInc(5);
> inc(1)
6
> inc(2)
8

封閉 是函式加上與建立函式的範圍的連線。這個名稱源自封閉會「封閉」函式的自由變數。如果變數未在函式中宣告,就是自由變數,也就是說它「來自外部」。

透過環境處理封閉

提示

這是一個進階區段,會深入探討封閉的運作方式。您應該熟悉環境 (檢閱 環境:管理變數)。

封閉是環境在執行離開其範圍後仍存在的範例。為了說明封閉的運作方式,讓我們檢視與 createInc() 的前一次互動,並將其分成四個步驟 (在每個步驟中,目前活動的執行內容及其環境已標示出來;如果函式處於活動狀態,也會標示出來)

  1. 這個步驟發生在互動之前,以及 createInc 的函式宣告評估之後。createInc 的項目已新增至全域環境 (#0) 並指向函式物件。

    image with no caption
  2. 這個步驟發生在執行函式呼叫 createInc(5) 期間。createInc 的新環境 (#1) 已建立並推入堆疊中。其外部環境是全域環境 (與 createInc.[[Scope]] 相同)。環境會存放參數 startValue

    image with no caption
  3. 此步驟發生在指派給 inc 之後。在我們從 createInc 回傳後,指向其環境的執行內容已從堆疊中移除,但環境仍存在於堆積區中,因為 inc.[[Scope]] 參照它。 inc 是閉包(函數加上誕生環境)。

    image with no caption
  4. 此步驟發生在執行 inc(1) 期間。已建立新的環境 (#1),並已將指向它的執行內容推入堆疊中。它的外部環境是 inc[[Scope]]。外部環境讓 inc 存取 startValue

    image with no caption
  5. 此步驟發生在執行 inc(1) 之後。沒有參照(執行內容、outer 欄位或 [[Scope]])再指向 inc 的環境。因此,它不再需要,可以從堆積區中移除。

    image with no caption

陷阱:無意間共用環境

有時,您建立的函數行為會受到目前範圍中變數的影響。在 JavaScript 中,這可能會造成問題,因為每個函數都應使用函數建立時變數具有的值。然而,由於函數是閉包,函數將永遠使用變數的 目前 值。for 迴圈中,這可能會妨礙正常運作。範例將讓事情更清楚:

function f() {
    var result = [];
    for (var i=0; i<3; i++) {
        var func = function () {
            return i;
        };
        result.push(func);
    }
    return result;
}
console.log(f()[1]());  // 3

f 回傳包含三個函數的陣列。所有這些函數仍可存取 f 的環境,因此可存取 i。事實上,它們共用相同的環境。唉,在迴圈結束後,i 在該環境中的值為 3。因此,所有函數都回傳 3

這不是我們想要的。若要修正問題,我們需要在建立使用 i 的函數之前,建立索引 i 的快照。換句話說,我們希望將每個函數封裝在函數建立時 i 所具有的值中。因此,我們採取下列步驟

  1. 為回傳陣列中的每個函數建立新的環境。
  2. i 的目前值(副本)儲存在該環境中。

只有函式會建立環境,所以我們使用 IIFE(請參閱 透過 IIFE 介紹新的範圍)來達成步驟 1

function f() {
    var result = [];
    for (var i=0; i<3; i++) {
        (function () { // step 1: IIFE
            var pos = i; // step 2: copy
            var func = function () {
                return pos;
            };
            result.push(func);
        }());
    }
    return result;
}
console.log(f()[1]());  // 1

請注意,這個範例與真實世界有關,因為當您透過迴圈將事件處理常式新增到 DOM 元素時,就會出現類似的場景。

下一頁:17. 物件與繼承