从一个"小事故"说起
不知道你有没有遇到过这样的场景:用户疯狂点击"打开新网页"按钮,结果屏幕上弹出了十几个一模一样的窗口。又或者,你在项目里维护一个全局配置对象,结果发现每个模块都 new 了一个新实例,改了这个没改那个,最后状态乱七八糟。
这些问题背后其实指向同一个根源:有些东西,整个系统只需要一个。
这就是我们今天要聊的单例模式(Singleton Pattern)。
单例模式到底是什么
先给个定义:确保一个类只有一个实例,并提供一个全局访问点。
说白了就是:这个类你 new 一百次,拿到手的还是同一个对象。它解决的是"全局唯一"的问题------全局状态管理、资源复用、配置统一,都离不开它。
单例模式属于 GoF 23 种设计模式中的创建型模式 ,跟工厂模式、建造者模式是"邻居"。但它的特殊之处在于:别的创建型模式关心的是"怎么创建",它关心的是"怎么不创建多个"。
单例模式的本质不是"只能创建一个实例",而是"提供一个全局访问点,且这个访问点背后永远只有一个实例"。
从一个弹窗管理器开始
先看一个最经典的场景:页面上有个按钮,点击打开一个新窗口。我们当然不希望每次点击都 window.open 一发不可收拾,而是希望有一个统一的"弹窗管家"来管理这个行为。
xml
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>单例模式打开窗口</title>
</head>
<body>
<button id="openBtn">打开新网页</button>
<script>
class Popup {
static ins; // 静态属性,存放单例实例
static getInstance() {
if (!Popup.ins) {
Popup.ins = new Popup();
}
return Popup.ins;
}
open(url) {
window.open(url, "_blank");
}
}
const a = Popup.getInstance(); // 单例方式获取实例,替代 new
const b = Popup.getInstance();
console.log(a === b); // true,证明是同一个实例
const openBtn = document.getElementById("openBtn");
openBtn.addEventListener("click", () => {
a.open("https://www.baidu.com");
});
</script>
</body>
</html>
这段代码的核心逻辑其实就三行:
scss
static getInstance() {
if (!Popup.ins) {
Popup.ins = new Popup();
}
return Popup.ins;
}
你会发现这就是一个惰性初始化 (Lazy Initialization):第一次调用 getInstance() 时才创建实例,之后直接返回缓存的那个。用 a === b 验证一下,确实是同一个对象。
但问题来了:这个实现有个明显的漏洞------Popup.ins 是公开的,谁都可以直接改:
ini
Popup.ins = null; // 手贱一下,单例就没了
Popup.ins = new Popup(); // 或者直接给你换个新的
对于中级开发者来说,我们得把"单例"这件事做得更可靠一些。
更可靠的单例实现
方案一:闭包 + IIFE
ini
const Popup = (function () {
let instance; // 私有变量,外部无法直接访问
class PopupClass {
open(url) {
window.open(url, "_blank");
}
}
return {
getInstance() {
if (!instance) {
instance = new PopupClass();
}
return instance;
}
};
})();
const a = Popup.getInstance();
const b = Popup.getInstance();
console.log(a === b); // true
这样 instance 被闭包保护起来,外部根本摸不到,单例的"唯一性"就有了保障。
方案二:ES6 模块天然单例
在 ES6 模块体系中,模块本身就是单例的 ------无论被多少地方 import,模块代码只执行一次,导出的对象永远是同一个。
javascript
// popup.js
class Popup {
open(url) {
window.open(url, "_blank");
}
}
export const popupInstance = new Popup();
javascript
// 任何地方使用
import { popupInstance } from './popup.js';
popupInstance.open("https://www.baidu.com");
这种方式简单粗暴,也是前端工程化中最常用的单例实现方式。你项目里的 Axios 实例、WebSocket 连接、Vuex/Pinia Store,本质上都是这么玩的。
方案三:Symbol 做私有 key
如果你想继续用 Class 的写法,但又想保护 ins 不被随意访问,可以用 Symbol:
javascript
const INS = Symbol('singleton');
class Popup {
static getInstance() {
if (!Popup[INS]) {
Popup[INS] = new Popup();
}
return Popup[INS];
}
open(url) {
window.open(url, "_blank");
}
}
Symbol 作为属性 key 不会被常规遍历发现,也不会跟其他属性冲突。不过说实话,在 JS 里"防君子不防小人",真要改还是能通过 Object.getOwnPropertySymbols 拿到。所以工程实践中,方案二(ES6 模块)才是最主流的选择。
单例模式在前端的高频落地场景
理论讲完了,来看看真实项目中单例模式都在哪里"打工"。
场景一:全局状态管理(Pinia / Vuex)
你用 Pinia 定义的 Store,本质上就是一个单例。不管在哪个组件里调用 useUserStore(),拿到的都是同一个实例,状态自然就同步了。
javascript
// stores/user.js
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
name: '',
token: ''
}),
actions: {
setUser(user) {
this.name = user.name;
this.token = user.token;
}
}
});
Pinia 内部保证了同一个 id 的 Store 只会被创建一次------这就是单例模式在框架层面的应用。
场景二:Axios 实例封装
几乎每个前端项目都会封装一个统一的 HTTP 客户端:
ini
// utils/request.js
import axios from 'axios';
const request = axios.create({
baseURL: '/api',
timeout: 10000,
});
// 请求拦截器
request.interceptors.request.use(config => {
config.headers.Authorization = localStorage.getItem('token') || '';
return config;
});
export default request;
整个项目共享这一个 Axios 实例,好处显而易见:拦截器配置一次、Token 注入一处、错误处理统一。如果每个模块都自己 axios.create 一个,那你改拦截器就要改到怀疑人生。
场景三:WebSocket 连接管理
一个页面通常只需要一条 WebSocket 连接。如果每次组件挂载都 new WebSocket(),服务端可能会因为连接数爆掉而把你拉黑。
javascript
// utils/socket.js
class SocketManager {
static instance = null;
static getInstance() {
if (!SocketManager.instance) {
SocketManager.instance = new SocketManager();
}
return SocketManager.instance;
}
connect(url) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return this.ws; // 已连接,直接复用
}
this.ws = new WebSocket(url);
return this.ws;
}
}
export default SocketManager;
单例模式的"阴暗面"
聊完了好处,也得说实话。单例模式在江湖上有个外号叫 "披着设计模式外衣的全局变量" ,它确实有一些需要警惕的地方。
问题一:隐藏的耦合
单例是全局可访问的,这意味着任何模块都可以依赖它。如果滥用,你的代码会变成一张谁也理不清的依赖网。
问题二:难以测试
因为单例是全局唯一的,在单元测试中很难"重置"它的状态。你测完一个用例,单例的脏数据可能影响下一个用例。
问题三:违反单一职责原则
单例类往往既要管自己的业务逻辑,又要管"自己只能有一个实例"这件事。这在大型项目中会让类的职责变重。
设计模式不是银弹,单例用好了是全局状态管理利器,用不好就是隐藏的全局变量灾难。
什么时候该用单例?
给你一个简单的判断标准:
- ✅ 资源本身在业务上就是唯一的:如全局配置、日志收集器、数据库连接池、WebSocket 连接
- ✅ 创建成本高且可以复用:如线程池(在 JS 中更多是连接池)、缓存管理器
- ❌ 仅仅因为"方便"就把普通对象改成单例:比如用户信息、购物车数据,这些应该交给状态管理工具(Pinia/Vuex/Redux),而不是手动实现单例
总结
这篇文章从一个弹窗管理的例子出发,聊了单例模式的核心思想、多种实现方式,以及在前端工程中的真实应用场景。来一张图帮你快速回顾:
| 实现方式 | 适用场景 | 私有性 |
|---|---|---|
| Class 静态方法 | 简单场景、教学演示 | ❌ 属性公开 |
| 闭包 + IIFE | 需要保护实例引用 | ✅ 私有变量 |
| ES6 模块导出实例 | 工程实践首选 | ✅ 天然单例 |
| Symbol 属性 | 需要 Class 写法 + 一定保护 | ⚠️ 半私有 |
核心就一句话:确保一个类只有一个实例,并提供一个全局访问点。
下次当你发现某个对象"整个项目只需要一个"的时候,别犹豫,单例模式安排上。但也要记得,模式是工具,不是信仰------适合的才是最好的。