JavaScript ‧ 變數與資料型別 — Symbol
簡介
在日常的 JavaScript 開發中,我們最常使用的資料型別是 Number、String、Boolean、Object、Array 等等。然而,當程式規模逐漸增大、模組化需求提升時,名稱衝突(name collision)會成為一個隱蔽的痛點。
ES6(ECMAScript 2015)引入的 Symbol,正是為了解決這類問題而設計的「唯一且不可變」的原始值(primitive value),讓我們可以安全地為物件屬性建立唯一的鍵。
Symbol 不僅是避免衝突的利器,也常被用於 自訂迭代行為、內部實作隱藏屬性、框架與函式庫的「協議」(protocol)等情境。掌握 Symbol 的使用方式,對於寫出可維護、可擴充的程式碼非常重要。
核心概念
1. Symbol 是什麼?
- 唯一性:每一次呼叫
Symbol()都會產生一個全新、唯一的 Symbol。即使兩個 Symbol 的描述(description)相同,它們仍然不相等。 - 不可變:產生後的 Symbol 本身無法被改變,也無法從 Symbol 取得其內部值。
- 原始值:與
Number、String、Boolean同屬「原始值」類型,typeof Symbol()回傳"symbol"。
const s1 = Symbol(); // 沒有描述
const s2 = Symbol('id'); // 有描述(僅供除錯使用)
const s3 = Symbol('id');
console.log(s1 === s2); // false
console.log(s2 === s3); // false,描述相同但仍不相等
console.log(typeof s1); // "symbol"
註:描述(description)僅在除錯或
String()轉換時顯示,不影響 Symbol 的唯一性。
2. 為什麼要用 Symbol 作為屬性鍵?
在物件上使用字串作為鍵時,若不慎與其他程式碼使用相同的字串,就會產生 屬性覆寫 或 意外讀取 的問題。Symbol 的唯一性可以避免這類衝突:
const USER_ID = Symbol('userId');
const user = {
name: 'Alice',
[USER_ID]: 12345 // 使用 Symbol 作為鍵
};
const anotherUser = {
name: 'Bob',
userId: 99999 // 一般字串鍵,可能與其他程式衝突
};
console.log(user[USER_ID]); // 12345
console.log(user.userId); // undefined,避免了衝突
3. Symbol 的全域註冊表(Global Symbol Registry)
有時候我們希望在不同模組或檔案之間共享同一個 Symbol,這時可以使用 Symbol.for(key) 與 Symbol.keyFor(symbol):
// moduleA.js
export const GLOBAL_ID = Symbol.for('globalId');
// moduleB.js
import { GLOBAL_ID } from './moduleA.js';
const obj = {
[GLOBAL_ID]: 'shared value'
};
console.log(obj[Symbol.for('globalId')]); // "shared value"
console.log(Symbol.keyFor(GLOBAL_ID)); // "globalId"
Symbol.for('key'):若全域註冊表已有對應的 Symbol,直接回傳;否則建立新 Symbol 並註冊。Symbol.keyFor(symbol):回傳 Symbol 在全域註冊表中的鍵(key),若不是全域 Symbol 則回傳undefined。
注意:全域 Symbol 仍然保持唯一性,只是可以在不同檔案間取得同一個實例,適合「協議」或「共享常數」的情境。
4. Symbol 內建的幾個「隱藏」屬性
JavaScript 為了讓物件支援迭代、型別判斷等功能,定義了一組內建 Symbol(常稱為 well‑known symbols):
| Symbol | 用途 |
|---|---|
Symbol.iterator |
定義物件的 預設迭代器,讓 for...of、Array.from 等可遍歷 |
Symbol.asyncIterator |
定義 非同步迭代器,支援 for await...of |
Symbol.hasInstance |
控制 instanceof 判斷的行為 |
Symbol.toStringTag |
改寫 Object.prototype.toString.call(obj) 的結果 |
Symbol.toPrimitive |
客製化物件轉成原始值(例如在算術運算或字串拼接時) |
Symbol.isConcatSpreadable |
決定 Array.prototype.concat 是否展開此物件 |
Symbol.match、Symbol.replace、Symbol.search、Symbol.split |
為正規表達式提供自訂行為 |
以下示範最常用的 Symbol.iterator:
const range = {
from: 1,
to: 5,
// 實作可迭代協議
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { done: true };
}
};
}
};
for (const num of range) {
console.log(num); // 1 2 3 4 5
}
5. Symbol 與 JSON 序列化
JSON.stringify 預設會 忽略 Symbol 屬性,且 Symbol 本身也無法被直接序列化。這讓 Symbol 成為「隱蔽」資料的好選擇:
const secret = Symbol('secret');
const obj = {
name: 'Charlie',
[secret]: 'my password'
};
console.log(JSON.stringify(obj)); // {"name":"Charlie"},secret 被自動排除
若真的需要保留 Symbol,必須自行實作 toJSON 或使用自訂 replacer。
程式碼範例
以下提供 5 個實用範例,涵蓋 Symbol 的不同面向,並在每段加入說明註解。
範例 1️⃣:利用 Symbol 建立「私有屬性」的簡易封裝
const _balance = Symbol('balance'); // 私有屬性鍵
class BankAccount {
constructor(owner, initial) {
this.owner = owner;
this[_balance] = initial; // 只在類別內部使用 Symbol
}
deposit(amount) {
if (amount > 0) this[_balance] += amount;
}
withdraw(amount) {
if (amount > 0 && amount <= this[_balance]) this[_balance] -= amount;
}
getBalance() {
return this[_balance];
}
}
const acct = new BankAccount('Tom', 1000);
acct.deposit(500);
console.log(acct.getBalance()); // 1500
console.log(acct._balance); // undefined,外部無法直接存取
重點:使用 Symbol 作為屬性鍵,可在外部「看不見」該屬性,實作簡易的私有欄位。
範例 2️⃣:全域 Symbol 作為跨模組的共用常數
// constants.js
export const EVENT_CLICK = Symbol.for('event:click');
export const EVENT_SUBMIT = Symbol.for('event:submit');
// emitter.js
import { EVENT_CLICK, EVENT_SUBMIT } from './constants.js';
export const emitter = {
listeners: new Map(),
on(event, fn) {
if (!this.listeners.has(event)) this.listeners.set(event, []);
this.listeners.get(event).push(fn);
},
emit(event, payload) {
(this.listeners.get(event) || []).forEach(fn => fn(payload));
}
};
// app.js
import { emitter } from './emitter.js';
import { EVENT_CLICK } from './constants.js';
emitter.on(EVENT_CLICK, data => console.log('Clicked:', data));
emitter.emit(EVENT_CLICK, { x: 120, y: 80 });
說明:使用
Symbol.for確保EVENT_CLICK在所有檔案中指向同一個 Symbol,避免字串衝突。
範例 3️⃣:自訂物件的迭代行為(Symbol.iterator)
function createMatrix(rows, cols, initial = 0) {
const data = Array.from({ length: rows }, () => Array(cols).fill(initial));
return {
rows,
cols,
get(r, c) { return data[r][c]; },
set(r, c, v) { data[r][c] = v; },
// 讓 matrix 可以被 for...of 逐行遍歷
[Symbol.iterator]() {
let r = 0;
return {
next: () => {
if (r < rows) {
return { value: data[r++], done: false };
}
return { done: true };
}
};
}
};
}
const matrix = createMatrix(3, 3, 1);
for (const row of matrix) {
console.log(row); // [1,1,1] 三次
}
實務意義:自訂迭代器讓資料結構能自然融入 JavaScript 的迭代語法,提升可讀性與可組合性。
範例 4️⃣:使用 Symbol.toStringTag 改寫 Object.prototype.toString
class MySet {
constructor(...items) {
this.items = new Set(items);
}
// 改寫內建的 toStringTag
get [Symbol.toStringTag]() {
return 'MyCustomSet';
}
}
const s = new MySet(1, 2, 3);
console.log(Object.prototype.toString.call(s)); // "[object MyCustomSet]"
技巧:透過
Symbol.toStringTag,可以讓自訂類別在除錯或日誌中呈現更友善的名稱。
範例 5️⃣:Symbol.toPrimitive 客製化型別轉換
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
// 定義如何轉成原始值
[Symbol.toPrimitive](hint) {
if (hint === 'string') {
return `${this.celsius}°C`;
}
// hint 為 'number' 或 'default' 時回傳華氏溫度
return this.celsius * 9/5 + 32;
}
}
const t = new Temperature(25);
console.log(`現在溫度是 ${t}`); // "現在溫度是 25°C"
console.log(t + 0); // 77 (自動轉成華氏)
應用:在需要同時支援字串與數值語境的物件(例如貨幣、度量單位)時,
Symbol.toPrimitive能提供直觀且安全的行為。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的最佳實踐 |
|---|---|---|
| 忘記使用方括號 | obj[mySymbol] 必須使用方括號,obj.mySymbol 會被解讀為字串鍵。 |
永遠 用方括號存取 Symbol 屬性。 |
| 將 Symbol 用作普通字串 | String(mySymbol) 會得到 "Symbol(description)",但仍然是字串,容易混淆。 |
若需要顯示描述,使用 mySymbol.description(ES2022)或 String(),但不要把它當作唯一鍵。 |
| 全域 Symbol 濫用 | 盲目使用 Symbol.for 會造成全域命名空間污染,失去唯一性的好處。 |
僅在跨模組協議或共享常數時使用 Symbol.for,其他情況直接 Symbol()。 |
| JSON 序列化時遺失資料 | JSON.stringify 會忽略 Symbol 屬性,導致資料遺失。 |
若必須序列化,實作 toJSON 方法或使用 replacer 手動加入 Symbol 資料。 |
| 過度依賴 Symbol 作為私有屬性 | Symbol 仍可透過 Object.getOwnPropertySymbols 取得,不能視為真正的私有。 |
為了更嚴格的封裝,可結合 WeakMap 或 #私有欄位(class private fields)。 |
最佳實踐總結:
- 唯一鍵:使用
Symbol()產生唯一鍵,避免任何字串衝突。 - 共用鍵:跨檔案或框架協議使用
Symbol.for,同時保留可追蹤的鍵名。 - 隱蔽屬性:將 Symbol 屬性設為不可列舉(
enumerable: false),降低意外讀取的機會。 - 迭代與協定:善用內建 Symbol(如
Symbol.iterator、Symbol.toPrimitive)提升物件的可組合性與語意。 - 測試:在單元測試中,使用
Object.getOwnPropertySymbols驗證 Symbol 屬性是否正確設定。
實際應用場景
1. 框架設計:事件系統的唯一事件類型
許多 UI 框架(如 Vue、React)會使用 Symbol 來表示內部事件或狀態,以防止使用者自行觸發或覆寫。例如:
const INTERNAL_UPDATE = Symbol('internal:update');
class Store {
constructor() {
this.state = {};
this.listeners = new Map();
}
// 只允許內部使用 INTERNAL_UPDATE 觸發
_dispatch(event, payload) {
if (event === INTERNAL_UPDATE) {
// 處理更新...
}
}
}
2. 資料模型:隱藏的元資料(metadata)
在大型資料模型中,我們常需要在物件上保存一些僅供系統內部使用的資訊(如驗證狀態、快取標記),使用 Symbol 可避免這些資訊被外部程式碼誤用:
const _validation = Symbol('validation');
function validate(user) {
const ok = /* ... */;
user[_validation] = ok;
return ok;
}
3. 多執行緒 / Web Worker 通訊協定
當主執行緒與 Worker 之間傳遞訊息時,使用 Symbol 作為訊息類型的鍵,可以確保訊息不會因為字串衝突而被誤判:
// worker.js
self.onmessage = e => {
const { type, payload } = e.data;
if (type === Symbol.for('worker:ping')) {
self.postMessage({ type: Symbol.for('worker:pong'), payload: null });
}
};
4. 自訂集合(Set / Map)結構
若要實作一個 只接受唯一值 的集合,使用 Symbol 作為內部標記,可避免使用者自行插入相同的字串鍵:
class UniqueSet {
constructor() {
this._store = new Map();
}
add(value) {
const key = typeof value === 'object' && value !== null ? Symbol() : value;
this._store.set(key, value);
}
has(value) {
// 簡化示例:實務上需要更完整的映射
return [...this._store.values()].includes(value);
}
}
總結
- Symbol 是 ES6 引入的「唯一且不可變」的原始值,專門用來避免屬性名稱衝突與實作隱蔽屬性。
- 透過 全域 Symbol Registry(
Symbol.for/Symbol.keyFor)可以在不同模組間共享同一個 Symbol,適合協議或常數。 - JavaScript 為了讓物件更具彈性,提供了多組 well‑known symbols(如
Symbol.iterator、Symbol.toPrimitive),讓開發者可以自訂迭代、型別轉換、字串表示等行為。 - 在實務開發中,Symbol 常被用於 私有屬性、事件類型、跨模組協議、隱蔽元資料 等情境,能提升程式碼的安全性與可維護性。
- 使用 Symbol 時要留意 方括號存取、JSON 序列化、全域污染 等常見陷阱,並配合 最佳實踐(如使用
Object.getOwnPropertySymbols、避免過度依賴 Symbol 作為唯一私有欄位)來寫出更健全的程式。
掌握 Symbol 的特性與應用,將讓你在 JavaScript 的模組化、框架設計與大型系統開發中,擁有更強大的工具箱。祝你寫程式愉快,寫出乾淨、可靠的程式碼!