前端知库

单例模式在前端怎样实现?ES 模块天然是单例吗?

单例模式保证一个类只有一个实例,并提供全局访问点,常见于全局弹窗、请求客户端、store。可以用闭包或静态属性缓存实例,惰性创建。ES 模块在同一个模块图里只执行一次,导出的对象天然被共享,但多个打包副本、微前端或测试隔离时可能出现多份。

参考答案

简答

页面上的登录弹窗,不管点多少次“登录”按钮,都应该只有一个,不能每点一次就新建一个叠在上面。保证某个东西全局只有一份、大家用的是同一个,这就是单例模式。

实现思路是把创建好的实例缓存起来。第一次调用时创建并保存,之后再调用直接返回保存的那个。常见写法有两种:一种是用闭包保存实例,返回一个 getInstance 函数;另一种是在 class 上用静态属性保存。创建推迟到第一次使用时才发生,叫惰性单例,弹窗这类不一定用到的东西适合这样做。

在现代前端里,更常见的单例其实是 ES 模块。一个模块不管被 import 多少次,在同一个页面的模块图里只会执行一次,所以 export const store = createStore() 导出的就是所有地方共享的同一个对象,不需要再写单例模式。

要注意“只有一份”的前提。打包出了两份同一个库、微前端的多个子应用各自加载、或者服务端渲染时多个请求共用一个模块,都可能让“单例”变成多份或者被不该共享的人共享。服务端渲染时,模块级的单例会被所有用户的请求共用,存用户数据会串号。

两种实现

JavaScript
function createSingleton(factory) {
  let instance;
  return function getInstance() {
    if (instance === undefined) instance = factory();
    return instance;
  };
}

let created = 0;
const getDialog = createSingleton(() => {
  created += 1;
  return { open: false };
});

console.log(getDialog() === getDialog()); // true
console.log(created); // 1

class ApiClient {
  static #instance;
  static getInstance() {
    return (ApiClient.#instance ??= new ApiClient());
  }
}
console.log(ApiClient.getInstance() === ApiClient.getInstance()); // true

createSingleton 被调用了两次 getDialog(),工厂函数却只执行了一次,created 是 1。因为 instance 保存在闭包里,第一次调用后就有值了,第二次直接返回。工厂函数作为参数传进来,同一个 createSingleton 可以把任何东西变成惰性单例,创建逻辑和“只创建一次”的逻辑分开了。

ApiClient 用私有静态字段 #instance 保存实例,外部改不了它。不过外部仍然可以直接 new ApiClient() 绕过去,JS 没有私有构造函数,只能靠约定或者在构造函数里检查。

为什么说 ES 模块就是单例

JavaScript
// store.js
export const store = { count: 0 };

// a.js
import { store } from './store.js';
store.count += 1;

// b.js
import { store } from './store.js';
console.log(store.count); // a.js 先执行时是 1

a.js 和 b.js 拿到的是同一个 store,因为浏览器按 URL 缓存模块,同一个模块只执行一次。Pinia、Vuex 的 store 实例,axios 的默认实例,都是靠模块导出在全局共享的。

常见追问

单例有什么缺点?

它相当于全局变量,任何地方都能读写,依赖关系藏在代码里,测试时上一个用例留下的状态会影响下一个。所以要提供重置方法,或者改成把实例作为参数传进去,测试时可以换成假的。

SSR 里为什么不能用模块级单例存状态?

服务端进程会处理很多用户的请求,模块只加载一次,模块级的对象就被所有请求共用,一个用户的数据可能出现在另一个用户的页面上。所以 Pinia、Vue 在 SSR 下要求每个请求创建新的 app 和 store。