Интеграция с Angular: сервисы и DI

Интеграция клиентского хранилища в Angular требует соблюдения принципов инверсии зависимостей, тестируемости и изоляции побочных эффектов. Библиотека localForage, предоставляющая единый асинхронный API поверх IndexedDB, WebSQL и localStorage, органично встраивается в Angular через сервисный слой и механизм Dependency Injection (DI).

Ключевая задача интеграции заключается не в прямом вызове API localForage из компонентов, а в построении абстракции, которая:

  • инкапсулирует работу с браузерным хранилищем
  • обеспечивает SSR-совместимость
  • поддерживает расширяемость (например, шифрование или версионирование данных)
  • упрощает тестирование

Базовая установка и конфигурация localForage

localForage предоставляет глобальный объект-конфигуратор, который может быть переиспользован внутри Angular-сервиса:

import localforage from 'localforage';

localforage.config({
  name: 'MyApp',
  storeName: 'app_storage',
  description: 'Primary storage for Angular application'
});

Однако в Angular-подходе конфигурация должна быть инкапсулирована в DI, чтобы избежать глобального состояния.


Инъекционный токен для конфигурации хранилища

Для гибкости создаётся токен конфигурации:

import { InjectionToken } from '@angular/core';

export interface LocalForageConfig {
  name: string;
  storeName: string;
  version?: number;
}

export const LOCAL_FORAGE_CONFIG =
  new InjectionToken<LocalForageConfig>('LOCAL_FORAGE_CONFIG');

Это позволяет задавать конфигурацию на уровне модуля:

providers: [
  {
    provide: LOCAL_FORAGE_CONFIG,
    useValue: {
      name: 'MyApp',
      storeName: 'main_store'
    }
  }
]

Сервис-обёртка над localForage

Сервис является центральной точкой доступа к хранилищу:

import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import localforage from 'localforage';
import { LOCAL_FORAGE_CONFIG, LocalForageConfig } from './localforage.tokens';

@Injectable({
  providedIn: 'root'
})
export class LocalForageService {
  private storage = localforage;
  private isBrowser: boolean;

  constructor(
    @Inject(LOCAL_FORAGE_CONFIG) config: LocalForageConfig,
    @Inject(PLATFORM_ID) platformId: Object
  ) {
    this.isBrowser = isPlatformBrowser(platformId);

    this.storage.config({
      name: config.name,
      storeName: config.storeName
    });
  }

  private ensureBrowser(): boolean {
    return this.isBrowser;
  }

Базовые операции: set, get, remove

Асинхронная природа localForage требует строгой типизации возвращаемых Promise:

  async setItem<T>(key: string, value: T): Promise<T | null> {
    if (!this.ensureBrowser()) return null;
    return await this.storage.setItem<T>(key, value);
  }

  async getItem<T>(key: string): Promise<T | null> {
    if (!this.ensureBrowser()) return null;
    return await this.storage.getItem<T>(key);
  }

  async removeItem(key: string): Promise<void> {
    if (!this.ensureBrowser()) return;
    await this.storage.removeItem(key);
  }

Очистка и управление хранилищем

Для сценариев миграции и сброса состояния:

  async clear(): Promise<void> {
    if (!this.ensureBrowser()) return;
    await this.storage.clear();
  }

  async length(): Promise<number> {
    if (!this.ensureBrowser()) return 0;
    return await this.storage.length();
  }

Итерация по ключам

localForage поддерживает перебор записей, что полезно для кэшей и офлайн-очередей:

  async iterate<T>(
    iterator: (value: T, key: string, iterationNumber: number) => void
  ): Promise<void> {
    if (!this.ensureBrowser()) return;

    await this.storage.iterate<T, void>((value, key, iterationNumber) => {
      iterator(value, key, iterationNumber);
    });
  }

Интеграция с RxJS

Angular-проекты часто требуют реактивного слоя поверх Promise API. Используется обёртка через Observable:

import { from, Observable } from 'rxjs';

  getItem$<T>(key: string): Observable<T | null> {
    return from(this.getItem<T>(key));
  }

  setItem$<T>(key: string, value: T): Observable<T | null> {
    return from(this.setItem<T>(key, value));
  }

Для более сложных сценариев (например, кеширование состояния UI) можно добавлять BehaviorSubject:

import { BehaviorSubject } from 'rxjs';

private cache$ = new BehaviorSubject<Record<string, unknown>>({});

getCache$() {
  return this.cache$.asObservable();
}

SSR-совместимость и platform guard

Angular Universal не имеет доступа к browser storage, поэтому критично изолировать вызовы:

private safeCall<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
  if (!this.isBrowser) {
    return Promise.resolve(fallback);
  }
  return fn();
}

Использование:

async getItem<T>(key: string): Promise<T | null> {
  return this.safeCall(
    () => this.storage.getItem<T>(key),
    null
  );
}

Кэширование слоя поверх localForage

Часто требуется комбинировать память и persistent storage:

private memoryCache = new Map<string, unknown>();
async getCached<T>(key: string): Promise<T | null> {
  if (this.memoryCache.has(key)) {
    return this.memoryCache.get(key) as T;
  }

  const value = await this.getItem<T>(key);

  if (value !== null) {
    this.memoryCache.set(key, value);
  }

  return value;
}

Обработка версионирования данных

При изменении структуры данных требуется миграция:

private readonly version = 2;

async migrate(): Promise<void> {
  const storedVersion = await this.getItem<number>('__version__') || 0;

  if (storedVersion < this.version) {
    await this.clear();
    await this.setItem('__version__', this.version);
  }
}

Использование interceptors для кэш-логики

Хотя Angular не имеет встроенных interceptors для local storage, можно реализовать паттерн декоратора:

export class CachedStorageService {
  constructor(private storage: LocalForageService) {}

  async get<T>(key: string): Promise<T | null> {
    const cached = await this.storage.getItem<T>(key);

    if (cached) return cached;

    const fresh = await this.fetchFromApi<T>(key);

    await this.storage.setItem(key, fresh);

    return fresh;
  }

  private async fetchFromApi<T>(key: string): Promise<T> {
    throw new Error('Implementation required');
  }
}

Использование standalone-подхода Angular

В современных Angular-приложениях сервисы легко подключаются через provide:

export const appProviders = [
  {
    provide: LOCAL_FORAGE_CONFIG,
    useValue: {
      name: 'ModernApp',
      storeName: 'cache'
    }
  }
];

Тестирование сервиса

Mock localForage позволяет изолировать тесты:

const mockStorage = {
  getItem: jasmine.createSpy().and.resolveTo('value'),
  setItem: jasmine.createSpy().and.resolveTo('value'),
  removeItem: jasmine.createSpy().and.resolveTo(undefined),
  clear: jasmine.createSpy().and.resolveTo(undefined)
};

Подмена через DI:

{
  provide: LocalForageService,
  useValue: mockStorage
}

Обработка ошибок и деградация функциональности

localForage может падать в условиях приватного режима или ограниченного storage quota:

async safeGet<T>(key: string): Promise<T | null> {
  try {
    return await this.storage.getItem<T>(key);
  } catch {
    return null;
  }
}

Структурирование ключей и неймспейсинг

Для предотвращения коллизий ключи должны быть централизованы:

export const STORAGE_KEYS = {
  USER: 'user:data',
  SETTINGS: 'app:settings',
  CACHE: 'app:cache'
};

Использование в компонентах через DI

Компонент работает только через сервисный слой:

constructor(private storage: LocalForageService) {}

async ngOnInit() {
  const settings = await this.storage.getItem('app:settings');
}

Прямой доступ к localForage исключается, что обеспечивает архитектурную стабильность и единообразие работы с данными в приложении Angular.