JavaScript ES6+ 新特性:深入了解 Symbol
簡介
在 ES6 之前,JavaScript 的屬性鍵只能是字串(String),這讓物件在設計大型或可擴充的系統時,常會遭遇命名衝突的問題。Symbol(符號)正是為了解決這類衝突而誕生的全新原始資料型別。它提供了唯一且不可變的值,可作為物件屬性的鍵,讓開發者能安全地為物件加入「隱藏」或「專屬」的屬性。
Symbol 不僅是語法糖,更是建構 可插拔模組、內部實作細節隱蔽、以及 自訂迭代行為 的基礎。掌握 Symbol,等於取得了在 JavaScript 生態系中更高層次的抽象與彈性,對於從前端框架到 Node.js 套件的開發,都有實際且重要的影響。
核心概念
1. Symbol 是什麼?
- Symbol 是一種 原始資料型別(primitive),與
Number、String、Boolean、BigInt、null、undefined同屬一級。 - 每一次呼叫
Symbol()都會產生 唯一 的 Symbol,除非使用 全域註冊表(Symbol.for)取得相同的 Symbol。 - Symbol 不可被隱式轉型 為字串或數字,若需要字串表示,必須使用
String(symbol)或symbol.description。
const s1 = Symbol(); // 完全唯一
const s2 = Symbol('id'); // description 為 'id',但 s1 !== s2
const s3 = Symbol.for('id'); // 取自全域註冊表,若已存在則回傳同一個 Symbol
const s4 = Symbol.for('id'); // s3 與 s4 完全相等
console.log(s1 === s2); // false
console.log(s3 === s4); // true
2. 為何要使用 Symbol 作為屬性鍵?
| 傳統字串鍵 | Symbol 鍵 |
|---|---|
| 可能被意外覆寫或衝突 | 唯一,保證不會被外部覆寫 |
Object.keys() 會列出 |
不可列舉(除非使用 Object.getOwnPropertySymbols) |
| 無法區分「公有」與「內部」 | 可作為 私有 或 內部 屬性使用 |
3. 建立與使用 Symbol
3.1 基本用法
// 建立 Symbol 作為屬性鍵
const ID = Symbol('id');
const user = {
[ID]: 12345, // 使用 computed property name
name: 'Alice'
};
console.log(user[ID]); // 12345
3.2 隱藏屬性(模擬私有)
const _size = Symbol('size');
class Stack {
constructor() {
this[_size] = 0; // 私有變數
this.items = [];
}
push(item) {
this.items.push(item);
this[_size]++; // 只在內部可見
}
pop() {
if (this[_size] === 0) return undefined;
this[_size]--;
return this.items.pop();
}
get size() {
return this[_size];
}
}
const s = new Stack();
s.push(1); s.push(2);
console.log(s.size); // 2
console.log(s._size); // undefined (外部看不到)
3.3 為物件加入「內建」行為(利用 well‑known Symbol)
JavaScript 為某些常見操作定義了 內建 Symbol(well‑known Symbol),例如:
Symbol.iterator:讓物件可被for…of迭代Symbol.toStringTag:自訂Object.prototype.toString的結果Symbol.hasInstance:自訂instanceof行為
// 讓自訂物件支援迭代
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { done: true };
}
};
}
}
for (const n of new Range(1, 5)) {
console.log(n); // 1 2 3 4 5
}
3.4 自訂 toStringTag
class MyArray {
constructor(...elements) {
this.elements = elements;
}
get [Symbol.toStringTag]() {
return 'MyArray';
}
}
const arr = new MyArray(1, 2, 3);
console.log(Object.prototype.toString.call(arr)); // [object MyArray]
3.5 使用 Symbol 作為「常數」列舉
在大型專案中,常會把 Symbol 作為事件名稱或 Redux action type,以避免字串衝突:
// actions.js
export const ActionTypes = {
ADD_TODO: Symbol('ADD_TODO'),
REMOVE_TODO: Symbol('REMOVE_TODO')
};
// reducer.js
function todoReducer(state = [], action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
return [...state, action.payload];
case ActionTypes.REMOVE_TODO:
return state.filter(todo => todo.id !== action.payload.id);
default:
return state;
}
}
4. 取得與列舉 Symbol 屬性
Object.getOwnPropertySymbols(obj):取得物件自身的 Symbol 鍵陣列。Reflect.ownKeys(obj):同時返回字串鍵與 Symbol 鍵。for…in、Object.keys()、Object.getOwnPropertyNames()不會列出 Symbol 鍵。
const symA = Symbol('a');
const obj = { x: 1, [symA]: 2 };
console.log(Object.keys(obj)); // ['x']
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(a)]
console.log(Reflect.ownKeys(obj)); // ['x', Symbol(a)]
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
Symbol 直接轉字串會得到 Symbol() |
String(sym) 會回傳 "Symbol(description)",但 sym + '' 仍會拋錯 |
使用 sym.description 取得描述文字,或顯式 String(sym) |
| 全域註冊表的命名衝突 | Symbol.for('key') 會在全域共享,若不同模組使用相同字串可能意外共用 |
只在需要跨模組共用時使用 Symbol.for,平時建議直接 Symbol() |
| 忘記使用 computed property name | 在物件字面量中直接寫 mySymbol: value 會被解析為字串鍵 |
必須使用 [mySymbol] 包住 Symbol |
| 無法序列化 Symbol | JSON.stringify 會忽略 Symbol 鍵與 Symbol 值 |
若需要序列化,可在 toJSON 方法中自行處理,或轉成字串描述 |
使用 instanceof 時忘記實作 Symbol.hasInstance |
自訂類別想改變 instanceof 行為時,若未實作會得到預設結果 |
在類別上實作 static [Symbol.hasInstance](obj) { … } |
最佳實踐
- 作為私有屬性:在類別或模組內部使用 Symbol,讓外部程式碼無法直接存取或覆寫。
- 避免過度使用全域 Symbol:只有在需要跨模組協議(如共享事件名稱)時才使用
Symbol.for。 - 結合
Reflect.ownKeys:若要遍歷所有鍵(包含 Symbol),使用Reflect.ownKeys而非Object.keys。 - 保持描述有意義:即使 Symbol 本身是唯一的,給它一個有描述性的字串有助於除錯與日誌。
- 在工具函式庫中封裝:把常用的 Symbol 常數集中管理(例如
const INTERNAL = Symbol('internal')),提升可維護性。
實際應用場景
1. 框架內部的「隱藏」屬性
許多 UI 框架(如 Vue、React)會在虛擬 DOM 節點上掛上 Symbol 屬性,以避免使用者自行寫的屬性與框架內部衝突。
// Vue 內部可能會使用 Symbol 來標記觀測狀態
const OBSERVER = Symbol('observer');
function observe(obj) {
obj[OBSERVER] = new Observer(obj);
}
2. 自訂迭代器(如資料流、惰性序列)
在大型資料處理或串流框架中,實作 [Symbol.iterator] 可以讓自訂資料結構直接支援 for…of、Array.from 等語法,提升可讀性。
class LazyRange {
constructor(start, end, step = 1) {
this.start = start;
this.end = end;
this.step = step;
}
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; i += this.step) {
yield i;
}
}
}
3. Redux / Flux 中的 Action Type
使用 Symbol 作為 Action Type 可以保證每個 Action 都是唯一的,即使不同模組不小心使用相同字串也不會產生衝突。
// actions.js
export const INCREMENT = Symbol('INCREMENT');
export const DECREMENT = Symbol('DECREMENT');
4. 內部協議(如插件系統)
若要在插件與核心之間傳遞特殊訊號,Symbol 是理想的選擇,因為插件無法「猜測」或覆寫核心的 Symbol。
// core.js
export const PLUGIN_HOOK = Symbol('pluginHook');
export function registerPlugin(plugin) {
// plugin 必須實作 [PLUGIN_HOOK] 方法
if (typeof plugin[PLUGIN_HOOK] === 'function') {
plugin[PLUGIN_HOOK]();
}
}
5. 防止屬性被 Object.assign 複製
Object.assign 只會複製可列舉的字串鍵,Symbol 鍵預設不會被複製,這在實作「保護」屬性時相當有用。
const secret = Symbol('secret');
const src = { a: 1, [secret]: 42 };
const dst = Object.assign({}, src);
console.log(dst[secret]); // undefined
總結
- Symbol 為 ES6 引入的唯一、不可變的原始資料型別,解決了屬性命名衝突與「私有」屬性需求。
- 透過 computed property name (
[mySymbol]) 可以把 Symbol 當作物件鍵;使用 well‑known Symbol 能自訂迭代、toString、instanceof等行為。 - 常見陷阱包括 Symbol 轉字串、全域註冊表的意外共享以及 JSON 序列化的限制。遵守 最佳實踐(如適當使用
Symbol.for、集中管理 Symbol 常數)可避免這些問題。 - 在 框架設計、插件系統、Redux Action、惰性序列 等實務情境中,Symbol 為程式碼提供了更高的安全性與彈性。
掌握 Symbol 後,你將能寫出 更具模組化、可維護 的 JavaScript 程式碼,並在大型專案或開源套件中避免許多微妙的 bug。從現在開始,善用 Symbol 為你的程式設計增添「唯一」的力量吧!