[JS] 作用域(Scope)與範圍鏈(Scope Chain)

作用域(Scope)變數可以被使用的區域,區分作用域可以避免變數的衝突。

舉例來說,甲、乙班各有一位同學叫小明,分別在兩個班上點名,點到小明都不會有問題,但若是全校廣播找小明,就會出現衝突,不知道要找哪一個小明。

作用域也有上下(內外)層級關係,當自己的作用域不存在特定變數時,可以向上(外)層尋找。

換個例子,雖然自己的班上找不到校長,但可以從自己的學校找到校長。

接著就來看 JavaScript 如何去實現這些功能。


💎 作用域(Scope)

🔸 1、全域作用域(Global Scope)

全域顧名思義就是全部的區域,網頁中的全域物件就是 window
在初學 JS 時,會先練習開啟一個空白檔案,並且撰寫變數宣告,這時的變數就屬於全域變數。

不同的宣告方式產生的全域變數也有差異:

  • var:宣告後會成為 window 物件底下的一個屬性。
  • let、const:宣告後不會放到 window 底下,而是一個區塊作用域。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    var a = 1;
    let b = 2;
    const c = 3;

    console.log(a); // 1
    console.log(window.a); // 1

    console.log(b); // 2
    console.log(window.b); // undefined

    console.log(c); // 3
    console.log(window.c); // undefined

🔸 2、函式作用域(Function Scope)

var 宣告的變數會在函式 (function) 內區隔,先來看看下面的範例碼:

1
2
3
4
5
6
7
8
9
10
11
function f1() {
var a = 1;
f2();
}
function f2() {
console.log(a)
}
f1();

// 執行結果:
// Uncaught ReferenceError: a is not defined

上面的範例可以知道 f2 函式無法取得 f1 函式內宣告的變數。

再來看看另一個經典題目,當 var 遇到 call stack 時出現的詭異狀況。

1
2
3
4
5
6
7
8
9
10
for (var i = 1; i < 5; i++) {
setTimeout(() => console.log(i), 500);
}
console.log(i);

// 5
// 5
// 5
// 5
// 5

如果你覺得這個輸出結果非常奇怪,不要忘了 var 的作用域是在函式中,上面這段 for 迴圈並不是函式,所以 var i宣告在全域,迴圈總共執行了四次,但是每次都是覆蓋掉全域中的 i ,最後只有一個全域變數 i 存放數值 5,接著 setTimeout 裡面的函式執行時因為作用域裡面沒有變數 i,向外找到全域的 i (此時迴圈已經執行完,值是5),才會都印出一樣的結果;除此之外,印出的第一個 5 是來自程式碼最後一行全域的呼叫,也證實了這個變數存在全域。

函式的範圍較大,撰寫時常使用的邏輯判斷都沒辦法區隔作用域,為了區隔作用域,衍生出閉包、立即函式等各種應用技巧,這個問題在 ES6 的版本後才獲得改善。

延伸閱讀
1、JavaScript 核心觀念(36)-函式以及 This 的運作-立即函式
2、閉包

🔸 3、區塊作用域(Block Scope)

ES6 版本加入的 letconst 兩種變數宣告方式,作用域會在大括號 {} 內區隔:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
const a = 1;
{
let b = 2;
var c = 3;
}
console.log(a); // 1
console.log(b); // b is not defined
console.log(c); // 3
}
console.log(a); // a is not defined
console.log(b); // b is not defined
console.log(c); // 3

上面的範例可以看到 var 無論在哪個大括號內都可以取用,但 letconst 無法在自己存在的大括號外被取用。

常用的 if-elsefor等方法都會使用大括號,相對於 function,可以把作用域區分的更細。

前個段落 setTimeout 範例內的 var 改成 let

1
2
3
4
5
6
7
8
9
for (let i = 1; i < 5; i++) {
setTimeout(() => console.log(i), 500);
}
console.log(i);
// Uncaught ReferenceError: i is not defined
// 1
// 2
// 3
// 4

從結果可以發現,每次的 let i 都是獨立的作用域,所以 setTimeout 實際執行時能夠找到該次迴圈區塊內產生的變數 i,除此之外,全域環境中也不會找到在區塊作用域中的變數 i (出現未定義的錯誤)。

參考資料
JS 原力覺醒 Day04 - Function Scope / Block Scope

  • 直接宣告(不加前綴詞),無論放在 區塊{}函式 內都會成為 window 物件底下的一個屬性。
    1
    2
    3
    4
    5
    6
    7
    8
    dirtyA = 11;
    console.log(window.dirtyA); // 11

    function dirtyVariable() {
    dirtyB = 12;
    }
    dirtyVariable();
    console.log(window.dirtyB); // 12
  • 直接宣告在 window 底下的變數可以使用 delete 物件方法刪除;而使用 var 宣告的全域變數無法使用 delete 刪除(介於屬性與非屬性之間)。
    1
    2
    3
    4
    5
    6
    7
    x = 1;
    delete window.x; // true
    console.log(x); // x is not defined

    var y = 2;
    delete window.y; // false
    console.log(y); // 2
  • 在 ES5 時可以透過'use strict'(嚴格模式)來避免這些不好的操作,ES6 以後一律使用 letconst

延伸閱讀:
1、深入JavaScript系列(一):詞法環境
2、Javascript 的嚴格模式 (Strict Mode)