LocalStorage, SessionStorage для Angular Universal

Aleksandr Serenko
F.A.F.N.U.R
Published in
7 min readAug 3, 2019

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

Предыдущие статьи:

  1. Статья о настройки Angular Universal & Nx
  2. Статья о настройке Prettier, tslint и eslint в Angular

Следующие статьи:

  1. Статья о локализации в Angular с помощью ngx-translate
  2. Статья про Redux в Angular с помощью Ngrx. Создание Store в Angular

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock