Декоратор withThrottleAndCache
Комбинированный декоратор withThrottleAndCache — это продвинутый инструмент оптимизации, который применяется в сценариях с высокой частотой генерации событий, когда запрашиваемые данные при этом могут повторяться или дублироваться.
Троттлинг защищает интерфейс от «спама» вызовами на микро-временных отрезках, а кэш защищает от повторных ресурсоемких операций (сетевых запросов или тяжелых вычислений), если состояние стейта вернулось к прежнему значению.
Практические кейсы применения декоратора withThrottleAndCache
ts
const optimizedResource = engine.resource(
withThrottleAndCache(
async (bboxValue, abortSignal) => {
const res = await fetch(`/api/stations?bbox=${bboxValue}`, { signal: abortSignal });
return res.json();
},
{
limit: 400, // Запросы улетают не чаще раза в 400 мс
ttl: 30 * 1000 // Каждая уникальная зона кэшируется на 30 секунд
}
),
bboxSignal
);1. Интерактивные карты (Драг, скролл и зум)
Это классический пример, под который проектируется данный паттерн в ГИС-системах (Google Maps, Mapbox, Leaflet):
- Проблема без декоратора: Когда пользователь плавно тащит карту мышкой, событие смены координат генерируется каждые 10–15 мс. Если отправлять запросы к API на каждый пиксель — бэкенд упадет от DDOS-нагрузки. Если же использовать обычный
withDebounce, то во время движения карта будет абсолютно пустой, и объекты появятся только после полной остановки, что ломает UX. - Решение с Throttle + Cache: Троттлинг режет частоту, отправляя запрос к API строго раз в 300–400 мс прямо в процессе движения (карта наполняется объектами на лету). А если пользователь двигает карту «туда-сюда» в рамках одной и той же области, кэш мгновенно подставляет объекты из оперативной памяти
Map, полностью отменяя лишние HTTP-запросы к серверу.
2. Живой поиск с подсказками (Search Suggestions / Autocomplete)
- Проблема: Пользователь быстро вводит текст, затем стирает буквы (клавишей Backspace), чтобы исправить опечатку, и пишет слово заново.
- Решение с Throttle + Cache: Троттлинг не дает отправлять запросы на бэкенд на каждую нажатую клавишу, группируя ввод порциями. Но если пользователь стёр слово
ReactдоRe, а затем передумал и снова быстро напечаталReact— кэш мгновенно вернет результат для словаReact, который был получен секунду назад, не нагружая базу данных поискового движка повторным тяжелымLIKE / ILIKEзапросом.
3. Изменение размеров адаптивных панелей (Resize / Dashboard Layouts)
- Проблема: Пользователь плавно растягивает окно браузера или сложный внутренний сплиттер (сайдбар), внутри которого находятся тяжелые аналитические компоненты. Эти компоненты запрашивают агрегированные метрики, таблицы или графики строго под текущий размер экрана (ширину контейнера).
- Решение с Throttle + Cache: Троттлинг заставляет компоненты запрашивать новые чанки данных с сервера порциями (например, раз в 500 мс) прямо в процессе растягивания, чтобы UI менялся плавно. Если пользователь вернул размер панели назад к привычному состоянию, кэш мгновенно отдаст готовые данные под этот конкретный размер без повторного обращения к серверу аналитики.
4. Бесконечная лента с подгрузкой вверх и вниз (Infinite Scroll / Фид данных)
- Проблема: Пользователь очень быстро и хаотично скроллит бесконечную ленту вверх и вниз. При пересечении триггеров видимости (
IntersectionObserver) начинают генерироваться запросы на подгрузку страниц. - Решение с Throttle + Cache: Троттлинг жестко контролирует, чтобы триггеры подгрузки срабатывали с безопасным временным интервалом. Если пользователь резко крутанул колесо мыши обратно вверх, чтобы перечитать верхние посты, кэш не даст заново скачивать только что просмотренные страницы, экономя трафик и исключая лишние промаргивания списков.
Сводная шпаргалка по декораторам ресурсов:
withDebounce— Нужен, когда важен только финальный результат после того, как пользователь затих (пример: валидация формы при вводе email).withThrottle— Нужен, когда важен процесс в динамике, но порциями (пример: плавное рисование на холсте, анимация куба Three.js при ресайзе).withThrottleAndCache— Нужен, когда важен процесс в динамике, но данные внутри этого процесса имеют свойство повторяться на коротком промежутке времени (пример: скролл, перемещение карт, живой поиск).
Еще пример
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/react'
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>
)
}