本文 AI 產出,尚未審核

JavaScript ‧ 變數與資料型別 — Symbol


簡介

在日常的 JavaScript 開發中,我們最常使用的資料型別是 NumberStringBooleanObjectArray 等等。然而,當程式規模逐漸增大、模組化需求提升時,名稱衝突(name collision)會成為一個隱蔽的痛點。
ES6(ECMAScript 2015)引入的 Symbol,正是為了解決這類問題而設計的「唯一且不可變」的原始值(primitive value),讓我們可以安全地為物件屬性建立唯一的鍵

Symbol 不僅是避免衝突的利器,也常被用於 自訂迭代行為內部實作隱藏屬性框架與函式庫的「協議」(protocol)等情境。掌握 Symbol 的使用方式,對於寫出可維護、可擴充的程式碼非常重要。


核心概念

1. Symbol 是什麼?

  • 唯一性:每一次呼叫 Symbol() 都會產生一個全新、唯一的 Symbol。即使兩個 Symbol 的描述(description)相同,它們仍然不相等。
  • 不可變:產生後的 Symbol 本身無法被改變,也無法從 Symbol 取得其內部值。
  • 原始值:與 NumberStringBoolean 同屬「原始值」類型,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...ofArray.from 等可遍歷
Symbol.asyncIterator 定義 非同步迭代器,支援 for await...of
Symbol.hasInstance 控制 instanceof 判斷的行為
Symbol.toStringTag 改寫 Object.prototype.toString.call(obj) 的結果
Symbol.toPrimitive 客製化物件轉成原始值(例如在算術運算或字串拼接時)
Symbol.isConcatSpreadable 決定 Array.prototype.concat 是否展開此物件
Symbol.matchSymbol.replaceSymbol.searchSymbol.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)。

最佳實踐總結

  1. 唯一鍵:使用 Symbol() 產生唯一鍵,避免任何字串衝突。
  2. 共用鍵:跨檔案或框架協議使用 Symbol.for,同時保留可追蹤的鍵名。
  3. 隱蔽屬性:將 Symbol 屬性設為不可列舉(enumerable: false),降低意外讀取的機會。
  4. 迭代與協定:善用內建 Symbol(如 Symbol.iteratorSymbol.toPrimitive)提升物件的可組合性與語意。
  5. 測試:在單元測試中,使用 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 RegistrySymbol.for / Symbol.keyFor)可以在不同模組間共享同一個 Symbol,適合協議或常數。
  • JavaScript 為了讓物件更具彈性,提供了多組 well‑known symbols(如 Symbol.iteratorSymbol.toPrimitive),讓開發者可以自訂迭代、型別轉換、字串表示等行為。
  • 在實務開發中,Symbol 常被用於 私有屬性、事件類型、跨模組協議、隱蔽元資料 等情境,能提升程式碼的安全性與可維護性。
  • 使用 Symbol 時要留意 方括號存取、JSON 序列化、全域污染 等常見陷阱,並配合 最佳實踐(如使用 Object.getOwnPropertySymbols、避免過度依賴 Symbol 作為唯一私有欄位)來寫出更健全的程式。

掌握 Symbol 的特性與應用,將讓你在 JavaScript 的模組化、框架設計與大型系統開發中,擁有更強大的工具箱。祝你寫程式愉快,寫出乾淨、可靠的程式碼!