单例模式

291 阅读7分钟

在我们的程序中使用共享的全局实例

单例是可以被实例化一次的类,它可以被全局访问到。这个单一实例可以在我们程序中共享,这使得单例非常适合管理应用程序中的全局状态。

首先,让我们看一下使用 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方法返回的值实际上是返回了2个不同的实例的引用:它们并不是严格相等。

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

确保只能创建一个单例的一种方法是创建一个叫做instance的变量。在Counter的构造函数中,我们可以在创建新实例时将实例设置为对实例的引用。我们可以通过检查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实例。但是在此之前,我们最好使用 freeze这个实例。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的当前值
  • buluButton.js: 导入Counter类,并将Counterdecrement方法作为事件监听器添加到蓝色按钮上,并通过调用getCount方法记录counter的当前值

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

当我们在redButton.js或者blueButton.js中调用increment方法,两个文件中Counter实例上的counter属性值都会更新。不管我们点击红色或蓝色的按钮都没关系:所有的实例共享相同的值。这就是为什么counter一直加一的原因,即使我们是在不同的文件中调用该方法。

优(缺)点

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

\

在许多编程语言中,比如Java或者C++,是不可能像JavaScript那样直接创建对象的。在那些面向对象的编程语言中,我们需要创建一个类,这个类会创建一个对象。该创建的对象拥有类实例的值,就像(上面)JavaScript示例中的实例一样。

\

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

\

使用常规对象

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

  • 一个count属性
  • 一个让count属性值加一的increment方法
  • 一个让count属性值减一的decrement方法
// counter.js
let count = 0;
const counter = {
  increment() {
    return ++count;
  },
  decrement() {
    return --count;
  }
};
Object.freeze(counter);
export { counter };

由于对象是通过引用传递的,redButton.jsblueButton.js都导入了相同的counter对象引用,修改这些文件中的任何一个count值同样都会修改counter对象上的值,对于这两个js文件都是可见的。

测试

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

// test.js
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
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);
  }
}

依赖隐藏

当导入其他模块时,在这种情况下的superCounter.js,模块导入的Singleton可能并不明显。在其他文件中,如本例中的index.js,我们可能正在导入该模块并调用其方法。如果我们不小心修改了Singleton中的值,这会产生意想不到的行为,因为在整个应用中共享了Singleton的多个实例,这实例也都会被修改。

// test.js
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);
});
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);
  }
}

全局行为

一个单例应该能在整个App中被引用,本质上全局变量显示相同的行为:由于全局变量在全局作用域内可用,我们可以在整个App中访问这些变量。

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

在ES2015创建全局变量是非常少见的 ,新的letconst关键字可以防止开发者意外的污染作用域,通过使用这两个关键字声明的变量将被限制在块级作用域。JavaScript中新的模块系统使创建全局可访问的值更容易,而不用污染全局作用域。通过使用export关键字将值导出,并在其他文件中使用import关键字将这些值导入。

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

通常,代码中的某些部分会修改全局作用域中的某个值,而另外一部分会使用到该值。在这里,执行顺序非常重要:我们不能在没有数据的时候去意外的使用数据。随着App程序的增常,以及数十个组件相互依赖,在使用全局状态是理解数据流可能会变得非常棘手。

React中的状态管理

在React中,我们经常使用Redux或者React Context等状态管理工具来管理全局状态,而不是使用单例。尽管它们与单例管理全局状态的行为看起来很相似,这些工具提供只读状态而不是单例的可变状态。当使用Redux的时候,只有在组件通过发送一个dispatcher动作之后,纯函数reducers才能够更新状态。

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

原文

Singleton Pattern