停止编写单例类:使用 ES6 模块(TypeScript 方式)

发布: (2025年12月26日 GMT+8 20:44)
7 min read
原文: Dev.to

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 中想使用单例时,请记住:你可能只需要一个模块即可。

Back to Blog

相关文章

阅读更多 »