2022 年 6 月 13 日

變數作用域,封閉

JavaScript 是一種非常以函式為導向的語言。它給了我們很大的自由度。函式可以在任何時候建立,傳遞給另一個函式作為參數,然後在稍後從程式碼中完全不同的位置呼叫。

我們已經知道函式可以存取函式外部的變數(「外部」變數)。

但是,如果函式建立之後外部變數發生變更,會怎麼樣?函式會取得較新的值還是舊值?

如果函式作為參數傳遞並從程式碼中的另一個位置呼叫,它會在新的位置存取外部變數嗎?

讓我們擴充我們的知識,以了解這些情況和更複雜的情況。

我們將在此討論 let/const 變數

在 JavaScript 中,有 3 種宣告變數的方法:letconst(現代方法)和 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);

對於 ifforwhile 等,在 {...} 中宣告的變數也只在內部可見

if (true) {
  let phrase = "Hello!";

  alert(phrase); // Hello!
}

alert(phrase); // Error, no such variable!

這裡,在 if 結束後,下面的 alert 將不會看到 phrase,因此會產生錯誤。

這很好,因為它允許我們建立區塊局部變數,特定於 if 分支。

類似的概念也適用於 forwhile 迴圈

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 中,每個執行中的函式、程式碼區塊 {...},以及整體腳本都有一個內部(隱藏)關聯物件,稱為「詞法環境」。

詞法環境物件包含兩部分

  1. 環境記錄 – 一個物件,將所有區域變數儲存在其屬性中(以及一些其他資訊,例如 this 的值)。
  2. 外部詞法環境的參考,與外部程式碼相關聯。

「變數」只是一個特殊內部物件的屬性,即「環境記錄」。「取得或變更變數」表示「取得或變更該物件的屬性」。

在這個沒有函式的簡單程式碼中,只有一個詞法環境

這是所謂的全域詞法環境,與整個腳本相關聯。

在上面的圖片中,矩形表示環境記錄(變數儲存),箭頭表示外部參考。全域詞法環境沒有外部參考,這就是箭頭指向 null 的原因。

隨著程式碼開始執行並繼續執行,詞法環境會改變。

以下是一個稍長的程式碼

右側的矩形展示了全域詞法環境在執行期間如何改變

  1. 當腳本開始時,詞法環境會預先填入所有宣告的變數。
    • 最初,它們處於「未初始化」狀態。這是一個特殊的內部狀態,表示引擎知道變數,但直到使用 let 宣告變數之前,都無法參考它。這幾乎就像變數不存在一樣。
  2. 然後出現 let phrase 定義。還沒有指定,因此其值為 undefined。我們可以從此處開始使用變數。
  3. 將值指定給 phrase
  4. 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 變數會在同一個地方增加到 23,以此類推。

閉包

有一個通用的程式設計術語「閉包」,開發人員通常應該知道它。

閉包是一種記住其外部變數並可以存取它們的函式。在某些語言中,這是不可能的,或者函式應該以特殊方式撰寫才能做到這一點。但如上所述,在 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 的一個特殊功能。也許它會在某個時候被更改。你隨時可以透過執行此頁面上的範例來檢查它。

任務

重要性:5

函式 sayHi 使用外部變數 name。當函式執行時,它將使用哪個值?

let name = "John";

function sayHi() {
  alert("Hi, " + name);
}

name = "Pete";

sayHi(); // what will it show: "John" or "Pete"?

這種情況在瀏覽器和伺服器端開發中都很常見。函式可能會被排程在建立之後執行,例如在使用者動作或網路請求之後。

因此,問題是:它會擷取最新的變更嗎?

答案是:Pete

函數會取得目前的外層變數,它會使用最新的值。

舊的變數值不會儲存在任何地方。當函數需要變數時,它會從自己的詞彙環境或外層詞彙環境取得目前的值。

重要性:5

下方的函數 `makeWorker` 會建立另一個函數並傳回。那個新的函數可以在其他地方呼叫。

它會存取建立位置或呼叫位置的外層變數,還是兩者都會?

function makeWorker() {
  let name = "Pete";

  return function() {
    alert(name);
  };
}

let name = "John";

// create a function
let work = makeWorker();

// call it
work(); // what will it show?

它會顯示哪個值?「Pete」還是「John」?

答案是:Pete

下方程式碼中的函數 `work()` 會透過外層詞彙環境參考從其來源位置取得 `name`

所以,這裡的結果是 "Pete"

但如果 `makeWorker()` 中沒有 `let name`,則搜尋會移到外層並取得全域變數,如我們從上方的鏈中所見。在這種情況下,結果將會是 "John"

重要性:5

這裡我們使用相同的 `makeCounter` 函數建立兩個計數器:`counter` 和 `counter2`。

它們是否獨立?第二個計數器會顯示什麼?0,12,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。

函數 `counter` 和 `counter2` 是透過 `makeCounter` 的不同呼叫建立的。

因此,它們有獨立的外層詞彙環境,每個都有自己的 `count`。

重要性:5

這裡使用建構函數建立一個計數器物件。

它會運作嗎?它會顯示什麼?

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() ); // ?

它肯定會正常運作。

兩個巢狀函數都在同一個外層詞彙環境中建立,因此它們會共用對同一個 `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
重要性:5

看看程式碼。最後一行的呼叫結果會是什麼?

let phrase = "Hello";

if (true) {
  let user = "John";

  function sayHi() {
    alert(`${phrase}, ${user}`);
  }
}

sayHi();

結果是錯誤

函數 `sayHi` 在 `if` 內部宣告,因此它只存在於其中。外部沒有 `sayHi`。

重要性:4

撰寫函數 `sum`,其運作方式如下:sum(a)(b) = a+b

對,就是這樣,使用雙括號(不是打錯字)。

例如

sum(1)(2) = 3
sum(5)(-1) = 4

要讓第二個括號運作,第一個括號必須傳回一個函式。

像這樣

function sum(a) {

  return function(b) {
    return a + b; // takes "a" from the outer lexical environment
  };

}

alert( sum(1)(2) ); // 3
alert( sum(5)(-1) ); // 4
重要性:4

這段程式碼的結果會是什麼?

let x = 1;

function func() {
  console.log(x); // ?

  let x = 2;
}

func();

附註:這個任務有一個陷阱。答案並不顯而易見。

結果是:錯誤

試著執行看看

let x = 1;

function func() {
  console.log(x); // ReferenceError: Cannot access 'x' before initialization
  let x = 2;
}

func();

在這個範例中,我們可以觀察到「不存在」與「未初始化」變數之間的特殊差異。

正如你在文章 變數範圍、封閉 中所讀到的,變數從執行進入程式碼區塊(或函式)的那一刻起,便開始處於「未初始化」狀態。而且會一直保持未初始化狀態,直到對應的 let 語句出現。

換句話說,變數在技術上是存在的,但在 let 之前無法使用。

以上的程式碼示範了這一點。

function func() {
  // the local variable x is known to the engine from the beginning of the function,
  // but "uninitialized" (unusable) until let ("dead zone")
  // hence the error

  console.log(x); // ReferenceError: Cannot access 'x' before initialization

  let x = 2;
}

變數暫時無法使用的區域(從程式碼區塊的開頭到 let)有時稱為「死區」。

重要性:5

我們有一個內建方法 arr.filter(f),用於陣列。它會透過函式 f 篩選所有元素。如果函式傳回 true,則該元素會傳回至結果陣列中。

建立一組「準備好使用」的篩選器

  • inBetween(a, b) – 在 ab 之間或等於它們(包含)。
  • inArray([...]) – 在給定的陣列中。

用法必須像這樣

  • arr.filter(inBetween(3,6)) – 僅選取介於 3 和 6 之間的值。
  • arr.filter(inArray([1,2,3])) – 僅選取與 [1,2,3] 的其中一個成員匹配的元素。

例如

/* .. your code for inBetween and 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" }
];

通常的做法是

// by name (Ann, John, Pete)
users.sort((a, b) => a.name > b.name ? 1 : -1);

// by age (Pete, Ann, John)
users.sort((a, b) => a.age > b.age ? 1 : -1);

我們可以讓它更簡潔嗎?像這樣

users.sort(byField('name'));
users.sort(byField('age'));

因此,我們不用寫函式,只要輸入 byField(fieldName) 即可。

撰寫可供此用途的函式 byField

開啟一個包含測試的沙盒。

function byField(fieldName){
  return (a, b) => a[fieldName] > b[fieldName] ? 1 : -1;
}

在沙盒中開啟包含測試的解答。

重要性:5

以下程式碼會建立一個 shooters 陣列。

每個函式都應該輸出其數字。但有些地方出錯了...

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let shooter = function() { // create a shooter function,
      alert( i ); // that should show its number
    };
    shooters.push(shooter); // and add it to the array
    i++;
  }

  // ...and return the array of shooters
  return shooters;
}

let army = makeArmy();

// all shooters show 10 instead of their numbers 0, 1, 2, 3...
army[0](); // 10 from the shooter number 0
army[1](); // 10 from the shooter number 1
army[2](); // 10 ...and so on.

為什麼所有射手都顯示相同的值?

修正程式碼,讓它們按照預期運作。

開啟一個包含測試的沙盒。

讓我們檢視一下 makeArmy 內部實際發生了什麼事,解決方案就會顯而易見。

  1. 它建立一個空的陣列 shooters

    let shooters = [];
  2. 在迴圈中透過 shooters.push(function) 填入函式。

    每個元素都是一個函式,因此產生的陣列看起來像這樣

    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); }
    ];
  3. 陣列從函式中傳回。

    然後,稍後呼叫任何成員,例如 army[5]() 會從陣列中取得元素 army[5](它是一個函式)並呼叫它。

    現在為什麼所有這些函式都顯示相同的值 10

    那是因為 shooter 函式內部沒有區域變數 i。當呼叫此類函式時,它會從其外部詞彙環境中取得 i

    那麼,i 的值會是什麼?

    如果我們查看原始碼

    function makeArmy() {
      ...
      let i = 0;
      while (i < 10) {
        let shooter = function() { // shooter function
          alert( i ); // should show its number
        };
        shooters.push(shooter); // add function to the array
        i++;
      }
      ...
    }

    我們可以看到所有 shooter 函式都是在 makeArmy() 函式的詞彙環境中建立的。但當呼叫 army[5]() 時,makeArmy 已經完成它的工作,而 i 的最後一個值是 10whilei=10 時停止)。

    結果,所有 shooter 函式都從外部詞彙環境取得相同的值,也就是最後一個值 i=10

    如上所示,在 while {...} 區塊的每次反覆運算中,都會建立一個新的詞彙環境。因此,為了修正這個問題,我們可以將 i 的值複製到 while {...} 區塊內的變數,如下所示

    function makeArmy() {
      let shooters = [];
    
      let i = 0;
      while (i < 10) {
          let j = i;
          let shooter = function() { // shooter function
            alert( j ); // should show its number
          };
        shooters.push(shooter);
        i++;
      }
    
      return shooters;
    }
    
    let army = makeArmy();
    
    // Now the code works correctly
    army[0](); // 0
    army[5](); // 5

    這裡的 let j = i 宣告一個「反覆運算區域」變數 j,並將 i 複製到其中。基本型別是「按值」複製的,因此我們實際上取得了 i 的一個獨立副本,它屬於目前的迴圈反覆運算。

    射手現在可以正確運作,因為 i 的值現在離得更近了。不在 makeArmy() 詞彙環境中,而是在對應於目前迴圈反覆運算的詞彙環境中

    如果我們一開始就使用 for,也可以避免這樣的問題,如下所示

    function makeArmy() {
    
      let shooters = [];
    
      for(let i = 0; i < 10; i++) {
        let shooter = function() { // shooter function
          alert( i ); // should show its number
        };
        shooters.push(shooter);
      }
    
      return shooters;
    }
    
    let army = makeArmy();
    
    army[0](); // 0
    army[5](); // 5

    這基本上是一樣的,因為 for 在每次迭代時會產生一個新的詞法環境,並有它自己的變數 i。因此,在每次迭代中產生的 shooter 參照它自己的 i,從那個迭代開始。

現在,由於你已經投入這麼多心力閱讀這篇文章,而最終的秘訣非常簡單,只要使用 for,你可能會想,這樣值得嗎?

嗯,如果你可以輕易回答這個問題,你就不會閱讀解答了。因此,希望這項任務能幫助你更了解某些事情。

此外,確實有某些情況會偏好使用 while 而不是 for,以及其他會出現此類問題的場景。

在沙盒中開啟包含測試的解答。

教學課程地圖

留言

留言前請先閱讀這段內容…
  • 如果你有建議要如何改進,請 提交 GitHub 問題 或提出拉取請求,而不是留言。
  • 如果你無法理解文章中的某些內容,請說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行,請將它們包覆在 <pre> 標籤中,若要插入超過 10 行,請使用沙盒 (plnkrjsbincodepen…)