本文 AI 產出,尚未審核

JavaScript:變數與資料型別 ── 區塊作用域 vs 函式作用域


簡介

在 JavaScript 中,**變數的可見範圍(scope)**直接影響程式的可讀性、維護性與執行結果。早期的 JavaScript 只支援「函式作用域」(function scope),而 ES6 之後引入了「區塊作用域」(block scope),讓開發者可以更精確地控制變數的生命週期。掌握兩者的差異,不僅能避免常見的 ReferenceError意外覆寫,也能寫出更安全、可預測的程式碼。

本篇文章將以 淺顯易懂 的方式說明 區塊作用域函式作用域 的概念、語法差異與實務應用,並提供多個實用範例、常見陷阱與最佳實踐,幫助初學者快速上手,同時讓中階開發者重新檢視自己的程式風格。


核心概念

1. 什麼是作用域?

作用域指的是 變數、函式或類別等識別字在程式碼中可被存取的範圍

  • 全域作用域:在整個執行環境 (如瀏覽器的 window 或 Node.js 的 global) 都可見。
  • 函式作用域:在函式內部宣告的變數,只能在該函式本身與其內部的巢狀函式中存取。
  • 區塊作用域:在 {} 大括號所形成的程式區塊(如 if、for、while、switch)內部宣告的變數,只能在該區塊內使用。

2. varletconst 的差異

關鍵字 作用域 重新賦值 重新宣告 Hoisting 行為
var 函式作用域(或全域) ✅(同一作用域) 變數提升(初始化為 undefined
let 區塊作用域 ❌(同區塊內會拋錯) 變數提升但在 TDZ(Temporal Dead Zone)期間不可存取
const 區塊作用域 ❌(只能指向同一記憶體) let

重點:在大多數情況下,建議使用 letconst,除非真的需要 var 的函式作用域特性。

3. 函式作用域的行為

function foo() {
  var a = 1;          // a 只在 foo 內可見
  if (true) {
    var a = 2;        // 同一個 a 被重新賦值,會影響外層
    console.log(a);  // 2
  }
  console.log(a);    // 2,因為 var 沒有區塊作用域
}
foo();
  • var提升(hoist)到函式最上方,且 不受 block(如 if)限制。
  • 這意味著在同一函式內,同名變數只能有一個實體,容易產生意外覆寫。

4. 區塊作用域的行為

function bar() {
  let b = 1;          // b 只在 bar 內可見
  if (true) {
    let b = 2;        // 這裡是全新變數,與外層 b 無關
    console.log(b);  // 2
  }
  console.log(b);    // 1,外層 b 沒被改動
}
bar();
  • let / const 僅在 最近的 {} 大括號 內有效。
  • 若在同一區塊內重複宣告會拋出 SyntaxError
  • 變數在宣告前的 Temporal Dead Zone 期間(從區塊開始到宣告行)會拋出 ReferenceError,避免了「使用未初始化變數」的情況。

5. for 迴圈中的作用域差異

// 使用 var
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log('var i =', i), 0);
}
// 輸出: var i = 3, var i = 3, var i = 3

// 使用 let
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log('let j =', j), 0);
}
// 輸出: let j = 0, let j = 1, let j = 2
  • var 的迴圈變數在迴圈結束後仍指向同一記憶體位置,所有閉包會抓到 最後的值
  • let 為每一次迭代 建立獨立的區塊作用域,閉包會正確捕獲當時的值。

程式碼範例

範例 1:var 與函式作用域的意外覆寫

function calculateTotal(price) {
  var tax = 0.1;          // 稅率
  if (price > 100) {
    var tax = 0.2;        // 想要給大額商品不同稅率,但其實覆寫了上面的 tax
  }
  return price * (1 + tax);
}
console.log(calculateTotal(80));   // 88   (0.1 稅率)
console.log(calculateTotal(200));  // 240  (0.2 稅率,正確)

說明:此例在小額商品時 tax 仍被 if 區塊的 var tax 覆寫,雖然結果看似正確,但若在 if 內部再做其他運算,可能會出現不可預期的行為。改用 let 可避免此問題。

範例 2:let 的區塊作用域保護變數

function getDiscount(price) {
  let discount = 0;               // 預設無折扣
  if (price >= 500) {
    let discount = 0.15;          // 只在此區塊內有效
    console.log('區塊內折扣 =', discount);
  }
  console.log('函式層級折扣 =', discount);
  return price * (1 - discount);
}
getDiscount(600);
// 輸出:
// 區塊內折扣 = 0.15
// 函式層級折扣 = 0

說明:外層 discount 沒被內層的 let 改寫,保持原始值,避免了意外的全域副作用。

範例 3:const 用於不可變的參考

function freezeConfig() {
  const CONFIG = {
    apiUrl: 'https://api.example.com',
    timeout: 5000
  };
  // CONFIG = {};               // ❌ 重新指派會拋錯
  CONFIG.timeout = 8000;        // ✅ 仍可修改屬性值(物件本身是可變的)

  // 若想徹底凍結,可使用 Object.freeze
  Object.freeze(CONFIG);
  // CONFIG.timeout = 3000;    // ❌ 在嚴格模式下會拋 TypeError
  return CONFIG;
}
console.log(freezeConfig());

說明const 只保證變數指向的記憶體位址不變,若要讓物件內容真正不可變,需配合 Object.freeze

範例 4:for…let 與閉包的正確寫法

function createHandlers() {
  const handlers = [];
  for (let index = 0; index < 5; index++) {
    handlers.push(() => console.log('handler', index));
  }
  return handlers;
}
const hs = createHandlers();
hs[0](); // handler 0
hs[4](); // handler 4

說明:每一次迭代都產生獨立的 index,所以閉包捕獲的值正確。若改成 var,所有 handler 都會印出 5

範例 5:TDZ(Temporal Dead Zone)示範

{
  // console.log(a); // ❌ ReferenceError: Cannot access 'a' before initialization
  let a = 10;
  console.log(a);    // 10
}

說明:在 let/const 宣告之前,變數處於 TDZ,存取會拋錯,這是 ES6 為了避免「使用未定義變數」所設計的安全機制。


常見陷阱與最佳實踐

陷阱 說明 解決方式
使用 var 造成變數提升 var 會在函式最上方被提升,可能導致變數在宣告前就被使用,得到 undefined 盡量改用 let/const;若必須使用 var,務必把宣告寫在最上方。
迴圈內的閉包抓到錯誤的值 var 迭代變數在所有閉包中共享同一記憶體。 使用 let,或在 var 迴圈內建立 IIFE ((function(i){...})(i))。
同一區塊內重複宣告 let/const 會拋出 SyntaxError,但在大型程式中不易立即發現。 使用 Linter(如 ESLint)檢查「no-redeclare」規則。
const 仍可修改物件屬性 初學者誤以為 const 讓物件不可變。 若需要真正的不可變,結合 Object.freezeObject.seal 或 immutable library。
TDZ 忽略導致 ReferenceError 在變數宣告前使用 let/const,會拋錯。 把所有使用位置搬到宣告之後,或改用 var(不建議)。

最佳實踐

  1. 預設使用 const,除非需要重新賦值才改用 let
  2. 避免在同一作用域混用 varletconst,保持風格一致。
  3. 啟用 Linter(ESLint)與 strict mode ("use strict";) 以捕捉潛在錯誤。
  4. 在迴圈或條件式中,盡可能使用 區塊作用域,讓變數生命週期最小化。
  5. 對於需要共享變數的情況,明確使用 函式作用域(如 IIFE)或 module pattern,避免全域污染。

實際應用場景

1. 表單驗證模組

在表單驗證時,我們常會在 for 迴圈裡產生多個驗證函式。使用 let 可以保證每個驗證器捕獲正確的欄位名稱:

function buildValidators(fields) {
  const validators = {};
  for (let i = 0; i < fields.length; i++) {
    const name = fields[i];
    validators[name] = value => {
      if (!value) return `${name} 必填`;
      return null;
    };
  }
  return validators;
}

2. 事件委派(Event Delegation)

在大型單頁應用(SPA)中,我們常把事件掛在父容器上,並在回呼中根據 event.target 判斷。若在迴圈內建立多個回呼,let 能避免捕獲錯誤的索引:

const menuItems = document.querySelectorAll('.menu > li');
menuItems.forEach((item, idx) => {
  item.addEventListener('click', () => {
    console.log('點擊第', idx, '個選項');
  });
});

3. 服務端渲染(SSR)或模組化開發

在 Node.js 中,每個檔案本身就是一個 模組作用域,但仍需注意 區塊作用域 以避免變數外洩:

// utils.js
function sum(arr) {
  let total = 0;          // 只在 sum 函式內部可見
  for (const n of arr) total += n;
  return total;
}
module.exports = { sum };

總結

  • 函式作用域var)是 JavaScript 最早的作用域機制,會把變數提升到函式最上方,且不受區塊限制,容易產生意外覆寫。
  • 區塊作用域letconst)則以最近的 {} 為界限,提供更細緻的生命週期控制,並引入 TDZ 以防止未初始化存取。
  • 在實務開發中,優先使用 constlet,僅在必須兼容舊版瀏覽器或特殊需求時才考慮 var
  • 正確掌握作用域差異,可避免常見的 ReferenceError意外變數覆寫,提升程式的可維護性與安全性。

透過本文的概念說明與範例示範,希望你在撰寫 JavaScript 程式時,能更自信地選擇適當的變數宣告方式,寫出 乾淨、可預測 的程式碼。祝開發順利!