单例模式的核心目标很简单:保证一个类在整个应用程序中只有一个实例,并提供一个全局访问点。在JavaScript里,这个需求其实非常常见——全局的登录弹窗、统一的请求缓存、全局配置对象、音视频播放器等,都希望只存在一份实例,避免重复创建带来的资源浪费和状态混乱。JavaScript没有传统面向对象语言的private构造函数,所以实现单例的方式相当灵活,下面详细介绍几种主流写法。

闭包实现单例:最经典的写法
闭包是实现单例最直接的手段。思路是把实例缓存在闭包变量里,每次调用都判断这个变量是否已经有值,有就直接返回,没有才创建。来看一个基础版本:
function createLoginLayer() {
var instance = null;
return function () {
if (!instance) {
// 创建登录弹窗 DOM
instance = document.createElement('div');
instance.innerHTML = '我是登录弹窗';
instance.style.display = 'none';
document.body.appendChild(instance);
}
return instance;
};
}
var getLoginLayer = createLoginLayer();
var a = getLoginLayer();
var b = getLoginLayer();
console.log(a === b); // true这段代码的关键在于instance变量被外层函数的作用域保护起来,外部无法直接访问和修改,只能通过返回的函数获取。两次调用返回的是同一个DOM节点,比较结果为true,说明单例生效了。
这种写法的优点是兼容性好,任何支持闭包的环境都能跑,而且把创建逻辑和单例管理逻辑分离得比较清楚。缺点是代码结构上稍显松散,如果需要给多个不同的类都加单例能力,就得重复写类似的判断逻辑,违反了开放封闭原则。
代理模式优化:把单例逻辑和业务逻辑解耦
上面的写法把“创建弹窗”和“保证唯一”两件事揉在一个函数里,更好的做法是用代理模式把二者分开。单例逻辑放在代理函数中,业务函数只负责创建对象,这样任何创建函数都能复用单例能力:
// 普通的创建函数,只关心怎么创建
function createLoginLayer() {
var div = document.createElement('div');
div.innerHTML = '我是登录弹窗';
div.style.display = 'none';
document.body.appendChild(div);
return div;
}
// 代理函数,负责管理单例
var proxyCreateLoginLayer = (function () {
var instance = null;
return function () {
if (!instance) {
instance = createLoginLayer();
}
return instance;
};
})();这种结构的好处非常明显:将来如果要给购物车弹窗、消息提示框做单例,只需要换掉被代理的创建函数,代理逻辑完全不用动。这是《JavaScript设计模式与开发实践》一书中的经典案例,也是面试中经常被追问的进阶写法。
需要注意的是,代理模式要求被代理的函数本身不持有状态,如果创建函数依赖外部变量或者内部维护了自己的缓存,代理层的控制可能会被绕过,这一点在重构老代码时要格外小心。
ES6 Class结合静态方法:现代主流方案
ES6之后,用Class配合静态方法实现单例成为更常见的写法。静态属性属于类本身而不是实例,天然适合存放那个唯一的实例:
class Singleton {
constructor() {
if (Singleton.instance) {
throw new Error('请使用 Singleton.getInstance() 获取实例');
}
this.data = {};
Singleton.instance = this;
}
static getInstance() {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
}
const a = Singleton.getInstance();
const b = Singleton.getInstance();
console.log(a === b); // true
new Singleton(); // 抛出错误,阻止外部直接 new这个版本不仅提供了全局访问点,还在构造函数里做了防御,外部直接new会直接报错,强制使用者走getInstance入口。相比闭包写法,Class版本的语义更清晰,代码组织也更符合现代工程习惯。
如果追求更简洁的写法,还可以利用静态字段直接简化:
class Config {
static instance = null;
constructor(options) {
if (Config.instance) return Config.instance;
this.options = options;
Config.instance = this;
}
}这个版本的构造函数会在第二次被调用时直接返回已存在的实例,外部甚至感知不到区别,new Config()多少次拿到的都是同一个对象。缺点是失去了一种显式的获取方式,代码可读性上略逊于getInstance方案。
模块化与Proxy:更工程化的选择
很多开发者忽略了一点:ES Module本身就是天然的单例。模块在被首次导入时执行一次,之后无论多少个文件导入它,拿到的都是同一个模块实例。所以最简单的单例可能根本不需要写任何模式代码:
// store.js
export const globalStore = {
user: null,
token: '',
setUser(user) {
this.user = user;
}
};
// 任意多个文件中导入,globalStore 都是同一个对象
import { globalStore } from './store.js';这种方式在Vue、React项目中极为常见,全局状态、工具方法集合、配置对象都可以这样处理。它的好处是零成本、无侵入,缺点是模块加载时就创建了对象,如果这个对象初始化开销很大却未必会被用到,就浪费了资源。
针对这个问题,可以引入惰性单例(Lazy Singleton)的思想:把实例化推迟到第一次真正使用的时候。结合Proxy可以写出比较优雅的实现:
const lazySingleton = (className) => {
return new Proxy({}, {
get(target, prop) {
// 第一次访问属性时才真正实例化
if (!target.instance) {
target.instance = new className();
}
const value = target.instance[prop];
return typeof value === 'function'
? value.bind(target.instance)
: value;
}
});
};
class HeavyService {
constructor() {
console.log('昂贵的初始化,只在首次访问时执行');
this.data = new Array(1000000).fill(0);
}
getData() { return this.data.length; }
}
const service = lazySingleton(HeavyService);
console.log('尚未实例化');
console.log(service.getData()); // 此时才打印初始化日志Proxy版本的优势在于使用者完全不需要关心单例细节,直接像操作普通对象一样使用即可,实例化时机被完全隐藏在拦截层。不过要注意get拦截器中函数绑定this的处理,否则方法调用时上下文会丢失。
各方案对比与选型建议
综合来看,几种方案各有定位。闭包写法适合快速实现和面试演示;代理模式适合需要复用单例逻辑的多对象场景;ES6 Class静态方法适合作为类库的标准接口暴露;ES Module导出适合项目内的全局状态管理,是工程中最省事的方式;惰性单例则适合初始化成本高的对象,比如大型数据结构、WebSocket连接池等。
选型时可以问自己两个问题:这个对象是否初始化开销大且可能用不到?是否需要在多个不同类之间复用单例能力?前者指向惰性单例,后者指向代理模式,两者都不是的话,直接用模块导出往往就够了。另外提醒一点,单例模式本质上引入了全局状态,在单测中可能造成状态污染,测试时最好提供重置实例的手段,比如增加一个静态的resetInstance方法,避免用例之间互相影响。
JavaScript单例模式闭包ES6 Class修改时间:2026-09-11 09:24:36