从 Popup 弹窗到 WebGPU 推理管线,同一个设计模式在不同场景下的优雅实现。
一、一个场景开场
假设你做了一个按钮,点击后弹出新窗口。如果用户狂点按钮,每次 new Popup() 都会创建一个新窗口 ------ 这就炸了。
html
<!-- ❌ 每次点击都 new 一个新实例,弹出一堆窗口 -->
<button id="openBtn">打开新网页</button>
<script>
class Popup {
open(url) {
window.open(url, '_blank');
}
}
document.getElementById("openBtn").addEventListener("click", () => {
new Popup().open("https://www.baidu.com"); // 每次点都 new
});
</script>
问题:同一个功能,为什么要创建多个对象?我们希望全局只有一个 Popup 实例,大家共用。
这就是**单例模式(Singleton Pattern)**的用武之地。
二、单例模式:从 Popup 看起
html
<button id="openBtn">打开新网页</button>
<script>
class Popup {
static ins; // 静态属性,属于类本身,不属于实例
static getInstance() {
if (!Popup.ins) {
Popup.ins = new Popup(); // 第一次调用才 new
}
return Popup.ins; // 后续调用直接返回已有的
}
open(url) {
window.open(url, '_blank');
}
}
// 验证:两次获取的是同一个实例吗?
const a = Popup.getInstance();
const b = Popup.getInstance();
console.log(a === b); // true
// 使用
const openBtn = document.getElementById("openBtn");
openBtn.addEventListener("click", () => {
a.open("https://www.baidu.com");
});
</script>
拆解每一行
| 代码 | 含义 |
|---|---|
static ins |
静态属性,挂在类 Popup 上,而不是实例上。所有地方通过 Popup.ins 访问的都是同一个值 |
static getInstance() |
静态方法,不依赖实例就能调用 |
if (!Popup.ins) |
判断是否已经创建过实例,只创建一次 |
Popup.ins = new Popup() |
第一次调用时创建实例,并缓存到静态属性上 |
return Popup.ins |
后续调用直接返回缓存的实例 |
这就是单例模式的全部精髓:类只实例化一次,全局只有一个实例。
三、静态属性 vs 实例属性:一张表讲清楚
| 对比维度 | 实例属性 | 静态属性 |
|---|---|---|
| 语法 | this.xxx |
static xxx |
| 归属 | 属于 new 出来的实例对象 |
属于类本身 |
| 访问方式 | new A().xxx |
A.xxx |
| 不需要 new 就能访问? | 否 | 是 |
| 内存 | 每个实例各一份 | 全局唯一一份 |
| 典型场景 | 组件内部状态 | 全局配置、单例实例、工具方法 |
类比理解:
js
class Person {
static species = "人类"; // 所有人共享,属于 "Person" 这个类
constructor(name) {
this.name = name; // 每个人不同,属于具体的人
}
}
const xiaoming = new Person("小明");
const xiaohong = new Person("小红");
console.log(xiaoming.name); // "小明" --- 实例属性,各不同
console.log(xiaohong.name); // "小红"
console.log(Person.species); // "人类" --- 静态属性,全局唯一
四、进阶:单例模式在 WebGPU DeepSeek 项目中的实战
回到之前聊过的 WebGPU DeepSeek R1 项目,Worker 线程里也有一个单例:
js
class TextGenerationPipeline {
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
static async getInstance(progress_callback = null) {
// ??= 逻辑空赋值:只有 this.tokenizer 为 null/undefined 时才执行
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
});
return Promise.all([this.tokenizer]);
}
}
// 使用:多次调用 getInstance,实际只下载一次
const [tokenizer] = await TextGenerationPipeline.getInstance(progressCallback);
为什么这里必须用单例?
- 模型文件巨大(GB 级别),下载需要几分钟甚至更久
- 内存昂贵:模型加载到内存后占用大量空间,绝不能加载两份
- 多次调用:用户可能多次点击"生成",每次都应该复用同一个已加载的模型
??= 运算符:单例的现代写法
js
// 传统写法
if (this.tokenizer === null || this.tokenizer === undefined) {
this.tokenizer = await AutoTokenizer.from_pretrained(...)
}
// ??= 写法(等价,更简洁)
this.tokenizer ??= await AutoTokenizer.from_pretrained(...)
??= 只忽略 null 和 undefined,不忽略 0、""、false,比 ||= 更精确。这是单例模式在现代 JavaScript 中最优雅的实现方式。
五、单例模式的其他应用场景
| 场景 | 为什么用单例 |
|---|---|
| 全局状态管理(如 Redux Store) | 整个应用只有一个 Store |
| 数据库连接池 | 连接是昂贵的资源,全局复用 |
| 日志记录器 | 所有模块写入同一个日志 |
| 配置管理 | 全局配置只读一份 |
| 浏览器缓存管理 | 缓存空间全局共享 |
| WebSocket 连接 | 和服务端保持一条长连接就够了 |
六、单例模式的优缺点
优点
- 全局唯一:天然保证实例唯一性
- 节省资源:避免重复创建的 CPU/内存开销
- 统一入口:所有调用方通过同一个方法获取实例,便于管理
缺点
- 全局状态:相当于全局变量,可能被任意代码修改,难以追踪
- 测试困难:单例持有状态,单元测试之间可能互相影响
- 强耦合:调用方依赖具体的类,难以替换实现
最佳实践:单例模式适合真正的「全局唯一资源」场景(如模型实例、数据库连接),不要为了省事把所有东西都搞成单例。
七、总结
| 问题 | 答案 |
|---|---|
| 单例模式是什么? | 保证一个类只有一个实例,并提供全局访问点 |
| 核心实现? | static 属性 + getInstance() 方法 + 懒加载判断 |
| 和 new 的区别? | new 每次创建新实例,getInstance() 永远返回同一个 |
| 什么时候用? | 全局唯一资源:模型实例、数据库连接、配置管理、日志服务 |
| 什么时候不用? | 需要多实例的场景、需要频繁创建销毁的对象、单元测试频繁的场景 |
一句话 :单例模式解决的是「这个资源全局只需要一份」的问题。从 Popup 弹窗到 GB 级大模型,思路完全一样:第一次创建,后面复用,static + getInstance() + 判空逻辑,三行搞定。这也是 OOP 23 种设计模式中最经典、最常用的一种。理解了它,你就理解了「面向设计,而不是面向实现」的编程思想。