模块执行上下文本身不直接提供单例能力,但利用ES模块顶层作用域单次求值特性,配合模块内变量+导出函数/对象,是实现JavaScript单例最自然、最可靠的方式。

模块执行上下文本身不直接提供单例能力,但利用 ES 模块的顶层作用域单次求值特性,配合模块内变量 + 导出函数/对象,是实现 JavaScript 单例最自然、最可靠的方式。
利用模块顶层变量封装实例
ES 模块在首次导入时执行一次,其顶层声明的变量只会初始化一次。只要不导出构造函数或允许重复创建的工厂方法,就能天然保证单例性。
- 在模块内部用
let或const声明私有实例变量 - 导出一个只读对象或访问器函数(如
getInstance()),而非类本身 - 避免导出类、构造函数或
new可调用的函数
示例:
// logger.js<br>let instance;<br><br>class Logger {<br> constructor() {<br> this.id = Math.random().toFixed(4);<br> }<br> log(msg) {<br> console.log(`[${this.id}] ${msg}`);<br> }<br>}<br><br>export function getInstance() {<br> if (!instance) {<br> instance = new Logger();<br> }<br> return instance;<br>}
导出已实例化的对象(更简洁)
若无需延迟初始化或参数化构造,直接导出模块级实例是最简方式,语义清晰且无额外开销。
- 实例在模块加载时立即创建,适合无依赖、无异步初始化的场景
- 导出对象本身即可,其他模块多次
import得到的是同一引用 - 注意:不可导出
class或function,否则使用者仍可new多次
示例:
// config.js<br>export const config = {<br> apiUrl: 'https://api.example.com',<br> timeout: 5000,<br>};
处理带初始化逻辑的单例(如异步依赖)
当单例需等待异步操作(如读取配置、连接数据库)才能就绪,应导出一个 Promise 或使用“懒初始化 + 缓存 Promise”模式。
- 模块内缓存 Promise,确保并发调用
getInstance()返回同一个 Promise - 避免每次调用都触发新异步流程
- 可搭配
async/await和闭包变量控制状态
示例(带缓存的异步单例):
// db.js<br>let _dbPromise;<br><br>export async function getDB() {<br> if (!_dbPromise) {<br> _dbPromise = (async () => {<br> const db = await createConnection(); // 假设是异步初始化<br> return Object.freeze(db); // 可选:防止意外修改<br> })();<br> }<br> return _dbPromise;<br>}
不推荐的方式及常见误区
以下做法看似单例,实则破坏模块上下文保障或引入隐患:
- 在模块中导出类,由使用者
new MyClass()—— 完全失去单例约束 - 用全局变量(
window.instance或globalThis.instance)存储实例 —— 绕过模块封装,污染全局,不利于测试和 tree-shaking - 在多个模块中各自定义同名单例变量 —— 不同模块的顶层变量彼此隔离,无法共享
- 依赖
require.cache或 Node.js 特定机制 —— 不适用于 ESM 环境,且违背标准模块语义

















