
JavaScript单例模式怎么写?3种常用方法+实操细节
单例模式的实现思路其实就一句话:用“封装”把实例藏起来,只暴露一个“获取实例”的入口。但在JavaScript里,不同场景有不同的实现方式,我 了3种最常用的——闭包经典版、ES6模块化版、懒加载类版,每个都给你讲清楚“怎么写”“为什么这么写”,以及我踩过的坑。
闭包是JavaScript里的“老工具”了,用来实现单例特别适合——用外层函数保存实例,内层函数负责创建/返回实例,因为闭包会保留外层函数的作用域,实例不会被垃圾回收。
我去年帮同事改一个电商项目的“加入购物车提示弹框”时,就用了这个方法。他之前的代码是“点击一次创建一个弹框”,结果用户点5次,页面上飘5个弹框。我帮他改成闭包单例后,不管点多少次,都只有一个弹框实例:
// 封装弹框的单例函数
function createCartToast() {
let instance; // 保存实例的变量,存在外层函数作用域里
return function(content) {
if (!instance) { // 第一次调用时,创建实例
instance = document.createElement('div');
instance.className = 'cart-toast';
instance.style.cssText = 'position: fixed; bottom: 50px; left: 50%; transform: translateX(-50%); padding: 10px 20px; background: #333; color: #fff; border-radius: 4px;';
document.body.appendChild(instance);
}
// 不管有没有实例,都更新内容并显示
instance.textContent = content;
instance.style.display = 'block';
// 3秒后隐藏
setTimeout(() => {
instance.style.display = 'none';
}, 3000);
return instance;
}
}
// 获取弹框的入口函数
const showCartToast = createCartToast();
// 调用:点一次显示一次,但只有一个实例
showCartToast('已加入购物车');
showCartToast('库存不足');
这里的关键是外层函数createCartToast
里的instance
变量——它被内层函数“记住”了,就算外层函数执行完,instance
也不会被销毁。每次调用showCartToast
,都会先检查instance
是否存在:没有就创建,有就直接用。
我当时改完,同事拍着大腿说:“原来闭包还能这么用!之前我一直以为闭包只能用来做私有变量,没想到单例也靠它。”不过要注意:闭包单例要手动管理实例——如果需要“重置实例”(比如用户退出登录后清空弹框内容),得加个reset
方法,手动把instance
设为null
,不然下次调用还是旧实例。
如果你的项目用了ES6模块化(比如Vue/React项目),实现单例更简单——模块本身就是单例的!因为ES6模块的加载是“静态的”,一旦模块被加载,导出的内容就固定了,不管导入多少次,都是同一个对象。
我现在做React项目时,常用这种方法写全局工具类。比如写一个api.js
,封装Axios请求:
// api.js(ES6模块)
import axios from 'axios';
// 创建Axios实例(单例)
const apiInstance = axios.create({
baseURL: 'https://api.example.com',
timeout: 5000
});
// 添加请求拦截器(全局只执行一次)
apiInstance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = Bearer ${token}
;
}
return config;
});
// 导出单例实例
export default apiInstance;
然后在组件里导入:
// Home.js
import api from './api.js';
// 不管导入多少次,api都是同一个Axios实例
async function fetchData() {
const res = await api.get('/home');
console.log(res.data);
}
这种方法的优点是简洁到“不用额外写逻辑”——模块帮你管好了实例。但要注意:模块是“按需加载”的,如果你的项目用了代码分割(Code Splitting),要确保模块只被加载一次,不然可能会创建多个实例(不过一般项目里不会有这问题)。
如果喜欢用“类”来组织代码,可以用“懒加载”的方式实现单例——在类的构造函数里检查实例,不存在就创建,存在就返回已有实例。
我之前写一个“全局状态管理器”时,用了这种方法:
class GlobalState {
constructor() {
// 关键:检查有没有已存在的实例
if (GlobalState.instance) {
return GlobalState.instance; // 直接返回已有实例
}
// 第一次创建时,初始化状态
this.state = {
userInfo: null,
theme: 'light'
};
// 保存实例到类的静态属性里
GlobalState.instance = this;
}
// 修改状态的方法
setState(key, value) {
this.state[key] = value;
// 触发状态更新(比如通知组件重新渲染)
console.log('状态更新:', this.state);
}
}
// 使用:不管new多少次,都是同一个实例
const state1 = new GlobalState();
const state2 = new GlobalState();
console.log(state1 === state2); // true
state1.setState('theme', 'dark');
console.log(state2.state.theme); // dark(共享状态)
这种写法的好处是符合面向对象的思维,但要注意:如果子类继承这个类,可能会破坏单例——比如子类的构造函数没检查父类的实例,就会创建新的实例。所以如果用类实现单例,最好在父类里加一个“禁止继承”的逻辑(比如用Object.freeze
冻结类)。
3种方法对比:选对工具做对事
为了帮你快速选到适合的方法,我整理了一个对比表格——都是我实际项目里用过的经验,直接拿去用:
实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
闭包封装 | 兼容性好(支持IE8+);手动控制实例 | 需要写额外逻辑;易忘“重置实例” | 旧项目、需要兼容低版本浏览器 |
ES6模块化 | 简洁;自动管理实例;支持Tree Shaking | 依赖ES6模块系统;不适合旧项目 | 现代前端项目(Vue/React/Angular) |
懒加载类 | 面向对象;逻辑清晰 | 继承时易破坏单例;代码稍繁琐 | 需要用类组织代码的场景(如状态管理器) |
单例模式到底用在哪?4个前端高频场景+避坑技巧
写会了单例模式,你可能会问:“我知道怎么写,但什么时候该用?”其实前端项目里,只要需要“唯一实例”的场景,都能用到单例。我 了4个最常用的场景,每个都附带我踩过的坑——帮你少走弯路。
弹框是单例模式的“经典用武之地”——不管是提示弹框、确认弹框还是加载弹框,都应该只有一个实例。我之前做一个教育类项目时,写了个“课程报名确认弹框”,一开始没用到单例,结果用户点“确认报名”3次,页面上出现3个弹框,用户直接懵了:“我到底报名成功没?”
后来改成单例后,代码是这样的(用ES6模块化):
// ConfirmModal.js
import React from 'react';
import ReactDOM from 'react-dom/client';
// 单例弹框组件
class ConfirmModal extends React.Component {
render() {
const { visible, content, onConfirm } = this.props;
if (!visible) return null;
return (
{content}
);
}
}
// 保存弹框的根节点和实例
let modalRoot;
let modalInstance;
// 显示弹框的方法(单例入口)
export function showConfirmModal(options) {
if (!modalRoot) {
modalRoot = document.createElement('div');
document.body.appendChild(modalRoot);
}
if (!modalInstance) {
modalInstance = ReactDOM.createRoot(modalRoot);
}
// 更新弹框的props
modalInstance.render(
visible={true}
{...options}
onCancel={() => modalInstance.render()}
/>
);
}
现在不管调用多少次showConfirmModal
,都只有一个弹框实例——既减少了DOM操作,又避免了用户 confusion。避坑提醒:弹框的“隐藏”逻辑要写对——不要直接删除DOM,而是把visible
设为false
,这样下次显示时不用重新创建实例,性能更好。
你用Vuex或Redux时,有没有想过:“为什么整个应用只能有一个Store?”答案就是单例模式!因为全局状态需要“唯一数据源”——如果有多个Store,状态就会分裂,组件之间无法同步数据。
尤雨溪在Vue文档里明确说过:“Vuex的Store是单例的,因为我们需要一个全局的状态容器来管理应用的所有状态。”我之前做Vue项目时,用Vuex管理用户的“收藏课程”列表,不管在“课程详情页”还是“个人中心页”,调用this.$store.commit('addFavorite', courseId)
,都是操作同一个Store实例,数据同步得特别丝滑。
避坑提醒:不要试图创建多个Store——我之前见过一个新手,为了“分开管理不同模块的状态”,创建了两个Store,结果“收藏课程”的状态在“个人中心”不显示,查了3小时才发现是Store多实例的问题。记住:Vuex/Redux的Store必须是单例,模块划分用modules
就够了。
像Axios请求拦截器、时间格式化工具、缓存服务这些“全局工具”,都应该用单例——初始化一次就够了,重复初始化会浪费内存。
我之前做一个新闻类项目时,写了个“本地缓存服务”,用来存用户的“浏览记录”:
// CacheService.js(ES6模块化单例)
class CacheService {
constructor() {
this.storage = localStorage;
}
// 存数据
set(key, value, expire = 86400) { // 默认过期时间1天
const data = {
value: value,
expire: Date.now() + expire * 1000
};
this.storage.setItem(key, JSON.stringify(data));
}
// 取数据
get(key) {
const data = JSON.parse(this.storage.getItem(key));
if (!data) return null;
if (Date.now() > data.expire) {
this.remove(key);
return null;
}
return data.value;
}
// 删除数据
remove(key) {
this.storage.removeItem(key);
}
}
// 导出单例实例
export default new CacheService();
然后在组件里导入:
// NewsList.js
import CacheService from './CacheService.js';
// 存浏览记录
function saveBrowseHistory(newsId) {
const history = CacheService.get('browseHistory') || [];
history.push(newsId);
CacheService.set('browseHistory', history);
}
这个服务不管导入多少次,都是同一个实例——初始化时只连接一次localStorage
,不会重复创建。避坑提醒:工具类的“初始化逻辑”要放在构造函数里,不要放在原型方法里——比如我之前把this.storage = localStorage
写在set
方法里,结果每次调用set
都重新赋值,虽然不影响功能,但没必要。
项目里的“全局配置”(比如API地址、主题颜色、版本号),也适合用单例——避免散落各地的“魔法值”。我现在做项目时,都会写一个config.js
,用单例存所有配置:
// config.js(ES6模块化单例)
const config = {
apiBaseUrl: process.env.NODE_ENV === 'production' ? 'https://api.example.com' 'http://localhost:3000',
themeColor: '#2f54eb',
appVersion: 'v1.2.3'
};
// 冻结配置对象,防止被修改
Object.freeze(config);
export default config;
这样不管在哪个组件里,都能拿到统一的配置——比如import config from './config.js'
,然后用config.apiBaseUrl
发请求。避坑提醒:用Object.freeze
冻结配置对象,防止有人不小心修改配置(比如把themeColor
改成红色),导致整个项目风格乱掉。
其实单例模式的本质,就是“用封装解决重复”——把“创建实例”的逻辑藏起来,只给用户一个“获取实例”的入口。我做前端这么多年,见过很多新手把单例模式想得太复杂,其实只要记住:能少创建一个实例,就少创建一个——毕竟内存是有限的,用户的耐心也是有限的。
如果你按这些方法试了,欢迎回来告诉我效果!或者你还有什么单例模式的问题,评论区一起聊~
本文常见问题(FAQ)
单例模式的核心到底是什么?
单例模式的核心其实特简单,就是保证一个类或者对象在整个项目里只能有一个实例。怎么做到呢?靠“封装”——把创建实例的逻辑藏起来,只给外面留一个“获取实例”的入口,这样不管谁用,拿到的都是同一个东西。比如购物车弹框,不管点多少次,都只有一个弹框实例,不会飘出来好几个占内存。
闭包实现单例要注意什么?
用闭包实现单例得注意俩点:一是得记得手动管理实例,比如要是需要重置(比如用户退出登录后清空弹框内容),得自己写个方法把保存实例的变量设为null;二是闭包会保留外层函数的作用域,别不小心把无关变量也包进去,不然可能占内存。我之前帮同事改购物车弹框时,就差点忘了加重置逻辑,后来用户反馈退出后再进弹框还是旧内容,才赶紧补上。
ES6模块化为什么能直接实现单例?
ES6模块本身就是单例的!因为模块加载是静态的,一旦加载过,导出的内容就固定了,不管你在多少个组件里导入,拿到的都是同一个东西。比如你写个api.js封装Axios实例,导出后不管在Home.js还是Detail.js里导入,用的都是同一个Axios实例,不用额外写逻辑,特省心。不过得注意,要是项目用了代码分割,得确保模块只加载一次,不然可能出问题,但一般项目里不会有这情况。
弹框组件为什么一定要用单例?
弹框不用单例的话,用户点多少次就会创建多少个DOM节点,既占内存又影响体验。比如我之前做教育项目的报名确认弹框,一开始没用到单例,用户点3次就出3个弹框,用户都懵了,还问“我到底报名成功没?”改成单例后,不管点多少次,都只有一个弹框实例——要显示就更新内容,要隐藏就设visible为false,既省内存又不会让用户 confusion。
全局配置用单例有什么好处?
全局配置用单例能避免“魔法值”散落各地。比如你把API地址、主题颜色、版本号这些配置存到一个config.js里,用Object.freeze冻住,导出后不管在哪个组件里用,拿到的都是统一的配置。要是不用单例,可能有人在组件里直接写“https://api.example.com”,后来要改生产环境的地址,得挨个文件找,特麻烦。用单例的话,改config.js里的一处就行,还能防止有人不小心修改配置——毕竟冻住了,想改也改不了。