javaScript設計模式 : Singleton (單例模式)



單例不是萬靈藥,但用對地方很省事。多數時候,我們只是需要「有且只有一份」:設定、快取、事件匯流排、昂貴連線。問題在於現代前後端環境太多元:多分頁、iframe、Worker、Cluster、SSR、HMR……「唯一」很容易失真。

這篇從「什麼該做成單例、什麼不該」開始,示範四款常用寫法(模組單例、Class + getInstance、惰性/異步單例、鍵控單例),再逐一處理測試隔離、路徑快取、資源釋放與重連、導入路徑一致性等細節。讀完你會知道:什麼時候該收手改用工廠/DI,什麼時候放心用單例,才不會把好工具變成技術債。希望本篇文章能幫助到需要的您。


目錄

{tocify} $title={目錄} 


Singleton 是什麼?為什麼需要它?

Singleton(單例)的核心概念是:

在整個應用生命週期中,某個類別(或模組)只會存在「唯一實例」。


典型用途:

    設定(Config):全站共用的只讀設定。

    紀錄器(Logger):統一收集與輸出日誌。

    資源連線:如資料庫連線(Node.js)、WebSocket 客戶端、IndexedDB 連線(瀏覽器)。

    全域事件匯流排(Event Bus)或 快取(Cache)。


好處:

    狀態集中:避免四處 new 出多個物件,導致難以同步。

    節省資源:昂貴資源只建立一次(連線、緩存)。

    接口一致:全域統一入口,易於治理與監控。


風險:

    隱藏耦合:任何地方都能取到它,測試/重構變困難。

    狀態污染:若物件可變,容易被不同模組互相影響。

    跨環境多實例:SSR、iframe、Worker、HMR 都可能讓「唯一」變成「多個」。


關鍵心法:把 Singleton 當作「組件化的服務」。需要時才初始化(lazy),並盡量 只讀 或 明確管理可變狀態。


JavaScript/Node.js 與 Singleton 的語言特性

在 JS 世界裡,模組(module)本身就很像天然 Singleton:

ESM(import/export)只會評估一次,同一路徑重複匯入共享同一份模組實例(import cache)。

Node.js 的 require 也有快取機制;同一絕對路徑只會載入一次(require.cache)。


但也有例外:

不同打包檔、多重副本的套件、微前端、SSR 的每請求隔離、HMR(熱更新)、iframe/Worker 都可能讓你意外拿到多個實例。

跨分頁/跨 Tab:每個瀏覽器分頁是不同執行環境,天然就「不是單例」。


五種常見 Singleton 實作方式(含優缺點)

1.    ES Module 直接導出單例(最簡潔)

// logger.js(ESM)
class Logger {
  logs = [];
  info(msg) { this.logs.push({ level: 'info', msg, t: Date.now() }); console.info(msg); }
  error(msg) { this.logs.push({ level: 'error', msg, t: Date.now() }); console.error(msg); }
}
export const logger = new Logger(); // 模組只評估一次

// 任意檔案
import { logger } from './logger.js';
logger.info('App started');


優點:語法最簡單,ESM 快取幫你維持單例。

缺點:無法延遲初始化(一載入就 new),不易參數化,多 bundle/SSR 場景需額外小心。


2.    閉包(IIFE)+ 惰性初始化(Lazy)(通用純函式風格)

// config.js
export const getConfig = (() => {
  /** @type {object | null} */
  let instance = null;

  return (overrides) => {
    if (!instance) {
      const base = { apiBase: '/api', retry: 3, featureFlags: {} };
      instance = Object.freeze({ ...base, ...overrides });
    }
    return instance;
  };
})();

// 使用時可第一次注入參數,之後忽略或檢查一致性
import { getConfig } from './config.js';
const cfg = getConfig({ apiBase: 'https://example.com/api' });


優點:可延遲、可注入參數(第一次),之後固定。

缺點:需要自訂「覆蓋策略」;若多處同時第一次呼叫,可能有競態(需鎖)。


3.    Class + 靜態 getInstance() + 私有建構(OOP 愛用)

// db.js(Node.js 或瀏覽器)
export class DB {
  static #instance;
  #connected = false;
  #conn = null;

  constructor() {
    if (DB.#instance) throw new Error('Use DB.getInstance() instead of new.');
  }

  static getInstance() {
    if (!DB.#instance) DB.#instance = new DB();
    return DB.#instance;
  }

  async connect(dsn) {
    if (this.#connected) return this.#conn;
    // 模擬昂貴連線
    await new Promise(r => setTimeout(r, 100));
    this.#conn = { dsn, openedAt: Date.now() };
    this.#connected = true;
    return this.#conn;
  }
}

import { DB } from './db.js';
const db = DB.getInstance();
await db.connect('postgres://user:pass@host/db');


優點:介面清晰,能封裝生命週期。

缺點:若誤用 new DB() 會破功(上面有防守);跨 bundle/SSR 還是要注意。


4.    異步單例(Async Singleton):避免「雙重初始化競態」

當初始化需要等待(如讀取檔案、打 API、開連線),要保證同時多處呼叫只會做一次。

// async-singleton.js
export function createAsyncSingleton(factory) {
  /** @type {Promise<any> | null} */
  let creating = null;
  /** @type {any | null} */
  let instance = null;

  return async () => {
    if (instance) return instance;
    if (!creating) {
      creating = (async () => {
        const value = await factory();
        instance = value;
        creating = null;
        return value;
      })();
    }
    return creating;
  };
}

// 使用:例如建立 IndexedDB 或 WebSocket
import { createAsyncSingleton } from './async-singleton.js';

export const getWS = createAsyncSingleton(async () => {
  const ws = new WebSocket('wss://example.com/ws');
  await new Promise((resolve, reject) => {
    ws.addEventListener('open', resolve, { once: true });
    ws.addEventListener('error', reject, { once: true });
  });
  return ws;
});

// 任何地方
const ws = await getWS(); // 同時多處呼叫只會初始化一次


關鍵:用「共享的建立中 Promise」當鎖,杜絕重複初始化。


5.    鍵控單例(Keyed Singleton):多租戶/多環境場景

有時你需要「每個 key 一個單例」(例如每個租戶、每個語系、每個 featureScope)。

// keyed-singleton.js
export function createKeyedSingleton(factory) {
  const pool = new Map(); // key -> instance or Promise

  return (key) => {
    if (pool.has(key)) return pool.get(key);
    const value = factory(key);
    pool.set(key, value);
    return value;
  };
}

import { createKeyedSingleton } from './keyed-singleton.js';

export const getClient = createKeyedSingleton((tenantId) => {
  return Object.freeze({
    tenantId,
    fetch(path) { return fetch(`/t/${tenantId}${path}`); }
  });
});

const a = getClient('A'); // A 客戶專用
const b = getClient('B'); // B 客戶專用


在不同執行環境的落地策略

A. 瀏覽器(單頁、HMR、跨分頁)

同一分頁:ESM 模組快取通常能保證單例。

HMR(Vite/Webpack Dev Server):模組可能被替換重載,請加上「重入防守」:

        可以把實例掛在 globalThis,並檢查是否已存在。

跨分頁/多 Tab:天然不是單例。若需要「邏輯上的單例」,可用:

        BroadcastChannel 協調事件;

        localStorage + storage 事件同步;

        SharedWorker 共享後台執行緒(同源限制)。

// global-singleton.js(瀏覽器)
const KEY = '__app_logger__';
if (!globalThis[KEY]) {
  globalThis[KEY] = { logs: [], info: (m)=>console.info(m) };
}
export const logger = globalThis[KEY];

小心:把東西掛在 globalThis 只是開發期或微前端的務實做法,正式環境仍建議透過應用殼層管理依賴注入。


B. Node.js(多進程、多執行緒、微服務)

同進程:require/ESM 快取可靠。

Cluster / 多進程:每個進程都有自己的記憶體,會有多個實例;若要全域唯一,需改為 IPC 或外部服務(如 Redis、DB)協調。

Worker Threads:每個執行緒各自一份模組快取,同理需要跨執行緒通訊。


C. SSR(Next.js、Nuxt 等)

每個請求都應獨立(避免使用者 A 的狀態污染使用者 B)。

建議:把「與使用者或請求綁定」的東西(像 session、req-scoped cache)放進請求容器,不要放全域單例。

只有「真正全域且與使用者無關」的(如靜態設定、資料庫連線池)才放全域。

// next.js pseudo
// lib/db.ts
import { DB } from './db';
export const db = DB.getInstance(); // 連線池可全域

// app/route.js(每請求)
export async function GET(req) {
  const userScoped = new Map(); // 每請求自己的暫存
  // ...
}


常見錯誤與雷點

1.    把可變狀態丟進單例且沒有界線

    後果:不可預期的互相汙染。

    解方:盡量不變(Object.freeze);必要可變時,集中透過方法操作、加事件與審計。

2.    異步初始化的競態(同時多個地方首次呼叫,建立了多次)

    解方:使用「共享 Promise」的 Async Singleton(見上)。

3.    在測試中殘留全域狀態

    後果:測試互相影響、無法平行。

    解方:提供 resetForTest()、或在測試 runner 中重設模組快取;更佳做法是**依賴注入(DI)**把單例換成替身。

// 可選:測試用重設
export function __resetForTest__() {
  DB.#instance = null;
}


4.    SSR 污染(把使用者態放單例、跨請求共享)

    解方:請求作用域(request-scoped)資料不要用全域單例。


5.    多 bundle / 微前端 / 重複安裝套件導致多份實例

    解方:由宿主殼層提供依賴(DI 容器或 globalThis keyed object),各子應用向外拿;或統一 import map。


6.    HMR 重載導致重複初始化

    解方:HMR 守衛(globalThis 檢查)、或利用 HMR API 清理舊實例的資源(關閉連線/移除監聽)。


7.    跨 iframe / Worker 天然多實例卻以為是單例

    解方:用 postMessage/BroadcastChannel/MessageChannel 協調;或抽象成「服務代理」。


8.    把 Singleton 當作 Service Locator(到處從單例取依賴,耦合加劇)

    解方:明確的依賴注入(建構子/工廠傳入),讓模組更容易測試與替換。


9.    未處理資源釋放(事件監聽、計時器、連線)

    解方:提供 dispose(),並用 AbortController 管理生命週期。

// 事件監聽的釋放模式
class Bus {
  #ac = new AbortController();
  on(el, type, fn) { el.addEventListener(type, fn, { signal: this.#ac.signal }); }
  dispose() { this.#ac.abort(); }
}


10.    動態 import 與路徑不一致造成重複評估

    解方:避免字串拼接路徑導致不同 URL 被視為不同模組;統一路徑或集中載入處理。


實戰:四個常見單例服務

A.     Logger(模組單例 + 冪等初始化)

// logger.js
const KEY = '__app_logger_v1__';
if (!globalThis[KEY]) {
  const logs = [];
  globalThis[KEY] = Object.freeze({
    get size() { return logs.length; },
    info(msg) { logs.push({ level: 'info', msg, t: Date.now() }); console.info(msg); },
    error(msg) { logs.push({ level: 'error', msg, t: Date.now() }); console.error(msg); },
    toJSON() { return logs.slice(); }
  });
}
export const logger = globalThis[KEY];


B.     API Client(惰性 + 不變設定)

// api.js
export const getApi = (() => {
  let inst = null;
  return (base = '/api') => {
    if (inst) return inst;
    inst = Object.freeze({
      base,
      get(path) { return fetch(base + path).then(r => r.json()); },
      post(path, body) {
        return fetch(base + path, { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
      }
    });
    return inst;
  };
})();


C.     WebSocket(異步單例 + 自動重連簡版)

// ws.js
import { createAsyncSingleton } from './async-singleton.js';

function connect(url) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(url);
    ws.addEventListener('open', () => resolve(ws), { once: true });
    ws.addEventListener('error', reject, { once: true });
  });
}

export const getWS = createAsyncSingleton(async () => {
  let ws = await connect('wss://example.com/ws');
  // 可加上心跳/重連策略
  return ws;
});


D.     IndexedDB(瀏覽器)/ DB(Node)連線(異步、唯一)

// idb.js
import { createAsyncSingleton } from './async-singleton.js';

function openDB(name) {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(name, 1);
    req.onupgradeneeded = () => req.result.createObjectStore('kv');
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export const getDB = createAsyncSingleton(() => openDB('app-db'));


與測試(Jest/Vitest)共存

測試彼此隔離,避免互相污染:

    善用 工廠 + DI,在測試中注入假的單例或替代品。

    需要時,暴露「測試專用重設」API,或使用測試框架提供的 module reset。

// example.spec.js(Vitest)
import { vi, beforeEach, it, expect } from 'vitest';
import { getApi } from './api.js';

beforeEach(() => {
  vi.resetModules(); // 讓模組快取清空,避免單例殘留
});

it('should request to base api', async () => {
  global.fetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ ok: true }) });
  const api = getApi('https://mock/api');
  await api.get('/ping');
  expect(fetch).toHaveBeenCalledWith('https://mock/api/ping');
});


何時不要用 Singleton?替代方案

1.    與使用者或請求綁定的狀態(例如購物車、Session、A/B 分流):

→ 使用 工廠(Factory) 產生「每個請求/使用者一份」。

2.    需要高度可測試與替換的依賴:

→ 使用 依賴注入(DI) 或將實作掛在外層容器,由呼叫者決定傳入哪個版本。

3.    臨時性工具(純函數更好):

→ 直接用純函式,避免共享狀態。


問題集

Q1:ESM 天然是單例,那我還需要自己寫 Singleton 嗎?

A:大多數簡單情況不需要。但當你需要 延遲初始化、參數化、異步建立、重入防守、HMR/SSR 適配 時,自訂 Singleton 更可靠。

Q2:如何在微前端確保只有一份?

A:由宿主應用統一提供依賴(例如將服務掛在 window.AppServices),子應用讀取同一個引用;或使用 import map 保證只載入一份依賴。

Q3:跨分頁要共享連線可以嗎?

A:WebSocket/IndexedDB 無法自然跨分頁共享實例。通常改為 每分頁一條,或以 BroadcastChannel 做消息轉發;或考慮 SharedWorker。

Q4:單例一定是反模式嗎?

A:不是。錯誤使用才是問題。把它當作一個「受控的應用服務」並配合 DI/測試策略,就很實用。


完整避雷清單

⛔ 在模組載入時就做昂貴初始化 → ✅ 改為 Lazy/Async Singleton。

⛔ 未防競態,導致初始化多次 → ✅ 「共享 Promise」鎖。

⛔ 可變狀態到處改 → ✅ 盡量 不變/唯讀,必要可變集中管理。

⛔ SSR 把使用者態放全域 → ✅ 使用 請求作用域 容器。

⛔ HMR 重載後資源洩漏 → ✅ dispose() 與 globalThis 守衛。

⛔ 多 bundle/副本 → ✅ 由宿主統一提供依賴或 import map 鎖版本。

⛔ 測試互相污染 → ✅ vi.resetModules()/jest.resetModules() 或提供 __resetForTest__()。

⛔ 動態 import 路徑不一致 → ✅ 統一載入路徑。

⛔ 濫用單例當 Service Locator → ✅ 明確 DI。

⛔ 忘記釋放事件/計時器 → ✅ AbortController 管理生命週期。


結語

Singleton 在 JS/Node 世界並不是洪水猛獸,它其實是「節流 + 治理 的方法」。

當你用 延遲初始化、共享 Promise 鎖、不可變資料、DI 與測試策略 把關,就能享受單例帶來的整潔與效率,同時避開大多數坑。

如果你打算把本篇做成團隊規範:建議把「實作模板(Async/Keyed Singleton)」和「檢查表」直接收錄到工程範本或 README,長期會省下很多 Debug 時間。


延伸閱讀推薦:

javaScript設計模式 : Factory Method   (工廠方法)

javaScript設計模式 : Abstract Factory(抽象工廠)

javaScript設計模式 : Builder(建造者模式)

javaScript設計模式 : Prototype(原型)

javaScript設計模式 : Adapter(轉接器模式)

javaScript設計模式 : Bridge( 橋接模式 )

javaScript設計模式 : Composite(組合模式)

 javaScript設計模式 : Decorator(裝飾者)

javaScript設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)

張貼留言 (0)
較新的 較舊