✍作者:周棋洛,计算机学生
♉星座:金牛座
主页:点击学习更多
关键:JavaScript
单例
设计模式
单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
在日常生活中,经常会遇到一些独一无二、独特而又特殊的事物。有时候,这些事物的独特性是为了保护它的重要性,有时候则是为了提高效率和资源利用。
而在软件开发领域,单例设计模式就是一种让对象独一无二的方式。
单例模式是一种常用的模式,属于创建型设计模式。有一些对象我们往往只需要一个,比如线程池、全局缓存、浏览器中的 window 对象等。
在 JavaScript 开发中,单例模式的用途同样非常广泛。比如页面中的登录浮窗是唯一的,无论单击多少次按钮,这个浮窗都只会被创建一次,那么这个登录浮窗就适合用单例模式来创建。
想要实现一个标准的单例模式并不复杂,用一个变量来标志当前是否已经为某个类创建过对象,如果是,则在下一次获取该类的实例时,直接返回之前创建好的对象即可。用代码模拟一下:
let Singleton = function (name) {
this.name = name;
}
Singleton.getInstance = (function () {
// 闭包,实例标记
let instance = null;
return function (name) {
if (!instance) {
instance = new Singleton(name);
}
return instance;
}
})();
先定义一个构造函数,然后对外暴露了一个方法用来获取对象实例,这个方法中使用自执行函数返回一个闭包函数,借助闭包保留了 instance
局部变量的状态。
判断实例是否存在,如果不存在创建并返回,否则直接返回已有实例。确保了只创建一次。
// 测试
let a = Singleton.getInstance("王子周棋洛");
let b = Singleton.getInstance("王子");
console.log(a === b); // true
我们已经实现了一个简单的单例,但是它并不完美,看下面代码:
let n1 = new Singleton("周棋洛");
let n2 = new Singleton("周棋洛王子");
console.log(n1 === n2); // false
当用户使用new关键字创建对象时,我们的单例就控制不住了。下面,我们针对这种情况对代码做一些处理,实现一个透明的单例模式。
透明的单例模式,即用户从这个类创建对象时,可以像其他任意普通类一样。而不是只能通过我们规定的对外暴露的方法创建。
下面我们将写一个创建元素的方法,保证页面只创建一次,单实例。
let createP = (function () {
let instance;
// 重写构造函数
let createP = function (text) {
if (instance) {
return instance;
}
this.text = text;
this.init();
return instance = this;
}
createP.prototype.init = function () {
let p = document.createElement('p');
p.innerHTML = this.text;
document.body.appendChild(p);
}
// 返回新的构造函数
return createP;
})()
// 测试
let a = new createP('你好');
let b = new createP('世界');
console.log(a === b); // true
现在实现了一个透明的单例类,但它同样有一些缺点。
为了把 instance 状态保存起来,使用了自执行的匿名函数和闭包,并让这个匿名函数返回真正的构造方法,这增加了一些程序的可读性。
// 重写构造函数,闭包
let createP = function (text) {
if (instance) {
return instance;
}
this.text = text;
this.init();
return instance = this;
}
其次,观察重写的构造函数,他做了两件事,一是保证单例,二是执行初始化init方法创建P标签。根据“单一职责原则”
,这里的代码写的并不漂亮,假如如果我们有了新需求,创建100个P标签,怎么办,做法是新建一个方法把这个方法的单实例职责去掉,这样的修改会给我们带来不必要的烦恼。
“单一职责原则”:简单来说,就是一个类或者模块应该只负责一项职责,不要承担过多的职责,以达到代码更加清晰、简洁、易于维护的目的。
如果一个类或者模块承担的职责过多,一旦需要修改其中一部分功能时可能会影响其他功能,增加了耦合度,也会导致代码的复杂性增加,难以理解和维护。
下面我们将引入代理类,来解决上面职责过多的问题。
根据上面提到的单一职责,把负责单例的代码移除,使它变成一个普通的创建类。
// 单一职责,负责创建dom
let createP = function (text) {
this.text = text;
this.init();
}
createP.prototype.init = function () {
let p = document.createElement('p');
p.innerHTML = this.text;
document.body.appendChild(p);
}
// 引入代理类,闭包,单实例
let ProxySingletonCreateP = (function () {
let instance;
return function (text) {
if (!instance) {
instance = new createP(text);
}
return instance;
}
})();
// 测试
let a = new ProxySingletonCreateP('你好');
let b = new ProxySingletonCreateP('世界');
console.log(a === b); // true
与之前不同的是,通过引入代理类,把负责管理单例的逻辑转到了代理类 ProxySingletonCreateP 中。
这样一来,createP 就变成了一个普通的单一职责类。
在Java中,如果需要对象,首先要定义一个类,对象总是从类创建出来的。但是在 JavaScript 这个无类的语言中,创建对象十分简单。
JavaScript 对象的创建底层包含了四个步骤:创建新的空对象、设置对象原型、将 proto 属性指向该原型对象、执行构造函数或者直接返回字面量对象。
这些步骤都是为了将属性和方法添加到新对象中,并与原型对象建立联系。
在 JavaScript 中,全局变量是单例模式的一种体现。单例模式指的是只创建一个实例对象,并提供一个访问它的全局访问点。全局变量在 JavaScript 中也是只有一个实例,可以通过任何地方访问它,因此也符合单例模式的定义。
let a = {}
但是,需要注意的是,在 JavaScript 中过多地使用全局变量可能会导致命名空间污染和代码维护的困难。
因此,最好尽量避免使用全局变量,即使需要,也要把它的污染降到最低。
例如可以使用命名空间:
let namespace1 = {
a:10,
b:20
};
或者借助闭包封装私有变量等等:
let user = (function () {
let _name = '王子';
return {
getUserInfo: function () {
return _name;
}
}
})();
console.log(user.getUserInfo()); // 王子
使用下划线约定私有变量,变量被封装在闭包产生的作用域中,外部访问不到这些变量,这就避免了对全局的命名污染。
惰性单例指的是在需要的时候才创建对象实例。惰性单例是单例模式的重点,这种技术在开发中非常有用,实用程度可能超出了我们的想象。
现在有这样一个场景,我的网站接入了用户登录功能,登录悬浮窗只能存在一个,单实例。
我们可以在网页加载时,就创建出这个悬浮框,然后最开始的时候通过css将它隐藏,当点击登录按钮时,通过css将它显示出来,这样就确保了登录悬浮框的单实例。
但这种方式有一个问题,如果进入网页只是看看,根本不需要进行登录操作,因为登录悬浮框总是在网站加载就被创建好,那将白白浪费一些 DOM 节点。
简单改一下逻辑,当点击登录按钮时,再惰性创建登录悬浮窗,代码如下:
<body>
<button>登录button>
<script>
function createLoginBox() {
let div = document.createElement("div");
div.style.width = "300px";
div.style.height = "200px";
div.style.marginTop = "15px";
div.style.background = "#bbb";
div.innerHTML = "这是一个登录框";
document.body.appendChild(div);
}
document.querySelector("button").addEventListener("click", () => {
createLoginBox();
})
script>
body>
虽然实现了惰性创建的需求,但是失去了单例的效果,就像这样:
我们可以通过一个变量判断当前是否已经创建过登录悬浮框,如果有就不再创建了,否则创建它,这里借助闭包实现,修改后的代码如下:
<body>
<button>登录button>
<script>
let createLoginBox = function () {
let box;
return function () {
if (!box) {
let div = document.createElement("div");
div.style.width = "300px";
div.style.height = "200px";
div.style.marginTop = "15px";
div.style.background = "#bbb";
div.innerHTML = "这是一个登录框";
document.body.appendChild(div);
box = div;
}
return box;
}
}();
document.querySelector("button").addEventListener("click", () => {
createLoginBox();
})
script>
body>
借助了闭包,将变量box封装在闭包产生的作用域中,通过判断变量box来决定是否创建登录悬浮框,这样就解决问题了。
上面我们完成了惰性单例的功能,但是代码违反了单一职责原则,这里就不再过多解释单一职责了,前文已经说过了,代码将创建dom逻辑和单例判断都放到了createLoginBox内部实现。
如果有个新需求,创建一个唯一的搜索框,就要把上面的代码复制粘贴,修改一下创建逻辑,这不好吧!
所以我们需要分析变与不变,在上面的需求里,变化的是你要创建什么,不变的是单例的逻辑,所以我们可以把实现单例的逻辑抽出来,因为它始终不变,代码如下:
let getSingleton = function (fn) {
let instance;
return function () {
// 如果实例存在,直接返回,否则调用函数并给instanc赋值
return instance || (instance = fn.apply(this, arguments));
}
}
this 在 apply() 中指向的是当前上下文中的 this 对象,也就是 getSingleton 函数本身。
因此,这里使用 apply() 这种方式可以确保 fn 函数中的 this 指向的是当前上下文中的 this 对象。
在第二次及以后的调用中,由于 instance 已经被赋值了,所以直接返回 instance 即可,避免了重复创建实例,提高了性能。
现在我想创建单一实例的登录悬浮框,注册悬浮框,代码这样写就可以了:
<body>
<button>登录button>
<button>注册button>
<script>
let createLoginBox = function () {
let div = document.createElement("div");
div.style.width = "300px";
div.style.height = "200px";
div.style.marginTop = "15px";
div.style.background = "#bbb";
div.innerHTML = "这是一个登录框";
document.body.appendChild(div);
return div;
};
let createRegisterBox = function () {
let div = document.createElement("div");
div.style.width = "300px";
div.style.height = "200px";
div.style.marginTop = "15px";
div.style.background = "#bbb";
div.innerHTML = "这是一个注册框";
document.body.appendChild(div);
return div;
};
let getSingleton = function (fn) {
let instance;
return function () {
// 如果实例存在,直接返回,否则调用函数并给instanc赋值
return instance || (instance = fn.apply(this, arguments));
}
}
let createSingletonLoginBox = getSingleton(createLoginBox);
let createSingletonRegisterBox = getSingleton(createRegisterBox);
document.querySelectorAll("button")[0].addEventListener("click", () => {
createSingletonLoginBox();
})
document.querySelectorAll("button")[1].addEventListener("click", () => {
createSingletonRegisterBox();
})
script>
body>
无论点击多少次,他们都只会被创建一次。
在这个案例中,我们把创建实例对象的职责和管理单例的职责分别放置在两个方法里,这两个方法可以独立变化而互不影响,当它们连接在一起的时候,就完成了创建唯一实例对象的功能,是不是挺优雅的。
这种单例模式的用途远不止创建对象,比如要给这个列表绑定 click 事件,测试代码如下:
<body>
<ul>
<li>1li>
<li>2li>
<li>3li>
<li>4li>
ul>
<script>
let getSingleton = function (fn) {
let instance;
return function () {
// 如果实例存在,直接返回,否则调用函数并给instanc赋值
return instance || (instance = fn.apply(this, arguments));
}
}
let bindEvent = function () {
console.log("===开始绑定===");
document.querySelectorAll("li").forEach(li => {
li.addEventListener("click", () => {
console.log("点击了")
})
})
return true;
}
let bindEventSingle = getSingleton(() => {
console.log("===单例开始绑定===");
document.querySelectorAll("li").forEach(li => {
li.addEventListener("click", () => {
console.log("单例点击了")
})
})
return true;
})
bindEvent();
bindEvent();
bindEvent();
bindEventSingle();
bindEventSingle();
bindEventSingle();
script>
body>
单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
因为语言的差异性,在 JavaScript 中有更适合它的方法创建单例。本文还用到了代理模式和单一职责原则。
在 getSinge 函数中,实际上用到了闭包和高阶函数的概念。单例模式是一种简单但非常实用的模式,特别是惰性单例技术,使用非常广泛,在合适的时候才创建对象,并且只创建唯一的一个。
更酷的是,创建对象的职责和管理单例的职责被拆分在两个不同的方法中,这两个方法组合起来才具有单例模式的威力。你学到了吗,少年?