Last active
December 12, 2023 20:53
-
-
Save ckimrie/63334b6ad2873bd9db7ccbbf8ccdfd53 to your computer and use it in GitHub Desktop.
Example on how to achieve RxJS observable caching and storage in Angular 2+. Ideal for storing Http requests client side for offline usage.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Component, OnInit, OnDestroy } from '@angular/core'; | |
import {Http} from "@angular/http"; | |
import { LocalCacheService } from "./local-cache.service"; | |
@Component({ | |
selector: 'app-example', | |
templateUrl: './app.component.html', | |
styleUrls: ['./app.component.scss'] | |
}) | |
export class ExampleComponent implements OnInit, OnDestroy { | |
constructor(public http:Http, public cache:LocalCacheService){ | |
//Cache an observable | |
let requestObservable = this.http.get("http://example.com/path/to/api").map(res => res.json()) | |
this.cache.observable('my-cache-key', requestObservable, 300).subscribe(result => { | |
//Use result | |
console.log(result) | |
}); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {Injectable} from "@angular/core"; | |
import {LocalStorageService} from "./local-storage.service"; | |
import {Observable} from "rxjs/Observable"; | |
import {isEmpty, isString, isNumber, isDate} from 'lodash'; | |
@Injectable() | |
export class LocalCacheService { | |
/** | |
* Default expiry in seconds | |
* | |
* @type {number} | |
*/ | |
defaultExpires: number = 86400; //24Hrs | |
constructor(private localstorage: LocalStorageService) {} | |
/** | |
* Cache or use result from observable | |
* | |
* If cache key does not exist or is expired, observable supplied in argument is returned and result cached | |
* | |
* @param key | |
* @param observable | |
* @param expires | |
* @returns {Observable<T>} | |
*/ | |
public observable<T>(key: string, observable: Observable<T>, expires:number = this.defaultExpires): Observable<T> { | |
//First fetch the item from localstorage (even though it may not exist) | |
return this.localstorage.getItem(key) | |
//If the cached value has expired, nullify it, otherwise pass it through | |
.map((val: CacheStorageRecord) => { | |
if(val){ | |
return (new Date(val.expires)).getTime() > Date.now() ? val : null; | |
} | |
return null; | |
}) | |
//At this point, if we encounter a null value, either it doesnt exist in the cache or it has expired. | |
//If it doesnt exist, simply return the observable that has been passed in, caching its value as it passes through | |
.flatMap((val: CacheStorageRecord | null) => { | |
if (!isEmpty(val)) { | |
return Observable.of(val.value); | |
} else { | |
return observable.flatMap((val:any) => this.value(key, val, expires)); //The result may have 'expires' explicitly set | |
} | |
}) | |
} | |
/** | |
* Cache supplied value until expiry | |
* | |
* @param key | |
* @param value | |
* @param expires | |
* @returns {Observable<T>} | |
*/ | |
value<T>(key:string, value:T, expires:number|string|Date = this.defaultExpires):Observable<T>{ | |
let _expires:Date = this.sanitizeAndGenerateDateExpiry(expires); | |
return this.localstorage.setItem(key, { | |
expires: _expires, | |
value: value | |
}).map(val => val.value); | |
} | |
/** | |
* | |
* @param key | |
* @returns {Observable<null>} | |
*/ | |
expire(key:string):Observable<null>{ | |
return this.localstorage.removeItem(key); | |
} | |
/** | |
* | |
* @param expires | |
* @returns {Date} | |
*/ | |
private sanitizeAndGenerateDateExpiry(expires:string|number|Date):Date{ | |
let expiryDate:Date = this.expiryToDate(expires); | |
//Dont allow expiry dates in the past | |
if(expiryDate.getTime() <= Date.now()){ | |
return new Date(Date.now() + this.defaultExpires); | |
} | |
return expiryDate; | |
} | |
/** | |
* | |
* @param expires | |
* @returns {Date} | |
*/ | |
private expiryToDate(expires:number|string|Date):Date{ | |
if(isNumber(expires)){ | |
return new Date(Date.now() + Math.abs(expires)*1000); | |
} | |
if(isString(expires)){ | |
return new Date(expires); | |
} | |
if(isDate(expires)){ | |
return expires; | |
} | |
return new Date(); | |
} | |
} | |
/** | |
* Cache storage record interface | |
*/ | |
interface CacheStorageRecord { | |
expires: Date, | |
value: any | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as localforage from 'localforage'; | |
import {Injectable} from "@angular/core"; | |
import {Observable} from "rxjs/Observable"; | |
@Injectable() | |
export class LocalStorageService { | |
/** | |
* | |
* @param key | |
* @param value | |
* @returns {any} | |
*/ | |
public setItem<T>(key:string, value:T):Observable<T>{ | |
return Observable.fromPromise(localforage.setItem(key, value)) | |
} | |
/** | |
* | |
* @param key | |
* @returns {any} | |
*/ | |
public getItem<T>(key:string):Observable<T>{ | |
return Observable.fromPromise(localforage.getItem(key)) | |
} | |
/** | |
* | |
* @param key | |
* @returns {any} | |
*/ | |
public removeItem(key:string):Observable<void>{ | |
return Observable.fromPromise(localforage.removeItem(key)) | |
} | |
} |
Hi there, please advise what's "import * as localforage from 'localforage';" in the local-storage.service.ts? Is it a typo?
It's the localforage package, which this relies on as a dependency.
Hi,
For RXJS v6:
import {of as _observableOf} from 'rxjs';
import { mergeMap as _observableMergeMap, catchError as _observableCatch } from 'rxjs/operators';
...
public observable<T>(key: string, observable: Observable<T>, expires:number = this.defaultExpires): Observable<T> {
//First fetch the item from localstorage (even though it may not exist)
return this.localstorage.getItem(key)
//If the cached value has expired, nullify it, otherwise pass it through
.map((val: CacheStorageRecord) => {
if(val){
return (new Date(val.expires)).getTime() > Date.now() ? val : null;
}
return null;
})
//At this point, if we encounter a null value, either it doesnt exist in the cache or it has expired.
//If it doesnt exist, simply return the observable that has been passed in, caching its value as it passes through
.pipe(_observableMergeMap((val: CacheStorageRecord | null) => {
if (!isEmpty(val)) {
return _observableOf(val.value);
} else {
return observable.pipe(_observableMergeMap((val:any) => this.value(key, val, expires))); //The result may have 'expires' explicitly set
}
}))
}
Hello, I share the updated code with the current version of rxjs
A small change was made, the storage was placed in a variable, so we can change it if it is a session or local storage
localStorage | sessionStorage
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class LocalStorageService {
storage = localStorage;
/**
*
* @param key
* @param value
* @returns {any}
*/
public setItem<T>(key: string, value: T): Observable<T> {
this.storage.setItem(key, JSON.stringify(value));
return of<T>(value);
}
/**
*
* @param key
* @returns {any}
*/
public getItem<T>(key: string): Observable<T> {
const value = this.storage.getItem(key);
return of<T>(
value ? JSON.parse(this.storage.getItem(key)) : (null as T)
);
}
/**
*
* @param key
* @returns {any}
*/
public removeItem(key: string): Observable<void> {
return of(this.storage.removeItem(key));
}
}
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { isString, isNumber, isDate } from 'lodash';
import { LocalStorageService } from './local-storage.service';
/**
* Cache storage record interface
*/
interface CacheStorageRecord {
expires: Date;
value: any;
}
@Injectable({
providedIn: 'root',
})
export class LocalCacheService {
/**
* Default expiry in seconds
*
* @type {number}
*/
defaultExpires: number = 86400; //24Hrs
constructor(private localstorage: LocalStorageService) {}
/**
* Cache or use result from observable
*
* If cache key does not exist or is expired, observable supplied in argument is returned and result cached
*
* @param key
* @param observable
* @param expires
* @returns {Observable<T>}
*/
public observable<T>(
key: string,
observable: Observable<T>,
expires: number = this.defaultExpires
): Observable<T> {
//First fetch the item from localstorage (even though it may not exist)
return this.localstorage.getItem(key).pipe(
//If the cached value has expired, nullify it, otherwise pass it through
map((val: CacheStorageRecord) => {
if (val) {
return new Date(val.expires).getTime() > Date.now()
? val
: null;
}
return null;
}),
//At this point, if we encounter a null value, either it doesnt exist in the cache or it has expired.
//If it doesnt exist, simply return the observable that has been passed in, caching its value as it passes through
switchMap((val: CacheStorageRecord | null) => {
console.warn('flatMap', val);
if (val) {
return of(val.value);
} else {
return observable.pipe(
switchMap((val: any) => this.value(key, val, expires))
); //The result may have 'expires' explicitly set
}
})
);
}
/**
* Cache supplied value until expiry
*
* @param key
* @param value
* @param expires
* @returns {Observable<T>}
*/
value<T>(
key: string,
value: T,
expires: number | string | Date = this.defaultExpires
): Observable<T> {
let _expires: Date = this.sanitizeAndGenerateDateExpiry(expires);
console.warn('value', value);
return this.localstorage
.setItem<CacheStorageRecord>(key, {
expires: _expires,
value: value,
})
.pipe(
map((item) => {
return item.value;
})
);
}
/**
*
* @param key
* @returns {Observable<null>}
*/
expire(key: string): Observable<void> {
return this.localstorage.removeItem(key);
}
/**
*
* @param expires
* @returns {Date}
*/
private sanitizeAndGenerateDateExpiry(
expires: string | number | Date
): Date {
let expiryDate: Date = this.expiryToDate(expires);
//Dont allow expiry dates in the past
if (expiryDate.getTime() <= Date.now()) {
return new Date(Date.now() + this.defaultExpires);
}
return expiryDate;
}
/**
*
* @param expires
* @returns {Date}
*/
private expiryToDate(expires: number | string | Date): Date {
if (isNumber(expires)) {
return new Date(Date.now() + Math.abs(expires as number) * 1000);
}
if (isString(expires)) {
return new Date(expires);
}
if (isDate(expires)) {
return expires as Date;
}
return new Date();
}
}
Thank you!!
Thank you!
Great!
@atdetquizan Thank you
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hello,
Could you please support RXJS v6 with error:
TS2345: Argument of type 'OperatorFunction<CacheStorageRecord, CacheStorageRecord | null>' is not assignable to parameter of type 'OperatorFunction<{}, CacheStorageRecord | null>'. Type '{}' is not assignable to type 'CacheStorageRecord'. Property 'expires' is missing in type '{}'.
public observable(key: string, observable: Subscription, expires: number = this.defaultExpires): Observable {
return this.localstorage.getItem(key).pipe(
map((val: CacheStorageRecord) => {
if(val){
return (new Date(val.expires)).getTime() > Date.now() ? val : null
}
return null
}),
flatMap((val: CacheStorageRecord | null) => {
if (!isEmpty(val)) {
return of(val.value)
} else {
return flatMap((data:any) => this.value(key, data, expires)) //The result may have 'expires' explicitly set
}
}))
}
Thank you