“这里该不该用单例?”—— 一个值得收藏的前端决策框架

0 阅读6分钟

很多前端第一次看到单例模式,都会觉得这东西很简单——"就是让一个类只能 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

qrcode_for_gh_6a9e7f3719d6_344.jpg