TypeScript 中的单例模式

什么是单例模式?

单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。在 TypeScript 中,单例模式特别有用,因为它结合了 JavaScript 的灵活性和 TypeScript 的类型安全。

为什么需要单例模式?

想象一下这些场景:

  • 数据库连接池管理
  • 应用程序配置管理器
  • 日志记录器
  • 缓存管理器

在这些情况下,我们需要确保整个应用程序中只有一个实例来处理这些全局资源,避免资源浪费和不一致的状态。

基础单例实现

让我们从最简单的实现开始:

class Singleton {
  private static instance: Singleton;

  private constructor() {
    // 私有构造函数防止外部实例化
  }

  public static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }

  public someBusinessLogic() {
    // 业务逻辑
  }
}

线程安全的单例实现

在 JavaScript/TypeScript 中,由于是单线程环境,我们不需要担心传统的线程安全问题。但考虑到异步操作,我们可以使用更安全的实现:

class ThreadSafeSingleton {
  private static instance: ThreadSafeSingleton;

  private constructor() {
    // 初始化代码
  }

  public static getInstance(): ThreadSafeSingleton {
    if (!ThreadSafeSingleton.instance) {
      ThreadSafeSingleton.instance = new ThreadSafeSingleton();
    }
    return ThreadSafeSingleton.instance;
  }

  // 使用 Promise 确保异步安全
  public static async getInstanceAsync(): Promise<ThreadSafeSingleton> {
    if (!ThreadSafeSingleton.instance) {
      ThreadSafeSingleton.instance = new ThreadSafeSingleton();
      // 模拟异步初始化
      await new Promise(resolve => setTimeout(resolve, 0));
    }
    return ThreadSafeSingleton.instance;
  }
}

使用模块模式的单例实现

TypeScript 的模块系统天然支持单例模式:

// Logger.ts
class Logger {
  private logs: string[] = [];

  log(message: string) {
    this.logs.push(`${new Date().toISOString()}: ${message}`);
    console.log(message);
  }

  getLogs(): string[] {
    return [...this.logs];
  }
}

// 直接导出实例
export const logger = new Logger();

带参数的单例模式

有时我们需要在单例初始化时传递参数:

class ConfigManager {
  private static instance: ConfigManager;
  private config: Record<string, any>;

  private constructor(initialConfig?: Record<string, any>) {
    this.config = initialConfig || {};
  }

  public static initialize(initialConfig?: Record<string, any>): ConfigManager {
    if (!ConfigManager.instance) {
      ConfigManager.instance = new ConfigManager(initialConfig);
    }
    return ConfigManager.instance;
  }

  public static getInstance(): ConfigManager {
    if (!ConfigManager.instance) {
      throw new Error('ConfigManager not initialized. Call initialize() first.');
    }
    return ConfigManager.instance;
  }

  public set(key: string, value: any): void {
    this.config[key] = value;
  }

  public get(key: string): any {
    return this.config[key];
  }
}

单例模式的优缺点

优点:

  • 严格控制实例数量:确保全局唯一实例
  • 全局访问点:方便在任何地方访问
  • 延迟初始化:只有在需要时才创建实例

缺点:

  • 违反单一职责原则:类需要管理自己的生命周期
  • 隐藏的依赖关系:单例的使用可能不明显
  • 测试困难:难以模拟和测试
  • 全局状态:可能导致代码耦合

测试单例模式

测试单例类时需要特别注意:

describe('Singleton', () => {
  beforeEach(() => {
    // 重置单例实例用于测试
    (Singleton as any).instance = undefined;
  });

  it('should return the same instance', () => {
    const instance1 = Singleton.getInstance();
    const instance2 = Singleton.getInstance();
    expect(instance1).toBe(instance2);
  });
});

实际应用示例:数据库连接池

让我们看一个实际的数据库连接池单例实现:

interface DatabaseConfig {
  host: string;
  port: number;
  username: string;
  password: string;
  database: string;
}

class DatabaseConnectionPool {
  private static instance: DatabaseConnectionPool;
  private connections: any[] = [];
  private config: DatabaseConfig;

  private constructor(config: DatabaseConfig) {
    this.config = config;
    this.initializePool();
  }

  public static getInstance(config?: DatabaseConfig): DatabaseConnectionPool {
    if (!DatabaseConnectionPool.instance) {
      if (!config) {
        throw new Error('Configuration required for first initialization');
      }
      DatabaseConnectionPool.instance = new DatabaseConnectionPool(config);
    }
    return DatabaseConnectionPool.instance;
  }

  private initializePool(): void {
    // 初始化连接池
    for (let i = 0; i < 10; i++) {
      this.connections.push(this.createConnection());
    }
  }

  private createConnection(): any {
    // 创建数据库连接的逻辑
    return {
      query: (sql: string) => console.log(`Executing: ${sql}`),
      close: () => console.log('Connection closed')
    };
  }

  public getConnection(): any {
    return this.connections.pop() || this.createConnection();
  }

  public releaseConnection(connection: any): void {
    this.connections.push(connection);
  }
}

实际应用示例:Streams to River

Streams to River 由字节跳动开源, 是一款英语学习应用。该产品的初衷是通过将日常所见的英语单词、句子和相关的上下文进行记录、提取和管理, 结合 艾宾浩斯遗忘曲线,进行周期性的学习和记忆。

在开发过程中,深度采用了 TRAE 进行代码的开发和调试、注释和单测的编写,通过 coze workflow 快速集成了图像转文字、实时聊天、语音识别、单词划线等大模型能力。

在该项目代码中就存在大量的单例模式代码。

1. AuthService 的实现

class AuthService {
  private static instance: AuthService;
  private serverConfig: ServerConfig;

  private constructor() {
    this.serverConfig = ServerConfig.getInstance();
  }

  public static getInstance(): AuthService {
    if (!AuthService.instance) {
      AuthService.instance = new AuthService();
    }
    return AuthService.instance;
  }

  async login(loginData: LoginRequest): Promise<AuthResponse> {
    try {
      const response = await Taro.request({
        url: this.serverConfig.getFullUrl('/api/login'),
        method: 'POST',
        data: loginData,
        header: {
          'Content-Type': 'application/json'
        }
      });

      if (response.statusCode === 200) {
        const authData = response.data as AuthResponse;
        await this.setToken(authData.token);
        return authData;
      } else {
        throw new Error(response.data || '登录失败');
      }
    } catch (error) {
      console.error('Login error:', error);
      throw error;
    }
  }

  async register(registerData: RegisterRequest): Promise<AuthResponse> {
    try {
      const response = await Taro.request({
        url: this.serverConfig.getFullUrl('/api/register'),
        method: 'POST',
        data: registerData,
        header: {
          'Content-Type': 'application/json'
        }
      });

      if (response.statusCode === 200) {
        const authData = response.data as AuthResponse;
        await this.setToken(authData.token);
        return authData;
      } else {
        throw new Error(response.data || '注册失败');
      }
    } catch (error) {
      console.error('Register error:', error);
      throw error;
    }
  }

  async getUserInfo(): Promise<User> {
    try {
      const token = await this.getToken();
      if (!token) {
        throw new Error('未找到token');
      }

      const response = await Taro.request({
        url: this.serverConfig.getFullUrl('/api/user'),
        method: 'GET',
        header: {
          'Authorization': `Bearer ${token}`,
          'Content-Type': 'application/json'
        }
      });

      if (response.statusCode === 200) {
        return response.data as User;
      } else {
        throw new Error('获取用户信息失败');
      }
    } catch (error) {
      console.error('Get user info error:', error);
      throw error;
    }
  }

  async setToken(token: string): Promise<void> {
    try {
      await Taro.setStorageSync('jwt_token', token);
    } catch (error) {
      console.error('Set token error:', error);
      throw error;
    }
  }

  async getToken(): Promise<string | null> {
    try {
      return Taro.getStorageSync('jwt_token') || null;
    } catch (error) {
      console.error('Get token error:', error);
      return null;
    }
  }

  async clearToken(): Promise<void> {
    try {
      await Taro.removeStorageSync('jwt_token');
    } catch (error) {
      console.error('Clear token error:', error);
    }
  }

  async isLoggedIn(): Promise<boolean> {
    const token = await this.getToken();
    return !!token;
  }

  async logout(): Promise<void> {
    await this.clearToken();
  }
}

2. AudioManager 的实现

class AudioManager {
  private static instance: AudioManager;
  private currentAudio: HTMLAudioElement | null = null;
  private currentWordId: number | null = null;
  private playingCallbacks: Map<number, (isPlaying: boolean) => void> = new Map();

  static getInstance(): AudioManager {
    if (!AudioManager.instance) {
      AudioManager.instance = new AudioManager();
    }
    return AudioManager.instance;
  }

  // Register playback status callback
  registerCallback(wordId: number, callback: (isPlaying: boolean) => void) {
    this.playingCallbacks.set(wordId, callback);
  }

  // Unregister callback
  unregisterCallback(wordId: number) {
    this.playingCallbacks.delete(wordId);
  }

  // Play audio
  async playAudio(wordId: number, audioUrl: string): Promise<void> {
    try {
      // Stop currently playing audio
      this.stopCurrentAudio();

      // Create new audio instance
      const audio = new Audio(audioUrl);
      this.currentAudio = audio;
      this.currentWordId = wordId;

      // Set audio properties
      audio.preload = 'auto';
      audio.volume = 1.0;

      // Notify playback start
      this.notifyPlayingState(wordId, true);

      // Listen to audio events
      audio.addEventListener('ended', () => {
        this.handleAudioEnd();
      });

      audio.addEventListener('error', (e) => {
        console.error('Audio playback error:', e);
        this.handleAudioEnd();
      });

      // Play audio
      await audio.play();
    } catch (error) {
      console.error('Failed to play audio:', error);
      this.handleAudioEnd();
    }
  }

  // Stop current audio
  private stopCurrentAudio() {
    if (this.currentAudio) {
      this.currentAudio.pause();
      this.currentAudio.currentTime = 0;
      this.currentAudio = null;
    }
    if (this.currentWordId !== null) {
      this.notifyPlayingState(this.currentWordId, false);
      this.currentWordId = null;
    }
  }

  // Handle audio end
  private handleAudioEnd() {
    if (this.currentWordId !== null) {
      this.notifyPlayingState(this.currentWordId, false);
    }
    this.currentAudio = null;
    this.currentWordId = null;
  }

  // Notify playback state change
  private notifyPlayingState(wordId: number, isPlaying: boolean) {
    const callback = this.playingCallbacks.get(wordId);
    if (callback) {
      callback(isPlaying);
    }
  }

  // Check if currently playing
  isPlaying(wordId: number): boolean {
    return this.currentWordId === wordId && this.currentAudio !== null;
  }
}

3. ServerConfig 的实现

class ServerConfig {
  private static instance: ServerConfig;
  private config: ServerConfigInterface;

  private constructor() {
    this.config = this.loadConfig();
  }

  public static getInstance(): ServerConfig {
    if (!ServerConfig.instance) {
      ServerConfig.instance = new ServerConfig();
    }
    return ServerConfig.instance;
  }

  private loadConfig(): ServerConfigInterface {
    const serverDomain = location.origin;

    const url = new URL(serverDomain);

    return {
      domain: url.hostname,
      port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80),
      protocol: url.protocol.replace(':', ''),
    };
  }

  public getDomain(): string {
    return this.config.domain;
  }

  public getPort(): number {
    return this.config.port || 80;
  }

  public getProtocol(): string {
    return this.config.protocol || 'http';
  }

  public getBaseUrl(): string {
    const port = this.getPort();
    const protocol = this.getProtocol();
    const domain = this.getDomain();

    if ((protocol === 'http' && port === 80) || (protocol === 'https' && port === 443)) {
      return `${protocol}://${domain}`;
    }

    return `${protocol}://${domain}:${port}`;
  }

  public getFullUrl(path: string = ''): string {
    const baseUrl = this.getBaseUrl();
    const cleanPath = path.startsWith('/') ? path : `/${path}`;
    return `${baseUrl}${cleanPath}`;
  }
}

实际应用示例:Cherry Studio

🍒 Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客户端,兼容 Windows、Mac 和 Linux 系统。。

该项目前端是比较复杂的, 采用良好的设计十分必要。它的代码中也存在大量的单例模式设计。

1. StoreSyncService 的实现

import { IpcChannel } from '@shared/IpcChannel'
import type { StoreSyncAction } from '@types'
import { BrowserWindow, ipcMain } from 'electron'

/**
 * StoreSyncService class manages Redux store synchronization between multiple windows in the main process
 * It uses singleton pattern to ensure only one sync service instance exists in the application
 *
 * Main features:
 * 1. Manages window subscriptions for store sync
 * 2. Handles IPC communication for store sync between windows
 * 3. Broadcasts Redux actions from one window to all other windows
 * 4. Adds metadata to synced actions to prevent infinite sync loops
 */
export class StoreSyncService {
  private static instance: StoreSyncService
  private windowIds: number[] = []
  private isIpcHandlerRegistered = false

  private constructor() {
    return
  }

  /**
   * Get the singleton instance of StoreSyncService
   */
  public static getInstance(): StoreSyncService {
    if (!StoreSyncService.instance) {
      StoreSyncService.instance = new StoreSyncService()
    }
    return StoreSyncService.instance
  }

  /**
   * Subscribe a window to store sync
   * @param windowId ID of the window to subscribe
   */
  public subscribe(windowId: number): void {
    if (!this.windowIds.includes(windowId)) {
      this.windowIds.push(windowId)
    }
  }

  /**
   * Unsubscribe a window from store sync
   * @param windowId ID of the window to unsubscribe
   */
  public unsubscribe(windowId: number): void {
    this.windowIds = this.windowIds.filter((id) => id !== windowId)
  }

  /**
   * Sync an action to all renderer windows
   * @param type Action type, like 'settings/setTray'
   * @param payload Action payload
   *
   * NOTICE: DO NOT use directly in ConfigManager, may cause infinite sync loop
   */
  public syncToRenderer(type: string, payload: any): void {
    const action: StoreSyncAction = {
      type,
      payload
    }

    //-1 means the action is from the main process, will be broadcast to all windows
    this.broadcastToOtherWindows(-1, action)
  }

  /**
   * Register IPC handlers for store sync communication
   * Handles window subscription, unsubscription and action broadcasting
   */
  public registerIpcHandler(): void {
    if (this.isIpcHandlerRegistered) return

    ipcMain.handle(IpcChannel.StoreSync_Subscribe, (event) => {
      const windowId = BrowserWindow.fromWebContents(event.sender)?.id
      if (windowId) {
        this.subscribe(windowId)
      }
    })

    ipcMain.handle(IpcChannel.StoreSync_Unsubscribe, (event) => {
      const windowId = BrowserWindow.fromWebContents(event.sender)?.id
      if (windowId) {
        this.unsubscribe(windowId)
      }
    })

    ipcMain.handle(IpcChannel.StoreSync_OnUpdate, (event, action: StoreSyncAction) => {
      const sourceWindowId = BrowserWindow.fromWebContents(event.sender)?.id

      if (!sourceWindowId) return

      // Broadcast the action to all other windows
      this.broadcastToOtherWindows(sourceWindowId, action)
    })

    this.isIpcHandlerRegistered = true
  }

  /**
   * Broadcast a Redux action to all other windows except the source
   * @param sourceWindowId ID of the window that originated the action
   * @param action Redux action to broadcast
   */
  private broadcastToOtherWindows(sourceWindowId: number, action: StoreSyncAction): void {
    // Add metadata to indicate this action came from sync
    const syncAction = {
      ...action,
      meta: {
        ...action.meta,
        fromSync: true,
        source: `windowId:${sourceWindowId}`
      }
    }

    // Send to all windows except the source
    this.windowIds.forEach((windowId) => {
      if (windowId !== sourceWindowId) {
        const targetWindow = BrowserWindow.fromId(windowId)
        if (targetWindow && !targetWindow.isDestroyed()) {
          targetWindow.webContents.send(IpcChannel.StoreSync_BroadcastSync, syncAction)
        } else {
          this.unsubscribe(windowId)
        }
      }
    })
  }
}

// Export singleton instance
export default StoreSyncService.getInstance()

2. NotificationQueue 的实现

import type { Notification } from '@renderer/types/notification'
import PQueue from 'p-queue'

type NotificationListener = (notification: Notification) => Promise<void> | void

export class NotificationQueue {
  private static instance: NotificationQueue
  private queue = new PQueue({ concurrency: 1 })
  private listeners: NotificationListener[] = []

  // oxlint-disable-next-line @typescript-eslint/no-empty-function
  private constructor() {}

  public static getInstance(): NotificationQueue {
    if (!NotificationQueue.instance) {
      NotificationQueue.instance = new NotificationQueue()
    }
    return NotificationQueue.instance
  }

  public subscribe(listener: NotificationListener) {
    this.listeners.push(listener)
  }

  public unsubscribe(listener: NotificationListener) {
    this.listeners = this.listeners.filter((l) => l !== listener)
  }

  public async add(notification: Notification): Promise<void> {
    await this.queue.add(() => Promise.all(this.listeners.map((listener) => listener(notification))))
  }

  /**
   * 清空通知队列
   */
  public clear(): void {
    this.queue.clear()
  }

  /**
   * 获取队列中等待的任务数量
   */
  public get pending(): number {
    return this.queue.pending
  }

  /**
   * 获取队列的大小(包括正在进行和等待的任务)
   */
  public get size(): number {
    return this.queue.size
  }
}

3. AgentService 的实现

import path from 'node:path'

import { loggerService } from '@logger'
import { pluginService } from '@main/services/agents/plugins/PluginService'
import { getDataPath } from '@main/utils'
import type {
  AgentEntity,
  CreateAgentRequest,
  CreateAgentResponse,
  GetAgentResponse,
  ListOptions,
  UpdateAgentRequest,
  UpdateAgentResponse
} from '@types'
import { AgentBaseSchema } from '@types'
import { asc, count, desc, eq } from 'drizzle-orm'

import { BaseService } from '../BaseService'
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
import type { AgentModelField } from '../errors'

const logger = loggerService.withContext('AgentService')

export class AgentService extends BaseService {
  private static instance: AgentService | null = null
  private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']

  static getInstance(): AgentService {
    if (!AgentService.instance) {
      AgentService.instance = new AgentService()
    }
    return AgentService.instance
  }

  async initialize(): Promise<void> {
    await BaseService.initialize()
  }

  // Agent Methods
  async createAgent(req: CreateAgentRequest): Promise<CreateAgentResponse> {
    this.ensureInitialized()

    const id = `agent_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
    const now = new Date().toISOString()

    if (!req.accessible_paths || req.accessible_paths.length === 0) {
      const defaultPath = path.join(getDataPath(), 'agents', id)
      req.accessible_paths = [defaultPath]
    }

    if (req.accessible_paths !== undefined) {
      req.accessible_paths = this.ensurePathsExist(req.accessible_paths)
    }

    await this.validateAgentModels(req.type, {
      model: req.model,
      plan_model: req.plan_model,
      small_model: req.small_model
    })

    const serializedReq = this.serializeJsonFields(req)

    const insertData: InsertAgentRow = {
      id,
      type: req.type,
      name: req.name || 'New Agent',
      description: req.description,
      instructions: req.instructions || 'You are a helpful assistant.',
      model: req.model,
      plan_model: req.plan_model,
      small_model: req.small_model,
      configuration: serializedReq.configuration,
      accessible_paths: serializedReq.accessible_paths,
      created_at: now,
      updated_at: now
    }

    await this.database.insert(agentsTable).values(insertData)
    const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
    if (!result[0]) {
      throw new Error('Failed to create agent')
    }

    const agent = this.deserializeJsonFields(result[0]) as AgentEntity
    return agent
  }

  async getAgent(id: string): Promise<GetAgentResponse | null> {
    this.ensureInitialized()

    const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)

    if (!result[0]) {
      return null
    }

    const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse
    agent.tools = await this.listMcpTools(agent.type, agent.mcps)

    // Load installed_plugins from cache file instead of database
    const workdir = agent.accessible_paths?.[0]
    if (workdir) {
      try {
        agent.installed_plugins = await pluginService.listInstalledFromCache(workdir)
      } catch (error) {
        // Log error but don't fail the request
        logger.warn(`Failed to load installed plugins for agent ${id}`, {
          workdir,
          error: error instanceof Error ? error.message : String(error)
        })
        agent.installed_plugins = []
      }
    } else {
      agent.installed_plugins = []
    }

    return agent
  }

  async listAgents(options: ListOptions = {}): Promise<{ agents: AgentEntity[]; total: number }> {
    this.ensureInitialized() // Build query with pagination

    const totalResult = await this.database.select({ count: count() }).from(agentsTable)

    const sortBy = options.sortBy || 'created_at'
    const orderBy = options.orderBy || 'desc'

    const sortField = agentsTable[sortBy]
    const orderFn = orderBy === 'asc' ? asc : desc

    const baseQuery = this.database.select().from(agentsTable).orderBy(orderFn(sortField))

    const result =
      options.limit !== undefined
        ? options.offset !== undefined
          ? await baseQuery.limit(options.limit).offset(options.offset)
          : await baseQuery.limit(options.limit)
        : await baseQuery

    const agents = result.map((row) => this.deserializeJsonFields(row)) as GetAgentResponse[]

    for (const agent of agents) {
      agent.tools = await this.listMcpTools(agent.type, agent.mcps)
    }

    return { agents, total: totalResult[0].count }
  }

  async updateAgent(
    id: string,
    updates: UpdateAgentRequest,
    options: { replace?: boolean } = {}
  ): Promise<UpdateAgentResponse | null> {
    this.ensureInitialized()

    // Check if agent exists
    const existing = await this.getAgent(id)
    if (!existing) {
      return null
    }

    const now = new Date().toISOString()

    if (updates.accessible_paths !== undefined) {
      updates.accessible_paths = this.ensurePathsExist(updates.accessible_paths)
    }

    const modelUpdates: Partial<Record<AgentModelField, string | undefined>> = {}
    for (const field of this.modelFields) {
      if (Object.prototype.hasOwnProperty.call(updates, field)) {
        modelUpdates[field] = updates[field as keyof UpdateAgentRequest] as string | undefined
      }
    }

    if (Object.keys(modelUpdates).length > 0) {
      await this.validateAgentModels(existing.type, modelUpdates)
    }

    const serializedUpdates = this.serializeJsonFields(updates)

    const updateData: Partial<AgentRow> = {
      updated_at: now
    }
    const replaceableFields = Object.keys(AgentBaseSchema.shape) as (keyof AgentRow)[]
    const shouldReplace = options.replace ?? false

    for (const field of replaceableFields) {
      if (shouldReplace || Object.prototype.hasOwnProperty.call(serializedUpdates, field)) {
        if (Object.prototype.hasOwnProperty.call(serializedUpdates, field)) {
          const value = serializedUpdates[field as keyof typeof serializedUpdates]
          ;(updateData as Record<string, unknown>)[field] = value ?? null
        } else if (shouldReplace) {
          ;(updateData as Record<string, unknown>)[field] = null
        }
      }
    }

    await this.database.update(agentsTable).set(updateData).where(eq(agentsTable.id, id))
    return await this.getAgent(id)
  }

  async deleteAgent(id: string): Promise<boolean> {
    this.ensureInitialized()

    const result = await this.database.delete(agentsTable).where(eq(agentsTable.id, id))

    return result.rowsAffected > 0
  }

  async agentExists(id: string): Promise<boolean> {
    this.ensureInitialized()

    const result = await this.database
      .select({ id: agentsTable.id })
      .from(agentsTable)
      .where(eq(agentsTable.id, id))
      .limit(1)

    return result.length > 0
  }
}

export const agentService = AgentService.getInstance()

总结

单例模式在 TypeScript 中是一个强大而有用的模式,但需要谨慎使用。通过合理的实现和适当的使用场景,它可以有效地管理全局资源和状态。记住,单例模式不是万能的,在决定使用之前,请确保它确实是解决你问题的最佳方案。

posted @ 2025-11-07 15:37  guangzan  阅读(58)  评论(0)    收藏  举报