Skip to content

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

实现剖析

  1. 我们用一个立即执行函数表达式 (IIFE) 创建了一个私有作用域。
  2. 在这个作用域内,我们声明了一个 instance 变量,它就是我们用来“缓存”唯一实例的地方。
  3. 我们对外暴露的不是 ShoppingCart 类,而是一个拥有 getInstance 方法的对象。
  4. getInstance 方法是获取单例的唯一入口。它会检查 instance 是否已经被创建。如果是,就直接返回缓存的实例;如果不是,就 new 一个新的,存到 instance 中,然后再返回。

由于闭包的特性,instance 变量会一直存在于内存中,不会被垃圾回收,从而保证了实例的持久唯一。

四、优缺点与风险

优点

  1. 全局唯一:确保了在任何地方访问到的都是同一个对象实例,非常适合管理全局状态,如配置信息、登录状态、购物车等。
  2. 节省资源:避免了对同一资源的重复创建和销毁。
  3. 懒加载 (Lazy Loading):实例只在第一次调用 getInstance 时被创建,而不是在程序加载时就创建,可以节省启动时间。

缺点与风险

警告:单例模式是一把双刃剑

单例模式本质上引入了全局状态,这与现代前端推崇的“单向数据流”和“状态局部化”思想有所相悖。

  1. 全局污染:它就像一个美化版的全局变量,容易造成模块间的强耦合。一个模块可以轻易地修改单例的状态,从而影响到系统中所有其他使用该单例的模块,这使得 Bug 的追踪变得困难。
  2. 可测试性差:当你的代码依赖于一个全局单例时,编写单元测试会变得很困难。因为测试用例之间可能会相互影响单例的状态。
  3. 违反单一职责原则:一个类既要负责自身的业务逻辑(如购物车管理),又要负责保证自己的唯一性,职责不够单一。 :::

五、适用场景

尽管有风险,但在以下场景中,单-例模式仍然是合理的选择:

  • 需要一个全局唯一的对象来协调系统各部分,例如:一个全局的配置对象。
  • 管理一个共享资源,例如:一个全局的数据库连接池,或一个 Web Socket 连接实例。
  • 需要一个唯一的实例来充当“管理器”,例如:一个全局的弹窗(Modal)管理器,确保同一时间只有一个弹窗显示。