单例模式:为什么大模型只加载一次就够了?

从 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)**的用武之地。

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);

为什么这里必须用单例?

  1. 模型文件巨大(GB 级别),下载需要几分钟甚至更久
  2. 内存昂贵:模型加载到内存后占用大量空间,绝不能加载两份
  3. 多次调用:用户可能多次点击"生成",每次都应该复用同一个已加载的模型

??= 运算符:单例的现代写法

js 复制代码
// 传统写法
if (this.tokenizer === null || this.tokenizer === undefined) {
  this.tokenizer = await AutoTokenizer.from_pretrained(...)
}

// ??= 写法(等价,更简洁)
this.tokenizer ??= await AutoTokenizer.from_pretrained(...)

??= 只忽略 nullundefined,不忽略 0""false,比 ||= 更精确。这是单例模式在现代 JavaScript 中最优雅的实现方式。

五、单例模式的其他应用场景

场景 为什么用单例
全局状态管理(如 Redux Store) 整个应用只有一个 Store
数据库连接池 连接是昂贵的资源,全局复用
日志记录器 所有模块写入同一个日志
配置管理 全局配置只读一份
浏览器缓存管理 缓存空间全局共享
WebSocket 连接 和服务端保持一条长连接就够了

六、单例模式的优缺点

优点

  • 全局唯一:天然保证实例唯一性
  • 节省资源:避免重复创建的 CPU/内存开销
  • 统一入口:所有调用方通过同一个方法获取实例,便于管理

缺点

  • 全局状态:相当于全局变量,可能被任意代码修改,难以追踪
  • 测试困难:单例持有状态,单元测试之间可能互相影响
  • 强耦合:调用方依赖具体的类,难以替换实现

最佳实践:单例模式适合真正的「全局唯一资源」场景(如模型实例、数据库连接),不要为了省事把所有东西都搞成单例。

七、总结

问题 答案
单例模式是什么? 保证一个类只有一个实例,并提供全局访问点
核心实现? static 属性 + getInstance() 方法 + 懒加载判断
和 new 的区别? new 每次创建新实例,getInstance() 永远返回同一个
什么时候用? 全局唯一资源:模型实例、数据库连接、配置管理、日志服务
什么时候不用? 需要多实例的场景、需要频繁创建销毁的对象、单元测试频繁的场景

一句话 :单例模式解决的是「这个资源全局只需要一份」的问题。从 Popup 弹窗到 GB 级大模型,思路完全一样:第一次创建,后面复用,static + getInstance() + 判空逻辑,三行搞定。这也是 OOP 23 种设计模式中最经典、最常用的一种。理解了它,你就理解了「面向设计,而不是面向实现」的编程思想。

相关推荐
windliang2 小时前
Claude Code 源码分析(八):Memory 如何被写入、整理与按需召回
前端·算法·面试
lingran__3 小时前
C++ 高阶数据结构:红黑树万字详解|完整原理推导 + 插入实现 + 完整性校验【STL 底层】
数据结构·c++·面试·红黑树·二叉搜索树·平衡二叉树·stl底层
ssshooter3 小时前
为什么明明只有一个 12px 的小元素,父容器却有 63px 高?
前端·javascript·面试
淡海水3 小时前
15-07-YooAsset面试篇-Unity性能优化与内存管理
java·unity·面试·性能优化·c#·游戏引擎
.柒宇.4 小时前
运维常见面试题_02_数据库
运维·数据库·sql·面试
kyriewen15 小时前
面试官问"这段逻辑为什么这样写"——我才发现,这半年我写的代码,我自己都解释不了
前端·javascript·面试
大龄秃头程序员18 小时前
深入梳理 UIView touch 事件全链路原理与实战
面试
众人皆醒我独醉19 小时前
Token:AI 世界的基本粒子——不是"字",是统计上最优的"字符组合"
后端·面试·llm
古法安卓19 小时前
Android-DeviceStorageMonitorService 流程分析
java·面试·android studio