Skip to content

Декоратор 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>
  )
}