单例是在应用的整个生命周期中只存在一个实例的对象。所有调用者拿到它时,得到的都是同一个实例------相同的状态、相同的方法、相同的标识。我们可以使用单例模式来管理全局状态和共享资源。

一、单例模式到底在解决什么问题?
单例模式的目的很纯粹:无论代码从哪个地方获取这个对象,得到的都是同一个实例,共享同一份状态。
在前端领域,单例模式的身影随处可见:
- Axios 请求实例:整个应用通常只需要一个配置好的 Axios 实例,统一管理 baseURL、超时、拦截器。
- 功能开关客户端:应用启动时拉取一次远程配置,然后所有组件都读取同一份开关数据。
- WebSocket 连接:一个应用通常只维护一条长连接,收到消息后分发给不同的订阅者。
- 埋点客户端:收集用户的点击、浏览行为,在合适的时机批量发送,如果存在多个实例就会竞争发送。
这些场景的共同点是:如果存在多个实例,要么浪费资源,要么行为错乱。单例模式就是为了强制保证"只有一个"。
但是,在动手写单例之前,有一个关键问题需要先想清楚:你真的需要一个"单例类"吗?还是只需要一个模块级的状态? 在现代 JavaScript 中,后者往往更简单、更安全。
二、经典写法:基于类的单例
在 ES6 之前,单例通常是通过立即执行函数和闭包来实现的。现在,我们可以利用私有静态字段 和静态方法写出更安全的版本。
下面看一个参考文档中的"功能开关客户端"例子---------应用启动时从远程加载配置,之后所有组件通过它判断功能是否开启。
javascript
// FeatureFlags.js
class FeatureFlags {
// 私有静态字段,存放唯一实例
static #instance = null;
// 私有实例字段,外部无法直接访问
#flags = new Map();
#loaded = false;
constructor() {
// 如果已经有实例,直接返回那个实例
if (FeatureFlags.#instance) {
return FeatureFlags.#instance;
}
FeatureFlags.#instance = this;
}
// 静态方法:获取实例的唯一入口
static getInstance() {
return (FeatureFlags.#instance ??= new FeatureFlags());
}
async load(url) {
if (this.#loaded) return;
const res = await fetch(url);
const data = await res.json();
for (const [key, value] of Object.entries(data)) {
this.#flags.set(key, value);
}
this.#loaded = true;
}
isEnabled(name) {
return this.#flags.get(name) === true;
}
}
export default FeatureFlags;
在组件中使用时,我们不直接 new,而是通过 getInstance() 获取:
javascript
import FeatureFlags from './FeatureFlags';
// 应用启动时加载
const flags = FeatureFlags.getInstance();
await flags.load('/config/flags.json');
// 在任意组件中判断
if (flags.isEnabled('new-checkout')) {
// 渲染新版支付流程
}
这种写法保证了:
- 唯一性 :无论调用多少次
getInstance(),返回的都是同一个对象。 - 封装性 :
#flags和#loaded是私有的,外部无法修改。 - 惰性初始化 :只有在第一次调用
getInstance()时才创建实例。
三、更优雅的现代方案:模块作用域状态
上面的写法虽然严谨,但总感觉有点"过度设计"。事实上,ES Module 本身就能天然保证单例。
ESM 规范规定,一个模块在同一个 JavaScript 运行时(realm)中,只会被执行一次 。模块顶层定义的变量,在整个应用生命周期内都是同一个引用。换句话说,模块就是天然的单例。
用模块作用域状态改写功能开关客户端:
javascript
// feature-flags.js
const flags = new Map();
let loaded = false;
export async function load(url) {
if (loaded) return;
const res = await fetch(url);
const data = await res.json();
for (const [key, value] of Object.entries(data)) {
flags.set(key, value);
}
loaded = true;
}
export function isEnabled(name) {
return flags.get(name) === true;
}
使用时直接导入函数即可,完全不需要 getInstance() 或者 new:
javascript
import { load, isEnabled } from './feature-flags';
await load('/config/flags.json');
if (isEnabled('new-checkout')) {
// ...
}
这种写法的优势非常明显:
- 代码更简洁:没有类、没有静态方法、没有构造函数保护。
- 天然单例 :任何文件导入的
flags和loaded都是同一份。 - 更容易测试 :测试时可以重置模块(使用 Vitest 的
vi.resetModules()或 Jest 的jest.resetModules())。
绝大多数前端场景下,模块作用域状态就是最佳的单例实现方式 。我们日常用到的 axios 实例、zustand 的 store、pinia 的 store,本质上都是利用模块作用域来实现单例。
四、现代前端框架提供的替代方案
在 React 和 Vue 中,框架本身提供了一些机制,可以替代传统单例。
1. React Context ------ 组件树级别的"单例"
React 的 createContext 配合 Provider,可以在组件树中共享数据,作用域是"提供者之下的所有子组件"。这种"单例"是可切换的:测试时、Storybook 中都可以传入不同的值,而无需修改业务代码。
jsx
const FeatureFlagsContext = createContext(null);
export function FeatureFlagsProvider({ client, children }) {
return (
<FeatureFlagsContext.Provider value={client}>
{children}
</FeatureFlagsContext.Provider>
);
}
export function useFeatureFlag(name) {
const client = useContext(FeatureFlagsContext);
return client.isEnabled(name);
}
2. 轻量状态管理库 ------ Zustand
Zustand 的 store 创建一个自定义 Hook,整个应用共享同一个状态。它本质上是模块作用域状态,但提供了响应式能力和良好的测试支持。
javascript
import { create } from 'zustand';
export const useSession = create((set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}));
任意组件中直接使用,无需 Provider,也无需 getInstance:
jsx
function UserProfile() {
const user = useSession(state => state.user);
// ...
}
五、总结
ESM 规范保证一个模块在每个领域中只会执行一次。所有在顶层声明的变量,本质上就是单例------这些变量的生命周期与模块记录一致。没有需要防御的构造函数,没有需要记住的 getInstance,也不会有意外调用两次 new 的风险。
"只允许一个实例"的约束只是约定,而非必要。如果两个实例存在也不会造成任何问题,那你拥有的只是一个普通对象,而不是单例。