01 | 明明是全家共用的“公章”,你为什么要刻好几个?——单例模式

0 阅读4分钟

最近在帮几个朋友看代码,发现大家在处理“全局状态”或者“公共组件”时,经常会遇到一个挺让人头疼的问题。

比如你写了一个管理用户登录信息的模块,或者一个全局的配置中心。 结果因为代码逻辑在不同的地方被反复实例化,导致 A 页面改了配置,B 页面拿到的还是旧的。 这种“数据对不上”的情况,排查起来特别费劲,最后发现其实是内存里跑着好几个长得一模一样的对象。

我以前也经常在这上面栽跟头,总觉得多 new 一个对象没什么大不了。 但后来才意识到,很多时候我们需要的不是“一堆长得像的东西”,而是“唯一的那一个”。

这就是单例模式(Singleton)真正要解决的问题。

为什么我们总是需要那个“唯一”?

说白了,单例模式的核心逻辑就是:保证一个类只有一个实例,并提供一个全局访问点。

你可以把它想象成公司里的那个“公章”。 不管谁要用,都得去行政那儿拿那枚唯一的章。 如果每个部门都自己刻一个,那公司的合同就全乱套了。

在开发中,像数据库连接池、全局缓存、或者前端的登录弹窗,其实都是这种“公章”。 如果每次用到都去新建一个,不仅浪费内存,更麻烦的是你没法保证这些对象之间的数据是同步的。

聊聊这个模式是怎么“守门”的

我发现很多朋友在写单例的时候,容易把它写成一个简单的全局变量。 但全局变量太“软”了,谁都能改,谁都能覆盖。

更稳健的做法是在类内部加一个“守门员”。 当有人想要拿实例的时候,守门员先看一眼:家里是不是已经有一个了? 如果有,直接把旧的给你;如果没有,再现造一个。

在 JavaScript 里,用 ES6 的类写起来其实很直观:

class GlobalConfig {
  constructor() {
    // 这是一个很隐蔽的坑:如果不判断,每次 new 都会产生新对象
    if (GlobalConfig.instance) {
      return GlobalConfig.instance;
    }

    this.setting = { theme: 'dark' };
    // 第一次创建时,把自己挂在静态属性上
    GlobalConfig.instance = this;
  }
}

const a = new GlobalConfig();
const b = new GlobalConfig();
console.log(a === b); // true,它们是同一个“公章”

这种写法在初期很常见,但我们可以更进一步

其实在现代 JS 开发里,我们还有更优雅的“偷懒”办法。 如果你用的是 ES Modules,利用模块的特性,单例可以写得更简单。

我现在的思考是:既然模块在第一次被 import 的时候会被执行并缓存,那我们直接导出一个实例不就行了?

// config.js
class Config {}
export const config = new Config(); // 导出的永远是这一个实例

这种写法不仅代码量少,而且天然规避了多线程(在某些环境)或者重复实例化的风险。

看看两种写法的直观对比

1. 容易出问题的写法: 每次调用功能都 new Service()。 结果:内存里堆满了重复的对象,状态同步全靠运气,Bug 满天飞。

2. 更稳健的单例写法: 通过统一的入口获取实例。 结果:无论你在哪个文件、哪个组件里调用,拿到的永远是同一份数据。 逻辑清晰了,内存也省下来了。

给你的 3 条行动建议

  1. 先找找你的“公章”:检查一下你的项目,有没有像“日志记录”、“全局配置”、“弹窗管理”这种逻辑?如果有,优先考虑把它们改成单例。

  2. 别把单例当成垃圾桶:虽然单例好用,但别什么东西都往里塞。如果一个对象不需要全局共享状态,就让它保持普通身份,用完即销毁。

  3. 优先使用模块化单例:如果你在用 Webpack 或 Vite,直接导出实例是最推荐的做法,既安全又符合现代规范。

其实设计模式没那么玄乎,它就是前人踩了无数坑之后总结出来的“避坑指南”。 希望今天聊的这个小模式,能帮你省下一些加班排查数据同步 Bug 的时间。