对我们的下一本书感兴趣吗?了解更多关于 使用 React 构建大规模 JavaScript Web 应用程序

设计模式

单例模式

单例是只能实例化一次的类,并且可以全局访问。这个单个实例可以在整个应用程序中共享,这使得单例非常适合管理应用程序中的全局状态。

首先,让我们看看使用 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方法,我们只是将counter1counter2设置为不同的实例。counter1counter2getInstance方法返回的值实际上返回了对不同实例的引用:它们并不完全相等!

让我们确保只能创建一个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.jsblueButton.js模块
  • redButton.js:导入Counter,并将Counterincrement方法添加为红色按钮的事件监听器,并通过调用getCount方法记录counter的当前值
  • blueButton.js:导入Counter,并将Counterincrement方法添加为蓝色按钮的事件监听器,并通过调用getCount方法记录counter的当前值
index.js
counter.js
redButton.js
blueButton.js
1import "./redButton";
2import "./blueButton";
3
4console.log("Click on either of the buttons 🚀!");

blueButton.jsredButton.js都从counter.js导入同一个实例。此实例在两个文件中都作为Counter导入。

当我们在redButton.jsblueButton.js中调用increment方法时,Counter实例上的counter属性的值在两个文件中都会更新。无论我们点击红色按钮还是蓝色按钮,相同的值都会在所有实例之间共享。这就是为什么即使我们在不同的文件中调用方法,计数器也会继续增加1。


权衡

将实例化限制为一个实例可能会节省大量内存空间。我们可以只为一个实例设置内存,而不是每次都为新的实例设置内存,这个实例在整个应用程序中被引用。然而,单例实际上被认为是一种反模式,在 JavaScript 中可以(或者应该)避免使用。

在许多编程语言(如 Java 或 C++)中,我们无法像在 JavaScript 中那样直接创建对象。在那些面向对象的编程语言中,我们需要创建一个类,它创建了一个对象。该创建的对象具有该类实例的值,就像 JavaScript 示例中的instance的值一样。

然而,上述示例中显示的类实现实际上是过度的。由于我们可以直接在 JavaScript 中创建对象,因此我们可以简单地使用普通对象来实现完全相同的结果。让我们讨论使用单例的一些缺点!

使用普通对象

让我们使用与之前相同的示例。但是这一次,counter只是一个包含以下内容的对象:

  • 一个count属性
  • 一个将count的值增加1的increment方法
  • 一个将count的值减少1的decrement方法
counter.js
1let count = 0;
2
3const counter = {
4 increment() {
5 return ++count;
6 },
7 decrement() {
8 return --count;
9 }
10};
11
12Object.freeze(counter);
13export { counter };

由于对象是按引用传递的,因此redButton.jsblueButton.js都导入对同一个counter对象的引用。修改这两个文件中任何一个的count的值,都会修改counter上的值,这在两个文件中都是可见的。

测试

测试依赖于单例的代码可能很棘手。由于我们无法每次都创建新实例,因此所有测试都依赖于对先前测试的全局实例的修改。测试的顺序在此很重要,一个小的修改可能会导致整个测试套件失败。测试后,我们需要重置整个实例,以重置测试所做的修改。

test.js
superCounter.js
1import Counter from "../src/counterTest";
2
3test("incrementing 1 time should be 1", () => {
4 Counter.increment();
5 expect(Counter.getCount()).toBe(1);
6});
7
8test("incrementing 3 extra times should be 4", () => {
9 Counter.increment();
10 Counter.increment();
11 Counter.increment();
12 expect(Counter.getCount()).toBe(4);
13});
14
15test("decrementing 1 times should be 3", () => {
16 Counter.decrement();
17 expect(Counter.getCount()).toBe(3);
18});

依赖项隐藏

在导入另一个模块(在本例中为superCounter.js)时,可能不清楚该模块正在导入单例。在其他文件中(在本例中为index.js),我们可能会导入该模块并调用其方法。这样,我们就会意外地修改了单例中的值。这会导致意外行为,因为单例的多个实例可以在整个应用程序中共享,这些实例也会被修改。

test.js
superCounter.js
1import Counter from "./counter";
2
3export default class SuperCounter {
4 constructor() {
5 this.count = 0;
6 }
7
8 increment() {
9 Counter.increment();
10 return (this.count += 100);
11 }
12
13 decrement() {
14 Counter.decrement();
15 return (this.count -= 100);
16 }
17}

全局行为

单例实例应该可以在整个应用程序中被引用。全局变量本质上表现出相同的行为:由于全局变量在全局范围内可用,因此我们可以在整个应用程序中访问这些变量。

使用全局变量通常被认为是糟糕的设计决策。全局范围污染最终会导致意外覆盖全局变量的值,这会导致很多意外行为。

在 ES2015 中,创建全局变量相当少见。新的letconst关键字通过将使用这两个关键字声明的变量保持为块级作用域,防止开发人员意外污染全局范围。JavaScript 中新的module系统通过能够从模块export值,并在其他文件中import这些值,使创建全局可访问的值更容易,而不会污染全局范围。

但是,单例的常见用例是在整个应用程序中拥有某种全局状态。让代码库的多个部分依赖于同一个可变对象会导致意外行为。

通常,代码库的某些部分会修改全局状态中的值,而其他部分则使用这些数据。这里的执行顺序很重要:我们不想在没有数据可使用(还没有!)时意外地先使用数据!当您的应用程序增长时,理解使用全局状态时的数据流可能会变得非常棘手,并且数十个组件相互依赖。

React 中的状态管理

在 React 中,我们经常依靠ReduxReact Context等状态管理工具来实现全局状态,而不是使用单例。虽然它们的全局状态行为可能看起来与单例类似,但这些工具提供了只读状态,而不是单例的可变状态。在使用 Redux 时,只有纯函数reducer可以在组件通过dispatcher发送action后更新状态。

虽然使用这些工具并不能神奇地消除拥有全局状态的缺点,但我们至少可以确保全局状态按预期进行修改,因为组件无法直接更新状态。


参考资料