mcpField
S
singleton-pattern

singleton-pattern

github.com/patternsdev开发257638 次安装28 周前更新
代码仓库

关于 singleton-pattern

面向 JavaScript 开发者的单例模式说明,介绍如何限制类只创建一个实例并在模块间共享。也阐述其全局可变状态、测试和依赖隐藏方面的代价。

主要功能

  1. 01限制类只创建一个实例
  2. 02跨模块共享实例
  3. 03冻结导出实例以防修改
  4. 04说明测试与全局状态风险

适用场景

  1. 01在应用中共享配置或资源
  2. 02多个模块共同维护计数器状态
  3. 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。

使用这些工具并不能让全局状态的缺点凭空消失,但至少我们可以确保全局状态按照预期的方式进行修改,因为组件无法直接更新状态。

来源

参考资料

评论

支持 Markdown