停止编写单例类:使用 ES6 模块(TypeScript 方式)
Source: Dev.to
如果你来自面向对象的背景(Java / C# / 等),你可能已经熟悉 单例模式——一种只能拥有唯一实例并提供全局访问点的类。
在传统的 JavaScript 中,我们常常尝试通过带有静态方法的类或 getInstance() 函数来模拟这种行为。
但其实根本不需要这些样板代码。
JavaScript 已经为你提供了原生的单例机制:ES6 模块。
为什么 ES6 模块表现为单例
当模块被导入时,它只会 执行一次。导出的值会被 JavaScript 引擎缓存,因此后续的每次导入都会得到 相同的实例。
经典单例(详细版)
// LoggerClass.ts
class Logger {
private static instance: Logger;
private logs: string[] = [];
private constructor() {} // Prevent direct instantiation
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public log(message: string): void {
this.logs.push(message);
console.log(`LOG: ${message}`);
}
public getCount(): number {
return this.logs.length;
}
}
export default Logger.getInstance();
请注意,仅为确保单例实例和类型安全而额外编写的代码。
ES6 模块单例(干净且带类型)
// LoggerModule.ts
// 1️⃣ Private state (scoped to this file)
const logs: string[] = [];
// 2️⃣ Exported functions (public API)
export const log = (message: string): void => {
logs.push(message);
console.log(`LOG: ${message}`);
};
export const getCount = (): number => logs.length;
就是这样——没有类,没有 new,没有静态成员,也没有 private 关键字。文件作用域天然提供了私有性。
幕后发生了什么?
| 步骤 | 引擎的行为 |
|---|---|
| 首次导入 | 执行模块,分配 logs。 |
| 缓存 | 将模块实例存入内部缓存。 |
| 后续导入 | 返回缓存的实例——相同的 logs 数组。 |
Source: …
实际案例:全局计数器服务
我们将构建一个小型服务,让组件能够 递增、读取 并 订阅 一个全局计数器。监听器类型保证了严格的类型约束。
// services/CounterService.ts
// 👇 监听器的形状
type Listener = (count: number) => void;
// 🔒 私有状态
let count = 0;
let listeners: Listener[] = [];
// 通知所有监听器的辅助函数
const notify = (): void => {
listeners.forEach((listener) => listener(count));
};
// 📤 公共 API
export const increment = (): void => {
count += 1;
notify();
};
export const getValue = (): number => count;
export const subscribe = (listener: Listener): (() => void) => {
listeners.push(listener);
// 返回一个用于清理的取消订阅函数
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
修改计数器的组件
// components/CounterButton.tsx
import React from 'react';
import { increment, getValue } from '../services/CounterService';
const CounterButton: React.FC = () => (
<div>
{/* 组件 A(修改者) */}
<div>初始值加载:{getValue()}</div>
<button onClick={increment}>递增全局计数</button>
</div>
);
export default CounterButton;
观察计数器的组件
// components/CounterDisplay.tsx
import React, { useState, useEffect } from 'react';
import { getValue, subscribe } from '../services/CounterService';
const CounterDisplay: React.FC = () => {
// 使用当前单例值初始化状态
const [count, setCount] = useState(getValue());
useEffect(() => {
// `subscribe` 返回一个取消订阅函数——非常适合用于清理
const unsubscribe = subscribe((newCount) => setCount(newCount));
return unsubscribe;
}, []);
return (
<div>
{/* 组件 B(观察者) */}
<p>正在监听单例更新…</p>
<h2>{count}</h2>
</div>
);
};
export default CounterDisplay;
由于 Listener 被定义为 (count: number) => void,如果组件尝试使用不匹配的签名(例如期望字符串),TypeScript 将会 报错。
何时使用此模式
- API 客户端 – 单个带拦截器的 Axios 实例。
- WebSocket 连接 – 在多个屏幕之间共享的单个活动套接字。
- 功能标记 – 用于检查功能是否启用的简单存储。
注意: 这 不是 用于复杂 UI 状态的状态管理库(Redux、Zustand、Context API)的替代方案。它在实用逻辑和单一用途服务方面表现出色。
Source: …
性能优势
更简洁的代码固然好,但最有力的论点是性能。
现代打包工具(Webpack、Rollup、Vite)会执行树摇——它们会在最终 bundle 中剔除未使用的代码。
类的问题
// 传统类导入
import Logger from './LoggerClass';
// 你只使用 .log(),但 .getCount()、.reset()、.debug() …
由于类是单个导出,打包器必须包含整个类(所有方法),即使你只用了其中一个,也会导致 bundle 体积增大。
使用模块‑函数方式时,每个导出的函数都可以在未使用时单独被剔除,从而生成更小、更高效的 bundle。
TL;DR
- ES6 模块 天生就是单例 —— 不需要额外的样板代码。
- 使用模块作用域的变量 + 导出的函数即可实现干净、类型安全的单例。
- 非常适合共享工具函数、API 客户端、socket 连接以及简单的状态存储。
- 好处:代码更少、类型安全性更好、生产环境的 bundle 更小。
// Logger Example
// Original code
Logger.log('Hello');
模块方案
import { log } from './LoggerModule';
// The bundler sees that `getCount` is never imported.
// It removes it from the final JavaScript bundle.
log('Hello');
从基于类的单例切换到 ES6 模块的优势
- 简洁 – 无需样板代码、新关键字或静态方法。
- 安全 – 通过文件作用域变量实现真正的私有状态。
- 性能 – 细粒度的导入支持更好的 tree shaking,减小 bundle 大小。
所以下次在 React 或 TypeScript 中想使用单例时,请记住:你可能只需要一个模块即可。