前端实战中的单例模式:以医疗药敏管理为例
目录
- 一、什么是单例模式?
- 1. 状态共享性 —— 数据唯一,任意访问,任意修改
- 2. 生命周期控制性 —— 自己掌控何时创建、何时销毁
- 二、实战分析:医疗药敏管理系统中的单例应用
- 三、其他场景示例
- 单例实现:ConfigManager.ts
- 四、单例 VS Pinia
在大型项目中,尤其是业务数据复杂、组件之间需要共享状态的场景里,我们会遇到这样的问题:
某个模块的数据需要在多个地方访问和更新,但我们不希望它被重复实例化,避免状态混乱,如何实现?
这个时候单例模式
就要出场了。在本文中,我会结合一个医疗行业中抗生素药敏信息管理的实际案例,剖析单例模式的核心价值、使用场景、代码实践。
一、什么是单例模式?
单例模式的基本结构:
class Singleton {private static instance: Singleton;private constructor() {} // 构造器私有化,禁止外部 newpublic static getInstance(): Singleton {if (!Singleton.instance) {Singleton.instance = new Singleton();}return Singleton.instance;}
}
单例模式的本质是:状态共享 + 生命周期控制
这里有两个关键点:
1. 状态共享性 —— 数据唯一,任意访问,任意修改
单例其实就是一个全局变量 + 封装行为
的对象。你创建了一个类,它被实例化一次后,这个实例会在整个项目生命周期中被共享使用,不管你在哪里引用,拿到的都是同一个对象。这就意味着你可以在任意组件中:
- 读取状态;
- 修改状态;
- 保证数据一致。
const instance = useMySingleton(); // 不管在哪里调用,拿到的是同一个实例
instance.setValue(123);
如果你熟悉vue开发,会发现 Pinia 中定义的 store
很相似,比如:
const userStore = useUserStore();
userStore.name = 'zhen';
无论在哪个组件里调用 useUserStore()
,拿到的都是同一个 store 实例,状态是同步的。
文章的末尾我们会仔细对比下二者的区别。
2. 生命周期控制性 —— 自己掌控何时创建、何时销毁
单例模式的好处在于:你可以完全控制这个对象的生命周期。
- 什么时候创建(第一次调用才创建);
- 什么时候重置(提供 reset 方法);
- 什么时候释放资源(如关闭 socket,清除定时器等)。
这点和 Pinia 有点区别,因为 Pinia 的 store 是响应式的、受 Vue 生命周期自动管理的,你不能完全控制 store 的创建和销毁。
举个例子:
// 单例
const socket = useSocket(); // 第一次调用才创建连接
socket.close(); // 可以手动关闭连接// Pinia
const socketStore = useSocketStore();
socketStore.socket = new WebSocket(...) // 你无法很方便地从外部控制它的初始化时机和销毁逻辑
二、实战分析:医疗药敏管理系统中的单例应用
在 LIS 系统中,医院的微生物实验室都需要进行药敏测试管理。
简单来说,就是当医生从病人身上采集标本后,实验室需要:
- 分离出可能导致感染的细菌
- 测试这些细菌对不同抗生素的敏感程度
- 生成报告帮助医生选择有效的抗生素治疗
在这样的场景下,前端数据管理就需要考虑如下问题:
- 状态的集中管理:一个样本可能检出多种细菌,每种细菌又对应多种抗生素的测试结果。这些数据结构复杂且相互关联。如果分散管理,极易导致状态混乱和数据不一致。
- 数据共享与同步:用户可能在检出菌列表和药敏结果表格之间频繁切换、编辑。例如,在药敏结果表格中修改了某个抗生素的结果,这个状态需要被准确记录,并且如果后续有保存操作,需要将最新的完整数据提交给后端。
- 组件间通信:业务主页面可能包含多个子组件(例如,选择检出菌的组件、编辑药敏结果的组件、引用历史结果的弹窗等)。这些组件可能都需要访问或修改这份核心的药敏数据。
- 操作的原子性与数据一致性:当用户选择一个新的检出菌时,可能需要从后端加载默认的药敏模板;当用户修改检出菌名称时,可能需要判断是否要清除已有的药敏结果。这些操作都需要确保数据在不同步骤间保持一致。
- 提升可维护性:将数据管理逻辑从视图组件中抽离出来,形成独立的模块,可以使组件代码更专注于视图渲染和用户交互,降低耦合度,提高代码的可读性和可维护性。
那么这种情况下,使用 单例模式 就再合适不过了,我们通过一个类来提供集中的、可控的数据存储和操作接口,所有关于药敏数据的状态变更都通过这个管家来进行,确保了数据的统一和有序。
来看看核心代码逻辑演示:
// 定义数据结构
interface AntibioticResult {// resId: string; // 示例:代表具体字段// ... 更多药敏结果相关字段
}interface BacterialInfo {// bacId: string | number; // 示例:代表具体字段// ... 更多检出菌信息相关字段
}interface DSTestMap {bactRes: BacterialInfo; // 检出菌信息antiResList: AntibioticResult[]; // 相关的药敏结果列表
}/*** AntibioticResistanceMap 类 (核心数据管理类)* 储存和管理检出菌及其对应的药敏测试结果。* 此类采用单例模式,确保全局只有一个实例。*/
class AntibioticResistanceMap {// 使用 Map 结构来存储数据,键为细菌ID,值为检出菌信息和药敏结果列表private tableMap = new Map<string | number, DSTestMap>();/*** 清空映射表中的所有数据。*/public clearTableMap(): void {// 业务逻辑:清空 this.tableMap 实例。// 例如: this.tableMap.clear();}/*** 根据细菌ID删除映射表中的条目。* @param bacId 检出菌ID*/public deleteTableMap(bacId: string | number): void {// 业务逻辑:从 this.tableMap 中删除指定 bacId 的条目。// 例如: if (this.tableMap.has(bacId)) { this.tableMap.delete(bacId); }}/*** 设置或更新指定细菌ID的映射数据。* @param bacId 检出菌ID* @param options 包含检出菌和药敏结果的数据*/public setTableMap(bacId: string | number, options: DSTestMap): void {// 业务逻辑:// 1. (可选) 验证传入的 options 数据是否符合规范 (调用 this.validateData)。// 2. (可选) 对 options 数据进行深拷贝 (调用 this.cloneData) 以避免外部修改影响内部状态。// 3. 将处理后的数据存入 this.tableMap。// 例如: this.tableMap.set(bacId, processedOptions);}/*** 更新已存在的细菌ID的映射数据。* 通常用于部分更新检出菌信息或药敏结果列表。* @param bacId 检出菌ID* @param options 需要部分更新的数据*/public updateTableMap(bacId: string | number, options: Partial<DSTestMap>): void {// 业务逻辑:// 1. 获取 bacId 对应的现有数据。// 2. 将传入的 options 与现有数据合并。// 3. (可选) 验证合并后的数据。// 4. (可选) 对合并后的数据进行深拷贝。// 5. 将更新后的数据存回 this.tableMap。// 例如: const currentData = this.tableMap.get(bacId); /* ...合并与处理... */ this.tableMap.set(bacId, updatedData);}/*** 根据细菌ID获取映射数据。* @param bacId 检出菌ID* @returns 检出菌和药敏结果数据,如果不存在则返回 undefined*/public getTableMap(bacId: string | number): DSTestMap | undefined {// 业务逻辑:// 1. 从 this.tableMap 获取 bacId 对应的数据。// 2. (可选) 对获取的数据进行深拷贝后返回,以防止外部修改。// 例如: const data = this.tableMap.get(bacId); return data ? this.cloneData(data) : undefined;return undefined; // 仅为骨架示例}/*** 检查是否存在指定细菌ID的映射。* @param bacId 检出菌ID* @returns 如果存在则返回 true,否则返回 false*/public hasTableMap(bacId: string | number): boolean {// 业务逻辑:检查 this.tableMap 是否包含 bacId。// 例如: return this.tableMap.has(bacId);return false; // 仅为骨架示例}/*** 获取映射表中所有的数据。* @returns 包含所有检出菌和药敏结果数据的 Map 对象*/public getAllTableMap(): Map<string | number, DSTestMap> {// 业务逻辑:// 1. 创建一个新的 Map。// 2. 遍历 this.tableMap,将每个条目 (可选地进行深拷贝后) 存入新的 Map。// 3. 返回新的 Map。// 例如: const allData = new Map(); this.tableMap.forEach(...); return allData;return new Map(); }/*** 数据验证 (私有辅助方法)。* 用于验证将要存入映射表的数据的结构和内容的正确性。* @param data 需要验证的数据*/private validateData(data: DSTestMap): void {// 内部具体的数据验证逻辑。// 例如: 检查 bactRes 和 antiResList 是否存在,字段是否符合要求等。}/*** 深拷贝数据 (私有辅助方法)。* 用于创建数据的深拷贝副本,以防止原始数据被意外修改。* @param data 需要拷贝的数据* @returns 深拷贝后的数据副本*/private cloneData<T>(data: T): T {// 内部具体的深拷贝实现逻辑。// 例如: return JSON.parse(JSON.stringify(data));return data; // 仅为示例,实际应为深拷贝实现}
}// --- 单例模式实现的核心 ---
// instance 变量用于存储 AntibioticResistanceMap 的唯一实例。
// 初始化为 null,表示尚未创建实例。
let instance: AntibioticResistanceMap | null = null;/*** useDSTestMap Hook (工厂函数)* 这是获取 AntibioticResistanceMap 单例实例的唯一入口。* 如果实例不存在,则创建一个新实例;否则,返回现有实例。* @returns AntibioticResistanceMap 的单例实例*/
export const useDSTestMap = () => {if (!instance) {// 如果 instance 为 null (即第一次调用),则创建 AntibioticResistanceMap 的新实例。instance = new AntibioticResistanceMap();}// 返回(新创建的或已存在的)实例。return instance;
};
以上仅仅是我对这个特定业务场景下的逻辑抽象,来让大家通过代码实例感受单例模式的应用,替换到其他的业务场景也都是类似的。
三、其他场景示例
上面的业务对于没接触过 LIS 业务的人来说,看起来可能有点不直观,接下来举个简单的例子体会下单例模式。
场景:全局配置管理器(ConfigManager)
在中大型前端项目中,经常会有一些全局配置项,比如:
- 接口基础地址(baseURL)
- 环境标识(dev/test/prod)
- 默认请求头
- 开关配置项(如是否启用 mock、调试日志等)
这些配置需要:
- 全局唯一且共享;
- 在应用初始化时设定一次;
- 在后续任意模块或组件中都可以访问;
- 可在测试环境或某些场景下动态修改配置(比如切换 API 地址)。
这非常适合用单例模式封装。
单例实现:ConfigManager.ts
// ConfigManager.tstype ConfigOptions = {baseURL: string;env: 'dev' | 'test' | 'prod';enableMock: boolean;
};class ConfigManager {private static instance: ConfigManager;private config: ConfigOptions;private constructor() {// 默认配置this.config = {baseURL: '',env: 'dev',enableMock: false,};}public static getInstance(): ConfigManager {if (!ConfigManager.instance) {ConfigManager.instance = new ConfigManager();}return ConfigManager.instance;}public setConfig(newConfig: Partial<ConfigOptions>) {this.config = { ...this.config, ...newConfig };}public getConfig(): ConfigOptions {return this.config;}public reset() {this.config = {baseURL: '',env: 'dev',enableMock: false,};}
}export default ConfigManager;
✅ 使用示例 1:初始化时配置
// main.tsimport ConfigManager from './ConfigManager';const config = ConfigManager.getInstance();config.setConfig({baseURL: 'https://api.example.com',env: 'prod',enableMock: false,
});
✅ 使用示例 2:任意模块中读取配置
// services/userService.tsimport ConfigManager from '../ConfigManager';export async function fetchUserData() {const { baseURL, enableMock } = ConfigManager.getInstance().getConfig();const url = enableMock? '/mock/user': `${baseURL}/user`;const res = await fetch(url);return res.json();
}
✅ 使用示例 3:切换环境或调试时修改配置
// devtools/configPanel.tsx(假设你有一个设置面板)import ConfigManager from '../ConfigManager';function switchToMockMode() {const config = ConfigManager.getInstance();config.setConfig({enableMock: true,});
}
四、单例 VS Pinia
我们以 vue 开发为背景,看完上面的例子,你可能会想,我用 Pinia 不也一样吗?
其实它们的本质区别就是 状态模型 vs 响应式状态容器
我们来用一张表格来对比下
维度 | 单例模式(如 ConfigManager ) | Pinia |
---|---|---|
核心目的 | 管理和共享一个全局类实例 | 响应式地管理组件状态 |
数据结构 | 普通 JS 对象(不可响应) | Vue 响应式对象 |
使用语义 | 传统 OOP(面向对象) | Vue 组合式 API(函数式风格) |
调用方式 | ConfigManager.getInstance() | useXxxStore() |
响应性 | ❌ 无自动响应式,改了数据不会自动更新视图 | ✅ 自动驱动视图更新 |
依赖框架 | ❌ 完全独立于 Vue,可用于任何项目 | ✅ 必须依赖于 Vue(Pinia) |
场景适配 | 配置项、工具类、非 UI 逻辑、模块缓存 | 组件状态、表单数据、视图交互相关状态 |
所以,对于实际场景的选择:
场景 | 使用单例模式 | 使用 Pinia |
---|---|---|
全局 API 配置管理 | ✅ 非常合适 | ❌ 太重,没必要响应式 |
用户登录信息缓存 | ✅ 可行(如 JWT、用户 ID) | ✅ 更适合响应式场景(比如头像变化自动刷新) |
控制 debug 模式、mock 模式 | ✅ 合理、集中式管理 | ❌ 用 Pinia 会显得臃肿 |
页面内表单状态 | ❌ 不适合,改了没反应 | ✅ 响应式,推荐 |
多组件共享列表数据 | ❌ 实现复杂、无响应性 | ✅ 很方便,推荐 |
综上可以看出
是否需要响应式
是选择单例或 Pinia 的核心判断标准;- 单例模式适合做“全局共享、非 UI 驱动”的状态管理,比如配置信息、工具类、日志器等;
- Pinia 是“响应式状态容器”,适合组件状态、UI 状态、交互驱动的逻辑;