單例不是萬靈藥,但用對地方很省事。多數時候,我們只是需要「有且只有一份」:設定、快取、事件匯流排、昂貴連線。問題在於現代前後端環境太多元:多分頁、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設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
