JavaScript 单例模式详解:从唯一实例到按钮事件

JavaScript 单例模式:用一个弹窗管理器理解"类只实例化一次"

在面向对象项目中,我们经常会遇到这样一种需求:某个类负责管理一项全局能力,整个应用只需要一个管理对象,不希望业务代码在不同位置反复创建它。

例如,可以设计一个专门负责打开网页的 Popup 类。页面中的不同功能都可以使用这个类,但没有必要每次打开网页时都 new Popup()。这时就可以使用单例模式,让这个类在整个运行过程中只创建一个实例。

本文通过一个"点击按钮打开新网页"的例子,逐步理解单例模式的实现方式,以及静态属性、静态方法、实例方法和 DOM 事件之间的配合。

一、什么是单例模式

单例模式的核心可以概括为一句话:

一个类只实例化一次,并向外提供同一个实例。

如果直接使用 new 创建对象,每执行一次都会得到一个新实例:

js 复制代码
const a = new Popup();
const b = new Popup();

console.log(a === b); // false

ab 都由 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.insundefined。对它取反后条件成立,于是进入 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,证明 ab 指向的是同一个 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,而是调用单例对象 aopen 方法,再由这个方法打开网页。

从用户操作到浏览器打开网页,完整流程如下:

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 对象,而不是浏览器窗口。分清"单例对象"和"对象执行操作后产生的结果",才能准确理解这段代码所体现的设计思路。

相关推荐
你别说话了12 小时前
Vue项目解决跨域
前端·javascript·vue.js
指尖时光.12 小时前
Three.js 超全入门综合案例
开发语言·javascript·ecmascript
唐青枫13 小时前
http-server:别再双击 index.html,一条命令把目录变成网站
前端·javascript
学习星球15 小时前
Solid.js 实战:拆解官方 RealWorld 项目
开发语言·javascript·vue.js
YuJie17 小时前
JSBridge 基础知识
前端·javascript
默_笙17 小时前
🍕 后端接口还没写好,前端已经跑起来了?Mock 数据 + axios 接口层,前后端再也不互相"等"
前端·javascript
爱勇宝17 小时前
客户只想看个页面,我却做了一个静态演示发布系统
前端·javascript·后端
界面开发小八哥21 小时前
界面控件DevExpress Blazor v26.1新版亮点 - Navigation(导航)
javascript·.net·界面控件·blazor·devexpress·ui开发
SendTomo1 天前
send.wang(私传网)基于网页P2P直连传输大文件的优势
javascript·网络·webrtc·html5·p2p