Wendy Yuchen Sun

JavaScript 的 Scope

Apr 26, 2017

我最近努力想要把自己學到的 JS 知識彙整成比較有系統的體系。花了很多時間思考系統的架構,最後選擇以「議題」為核心。

不知道我這樣的詮釋有幾分正確,但最近我開始有點體悟到,一支 JS 程式所做的事情主要是設計、製造物件,以及透過物件之間的溝通達到程式的目的。不同功能的物件間,如果沒有溝通的必要,讓兩者互無干涉會比較保險、不生差池,這涉及到的其中一件事是如何控制 scope (例如盡量不要污染 global scope,意同於沒有必要讓所有物件都是隨處可見)。所以 scope 是我選擇的第一個議題。

什麼是 Scope

Scope 指何時可以使用一個特定的變數或函數。

Scope 不等於 context。Context 是指函式被呼叫、運行時,擁有該函式的物 件。Scope 基植於函式,每一次函式被呼叫時,可能會有不同的 scope,也就是該函示可以進用的變數範圍可能會有所變化;但 context 基植於物件,永遠是 this 所指涉的物件。

Context 會在下一篇整理文章中深入討論。

Difference between “Declare”, “Define” & “Initiate”

正式進入 scope 前,暫且岔個小路,區別上述幾字有何差異,以利往後的思考。

Global & Local Scope

回到 scope 的議題。Gobal scope 裡的變數可以被所有其他部分的程式進用;相反地,local scope 是只有部分的程式可以進用某一範圍內變數的意思。

Functional Scope, Using IIFE to Hide Information & Hoisting

一個函式可以創造 local scope,函式內的變數只有該函數及其內的他個函數可以進用。這裡可以延伸出用 IIFE 隱藏資訊保護 global scope 不被污染的手段。

1
2
3
4
5
6
7
8
console.log(name); // window.name === this.name === ""

(() => {
  var name = "Wendy";
  console.log(name); // "Wendy"
})();

console.log(name); // "" again

雖然寫程式時可以隨時 declare 和 define/initiate 變數,但是實際上程式運作時會將 declare 提到函式內部的最上方,先被 initiate 成 undefined。這個現象叫做 hoisting。

1
2
3
4
5
(() => {
  console.log(x); // undefined for hoisting
  var x = 1;
  console.log(x); // 1
})();

ES6: Block Scope (And As the New IIFE)

最一開始的 JS 是沒有提供 block scope 的,只有 function scope。但 function scope 有兩個有時候會變成問題的特點:

自從 ES6起,提供了 constlet 兩個可以 declare block scope 的方式,某個程度解決了上述的兩個問題。

1
2
3
4
5
6
7
// Before ES6
for (var i = 0; i < 5; i++) /* empty */;
i; // You got 5, not ReferenceError, for i is in global scope

// Make i staying in the for loop only
for (let i = 0; i < 5; i++) /* empty */;
i; // ReferenceError in program where i hasn't been in global scope
1
2
3
4
5
6
7
// Fix hoisting problem

(() => {
  console.log(x); // Boom! ReferenceError!
  const x = 1;
  console.log(x);
})();

關於 constlet 的 hoisting,有個比較細緻的曲折:它們並非沒有被 hoist,而是 hoist 後沒有被 initiate 成 undefined,所以提前進用會產生 ReferenceError 而非 undefined。這個現象叫做 “temporal dead zone”(TDZ)。

有了 block scope,上面這個 IIFE 甚至可以被以下這種寫法取代,不一定需要再特別製造一個即刻呼叫的函數,來保護資訊的隱私或是避 global scope 受污染:

1
2
3
4
5
6
{ // beginning of a "block" of statements 
  const x = 1;
  console.log(x); // 1
}; // statement group ends

console.log(x); // ReferenceError

Lexical Scope, Scope Chain & Closure

除了 function scope 以外,JS scope 的另一個重點是 lexical scope,意思是指巢狀的函數結構下,鑲嵌在其他函數內的函數,可以進用「定義」(注意:非運行時)在其外的函數的 scope。

這個由內到外的連結是一條 scope chain,JS 會從某個函式最裡面的 scope 開始找一個名稱所代表的值,若找不著就持續往外面的 scope 找,直到找到或找不到 (ReferenceError)。

Closure 與 lexical scope 關係很深。一個鑲嵌在其他函數內的函數,進用其外函數 local scope 的變數,就產生 closure,就算外在的函數已經運行後 return 結束了也是。這個特性也提供了隱藏資訊的好處,只希望被特定函式進用的變數,可以埋在包含這個特定函式的外部函式裡。

1
2
3
4
5
6
7
8
function idGeneratorFactory() {
  var id = 0; // only following returned function can access id
  return () => id++;
};

var genId = idGeneratorFactory();
genId(); // 0
genId(); // 1

Comparison: IIFE (or Block Scope) & Closure

留心 scope ,很大一部分的原因是要減少物件之間溝通的複雜度,所以我們有了:

等等隱藏資訊(尤其是避免 global scope 被污染)的手段。

前面提到 IIFE 可以被 block scope 取代,但 block scope 有一個缺點:無法 return。所以如果要像上一小節討論 closure 時所用的範例一樣, 利用 lexical scope 的特性創造一個受到保護但只有特定函式才能進用的變數(注意:差異在於不是沒有任何其他函式可以進用),那麼還是需要 lexical scope ,與當需要減少污染 global scope 機會時搭配 IIFE 的把戲,回傳出可以進用受保護值的函式,才能達成。

1
2
3
4
5
6
7
8
// Use IIFE to avoid declaring idGeneratorFactory
const genId = (() => {
  let id = 0;
  return () => id++;
})();

genId(); // 0
genId(); // 1

References

我沒有寫出所有我讀到的東西,只挑出我認為比較重點的部分。附上我參考的聯結:

Comment is free.