Appearance
2.2 单例模式 (Singleton Pattern)
欢迎来到我们的第二个核心入门模式——单例模式。如果说工厂模式解决的是“如何创建”,那么单例模式解决的则是“创建多少个”的问题。
答案是:只创建一个。
核心定义:保证一个类只有一个实例,并提供一个全局访问点来访问这个唯一的实例。
一、生活中的场景:唯一的购物车
想象一下你在一个电商网站购物。
- 你在商品列表页,把“苹果”加入了购物车。
- 你又跳转到另一个推荐商品页,把“香蕉”加入了购物车。
无论你在哪个页面操作,你操作的都应该是同一个购物车。最终结算时,购物车里应该同时有“苹果”和“香蕉”。
如果每次你添加商品时,系统都给你一个全新的、空的购物车,那将是一场灾难。这里的“购物车”,就是一个典型的需要“单例”的场景。
二、没有单例时的代码:灾难性的多个实例
让我们用代码来模拟一下上面那个糟糕的场景。
javascript
// shopping-cart.js
// 先定义一个购物车类
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(item) {
this.items.push(item);
console.log(`添加商品: ${item.name}`);
}
getItems() {
return this.items;
}
}
// =======================================================
// page-a.js (商品列表页)
// import ShoppingCart from './shopping-cart.js'; // 假设这是模块化环境
const cartInPageA = new ShoppingCart();
cartInPageA.addItem({ name: "苹果", price: 10 });
console.log("页面A的购物车:", cartInPageA.getItems());
// 输出: 页面A的购物车: [ { name: '苹果', price: 10 } ]
// =======================================================
// page-b.js (推荐商品页)
// import ShoppingCart from './shopping-cart.js';
const cartInPageB = new ShoppingCart();
cartInPageB.addItem({ name: "香蕉", price: 5 });
console.log("页面B的购物车:", cartInPageB.getItems());
// 输出: 页面B的购物车: [ { name: '香蕉', price: 5 } ]
// 问题:这两个购物车不是同一个!数据是割裂的!
console.log(cartInPageA === cartInPageB); // false因为在两个页面中都执行了 new ShoppingCart(),我们得到了两个完全独立的购物车实例。这显然不符合我们的业务需求。
三、使用单例模式重构
我们的目标是:无论 new 多少次(或者说,无论在哪里获取),我们都应该得到同一个 ShoppingCart 实例。
在 JavaScript 中,实现单例最经典、最优雅的方式是利用闭包。
javascript
// shopping-cart-singleton.js
const ShoppingCartSingleton = (function () {
// `instance` 这个变量被“藏”在了这个立即执行函数(IIFE)的闭包里
let instance;
// 购物车的实际构造函数
function ShoppingCart() {
// 防止外部通过 new 来创建
if (instance) {
return instance;
}
this.items = [];
instance = this;
}
// 公共方法
ShoppingCart.prototype.addItem = function (item) {
this.items.push(item);
console.log(`添加商品: ${item.name}`);
};
ShoppingCart.prototype.getItems = function () {
return this.items;
};
// 返回的不是 ShoppingCart 类本身,而是一个包含了获取单例方法的对象
return {
getInstance: function () {
// 如果 instance 不存在,就 new 一个;如果存在,就直接返回
if (!instance) {
instance = new ShoppingCart();
}
return instance;
},
};
})();
// =======================================================
// page-a.js
const cartInPageA = ShoppingCartSingleton.getInstance();
cartInPageA.addItem({ name: "苹果", price: 10 });
console.log("页面A的购物车:", cartInPageA.getItems());
// 输出: 页面A的购物车: [ { name: '苹果', price: 10 } ]
// =======================================================
// page-b.js
const cartInPageB = ShoppingCartSingleton.getInstance();
cartInPageB.addItem({ name: "香蕉", price: 5 });
console.log("页面B的购物车:", cartInPageB.getItems());
// 输出: 页面B的购物车: [ { name: '苹果', price: 10 }, { name: '香蕉', price: 5 } ]
// 成功了!它们是同一个实例
console.log(cartInPageA === cartInPageB); // true实现剖析
- 我们用一个立即执行函数表达式 (IIFE) 创建了一个私有作用域。
- 在这个作用域内,我们声明了一个
instance变量,它就是我们用来“缓存”唯一实例的地方。 - 我们对外暴露的不是
ShoppingCart类,而是一个拥有getInstance方法的对象。 getInstance方法是获取单例的唯一入口。它会检查instance是否已经被创建。如果是,就直接返回缓存的实例;如果不是,就new一个新的,存到instance中,然后再返回。
由于闭包的特性,instance 变量会一直存在于内存中,不会被垃圾回收,从而保证了实例的持久唯一。
四、优缺点与风险
优点
- 全局唯一:确保了在任何地方访问到的都是同一个对象实例,非常适合管理全局状态,如配置信息、登录状态、购物车等。
- 节省资源:避免了对同一资源的重复创建和销毁。
- 懒加载 (Lazy Loading):实例只在第一次调用
getInstance时被创建,而不是在程序加载时就创建,可以节省启动时间。
缺点与风险
警告:单例模式是一把双刃剑
单例模式本质上引入了全局状态,这与现代前端推崇的“单向数据流”和“状态局部化”思想有所相悖。
- 全局污染:它就像一个美化版的全局变量,容易造成模块间的强耦合。一个模块可以轻易地修改单例的状态,从而影响到系统中所有其他使用该单例的模块,这使得 Bug 的追踪变得困难。
- 可测试性差:当你的代码依赖于一个全局单例时,编写单元测试会变得很困难。因为测试用例之间可能会相互影响单例的状态。
- 违反单一职责原则:一个类既要负责自身的业务逻辑(如购物车管理),又要负责保证自己的唯一性,职责不够单一。 :::
五、适用场景
尽管有风险,但在以下场景中,单-例模式仍然是合理的选择:
- 需要一个全局唯一的对象来协调系统各部分,例如:一个全局的配置对象。
- 管理一个共享资源,例如:一个全局的数据库连接池,或一个 Web Socket 连接实例。
- 需要一个唯一的实例来充当“管理器”,例如:一个全局的弹窗(Modal)管理器,确保同一时间只有一个弹窗显示。