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 callbackregisterCallback(wordId: number, callback: (isPlaying: boolean) => void) {this.playingCallbacks.set(wordId, callback);}// Unregister callbackunregisterCallback(wordId: number) {this.playingCallbacks.delete(wordId);}// Play audioasync playAudio(wordId: number, audioUrl: string): Promise<void> {try {// Stop currently playing audiothis.stopCurrentAudio();// Create new audio instanceconst audio = new Audio(audioUrl);this.currentAudio = audio;this.currentWordId = wordId;// Set audio propertiesaudio.preload = 'auto';audio.volume = 1.0;// Notify playback startthis.notifyPlayingState(wordId, true);// Listen to audio eventsaudio.addEventListener('ended', () => {this.handleAudioEnd();});audio.addEventListener('error', (e) => {console.error('Audio playback error:', e);this.handleAudioEnd();});// Play audioawait audio.play();} catch (error) {console.error('Failed to play audio:', error);this.handleAudioEnd();}}// Stop current audioprivate 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 endprivate handleAudioEnd() {if (this.currentWordId !== null) {this.notifyPlayingState(this.currentWordId, false);}this.currentAudio = null;this.currentWordId = null;}// Notify playback state changeprivate notifyPlayingState(wordId: number, isPlaying: boolean) {const callback = this.playingCallbacks.get(wordId);if (callback) {callback(isPlaying);}}// Check if currently playingisPlaying(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: StoreSyncServiceprivate windowIds: number[] = []private isIpcHandlerRegistered = falseprivate 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 windowsthis.broadcastToOtherWindows(-1, action)}/*** Register IPC handlers for store sync communication* Handles window subscription, unsubscription and action broadcasting*/public registerIpcHandler(): void {if (this.isIpcHandlerRegistered) returnipcMain.handle(IpcChannel.StoreSync_Subscribe, (event) => {const windowId = BrowserWindow.fromWebContents(event.sender)?.idif (windowId) {this.subscribe(windowId)}})ipcMain.handle(IpcChannel.StoreSync_Unsubscribe, (event) => {const windowId = BrowserWindow.fromWebContents(event.sender)?.idif (windowId) {this.unsubscribe(windowId)}})ipcMain.handle(IpcChannel.StoreSync_OnUpdate, (event, action: StoreSyncAction) => {const sourceWindowId = BrowserWindow.fromWebContents(event.sender)?.idif (!sourceWindowId) return// Broadcast the action to all other windowsthis.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 syncconst syncAction = {...action,meta: {...action.meta,fromSync: true,source: `windowId:${sourceWindowId}`}}// Send to all windows except the sourcethis.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> | voidexport class NotificationQueue {private static instance: NotificationQueueprivate queue = new PQueue({ concurrency: 1 })private listeners: NotificationListener[] = []// oxlint-disable-next-line @typescript-eslint/no-empty-functionprivate 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 = nullprivate 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 Methodsasync 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 AgentEntityreturn 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 GetAgentResponseagent.tools = await this.listMcpTools(agent.type, agent.mcps)// Load installed_plugins from cache file instead of databaseconst workdir = agent.accessible_paths?.[0]if (workdir) {try {agent.installed_plugins = await pluginService.listInstalledFromCache(workdir)} catch (error) {// Log error but don't fail the requestlogger.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 paginationconst 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 : descconst 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 baseQueryconst 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 existsconst 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 ?? falsefor (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 中是一个强大而有用的模式,但需要谨慎使用。通过合理的实现和适当的使用场景,它可以有效地管理全局资源和状态。记住,单例模式不是万能的,在决定使用之前,请确保它确实是解决你问题的最佳方案。
文章转载自:guangzan
原文链接:https://www.cnblogs.com/guangzan/p/19199902
体验地址:http://www.jnpfsoft.com/?from=001YH
