JavaScript:變數與資料型別 ── 區塊作用域 vs 函式作用域
簡介
在 JavaScript 中,**變數的可見範圍(scope)**直接影響程式的可讀性、維護性與執行結果。早期的 JavaScript 只支援「函式作用域」(function scope),而 ES6 之後引入了「區塊作用域」(block scope),讓開發者可以更精確地控制變數的生命週期。掌握兩者的差異,不僅能避免常見的 ReferenceError 或 意外覆寫,也能寫出更安全、可預測的程式碼。
本篇文章將以 淺顯易懂 的方式說明 區塊作用域 與 函式作用域 的概念、語法差異與實務應用,並提供多個實用範例、常見陷阱與最佳實踐,幫助初學者快速上手,同時讓中階開發者重新檢視自己的程式風格。
核心概念
1. 什麼是作用域?
作用域指的是 變數、函式或類別等識別字在程式碼中可被存取的範圍。
- 全域作用域:在整個執行環境 (如瀏覽器的
window或 Node.js 的global) 都可見。 - 函式作用域:在函式內部宣告的變數,只能在該函式本身與其內部的巢狀函式中存取。
- 區塊作用域:在
{}大括號所形成的程式區塊(如if、for、while、switch)內部宣告的變數,只能在該區塊內使用。
2. var、let、const 的差異
| 關鍵字 | 作用域 | 重新賦值 | 重新宣告 | Hoisting 行為 |
|---|---|---|---|---|
var |
函式作用域(或全域) | ✅ | ✅(同一作用域) | 變數提升(初始化為 undefined) |
let |
區塊作用域 | ✅ | ❌(同區塊內會拋錯) | 變數提升但在 TDZ(Temporal Dead Zone)期間不可存取 |
const |
區塊作用域 | ❌(只能指向同一記憶體) | ❌ | 同 let |
重點:在大多數情況下,建議使用
let或const,除非真的需要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.freeze、Object.seal 或 immutable library。 |
| TDZ 忽略導致 ReferenceError | 在變數宣告前使用 let/const,會拋錯。 |
把所有使用位置搬到宣告之後,或改用 var(不建議)。 |
最佳實踐
- 預設使用
const,除非需要重新賦值才改用let。 - 避免在同一作用域混用
var、let、const,保持風格一致。 - 啟用 Linter(ESLint)與 strict mode (
"use strict";) 以捕捉潛在錯誤。 - 在迴圈或條件式中,盡可能使用 區塊作用域,讓變數生命週期最小化。
- 對於需要共享變數的情況,明確使用 函式作用域(如 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 最早的作用域機制,會把變數提升到函式最上方,且不受區塊限制,容易產生意外覆寫。 - 區塊作用域(
let、const)則以最近的{}為界限,提供更細緻的生命週期控制,並引入 TDZ 以防止未初始化存取。 - 在實務開發中,優先使用
const、let,僅在必須兼容舊版瀏覽器或特殊需求時才考慮var。 - 正確掌握作用域差異,可避免常見的 ReferenceError、意外變數覆寫,提升程式的可維護性與安全性。
透過本文的概念說明與範例示範,希望你在撰寫 JavaScript 程式時,能更自信地選擇適當的變數宣告方式,寫出 乾淨、可預測 的程式碼。祝開發順利!