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

目录

在大型项目中,尤其是业务数据复杂、组件之间需要共享状态的场景里,我们会遇到这样的问题:

某个模块的数据需要在多个地方访问和更新,但我们不希望它被重复实例化,避免状态混乱,如何实现?

这个时候单例模式就要出场了。在本文中,我会结合一个医疗行业中抗生素药敏信息管理的实际案例,剖析单例模式的核心价值、使用场景、代码实践。


一、什么是单例模式?

单例模式的基本结构:

ts 复制代码
class Singleton {
  private static instance: Singleton;

  private constructor() {} // 构造器私有化,禁止外部 new

  public static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }
}

单例模式的本质是:状态共享 + 生命周期控制

这里有两个关键点:

1. 状态共享性 ------ 数据唯一,任意访问,任意修改

单例其实就是一个全局变量 + 封装行为的对象。你创建了一个类,它被实例化一次后,这个实例会在整个项目生命周期中被共享使用,不管你在哪里引用,拿到的都是同一个对象。这就意味着你可以在任意组件中:

  • 读取状态;
  • 修改状态;
  • 保证数据一致。
ts 复制代码
const instance = useMySingleton(); // 不管在哪里调用,拿到的是同一个实例
instance.setValue(123);

如果你熟悉vue开发,会发现 Pinia 中定义的 store 很相似,比如:

ts 复制代码
const userStore = useUserStore();
userStore.name = 'zhen';

无论在哪个组件里调用 useUserStore(),拿到的都是同一个 store 实例,状态是同步的。

文章的末尾我们会仔细对比下二者的区别。


2. 生命周期控制性 ------ 自己掌控何时创建、何时销毁

单例模式的好处在于:你可以完全控制这个对象的生命周期

  • 什么时候创建(第一次调用才创建);
  • 什么时候重置(提供 reset 方法);
  • 什么时候释放资源(如关闭 socket,清除定时器等)。

这点和 Pinia 有点区别,因为 Pinia 的 store 是响应式的、受 Vue 生命周期自动管理的,你不能完全控制 store 的创建和销毁。

举个例子:

ts 复制代码
// 单例
const socket = useSocket(); // 第一次调用才创建连接
socket.close(); // 可以手动关闭连接

// Pinia
const socketStore = useSocketStore();
socketStore.socket = new WebSocket(...) // 你无法很方便地从外部控制它的初始化时机和销毁逻辑

二、实战分析:医疗药敏管理系统中的单例应用

在 LIS 系统中,医院的微生物实验室都需要进行药敏测试管理。

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

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

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

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

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

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

typescript 复制代码
// 定义数据结构
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

ts 复制代码
// ConfigManager.ts

type 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:初始化时配置

ts 复制代码
// main.ts

import ConfigManager from './ConfigManager';

const config = ConfigManager.getInstance();

config.setConfig({
  baseURL: 'https://api.example.com',
  env: 'prod',
  enableMock: false,
});

✅ 使用示例 2:任意模块中读取配置

ts 复制代码
// services/userService.ts

import 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:切换环境或调试时修改配置

ts 复制代码
// 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 状态、交互驱动的逻辑;
相关推荐
冼紫菜6 分钟前
如何使用责任链模式优雅实现功能(滴滴司机、家政服务、请假审批等)
java·开发语言·设计模式·责任链模式
程序员小杰@11 分钟前
✨WordToCard使用分享✨
前端·人工智能·开源·云计算
ValidationExpression23 分钟前
设计模式-策略模式
python·设计模式·策略模式
larntin200232 分钟前
vue2开发者sass预处理注意
前端·css·sass
Enti7c44 分钟前
利用jQuery 实现多选标签下拉框,提升表单交互体验
前端·交互·jquery
SHUIPING_YANG1 小时前
在Fiddler中添加自定义HTTP方法列并高亮显示
前端·http·fiddler
互联网搬砖老肖2 小时前
Web 架构之前后端分离
前端·架构
水银嘻嘻2 小时前
web 自动化之 selenium+webdriver 环境搭建及原理讲解
前端·selenium·自动化
寧笙(Lycode)2 小时前
为什么使用Less替代原始CSS?
前端·css·less
-Camellia007-2 小时前
TypeScript学习案例(1)——贪吃蛇
javascript·学习·typescript