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

浙公网安备 33010602011771号