单例模式(Singleton)
核心要点
- 定义:一个类在整个程序里只允许存在一个实例,并且对外提供一个统一的入口来拿到它。
- 关键能力:类(或者它的工厂函数)要能"记住"自己是否已经造过对象。第一次调用时创建并缓存,之后每次调用都直接把缓存的那一个交出去,调用多少次、谁来调用,拿到的都是同一个引用。
- 两种经典写法:
- 静态方法
getInstance,把实例存在类的静态属性上; - 闭包(IIFE)里放一个自由变量充当"私有的缓存",返回一个负责判断的函数。
- 现代 JS 还可以用
static #instance私有字段、在构造函数里拦截new,或者干脆利用 ES Module 只执行一次的特性。
- 静态方法
- 前端里的典型应用:Vuex / Redux 的全局 Store、插件的
install防重复安装、全局弹框、全局的请求实例 / 日志器 / 配置对象。 - Vuex 的做法:
install里用一个模块级变量记住已经装过的Vue,同一个Vue再装一次就直接return(开发环境顺便报错提示),保证一个应用只有一套 Store 注入逻辑。这道判断是Vue.use自带去重之外的第二层保险;两层都失效时,重复安装会让全局混入叠加、注入逻辑重复执行(常被简化成"Store 重新初始化、状态归零"的说法)。 - 两道常考题:基于
localStorage封装一个单例Storage(静态方法版 + 闭包版都要会写);实现一个全局唯一的 Modal 弹框(按需创建、反复复用同一个 DOM 节点)。 - 要注意的坑:实例别挂在
this上(那样每个对象各存一份,不是单例);单例是全局可变状态,会给单元测试和 SSR 带来麻烦。
单例到底解决什么问题
有些对象天然"只该有一个":
| 场景 | 如果出现多份会怎样 |
|---|---|
| 全局状态仓库(Store) | 组件 A 改了自己那份,组件 B 读的是另一份,数据对不上 |
| 本地存储的封装 | 每处 new 一个包装对象,浪费内存,配置(前缀、过期策略)也可能不一致 |
| 全局弹框 / 遮罩 | 每点一次按钮就往 body 里塞一个新节点,页面上叠了一堆 |
| 日志器、埋点 SDK、WebSocket 连接 | 重复连接、重复上报 |
单例模式给出的方案很朴素:创建对象这件事交给一个入口统一把关。入口先检查"之前有没有造过",没有才 new,有就把旧的返回。

实现方式
一个常见的错误写法
先看一个看起来像单例、其实不是的版本:
class Printer {
constructor(model) {
this.model = model;
this.cache = null; // 缓存放在了"实例"上
}
acquire(model) {
if (!this.cache) this.cache = new Printer(model);
return this.cache;
}
}
const p1 = new Printer().acquire('HP');
const p2 = new Printer().acquire('Canon');
console.log(p1 === p2); // false
问题出在 this.cache:它属于某一个具体对象,每 new Printer() 一次就多一份独立的缓存。只有恰好一直复用同一个 Printer 对象去调 acquire 时才"像"单例。而且外面还得先 new 一个对象才能拿单例,这本身就和"只要一个实例"矛盾。
正确的做法是把缓存放在所有调用方共享的位置:类本身(静态属性)或者闭包里。
写法一:静态方法
class AppConfig {
static getInstance() {
// 没造过就造一个,造过就直接用
if (!AppConfig.instance) {
AppConfig.instance = new AppConfig();
}
return AppConfig.instance;
}
describe() {
return `theme=${this.theme ?? 'light'}`;
}
}
const c1 = AppConfig.getInstance();
const c2 = AppConfig.getInstance();
c1.theme = 'dark';
console.log(c2.describe()); // theme=dark,改的是同一个对象
console.log(c1 === c2); // true
static 方法挂在类上,不需要先创建对象就能调用;AppConfig.instance 也是类级别的属性,所有调用方共享。不管调用多少次 getInstance,经过这层判断拦截后,c1、c2 指向的都是那唯一一个对象。
判断逻辑写在静态方法里还是写进构造函数里都可以,后面会给出构造函数的版本。
写法二:闭包
把缓存藏进立即执行函数的作用域,外部根本访问不到,相当于一个私有变量:
AppConfig.getInstance = (() => {
let shared; // 自由变量,只有下面这个函数能读写
return () => {
if (shared === undefined) shared = new AppConfig();
return shared;
};
})();
console.log(AppConfig.getInstance() === AppConfig.getInstance()); // true
和静态属性版相比,闭包版的好处是缓存不会被外部直接改掉(静态属性版里任何人都可以写 AppConfig.instance = null 把它重置)。
写法三:现代 JS 的写法
ES2022 起类支持私有字段,可以把缓存藏在 static # 里,并且在构造函数里拦截,让直接 new 也拿到同一个对象:
class Logger {
static #only = null;
#lines = [];
constructor() {
// 已经有实例了:返回它,替换掉这次 new 出来的新对象
if (Logger.#only) return Logger.#only;
Logger.#only = this;
}
static getInstance() {
return Logger.#only ?? new Logger();
}
log(msg) {
this.#lines.push(msg);
return this.#lines.length;
}
}
const l1 = new Logger();
const l2 = new Logger();
l1.log('boot');
console.log(l1 === l2); // true
console.log(l2.log('ready')); // 2,两条日志记在同一个对象里
console.log(Logger.getInstance() === l1); // true
这里用到一条语言规则:构造函数如果显式 return 一个对象,new 表达式的结果就是这个对象,而不是新建的 this。后面闭包版 Storage 能用 new 调用,也是同一个原理。
另外,在模块化的工程里,最省事的单例其实是 ES Module 本身:模块只会被求值一次,所有 import 拿到的是同一个导出对象。
// request.js
import axios from 'axios';
export const http = axios.create({ baseURL: '/api', timeout: 8000 });
// 任何文件 import { http } from './request' 拿到的都是同一个实例
饿汉和懒汉
| 懒汉式(lazy) | 饿汉式(eager) | |
|---|---|---|
| 创建时机 | 第一次调用 getInstance 时才创建 | 类 / 模块加载时立刻创建 |
| 优点 | 用不到就不占资源 | 写法最简单,没有判断分支 |
| 例子 | 上面的 getInstance、后面的弹框 | 模块顶层直接 export const store = createStore() |
JS 是单线程的,懒汉式不需要像 Java 那样考虑多线程下的"双重检查加锁"。不过如果创建过程是异步的(比如要先 await 建立连接),要缓存的是 Promise 而不是结果,否则两个几乎同时的调用会各自发起一次创建。
生产实践:Vuex 的 Store 为什么只有一个
Redux 和 Vuex 都把整个应用的状态集中放进一个全局 Store,这个 Store 就是单例思想在前端最典型的落地。
Store 为什么要唯一
Vuex 官方文档对这点的解释是:Vuex 采用单一状态树,一个对象装下应用的全部层级状态,作为"唯一数据源(SSOT, Single Source of Truth)"存在,所以每个应用只包含一个 store 实例。好处是任何一块状态都能直接定位到,调试时也能一次性拿到整个应用当前状态的快照。
为什么需要这样一个集中的地方?组件少的时候,父子之间用 props 和事件传数据就够了。可一旦组件很多、关系交错、嵌套又深,层层传递会让逻辑越来越难维护。更好的办法是把多个组件共享的数据抽出来放到全局,让组件按照约定好的规则去读写,状态的变化就变得可预测。Vuex 就是干这件事的,而那个存放共享数据的唯一数据源,就是 Store。
Vuex 怎么保证 Store 唯一
在 Vue 2 项目里接入 Vuex 一般是这样:
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex); // 安装插件
const store = new Vuex.Store({ state: { count: 0 } });
new Vue({
el: '#app',
store, // 注入根实例,子组件通过 this.$store 访问
});
Vue.use() 会调用插件对象上的 install 方法。Vuex 的 install 负责把 Store 的注入逻辑挂到 Vue 上(通过全局混入,在每个组件的 beforeCreate 里设置 this.$store)。换句话说,每执行一次 install,就会尝试再给 Vue 注入一次 Store。
Vuex 3 源码里的 install 长这样(节选,注释是我们加的):
let Vue // 模块级变量:记住已经装过 Vuex 的那个 Vue 构造函数
export function install (_Vue) {
// 同一个 Vue 已经装过了:开发环境报错提示,然后直接退出
if (Vue && _Vue === Vue) {
if (__DEV__) {
console.error(
'[vuex] already installed. Vue.use(Vuex) should be called only once.'
)
}
return
}
// 第一次安装:记下来,再把初始化逻辑混入 Vue 的生命周期钩子
Vue = _Vue
applyMixin(Vue)
}
模块顶部的 let Vue 扮演的正是前面 getInstance 里 instance 的角色:第一次进来时记下,之后同一个 Vue 再来就被拦在门外。于是一个 Vue 应用只会被装一次 Vuex,也就只有一个全局 Store。
补充两点:
- Vue 自己的
Vue.use也会记录装过的插件(installedPlugins),重复use同一个插件在 Vue 这一层就被挡掉了;Vuex 内部的这道判断是一层额外保险,针对的是绕过Vue.use、直接调用install的情况。- 到了 Vue 3 + Vuex 4 / Pinia,写法变成
app.use(createStore(...))/app.use(createPinia()),Store 挂在具体的app实例上(app.config.globalProperties和provide),"每个应用一个 Store"依旧成立,只是作用域从全局Vue收窄到了单个app。
如果 install 不做单例判断会怎样
先把前提说清楚:前面提到 Vue.use 自己就会去重,所以下面讨论的是两层保护都不起作用的情形——要么 Vue 层没有 installedPlugins 去重,要么代码绕过 Vue.use、直接调用了 Vuex.install。在这个前提下,假设 Vuex 也去掉了自己的判断,而项目里又一不小心装了两次:
// main.js
Vuex.install(Vue); // 首次安装
// ……业务代码往 store 里写了登录信息、购物车等数据……
// 某个后来加的模块绕过 Vue.use,又手动调了一次
Vuex.install(Vue);
这时第二次调用会把 applyMixin(Vue) 再执行一遍,真实的直接后果是全局混入被叠加:每个组件创建时,beforeCreate 里的注入逻辑(Vuex 3 里叫 vuexInit)会跑两遍、三遍……装几次就跑几次。
有人会问:Vue 2.6 起合并生命周期钩子时会做去重(dedupeHooks),重复混入不是会被合并掉吗?这个去重按函数引用比较,只能挡住"同一个函数对象被混入多次"。而 Vuex 3 的 vuexInit 是写在 applyMixin 函数体内部的函数声明,每调用一次 applyMixin 就生成一个新的函数对象,引用各不相同,去重认不出它们是"同一个钩子"。我们用 vue@2.6.14 + vuex@3.6.2 实测过:把 Vuex 自带的 applyMixin 连调两次,Vue.options.beforeCreate 里有 2 个钩子,创建一个组件时 vuexInit 执行 2 次,和 vue@2.5.17 下的结果一样。反过来,如果插件把钩子函数定义在模块顶层、每次混入的是同一个引用,在 2.6+ 里就会被去重,这时重复安装真正的隐患就只剩 install 里那些和钩子无关、只该做一次的副作用(注册全局组件、挂原型属性、发起初始化请求等)。
注意 Store 本身是用户代码 new Vuex.Store(...) 创建的,vuexInit 每次注入的都是同一个 options.store,所以 Vuex 3 里重复 install 并不会把已有状态清空,它带来的是重复执行、性能浪费,以及插件初始化逻辑里任何"只该跑一次"的副作用被放大。
常见的另一种说法是"重复安装会让 Store 重新初始化、之前写入的数据全部归零"。把它当成一种简化的、最坏情况下的概括来理解就行:如果某个插件的 install 里会自己创建全局状态容器,没有单例判断时第二次安装确实会造出一个新容器、把旧数据顶掉。无论具体表现是混入叠加还是状态被覆盖,"全局初始化只能执行一次"这个要求是一样的,这正是单例判断存在的意义。下面这张图把有守卫和没守卫两种情况放在一起对比:

用一段可运行的小代码模拟这道守卫的效果:
let installedOn = null;
let initCount = 0;
function install(App) {
if (installedOn && installedOn === App) {
console.warn('[myplugin] 已经安装过,忽略本次 use()');
return;
}
installedOn = App;
initCount++; // 真正的初始化只会走到这里一次
}
function FakeVue() {}
install(FakeVue);
install(FakeVue); // 打出警告,直接返回
console.log(initCount); // 1
所以 install 里的单例判断不是可有可无的。同样的思路在 Redux(createStore 产出的全局 store)、jQuery(全局只暴露一个 $ / jQuery 对象)等很多前端库里都能看到。
面试题一:实现一个单例的 Storage
题目:基于 localStorage 封装一个 Storage,要求它是单例,并提供 setItem(key, value) 和 getItem(key) 两个方法。
思路:判断逻辑放在静态方法里或者构造函数里都可以;面试时最好静态方法版和闭包版各写一份,展示两种思路都掌握了。
静态方法版
顺手做一点增强:存取时用 JSON 序列化,这样对象也能直接存。
class Storage {
static getInstance() {
// ??= :左边是 null/undefined 时才赋值
Storage.instance ??= new Storage();
return Storage.instance;
}
setItem(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
getItem(key) {
const raw = localStorage.getItem(key);
return raw === null ? null : JSON.parse(raw);
}
}
const cartA = Storage.getInstance();
const cartB = Storage.getInstance();
cartA.setItem('cart', { sku: 'A-1024', qty: 3 });
console.log(cartB.getItem('cart')); // { sku: 'A-1024', qty: 3 }
console.log(cartA === cartB); // true
注意:浏览器全局本来就有一个叫 Storage 的接口(localStorage 就是它的实例),在全局作用域用 class Storage 会遮蔽它。题目要求叫这个名字没问题,真实项目里建议写在模块里或者换个名字(比如 LocalCache)。
闭包版
用 ES5 的写法,把读写方法放在一个基础构造函数的原型上,再用闭包控制"只 new 一次":
// 真正干活的对象:方法挂在原型上
function StorageCore() {}
StorageCore.prototype.setItem = function (key, value) {
localStorage.setItem(key, value);
};
StorageCore.prototype.getItem = function (key) {
return localStorage.getItem(key);
};
// 对外暴露的入口:闭包里缓存唯一的 StorageCore 实例
var Storage = (function () {
var cached = null;
return function () {
if (cached === null) cached = new StorageCore();
return cached;
};
})();
var s1 = new Storage(); // 用 new 调用
var s2 = Storage(); // 直接调用,效果一样
s1.setItem('lang', 'zh-CN');
console.log(s2.getItem('lang')); // zh-CN
console.log(s1 === s2); // true
为什么 new Storage() 和 Storage() 结果一样?因为这个函数显式 return 了一个对象,new 时新建的 this 会被丢弃,表达式的值就是返回的 cached。所以加不加 new 都拿到同一个实例。
(以上两段在 Node 里用一个 Map 模拟 localStorage 跑过,输出与注释一致。)
面试题二:实现一个全局唯一的 Modal 弹框
这几乎是讲单例时必举的例子,也是单例模式在前端早期最集中的应用场景:页面上无论点多少次"打开",弹框节点都只应该创建一个。
思路:套路和前面完全一样。记住三样东西:一个缓存变量(instance)、一个负责判断的入口(getInstance 或闭包返回的函数)、静态方法或闭包二选一。另外要做到懒创建:没点过"打开"就不创建节点,避免白占内存。

闭包版
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>全局唯一弹框</title>
<style>
.login-dialog {
position: fixed;
inset: 50% auto auto 50%;
transform: translate(-50%, -50%);
width: 260px;
padding: 32px 0;
text-align: center;
background: #fff;
border: 1px solid #333;
border-radius: 8px;
}
</style>
</head>
<body>
<button id="show">登录</button>
<button id="hide">取消</button>
<script>
// 闭包里缓存唯一的弹框节点
const getDialog = (function () {
let dialog = null;
return function () {
if (!dialog) {
dialog = document.createElement('div');
dialog.className = 'login-dialog';
dialog.textContent = '请先登录(这个弹框全局只有一个)';
dialog.hidden = true;
document.body.appendChild(dialog);
}
return dialog;
};
})();
document.getElementById('show').addEventListener('click', () => {
getDialog().hidden = false; // 第一次点击时才真正创建节点
});
document.getElementById('hide').addEventListener('click', () => {
getDialog().hidden = true; // 关闭只是隐藏,节点保留以便复用
});
</script>
</body>
</html>
和 Storage 一样,这里的 getDialog 也可以写成 new getDialog(),结果相同,因为函数返回的是一个对象(DOM 节点)。
一个小细节:如果用户还没点过"登录"就先点了"取消",上面的写法会为了隐藏而创建节点。想彻底懒加载,可以单独暴露一个"只查不建"的方法,或者在关闭时先判断节点是否存在。
ES6 class 版
class LoginDialog {
static #el = null;
static getInstance() {
if (!LoginDialog.#el) {
const el = document.createElement('div');
el.className = 'login-dialog';
el.textContent = '请先登录(这个弹框全局只有一个)';
el.hidden = true;
document.body.appendChild(el);
LoginDialog.#el = el;
}
return LoginDialog.#el;
}
static open() { LoginDialog.getInstance().hidden = false; }
static close() { if (LoginDialog.#el) LoginDialog.#el.hidden = true; }
}
document.getElementById('show').addEventListener('click', LoginDialog.open);
document.getElementById('hide').addEventListener('click', LoginDialog.close);
close 里先判断节点是否存在,顺带解决了上面提到的"没打开就关闭也会创建节点"的问题。
可以看到,这类题的特点就是:核心套路记牢了,换个场景几乎可以直接默写。设计模式类的题目普遍如此,抓住每种模式的那一两个关键动作,就能举一反三。
单例的代价
单例好用,但它本质上是全局可变状态,有几件事要心里有数:
- 测试难隔离:一个用例改了单例里的数据,会影响后面的用例。常见做法是给单例留一个仅测试使用的重置方法,或者通过依赖注入把实例传进去,而不是在代码里到处直接调用
getInstance()。 - SSR 的跨请求污染:服务端渲染时 Node 进程长期存活,模块级的单例会被所有用户请求共享,A 用户的数据可能泄露给 B 用户。所以 Nuxt、Pinia 等都要求在每个请求里新建 Store,而不是导出一个模块级的 Store。
- 隐藏依赖:函数内部悄悄调用全局单例,调用方从签名上看不出它依赖了什么,重构时容易出错。
- 多个 bundle / iframe:同一份代码被打进两个包、或者在不同 iframe 里各执行一次,就会有两个"单例"。需要跨边界共享时,要把实例挂到真正共享的位置(比如
window上的某个命名空间)。
面试速答模板
单例模式是说一个类只能有一个实例,并且提供一个全局访问点。实现的关键是让创建入口记住自己有没有创建过:第一次调用时
new一个并缓存起来,以后直接返回缓存。JS 里常见两种写法:一种是静态方法getInstance,实例存在类的静态属性上;另一种是用闭包里的自由变量做缓存,外部改不到,更安全。现代写法还可以用static #instance私有字段,或者在构造函数里判断后return已有实例,让直接new也拿到同一个对象;工程里 ES Module 只执行一次,导出一个对象本身就是单例。前端最典型的应用是 Vuex / Redux 的全局 Store,Vuex 的install用一个模块级变量记住装过的 Vue,重复安装就直接返回,保证一个应用只有一套 Store 注入逻辑;它和Vue.use自带的去重是两层保护,如果绕过Vue.use直接重复调用install,没有这道判断就会让全局混入叠加、每个组件的注入逻辑重复执行。常见面试题是封装单例的localStorage工具和全局唯一的弹框,弹框要懒创建、关闭时只隐藏不销毁。最后要注意单例是全局可变状态,会给单元测试和 SSR 跨请求共享带来问题,必要时提供重置方法或按请求创建实例。
