Published on

单例模式详解

Authors
  • avatar
    Name
    青雲
    Twitter

在软件开发过程中,我们经常会遇到一些需要全局唯一的对象。这些对象可能是配置文件管理器、数据库连接池、日志处理器等。这时,单例模式(Singleton Pattern)就派上用场了。单例模式确保一个类只有一个实例,并提供一个全局访问点

核心概念

单例模式的两个核心要素:

  1. 唯一性:确保类只能有一个实例。
  2. 全局访问点:提供一个全局的访问点,使得所有需要使用该类实例的代码都通过这个访问点获取实例。

实现方式

饿汉式单例

饿汉式单例在类加载时就创建实例,不管你是否需要这个实例。这是最简单的实现方式。

class HungrySingleton {
  // 静态变量在类加载时初始化
  private static readonly instance: HungrySingleton = new HungrySingleton();

  // 私有构造函数,防止外部实例化
  private constructor() {
    console.log("饿汉式实例已创建");
  }

  public static getInstance(): HungrySingleton {
    return this.instance;
  }
}

// 测试饿汉式单例
const instance1 = HungrySingleton.getInstance();
const instance2 = HungrySingleton.getInstance();

console.log(instance1 === instance2); // 输出: true

懒汉式单例

懒汉式单例延迟创建实例,只有在第一次使用时才创建。这种方法可以避免不必要的资源消耗。

class LazySingleton {
  private static instance: LazySingleton | null = null;

  // 私有构造函数,防止外部实例化
  private constructor() {
    console.log("懒汉式实例已创建");
  }

  public static getInstance(): LazySingleton {
    if (this.instance === null) {
      this.instance = new LazySingleton();
    }
    return this.instance;
  }
}

// 测试懒汉式单例
const instance1 = LazySingleton.getInstance();
const instance2 = LazySingleton.getInstance();

console.log(instance1 === instance2); // 输出: true

双重检查锁

双重检查锁是在多线程环境下优化懒汉式单例的一种实现方式。它通过减少同步锁的范围以提高性能。

class DoubleCheckedLockingSingleton {
  private static instance: DoubleCheckedLockingSingleton | null = null;

  private constructor() {
    console.log("双重检查锁实例已创建");
  }

  public static getInstance(): DoubleCheckedLockingSingleton {
    if (this.instance === null) {
      // 加锁前再检查一次
      synchronized (DoubleCheckedLockingSingleton) {
        if (this.instance === null) {
          this.instance = new DoubleCheckedLockingSingleton();
        }
      }
    }
    return this.instance;
  }
}

// 测试双重检查锁单例
const instance1 = DoubleCheckedLockingSingleton.getInstance();
const instance2 = DoubleCheckedLockingSingleton.getInstance();

console.log(instance1 === instance2); // 输出: true

在TypeScript中,直接使用synchronized是不可行的。双重检查锁在Java等其他语言中应用广泛,但在TypeScript中实现相对较复杂,需要结合其他方式(如标志变量或额外的同步手段)实现类似效果。

其他

另外还有内部类实现方式枚举单例。内部类实现方式利用了Java等语言中的静态内部类特性来实现线程安全的延迟加载。在TypeScript中,可以通过闭包的方式实现类似效果。某些语言(如Java)中的枚举可以用来实现单例,这种方法简洁有效,且能提供序列化机制,防止多次实例化,但在JavaScript或TypeScript中并不适用。

应用场景

在前端应用中,单例模式经常被用于管理全局状态、服务或配置等,以确保一个类仅创建一个实例,比如用户认证服务、主题管理器或者全局状态管理器。

全局状态管理

在现代前端框架(如React、Vue)的开发中,全局状态管理是一个常见需求。例如,使用Redux或Vuex时,store通常是一个单例,确保应用中只有一个全局状态管理对象。

// Redux风格的全局状态管理
class Store {
  private static instance: Store | null = null;
  private state: { [key: string]: any } = {};

  private constructor() {
    // 初始化状态
    this.state = {
      user: null,
      theme: "light",
    };
  }

  public static getInstance(): Store {
    if (this.instance === null) {
      this.instance = new Store();
    }
    return this.instance;
  }

  public getState(key: string): any {
    return this.state[key];
  }

  public setState(key: string, value: any): void {
    this.state[key] = value;
  }
}

// 获取和使用单例Store
const store = Store.getInstance();
store.setState("user", { name: "Alice", age: 30 });
console.log(store.getState("user")); // 输出:{ name: "Alice", age: 30 }

你可以在初始化的时候创建Store,并通过public static getInstance() 方法全局访问它,而不需要在每个组件或模块中重新创建。

服务类单例

服务类单例在前端开发中非常常见,例如日志服务、请求服务等。通过单例模式,可以确保这些服务在整个应用中只有一个实例,便于集中管理和调用。以下是一些常见的服务类单例应用场景。

用户身份验证服务(AuthService)

用户身份验证服务类负责维护用户的登录状态和凭据。例如,在单页应用(SPA)中,通过这个服务在登录后跨组件共享用户状态。

class AuthService {
  private static instance: AuthService;
  private isAuthenticated: boolean = false;
  private userToken: string | null = null;

  private constructor() {}

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

  public login(username: string, password: string): void {
    // 实际应用中应调用后端API进行验证,这里做简化
    // 假设用户验证成功,并获得token
    this.userToken = 'someAuthToken';
    this.isAuthenticated = true;
  }

  public logout(): void {
    this.isAuthenticated = false;
    this.userToken = null;
  }

  public getToken(): string | null {
    return this.userToken;
  }

  public isLoggedIn(): boolean {
    return this.isAuthenticated;
  }
}

在应用的任何地方,可以通过 AuthService.getInstance() 来进行登录或检查登录状态。

HTTP请求服务(HttpService)

HTTP请求服务类单例负责在整个前端应用中执行HTTP请求。它可以配置统一的请求拦截器、响应处理器,以及错误处理机制。

import axios, { AxiosInstance } from 'axios';

class HttpService {
  private static instance: HttpService;
  private http: AxiosInstance;

  private constructor() {
    this.http = axios.create({
      baseURL: 'https://api.example.com',
      // 更多axios配置可在这里添加
    });

    // 请求拦截器
    this.http.interceptors.request.use(config => {
      // 可以在这里添加例如token的header等
      const token = AuthService.getInstance().getToken();
      if (token) {
        config.headers['Authorization'] = `Bearer ${token}`;
      }
      return config;
    }, error => {
      // 对请求错误做些什么
      return Promise.reject(error);
    });

    // 响应拦截器
    this.http.interceptors.response.use(response => {
      // 对响应数据做点什么
      return response;
    }, error => {
      // 对响应错误做点什么
      return Promise.reject(error);
    });
  }

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

  // GET请求的示例方法
  public get(url: string, params?: any): Promise<any> {
    return this.http.get(url, { params });
  }

  // POST请求的示例方法
  public post(url: string, data?: any): Promise<any> {
    return this.http.post(url, data);
  }

  // 其他HTTP方法(PUT, DELETE等)可以在这里继续添加
}

使用这个 HttpService 类,你可以在应用中的任何地方发起HTTP请求,确保所有的请求都会被正确地配置且拦截器会被应用。这样的设计保证了代码的DRY(Don't Repeat Yourself)原则,提升整体代码的可维护性。

配置管理

配置管理的应用场景在前端开发中非常常见,它可以帮助开发者在应用程序的不同部分共享配置信息。例如,可能需要在应用多个地方引用API的基础URL、第三方库的配置、功能开关等。使用单例模式管理这些配置可以使得配置信息的获取和变更集中化、统一化,从而方便配置的读取和更新。 比如,有一个全局配置服务类负责整个前端应用的配置数据,这些数据通常在应用启动时加载,并在整个应用生命周期中被多次读取。

class ConfigService {
  private static instance: ConfigService;
  private settings: Record<string, any> = {};

  private constructor() {
    // 构造函数是私有的,确保不能通过 new 来创建实例
  }

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

  // 加载配置
  public load(configObject: Record<string, any>): void {
    this.settings = { ...this.settings, ...configObject };
  }

  // 获取配置项
  public get(key: string, defaultValue: any = null): any {
    return this.settings.hasOwnProperty(key) ? this.settings[key] : defaultValue;
  }

  // 设置或更新配置项
  public set(key: string, value: any): void {
    this.settings[key] = value;
  }
}

使用这种方式,全局配置信息能够在一个地方集中管理和更新,同时确保在应用程序的任何部分都能一致地访问到最新的配置。这降低了出现配置不一致性的风险,并且简化了配置管理的复杂性。

服务和组件通信

在组件间通信中,事件总线或者全局状态通常用于发布和订阅事件。通过使用单例模式,可以确保事件的广播和监听是全局统一的。

class EventBus {
  private static instance: EventBus;
  private eventMap: any = {};  // 键为事件名,值为处理函数数组

  private constructor() {}

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

  // 订阅事件
  public on(event: string, handler: Function) {
    if (!this.eventMap[event]) {
      this.eventMap[event] = [];
    }
    this.eventMap[event].push(handler);
  }

  // 取消订阅事件
  public off(event: string, handler: Function) {
    if (this.eventMap[event]) {
      this.eventMap[event] = this.eventMap[event].filter((h: Function) => h !== handler);
    }
  }

  // 触发事件
  public emit(event: string, ...args: any[]) {
    if (this.eventMap[event]) {
      this.eventMap[event].forEach((handler: Function) => {
        handler(...args);
      });
    }
  }
}

这样,组件间可以通过单例的事件总线实例来通信,而不用担心创建多个实例导致的消息不同步。

缓存管理

缓存管理在前端开发中是一个重要的应用场景。它允许我们临时存储数据,以避免重复执行可能耗费资源的操作,比如网络请求、数据计算等。 在实际开发中,实现一个数据缓存服务类用来临时存储从服务器拉取的数据,减少不必要的网络请求,从而提高应用的效率和性能。

interface CacheContent<T> {
  expiry: number;
  value: T;
}

class CacheService {
  private static instance: CacheService;
  private cache: Record<string, CacheContent<any>> = {};

  private constructor() { 
    // 构造函数私有化,确保单例模式
  }

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

  // 设置缓存数据
  public set<T>(key: string, value: T, ttl: number): void {
    const now = new Date().getTime();
    const expiry = now + ttl;
    this.cache[key] = { value, expiry };
  }

  // 获取缓存数据
  public get<T>(key: string): T | null {
    const data = this.cache[key];

    if (!data) {
      return null; // 没有缓存信息
    }

    const now = new Date().getTime();

    if (now >= data.expiry) {
      delete this.cache[key]; // 清除过期数据
      return null; // 缓存已过期
    }

    return data.value;
  }

  // 删除缓存
  public invalidate(key: string): void {
    if (this.cache[key]) {
      delete this.cache[key];
    }
  }

  // 清空所有缓存
  public clear(): void {
    this.cache = {};
  }
}

这个缓存服务类可以提供一个全局的缓存实例,并且允许存取数据时设置生存时间(Time To Live, TTL),超时后数据会自动失效。

单例模式的优缺点

优点

  1. 资源控制:单例模式可以确保只创建一个实例,这在管理资源或配置的时候相当有用,例如数据库连接或文件系统。
  2. 全局访问点:提供了一个方便的全局访问点来访问实例,这意味着你可以在任何地方访问同一个实例,确保了数据和行为的一致性。
  3. 内存占用少:因为只创建一个对象,所以内存的占用通常会比创建多个实例的总和要小。
  4. 延迟初始化:单例可以在需要的时候才初始化(惰性初始化),减少了初始化阶段的资源消耗。
  5. 接口驱动的访问控制:因为实例化控制在单例类内部,可以灵活地更改实例的创建逻辑而不影响外部代码。

缺点

  1. 全局状态:由于单例是全局的,它们可能导致代码之间的隐藏依赖,使得代码变得相互耦合。
  2. 测试困难:由于它们的全局状态,单例模式在单元测试中可能较难模拟和测试,这可能导致不确定的测试结果。
  3. 并发问题:在多线程应用中,如果不适当地处理,单例可能会有线程安全问题。
  4. 代码灵活性降低:随着应用的扩展,可能需要从单例模式转移到更灵活的实例化模式,但这需要改变全局的访问点,可能会涉及大量的重构工作。

总结

单例模式是一种强大的设计模式,可以有效地管理全局唯一的资源。然而,在使用单例模式时需要谨慎,特别是在并发环境下,还需要确保线程安全。理解单例模式的原理和适用场景,可以帮助我们更好地利用它,避免潜在的问题。