单例是可以实例化一次并可以全局访问的类。单一实例可以在我们的应用程序中共享,这使得单例非常适合管理应用程序中的全局状态。
首先,看一下使用 ES2015 类的单例是什么样子。对于这个例子,我们将构建一个 Counter
类,它具有:
- 返回实例值的
getInstance
方法 - 返回
counter
变量当前值的getCount
方法 - 将
counter
的值增加 1 的increment
方法 - 将
counter
的值减一的decrement
方法
let counter = 0;
class Counter {
getInstance() {
return this;
}
getCount() {
return counter;
}
increment() {
return ++counter;
}
decrement() {
return --counter;
}
}
然而,这个类不符合 Singleton 的标准。 Singleton 应该只能被实例化一次。目前,我们可以创建 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
属性的值> 两个文件中的实例更新。我们点击红色还是蓝色按钮并不重要:所有实例共享相同的值。这就是为什么即使我们在不同的文件中调用该方法,计数器也会不断增加 1。
权衡
将实例化限制为仅一个实例可能会节省大量内存空间。我们不必每次都为一个新实例设置内存,而只需为该一个实例设置内存,该实例在整个应用程序中都会被引用。然而,单例实际上被认为是一种反模式,并且可以(或..应该)在 JavaScript 中避免。
在许多编程语言中,例如 Java 或 C++,不可能像 JavaScript 那样直接创建对象。在那些面向对象的编程语言中,我们需要创建一个类,它创建一个对象。创建的对象具有类实例的值,就像 JavaScript 示例中 instance
的值一样。
然而,上面示例中显示的类实现实际上是多余的。由于我们可以直接在 JavaScript 中创建对象,因此我们可以简单地使用常规对象来实现完全相同的结果。让我们来看看使用单例的一些缺点。
使用常规对象
让我们使用之前看到的相同示例。然而这一次, counter
只是一个包含以下内容的对象:
- a
count
propertycount
属性 - 将
count
的值增加 1 的increment
方法 - 将
count
的值减一的decrement
方法
let count = 0;
const counter = {
increment() {
return ++count;
},
decrement() {
return --count;
},
};
Object.freeze(counter);
export { counter };
由于对象是通过引用传递的,因此 redButton.js
和 blueButton.js
都导入对同一 counter
对象的引用。修改这两个文件中的 count
的值将修改 counter
上的值,该值在这两个文件中都可见。
测试
测试依赖于单例的代码可能会很棘手。由于我们无法每次都创建新实例,因此所有测试都依赖于对上一次测试的全局实例的修改。在这种情况下,测试的顺序很重要,一个小的修改可能会导致整个测试套件失败。测试完成后,我们需要重置整个实例,以重置测试所做的修改。
mport 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
,模块正在导入 Singleton 可能并不明显。在其他文件中,例如本例中的 index.js
,我们可能会导入该模块并调用其方法。这样,我们就无意中修改了 Singleton 中的值。这可能会导致意外的行为,因为单例的多个实例可以在整个应用程序中共享,这些实例也都会被修改。
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);
}
}
全局行为
Singleton 实例应该能够在整个应用程序中被引用。全局变量本质上表现出相同的行为:由于全局变量在全局范围内可用,因此我们可以在整个应用程序中访问这些变量。
拥有全局变量通常被认为是一个糟糕的设计决策。全局范围污染最终可能会意外覆盖全局变量的值,这可能会导致许多意外行为。
在 ES2015 中,创建全局变量相当罕见。新的 let
和 const
关键字通过将使用这两个关键字声明的变量保持在块范围内,可以防止开发人员意外污染全局范围。 JavaScript 中的新 module
系统可以更轻松地创建全局可访问的值,而不会污染全局范围,因为能够从模块获取 export
值,以及 import
这些值在其他文件中。
然而,单例的常见用例是在整个应用程序中拥有某种全局状态。让代码库的多个部分依赖于同一个可变对象可能会导致意外的行为。
通常,代码库的某些部分会修改全局状态中的值,而其他部分则使用该数据。这里的执行顺序很重要:我们不想在没有数据可供使用时意外地首先使用数据。随着应用程序的增长,并且数十个组件相互依赖,理解使用全局状态时的数据流可能会变得非常棘手。
React 中的状态管理
在 React 中,我们经常通过 Redux 或 React Context 等状态管理工具来依赖全局状态,而不是使用单例。尽管它们的全局状态行为可能看起来与单例的行为类似,但这些工具提供只读状态而不是单例的可变状态。使用 Redux 时,在组件通过dispatcher发送操作后,只有纯函数reducers才能更新状态。
尽管使用这些工具并不会神奇地消除全局状态的缺点,但我们至少可以确保全局状态按照我们预期的方式发生变化,因为组件无法直接更新状态。