Skip to content
在此页中

单例模式

在应用中共享同一个全局实例

单例是指可以实例化一次,并能全局访问的类。这种单一实例可以在应用中共享,这使单例非常适合管理应用中的全局状态。

首先,让我们看看使用 ES2015 类来实例化的单例会是什么样子。在这个例子中,我们将构建 Counter 类,它具有:

  • getInstance 方法返回这个实例
  • getCount 方法返回 counter 变量的当前值
  • increment 方法将 counter 增加 1
  • decrement 方法将 counter 减少 1
JavaScript
let counter = 0;

class Counter {
  getInstance() {
    return this;
  }

  getCount() {
    return counter;
  }

  increment() {
    return ++counter;
  }

  decrement() {
    return --counter;
  }
}

然而,这个类不符合单例的标准!一个单例只能被 实例化一次 。现在,我们可以多次实例化 Counter 类。

JavaScript
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 两个不相同的实例。 counter1counter2 上的 getInstance 方法的返回值只是不同的实例引用:它们并不严格相等!

让我们确保只能创建 Counter 类的 一个 实例。

确保只能创建一个实例的其中一种方法就是创建一个称为 instance 的变量。在 Counter 的构造函数中,我们可以将 instance 设置为当新实例创建时对该实例的引用。我们可以通过检查 instance 变量是否已经有值来防止新的实例化。如果是这种情况,实例已经存在。这不应该发生:应该抛出一个错误让用户知道。

JavaScript
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();
// 错误: 只能创建一个实例!

完美!我们再也不能创建多个实例了。

让我们从 counter.js 文件中导出 Counter 实例。但在此之前,我们也应该 冻结 该实例。 Object.freeze 方法使得代码无法修改单例。无法添加或修改被冻结的实例的属性,这降低了意外覆盖单例属性的风险。

JavaScript
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 的值

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

当我们在 redbutton.jsbluebutton.js 中调用 increment 方法时,两个文件中的 Counter 实例的 counter 属性值都更新了。不管我们点击红色按钮还是蓝色按钮:在所有实例中都共享了相同的值。这就是为什么计数器一直会递增一的原因——即使我们在不同的文件中调用这个方法。

优势(劣势)

将实例化限制为仅一个实例可以节约大量内存空间。我们不必每次都为新实例设置内存,而只需为一个实例设置内存,该实例在整个应用中都可以被调用。但是,单例实际上被认为是一种 反模式 ,并且可以(或... 应该)在 JavaScript 中避免使用。

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

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

使用字面量对象

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

  • count 属性
  • increment 方法将 count 增加一
  • a decrement 方法将 count 减少一
let count = 0;

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

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

由于对象是通过引用传递的,redButton.jsblueButton.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 ,模块正导入的是一个单例可能并不明显。在其他文件中,在此例子中例如 index.js ,我们可能正在导入那个模块并且调用它的方法。因此,我们意外地修改了单例中的值。这可能会导致意外行为,因为可以在整个应用中共享单例的多个实例,这些实例也将同时被改变。

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 中,创建全局变量变得十分罕见。新的 letconst 关键字通过将使用它们声明的变量保持在块级作用域内来防止开发者意外污染全局作用域。JavaScript 中,新的 module 系统使创建全局可访问的值更容易,并且不会污染全局作用域,它能从模块中 export 值,并 import 这些值到其他文件中。

然而,单例的常见用例是在整个应用中维护某种 全局状态。让代码在多处依赖同一个 可变对象 可能会导致意外行为。

通常,代码中的某些部分会修改全局状态中的值,而其他部分会使用该数据。在这里,执行顺序很重要:我们不想在(还)没有数据可供消费时,贸然地先去消费数据!随着应用的增长以及数量众多的组件相互依赖,使用全局状态时理解数据流可能会变得非常棘手。

React 中的状态管理

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

尽管使用这些工具不会奇迹般地消除全局状态的缺点,但我们至少可以确保全局状态按照我们想要的方式发生变化,因为组件不能直接更新状态。

参考

Creative Commons License