当前位置: 首页 > news >正文

前端实战中的单例模式:以医疗药敏管理为例

目录

    • 一、什么是单例模式?
      • 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 系统中,医院的微生物实验室都需要进行药敏测试管理。

简单来说,就是当医生从病人身上采集标本后,实验室需要:

  • 分离出可能导致感染的细菌
  • 测试这些细菌对不同抗生素的敏感程度
  • 生成报告帮助医生选择有效的抗生素治疗

在这样的场景下,前端数据管理就需要考虑如下问题:

  1. 状态的集中管理:一个样本可能检出多种细菌,每种细菌又对应多种抗生素的测试结果。这些数据结构复杂且相互关联。如果分散管理,极易导致状态混乱和数据不一致。
  2. 数据共享与同步:用户可能在检出菌列表和药敏结果表格之间频繁切换、编辑。例如,在药敏结果表格中修改了某个抗生素的结果,这个状态需要被准确记录,并且如果后续有保存操作,需要将最新的完整数据提交给后端。
  3. 组件间通信:业务主页面可能包含多个子组件(例如,选择检出菌的组件、编辑药敏结果的组件、引用历史结果的弹窗等)。这些组件可能都需要访问或修改这份核心的药敏数据。
  4. 操作的原子性与数据一致性:当用户选择一个新的检出菌时,可能需要从后端加载默认的药敏模板;当用户修改检出菌名称时,可能需要判断是否要清除已有的药敏结果。这些操作都需要确保数据在不同步骤间保持一致。
  5. 提升可维护性:将数据管理逻辑从视图组件中抽离出来,形成独立的模块,可以使组件代码更专注于视图渲染和用户交互,降低耦合度,提高代码的可读性和可维护性。

那么这种情况下,使用 单例模式 就再合适不过了,我们通过一个类来提供集中的、可控的数据存储和操作接口,所有关于药敏数据的状态变更都通过这个管家来进行,确保了数据的统一和有序。

来看看核心代码逻辑演示:

// 定义数据结构
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 响应式状态容器

我们来用一张表格来对比下

维度单例模式(如 ConfigManagerPinia
核心目的管理和共享一个全局类实例响应式地管理组件状态
数据结构普通 JS 对象(不可响应)Vue 响应式对象
使用语义传统 OOP(面向对象)Vue 组合式 API(函数式风格)
调用方式ConfigManager.getInstance()useXxxStore()
响应性❌ 无自动响应式,改了数据不会自动更新视图✅ 自动驱动视图更新
依赖框架❌ 完全独立于 Vue,可用于任何项目✅ 必须依赖于 Vue(Pinia)
场景适配配置项、工具类、非 UI 逻辑、模块缓存组件状态、表单数据、视图交互相关状态

所以,对于实际场景的选择:

场景使用单例模式使用 Pinia
全局 API 配置管理✅ 非常合适❌ 太重,没必要响应式
用户登录信息缓存✅ 可行(如 JWT、用户 ID)✅ 更适合响应式场景(比如头像变化自动刷新)
控制 debug 模式、mock 模式✅ 合理、集中式管理❌ 用 Pinia 会显得臃肿
页面内表单状态❌ 不适合,改了没反应✅ 响应式,推荐
多组件共享列表数据❌ 实现复杂、无响应性✅ 很方便,推荐

综上可以看出

  • 是否需要响应式 是选择单例或 Pinia 的核心判断标准;
  • 单例模式适合做“全局共享、非 UI 驱动”的状态管理,比如配置信息、工具类、日志器等;
  • Pinia 是“响应式状态容器”,适合组件状态、UI 状态、交互驱动的逻辑;

相关文章:

  • 从一城一云到AI CITY,智慧城市进入新阶段
  • OpenCV 中用于背景分割的一个类cv::bgsegm::BackgroundSubtractorLSBP
  • 【数据融合实战手册·应用篇】“数字孪生+视频融合”让智慧城市拥有空间感知
  • 大语言模型主流架构解析:从 Transformer 到 GPT、BERT
  • 【JS逆向基础】前端基础-HTML与CSS
  • ‌CDGP|数据治理:探索企业数据有序与安全的解决之道
  • 开源照片管理系统PhotoPrism的容器化部署与远程管理配置
  • Python中的re库详细用法与代码解析
  • 跨浏览器自动化测试的智能生成方法
  • mission planner烧录ardupilot固件报错死机
  • 02_JVM
  • AI预测3D新模型百十个定位预测+胆码预测+去和尾2025年5月8日第72弹
  • SQLite3常用语句汇总
  • 文件包含 任意文件读取
  • 使用Jmeter进行核心API压力测试
  • 云计算的基础概论
  • 【工具推荐】Code2Prompt
  • 认识不同格式的点云数据 -PCD点云数据 文本点云数据
  • C++23 views::as_rvalue (P2446R2) 深入解析
  • Hutool中的Pair类详解
  • 数理+AI+工程,上海交大将开首届“笛卡尔班”招生约20名
  • 赵作海因病离世,妻子李素兰希望过平静生活
  • 招行:拟出资150亿元全资发起设立金融资产投资公司
  • “用鲜血和生命凝结的深厚情谊”——习近平主席署名文章中的中俄友好故事
  • 中国电信财务部总经理周响华调任华润集团总会计师
  • 北美票房|“雷霆”开画票房比“美队4”低,但各方都能接受