Декоратор withThrottleAndCahce
Пример использования
ts
import { AbstractService } from '@pravosleva/reactive-engine'
interface CombinedOptions {
limit?: number
ttl?: number
}
interface CacheEntry<T> {
data: T
timestamp: number
}
// Наш комбинированный декоратор
export const withThrottleAndCache = <S, T>(
fetcher: (source: S, signal: AbortSignal) => Promise<T>,
options: CombinedOptions = {}
) => {
const limit = options.limit ?? 300
const ttl = options.ttl ?? 5 * 60 * 1000
let lastExecutionTime = 0
let throttleTimeoutId: ReturnType<typeof setTimeout> | null = null
let lastSavedSource: S | null = null
let lastSavedResolve: ((value: T | PromiseLike<T>) => void) | null = null
let lastSavedReject: ((reason: any) => void) | null = null
let lastSavedSignal: AbortSignal | null = null
const cache = new Map<string, CacheEntry<T>>()
const getCacheKey = (source: S): string => {
return typeof source === 'object' && source !== null ? JSON.stringify(source) : String(source)
}
return (source: S, signal: AbortSignal): Promise<T> => {
const now = Date.now()
const remainingTime = limit - (now - lastExecutionTime)
const cacheKey = getCacheKey(source)
const onAbort = () => {
if (throttleTimeoutId) { clearTimeout(throttleTimeoutId); throttleTimeoutId = null; }
if (lastSavedReject) {
lastSavedReject(new DOMException('Aborted by signal', 'AbortError'))
lastSavedResolve = null; lastSavedReject = null;
}
}
const executeWithCache = async (src: S, sig: AbortSignal): Promise<T> => {
const currentNow = Date.now()
const currentKey = getCacheKey(src)
const cached = cache.get(currentKey)
if (cached && currentNow - cached.timestamp < ttl) {
return cached.data
}
const freshData = await fetcher(src, sig)
cache.set(currentKey, { data: freshData, timestamp: Date.now() })
return freshData
}
if (remainingTime <= 0) {
if (throttleTimeoutId) { clearTimeout(throttleTimeoutId); throttleTimeoutId = null; }
if (lastSavedReject) {
lastSavedReject(new DOMException('Aborted due to newer direct execution', 'AbortError'))
lastSavedResolve = null; lastSavedReject = null;
}
lastExecutionTime = now
return executeWithCache(source, signal)
}
if (lastSavedReject) {
lastSavedReject(new DOMException('Aborted due to newer throttled value', 'AbortError'))
}
return new Promise<T>((resolve, reject) => {
lastSavedSource = source
lastSavedResolve = resolve
lastSavedReject = reject
lastSavedSignal = signal
if (signal.aborted) return onAbort()
signal.addEventListener('abort', onAbort)
if (!throttleTimeoutId) {
throttleTimeoutId = setTimeout(async () => {
throttleTimeoutId = null
const savedSource = lastSavedSource!
const savedResolve = lastSavedResolve!
const savedReject = lastSavedReject!
const savedSignal = lastSavedSignal!
lastSavedSource = null; lastSavedResolve = null; lastSavedReject = null; lastSavedSignal = null;
savedSignal.removeEventListener('abort', onAbort)
try {
lastExecutionTime = Date.now()
const data = await executeWithCache(savedSource, savedSignal)
savedResolve(data)
} catch (error) {
savedReject(error)
}
}, remainingTime)
}
})
}
}
// Сам бизнес-сервис
export class ThrottleCacheLogic extends AbstractService {
// Дискретные координаты (округляем до 50px, чтобы пользователь мог легко повторно попасть в ту же зону)
public gridCoords = this.createSignal<{ x: number; y: number }>({ x: 0, y: 0 }, 'grid:signal:coords')
// Счетчик реальных (физических) вызовов тяжелого фетчера бэкенда
public fetchCount = this.createSignal<number>(0, 'grid:signal:fetch-count')
/**
* Реактивный ресурс с комбо-декоратором
*/
public gridResource = this.engine.resource(
withThrottleAndCache(
async (coords, abortSignal) => {
// Увеличиваем счетчик реальных обращений
this.fetchCount.value += 1
// Тяжелая фейковая операция расчета региона
await new Promise((resolve) => setTimeout(resolve, 150))
return `[Сектор: ${coords.x}:${coords.y}] — Данные успешно сгенерированы бэкендом.`
},
{
limit: 400, // Троттлинг: не чаще одного раза в 400 мс
ttl: 15 * 1000 // Кэш: храним историю секторов 15 секунд [1]
}
),
this.gridCoords
)
public updateCoords(x: number, y: number) {
// Округляем до сетки в 50 пикселей, создавая "дискретные зоны" для наглядности кэша
const gridX = Math.floor(x / 50) * 50
const gridY = Math.floor(y / 50) * 50
if (this.gridCoords.value.x !== gridX || this.gridCoords.value.y !== gridY) {
this.gridCoords.value = { x: gridX, y: gridY }
}
}
}tsx
import { MouseEvent } from 'react'
import { ReactiveEngine, useReactiveValue } from '@pravosleva/reactive-engine'
import { ThrottleCacheLogic } from './service.ThrottleCacheLogic'
import baseClasses from '~/ui.common.module.scss'
import clsx from 'clsx'
const engine = new ReactiveEngine()
export const ThrottleCacheExample = () => {
const logic = engine.inject(ThrottleCacheLogic)
const coords = engine.use(logic.gridCoords)
const fetchCount = engine.use(logic.fetchCount)
const { loading, data: result } = useReactiveValue(logic.gridResource)
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = Math.round(e.clientX - rect.left)
const y = Math.round(e.clientY - rect.top)
logic.updateCoords(x, y)
}
return (
<div
className={clsx(baseClasses.unit, baseClasses.stack2)}
style={{ fontFamily: 'system-ui', width: '600px' }}
>
<div className={baseClasses.absoluteUnitLabel}>Throttle + Cache Combo Demo</div>
{/* Аналитические счетчики */}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'small', background: '#15151a', padding: '16px', borderRadius: '8px', color: '#fff' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<span>Зона сетки:</span>
<span style={{ color: '#00b4d8', fontFamily: 'monospace' }}>{coords.x}:{coords.y}</span>
</div>
<div
style={{ display: 'flex', gap: '8px', alignItems: 'center' }}
>
<span>Запросов к API:</span>
<span style={{ color: '#f44336', fontWeight: 'bold' }}>{fetchCount}</span>
</div>
</div>
{/* Интерактивный интерактивный холст */}
<div
onMouseMove={handleMouseMove}
style={{ width: '100%', height: '180px', background: '#15151a', borderRadius: '8px', cursor: 'crosshair', display: 'flex', alignItems: 'center', justifyContent: 'center', userSelect: 'none', position: 'relative' }}
>
<span style={{ color: '#aaa', fontSize: 'small' }}>Водите курсор для исследования секторов</span>
</div>
{/* Терминал вывода */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<div style={{ fontSize: 'small', display: 'flex', justifyContent: 'space-between' }}>
<span>Статус обработки:</span>
{loading && <span style={{ color: '#e6af2e' }}>⏳ Тяжелый запрос к бэкенду...</span>}
{!loading && <span style={{ color: '#4caf50' }}>⚡ Ответ получен</span>}
</div>
<div style={{ background: '#111', borderRadius: '6px', padding: '12px', minHeight: '44px', display: 'flex', alignItems: 'center', fontSize: '12px', fontFamily: 'monospace', color: '#4caf50' }}>
{result || <span style={{ color: '#aaa' }}>Запустите движение мыши...</span>}
</div>
</div>
</div>
)
}