单例模式

单例(统一用名词 Singleton 代替)是可以实例化一次的类,并且可以全局访问。单个实例可以在整个应用程序中共享,非常适合管理应用程序中的全局状态。


计数器案例

构建一个Counter计数器具有以下内容的类:

  • 返回实例值的 getInstance 方法
  • 返回计数器变量当前值的 getCount 方法
  • 将计数器的值 +1 的 increment 方法
  • 将计数器的值 -1 的 decrement 方法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

let counter = 0;

class Counter {
  getInstance() {
    return this;
  }

  getCount() {
    return counter;
  }

  increment() {
    return ++counter;
  }

  decrement() {
    return --counter;
  }
}

然而,这个类不符合 Singleton 的标准! Singleton 应该只能被实例化一次。 上例可以创建 Counter 类的多个实例。可验证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

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

通过两次调用新方法,发现 counter1 和 counter2 被设置为不相等的实例。
counter1 和 counter2 上的 getInstance 方法返回的值有效地返回了对不同实例的引用。


如何确保只能创建类的一个实例?

  1. 确保只能创建一个实例的一种方法是创建一个名为 instance 的变量。

  2. 在 Counter 的构造函数中,我们可以在创建新实例时将 instance 设置为对实例的引用。

  3. 我们可以通过检查实例变量是否已经有值来防止新的实例化,并通过 throw 语句抛出错误提示。

throw 语句用来抛出一个用户自定义的异常。当前函数的执行将被停止(throw之后的语句将不会执行),并且控制将被传递到调用堆栈中的第一个catch块。如果调用者函数中没有catch块,程序将会终止。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

let instance;
let counter = 0;

class Counter {
  constructor() {
    if (instance) {
      throw new Error("您只能创建一个实例!"); 
      //throw 语句用来抛出一个用户自定义的异常。当前函数的执行将被停止(throw 之后的语句将不会执行)。
    }
    instance = this;
  }

  getInstance() {
    return this;
  }

  getCount() {
    return counter;
  }

  increment() {
    return ++counter;
  }

  decrement() {
    return --counter;
  }
}

const counter = new Counter();
const counter2 = new Counter();
// Error: 您只能创建一个实例!

上面这种情况,如果实例已经存在,则不应该继续:抛出一个错误让用户知道。
Perfect! 我们无法再创建多个实例。


如何冻结单例?

让我们从 counter.js 文件中导出 Counter 实例。 但在此之前,我们也应该冻结实例。

Object.freeze() 方法可以冻结一个对象。一个被冻结的对象再也不能被修改。此外,冻结一个对象后该对象的原型也不能被修改。freeze() 返回和传入的参数相同的对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
let instance;
let counter = 0;

class Counter {
  constructor() {
    if (instance) {
      throw new Error("您只能创建一个实例!");
    }
    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 属性的值都会更新。
我们点击红色按钮还是蓝色按钮并不重要:所有实例共享相同的值。 这就是为什么即使我们在不同的文件中调用该方法,计数器也会不断增加 1 的原因。


使用常规对象字面量实现

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

但是,上面示例中显示的类实现实际上是矫枉过正。由于我们可以直接在 JavaScript 中创建对象,因此我们可以简单地使用常规对象来实现完全相同的结果。

  • count property
  • 将 count 的值 +1 的 increment 方法
  • 将 count 的值 -1 的 decrement 方法

与 ES6 的 class 实现方式等效

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

let count = 0;

const counter = {
  increment() {
    return ++count;
  },
  decrement() {
    return --count;
  }
};

Object.freeze(counter);
export { counter };

由于对象是通过引用(reference)传递的,因此 redButton.js 和 blueButton.js 都导入了对同一个 singletonCounter 对象的引用。
修改其中一个文件中的 count 值将修改 singletonCounter 上的值,这在两个文件中都可见。


单例的优缺点

将实例化限制为仅一个实例可能会节省大量内存空间。不必每次都为一个新实例设置内存,我们只需为整个应用程序引用的那个实例设置内存。

然而,单例实际上被认为是一种反模式,并且可以(或应该)在 JavaScript 中避免。

让我们介绍一下使用单例的一些缺点!

1. 测试

测试依赖于 Singleton 的代码可能会变得棘手。 由于我们不能每次都创建新的实例,所以所有的测试都依赖于对之前测试的全局实例的修改。
在这种情况下,测试的顺序很重要,一个小的修改可能会导致整个测试套件失败。
测试后,我们需要重置整个实例,以重置测试所做的修改。

src/conterTest.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

// 此文件是必需的,确保此示例中的测试不会失败。
// 它是 counter.js 的副本

let instance;
let counter = 0;

class Counter {
  constructor() {
    if (instance) {
      throw new Error("您只能创建一个实例!");
    }
    instance = this;
    this.counter = counter;
  }

  getInstance() {
    return this;
  }

  getCount() {
    return counter;
  }

  increment() {
    return ++counter;
  }

  decrement() {
    return --counter;
  }
}

const singletonCounter = Object.freeze(new Counter());

export default singletonCounter;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19

import Counter from "../src/counterTest";

test("增加 1 次应该是 1", () => {
  Counter.increment();
  expect(Counter.getCount()).toBe(1);
});

test("增加 3 个额外的次数应该是 4", () => {
  Counter.increment();
  Counter.increment();
  Counter.increment();
  expect(Counter.getCount()).toBe(4);
});

test("递减1次应该是3", () => {
  Counter.decrement();
  expect(Counter.getCount()).toBe(3);
});

2. 依赖隐藏

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

3. 全局行为

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

拥有全局变量通常被认为是一个糟糕的设计决策。全局范围污染最终可能会意外覆盖全局变量的值,从而导致许多意外行为。

在 ES2015 中,创建全局变量是相当罕见的。新的 let 和 const 关键字通过将使用这两个关键字声明的变量保持在块范围内来防止开发人员意外污染全局范围。 JavaScript 中的新模块系统通过能够从模块导出值并将这些值导入其他文件,从而在不污染全局范围的情况下更容易地创建全局可访问值。

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

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


React 中的状态管理

在 React 中,我们经常通过 Redux 或 React Context 等状态管理工具来依赖全局状态,而不是使用 Singleton。
尽管它们的全局状态行为可能看起来类似于 Singleton 的行为,但这些工具提供只读状态而不是 Singleton 的可变状态。
使用 Redux 时,只有纯函数化简器(reducers)才能在组件通过调度程序(dispatcher)发送操作后更新状态。

尽管使用这些工具并没有神奇地消除拥有全局状态的缺点,但我们至少可以确保全局状态按照我们想要的方式进行变异,因为组件不能直接更新状态。


知识点

  • class
  • 对象字面量
  • throw 语句
  • Object.freeze()