关于 singleton-pattern
面向 JavaScript 开发者的单例模式说明,介绍如何限制类只创建一个实例并在模块间共享。也阐述其全局可变状态、测试和依赖隐藏方面的代价。
主要功能
- 01限制类只创建一个实例
- 02跨模块共享实例
- 03冻结导出实例以防修改
- 04说明测试与全局状态风险
适用场景
- 01在应用中共享配置或资源
- 02多个模块共同维护计数器状态
- 03评估单例对测试性的影响
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ patternsdev/ skills --skill singleton-pattern安装后在 Claude Code 中输入/singleton-pattern确认已加载。
详细介绍
单例模式
目录
单例是只能实例化一次、且可全局访问的类。这个 单一实例 可以在整个应用中共享,因此单例非常适合管理应用的全局状态。
适用场景
- 需要在整个应用中共享一个类的唯一实例时
- 适合管理全局状态、配置或共享资源
不适用场景
- 需要一个类的多个实例时——单例在设计上会强制使用唯一的共享实例
- 会引入隐式的全局状态耦合,使代码更难测试和理解时
- 依赖注入或模块级变量能够实现相同效果且更易于测试时
使用说明
- 在构造函数中检查是否已有实例,确保只能创建一个实例
- 对导出的实例使用
Object.freeze(),以防意外修改 - 在 JavaScript 中,尽可能优先使用简单的对象字面量或模块,而非基于类的单例
- 在 React 中,全局状态管理优先使用状态管理工具(Redux、Context),而非单例
- 请注意,共享的可变状态可能会使单例更难测试
详细信息
首先,我们来看看如何使用 ES2015 类实现单例。在这个示例中,我们将构建一个 Counter 类,其中包含:
- 一个返回实例值的
getInstance方法 - 一个返回
counter变量当前值的getCount方法 - 一个将
counter的值加一的increment方法 - 一个将
counter的值减一的decrement方法
let counter = 0;
class Counter {
getInstance() {
return this;
}
getCount() {
return counter;
}
increment() {
return ++counter;
}
decrement() {
return --counter;
}
}
然而,这个类不符合单例的标准!单例应该只能实例化一次。目前,我们可以创建多个 Counter 类的实例。
let counter = 0;
class Counter {
getInstance() {
return this;
}
getCount() {
return counter;
}
increment() {
return ++counter;
}
decrement() {
return --counter;
}
}
const counter1 = new Counter();
const counter2 = new Counter();
console.log(counter1.getInstance() === counter2.getInstance()); // false
通过两次调用 new,我们只是将 counter1 和 counter2 分别设为不同的实例。counter1 和 counter2 上的 getInstance 方法返回的值实际上引用了不同的实例:它们并不严格相等!
我们来确保只能创建一个 Counter 类的实例。
确保只能创建一个实例的方法之一,是创建一个名为 instance 的变量。在 Counter 的构造函数中,我们可以在创建新实例时,将 instance 设置为指向该实例的引用。我们可以通过检查 instance 变量是否已有值来阻止创建新实例。如果是这样,就说明实例已经存在。这种情况不应该发生:应该抛出错误来告知用户。
let instance;
let counter = 0;
class Counter {
constructor() {
if (instance) {
throw new Error("You can only create one instance!");
}
instance = this;
}
getInstance() {
return this;
}
getCount() {
return counter;
}
increment() {
return ++counter;
}
decrement() {
return --counter;
}
}
const counter1 = new Counter();
const counter2 = new Counter();
// Error: You can only create one instance!
很好!我们现在无法再创建多个实例了。
让我们从 counter.js 文件导出 Counter 实例。不过,在此之前,我们还应该冻结这个实例。Object.freeze 方法可以确保使用代码无法修改单例。冻结实例的属性无法添加或修改,从而降低意外覆盖单例上的值的风险。
let instance;
let counter = 0;
class Counter {
constructor() {
if (instance) {
throw new Error("You can only create one instance!");
}
instance = this;
}
getInstance() {
return this;
}
getCount() {
return counter;
}
increment() {
return ++counter;
}
decrement() {
return --counter;
}
}
const singletonCounter = Object.freeze(new Counter());
export default singletonCounter;
考虑一个使用以下文件实现 Counter 示例的应用:
counter.js:包含Counter类,并将一个Counter实例作为默认导出index.js:加载redButton.js和blueButton.js模块redButton.js:导入Counter,将Counter的increment方法添加为红色按钮的事件监听器,并调用getCount方法记录counter的当前值blueButton.js:导入Counter,将Counter的increment方法添加为蓝色按钮的事件监听器,并调用getCount方法记录counter的当前值
blueButton.js 和 redButton.js 都从 counter.js 导入同一个实例。在这两个文件中,该实例都以 Counter 的名称导入。
当我们在 redButton.js 或 blueButton.js 中调用 increment 方法时,Counter 实例上的 counter 属性值会在两个文件中同时更新。无论点击红色按钮还是蓝色按钮,所有实例共享的都是同一个值。因此,即使我们在不同文件中调用该方法,计数器也会持续递增一。
权衡
将实例化限制为仅有 一个 实例,可能会节省大量内存空间。我们不必每次都为新实例设置内存,而只需为这一个实例设置内存,并在整个应用程序中引用它。不过,Singleton 实际上被视为一种反模式,在 JavaScript 中可以(或者……应该)避免使用。
在许多编程语言中,例如 Java 或 C++,无法像在 JavaScript 中那样直接创建对象。在这些面向对象的编程语言中,我们需要创建一个类,再由它创建对象。创建出的对象的值就是该类的实例,就像 JavaScript 示例中 instance 的值一样。
不过,上述示例中展示的类实现实际上有些过度设计。既然可以在 JavaScript 中直接创建对象,我们就可以简单地使用普通对象来实现完全相同的结果。接下来看看使用 Singleton 的一些缺点!
使用普通对象
我们沿用之前的示例。不过这次,counter 只是一个对象,包含:
- 一个
count属性 - 一个
increment方法,可将count的值加一 - 一个
decrement方法,可将count的值减一
由于对象是按引用传递的,redButton.js 和 blueButton.js 都导入了对同一个 counter 对象的引用。在任一文件中修改 count 的值,都会修改 counter 上的值,因此两个文件都能看到这一变化。
测试
测试依赖 Singleton 的代码可能会很棘手。由于我们无法每次都创建新实例,所有测试都会依赖前一个测试对全局实例所做的修改。在这种情况下,测试的顺序很重要,一处小小的修改就可能导致整个测试套件失败。测试结束后,我们需要重置整个实例,以清除测试所做的修改。
隐藏依赖
导入另一个模块时(本例中是 superCounter.js),可能不容易看出该模块导入的是 Singleton。在其他文件中(例如本例中的 index.js),我们可能会导入该模块并调用它的方法。这样一来,我们可能会意外修改 Singleton 中的值。这可能导致意外行为,因为整个应用程序中可能会共享 Singleton 的多个实例,而这些实例也都会被修改。
全局行为
单例实例应当能够在整个应用中被引用。全局变量的行为基本相同:由于全局变量位于全局作用域中,因此我们可以在整个应用中访问这些变量。
通常认为使用全局变量是一种糟糕的设计决策。污染全局作用域可能会导致意外覆盖全局变量的值,从而引发许多意料之外的行为。
在 ES2015 中,创建全局变量的情况相当少见。新的 let 和 const 关键字通过将用这两个关键字声明的变量限制在块级作用域内,避免开发者意外污染全局作用域。JavaScript 中新的 module 系统让创建可全局访问的值变得更容易,同时又不会污染全局作用域:我们可以从模块中 export 值,并在其他文件中 import 这些值。
不过,单例的常见用例是在整个应用中维护某种全局状态。让代码库的多个部分依赖同一个可变对象,可能会导致意料之外的行为。
通常,代码库中的某些部分会修改全局状态中的值,而其他部分则会使用这些数据。此处的执行顺序很重要:我们不希望在还没有数据可用时就意外地先使用数据!随着应用不断增长,越来越多的组件相互依赖,理解使用全局状态时的数据流可能会变得非常棘手。
React 中的状态管理
在 React 中,我们通常依赖 Redux 或 React Context 等状态管理工具来使用全局状态,而不是使用单例。尽管它们的全局状态行为看起来可能与单例相似,但这些工具提供的是只读状态,而不是单例的_可变_状态。使用 Redux 时,只有纯函数 reducer 可以更新状态,而且必须先由组件通过 dispatcher 发送一个 action。
使用这些工具并不能让全局状态的缺点凭空消失,但至少我们可以确保全局状态按照预期的方式进行修改,因为组件无法直接更新状态。
来源
参考资料
- Do React Hooks replace Redux - Eric Elliott
- JavaScript Design Patterns: The Singleton - Samier Saeed
- Singleton - Refactoring Guru