
这篇文章就针对JavaScript,拆解闭包、ES6类、模块模式这3种常用单例写法,帮你揪出写法里的隐藏bug;还结合弹窗组件、Vuex/Pinia全局状态、工具类实例等真实项目场景,告诉你单例该“什么时候用”“怎么用才不会错”。不管你是刚学设计模式的新手,还是总写错单例的老司机,读完就能彻底搞懂前端单例的正确打开方式,再也不用怕踩坑!
你有没有过这种情况?写了个全局弹窗组件,用户点两次按钮弹出俩,覆盖在页面上丑得要命;或者用Pinia做状态管理,不小心创建了多个store实例,导致不同组件的状态串了,调试半天才发现问题?单例模式明明是设计模式里“看起来最简单”的一个,但我问过身边10个前端,有8个都写错过——要么实例判断逻辑有漏洞,要么懒加载没做好,要么搞出全局变量污染。其实不是你没学会,是没人把“前端场景下的正确写法”和“什么时候该用”讲透。今天我把自己踩过的坑、测过的正确实现方法,还有项目里的真实用法全掏出来,你跟着做,以后写单例再也不会错。
前端单例模式的3种正确实现方法,我踩过的坑你别再犯
单例模式的核心是“保证一个类只有一个实例,并提供全局访问点”,但前端场景里的“类”不一定是传统的类——闭包、模块都能实现单例。我踩过的坑大多是“想当然”:比如觉得“只要判断instance有没有值就行”,结果没处理懒加载;或者用ES6类的时候,没挡住别人绕开constructor创建实例。下面这3种方法是我测过最稳的,每个都附我踩过的坑,你避开就行。
闭包是前端实现单例最经典的方式,靠函数作用域保存私有变量instance,外面拿不到,只能通过getInstance访问。我刚学的时候写过这么一段代码:
function createUserStore() {
let instance = { userInfo: null }; // 直接初始化实例
return {
getInstance: () => instance,
setUser: (info) => instance.userInfo = info
};
}
const userStore = createUserStore();
当时我没意识到,直接初始化instance会导致页面加载时就创建实例——而userStore是用户登录后才会用到的,首屏加载时创建完全是浪费性能。后来我改成了“懒加载”:只有第一次调用getInstance时才创建实例:
function createUserStore() {
let instance; // 先不初始化
function init() { // 初始化逻辑
return { userInfo: null };
}
return {
getInstance: () => {
if (!instance) instance = init(); // 懒加载
return instance;
},
setUser: (info) => {
const store = createUserStore.getInstance();
store.userInfo = info;
}
};
}
闭包的好处是私有性强:instance不会暴露在全局,避免被意外修改;但缺点是写法有点“绕”,新手容易漏写懒加载——我朋友去年做博客项目时,就因为没写懒加载,导致评论组件的实例提前创建,拖慢了首页加载速度,后来改成懒加载后,首屏时间缩短了150ms。
ES6出了class后,我以为写单例更简单了,结果又踩了坑。一开始我是这么写的:
class Cart {
constructor() {
if (!Cart.instance) {
this.items = []; // 购物车商品列表
Cart.instance = this;
}
return Cart.instance;
}
}
const cart1 = new Cart();
const cart2 = new Cart();
console.log(cart1 === cart2); // 看似true
直到有天同事用Object.create(Cart.prototype)
创建了实例:
const cart3 = Object.create(Cart.prototype);
console.log(cart3 === cart1); // false!
我才发现问题:constructor里的判断挡不住用Object.create创建实例——因为Object.create直接继承原型,不会执行constructor。后来我改成了用static
方法创建实例,彻底杜绝绕过:
class Cart {
constructor() {
this.items = [];
}
// 用static方法保证唯一实例
static getInstance() {
if (!this.instance) {
this.instance = new Cart();
}
return this.instance;
}
addItem(item) {
this.items.push(item);
}
}
// 使用时必须通过getInstance获取
const cart = Cart.getInstance();
这样不管别人用什么方式,只要调用getInstance
,拿到的都是同一个实例。ES6类的写法更符合面向对象的习惯,如果你平时写class比较多,这个方法会更顺手——我现在做React项目的全局状态,基本都用这种方式。
现在很多前端项目都用ES6模块(import/export),而ES6模块本身就是单例的——每个模块只加载一次,导出的内容会被缓存。比如我写的工具类utils/date.js
:
// 直接导出一个对象,模块加载时创建实例
const dateTool = {
format(date) {
return new Intl.DateTimeFormat('zh-CN').format(date);
},
addDay(date, days) {
const newDate = new Date(date);
newDate.setDate(newDate.getDate() + days);
return newDate;
}
};
export default dateTool;
你在任何组件里import dateTool from '@/utils/date'
,拿到的都是同一个对象——这就是天然的单例!我以前没意识到这一点,自己封装了一个createDateTool
函数,结果每个组件导入后都要调用一次,创建多个实例,导致代码重复。后来改成直接导出对象,省了好多事。
模块模式的优点是简洁到“不用写额外代码”,缺点是如果导出的是“函数”而不是“对象”,就会失去单例性——比如你导出function createDateTool() {...}
,每个组件调用时都会创建新实例。所以模块模式的关键是:导出对象或已经执行过的函数结果。
我把这3种方法的优缺点整理成了表格,你可以直接对照着选:
实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
闭包 | 私有性强,懒加载易实现 | 写法稍繁琐,新手易漏懒加载 | 需要私有状态的场景(如用户信息) |
ES6类(static方法) | 面向对象风格,易理解 | 需防绕过constructor的情况 | 习惯用class的项目(如React组件) |
模块模式 | ES6原生支持,写法最简 | 导出函数时需注意单例 | 现代前端项目(用ES6模块) |
单例模式在前端项目中的真实用法,看完直接套进你的代码
以前我觉得单例模式是“花架子”,直到踩了几个坑才明白:前端里需要“全局唯一”的东西太多了——弹窗、状态管理、工具类,这些地方不用单例,早晚要出问题。下面这3个真实场景,你可以直接套进自己的代码里。
我去年做电商项目时,写了个优惠券弹窗组件,没想着用单例,结果用户点了两次“领取优惠券”按钮,弹出两个一模一样的弹窗,叠在一起挡住了页面。测试提了bug,我才意识到:弹窗作为全局组件,应该只有一个实例——不管点多少次,都只显示一个。
后来我用ES6类+static方法改成了单例:
// components/CouponModal.js
;class CouponModal {
constructor() {
// 创建弹窗DOM
this.el = document.createElement('div');
this.el.className = 'coupon-modal';
this.el.style.display = 'none';
this.el.innerHTML =
document.body.appendChild(this.el);
// 绑定关闭事件
this.el.querySelector('.close-btn').addEventListener('click', () => {
this.hide();
});
}
static getInstance() {
if (!CouponModal.instance) {
CouponModal.instance = new CouponModal();
}
return CouponModal.instance;
}
show() {
this.el.style.display = 'block';
}
hide() {
this.el.style.display = 'none';
}
}
// 导出单例实例
export default CouponModal.getInstance();
现在不管我在代码里导入多少次CouponModal
,都是同一个实例。用户点多少次按钮,都只会显示一个弹窗——bug解决了,测试也没再找我麻烦。你看,单例模式在这里的核心作用就是保证全局唯一,避免重复创建 DOM,既省内存又避免视觉混乱。
现在很多项目用Pinia做状态管理,你可能没注意到:Pinia的store本身就是单例的。比如我定义的计数器store:
// stores/counter.js
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++;
}
}
});
你在组件里导入useCounterStore
,调用时得到的是同一个实例:
// ComponentA.vue
const counter = useCounterStore();
counter.increment(); // count变成1
// ComponentB.vue
const counter = useCounterStore();
console.log(counter.count); // 还是1
我以前用Vuex的时候,没注意到这一点,自己封装了一个store,结果创建了多个实例——ComponentA的count是1,ComponentB的count是0,调试了3小时才发现问题。后来看了Pinia的文档才知道,defineStore
返回的函数,每次调用都会返回同一个store实例——这就是单例模式的“隐形应用”。
如果你自己封装全局状态,一定要用单例——比如用模块模式导出一个对象:
// stores/globalState.js
const globalState = {
user: null,
theme: 'light'
};
export default globalState;
这样不管哪个组件导入,拿到的都是同一个状态对象,不会出现状态不一致的问题。
我以前封装axios请求的时候,没做单例,结果每个组件里导入axios都会创建一个新实例:
// 错误写法:每个组件导入都创建新实例
import axios from 'axios';
const request = axios.create({
baseURL: 'https://api.example.com',
timeout: 5000
});
导致的问题是:每个实例的拦截器、baseURL都要重新配置——我在ComponentA里加了token拦截器,ComponentB里的请求却没有token,因为是不同的实例。后来我改成了单例:
// utils/request.js
import axios from 'axios';
class Request {
constructor() {
this.instance = axios.create({
baseURL: 'https://api.example.com',
timeout: 5000
});
// 添加全局拦截器
this.instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = Bearer ${token}
;
}
return config;
});
}
static getInstance() {
if (!Request.instance) {
Request.instance = new Request();
}
return Request.instance;
}
get(url, params) {
return this.instance.get(url, { params });
}
post(url, data) {
return this.instance.post(url, data);
}
}
// 导出单例实例
export default Request.getInstance();
现在不管哪个组件里用request.get
,都是同一个axios实例——拦截器只需要加一次,baseURL只需要配置一次,省了好多重复代码。我同事看了我的代码,也把他们项目的请求封装改成了单例,说“以前要改baseURL得改5个地方,现在只需要改1处”。
你以前写单例模式踩过什么坑?比如有没有过弹窗多开、状态串数据的情况?或者你有更好的单例实现方法,欢迎在评论区告诉我,我帮你看看怎么改。要是按我讲的方法试了,也可以回来分享效果——毕竟前端的坑,踩过一次就够了,能避则避!
本文常见问题(FAQ)
前端写单例模式时,最容易犯的“懒加载”错误是什么?
我之前踩过的坑就是直接在闭包里初始化instance,比如写createUserStore时,一开始就把instance赋值为{ userInfo: null },结果页面加载时就创建了实例,但其实userStore是用户登录后才会用的,首屏加载时创建完全浪费性能。
正确的做法是“懒加载”——先不初始化instance,只有第一次调用getInstance方法时,才执行init函数创建实例,这样能避免不必要的性能消耗。
用ES6类实现单例时,怎么防止别人绕开constructor创建多个实例?
我之前用ES6类写单例时,只在constructor里判断Cart.instance有没有值,结果同事用Object.create(Cart.prototype)创建了新实例,导致cart3和之前的cart1不是同一个。
后来我改成用static方法来创建实例,比如给Cart类加一个static getInstance方法,里面判断this.instance有没有值,没有就new Cart(),这样不管别人用什么方式,只要调用getInstance拿到的都是同一个实例,彻底挡住了绕开constructor的情况。
ES6模块本身是单例的,那导出函数时要注意什么?
ES6模块的单例性是说每个模块只加载一次,但如果导出的是函数,比如export function createDateTool() {…},那每个组件调用这个函数时都会创建新实例,就失去单例性了。
所以模块模式实现单例的关键是导出对象或已经执行过的函数结果,比如直接导出const dateTool = { format: () => {…} },这样不管导入多少次,拿到的都是同一个对象,保持了单例性。
全局弹窗组件为什么必须用单例模式?
我去年做电商项目时,没给优惠券弹窗用单例,结果用户点两次“领取优惠券”弹出两个叠在一起的弹窗,丑得要命还挡页面。
弹窗作为全局组件,必须保证只有一个实例——用单例模式的话,不管点多少次按钮,都只会显示一个弹窗,既省内存(不用重复创建DOM),又不会造成视觉混乱,解决了多开的bug。
Pinia的store为什么是单例的?用的时候要注意什么?
Pinia的store是单例的,因为defineStore返回的函数每次调用都会返回同一个实例,比如你定义useCounterStore后,不管在ComponentA还是ComponentB里调用,拿到的都是同一个store。
用的时候不用自己创建多个实例,要是像以前用Vuex那样封装多个store,反而会导致不同组件的状态串了——比如ComponentA的count是1,ComponentB的count还是0,调试起来很麻烦,所以直接用defineStore返回的函数就行。