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

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

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

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

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

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

相关推荐
黄敬峰3 分钟前
一文讲透 JWT 登录鉴权:token 的「颁发 → 存储 → 携带 → 校验」完整闭环
面试
tech_zjf32 分钟前
当 AI 把 Next.js Route 越写越快:我为什么做了 next-route-kit
前端·后端
常宇佳35 分钟前
vue3 @代指src路径设置
前端·typescript·vue
LayZhangStrive1 小时前
融360 一面
java·面试·后端开发
砚凝霜1 小时前
软考网络工程师|案例分析:Eth‑Trunk 链路聚合、iStack 堆叠、CSS 集群核心考点总结
前端·css·网络
二级小助手1 小时前
二级Web前端选择题高频真题20道与考场避坑笔记
javascript·css3·html5·web前端·计算机二级·二级web·web真题
珐恩AI-人工智能1 小时前
大模型意图召回偏差分析:GEO如何解决“有收录却不触发问答曝光”的难题
大数据·前端·人工智能·html·流量运营·geo优化
城管不管1 小时前
重生——第十次面试之开源中国一面挂
java·linux·开发语言·算法·面试·职场和发展·开源
程序员老赵2 小时前
Docker 部署禅道 ZenTao:轻松搭建研发项目管理平台
前端·后端·github
前端小卡拉2 小时前
用了大半年 AI 编程工具,我才搞懂 Skill 到底是什么
前端·javascript