夜行手札

技術成長 · Web 開發 · JavaScript

JavaScript 的作用域到底是什麼?變數到底能活在哪裡?用「領域展開」理解 Scope。

領域展開
9 分鐘閱讀

上一篇談到 Hoisting 時,我們不斷看到「目前作用域」、「區塊作用域」、「外層變數」等名詞。

那麼,作用域詳細來說到底是什麼?

可以先用一句話理解:

作用域決定了一個變數可以在哪些地方被看見、存取和使用。

如果用《咒術迴戰》的概念來比喻,我們可以暫時把作用域想成一種「領域展開」。

當領域形成後,領域裡會有自己的規則和事物。如果使用 letconst 在領域內建立變數,通常就只能在這個範圍內直接使用。

一旦離開領域,外面的程式就無法直接取得裡面的東西。

JavaScript
{
    const technique = "領域內的術式";

    console.log(technique); // 領域內可以使用
}

console.log(technique); // ReferenceError

白話來說:

technique 是在 {} 這個領域裡建立的術式,所以只在領域裡有效。走出領域後,外面的程式就找不到它了。

但這裡要注意,以上只是一個幫助理解的比喻。

JavaScript 不是真的在執行期間展開一道結界,它的作用域主要是由程式碼的宣告位置與巢狀結構決定的。

---

最基本的作用域範例

先看一個函式:

JavaScript
function sayHello() {
    const message = "Hello";

    console.log(message);
}

sayHello();

console.log(message); // ReferenceError

sayHello() 裡面,我們可以正常使用 message

但離開函式後,外面的程式就找不到它了。

可以把 sayHello() 想成一個獨立領域:

message 是在這個領域裡建立的東西,只有領域內的程式能直接使用。

這就是作用域最基本的概念。

---

最外層作用域

宣告在函式與區塊之外的變數,位於目前程式最外層的作用域。

JavaScript
const siteName = "夜行手記";

function showSiteName() {
    console.log(siteName);
}

showSiteName(); // 夜行手記

showSiteName() 裡面沒有自己的 siteName,所以 JavaScript 會往外尋找,最後找到外層的:

JavaScript
const siteName = "夜行手記";

可以把它理解成:

作用域內沒有需要的東西,所以開始往外層尋找。

就像領地裡面沒了某種東西,你就得向外尋找取得的機會。

Tips:全域變數是不是很方便?

在一般的 Script 環境中,宣告在最外層的變數,通常會被稱為全域變數。

全域變數可以在很大的範圍內被存取,看起來非常方便。

但如果太多程式都能修改同一個變數,也容易產生問題:

JavaScript
let count = 0;

function addCount() {
    count++;
}

function resetCount() {
    count = 0;
}

addCount()resetCount() 都能修改外面的 count

當操作它的地方越來越多,發生 Bug 時就會很難確認:

到底是誰動了我的 count

所以全域變數可以使用,只是要避免開放太多管道任意指向去修改同一份狀態。

即使單元測試完整,過多共享的可變狀態仍然會增加程式之間的耦合。所以說測試可以是防護手段,但不是讓全域變數變成零風險的免死金牌。

Tips:最外層變數不一定都是全域變數

如果程式使用 ES Module:

JavaScript
<script type="module" src="app.js"></script>

檔案最外層的變數屬於模組作用域,不會直接變成所有程式都能任意取得的真正全域變數。

因此,「最外層作用域」和「全域作用域」在某些執行環境中不能完全畫上等號。

---

函式作用域

使用 var 在函式裡宣告的變數,會屬於整個函式作用域。

JavaScript
function showAge() {
    var age = 20;

    console.log(age); // 20
}

showAge();

console.log(age); // ReferenceError

這裡的 age 位於 showAge() 的函式作用域中。

外面的程式無法直接取得它。

這裡也可以順便釐清 var 的一個常見誤解:

var 並不是完全沒有作用域,而是沒有一般的區塊作用域,但具有函式作用域。

例如:

JavaScript
function test() {
    if (true) {
        var message = "晚安";
    }

    console.log(message); // 晚安
}

test();
console.log(message); // ReferenceError

雖然 message 宣告在 if{} 裡面,但因為使用的是 var,所以它的有效範圍是整個 test() 函式。

不過離開函式後,一樣不能使用。

---

區塊作用域

letconst 具有區塊作用域。

所謂的區塊,通常是由 {} 包起來的一段程式。

JavaScript
if (true) {
    let userName = "Dennis";
    const siteName = "夜行手記";

    console.log(userName); // Dennis
    console.log(siteName); // 夜行手記
}

console.log(userName); // ReferenceError
console.log(siteName); // ReferenceError

userNamesiteName 都是在 if 的作用域內建立的。

一旦離開這個 {},外面的程式就無法直接使用它們。

Tips:哪些地方會形成區塊?

常見的區塊包括:

JavaScript
if (true) {
    const message = "if 區塊";
}
JavaScript
for (let i = 0; i < 3; i++) {
    const message = "for 區塊";
}

也可以直接建立一個獨立區塊:

JavaScript
{
    const message = "獨立區塊";
}

但不是所有 {} 都代表區塊作用域。

例如:

JavaScript
const user = {
    name: "Dennis",
    age: 20
};

這裡的 {} 是物件語法,用來描述物件內容,不是在建立一個新的區塊作用域。

---

varletconst 進入同一個作用域後會怎樣?

直接看例子:

JavaScript
if (true) {
    var meow = "var的meow";
    let dennis = 20;
    const siteName = "夜行手記";
}

console.log(meow); // var的meow

console.log(dennis);   // ReferenceError
console.log(siteName); // ReferenceError

meow 使用 var 宣告,所以沒有被限制在 if 區塊裡。

dennissiteName 使用 letconst 宣告,因此只能留在自己的作用域內。

白話來說:

  • var:作用域的門關起來了,它還是跑得出去。
  • letconst:乖乖待在作用域裡,離開就找不到了。

這也是現代 JavaScript 通常優先使用 letconst 的原因之一。

變數的活動範圍越明確,就越不容易在其他地方被意外使用或修改。

---

內層可以往外找,外層不能直接往內找

JavaScript 查找變數時,內層作用域可以往外層尋找。

JavaScript
const outside = "外面的變數";

function test() {
    const inside = "裡面的變數";

    console.log(outside); // 外面的變數
    console.log(inside);  // 裡面的變數
}

test();

test() 裡面可以找到自己的 inside,也可以往外找到 outside

但外層作用域不能直接取得函式內部的變數:

JavaScript
console.log(inside); // ReferenceError

用領域的概念來想:

身處內層領域時,可以往外接觸外層環境;但站在領域外面的人,不能直接伸手取得領域內的術式。

這裡特別使用「不能直接取得」,是因為之後會提到的 Closure,可以讓函式間接保留並使用內層資料。

---

JavaScript 是怎麼往外找變數的?

來看一個巢狀函式:

JavaScript
const levelOne = "第一層";

function outer() {
    const levelTwo = "第二層";

    function inner() {
        const levelThree = "第三層";

        console.log(levelThree);
        console.log(levelTwo);
        console.log(levelOne);
    }

    inner();
}

outer();

inner() 使用一個變數時,JavaScript 會按照順序尋找:

  1. 先在 inner() 裡找。
  2. 找不到就往外到 outer()
  3. 還是找不到,就繼續往最外層尋找。
  4. 如果一路找到最外面都沒有,才發生 ReferenceError

這條由內往外尋找的路線,稱為 Scope Chain

Tips:什麼是 Scope Chain?

Scope Chain 通常翻成「作用域鏈」。

白話來說就是:

JavaScript 尋找變數時,會依序查找的一連串作用域。

如果用領域來比喻:

先找目前的領域,找不到就往外層領域找,再找不到就繼續向外。

但它只會由內往外找,不會從外層跑進其他函式裡東翻西找。

---

同名變數會發生什麼?

不同作用域中,可以存在同名變數:

JavaScript
const name = "外面的 Dennis";

function showName() {
    const name = "裡面的 Dennis";

    console.log(name);
}

showName();        // 裡面的 Dennis
console.log(name); // 外面的 Dennis

showName() 執行 console.log(name) 時,JavaScript 會先從目前作用域尋找。

它已經在內層找到 name,所以不會繼續往外使用最外層的 name

這個現象叫做 Shadowing

Tips:什麼是 Shadowing?

Shadowing 通常翻譯成「變數遮蔽」。

可以把它理解成:

內層作用域出現同名變數後,在目前範圍裡,外層的同名變數會暫時被遮住。

如果用領域的概念來想:

在內層領域時,這個領域擁有自己的 name 規則,所以領域內會優先使用自己的版本。

外層變數並沒有消失或被修改。

離開內層作用域後,外面的 name 仍然存在:

JavaScript
console.log(name); // 外面的 Dennis

---

Shadowing 和 TDZ 一起出現會怎樣?

上一篇 Hoisting 裡,我們看過這個例子:

JavaScript
const meow = "外面的 meow";

{
    console.log(meow);

    let meow = "裡面的 meow";
}

有人可能會想:

內層的 meow 還沒有宣告完成,那 JavaScript 應該會往外找到 "外面的 meow" 吧?

實際上不會。

程式會直接發生 ReferenceError

原因是內層的 let meow,從區塊開始時就已經遮蔽了外層的 meow

但是內層的 meow 在執行到宣告以前,又仍然處於 TDZ,不能使用。

所以當下的狀況變成:

  • 外層的 meow 已經被內層遮住。
  • 內層的 meow 又還沒完成初始化。
  • 最後只能得到 ReferenceError

簡單來說:

外面的 meow 被領域隔開了,裡面的 meow 又還在吟唱、沒辦法施展,所以現主時兩隻 meow 都叫不出來。

---

什麼是 Lexical Scope?

JavaScript 使用的是 Lexical Scope,中文通常翻成「詞法作用域」。

啊哈...這又是什麼抽象的名詞...

沒事,其實核心概念只有一句話:

函式能存取哪些外部變數,主要由函式寫在哪裡決定,而不是最後從哪裡被呼叫。

例如:

JavaScript
const name = "全域的 Dennis";

function outer() {
    const name = "outer 裡面的 Dennis";

    function inner() {
        console.log(name);
    }

    return inner;
}

const showName = outer();

showName(); // outer 裡面的 Dennis

inner() 是寫在 outer() 裡面的,所以它往外尋找 name 時,會先找到:

JavaScript
const name = "outer 裡面的 Dennis";

即使最後是在外面呼叫:

JavaScript
showName();

它仍然不會改成使用最外層的 name

這段程式其實已經稍微碰到了 Closure 的概念,下一篇會再完整拆解。

Tips:Lexical 是什麼意思?

Lexical 可以理解成:

依照程式碼的書寫位置。

函式在建立時,就已經決定往外尋找變數時會經過哪些作用域。

不是看誰呼叫它,也不是看它最後在哪裡執行。

---

宣告位置和呼叫位置有什麼差別?

來看一個例子:

JavaScript
const message = "全域訊息";

function showMessage() {
    console.log(message);
}

function run() {
    const message = "run 裡面的訊息";

    showMessage();
}

run();

最後輸出的是:

text
全域訊息

有人可能會覺得:

showMessage() 是在 run() 裡面被呼叫的,應該要取得 run() 裡面的 message 吧?

但 JavaScript 不會這樣判斷。

showMessage() 宣告在最外層,所以它往外尋找變數時,會依照自己宣告位置的作用域路線尋找。

它不會因為被 run() 呼叫,就突然獲得存取 run() 區域變數的能力。

如果繼續用領域來比喻的話:

showMessage() 屬於哪個領域,是在它被創造時就決定的,不會因為跑到別人的領域裡被呼叫,就突然改換陣營。

---

領域展開這個比喻有哪些限制?

把作用域比喻成領域展開,可以幫助理解:

  • 變數有自己的有效範圍。
  • 內層與外層作用域。
  • 變數遮蔽。
  • 作用域鏈。
  • 離開作用域後,外部不能直接取得內部變數。

但這個比喻並不完全等於 JavaScript 的實際機制。

領域展開比較像角色在丟招時,主動建立一個影響範圍;JavaScript 的詞法作用域,則主要由程式碼寫在哪裡決定。

所以比較精確的說法是:

領域展開可以幫助我們理解「變數的有效範圍」,而 JavaScript 真正判斷作用域時,看的仍然是程式碼的宣告位置與巢狀關係。

---

為什麼理解作用域很重要?

因為它會直接影響我們平常寫程式的方式。

避免不同變數互相污染

JavaScript
function calculatePrice() {
    const total = 100;
}

function calculateScore() {
    const total = 80;
}

兩個函式都能使用 total,但因為存在於不同作用域中,所以不會互相影響。

限制資料能被修改的地方

JavaScript
function createCounter() {
    let count = 0;

    count++;

    return count;
}

count 被限制在函式裡,外面的程式無法直接修改它。

理解 Hoisting 和 TDZ

Hoisting、TDZ 和 Shadowing,都建立在作用域之上。

如果不知道一個變數屬於哪個作用域,就很難理解:

  • 為什麼變數會被遮蔽?
  • 為什麼 JavaScript 不去外層尋找?
  • 為什麼宣告前會發生錯誤?

為 Closure 做準備

函式可以依照詞法作用域往外取得變數。

而且在某些情況下,即使外層函式已經執行結束,內層函式仍然可以保留並使用外層的資料。

這就是下一篇會提到的 Closure

---

簡單整理

作用域決定了一個變數可以在哪些地方被看見和使用。

JavaScript 常見的作用域包括:

  • 全域或模組的最外層作用域
  • 函式作用域
  • 區塊作用域

其中:

  • var 具有函式作用域,但沒有一般的區塊作用域。
  • letconst 具有區塊作用域。
  • 內層作用域可以往外尋找變數。
  • 外層作用域不能直接取得內層變數。
  • JavaScript 會沿著 Scope Chain,由內往外尋找變數。
  • 內層同名變數會遮蔽外層變數,這個現象稱為 Shadowing。
  • JavaScript 使用 Lexical Scope,函式能取得哪些外部變數,主要由宣告位置決定。
  • Shadowing 和 TDZ 同時出現時,可能造成外層變數被遮住,但內層變數又還不能使用。

短短懶人包:

作用域就像變數的領域展開,決定它能在哪裡發揮效果;而詞法作用域,則決定函式會沿著哪一條路往外尋找資料。

下一篇我們就可以繼續看看:

為什麼一個函式離開原本的作用域後,居然還能記得外面的資料?

這就是 JavaScript 的 Closure。