# 单例模式
TIP
在整个应用程序中共享单个全局实例
# 例子
首先,让我们看看使用 ES2015类的单例模式是什么样子的。对于这个示例,我们将构建一个 Counter 类,它具有:
- 返回实例值的 getInstance 方法
- 返回计数器变量当前值的 getCount 方法
- 将计数器的值加一的加法
- 把计数器的值减一的减法
let counter = 0;
class Counter {
getInstance() {
return this;
}
getCount() {
return counter;
}
increment() {
return ++counter;
}
decrement() {
return --counter;
}
}
但是,这个类不符合单例的标准!Singleton 应该只能被实例化一次。目前,我们可以创建 Counter 类的多个实例。
const counter1 = new Counter();
const counter2 = new Counter();
console.log(counter1.getInstance() === counter2.getInstance()); // false
通过两次调用这个新方法,我们只需要将 counter 1和 counter 2设置为等于不同的实例。GetInstance 方法返回的值对 Counter1和 Counter2有效地返回了对不同实例的引用: 它们不是严格相等的!
让我们确保只能创建 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 实例。但是在这样做之前,我们还应该冻结该实例。急冻方法确保使用代码不能修改 Singleton。不能添加或修改冻结实例上的属性,这降低了意外覆盖 Singleton 上的值的风险。
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 和 bluButton.js 模块
redButton.js
: 导入 Counter,并将 Counter 的递增方法作为事件侦听器添加到红色按钮,并通过调用 getCount 方法记录 Counter 的当前值
blueButton.js
: 导入 Counter,并将 Counter 的递增方法作为事件侦听器添加到蓝色按钮,并通过调用 getCount 方法记录 Counter 的当前值
// redButton.js
import Counter from "./counter";
const button = document.getElementById("red");
button.addEventListener("click", () => {
Counter.increment();
console.log("Counter total: ", Counter.getCount());
});
// blueButton.js
import Counter from "./counter";
const button = document.getElementById("blue");
button.addEventListener("click", () => {
Counter.increment();
console.log("Counter total: ", Counter.getCount());
});
// index.js
import "./redButton";
import "./blueButton";
console.log("Click on either of the buttons 🚀!");
blueButton.js
和 redButton.js
都从 Counter.js
导入相同的实例,这个实例在两个文件中都作为 Counter 导入。
当我们在 redButton.js 或 bluButton.js 中调用递增方法时,Counter 实例上的 Counter 属性的值会在两个文件中都更新。单击红色或蓝色按钮并不重要: 所有实例共享相同的值。这就是为什么计数器一直递增一,即使我们在不同的文件中调用这个方法。
# 取舍
将实例化限制在一个实例中可能会节省大量内存空间。我们不必每次都为一个新实例设置内存,而只需为那个实例设置内存,这个实例在整个应用程序中都会被引用。然而,单例实际上被认为是反模式,并且可以(或者。.应该在 JavaScript 中避免)。
在许多编程语言中,如 Java 或 C++ ,不可能像在 JavaScript 中那样直接创建对象。在这些面向对象程序设计语言中,我们需要创建一个类来创建一个对象。创建的对象具有类的实例的值,就像 JavaScript 示例中的 instance 的值一样。
但是,上面示例中显示的类实现实际上有些夸张。因为我们可以直接在 JavaScript 中创建对象,所以我们可以简单地使用一个常规对象来实现完全相同的结果。让我们来谈谈使用单例模式的一些缺点吧!
# 使用一个普通对象
让我们使用前面看到的同一个例子,但是这一次,计数器只是一个对象包含:
- 一个 count 属性
- 将 count 的值加一的假发
- 把 count 的值减一的减法
let count = 0;
const counter = {
increment() {
return ++count;
},
decrement() {
return --count;
}
};
Object.freeze(counter);
export default counter;
由于对象是通过引用传递的,所以 redButton.js
和 bluButton.js
都导入了对同一个计数器对象的引用。修改这两个文件中的 count
的值将修改计数器上的值,该值在两个文件中都可见。
# 测试
测试依赖于 Singleton 的代码可能会很棘手。因为我们不能每次都创建新的实例,所有的测试都依赖于对前一个测试的全局实例的修改。在这种情况下,测试的顺序很重要,一个小小的修改就可能导致整个测试套件的失败。测试之后,我们需要重置整个实例,以便重置测试所做的修改。
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);
});
# 全局行为
一个 Singleton 实例应该能够在整个应用程序中被引用。全局变量基本上显示相同的行为: 因为全局变量在全局范围内可用,所以我们可以在整个应用程序中访问这些变量。
拥有全局变量通常被认为是一个糟糕的设计决策。全局范围污染最终可能意外地覆盖全局变量的值,这可能导致许多意想不到的行为。
在 ES2015中,创建全局变量是相当罕见的。新的 let 和 const 关键字通过保持用这两个关键字块作用域声明的变量,防止开发人员意外地污染全局作用域。JavaScript 中的新模块系统使得创建全局可访问值变得更加容易,而不会污染全局范围,因为它能够从模块中导出值,并将这些值导入到其他文件中。
但是,Singleton 的常见用例是在整个应用程序中具有某种全局状态。代码库的多个部分依赖于同一个可变对象可能导致意外行为。
通常,代码库的某些部分修改全局状态中的值,而其他部分使用该数据。这里的执行顺序很重要: 我们不希望在没有数据可以使用的情况下意外地首先使用数据!在使用全局状态时理解数据流可能会随着应用程序的增长而变得非常棘手,因为许多组件相互依赖。
# 状态管理
在 React 中,我们通常通过状态管理工具(如 Redux 或 React Context)来依赖全局状态,而不是使用 Singleton。虽然它们的全局状态行为可能看起来类似于 Singleton,但是这些工具提供的是只读状态,而不是 Singleton 的可变状态。在使用 Redux 时,只有纯函数简化程序可以在组件通过调度程序发送操作之后更新状态。
虽然拥有全局状态的缺点不会因为使用这些工具而神奇地消失,但是我们至少可以确保全局状态按照我们想要的方式发生了变化,因为组件不能直接更新状态。
单例模式(Singleton):又被称为单体模式,是只允许实例化一次的对象类。有时我们也用一个对象来规划一个命名空间,井井有条地管理对象上的属性和方法。
单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点
单例模式是一种常用的模式,有一些对象我们往往只需要一个,比如线程池,全局缓存,浏览器中的 window 对象,sessionStorage 对象,localStorage 对象等。
# 简单实现单例模式
要实现一个标准的单例模式,基本思路是用一个变量来标识当前是否以及为某个类创建过对象,如果是,则下一次获取该类的实例时,直接返回之前创建的对象。
const Singleton = function (name) {
this.instance = null;
this.name = name;
};
Singleton.prototype.getName = function () {
return this.name;
};
Singleton.getInstance = (function (name) {
let instance = null;
return function (name) {
if (!instance) {
instance = new Singleton(name);
}
return instance;
};
})();
let a = Singleton.getInstance("alice");
let b = Singleton.getInstance("bob");
console.log(a.getName()); // alice
console.log(b.getName()); // alice
console.log(a === b); // true
这种方式相对简单,但有一个问题,就是增加了这个类的“不透明性”, Singleton 类的使用者必须知道这是一个单例类,跟以往通过 new XXX 的方式来获取对象不同,这里偏要使用 Singleton.getInstance 来获取对象
# 用代理实现单例模式
// 首先创建一个普通的生成div的类
const CreateDiv = function (html) {
this.html = html;
this.init();
};
CreateDiv.prototype.init = function () {
const div = document.createElement("div");
div.innerHTML = this.html;
document.body.appendChild(div);
};
// 然后引入代理类
const ProxySingletonCreateDiv = (function () {
let instance = null;
return function (html) {
if (!instance) {
instance = new CreateDiv(html);
}
return instance;
};
})();
// 测试
let a = new ProxySingletonCreateDiv("alice");
let b = new ProxySingletonCreateDiv("bob");
console.log(a === b); // true
# 全局变量与命名空间
全局变量不是单例模式,但在 JavaScript 开发中,我们经常会把全局变量当成单例来使用。 但是全局变量存在很多问题,它很容器造成命名空间污染,在早期没有模块化编程的大中型项目中,如果不加以限制和管理,就很容易让我们的程序存在很多这样的变量。
- 使用命名空间
1.1 使用对象字面量,一个对象就是一个模块,就是一个命名空间
const namespace1 = {
a:function(){
console.log('a')
},
b:function(){
console.log('b')
}b
}
1.2 巧妙的动态创建命名空间
const app = {};
app.namespace = function (name) {
const parts = name.split(".");
let current = app; // 指针
let next = null;
for (i in parts) {
next = current[parts[i]];
if (!next) {
current[parts[i]] = {};
next = current[parts[i]];
}
current = next;
}
};
app.namespace("event");
app.namespace("dom.style");
console.log(app); // { namespace: [Function], event: {}, dom: { style: {} } }
- 使用闭包封装私有变量
这种方法将一些变量封装在闭包的内部,只向外暴露一些接口
const user = (function () {
var _name = "tom";
var _age = "18";
return {
getUserInfo: function () {
return _name + "-" + _age;
}
};
})();
这里我们约定使用下划线来定义模块内部的私有变量
- 使用目前大行其道的模块化
借助于 nodejs 和各种打包器的生态,我们有了模块化,每一个模块都是一个闭包环境
import Cookies from "js-cookie";
const debug = process.NODE_ENV != "production";
function get(key) {
let value = Cookies.get(key);
debug && console.log(`cookie.get: ${key} -- ${value}`);
}
function set(key, value) {
Cookies.set(key, value);
debug && console.log(`cookie.set: ${key} -- ${value}`);
}
function clear() {
let cookies = document.cookie.split(";");
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i];
var eqPos = cookie.indexOf("=");
var name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
}
}
export default {
get,
set,
clear
};
# 惰性单例及引用
惰性单例指的是在需要的时候才创建对象示例,实际上我们前面已经这么做了。
现在让我们贴近需求,来创建一个唯一的弹窗实例。
<body>
<button id="loginBtn">登录</button>
</body>
const createSingleLoginLayer = (function () {
let div = null;
return function () {
if (!div) {
div = document.createElement("div");
div.innerHTML = "我是登录浮窗";
div.style.display = "none";
document.body.appendChild(div);
}
return div;
};
})();
document.getElementById("loginBtn").addEventListener("click", function () {
let loginLayer = createSingleLoginLayer();
loginLayer.style.display = "block";
});
是的,这样已经实现了,但我们还能继续优化,这里需要应用到单一职责原则。
// 通用的惰性单例
const getSingle = function (fn) {
let result;
return function () {
return result || (result = fn.apply(this, arguments));
};
};
// 具体业务
const createLoginLayer = function () {
div = document.createElement("div");
div.innerHTML = "我是登录浮窗";
div.style.display = "none";
document.body.appendChild(div);
};
const createSingleLoginLayer = getSingle(createLoginLayer);
document.getElementById("loginBtn").addEventListener("click", function () {
let loginLayer = createSingleLoginLayer();
loginLayer.style.display = "block";
});
在这个例子中,我们把创建实例对象和职责和管理单例的职责分别放置在两个方法里,这两个方法可以独立变化,当他们连接在一起的时候,就完成了创建唯一实例对象的功能,我只能说非常的酷 Cool~
又比如这个需求,给一个动态列表里绑定 click 事件。
// 使用jquery
const bindEvent = function () {
$("div").one("click", function () {
console.log("div.click");
});
};
const render = function () {
console.log("Start Render");
bindEvent();
};
render();
render();
render();
// 使用getSingle
const bindEvent = getSingle(function () {
document.getElementById("div").addEventListener("click", function () {
console.log("div.click");
});
});
const render = function () {
console.log("Start Render");
bindEvent();
};
render();
render();
render();
# 小结
单例模式是一种简单但非常使用的模式,特别是惰性单例模式,在合适的时候才创建对象,并且只创建唯一的一个。
单例模式的模板
// 通用的惰性单例
const getSingle = function (fn) {
let result;
return function () {
return result || (result = fn.apply(this, arguments));
};
};