单例模式

保证一个类只有一个实例,并提供一个访问它的全局访问点。

一、什么时候用

单例的本质是共享一份状态或一份昂贵的资源。前端典型场景:

  • 全局唯一的 UI:登录浮层、Toast 容器、全局 Loading、右键菜单
  • 全局状态与配置:Vuex / Pinia store、Redux store、主题配置
  • 昂贵资源的复用:WebSocket 连接、IndexedDB 连接、线程池、Canvas 离屏画布

二、最简单的单例:ES Module

JS 里 90% 的单例需求,用一个模块就解决了——ES Module 只会被求值一次,多次 import 拿到的是同一个模块实例。

// logger.js
class Logger {
    constructor() { this.logs = []; }
    log(msg) { this.logs.push(msg); console.log(msg); }
}
export default new Logger();   // 模块级实例,天然单例
// a.js 和 b.js 里 import 到的是同一个对象
import logger from './logger.js';

模块单例的两个边界

  1. 同一个包被打包多份时会有多个实例(比如依赖树里同时存在 lodash@4 和 lodash@5,或 monorepo 里没有做 dedupe)。排查"单例失效"先看构建产物里该模块出现了几次。
  2. SSR 环境下模块单例是跨请求共享的。在 Node 服务端把用户态数据挂到模块单例上,会造成用户间数据串号,这是服务端渲染最经典的事故之一。Nuxt/Next 里 store 必须每个请求新建一份,正是这个原因。

三、惰性单例

"惰性"指在第一次真正需要时才创建,而不是模块加载时就创建。把"管理单例"和"创建对象"两件事分开,才符合单一职责:

// 通用的惰性单例包装器:只负责"只执行一次"
function getSingle(fn) {
    let result;
    let called = false;          // 用标志位,而不是 result || (...)
    return function (...args) {
        if (!called) {
            called = true;
            result = fn.apply(this, args);
        }
        return result;
    };
}

常见实现里的坑

网上流传的写法是:

return function () { return result || (result = fn.apply(this, arguments)); };

当 fn 返回 falsy 值(0、''、null、undefined、false)时,result 永远为假,每次调用都会重新执行 fn,单例失效。必须用独立的标志位判断。

用它实现"全局唯一登录浮层":

const createLoginLayer = function () {
    const div = document.createElement('div');
    div.innerHTML = '我是浮窗';
    div.style.display = 'none';
    document.body.appendChild(div);
    return div;
};

const createSingleLoginLayer = getSingle(createLoginLayer);

document.getElementById('loginBtn').onclick = function () {
    const loginLayer = createSingleLoginLayer();   // 无论点多少次,DOM 只创建一次
    loginLayer.style.display = 'block';
};

getSingle 的价值在于它与业务无关,可以复用在任何"只执行一次"的场景:

const initSDK   = getSingle(() => new PaymentSDK(config));
const getSocket = getSingle(() => new WebSocket(url));

四、类的单例实现

需要在类上表达单例语义时:

class Config {
    static #instance: Config | null = null;    // #私有静态字段,外部无法篡改
    private data = new Map<string, unknown>();

    private constructor() {}                   // 私有构造函数,禁止 new

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

    get(key: string) { return this.data.get(key); }
    set(key: string, value: unknown) { this.data.set(key, value); return this; }
}

Config.getInstance().set('theme', 'dark');
// new Config();  // ❌ TS 编译期报错:构造函数是私有的

也可以用 Proxy 拦截 new,让普通的类不用改代码就变成单例:

function singleton(Ctor) {
    let instance;
    return new Proxy(Ctor, {
        construct(target, args) {
            if (!instance) instance = Reflect.construct(target, args);
            return instance;
        }
    });
}

class Modal { constructor(title) { this.title = title; } }
const SingleModal = singleton(Modal);
new SingleModal('a') === new SingleModal('b');   // true(第二次的参数被忽略)

五、JS 不需要"双重检查锁"

Java 的单例要写 synchronized + volatile 的双重检查锁定(DCL),是因为多线程可能同时进入创建分支。

JS 主线程是单线程的,if (!instance) instance = new X() 之间不会被打断,因此不存在这个问题。 面试里被问到 DCL 时,正确回答是说明这个差异,而不是照搬 Java 写法。

但异步创建的单例确实存在竞态:

// ❌ 并发调用时会创建多个连接
let conn;
async function getConn() {
    if (!conn) conn = await createConnection();   // await 期间控制权交出,第二次调用时 conn 仍是 undefined
    return conn;
}

// ✅ 缓存 Promise 而不是缓存结果
let connPromise;
function getConn() {
    if (!connPromise) {
        connPromise = createConnection().catch(err => {
            connPromise = undefined;   // 失败要清空,否则永远拿到一个 rejected 的 Promise
            throw err;
        });
    }
    return connPromise;
}

缓存 Promise 是这个模式的关键:所有并发调用者拿到同一个 pending 的 Promise,底层只执行一次。请求去重(防止同一接口被并发打多次)用的也是这个技巧。

六、单例的代价

单例本质上是"受控的全局变量"

  • 隐式依赖:函数签名看不出它用了哪个单例,调用者难以察觉;
  • 难以测试:单例持有的状态跨测试用例泄漏,需要额外的 reset 钩子;
  • 并发/多实例场景失效:SSR、多窗口、微前端中同一份模块被加载多次时都会出问题。

所以:优先用依赖注入把实例传进去,只在确实需要"全局唯一"时才用单例。


下一步 👉 工厂模式

上次更新:
贡献者: Joe