LocalStorage, SessionStorage для Angular Universal
Update 15 feb 2020: Данная статья была актуальна для Angular 8. С обновлением Universal и рядом подходов к организации веб хранилищ, можете посмотреть более актуальную статью — Кроссплатформенные web storage в Angular 9. Реализация LocalStorage, SessionStorage и Cookies в Angular Universal.
При разработке SSR
приложения, часто встречаются задачи связанные с сохранением данных в localStorage
, sessionStorage
или куках. Так как в браузерной версии проблем в не возникает, то в серверной версии, начинаются проблемы из-за того, что там нет window и соответственно нет каких-либо хранилищ. Данная статья призвана устранить все проблемы связанные с сохранением данных при использовании Universal.
Создадим несколько хранилищ, такие как LocalStorage
, SessionStorage
и CookieStorage
. Для LocalStorage
, SessionStorage
в браузерной версии будем использовать стандартные хранилища, если же они не доступны будем использовать временные хранилища или cookies.
Так как в DOM
есть Storage interface
, создадим абстрактный класс для данного интерфейса — AbstractStorage
.
/**
* Abstract Storage for all storage's
*
* @description
* The Storage interface of the Web Storage API provides access to a particular domain's session or local storage.
* It allows, for example, the addition, modification, or deletion of stored data items.
*/
export abstract class AbstractStorage implements Storage {
readonly length: number;
abstract clear(): void;
abstract getItem(key: string): string | null;
abstract key(index: number): string | null;
abstract removeItem(key: string): void;
abstract setItem(key: string, value: string): void;
}
Для того, чтобы DI не создавать новые токены, создадим абстрактные классы для наших хранилищ.
Но прежде чем начнем реализовывать, нужно добавить маленький вспомогательный класс — MemoryStorage
:
import { Dictionary } from '@medium-stories/common';
import { AbstractStorage } from '../interfaces/abstract-storage.interface';
/**
* Memory Storage
*
* @description
* It simple storage for emulate Web Storage API
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
*/
export class MemoryStorage implements AbstractStorage {
/**
* Storage data
*/
private data: Dictionary<string> = {};
get length(): number {
return Object.keys(this.data).length;
}
clear(): void {
this.data = {};
}
getItem(key: string): string | null {
return key in this.data ? this.data[key] : null;
}
key(index: number): string | null {
const keys = Object.keys(this.data);
return index >= 0 && keys.length < index ? keys[index] : null;
}
removeItem(key: string): void {
delete this.data[key];
}
setItem(key: string, value: string): void {
this.data[key] = value;
}
}
Dictionary
— это просто словарь, в котором храниться ключ значение, где ключ это строка:
export interface Dictionary<T = any> {
[id: string]: T;
}
Cookie Storage
Расширим абстрактный интерфейс хранилища методом для setItem
:
import { AbstractStorage } from './abstract-storage.interface';
/**
* Cookie Storage
*/
export abstract class CookieStorage extends AbstractStorage {
abstract setItem(key: string, value: string, options?: object): void;
}
Для работы с куками была использована angular библиотека — ngx-cookie
.
Создадим реализацию для CookieStorage
в папке storages
:
import { Injectable } from '@angular/core';
import { CookieService } from 'ngx-cookie';
import { CookieStorage } from '../interfaces/cookie-storage.interface';
/**
* Base cookie storage
*/
@Injectable()
export class BaseCookieStorage implements CookieStorage {
constructor(private cookieService: CookieService) {}
get length(): number {
return Object.keys(this.cookieService.getAll()).length;
}
clear(): void {
this.cookieService.removeAll();
}
getItem(key: string): string | null {
const item = this.cookieService.get(key);
return item != null ? item : null;
}
key(index: number): string | null {
const keys = Object.keys(this.cookieService.getAll());
return index >= 0 && index < keys.length ? keys[index] : null;
}
removeItem(key: string): void {
this.cookieService.remove(key);
}
setItem(key: string, data: string, options?: object): void {
this.cookieService.put(key, data, options);
}
}
Так как у ngx-cookie
имеется проблема с pull-request’ами, создадим server cookie service, который будет прокидывать cookie из браузера в на сервер и наоборот.
import { Inject, Injectable } from '@angular/core';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { CookieOptions, CookieOptionsProvider, CookieService } from 'ngx-cookie';
/**
* Custom CookieService
* @see https://github.com/salemdar/ngx-cookie/pull/82#issuecomment-487301560
*/
@Injectable()
export class ServerCookieService extends CookieService {
constructor(@Inject(REQUEST) private request: any, @Inject(RESPONSE) private response: any, _optionsProvider: CookieOptionsProvider) {
super(_optionsProvider);
}
put(key: string, value: string, options: CookieOptions = {}): void {
let findKey = false;
let newCookie = Object.keys(this.getAll())
// tslint:disable-next-line: no-shadowed-variable
.map(keyItem => {
if (keyItem === key) {
findKey = true;
return `${key}=${value}`;
}
return `${keyItem}=${this.get(keyItem)}`;
})
.join('; ');
if (!findKey) {
newCookie += `; ${key}=${value};`;
}
this.request.headers.cookie = newCookie;
// not sure
this.cookieString = newCookie;
}
remove(key: string, options?: CookieOptions): void {
const newCookie = Object.keys(this.getAll())
// tslint:disable-next-line: no-shadowed-variable
.map(keyItem => {
if (keyItem === key) {
return '';
}
return `${keyItem}=${this.get(keyItem)}`;
})
.join('; ');
this.request.headers.cookie = newCookie;
// not sure
this.cookieString = newCookie;
}
protected get cookieString(): string {
return this.request.cookie || this.request.headers['cookie'] || '';
}
protected set cookieString(val: string) {
this.request.cookie = val;
this.response.cookie = val;
}
}
Local Storage
Интерфейс будет представлять собой просто наследование от AbstractStorage
:
import { AbstractStorage } from './abstract-storage.interface';
/**
* Local Storage
*/
export abstract class LocalStorage extends AbstractStorage {}
Так как в режиме инкогнито, у нас может быть не доступ storage, то у нас есть два пути:
— либо хранить все в куках, но не совсем хорошо, так как у cookie
есть ограничение на длину;
— либо использовать временное хранилище для экземпляра приложения (просто переменная).
Реализация LocalStorage
будет следующей:
import { Inject, Injectable } from '@angular/core';
import { CookieStorage } from '../interfaces/cookie-storage.interface';
import { LocalStorage } from '../interfaces/local-storage.interface';
import { COOKIE_IN_INCOGNITO } from '../storage.tokens';
import { storageAvailable } from '../utils/storage.util';
import { MemoryStorage } from './memory.storage';
/**
* Browser local storage
*/
@Injectable()
export class BrowserLocalStorage implements LocalStorage {
/**
* Storage
*/
private readonly storage: Storage;
constructor(@Inject(COOKIE_IN_INCOGNITO) private cookieInIncognito: boolean, private cookieStorage: CookieStorage) {
if (storageAvailable('localStorage')) {
this.storage = window.localStorage;
} else if (this.cookieInIncognito) {
this.storage = this.cookieStorage;
} else {
this.storage = new MemoryStorage();
}
}
get length(): number {
return this.storage.length;
}
clear(): void {
this.storage.clear();
}
getItem(key: string): string | null {
return this.storage.getItem(key);
}
key(index: number): string | null {
return this.storage.key(index);
}
removeItem(key: string): void {
this.storage.removeItem(key);
}
setItem(key: string, value: string): void {
this.storage.setItem(key, value);
}
}
storageAvailable
— функция проверяющая доступность хранилища для чтения/записи.
/**
* Return is storage available
* @param type Storage name like as localStorage, sessionStorage
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
*/
export function storageAvailable(type: string): boolean {
let storage;
try {
storage = window[type];
const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
return true;
} catch (e) {
return (
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// Firefox
e.code === 1014 ||
// test name field too, because code might not be present
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
(storage && storage.length !== 0)
);
}
}
COOKIE_IN_INCOGNITO
— токен, который является boolean и говорит, нужно ли использовать cookieStorage
для сохранения данных
import { InjectionToken } from '@angular/core';
export const COOKIE_IN_INCOGNITO = new InjectionToken<boolean>('CookieInIncognito');
SessionStorage
Аналогично выглядит интерфейс для SessionStorage
:
import { AbstractStorage } from './abstract-storage.interface';
/**
* Session Storage
*/
export abstract class SessionStorage extends AbstractStorage {}
и его реализация:
import { Inject, Injectable } from '@angular/core';
import { CookieStorage } from '../interfaces/cookie-storage.interface';
import { SessionStorage } from '../interfaces/session-storage.interface';
import { COOKIE_IN_INCOGNITO } from '../storage.tokens';
import { storageAvailable } from '../utils/storage.util';
import { MemoryStorage } from './memory.storage';
/**
* Browser session storage
*/
@Injectable()
export class BrowserSessionStorage implements SessionStorage {
/**
* Storage
*/
private readonly storage: Storage;
constructor(@Inject(COOKIE_IN_INCOGNITO) private cookieInIncognito: boolean, private cookieStorage: CookieStorage) {
if (storageAvailable('sessionStorage')) {
this.storage = window.sessionStorage;
} else if (this.cookieInIncognito) {
this.storage = this.cookieStorage;
} else {
this.storage = new MemoryStorage();
}
}
get length(): number {
return this.storage.length;
}
clear(): void {
this.storage.clear();
}
getItem(key: string): string | null {
return this.storage.getItem(key);
}
key(index: number): string | null {
return this.storage.key(index);
}
removeItem(key: string): void {
this.storage.removeItem(key);
}
setItem(key: string, value: string): void {
this.storage.setItem(key, value);
}
}
Модули
Опишем опции модулей:
import { Type } from '@angular/core';
import { CookieService } from 'ngx-cookie';
import { CookieStorage } from './cookie-storage.interface';
import { LocalStorage } from './local-storage.interface';
import { SessionStorage } from './session-storage.interface';
/**
* Storage options interface
*/
export interface StorageOptions {
/**
* Set cookie storage for local storage and session storage in incognito mode
*/
cookieInIncognito?: boolean;
/**
* Cookie service
*/
cookieService: Type<CookieService>;
/**
* Cookie storage
*/
cookieStorage: Type<CookieStorage>;
/**
* Local storage
*/
localStorage: Type<LocalStorage>;
/**
* Session storage
*/
sessionStorage: Type<SessionStorage>;
}
Для браузерной версии, модуль будет выглядеть следующим образом:
import { ModuleWithProviders, NgModule } from '@angular/core';
import { CookieModule } from 'ngx-cookie';
import { LocalStorage } from './interfaces/local-storage.interface';
import { SessionStorage } from './interfaces/session-storage.interface';
import { StorageOptions } from './interfaces/storage-options.interface';
import { CookieStorage } from './interfaces/cookie-storage.interface';
import { COOKIE_IN_INCOGNITO } from './storage.tokens';
import { BaseCookieStorage } from './storages/base-cookie.storage';
import { BrowserLocalStorage } from './storages/browser-local.storage';
import { BrowserSessionStorage } from './storages/browser-session.storage';
@NgModule({
imports: [CookieModule.forRoot()]
})
export class BrowserStorageModule {
static forRoot(options: Partial<StorageOptions> = {}): ModuleWithProviders {
return {
ngModule: BrowserStorageModule,
providers: [
{
provide: COOKIE_IN_INCOGNITO,
useValue: !!options.cookieInIncognito
},
{
provide: CookieStorage,
useClass: options.cookieStorage || BaseCookieStorage
},
{
provide: LocalStorage,
useClass: options.localStorage || BrowserLocalStorage
},
{
provide: SessionStorage,
useClass: options.sessionStorage || BrowserSessionStorage
}
]
};
}
}
И серверная реализация:
import { ModuleWithProviders, NgModule } from '@angular/core';
import { CookieModule, CookieService } from 'ngx-cookie';
import { CookieStorage } from './interfaces/cookie-storage.interface';
import { LocalStorage } from './interfaces/local-storage.interface';
import { SessionStorage } from './interfaces/session-storage.interface';
import { StorageOptions } from './interfaces/storage-options.interface';
import { ServerCookieService } from './services/server-cookie.service';
import { COOKIE_IN_INCOGNITO } from './storage.tokens';
import { BaseCookieStorage } from './storages/base-cookie.storage';
import { MemoryStorage } from './storages/memory.storage';
@NgModule({
imports: [CookieModule.forRoot()]
})
export class ServerStorageModule {
static forRoot(options: Partial<StorageOptions> = {}): ModuleWithProviders {
return {
ngModule: ServerStorageModule,
providers: [
{
provide: COOKIE_IN_INCOGNITO,
useValue: !!options.cookieInIncognito
},
{
provide: CookieService,
useClass: options.cookieService || ServerCookieService
},
{
provide: CookieStorage,
useClass: options.cookieStorage || BaseCookieStorage
},
{
provide: LocalStorage,
useClass: options.localStorage || options.cookieInIncognito ? options.cookieStorage || BaseCookieStorage : MemoryStorage
},
{
provide: SessionStorage,
useClass: options.sessionStorage || options.cookieInIncognito ? options.cookieStorage || BaseCookieStorage : MemoryStorage
}
]
};
}
}
Для демонстрации, сделаем клон frontend/base
приложения, создание которого было описано здесь.
Исходники
Все исходники находятся на github, в репозитории https://github.com/Fafnur/medium-stories.
Хранилище реализовано в отдельной Nx библиотеке, которая располагается в libs/storage.
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий tag — storage.
git checkout storage
Предыдущие статьи:
Следующие статьи: