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

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

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

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

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

  • 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...

相关推荐
PedroQue991 小时前
uni-router新推useUniEventChannel,彻底解决页面通信难题
前端·uni-app
IT_陈寒1 小时前
Vite冷启动快?我遇到了个奇怪的依赖问题
前端·人工智能·后端
2603_955279701 小时前
通过 brew upgrade cmake 升级到最新版本
前端
FREEDOM_X1 小时前
Linux 进程间通讯(IPC)——总结
linux·c语言·前端·嵌入式硬件·struts
帅次2 小时前
Android 高级工程师面试:网络与数据层 近1年高频追问 18 题
android·网络·okhttp·面试·retrofit·gilde
WWBQU2 小时前
前端day06
前端
岁月宁静2 小时前
DeepSeek Harness 团队 招聘 :JD反推:新手如何系统掌握AI Agent开发技术?
前端·人工智能·后端
小则又沐风a2 小时前
深入理解文件系统(二)
java·服务器·前端
sztomarch2 小时前
Disable-Download-Chrome-AI-Mode
前端·chrome