9. 變數和範圍
目錄
請支持這本書:購買它(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

9. 變數和範圍



9.1 概觀

ES6 提供了兩種新的宣告變數方法:letconst,它們大多取代了 ES5 宣告變數的方法 var

9.1.1 let

let 的運作方式類似於 var,但它宣告的變數是區塊範圍的,它只存在於目前的區塊中。var函式範圍的。

在以下程式碼中,你可以看到宣告 let 的變數 tmp 只存在於從 A 行開始的區塊中

function order(x, y) {
    if (x > y) { // (A)
        let tmp = x;
        x = y;
        y = tmp;
    }
    console.log(tmp===x); // ReferenceError: tmp is not defined
    return [x, y];
}

9.1.2 const

const 的運作方式類似於 let,但您宣告的變數必須立即初始化,而且其值之後無法變更。

const foo;
    // SyntaxError: missing = in const declaration

const bar = 123;
bar = 456;
    // TypeError: `bar` is read-only

由於 for-of 會為每個迴圈迭代建立一個 繫結(變數的儲存空間),因此對迴圈變數宣告 const 是可以的

for (const x of ['a', 'b']) {
    console.log(x);
}
// Output:
// a
// b

9.1.3 宣告變數的方式

下表概述了在 ES6 中宣告變數的六種方式(靈感來自 kangax 的表格

  提升 範圍 建立全域屬性
var 宣告 函式
let 暫時性死區 區塊
const 暫時性死區 區塊
函式 完整 區塊
class 區塊
import 完整 模組全域

9.2 透過 letconst 進行區塊範圍設定

letconst 都會建立 區塊範圍 的變數,它們只存在於它們周圍最內層的區塊中。下列程式碼示範了 const 宣告的變數 tmp 只存在於 if 陳述式的區塊中

function func() {
    if (true) {
        const tmp = 123;
    }
    console.log(tmp); // ReferenceError: tmp is not defined
}

相反地,var 宣告的變數是函式範圍的

function func() {
    if (true) {
        var tmp = 123;
    }
    console.log(tmp); // 123
}

區塊範圍設定表示您可以在函式中隱藏變數

function func() {
  const foo = 5;
  if (···) {
     const foo = 10; // shadows outer `foo`
     console.log(foo); // 10
  }
  console.log(foo); // 5
}

9.3 const 建立不可變的變數

let 建立的變數是可變的

let foo = 'abc';
foo = 'def';
console.log(foo); // def

常數(由 const 建立的變數)是不可變的,您無法為它們指定不同的值

const foo = 'abc';
foo = 'def'; // TypeError

9.3.1 陷阱:const 沒有讓值變成不可變的

const 只表示變數永遠具有相同的值,但並不表示值本身是或會變成不可變的。例如,obj 是常數,但它指向的值是可變的,我們可以為它新增屬性

const obj = {};
obj.prop = 123;
console.log(obj.prop); // 123

不過,我們無法為 obj 指定不同的值

obj = {}; // TypeError

如果您希望 obj 的值不可變,您必須自行處理。例如,透過 凍結它

const obj = Object.freeze({});
obj.prop = 123; // TypeError
9.3.1.1 陷阱:Object.freeze() 是淺層的

請記住,Object.freeze()淺層的,它僅凍結其參數的屬性,而非儲存在其屬性中的物件。例如,物件 obj 已凍結

> const obj = Object.freeze({ foo: {} });
> obj.bar = 123
TypeError: Can't add property bar, object is not extensible
> obj.foo = {}
TypeError: Cannot assign to read only property 'foo' of #<Object>

但物件 obj.foo 則沒有。

> obj.foo.qux = 'abc';
> obj.foo.qux
'abc'

9.3.2 迴圈主體中的 const

一旦建立 const 變數,就無法變更。但這並不表示您無法重新進入其範圍並從頭開始,使用新的值。例如,透過迴圈

function logArgs(...args) {
    for (const [index, elem] of args.entries()) { // (A)
        const message = index + '. ' + elem; // (B)
        console.log(message);
    }
}
logArgs('Hello', 'everyone');

// Output:
// 0. Hello
// 1. everyone

此程式碼中有兩個 const 宣告,在 A 行和 B 行。在每次迴圈反覆運算期間,其常數都有不同的值。

9.4 暫時性死區

letconst 宣告的變數具有所謂的暫時性死區 (TDZ):進入其範圍時,在執行到達宣告之前,無法存取 (取得或設定) 該變數。讓我們比較由 var 宣告的變數 (沒有 TDZ) 和由 let 宣告的變數 (有 TDZ) 的生命週期。

9.4.1 var 宣告的變數的生命週期

var 變數沒有暫時性死區。其生命週期包含下列步驟

9.4.2 let 宣告的變數的生命週期

透過 let 宣告的變數有暫時性死區,其生命週期如下

const 變數的運作方式類似於 let 變數,但它們必須具有初始化器 (亦即,立即設定為一個值),且無法變更。

9.4.3 範例

在 TDZ 內,如果變數被取得或設定,將會拋出例外

let tmp = true;
if (true) { // enter new scope, TDZ starts
    // Uninitialized binding for `tmp` is created
    console.log(tmp); // ReferenceError

    let tmp; // TDZ ends, `tmp` is initialized with `undefined`
    console.log(tmp); // undefined

    tmp = 123;
    console.log(tmp); // 123
}
console.log(tmp); // true

如果有一個初始化器,則 TDZ 會在初始化器評估後且結果已指派給變數之後結束

let foo = console.log(foo); // ReferenceError

以下程式碼示範了死區實際上是時間性的(基於時間),而非空間性的(基於位置)

if (true) { // enter new scope, TDZ starts
    const func = function () {
        console.log(myVar); // OK!
    };

    // Here we are within the TDZ and
    // accessing `myVar` would cause a `ReferenceError`

    let myVar = 3; // TDZ ends
    func(); // called outside TDZ
}

9.4.4 typeof 會為 TDZ 中的變數拋出 ReferenceError

如果您透過 typeof 存取時間死區中的變數,您會得到一個例外

if (true) {
    console.log(typeof foo); // ReferenceError (TDZ)
    console.log(typeof aVariableThatDoesntExist); // 'undefined'
    let foo;
}

為什麼?其原理如下:foo 並未未宣告,而是未初始化。您應該知道它的存在,但您並不知道。因此,收到警告似乎是可取的。

此外,這種檢查僅對有條件建立全域變數有用。這是在一般程式中不需要執行的動作。

9.4.4.1 有條件建立變數

當涉及到有條件建立變數時,您有兩個選項。

選項 1 – typeofvar

if (typeof someGlobal === 'undefined') {
    var someGlobal = { ··· };
}

此選項僅在全域範圍內有效(因此不在 ES6 模組內)。

選項 2 – window

if (!('someGlobal' in window)) {
    window.someGlobal = { ··· };
}

9.4.5 為什麼會有時間死區?

有幾個原因讓 constlet 具有時間死區

9.4.6 進一步閱讀

本節的來源

9.5 迴圈標頭中的 letconst

下列迴圈允許你在標頭中宣告變數

若要進行宣告,你可以使用 varletconst。它們各自有不同的效果,我將在下面說明。

9.5.1 for 迴圈

for 迴圈標頭中使用 var 宣告變數會為該變數建立單一繫結(儲存空間)

const arr = [];
for (var i=0; i < 3; i++) {
    arr.push(() => i);
}
arr.map(x => x()); // [3,3,3]

三個箭頭函式的本體中的每個 i 都參照相同的繫結,這就是為什麼它們都傳回相同的值。

如果你使用 let 宣告變數,則會為每個迴圈反覆建立新的繫結

const arr = [];
for (let i=0; i < 3; i++) {
    arr.push(() => i);
}
arr.map(x => x()); // [0,1,2]

這一次,每個 i 都參照特定反覆的繫結,並保留當時的目前值。因此,每個箭頭函式都傳回不同的值。

const 的運作方式與 var 相同,但你無法變更使用 const 宣告變數的初始值

// TypeError: Assignment to constant variable
// (due to i++)
for (const i=0; i<3; i++) {
    console.log(i);
}

一開始,為每個反覆取得新的繫結可能看起來很奇怪,但當你使用迴圈建立參照迴圈變數的函式時,它非常有用,如 後面的章節 所述。

9.5.2 for-of 迴圈和 for-in 迴圈

for-of 迴圈中,var 會建立單一繫結

const arr = [];
for (var i of [0, 1, 2]) {
    arr.push(() => i);
}
arr.map(x => x()); // [2,2,2]

const 會為每次反覆運算建立一個不可變繫結

const arr = [];
for (const i of [0, 1, 2]) {
    arr.push(() => i);
}
arr.map(x => x()); // [0,1,2]

let 也會為每次反覆運算建立一個繫結,但它建立的繫結是可變的。

for-in 迴圈的運作方式與 for-of 迴圈類似。

9.5.3 每次反覆運算繫結有什麼用?

以下是一個顯示三個連結的 HTML 頁面

  1. 如果您按一下「是」,它會翻譯成「ja」。
  2. 如果您按一下「否」,它會翻譯成「nein」。
  3. 如果您按一下「也許」,它會翻譯成「vielleicht」。
<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
</head>
<body>
    <div id="content"></div>
    <script>
        const entries = [
            ['yes', 'ja'],
            ['no', 'nein'],
            ['perhaps', 'vielleicht'],
        ];
        const content = document.getElementById('content');
        for (const [source, target] of entries) { // (A)
            content.insertAdjacentHTML('beforeend',
                `<div><a id="${source}" href="">${source}</a></div>`);
            document.getElementById(source).addEventListener(
                'click', (event) => {
                    event.preventDefault();
                    alert(target); // (B)
                });
        }
    </script>
</body>
</html>

顯示的內容取決於變數 target (B 行)。如果我們在 A 行中使用 var 而非 const,整個迴圈只會有一個繫結,而 target 之後的值會是 'vielleicht'。因此,不論您按一下哪個連結,您都會看到翻譯「vielleicht」。

很幸運地,使用 const 後,我們會為每次迴圈反覆運算取得一個繫結,而翻譯也會正確顯示。

9.6 參數作為變數

9.6.1 參數與區域變數

如果您使用 let 宣告一個與參數同名的變數,您會得到一個靜態(載入時間)錯誤

function func(arg) {
    let arg; // static error: duplicate declaration of `arg`
}

在區塊中執行相同的動作會隱藏參數

function func(arg) {
    {
        let arg; // shadows parameter `arg`
    }
}

相反地,使用 var 宣告一個與參數同名的變數不會有任何動作,就像在同一個範圍內重新宣告一個 var 變數不會有任何動作一樣。

function func(arg) {
    var arg; // does nothing
}
function func(arg) {
    {
        // We are still in same `var` scope as `arg`
        var arg; // does nothing
    }
}

9.6.2 參數預設值與暫時性死區

如果參數有預設值,它們會被視為一系列的 let 陳述式,並且會受到暫時性死區的影響

// OK: `y` accesses `x` after it has been declared
function foo(x=1, y=x) {
    return [x, y];
}
foo(); // [1,1]

// Exception: `x` tries to access `y` within TDZ
function bar(x=y, y=2) {
    return [x, y];
}
bar(); // ReferenceError

9.6.3 參數預設值不會看到主體的範圍

參數預設值的範圍與主體的範圍是分開的(前者包圍後者)。這表示在參數預設值「內部」定義的方法或函式不會看到主體的區域變數

const foo = 'outer';
function bar(func = x => foo) {
    const foo = 'inner';
    console.log(func()); // outer
}
bar();

9.7 全域物件

JavaScript 的 全域物件(在網頁瀏覽器中為 window,在 Node.js 中為 global)比較像是一個錯誤,而不是一個功能,特別是在效能方面。這就是為什麼 ES6 引入一個區別是有道理的

請注意,模組的主體不會在全域範圍內執行,只有腳本會。因此,各種變數的環境會形成下列鏈。

9.8 函式宣告和類別宣告

函式宣告…

下列程式碼示範函式宣告的提升

{ // Enter a new scope

    console.log(foo()); // OK, due to hoisting
    function foo() {
        return 'hello';
    }
}

類別宣告…

類別不會提升可能會令人驚訝,因為在底層,它們會建立函式。這種行為的理由是,它們的 extends 子句的值是透過運算式定義的,而這些運算式必須在適當的時間執行。

{ // Enter a new scope

    const identity = x => x;

    // Here we are in the temporal dead zone of `MyClass`
    const inst = new MyClass(); // ReferenceError

    // Note the expression in the `extends` clause
    class MyClass extends identity(Object) {
    }
}

9.9 編碼風格:constletvar

我建議總是使用 letconst

  1. 優先使用 const。只要變數不變更其值,你就可以使用它。換句話說:變數不應該是指定運算的左側或 ++-- 的運算元。變更 const 變數所參考的物件是允許的
     const foo = {};
     foo.prop = 123; // OK
    

    你甚至可以在 for-of 迴圈中使用 const,因為每個迴圈迭代都會建立一個(不可變)繫結。

     for (const x of ['a', 'b']) {
         console.log(x);
     }
     // Output:
     // a
     // b
    

    for-of 迴圈的主體內,x 無法變更。

  2. 否則,使用 let – 當變數的初始值稍後變更時。
     let counter = 0; // initial value
     counter++; // change
    
     let obj = {}; // initial value
     obj = { foo: 123 }; // change
    
  3. 避免使用 var

如果你遵循這些規則,var 將只會出現在舊版程式碼中,表示需要小心重構。

var 做了一件事是 letconst 沒有做的:透過它宣告的變數會變成全域物件的屬性。然而,這通常不是一件好事。你可以透過指定給 window(在瀏覽器中)或 global(在 Node.js 中)來達成相同的效果。

9.9.1 另一種方法

另一種方法是僅對完全不可變的事物(原始值和凍結物件)使用 const。然後我們有兩種方法

  1. 優先使用 const const 標記不可變繫結。
  2. 偏好使用 let const 標記不可變的值。

我稍微偏好 #1,但 #2 也可以。

下一篇:10. 解構