Декоратор withThrottle
Практические кейсы применения декоратора withThrottle
ts
const throttledMapResource = engine.resource(
withThrottle(
async (bboxValue, abortSignal) => {
const res = await fetch(`/api/stations?bbox=${bboxValue}`, { signal: abortSignal });
return res.json();
},
// Запросы к API будут улетать не чаще, чем 1 раз в 400 миллисекунд
{ limit: 400 }
),
bboxSignal
);Декоратор withThrottle (заслонка) — это инструмент оптимизации, который применяется в сценариях с высокой частотой генерации событий, когда нам важен непрерывный процесс изменений в динамике, но с жестким ограничением максимальной частоты вызовов.
В отличие от дебаунса, который бесконечно откладывает выполнение запроса и ждет наступления полной «тишины», троттлинг гарантированно пропускает самый первый вызов мгновенно (Leading edge), а затем равномерно порциями (например, строго раз в 300 мс) досылает актуальные данные. Это позволяет приложению реагировать на действия пользователя прямо в процессе их совершения.
1. Плавный ресайз тяжелых интерфейсов (Window Resize / WebGL & Canvas)
- Проблема без декоратора: При изменении размеров окна браузера (когда пользователь плавно тянет за угол экрана) событие
resizeгенерируется непрерывно десятки раз в секунду. Если на каждый пиксель запускать тяжелый пересчет матриц проекции 3D-сцены (Three.js), перезапуск физики (Phaser/Kaplay) или перерисовку сложных SVG-графиков дашборда, процессор мгновенно загрузится на 100%, и интерфейс начнет намертво зависать и дергаться. - Решение с Throttle: Вы выставляете лимит
limit: 50(20 кадров в секунду для ресайза). При перетаскивании окна холст графики плавно и адаптивно подстраивается под новые размеры порциями, не перегружая CPU. Хвостовой вызов (Trailing edge) гарантирует, что когда пользователь отпустит мышь, сцена идеально встанет под финальный размер экрана.
2. Бесконечная лента и пагинация при прокрутке (Scroll Events / Infinite Scroll)
- Проблема: Нам нужно реализовать бесконечную подгрузку постов, когда пользователь доскроллил до конца страницы. Если повесить проверку координат скролла (
window.scrollY) на стандартное событиеscroll, браузер будет выполнять математические вычисления на каждый сдвиг колеса мыши. Это приводит к так называемому «jank-эффекту» — микрофризам и дерганью прокрутки, особенно на мобильных устройствах. - Решение с Throttle: Выставляется лимит
limit: 100–200мс. Браузер вычисляет расстояние до низа страницы всего несколько раз в секунду. Этого более чем достаточно, чтобы вовремя и бесшовно инициировать подгрузку следующей порции контента, сохраняя при этом идеальную нативную плавность прокрутки (60+ FPS).
3. Отслеживание перемещения курсора или тач-событий (Mouse Move / Drag & Drop)
- Проблема: В приложении разрабатывается интерактивный инструмент (например, холст для рисования, Drag & Drop аналитических карточек на дашборде или кастомный графический курсор, подгружающий координаты). Нативные события
mousemoveиtouchmoveспамят координатами слишком часто. Если отправлять эти данные в реактивное состояние на каждый миг движения — граф зависимостей стейт-менеджера будет перегружен ежеминутными циклами обновлений. - Решение с Throttle: Троттлинг ограничивает поток координат до комфортных, например, 30 вызовов в секунду (
limit: 33). Физическая траектория движения и отзывчивость интерфейса полностью сохраняются, но нагрузка на реактивный движок и рендеринг UI снижается в разы.
4. Глобальный Rate Limiting для кнопок отправки данных (UI Spam Protection)
- Проблема: Пользователь из-за плохого интернет-соединения или нетерпения начинает яростно кликать по кнопке «Обновить данные», «Повторить запрос» или «Поставить лайк». Если на каждый клик слать сетевой запрос, клиент создаст искусственную DDOS-нагрузку на сервер API, отправляя кучу дублирующих транзакций.
- Решение с Throttle: Кнопка оборачивается в троттлинг с лимитом
limit: 1000(1 секунда). Первый клик улетает на сервер мгновенно (Leading edge), давая моментальный отклик. Все последующие яростные клики в течение секунды полностью блокируются, защищая бэкенд и сохраняя стабильность бизнес-логики.
Сводная шпаргалка по декораторам ресурсов:
withThrottle— Нужен, когда важен процесс в динамике, но порциями (пример: плавное изменение размеров куба Three.js, отслеживание скролла ленты, защита кнопок от спама).withCache— Нужен, когда данные редко меняются, и мы хотим полностью исключить повторные запросы при возвращении к прежним параметрам (пример: переключение табов, пагинация назад).withDebounce— Нужен, когда важен только финальный результат после того, как пользователь полностью затих (пример: валидация формы при вводе email, живой поиск).withThrottleAndCache— Нужен, когда важен процесс в динамике, но данные внутри этого процесса имеют свойство повторяться на коротком промежутке времени (пример: перемещение интерактивных карт, умный живой поиск).
Пример использования
Ниже приведен готовый пример использования сервиса:
ts
import { AbstractService } from '@pravosleva/reactive-engine'
interface ThrottleOptions {
limit?: number
}
// Наш декоратор троттлинга (wip)
export const withThrottle = <S, T>(
fetcher: (source: S, signal: AbortSignal) => Promise<T>,
options: ThrottleOptions = {}
) => {
const limit = options.limit ?? 300
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
return (source: S, signal: AbortSignal): Promise<T> => {
const now = Date.now()
const remainingTime = limit - (now - lastExecutionTime)
const onAbort = () => {
if (throttleTimeoutId) { clearTimeout(throttleTimeoutId); throttleTimeoutId = null; }
if (lastSavedReject) {
lastSavedReject(new DOMException('Aborted by signal', 'AbortError'))
lastSavedResolve = null; lastSavedReject = null;
}
}
if (remainingTime <= 0) {
if (throttleTimeoutId) { clearTimeout(throttleTimeoutId); throttleTimeoutId = null; }
if (lastSavedReject) {
lastSavedReject(new DOMException('Aborted due to newer execution', 'AbortError'))
lastSavedResolve = null; lastSavedReject = null;
}
lastExecutionTime = now
return fetcher(source, signal)
}
if (lastSavedReject) {
lastSavedReject(new DOMException('Aborted due to newer 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 fetcher(savedSource, savedSignal)
savedResolve(data)
} catch (error) {
savedReject(error)
}
}, remainingTime)
}
})
}
}
// Сам бизнес-сервис
export class Throttle2DLogic extends AbstractService {
// Сигнал, куда записываются сырые координаты X и Y при движении мыши
public coordsSignal = this.createSignal<{ x: number; y: number }>({ x: 0, y: 0 }, '3d:signal:coords')
/**
* Реактивный ресурс, обёрнутый в декоратор withThrottle.
* Он считывает координаты и выполняет "тяжёлый" фейковый расчёт зон.
* Троттлинг гарантирует частоту выполнения не чаще 1 раза в 300 мс.
*/
public analyticsResource = this.engine.resource(
withThrottle(
async (coords, abortSignal) => {
// Имитируем небольшую задержку расчёта (например, обращение к гео-модели)
await new Promise((resolve) => setTimeout(resolve, 100))
const sector = coords.x < 200 ? 'Левый сектор' : 'Правый сектор'
return `Аналитика GPU: [${sector}] для точки X: ${coords.x}, Y: ${coords.y}`
},
{ limit: 300 } // Лимит троттлинга 300 мс
),
this.coordsSignal
)
/**
* Экшен обновления координат из UI
*/
public updateCoords(x: number, y: number) {
this.coordsSignal.value = { x, y }
}
}tsx
import { MouseEvent } from 'react'
import { ReactiveEngine, useReactiveValue } from '@pravosleva/reactive-engine'
import { Throttle2DLogic } from './service.Throttle2DLogic'
import baseClasses from '~/ui.common.module.scss'
import clsx from 'clsx'
const engine = new ReactiveEngine()
export const Throttle2DExample = () => {
const logic = engine.inject(Throttle2DLogic)
// Подписываемся на сырой сигнал координат и обработанный ресурс аналитики
const coords = engine.use(logic.coordsSignal)
const { loading, data: analyticsResult } = useReactiveValue(logic.analyticsResource)
// Перехват движения мыши внутри зоны
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}>Simple Throttle Mouse Tracking Demo</div>
<div className={baseClasses.stack1}>
{/* Индикаторы текущего состояния */}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'small' }}>
<span>Сырые координаты из сигнала:</span>
<span style={{ fontFamily: 'monospace', color: '#00b4d8' }}>X: {coords.x}, Y: {coords.y}</span>
</div>
{/* Интерактивная зона для вождения мышкой */}
<div
onMouseMove={handleMouseMove}
style={{ width: '100%', height: '180px', background: '#15151a', borderRadius: '8px', cursor: 'crosshair', display: 'flex', alignItems: 'center', justifyContent: 'center', userSelect: 'none' }}
>
<span style={{ color: '#aaa', fontSize: '13px' }}>Двигайте курсор внутри этой зоны</span>
</div>
</div>
{/* Терминал вывода затроттленной аналитики */}
<div className={baseClasses.stack1}>
<div style={{ fontSize: 'small', display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
<span>Результат обработки (не чаще 1 раза в 300мс):</span>
{loading && <span style={{ color: '#e6af2e' }}>⏳ Расчёт...</span>}
</div>
<div style={{ background: '#111', borderRadius: '8px', padding: '12px', minHeight: '44px', display: 'flex', alignItems: 'center', fontSize: '13px', fontFamily: 'monospace', color: '#4caf50' }}>
{analyticsResult || <span style={{ color: '#aaa' }}>Запустите движение мыши...</span>}
</div>
</div>
</div>
)
}