单例模式在前端中的正确打开方式

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

一、单例模式到底在解决什么问题?

单例模式的目的很纯粹:无论代码从哪个地方获取这个对象,得到的都是同一个实例,共享同一份状态。

在前端领域,单例模式的身影随处可见:

  • 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')) {
  // 渲染新版支付流程
}

这种写法保证了:

  1. 唯一性 :无论调用多少次 getInstance(),返回的都是同一个对象。
  2. 封装性 :#flags 和 #loaded 是私有的,外部无法修改。
  3. 惰性初始化 :只有在第一次调用 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 的风险。

"只允许一个实例"的约束只是约定,而非必要。如果两个实例存在也不会造成任何问题,那你拥有的只是一个普通对象,而不是单例。

参考文档:www.patterns.dev/vanilla/sin...

相关推荐
喵个咪1 分钟前
GoWind CMS 拆解:Flutter 设计系统内核——双态 token、四档断点与自适应壳层
前端·flutter·交互设计
喵个咪25 分钟前
GoWind CMS 拆解:发布审批——多级推进、逐级角色与全程留痕
前端·后端·go
喵个咪33 分钟前
GoWind Flow 拆解:一个 Go 工作流引擎与它的双模式设计器
前端·后端·go
喵个咪1 小时前
GoWind Admin 拆解:打印模板设计器——从画布到服务端 PDF 的完整打印链路
前端·后端·go
总要学点什么1 小时前
第96篇:三维数据加载-城市建筑白模样式+着色器——让色带按体量上色,最白的那档却涂不上去
前端·gis·webgl·cesium·着色器·空间分析·白模
Hilaku2 小时前
你们公司有 35 岁以上的前端吗?
前端·javascript·程序员
徐小夕2 小时前
开源Vector-Graph-RAG:向量+图谱双引擎,打破纯向量RAG的效果天花板
前端·vue.js·github
SameX2 小时前
HarmonyOS ArkTS 把组件截图保存成 PNG 再调系统分享面板的做法
前端
Swot2 小时前
SvelteKit SSG 发布后,为什么老访客非要强刷才能看到新内容?
前端
鸿蒙开发2 小时前
小程序存档写到一半怎么办?双槽保存与损坏恢复实战
前端·javascript·react.js