单例模式:全局唯一的关键

72 阅读6分钟

前言

假如你在开发一个大型系统,里面有个“配置中心”对象,负责管理全局配置。
如果每个模块都能随意 new 一个配置中心,会发生什么?

  • 资源浪费:每个模块都占用一份内存,明明只需要一份。
  • 状态不一致:A模块改了配置,B模块还在用旧的,BUG满天飞。
  • 维护困难:全局状态分散,排查问题像大海捞针。

就像公司有两个CEO,一个说向东,一个说向西,员工懵了,项目黄了。 想象一下,一家公司只能有一个CEO,所有重大决策都由他拍板。

而单例模式,就是让你的程序里某些“关键角色”像CEO一样,全局唯一

单例 VS 非单例

非单例代码(每次new都不一样)
class Config {
  constructor() {
    this.theme = 'dark';
  }
}
const a = new Config();
const b = new Config();
a.theme = 'light';
console.log(b.theme); // dark(状态不一致)

在这里我们定义了一个 Config 类,在构造函数中会初始化 theme 属性并赋值为 'dark'。当通过 new 关键字创建 Config 类的实例ab时,二者属于相互独立的对象。当修改实例a的 theme 属性为 light 时,实例 b 的 theme 属性并不会发生改变,依旧保持初始的 dark 状态。

这清晰地表明,每次使用 new 创建的实例都是彼此独立的,它们之间的状态不会相互干扰。

单例代码(全局唯一)
class Config {
  constructor() {
    if (Config.instance) return Config.instance;
    this.theme = 'dark';
    Config.instance = this;
  }
}
const a = new Config();
const b = new Config();
a.theme = 'light';
console.log(b.theme); // light(全局同步)

而这段代码同样定义了 Config 类,其构造函数会先检查 Config.instance 是否存在。若存在,则直接返回该实例;若不存在,就会初始化 theme 属性为 'dark',并将当前实例赋值给 Config.instance。如此一来,无论多少次使用 new 关键字去创建 Config 类的实例,实际上都只会得到同一个实例。当修改实例 a 的 theme 属性为 light 时,实例b的 theme 属性也会同步变为 light

一句话总结

单例模式确保全局仅有一个实例且状态完全同步,非单例模式每次 new 都生成独立实例且状态互不干扰。

实现方式

1. 我们可以通过一个本地存储(localStorage)管理类 Storage,并通过单例模式保证全局只有一个实例。
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>设计模式之单例模式</title>
</head>
<body>
    <script>
        class Storage {
            constructor(namespace = 'storage') {
                this.namespace = namespace;
            }
            static getInstance(){
                if (!Storage.instance) {
                    Storage.instance = new Storage();
                }
                return Storage.instance;
            }    
            getItem(key) {
                return localStorage.getItem(this.namespace + key);
            }   
            setItem(key, value) {
                localStorage.setItem(this.namespace + key, value);
            }
        }

        const storage1 = Storage.getInstance();
        const storage2 = Storage.getInstance();
        console.log(storage1 === storage2, '!!!');  // 输出: true '!!!'
        storage1.setItem('name','haha');
        console.log(storage1.getItem('name'));  // 输出: "haha"
        console.log(storage2.getItem('name'));  // 输出: "haha"
    </script>
</body>
</html>

核心思路是:通过 Storage.getInstance() 静态方法获取唯一实例,无论调用多少次,返回的都是同一个对象。这样可以确保所有数据操作都集中在同一个“存储管理员”手里,避免多实例带来的混乱和数据不一致。

在实际使用中,storage1 和 storage2 都是同一个实例。我们可以通过任意一个实例设置或获取本地存储的数据,数据始终保持同步。

2. 我们还可以用经典的ES5闭包+原型方式实现了单例模式:
function StorageBase() {
}

StorageBase.prototype.getItem = function(key) {
    return localStorage.getItem(key);
}

StorageBase.prototype.setItem = function(key, value) {
    return localStorage.setItem(key, value);
}

const Storage = (function() {
    let instance = null;
    return function() {
        if(!instance) {
            instance = new StorageBase();
        }
        return instance;
    }
})();

const storage1 = Storage();
const storage2 = Storage();
console.log(storage1 === storage2)

首先,代码定义了一个基础构造函数 StorageBase,它本身没有内容,只是作为本地存储操作对象的模板。

接着,在 StorageBase 的原型上添加了两个方法:getItem 用于从本地存储读取数据,setItem 用于向本地存储写入数据。这样,所有通过 StorageBase 创建的对象都能直接使用这两个方法。

然后,代码通过一个立即执行函数表达式(IIFE)创建了 Storage 变量。这个 IIFE 内部声明了一个 instance 变量,用于保存唯一的实例。IIFE 返回的其实是一个函数,每次调用这个函数时,都会先判断 instance 是否已经有值。如果没有(说明是第一次调用),就用 new StorageBase() 创建一个新对象,并把它赋值给 instance。如果已经有了,直接返回这个已经创建好的对象。

最后,代码通过 const storage1 = Storage();const storage2 = Storage(); 两次获取实例,并用 console.log(storage1 === storage2) 验证它们是否是同一个对象。结果为 true,说明无论调用多少次 Storage(),拿到的都是同一个实例,实现了单例模式。

主要区别对比

对比项ES6 类实现ES5 闭包实现
实现方式使用 ES6 类语法使用构造函数 + 原型 + 闭包
单例控制通过静态方法 getInstance() 和静态属性 instance 实现通过 IIFE 闭包和函数返回值控制
命名空间支持命名空间,防止键名冲突不支持命名空间
方法定义方法直接定义在类中方法定义在原型上
灵活性较高,支持更多 ES6 特性较低,依赖 ES5 特性
代码结构更简洁,符合现代 JS 风格较复杂,需要手动管理原型
单例模式的重要性
  1. 避免资源浪费

    • 单例模式确保一个类只有一个实例,避免重复创建消耗系统资源(如数据库连接、网络请求)。
  2. 数据一致性

    • 所有模块访问同一个实例,保证数据状态统一。例如,修改主题配置后,所有组件能立即感知变化。
  3. 简化代码结构

    • 全局唯一实例减少了组件间传递依赖的复杂度,使代码更易维护。
  4. 线程安全(在多线程环境中)

    • 在 Java、C# 等语言中,单例模式可设计为线程安全,避免多线程同时创建实例导致的问题。
常见应用场景
  1. 资源共享

    • 示例:数据库连接池、文件系统操作、缓存系统
    • 原因:避免重复创建多个相同资源,减少内存占用和性能开销。
  2. 全局状态管理

    • 示例:应用配置、用户会话信息、主题设置
    • 原因:确保所有组件访问的是同一份数据,状态变更统一生效。
  3. 日志记录器

    • 示例:统一的日志输出工具
    • 原因:避免多个日志实例导致输出混乱,确保日志顺序和格式一致。
  4. UI 组件

    • 示例:模态框、弹窗、通知组件
    • 原因:防止同一时间出现多个相同组件实例,造成用户体验问题。
小结

单例模式不仅仅是一种设计模式,更是一种资源管理和状态控制的哲学。它通过限制实例数量,解决了资源浪费、状态不一致、线程安全等核心问题,是构建健壮、高效系统的基石。