Logo

新人日誌

首頁關於我部落格

新人日誌

Logo

網站會不定期發佈技術筆記、職場心得相關的內容,歡迎關注本站!

網站
首頁關於我部落格
部落格
分類系列文

© 新人日誌. All rights reserved. 2020-present.

初學者指南:深入了解 JavaScript 的 Lexical Scope(詞法作用域)

最後更新:2026年8月31日JavaScript

先看一段程式碼,猜猜看它印出什麼:

let x = 1;

function hey() {
  console.log(x);
}

function test() {
  let x = 2;
  hey();
}

test();

答案是 1。

如果你猜 2,你的推論大概是這樣:hey 是在 test 裡面被呼叫的,而 test 裡面剛好有一個 x 是 2,所以 hey 應該會拿到那個 2。

這個推論很合理,但 JavaScript 不是這樣運作的。

hey 這個名字在程式裡出現了兩次,但那是兩件不同的事。

function hey() { ... } 這一整塊,是把這個函式寫出來,這叫做 hey 的定義。

test 裡面那行 hey(),是把已經定義好的函式叫起來執行,這叫做呼叫。

決定 console.log(x) 拿到哪個 x 的,是定義的位置。

hey 的定義寫在最外層,跟 let x = 1 平行,沒有寫進 test 的大括號裡。

而 test 裡的 x 只活在那對大括號之內,hey 的定義在括號外面,所以看不到 test 裡的 x。

於是 hey 看得到的只有最外層的 x,值是 1。

換句話說,hey 看得到哪些變數,取決於定義寫在哪一層,而不是呼叫寫在哪一層。

這條規則叫做 Lexical Scope(詞法作用域)。

Lexical 在這裡的意思就是「原始碼上的位置」——你把函式定義在哪一層,這個函式看得到哪些變數就在那一刻定案了。

作用域:一個變數能被看到的範圍

作用域(scope)指的是程式碼裡的一塊區域,在這塊區域內宣告的變數,只有這塊區域裡面的程式碼看得到。

最常見的一種作用域就是函式:

function greet() {
  let message = "hello";
  console.log(message);
}

greet();
console.log(message);

greet 裡面的那行 console.log 可以正常印出 hello,因為它跟 message 在同一個函式裡。

但最後那行會直接報錯:ReferenceError: message is not defined。

從函式外面看過去,message 這個變數根本不存在。

它在 greet 被呼叫的時候誕生,在 greet 執行完之後就沒了。

作用域可以一層包一層

函式裡面還可以再寫函式,於是作用域就變成了巢狀的結構:

let globalVar = "global";

function outer() {
  let outerVar = "outer";

  function inner() {
    let innerVar = "inner";
    console.log(innerVar);
    console.log(outerVar);
    console.log(globalVar);
  }

  inner();
}

outer();

這段程式碼有三層:最外層、outer 裡面、inner 裡面。

inner 這三行 console.log 全部都印得出來,但取得變數的方式不太一樣。

innerVar 就是在 inner 裡面宣告的,直接拿。

outerVar 不是在 inner 裡面宣告的,於是 JavaScript 往外一層去 outer 找,找到了。

globalVar 在 outer 裡也沒有,於是再往外一層找到最外層,也找到了。

這個「自己這層沒有就往外一層找,一路找到最外層為止」的過程,就叫做作用域鏈(scope chain)。

如果找到最外層還是沒有,才會丟出 ReferenceError。

為什麼是定義的位置,不是呼叫的位置

有了作用域鏈,我們可以回頭看開場那段程式碼了。

let x = 1;

function hey() {
  console.log(x);
}

function test() {
  let x = 2;
  hey();
}

test();

hey 的大括號裡面沒有 let x 這一行,也就是說 x 不是在這裡宣告的。

既然不是在這裡宣告的,JavaScript 就得沿著作用域鏈往外找。

關鍵在於:hey 的「外面」是哪一層?

定義寫在最外層,所以它的外面就是最外層,找到的 x 是 1。

test 裡的 x 從來不在 hey 的作用域鏈上。test 裡的 hey() 只是把 hey 叫起來執行,並不會順便把 test 的變數交給 hey。

理論上,語言也可以設計成讓函式去看「呼叫寫在哪一層」,真的有語言是這樣做的。

但 JavaScript 選的是另一條路:函式的作用域鏈在定義被寫下來的那一刻就固定了,之後不管誰呼叫它、在哪一層呼叫,都不會改變。

這對你的好處是,你光讀原始碼就能推斷出一個變數指的是誰,不需要去追它的呼叫路徑。

只能往外找,不能往內找

作用域鏈是單向的。

裡面的函式看得到外面的變數,但外面看不到裡面。

function outer() {
  let outerVar = "outer";

  function inner() {
    let innerVar = "inner";
    console.log(outerVar);
  }

  inner();
  console.log(innerVar);
}

outer();

inner 印 outerVar 沒問題,這是往外找。

但 outer 最後那行想印 innerVar 就會報錯,因為那是往內找,作用域鏈不往那個方向走。

站在 outer 的位置,inner 裡面發生的事完全是個黑盒子。

不只函式,區塊也有自己的作用域

如果你用的是 let 或 const,那麼一對大括號就足以形成一個作用域,不需要是函式:

if (true) {
  let blockVar = "inside the block";
}

console.log(blockVar);

這裡一樣會報錯。

blockVar 被關在 if 的大括號裡面,出了那對括號就不存在了。

這叫做區塊作用域(block scope),for 迴圈、while、甚至單獨寫一對 {} 都算。

但如果換成 var,行為就不一樣了:

if (true) {
  var oldVar = "inside the block";
}

console.log(oldVar);

這次會印出 inside the block。

var 不管區塊,它只認函式——寫在函式裡就屬於那個函式,沒寫在函式裡就屬於最外層。

這是 var 常常讓人踩雷的原因之一,也是現在建議一律用 let 和 const 的原因之一。

閉包:函式會把作用域一起帶走

前面的例子裡,inner() 這行呼叫都寫在 outer 的大括號裡面。

function outer() {
  let outerVar = "outer";

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

  inner();
}

outer();

inner() 那行還在 outer 的大括號之內,所以整段的執行順序是這樣:

  1. 呼叫 outer(),outerVar 誕生
  2. 執行到 inner(),印出 outerVar
  3. outer 結束,outerVar 消失

outerVar 要到第 3 步才消失,而 inner 在第 2 步就用完了,所以印得出來。

那如果 inner 是在第 3 步之後才被呼叫呢?

function outer() {
  let outerVar = "outer";

  return function inner() {
    console.log(outerVar);
  };
}

const closureFunction = outer();
closureFunction();

跟上一段程式碼的差別只有兩處。

第一,outer 不再自己呼叫 inner,而是用 return 把 inner 這個函式交出去。

第二,const closureFunction = outer(); 這行跑完之後,outer 就結束了,而 closureFunction 接住的正是 inner。

所以最後那行 closureFunction(),其實就是在呼叫 inner。

執行順序變成這樣:

  1. 呼叫 outer(),outerVar 誕生
  2. outer 把 inner 交出去,outer 結束
  3. 呼叫 closureFunction(),這時才印出 outerVar

問題來了:照前面說的,outerVar 應該在第 2 步就跟著 outer 一起消失。

那第 3 步還印得出東西嗎?

印得出來,結果是 outer。

原因在於,inner 的作用域鏈在定義被寫下來的時候就決定了,而它的定義寫在 outer 裡面。

只要 inner 還活著,那條鏈上的變數就都得留著,因為 inner 隨時可能會用到。

於是 outerVar 就跟著 inner 一起被保留了下來。

這種「函式帶著它出生時的作用域四處跑」的現象,就叫閉包(closure)。

閉包不是一個要另外學的機制,它就是 Lexical Scope 加上「函式可以被當成值傳來傳去」這兩件事撞在一起的結果。

實務應用:把變數藏起來

閉包最直接的用途,是讓一個變數只能透過你指定的方式被修改。

假設你要做一個計數器:

❌ 用全域變數的寫法:

let count = 0;

function increment() {
  count++;
  return count;
}

count 攤在最外層,任何一行程式碼都可以直接寫 count = 999 把它改掉。

而且你檔案裡再也不能有第二個叫 count 的全域變數,否則兩邊會互相覆蓋。

✅ 用閉包的寫法:

function createCounter() {
  let count = 0;

  return function () {
    count++;
    return count;
  };
}

const counter = createCounter();
console.log(counter());
console.log(counter());

依序印出 1 和 2。

count 被關在 createCounter 的作用域裡,外面沒有任何辦法直接看到 count——這正是前面說的「只能往外找,不能往內找」。

唯一能動到 count 的,就是那個被回傳出來的函式,而它只做加一這件事。

另外,因為每次呼叫 createCounter() 都會產生一組全新的 count,你可以同時開好幾個互不干擾的計數器。

重點整理

  • 作用域是一塊區域,在裡面宣告的變數只有裡面的程式碼看得到
  • 找不到變數時,JavaScript 會沿著作用域鏈往外一層一層找,找到最外層還沒有才報錯
  • Lexical Scope 的意思是:這條鏈由函式定義寫在哪一層決定,跟呼叫寫在哪一層無關
  • 作用域鏈單向往外,內層看得到外層的變數,外層看不到內層的
  • let 和 const 是區塊作用域,一對大括號就形成一層;var 只認函式
  • 閉包是函式被回傳出去之後,仍然帶著它出生時那條作用域鏈的現象
  • 把變數關在作用域裡、只開放特定函式去操作它,是控制修改途徑最簡單的方法

參考資料

  • MDN Web Docs – Closures
  • MDN Web Docs – Scope
  • JavaScript.info – Variable scope, closure
目前還沒有留言,成為第一個留言的人吧!

發表留言

留言將在審核後顯示。

JavaScript

目錄

  • 作用域:一個變數能被看到的範圍
  • 作用域可以一層包一層
  • 為什麼是定義的位置,不是呼叫的位置
  • 只能往外找,不能往內找
  • 不只函式,區塊也有自己的作用域
  • 閉包:函式會把作用域一起帶走
  • 實務應用:把變數藏起來
  • 重點整理
  • 參考資料