单例模式是前端开发里最容易被误用、也最实用的设计模式之一。它的核心约束很明确:无论调用多少次,某个逻辑单元在应用生命周期内只能存在一个实例,并且对外暴露统一的访问入口。在JavaScript这种没有类级强制约束的语言中,实现单例的方式非常灵活,但如果理解不透彻,很容易写成到处都是的全局变量。

一、为什么需要单例模式
在浏览器端开发中,很多能力天然就应该是全局唯一的。比如购物车状态、用户登录信息、前端路由控制器、WebSocket连接管理器等。如果每次使用都去new一个实例,不仅浪费内存,还会导致状态不同步:A页面修改了购物车,B页面看到的还是旧数据。
单例模式通过控制实例化过程,把创建权限收口到一个固定入口。调用方不需要关心实例是怎么来的,只管拿过来用。这种特性在多人协作的项目里尤其重要,它能降低模块间的隐性耦合,让依赖关系变得清晰可查。
二、基础实现:闭包私有变量方案
最经典的JavaScript单例写法是利用闭包,把实例保存在函数作用域内部的变量里,外界无法直接修改。下面这段代码演示了一个简单的配置管理器单例:
(function (global) {
// 私有变量,外部无法直接访问
var instance = null;
function ConfigManager() {
// 私有数据
var config = {
apiBase: 'https://ipipp.com/api',
timeout: 8000
};
this.get = function (key) {
return config[key];
};
this.set = function (key, value) {
config[key] = value;
};
}
// 暴露获取单例的方法
global.getConfigManager = function () {
if (!instance) {
instance = new ConfigManager();
}
return instance;
};
})(window);
// 使用方式
var c1 = getConfigManager();
var c2 = getConfigManager();
console.log(c1 === c2); // true
上面的实现中,instance被包裹在立即执行函数里,避免了挂载到全局造成的命名污染。每次调用getConfigManager时,如果实例不存在才创建,这就是懒加载思想。
这种写法的优点是兼容性好,在老旧浏览器里也能稳定运行。缺点是代码稍显啰嗦,而且ConfigManager内部的私有数据依赖闭包,无法使用原型链共享方法,在实例方法非常多时会略微增加内存开销。
三、ES模块带来的天然单例
在现代前端工程里,我们通常用webpack或vite打包,代码以ES模块组织。一个模块无论被import多少次,它的顶层代码只会执行一次,这本身就是语言层面的单例保障。
// store.js
const state = {
count: 0,
user: null
};
function increment() {
state.count += 1;
}
function setUser(u) {
state.user = u;
}
export default {
state,
increment,
setUser
};
在任意其他文件中写入import store from './store.js',拿到的都是同一个对象引用。你不需要手写任何判空逻辑,打包工具已经帮你做了模块缓存。
这种方式的维护性最好,也最符合当前团队的开发习惯。需要注意的一点是,模块导出的对象如果是引用类型,所有引用方都能直接改里面的字段,所以建议在复杂项目中只暴露方法,不直接暴露可变state,或者配合Object.freeze做浅冻结。
四、实战场景:前端缓存控制器
假设我们有一个需要频繁查询、但数据变动不频繁的接口,为了避免重复请求,可以用单例做一个带过期时间的缓存层。
class CacheController {
constructor() {
if (CacheController._instance) {
return CacheController._instance;
}
this.store = new Map();
CacheController._instance = this;
}
set(key, value, ttl = 60000) {
this.store.set(key, {
value,
expire: Date.now() + ttl
});
}
get(key) {
const item = this.store.get(key);
if (!item) return null;
if (Date.now() > item.expire) {
this.store.delete(key);
return null;
}
return item.value;
}
}
// 测试
const a = new CacheController();
const b = new CacheController();
a.set('name', 'tom');
console.log(b.get('name')); // tom这里在构造函数里判断了静态属性_instance,如果已经存在就直接返回,用class语法模拟了单例。虽然用new调用两次,但得到的是同一个对象。
这种方案适合需要集中式管理的缓存逻辑,比如多个组件都要读取同一份字典数据。它把过期淘汰策略封在内部,调用方完全无感。不过class上的静态属性在严格模式下可被外部改写,生产环境可以改用WeakMap来隐藏实例引用,安全性更高。
五、常见误区与避坑建议
很多人把单例和全局变量画等号,这是最大的认知偏差。全局变量是被动暴露,谁都能改;单例是主动提供受控入口,内部状态可以保护。如果你只是需要一个共享对象,优先用模块导出,而不是往window上挂属性。
另一个坑是单例带来的测试困难。因为实例跨测试用例存活,前一个测试修改了状态可能影响后一个。解决办法是在单例内部提供reset方法,或者在测试环境用依赖注入替换掉单例引用,保持每个用例的独立性。
| 实现方式 | 适用场景 | 主要风险 |
|---|---|---|
| 闭包私有变量 | 老旧浏览器、无构建工具项目 | 代码冗余、方法不能上原型 |
| ES模块导出 | 现代工程化项目 | 引用类型易被直接篡改 |
| class静态属性 | 需要明确类结构的业务 | 静态属性可被外部重置 |
单例模式不是银弹,不要在不需要全局唯一的地方强行使用。当你发现某个模块开始被十几个文件交叉引用,且状态必须保持一致时,它就是单例的最佳落脚点。
javascriptsingleton_patternmodule_pattern修改时间:2026-08-05 11:57:14