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

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

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

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

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

  • 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')) {
  // ...
}

这种写法的优势非常明显:

  • 代码更简洁:没有类、没有静态方法、没有构造函数保护。
  • 天然单例 :任何文件导入的 flagsloaded 都是同一份。
  • 更容易测试 :测试时可以重置模块(使用 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...

相关推荐
再吃一根胡萝卜41 分钟前
08 · 前端:Vue3 + SSE 流式与执行链路可视化
面试
再吃一根胡萝卜1 小时前
03 · 后端:FastAPI 与分层架构
面试
mldong2 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排3 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!5 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰5 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
Interview Aid1125 小时前
Walmart Global Tech SDE 三轮面经|基础、并发、压力面
面试·职场和发展
mayaairi6 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js