设计模式
单例模式
单例是只能实例化一次的类,并且可以全局访问。这个单个实例可以在整个应用程序中共享,这使得单例非常适合管理应用程序中的全局状态。
首先,让我们看看使用 ES2015 类单例可能是什么样子。在这个例子中,我们将构建一个Counter
类,它具有
- 一个返回实例值的
getInstance
方法 - 一个返回
counter
变量当前值的getCount
方法 - 一个将
counter
的值增加1的increment
方法 - 一个将
counter
的值减少1的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
的当前值
1import "./redButton";2import "./blueButton";34console.log("Click on either of the buttons 🚀!");
blueButton.js
和redButton.js
都从counter.js
导入同一个实例。此实例在两个文件中都作为Counter
导入。
当我们在redButton.js
或blueButton.js
中调用increment
方法时,Counter
实例上的counter
属性的值在两个文件中都会更新。无论我们点击红色按钮还是蓝色按钮,相同的值都会在所有实例之间共享。这就是为什么即使我们在不同的文件中调用方法,计数器也会继续增加1。
权衡
将实例化限制为一个实例可能会节省大量内存空间。我们可以只为一个实例设置内存,而不是每次都为新的实例设置内存,这个实例在整个应用程序中被引用。然而,单例实际上被认为是一种反模式,在 JavaScript 中可以(或者应该)避免使用。
在许多编程语言(如 Java 或 C++)中,我们无法像在 JavaScript 中那样直接创建对象。在那些面向对象的编程语言中,我们需要创建一个类,它创建了一个对象。该创建的对象具有该类实例的值,就像 JavaScript 示例中的instance
的值一样。
然而,上述示例中显示的类实现实际上是过度的。由于我们可以直接在 JavaScript 中创建对象,因此我们可以简单地使用普通对象来实现完全相同的结果。让我们讨论使用单例的一些缺点!
使用普通对象
让我们使用与之前相同的示例。但是这一次,counter
只是一个包含以下内容的对象:
- 一个
count
属性 - 一个将
count
的值增加1的increment
方法 - 一个将
count
的值减少1的decrement
方法
1let count = 0;23const counter = {4 increment() {5 return ++count;6 },7 decrement() {8 return --count;9 }10};1112Object.freeze(counter);13export { counter };
由于对象是按引用传递的,因此redButton.js
和blueButton.js
都导入对同一个counter
对象的引用。修改这两个文件中任何一个的count
的值,都会修改counter
上的值,这在两个文件中都是可见的。
测试
测试依赖于单例的代码可能很棘手。由于我们无法每次都创建新实例,因此所有测试都依赖于对先前测试的全局实例的修改。测试的顺序在此很重要,一个小的修改可能会导致整个测试套件失败。测试后,我们需要重置整个实例,以重置测试所做的修改。
1import Counter from "../src/counterTest";23test("incrementing 1 time should be 1", () => {4 Counter.increment();5 expect(Counter.getCount()).toBe(1);6});78test("incrementing 3 extra times should be 4", () => {9 Counter.increment();10 Counter.increment();11 Counter.increment();12 expect(Counter.getCount()).toBe(4);13});1415test("decrementing 1 times should be 3", () => {16 Counter.decrement();17 expect(Counter.getCount()).toBe(3);18});
依赖项隐藏
在导入另一个模块(在本例中为superCounter.js
)时,可能不清楚该模块正在导入单例。在其他文件中(在本例中为index.js
),我们可能会导入该模块并调用其方法。这样,我们就会意外地修改了单例中的值。这会导致意外行为,因为单例的多个实例可以在整个应用程序中共享,这些实例也会被修改。
1import Counter from "./counter";23export default class SuperCounter {4 constructor() {5 this.count = 0;6 }78 increment() {9 Counter.increment();10 return (this.count += 100);11 }1213 decrement() {14 Counter.decrement();15 return (this.count -= 100);16 }17}
全局行为
单例实例应该可以在整个应用程序中被引用。全局变量本质上表现出相同的行为:由于全局变量在全局范围内可用,因此我们可以在整个应用程序中访问这些变量。
使用全局变量通常被认为是糟糕的设计决策。全局范围污染最终会导致意外覆盖全局变量的值,这会导致很多意外行为。
在 ES2015 中,创建全局变量相当少见。新的let
和const
关键字通过将使用这两个关键字声明的变量保持为块级作用域,防止开发人员意外污染全局范围。JavaScript 中新的module
系统通过能够从模块export
值,并在其他文件中import
这些值,使创建全局可访问的值更容易,而不会污染全局范围。
但是,单例的常见用例是在整个应用程序中拥有某种全局状态。让代码库的多个部分依赖于同一个可变对象会导致意外行为。
通常,代码库的某些部分会修改全局状态中的值,而其他部分则使用这些数据。这里的执行顺序很重要:我们不想在没有数据可使用(还没有!)时意外地先使用数据!当您的应用程序增长时,理解使用全局状态时的数据流可能会变得非常棘手,并且数十个组件相互依赖。
React 中的状态管理
在 React 中,我们经常依靠Redux或React Context等状态管理工具来实现全局状态,而不是使用单例。虽然它们的全局状态行为可能看起来与单例类似,但这些工具提供了只读状态,而不是单例的可变状态。在使用 Redux 时,只有纯函数reducer可以在组件通过dispatcher发送action后更新状态。
虽然使用这些工具并不能神奇地消除拥有全局状态的缺点,但我们至少可以确保全局状态按预期进行修改,因为组件无法直接更新状态。
参考资料
- React Hooks 是否取代了 Redux - Eric Elliott
- JavaScript 设计模式:单例 - Samier Saeed
- 单例 - 重构大师