在整个应用之内全局共享一个实例的模式,但它在JS中竟然是一种反模式

所谓单例模式是指遵循这个模式设计的类,仅会被实例化一次,并且其实例允许全局获取。单例模式下派生的示例允许我们在全局共享唯一实例,因此非常适合用于保存整个应用的全局状态。

首先让我们先看看在ES2015的语法下单例模式长什么样子。比如我们想要创建一个计数器类,用于保存全局的某个行为发生的次数,那么对于这个类的设计,我们应该考虑实现如下4个方法:

getInstance方法,用于返回全局唯一的实例getCount方法用于返回当前实例的counter实例变量的值increment方法用于为counter属性的值加一decrement方法用于为counter属性的值减一

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两个实例看上去应该是拥有同样的初试属性。但是通过分别调用两个实例各自的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类的实例了。

接下来让我们从counter.js文件中导出Counter实例。但在这之前,我们应该先对此实例执行freeze操作。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方法作为事件监听的回调函数,并通过调用counter实例的getCounter方法获取当前的计数器值blueButton.js:导入Counter类,并且向蓝色按钮添加Counter实例的increment方法作为事件监听的回调函数,并通过调用counter实例的getCounter方法获取当前的计数器值

blueButton.js和redButton.js都引入了同一个counter.js的实例。也就是说这个实例作为Counter变量被导入到了两个文件中。

无论在redButton.js或是blueButton.js中调用increment方法时,Counter实例的counter属性值的更新会同步反应在两个文件中。不管我们点击的是红色按钮还是蓝色按钮:所有引用Counter实例的对象会共享同一个值。这就是为什么即便我们在不同的文件中调用递增方法,计数器的值都会增加的原因。

优 / 劣势

对一次性实例化的严格限制显然有节省内存空间的潜力。相对于每次都为新对象分配内存,通过单例模式我们仅需要为一个对象分配内存即可。然而,单例模式实际上被认为是一种反模式,并且应该避免在JavaScript中使用。

在很多编程语言中,比如Java或者C++,不可能像我们在JavaScript中一样直接创建对象。在这些面向对象的编程语言中,需要创建类,类创建对象。这些创建了的对象都拥有类实例的值,正如上面JavaScript示例中的instance实例一样。

但是像在上面示例代码中那样去声明一个单例模式的类显然有点大炮打蚊子。既然我们可以在JavaScript中直接创建对象,为什么不能直接创建一个对象来达到同样的目的呢?接下来我们说说使用单例模式的缺陷!

也,可以直接使用一个普通对象

仍然使用上面示例中的场景。只不过这一次我们将counter直接定义为一个拥有如下属性的简单对象:

count属性increment方法为count属性递增一decrement方法为count属性递减一

let count = 0;const counter = { increment() { return ++count; }, decrement() { return --count; }};Object.freeze(counter);export { counter };

由于对象传递的是引用,所以redButton.js和blueButton.js都导入了counter对象的同一个引用。无论在哪个文件中修改count属性的值都会修改counter对象的值,这个结果可以在两个引用它的文件中观察到。

测试时的麻烦事

测试单例模式的测试代码需要点技巧。由于我们不能每次都创建一个新的实例,因此所有测试都依赖于上一个测试用例对于全局引入的那个对象的修改。在此例中,测试用例的顺序关系到整个测试套件的成败。在测试之后,我们还需要注意重置对象的状态,以便其他不相关但也引入了单例对象的其他测试用例不受影响。

import Counter from "../src/counterTest";test("incrementing 1 time should be 1", () => { Counter.increment(); expect(Counter.getCount()).toBe(1);});test("incrementing 3 extra times should be 4", () => { Counter.increment(); Counter.increment(); Counter.increment(); expect(Counter.getCount()).toBe(4);});test("decrementing 1 times should be 3", () => { Counter.decrement(); expect(Counter.getCount()).toBe(3);});代码的坏味道:隐藏对于单例模块的依赖

当引入某一个其他模块,在此例中是superCounter.js时,引用superCounter的代码可能并不很清楚它的深层依赖是一个单例对象。而假设同时其他文件比如index.js中引入这个superCounter类,并且调用了递增或者递减方法。如此一来我们可能会在无意间改变了单例对象的值。这会导致意外行为的发生,由于多个superCounter的实例运行与整个应用中,而多个superCounter实例实际上对单例对象进行了多次引用,于是所有对于单例对象的引用都会导致counter属性的修改。而这一修改很可能并不是代码作者的本意,但却无意间导致了意外行为的产生。

import Counter from "./counter";export default class SuperCounter { constructor() { this.count = 0; } increment() { Counter.increment(); return (this.count += 100); } decrement() { Counter.decrement(); return (this.count -= 100); }}全局行为

单例实例本应在整个应用中被全局引用。但是全局变量本质上都会具有统一的特质:既然全局变量可以在全局作用域中使用,那我们理所当然的能够在全局任何地方获取到全局变量的引用。

于是问题出现了,在程序工程领域中,全局变量通常被认为是一个糟糕的设计。无意中对全局变量的覆盖最终造成全局变量污染,而这也是自有工程化的编写代码以来最常见的以外行为产生的原因。

在ES2015时代开启之后,创建全局变量已经不太常见。新引入的let和const关键字通过绑定代码块作用域,能够阻止开发人员无意间污染全局作用域。另外新引入的模块系统在更易于创建全局可获取的变量之余,免除了对全局作用域的污染。

但是单例模式最常见使用场景反而正是这种需要在整个应用中保存某种全局状态的用例。多个模块依赖于同一个可变对象的编程范式容易导致不可预知的行为发生。

常见的使用场景是代码库中的一方修改数据,另一方消费数据。因此各自的执行顺序则至关重要:毕竟我们可不希望一不小心在还没有任何数据的时候开始执行消费数据的代码。当整个应用越来越大,组件之间的依赖越来越复杂时,想要搞明白庞杂应用之内的相互调用关系也就变得愈加棘手。

React中的状态管理

在React中,应用通常会依赖一些状态管理工具,诸如Redux或者React Context,但单例模式的数据管理并不是选择之一。虽然这些工具的状态管理行为看上去似乎与单例模式有些相像,但他们通常会提供一种状态只读的能力以区别于单例模式下的可变数据模型。当使用Redux时,只有纯函数类型的reducer在收到组件通过dispatcher向其发送的action之后才允许更新状态。

虽然使用全局状态的缺点并不会因为引入了这些状态管理工具而奇迹般的消失,但由于组件无法直接更新状态,因此至少可以保证全局状态在变更时是来自于代码的真实意图。

原文地址:https://www.patterns.dev/posts/singleton-pattern/