diff --git a/readme.md b/readme.md index 31815d3..6b20f61 100644 --- a/readme.md +++ b/readme.md @@ -82,6 +82,8 @@ const [value, setValue] = useState(); | customInput | Нет | Element or string | Кастомный компонент поля ввода, например от Styled Components | | selectOnBlur | Нет | boolean | Если `true`, то при потере фокуса будет выбрана первая подсказка из списка | | uid | Нет | string | Уникальный ID который используется внутри компонента для связывания элементов при помощи aria атрибутов | +| httpCache | Нет | boolean | Необходимо ли кешировать HTTP-запросы | +| httpCacheTtl | Нет | boolean | Время жизни кеша HTTP-запросов (в миллисекундах). Значение по умолчанию - 10 минут | ## Методы diff --git a/src/BaseSuggestions.tsx b/src/BaseSuggestions.tsx index 6c201f5..ef0a432 100644 --- a/src/BaseSuggestions.tsx +++ b/src/BaseSuggestions.tsx @@ -4,6 +4,7 @@ import { debounce } from 'debounce'; import { nanoid } from 'nanoid'; import { CommonProps, DaDataSuggestion } from './types'; import { makeRequest } from './request'; +import { DefaultHttpCache, HttpCache } from './http-cache'; export type BaseProps = CommonProps; @@ -109,6 +110,21 @@ export abstract class BaseSuggestions extends React.Pu return this._uid!; } + get httpCache(): HttpCache | null { + const { httpCache: cacheProp, httpCacheTtl: ttl } = this.props; + if (!cacheProp) { + return null; + } + if (cacheProp instanceof HttpCache) { + return cacheProp; + } + const cache = DefaultHttpCache.shared; + if (typeof ttl === 'number') { + cache.ttl = ttl; + } + return cache; + } + protected getSuggestionsUrl = (): string => { const { url } = this.props; @@ -255,6 +271,7 @@ export abstract class BaseSuggestions extends React.Pu }, json: this.getLoadSuggestionsData() || {}, }, + this.httpCache, (suggestions) => { if (this.didMount) { this.setState({ suggestions, suggestionIndex: -1 }); diff --git a/src/__tests__/default-http-cache.test.ts b/src/__tests__/default-http-cache.test.ts new file mode 100644 index 0000000..db4ce5b --- /dev/null +++ b/src/__tests__/default-http-cache.test.ts @@ -0,0 +1,82 @@ +import { DefaultHttpCache as Cache } from '../http-cache'; + +describe('DefaultHttpCache', () => { + const createCacheWithInfinityTtl = () => { + const cache = new Cache(); + cache.ttl = Infinity; + return cache; + }; + + it('should return the same singleton instance on every call', () => { + expect(Cache.shared).toBeInstanceOf(Cache); + expect(Cache.shared).toBe(Cache.shared); + expect(new Cache()).not.toBe(Cache.shared); + }); + it('should serialize http payload to a string', () => { + const cache = createCacheWithInfinityTtl(); + const payload = { + method: 'GET', + headers: { hello: 'world' }, + body: { hi: 'there' }, + url: 'https://example.com', + }; + const key = cache.serializeCacheKey(payload); + expect(typeof key).toBe('string'); + expect(cache.serializeCacheKey(payload)).toBe(key); + expect( + cache.serializeCacheKey({ + ...payload, + url: 'https://example2.com', + }), + ).not.toBe(key); + expect(cache.serializeCacheKey({ ...payload })).toBe(key); + }); + it('should update ttl only if one is valid', () => { + const cache = new Cache(); + cache.ttl = 0; + expect(cache.ttl).toBe(0); + cache.ttl = Infinity; + expect(cache.ttl).toBe(Infinity); + cache.ttl = 10; + expect(cache.ttl).toBe(10); + cache.ttl = -1; + expect(cache.ttl).toBe(10); + cache.ttl = true as any; + expect(cache.ttl).toBe(10); + }); + it('should insert new cache entries', () => { + const cache = createCacheWithInfinityTtl(); + expect(cache.set('key', 1).get('key')).toBe(1); + expect(cache.set('key2', { hello: 'world' }).get('key2')).toStrictEqual({ hello: 'world' }); + }); + it('should delete cache entries', () => { + const cache = createCacheWithInfinityTtl(); + cache.set('key2', 2); + expect(cache.set('key', 1).delete('key').get('key')).toBeNull(); + expect(cache.get('key2')).toBe(2); + }); + it('should clear cache', () => { + const cache = createCacheWithInfinityTtl(); + cache.set('key', 1).set('key2', 2); + expect(cache.size).toBe(2); + cache.reset(); + expect(cache.size).toBe(0); + }); + it('should delete cache entries after their expiration', (done) => { + const cache = createCacheWithInfinityTtl(); + cache.set('key', 1); + cache.ttl = 0; + cache.set('key2', 2); + cache.ttl = 25; + cache.set('key3', 3); + cache.ttl = 100; + cache.set('key4', 4); + setTimeout(() => { + expect(cache.get('key')).toBe(1); + expect(cache.get('key2')).toBeNull(); + expect(cache.get('key3')).toBeNull(); + expect(cache.get('key4')).toBe(4); + done(); + }, 50); + }); +}); diff --git a/src/__tests__/mocks.ts b/src/__tests__/mocks.ts index 7f508f5..99261fd 100644 --- a/src/__tests__/mocks.ts +++ b/src/__tests__/mocks.ts @@ -2018,7 +2018,7 @@ export const mockedRequestCalls: any[] = []; export const createAddressMock = (wait?: number) => - (method: string, endpoint: string, data: RequestOptions, onReceiveData: (response: any) => void): void => { + (method: string, endpoint: string, data: RequestOptions, cache: any, onReceiveData: (response: any) => void): void => { mockedRequestCalls.push({ method, endpoint, data }); if (data.json.query) { diff --git a/src/http-cache/abstract.ts b/src/http-cache/abstract.ts new file mode 100644 index 0000000..c1b2dae --- /dev/null +++ b/src/http-cache/abstract.ts @@ -0,0 +1,59 @@ +import type { SerializeCacheKeyPayload } from './types'; + +export abstract class HttpCache { + /** + * Получить данные из кеша + * @param key - Уникальный ключ кеша + * @example + * ```ts + * cache.get('key'); + * ``` + */ + public abstract get(key: string): T | null; + + /** + * Добавить данные в кеш + * @param key - Уникальный ключ кеша + * @param data - Данные для добавления + * @example + * ```ts + * cache.set('key', { ok: true }); + * ``` + */ + public abstract set(key: string, data: any, ...rest: any): any; + + /** + * Удалить закешированные данные по ключу + * @param key - Уникальный ключ кеша + * @xample + * ```ts + * cache.delete('key'); + * ``` + */ + public abstract delete(key: string): any; + + /** + * Полностью очистить кеш + */ + public abstract reset(): any; + + /** + * Сгенерировать уникальный ключ кеша из параметров http-запроса + * @example + * ```ts + * cache.serializeCacheKey({ + * url: 'https://example.com', + * body: { key: "value" }, + * method: "POST" + * }) + * ``` + */ + public serializeCacheKey(payload: SerializeCacheKeyPayload): string { + try { + return JSON.stringify(payload); + } catch (_e) { + // на случай попытки сериализации объекта с циклическими зависимостями внутри + return payload.url + String(Math.random()); + } + } +} diff --git a/src/http-cache/default-cache.ts b/src/http-cache/default-cache.ts new file mode 100644 index 0000000..9258b5c --- /dev/null +++ b/src/http-cache/default-cache.ts @@ -0,0 +1,84 @@ +import { HttpCache } from './abstract'; +import type { HttpCacheEntry } from './types'; + +const minute = 60000; + +export class DefaultHttpCache extends HttpCache { + private static sharedInstance: DefaultHttpCache; + + private _map = new Map(); + + private _ttl = 10 * minute; + + /** + * Синглтон + * @example + * ```ts + * cache.shared.get('key'); + * ``` + */ + public static get shared(): DefaultHttpCache { + if (!DefaultHttpCache.sharedInstance) { + DefaultHttpCache.sharedInstance = new DefaultHttpCache(); + } + return DefaultHttpCache.sharedInstance; + } + + /** + * Время жизни кеша в миллисекундах + * @example + * ```ts + * cache.ttl = 60000; + * cache.ttl = Infinity; + * cache.tll = 0; + * + * // негативные значения игнорируются + * cache.ttl = -1; + * cache.ttl = Number.NEGATIVE_INFINITY; + * ``` + */ + public get ttl(): number { + return this._ttl; + } + + public set ttl(ttl: number) { + if (typeof ttl === 'number' && ttl >= 0) { + this._ttl = ttl; + } + } + + /** + * Количество элементов в кеше + */ + public get size(): number { + return this._map.size; + } + + public get(key: string) { + const data = this._map.get(key); + if (!data) return null; + if (data.expires <= Date.now()) { + this.delete(key); + return null; + } + return data.data as T; + } + + public set(key: string, data: any): this { + this._map.set(key, { + data, + expires: Date.now() + this.ttl, + }); + return this; + } + + public delete(key: string): this { + this._map.delete(key); + return this; + } + + public reset(): this { + this._map.clear(); + return this; + } +} diff --git a/src/http-cache/index.ts b/src/http-cache/index.ts new file mode 100644 index 0000000..6f9f421 --- /dev/null +++ b/src/http-cache/index.ts @@ -0,0 +1,2 @@ +export { HttpCache } from './abstract'; +export { DefaultHttpCache } from './default-cache'; diff --git a/src/http-cache/types.ts b/src/http-cache/types.ts new file mode 100644 index 0000000..71f0c6e --- /dev/null +++ b/src/http-cache/types.ts @@ -0,0 +1,11 @@ +export interface HttpCacheEntry { + data: any; + expires: number; +} + +export interface SerializeCacheKeyPayload { + headers?: Record; + method?: string; + url: string; + body?: Record; +} diff --git a/src/index.tsx b/src/index.tsx index 911cb41..fe2279a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -44,3 +44,4 @@ export { DaDataFioSuggestion, DaDataGender, }; +export { HttpCache } from './http-cache'; diff --git a/src/request.ts b/src/request.ts index bc44496..85f0f7e 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,3 +1,5 @@ +import type { HttpCache } from './http-cache'; + export interface RequestOptions { headers: { [header: string]: string }; json: any; @@ -9,12 +11,27 @@ export const makeRequest = ( method: string, endpoint: string, data: RequestOptions, + cache: HttpCache | null, onReceiveData: (response: any) => void, ): void => { if (xhr) { xhr.abort(); } + let cacheKey: string; + if (cache) { + cacheKey = cache.serializeCacheKey({ + headers: data.headers, + body: data.json, + url: endpoint, + method, + }); + const cachedData = cache.get(cacheKey); + if (cachedData) { + onReceiveData(cachedData); + return; + } + } xhr = new XMLHttpRequest(); xhr.open(method, endpoint); if (data.headers) { @@ -30,9 +47,10 @@ export const makeRequest = ( } if (xhr.status === 200) { - const responseJson = JSON.parse(xhr.response); - if (responseJson && responseJson.suggestions) { - onReceiveData(responseJson.suggestions); + const payload = JSON.parse(xhr.response)?.suggestions; + if (payload) { + cache?.set(cacheKey, payload); + onReceiveData(payload); } } }; diff --git a/src/types.ts b/src/types.ts index 37a6166..802f02c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ /* eslint-disable camelcase */ import { ElementType, HTMLProps, ReactNode } from 'react'; +import type { HttpCache } from './http-cache'; type Nullable = T | null; @@ -34,6 +35,16 @@ export interface CommonProps { customInput?: ElementType; selectOnBlur?: boolean; uid?: string; + /** + * Необходимо ли кешировать HTTP-запросы? + * Возможно передать собственный кеш наследующий {@link HttpCache}. + */ + httpCache?: boolean | HttpCache; + /** + * Время жизни кеша в миллисекундах. + * Игнорируется если был передан собственный {@link HttpCache}. + */ + httpCacheTtl?: number; } export interface DaDataAddressMetro {