JavaScript 单例模式:用一个弹窗管理器理解"类只实例化一次"
在面向对象项目中,我们经常会遇到这样一种需求:某个类负责管理一项全局能力,整个应用只需要一个管理对象,不希望业务代码在不同位置反复创建它。
例如,可以设计一个专门负责打开网页的 Popup 类。页面中的不同功能都可以使用这个类,但没有必要每次打开网页时都 new Popup()。这时就可以使用单例模式,让这个类在整个运行过程中只创建一个实例。
本文通过一个"点击按钮打开新网页"的例子,逐步理解单例模式的实现方式,以及静态属性、静态方法、实例方法和 DOM 事件之间的配合。
一、什么是单例模式
单例模式的核心可以概括为一句话:
一个类只实例化一次,并向外提供同一个实例。
如果直接使用 new 创建对象,每执行一次都会得到一个新实例:
js
const a = new Popup();
const b = new Popup();
console.log(a === b); // false
a 和 b 都由 Popup 类创建,但它们是两个不同的对象,因此严格相等比较的结果是 false。
单例模式则不让使用者每次都直接创建新对象,而是通过统一的方法获取实例:
js
const a = Popup.getInstance();
const b = Popup.getInstance();
console.log(a === b); // true
两次调用得到的是同一个对象,所以比较结果为 true。这就是这个例子中单例模式最直接的表现。
二、完整示例
下面先看完整代码,再逐部分分析:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>单例模式打开窗口</title>
</head>
<body>
<button id="openBtn">打开新网页</button>
<script>
class Popup {
static ins;
static getInstance() {
if (!Popup.ins) {
Popup.ins = new Popup();
}
return Popup.ins;
}
open(url) {
window.open(url, "_blank");
}
}
const a = Popup.getInstance();
const b = Popup.getInstance();
console.log(a === b);
const openBtn = document.getElementById("openBtn");
openBtn.addEventListener("click", () => {
a.open("https://www.bilibili.com/");
});
</script>
</body>
</html>
页面中只有一个按钮。点击按钮后,程序会通过 Popup 的唯一实例调用 open 方法,在新标签页中打开指定地址。
三、用类封装打开网页的能力
先看 Popup 类的基本结构:
js
class Popup {
open(url) {
window.open(url, "_blank");
}
}
open 是一个实例方法,它接收一个 url 参数,并把这个参数交给浏览器提供的 window.open:
js
window.open(url, "_blank");
这里两个参数的作用分别是:
url:需要打开的网页地址;"_blank":表示在新的浏览上下文中打开,在常见浏览器中通常表现为新标签页。
如果不做类的封装,也可以直接在点击事件中调用:
js
window.open("https://www.bilibili.com/", "_blank");
但封装为 Popup 类以后,页面交互代码不再直接负责打开窗口,而是把这项工作交给 Popup 对象:
js
a.open("https://www.bilibili.com/");
这样,"谁触发打开操作"和"怎样打开网页"被分开了。按钮只负责响应点击,Popup 负责执行打开操作。
四、静态属性:保存唯一实例
单例实现的第一部分是静态属性:
js
static ins;
static 表示这个属性属于类本身,而不是属于某个实例。因此可以直接通过类名访问它:
js
Popup.ins
而不是:
js
const popup = new Popup();
popup.ins;
刚开始执行时,Popup.ins 还没有保存任何实例,它的值是 undefined。当第一个实例创建出来以后,程序会把它存入 Popup.ins。后续代码再次获取实例时,就能直接复用这个对象。
之所以把它定义成静态属性,是因为在实例创建之前,程序就需要有一个地方判断"这个类是否已经创建过实例"。类本身正适合承担这个职责。
可以把 Popup.ins 理解成类内部用于记录唯一对象的位置:
text
Popup 类
└── ins ──> 唯一的 Popup 实例
五、静态方法:统一获取实例
保存实例以后,还需要提供一个统一入口来获取它:
js
static getInstance() {
if (!Popup.ins) {
Popup.ins = new Popup();
}
return Popup.ins;
}
getInstance 同样使用了 static,所以调用它时不需要提前创建对象:
js
Popup.getInstance();
这段方法可以拆成三步理解。
1. 判断实例是否存在
js
if (!Popup.ins) {
第一次调用 getInstance 时,Popup.ins 是 undefined。对它取反后条件成立,于是进入 if。
2. 只在第一次调用时创建对象
js
Popup.ins = new Popup();
程序创建一个 Popup 实例,并把它保存在静态属性 Popup.ins 中。
这行代码只会在 Popup.ins 没有值时执行。实例一旦被保存,后续调用就不会再次进入这个分支。
3. 返回已经保存的实例
js
return Popup.ins;
无论实例是在本次调用中新建的,还是之前已经创建好的,方法最后都会返回 Popup.ins。
因此,两次调用的执行过程并不相同:
js
const a = Popup.getInstance(); // 创建实例,保存后返回
const b = Popup.getInstance(); // 直接返回已经保存的实例
但两次返回的结果相同:
js
console.log(a === b); // true
严格相等运算符比较对象时,判断的是两边是否引用同一个对象。结果为 true,证明 a 和 b 指向的是同一个 Popup 实例。
六、静态方法与实例方法的区别
这个类中同时出现了静态方法和实例方法:
js
class Popup {
static getInstance() {
// 获取唯一实例
}
open(url) {
// 打开网页
}
}
它们的调用对象不同。
静态方法 getInstance 属于 Popup 类,通过类名调用:
js
const popup = Popup.getInstance();
实例方法 open 属于创建出来的对象,通过实例调用:
js
popup.open("https://www.bilibili.com/");
整个调用关系可以写成:
text
Popup.getInstance()
↓
获得唯一的 Popup 实例
↓
实例.open(url)
↓
window.open(url, "_blank")
简单来说,getInstance 负责"拿到对象",open 负责"让对象完成工作"。
七、把单例对象接入按钮点击事件
类定义完成以后,先获取它的唯一实例:
js
const a = Popup.getInstance();
然后通过 id 找到页面中的按钮:
js
const openBtn = document.getElementById("openBtn");
这与 HTML 中的 id 相对应:
html
<button id="openBtn">打开新网页</button>
接下来监听按钮的 click 事件:
js
openBtn.addEventListener("click", () => {
a.open("https://www.bilibili.com/");
});
用户点击按钮后,箭头函数执行。函数没有直接调用 window.open,而是调用单例对象 a 的 open 方法,再由这个方法打开网页。
从用户操作到浏览器打开网页,完整流程如下:
text
用户点击按钮
↓
触发 click 事件
↓
调用 a.open(url)
↓
执行 window.open(url, "_blank")
↓
浏览器打开目标网页
脚本放在按钮元素之后,因此执行 document.getElementById("openBtn") 时,按钮已经被浏览器解析,可以正常取得这个 DOM 元素。
八、这里的"唯一"究竟指什么
这个例子中有一个很容易混淆的地方:单例模式保证的是 Popup 类只创建一个对象,而不是只打开一个网页标签页。
下面两次获取实例,返回的是同一个对象:
js
const a = Popup.getInstance();
const b = Popup.getInstance();
console.log(a === b); // true
但是每次执行:
js
a.open("https://www.bilibili.com/");
内部都会再次调用:
js
window.open(url, "_blank");
因此,多次点击按钮仍然可能打开多个新标签页。唯一的是负责执行操作的 Popup 实例,不是 window.open 产生的窗口。
理解这个区别很重要。单例模式控制的是类的实例数量,并不会自动限制实例方法能够执行多少次。
九、为什么不用 new Popup()
单例写法的使用方式是:
js
const a = Popup.getInstance();
它用统一的实例获取方法替代了业务代码中的直接创建:
js
const a = new Popup();
如果各处都直接使用 new Popup(),类本身就无法复用之前创建的对象:
js
const a = new Popup();
const b = new Popup();
console.log(a === b); // false
使用 getInstance 后,对象的创建逻辑集中在类内部:
js
static getInstance() {
if (!Popup.ins) {
Popup.ins = new Popup();
}
return Popup.ins;
}
调用者只负责获取并使用实例,不需要判断实例是否已经存在。对于类较多、对象管理较复杂的面向对象项目,这种集中管理方式能让对象的创建关系更清楚。
需要注意的是,这个示例通过约定让调用者使用 getInstance。由于类仍然可以被直接 new,如果写出 new Popup(),依旧能够额外创建对象。因此,阅读和使用这段代码时,应统一从 Popup.getInstance() 获取单例。
十、页面基础结构中的几个细节
虽然重点是单例模式,完整页面中还有几个与运行效果直接相关的细节。
<!DOCTYPE html>
html
<!DOCTYPE html>
它声明当前文档使用 HTML5,让浏览器按照标准模式解析页面。
字符编码
html
<meta charset="UTF-8">
页面中包含中文按钮文字,指定 UTF-8 编码可以让中文正常显示。
视口设置
html
<meta name="viewport" content="width=device-width, initial-scale=1.0">
这行设置让页面宽度与设备宽度一致,并使用正常的初始缩放比例。
页面标题
html
<title>单例模式打开窗口</title>
它设置浏览器标签页显示的页面标题,也直接说明了示例的主题。
十一、总结
这个示例虽然代码不多,但完整展示了一个基础的 JavaScript 单例:
js
class Popup {
static ins;
static getInstance() {
if (!Popup.ins) {
Popup.ins = new Popup();
}
return Popup.ins;
}
open(url) {
window.open(url, "_blank");
}
}
其中各部分职责非常明确:
static ins保存唯一实例;static getInstance()判断实例是否存在,必要时创建,然后统一返回;open(url)是实例真正提供的功能;Popup.getInstance()代替业务代码直接使用new;a === b用来验证两次获取到的是同一个对象;- 按钮的点击事件通过这个唯一实例打开目标网页。
单例模式的重点并不只是写出一个 if 判断,而是把对象的创建和使用分开:类负责管理自己的唯一实例,业务代码只负责获取它并调用它提供的方法。
最后再强调一次:这里被限制为一个的是 Popup 对象,而不是浏览器窗口。分清"单例对象"和"对象执行操作后产生的结果",才能准确理解这段代码所体现的设计思路。