給急躁的程式設計師的 JavaScript(ES2022 版)
請支持這本書:購買捐贈
(廣告,請不要封鎖。)

11 變數和賦值



這些是 JavaScript 宣告變數的主要方式

在 ES6 之前,也有 var。但它有幾個怪癖,因此最好在現代 JavaScript 中避免使用它。您可以在 Speaking JavaScript 中閱讀更多相關資訊。

11.1 let

透過 let 宣告的變數是可以變更的

let i;
i = 0;
i = i + 1;
assert.equal(i, 1);

您也可以同時宣告和指定

let i = 0;

11.2 const

透過 const 宣告的變數是不可變的。您必須立即初始化

const i = 0; // must initialize

assert.throws(
  () => { i = i + 1 },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

11.2.1 const 和不可變性

在 JavaScript 中,const 僅表示繫結(變數名稱與變數值之間的關聯)是不可變的。值本身可能是可變的,例如以下範例中的 obj

const obj = { prop: 0 };

// Allowed: changing properties of `obj`
obj.prop = obj.prop + 1;
assert.equal(obj.prop, 1);

// Not allowed: assigning to `obj`
assert.throws(
  () => { obj = {} },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

11.2.2 const 和迴圈

您可以在 for-of 迴圈中使用 const,其中會為每個反覆運算建立新的繫結

const arr = ['hello', 'world'];
for (const elem of arr) {
  console.log(elem);
}
// Output:
// 'hello'
// 'world'

然而,在一般的 for 迴圈中,您必須使用 let

const arr = ['hello', 'world'];
for (let i=0; i<arr.length; i++) {
  const elem = arr[i];
  console.log(elem);
}

11.3 在 constlet 之間做決定

我建議使用以下規則來在 constlet 之間做決定

  練習:const

exercises/variables-assignment/const_exrc.mjs

11.4 變數的範圍

變數的範圍是程式中可以存取它的區域。考慮以下程式碼。

{ // // Scope A. Accessible: x
  const x = 0;
  assert.equal(x, 0);
  { // Scope B. Accessible: x, y
    const y = 1;
    assert.equal(x, 0);
    assert.equal(y, 1);
    { // Scope C. Accessible: x, y, z
      const z = 2;
      assert.equal(x, 0);
      assert.equal(y, 1);
      assert.equal(z, 2);
    }
  }
}
// Outside. Not accessible: x, y, z
assert.throws(
  () => console.log(x),
  {
    name: 'ReferenceError',
    message: 'x is not defined',
  }
);

每個變數都可以在其直接範圍和所有嵌套在該範圍內的範圍中存取。

透過 constlet 宣告的變數稱為區塊範圍,因為它們的範圍永遠是最內層的周圍區塊。

11.4.1 遮蔽變數

您無法在同一個層級兩次宣告同一個變數

assert.throws(
  () => {
    eval('let x = 1; let x = 2;');
  },
  {
    name: 'SyntaxError',
    message: "Identifier 'x' has already been declared",
  });

  為什麼是 eval()

eval() 會延遲剖析(因此也會延遲 SyntaxError),直到執行 assert.throws() 的回呼函式。如果我們不使用它,我們會在剖析此程式碼時就收到錯誤,而 assert.throws() 甚至不會執行。

但是,您可以巢狀一個區塊,並使用您在區塊外使用的同一個變數名稱 x

const x = 1;
assert.equal(x, 1);
{
  const x = 2;
  assert.equal(x, 2);
}
assert.equal(x, 1);

在區塊內,內部的 x 是唯一可以存取的同名變數。內部的 x 稱為遮蔽外部的 x。一旦您離開區塊,您就可以再次存取舊值。

  測驗:基礎

請參閱 測驗應用程式

11.5 (進階)

所有其餘的章節都是進階的。

11.6 術語:靜態與動態

這兩個形容詞描述程式語言中的現象

我們來看這兩個術語的範例。

11.6.1 靜態現象:變數作用域

變數作用域是靜態現象。考量下列程式碼

function f() {
  const x = 3;
  // ···
}

x靜態(或詞彙作用域。也就是說,它的作用域是固定的,且在執行階段不會變更。

變數作用域形成靜態樹(透過靜態巢狀結構)。

11.6.2 動態現象:函式呼叫

函式呼叫是動態現象。考量下列程式碼

function g(x) {}
function h(y) {
  if (Math.random()) g(y); // (A)
}

A 行中的函式呼叫是否發生,只能在執行階段決定。

函式呼叫形成動態樹(透過動態呼叫)。

11.7 全域變數和全域物件

JavaScript 的變數作用域是巢狀的。它們形成一棵樹

根節點也稱為全域作用域。在網路瀏覽器中,直接位於該作用域的唯一位置是指令碼的最上層。全域作用域的變數稱為全域變數,且可以在任何地方存取。有兩種全域變數

下列 HTML 片段示範了 globalThis 和兩種全域變數。

<script>
  const declarativeVariable = 'd';
  var objectVariable = 'o';
</script>
<script>
  // All scripts share the same top-level scope:
  console.log(declarativeVariable); // 'd'
  console.log(objectVariable); // 'o'
  
  // Not all declarations create properties of the global object:
  console.log(globalThis.declarativeVariable); // undefined
  console.log(globalThis.objectVariable); // 'o'
</script>

每個 ECMAScript 模組都有自己的作用域。因此,存在於模組最上層的變數不是全域性的。圖 5 說明了各種作用域之間的關聯性。

Figure 5: The global scope is JavaScript’s outermost scope. It has two kinds of variables: object variables (managed via the global object) and normal declarative variables. Each ECMAScript module has its own scope which is contained in the global scope.

11.7.1 globalThis [ES2020]

全域變數 globalThis 是存取全域物件的新標準方式。它的名稱來自於它在全域作用域中與 this 具有相同的值。

  globalThis 不總是直接指向全域物件

例如,在瀏覽器中,有一個間接層。該間接層通常不會被注意到,但它確實存在,而且可以被觀察到。

11.7.1.1 globalThis 的替代方案

較舊的存取全域物件的方式取決於平台

11.7.1.2 globalThis 的使用案例

由於向後相容性的問題,全域物件現在被認為是 JavaScript 無法擺脫的一個錯誤。它會對效能產生負面影響,而且通常會造成混淆。

ECMAScript 6 導入了幾個功能,讓避免使用全域物件變得更容易,例如

通常最好透過變數存取全域物件變數,而不是透過 globalThis 的屬性。前者在所有 JavaScript 平台上始終都能以相同的方式運作。

網路上的教學偶爾會透過 window.globVar 存取全域變數 globVar。但前綴「window.」並非必要,我建議省略它

window.encodeURIComponent(str); // no
encodeURIComponent(str); // yes

因此,globalThis 的使用案例相對較少,例如

11.8 宣告:範圍和啟動

以下是宣告的兩個關鍵面向

表格 1 總結了各種宣告如何處理這些面向。

表格 1:宣告的面向。「重複」說明宣告是否可以使用相同的名稱兩次(每個範圍)。「全域屬性」說明宣告在腳本的全域範圍內執行時,是否會將屬性新增到全域物件。TDZ 表示暫時性死區(稍後會說明)。(*) 函式宣告通常是區塊範圍,但在寬鬆模式中是函式範圍。
範圍 啟動 重複 全域屬性
const 區塊 宣告 (TDZ)
let 區塊 宣告 (TDZ)
函式 區塊 (*) 開始
類別 區塊 宣告 (TDZ)
匯入 模組 與 export 相同
var 函式 開始,部分

import§27.5「ECMAScript 模組」 中有說明。以下各節會更詳細地說明其他建構式。

11.8.1 constlet:暫時性死區

對於 JavaScript,TC39 需要決定在宣告常數之前,於其直接範圍內存取常數時會發生什麼事

{
  console.log(x); // What happens here?
  const x;
}

一些可能的方法是

  1. 名稱會在目前範圍的周圍範圍中解析。
  2. 您會取得 undefined
  3. 會發生錯誤。

方法 1 被拒絕,因為這方法在語言中沒有先例。因此,對於 JavaScript 程式設計師來說,這不會直覺。

方法 2 被拒絕,因為這樣 x 就不是常數了 - 它在宣告前後會有不同的值。

let 使用與 const 相同的方法 3,因此兩者運作方式類似,且可以輕易地在它們之間切換。

進入變數範圍和執行其宣告之間的時間稱為該變數的暫時性死區 (TDZ)

以下程式碼說明了暫時性死區

if (true) { // entering scope of `tmp`, TDZ starts
  // `tmp` is uninitialized:
  assert.throws(() => (tmp = 'abc'), ReferenceError);
  assert.throws(() => console.log(tmp), ReferenceError);

  let tmp; // TDZ ends
  assert.equal(tmp, undefined);
}

下一個範例顯示暫時性死區確實是暫時性的(與時間相關)

if (true) { // entering scope of `myVar`, TDZ starts
  const func = () => {
    console.log(myVar); // executed later
  };

  // We are within the TDZ:
  // Accessing `myVar` causes `ReferenceError`

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

即使 func() 位於 myVar 的宣告之前並使用該變數,我們也可以呼叫 func()。但是我們必須等到 myVar 的暫時性死區結束。

11.8.2 函式宣告和早期啟動

  有關函式的更多資訊

在本節中,我們使用函式 - 在我們有機會適當地學習它們之前。希望一切都仍然有意義。如果沒有,請參閱 §25「可呼叫值」

函式宣告在進入其範圍時總是執行,無論它位於該範圍內的何處。這使您可以在宣告 foo() 之前呼叫它

assert.equal(foo(), 123); // OK
function foo() { return 123; }

foo() 的早期啟動表示前一個程式碼等於

function foo() { return 123; }
assert.equal(foo(), 123);

如果您透過 constlet 宣告函數,則不會提早啟動。在以下範例中,您只能在宣告後使用 bar()

assert.throws(
  () => bar(), // before declaration
  ReferenceError);

const bar = () => { return 123; };

assert.equal(bar(), 123); // after declaration 
11.8.2.1 在沒有提早啟動的情況下先行呼叫

即使函數 g() 沒有提早啟動,它仍可以被同一個範圍內的先行函數 f() 呼叫,只要我們遵守下列規則:f() 必須在宣告 g() 之後呼叫。

const f = () => g();
const g = () => 123;

// We call f() after g() was declared:
assert.equal(f(), 123);

模組的函數通常會在執行完完整內容後才呼叫。因此,在模組中,您很少需要擔心函數的順序。

最後,請注意提早啟動如何自動遵守上述規則:在進入範圍時,所有函數宣告會先執行,然後才會進行任何呼叫。

11.8.2.2 提早啟動的陷阱

如果您依賴提早啟動在函數宣告之前呼叫函數,則需要小心它不會存取未提早啟動的資料。

funcDecl();

const MY_STR = 'abc';
function funcDecl() {
  assert.throws(
    () => MY_STR,
    ReferenceError);
}

如果您在宣告 MY_STR 之後呼叫 funcDecl(),問題就會消失。

11.8.2.3 提早啟動的優缺點

我們已經看到提早啟動有一個陷阱,而且您可以在不使用它的情況下獲得大部分的好處。因此,最好避免提早啟動。但我對此沒有強烈感覺,而且如前所述,我經常使用函數宣告,因為我喜歡它們的語法。

11.8.3 類別宣告不會提早啟動

即使類別宣告在某些方面類似於函數宣告,類別宣告不會提早啟動

assert.throws(
  () => new MyClass(),
  ReferenceError);

class MyClass {}

assert.equal(new MyClass() instanceof MyClass, true);

這是為什麼?請考慮下列類別宣告

class MyClass extends Object {}

extends 的運算元是一個表達式。因此,您可以執行以下操作

const identity = x => x;
class MyClass extends identity(Object) {}

此類表達式的評估必須在提及它的位置進行。其他任何事情都會令人困惑。這說明了為什麼類別宣告不會提早啟動。

11.8.4 var:提升(部分提早啟動)

var 是一種較舊的變數宣告方式,早於 constlet(現在較為優先)。請考慮以下 var 宣告。

var x = 123;

此宣告有兩個部分

以下程式碼示範 var 的效果

function f() {
  // Partial early activation:
  assert.equal(x, undefined);
  if (true) {
    var x = 123;
    // The assignment is executed in place:
    assert.equal(x, 123);
  }
  // Scope is function, not block:
  assert.equal(x, 123);
}

11.9 閉包

在我們探索閉包之前,我們需要了解繫結變數和自由變數。

11.9.1 繫結變數與自由變數

在每個範圍中,都有一組被提及的變數。在這些變數中,我們區分

考慮以下程式碼

function func(x) {
  const y = 123;
  console.log(z);
}

func() 的主體中,xy 是約束變數。z 是自由變數。

11.9.2 什麼是閉包?

那麼,什麼是閉包?

閉包是一個函式,加上與其「誕生地」中存在的變數的連線。

保留此連線的目的是什麼?它提供函式的自由變數的值,例如

function funcFactory(value) {
  return () => {
    return value;
  };
}

const func = funcFactory('abc');
assert.equal(func(), 'abc'); // (A)

funcFactory 傳回一個閉包,並指定給 func。由於 func 與其誕生地的變數有連線,因此它在 A 行中被呼叫時仍可存取自由變數 value(即使它已經「跳脫」其範圍)。

  JavaScript 中的所有函式都是閉包

靜態範圍透過 JavaScript 中的閉包獲得支援。因此,每個函式都是閉包。

11.9.3 範例:遞增器工廠

以下函式傳回遞增器(我剛編造的名稱)。遞增器是一個在內部儲存數字的函式。當它被呼叫時,它會透過將參數加到該數字上來更新該數字,並傳回新的值。

function createInc(startValue) {
  return (step) => { // (A)
    startValue += step;
    return startValue;
  };
}
const inc = createInc(5);
assert.equal(inc(2), 7);

我們可以看到,在 A 行中建立的函式會在自由變數 startValue 中保留其內部數字。這次,我們不只是從誕生範圍中讀取,我們使用它來儲存我們變更且會在函式呼叫中持續存在的資料。

我們可以透過區域變數在誕生範圍中建立更多儲存槽

function createInc(startValue) {
  let index = -1;
  return (step) => {
    startValue += step;
    index++;
    return [index, startValue];
  };
}
const inc = createInc(5);
assert.deepEqual(inc(2), [0, 7]);
assert.deepEqual(inc(2), [1, 9]);
assert.deepEqual(inc(2), [2, 11]);

11.9.4 閉包的用例

閉包有什麼好處?

  測驗:進階

請參閱 測驗應用程式