很多前端第一次看到单例模式,都会觉得这东西很简单——"就是让一个类只能 new 一次嘛"。
然后就在面试里背下了 if (instance) return instance 这行代码,以为掌握了一个设计模式。
但真正的问题不是"怎么实现只 new 一次"。而是:你为什么需要只 new 一次?这个"唯一性"到底在保护什么?保护不好的时候,又会伤害什么?
这篇文章不讲面试八股。我想和你聊清楚一件事:单例模式在前端的真实生存状态——它为什么诱人,为什么危险,以及你该用什么思维框架来判断"这里该不该用单例"。
一、单例的核心:不是"只有一个",是"所有人用同一个"
先看最经典的 JS 实现:
let instance;
let counter = 0;
class Counter {
constructor() {
if (instance) {
throw new Error("You can only create one instance!");
}
instance = this;
}
getCount() {
return counter;
}
increment() {
return ++counter;
}
decrement() {
return --counter;
}
}
const singletonCounter = Object.freeze(new Counter());
export default singletonCounter;
代码不复杂。但注意两个关键词:唯一实例 + 全局访问点。
这意味着:不管你在哪个文件 import 这个模块,拿到的都是同一个对象,操作的都是同一份状态。
这就像一座城市只建一个自来水总阀门。 资源唯一,全局接入——听起来很高效。但如果所有人共用同一个开关状态,A 调了水温,B 在洗澡就被烫了。
共享「资源」和共享「状态」是两回事。单例的陷阱在于把这两者混为一谈。
二、在 JavaScript 里,单例是过度设计
这是很多人没意识到的:在 JS 中,你根本不需要用类来实现单例。
let count = 0;
const counter = {
increment() { return ++count; },
decrement() { return --count; }
};
Object.freeze(counter);
export { counter };
为什么这就够了?因为 ES Modules 有一个天然特性:每个模块只执行一次,导出的对象在所有导入方之间共享同一个引用。
也就是说——模块系统本身就是一个「单例工厂」。你写一个普通对象,export 出去,它天然就是全局唯一的。
那经典的 class + if (instance) 写法是在防什么?是在防 Java/C++ 里"必须通过类实例化"的限制。在 JS 里,这个限制本来就不存在。
用类来实现单例,是在用 Java 的解法去解一个 JS 里不存在的问题。
三、真正的危险:三颗隐形地雷
单例的诱惑在于简单。但一旦用在真实项目里,三个问题迟早会爆:
地雷 1:测试隔离失败
// 测试 A
test("increment 1 time should be 1", () => {
Counter.increment();
expect(Counter.getCount()).toBe(1); // ✅
});
// 测试 B
test("increment 3 more times should be 4", () => {
Counter.increment();
Counter.increment();
Counter.increment();
expect(Counter.getCount()).toBe(4); // ✅ 但依赖了测试 A 的残留状态!
});
问题在哪?测试 B 不是独立的——它的正确性取决于测试 A 先跑过。调换顺序、单独运行,就会失败。
单例让测试从"独立验证"退化为"按顺序执行"——你写的不再是单元测试,而是一个有顺序依赖的脚本。
地雷 2:依赖隐藏
import Counter from "./counter";
export default class SuperCounter {
increment() {
Counter.increment(); // ← 你看不到这行在修改全局状态
return (this.count += 100);
}
}
从 SuperCounter 的构造函数和方法签名上,你看不出它内部偷偷依赖了一个全局单例。别的文件调用 SuperCounter.increment() 时,会「意外」修改 Counter 的状态——而调用者完全不知情。
这就像生态系统里的入侵物种——它和所有本地物种都产生交互,却不遵守食物链规则。 一开始很方便,但很快你发现它到处留痕,而你无法局部清除。
地雷 3:数据流失控
当应用规模增大,多个模块同时读写同一个单例的状态:
• 模块 A 改了值,模块 B 读到了意外结果
• 调试时你不知道是谁、在什么时序改了状态
• 你被迫去追踪"执行顺序"而非"数据流向"
全局可变状态让你的应用从"数据驱动"退化为"时序驱动"——你不再关心数据是什么,而是焦虑它什么时候被改过。
四、一张表看清:单例 vs 替代方案
| 维度 | 裸单例 | Redux/Zustand | 依赖注入 |
|---|---|---|---|
| 全局访问 | ✅ 直接 import | ✅ 通过 store | ✅ 通过 container |
| 状态可变性 | 任何人随时改 | 只能通过 action/dispatch | 注入时决定 |
| 依赖可见性 | ❌ 隐藏 | ⚠️ 半显式 | ✅ 构造函数声明 |
| 测试隔离 | ❌ 困难 | ⚠️ 需要 mock store | ✅ 每次注入新实例 |
| 状态溯源 | ❌ 不可追踪 | ✅ DevTools 时间旅行 | ✅ 明确生命周期 |
| 适合场景 | 真正不可变的配置 | UI 状态管理 | 服务层/业务逻辑 |
五、判断框架:这里该不该用单例?
每次你想用单例的时候,问自己三个问题:
问题 1:这个东西会变吗?
如果是不可变配置(API base URL、feature flags 的初始快照),用 Object.freeze + 模块导出就够了——这甚至不算设计模式,只是合理利用模块系统。
如果状态会变——别用裸单例,用状态管理工具。
问题 2:谁有权改它?
如果"所有人都能改",这不是单例的问题,是架构缺失了写入约束。Redux 的 reducer 就是一种写入约束——所有修改必须通过「法定程序」。
单例像央行——全球唯一,所有人通过它结算。但如果没有"法定程序"约束修改,那就不是央行,是无主金库。
问题 3:生命周期归谁管?
这是最容易被忽视的问题。单例的生命周期是"应用级"——从加载到卸载,它一直活着。但你的业务状态真的需要这么长的生命周期吗?
| 需求 | 正确的生命周期 | 方案 |
|---|---|---|
| 全局配置 | 应用级 | 模块导出 + freeze |
| 用户登录态 | 会话级 | Context / Store |
| 表单状态 | 组件级 | useState / useReducer |
| API Client | 请求级或应用级 | DI 容器按需创建 |
很多"单例需求"其实是生命周期没对齐——你把一个应该跟着组件死的状态,强行绑到了应用级全局上。
六、真正值得带走的思维转变
单例模式的学习价值不在于"学会了以后多用"。恰恰相反——
它教你的是:共享不等于全局,全局不等于方便,方便不等于安全。
每当你想用 export default 暴露一个可变对象的时候,停下来问:
• 这个共享需求,到底是生命周期边界问题(应该限定在某个层级内),还是数据流设计问题(应该有明确的读写协议)?
• 如果是前者,把它放到正确的 scope 里
• 如果是后者,给它加上写入约束(reducer、proxy、event bus)
单例不是一个"好不好"的问题,而是一个"你有没有想清楚替代方案"的问题。
如果你只想带走一句话,我建议记这个:
"只有一个实例"不是设计目标,"清晰的依赖边界 + 可控的修改路径"才是。
参考原文:
• Lyddia Hallie — Singleton Pattern
• Eyal Halpern Shalev — The Singleton Pattern Is a Refactoring Nightmare