如何将单例模式为?

摘要:什么是单例模式? 单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。在 TypeScript 中,单例模式特别有用,因为它结合了 JavaScript 的灵活性和 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 中是一个强大而有用的模式,但需要谨慎使用。通过合理的实现和适当的使用场景,它可以有效地管理全局资源和状态。记住,单例模式不是万能的,在决定使用之前,请确保它确实是解决你问题的最佳方案。