单例模式
保证一个类只有一个实例,并提供一个访问它的全局访问点。
一、什么时候用
单例的本质是共享一份状态或一份昂贵的资源。前端典型场景:
- 全局唯一的 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';
模块单例的两个边界
- 同一个包被打包多份时会有多个实例(比如依赖树里同时存在
lodash@4和lodash@5,或 monorepo 里没有做 dedupe)。排查"单例失效"先看构建产物里该模块出现了几次。 - 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、多窗口、微前端中同一份模块被加载多次时都会出问题。
所以:优先用依赖注入把实例传进去,只在确实需要"全局唯一"时才用单例。
下一步 👉 工厂模式
