Back

JavaScript 中的单例模式:实用工具还是隐藏陷阱?

JavaScript 中的单例模式:实用工具还是隐藏陷阱?

你编写了一个模块,导出了一个已配置的日志记录器或 API 客户端实例。每个文件都导入它,你假设整个应用程序中只运行一个实例。然后你的测试开始在不同运行之间泄漏状态。或者你的微前端架构突然出现两个”单例”副本相互冲突。发生了什么?

JavaScript 单例模式的工作方式与经典设计模式理论所描述的不同。理解 ES 模块单例实际存在的位置——以及它们在哪里失效——可以让你避免那些像追逐幽灵一样的调试过程。

核心要点

  • ES 模块单例是按模块图和运行时缓存的,而不是在整个系统中全局缓存
  • 单例适用于不可变数据和无状态操作,如日志工具和只读配置
  • 单例中的可变状态在服务端渲染、测试环境和微前端架构中会变得危险
  • 在使用单例之前,考虑你的代码是否在多个 bundle、worker 或服务器请求中运行,以及测试是否需要重置实例

现代 JavaScript 中”单例”的真正含义

暂时忘掉四人帮的定义。在现代 JavaScript 中,单例通常只是一个导出已实例化对象的模块:

// logger.js
class Logger {
  log(message) {
    console.log(`[${Date.now()}] ${message}`)
  }
}

export const logger = new Logger()

当你从多个文件导入 logger 时,你得到的是同一个实例——这不是因为巧妙的构造函数技巧,而是因为ES 模块是按模块图和运行时缓存的。模块执行一次,实例创建一次,每次导入都接收到对同一对象的引用。

这就是 ES 模块单例的基础。它很简单,而且通常正是你所需要的。

会失效的假设

这就是 JavaScript 中单例陷阱出现的地方:那个”单一实例”的作用域是特定 JavaScript 运行时内的特定模块图。它并不是在整个系统中神奇地全局存在。

这个假设在几个真实场景中会失效:

多个 bundle 或重复的包。 如果你的 monorepo 中有两个包,每个都打包了自己的依赖副本,你就会得到两个独立的模块图。两个”单例”。你的共享状态不再共享了。

测试运行器。 Jest、Vitest 和类似工具通常会在测试文件之间重置模块缓存或使用 worker 进程。你在一个测试文件中的单例可能与另一个测试文件中的不是同一个实例。

微前端。 每个独立部署的前端通常有自己的 JavaScript 运行时。一个微前端中的单例对另一个微前端是不可见的,除非通过构建或运行时显式共享实例。

Web Worker 和 Service Worker。 这些在独立的 JavaScript 上下文中运行。在 worker 中导入的模块与主线程中的同一模块是完全不同的实例。

具有请求隔离的服务器运行时。 在像 Next.js 或 Nuxt 这样在服务器上运行的框架中,单例可能会在多个用户请求之间持续存在(取决于运行时和部署模型),如果它持有可变的、特定于请求的状态,就会有数据泄漏的风险。

单例适用的场景

尽管存在这些陷阱,ES 模块单例对于某些前端工具和协调模式仍然非常有用:

  • 日志工具。 具有一致格式的共享日志记录器即使重复也不会造成危害,并且不持有敏感的每请求状态。
  • 配置快照。 启动时加载的只读配置作为单例使用很好。关键词是只读
  • 无状态工具。 在调用之间不维护可变状态的辅助函数或类是安全的候选对象。

共同点是:这些单例要么持有不可变数据,要么执行无状态操作。

单例成为负担的场景

可变状态是危险的地方。考虑:

  • 用户会话数据。 在服务端渲染中,持有用户信息的单例可能在请求之间泄漏。
  • 请求作用域的缓存。 应该按请求重置的数据被错误地持久化。
  • 共享的可变配置。 应用的一部分修改了设置,意外地影响了另一部分。

现代工具放大了这些问题。在 React 18+ 中,严格模式在开发环境中会故意双重调用渲染和某些 effect,暴露出未正确隔离的单例状态。Vite 或 webpack 中的热模块替换可能会在代码更改之间保留单例状态,造成微妙的 bug,你的”新鲜”代码在陈旧数据上运行。

实用的立场

JavaScript 单例模式本身并不坏——它只是比许多开发者假设的更窄。在使用模块级实例之前,问自己:

  1. 这个状态真的是不可变的或无状态的吗?
  2. 这段代码可能在多个 bundle、worker 或服务器请求中运行吗?
  3. 我的测试需要重置或模拟这个实例吗?

如果你对问题 2 或 3 的回答是”是”,并且涉及可变状态,请考虑替代方案:工厂函数、依赖注入,或框架特定的模式,如 React Context 或请求作用域的服务。

结论

当你理解单例的实际作用域时,它们是一个有用的工具。当你假设”单一实例”意味着运行时从未承诺过的东西时,它们就会成为隐藏的陷阱。对于模块级实例,坚持使用不可变或无状态数据,当你需要在测试、请求或运行时边界之间进行适当隔离时,使用依赖注入或工厂模式。

常见问题

Jest 通常会在测试文件之间重置模块缓存,或在隔离的 worker 进程中运行测试,创建新的模块图并重新实例化你的单例。在适当的地方使用 jest.resetModules() 或 jest.isolateModules(),或通过注入依赖来避免模块级的可变状态。

不可以。Web Worker 在独立的 JavaScript 上下文中运行,有自己的模块图。在 worker 中导入的模块与主线程中的同一模块是完全不同的实例。要共享状态,你必须使用 postMessage 或 SharedArrayBuffer 在上下文之间显式传递数据。

仅对不可变或无状态数据安全。服务器上的单例可能会在多个用户请求之间持续存在(取决于运行时和部署模型),可能会在用户之间泄漏敏感数据。对于特定于请求的状态(如用户会话或缓存),使用框架提供的请求作用域模式,而不是模块级实例。

工厂函数让你按需创建新实例。依赖注入显式传递实例,使测试更容易。框架特定的解决方案(如 React Context)提供作用域状态管理。对于服务器应用程序,请求作用域的服务确保请求之间的适当隔离,同时保持共享工具的便利性。

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay