Debounce и throttle
Прокси actions поставляется с четырьмя композируемыми обёртками — debounce, throttle, defer и queue — которые позволяют запланировать один побочный эффект, не выходя из обработчика. Рантайм владеет таймером, чистит его на dispose, а обёртки чисто компонуются со стратегиями concurrency.
actions.debounce, .throttle, .defer возвращают новый прокси, чьи вызовы действий планируются соответствующим образом. Изначальный actions.showToast?.(...) всё ещё происходит мгновенно.
Debounce vs throttle vs defer
Заголовок раздела «Debounce vs throttle vs defer»| Обёртка | Edge | Замена | Лучше всего подходит для |
|---|---|---|---|
debounce(ms) | Trailing | Каждый вызов заменяет любой ожидающий с тем же ключом (имя действия + payload key). После ms тишины — последний происходит. | Коалессинг пачек: “один beep на флешмоб сообщений”, “поиск после остановки печати”. |
throttle(ms) | Leading | Первый вызов происходит сразу. Последующие вызовы в течение ms сбрасываются (не очередь). | Rate-limiting: “максимум один пинг аналитики в 2 с”, “максимум один resize-хендлер на кадр”. |
defer(ms) | One-shot | Запланируй один вызов ровно через ms. Новые defer-вызовы не заменяют существующие — у каждого свой таймер. | ”Сбрось батч через 50 мс независимо от новых вызовов”, запланированные one-off’ы. |
Визуальная разница для 5 событий, выпущенных в t = 0, 50, 100, 150, 200мс с ms = 150:
queue.foo(payload) — сериализованные действия
Заголовок раздела «queue.foo(payload) — сериализованные действия»actions.queue.foo(...) отличается: не принимает аргумент ms. Он сериализует вызовы действия, не прогоны обработчика.
Сравнительно с trigger-level concurrency: 'queue':
| Механизм | Что сериализуется |
|---|---|
concurrency: 'queue' | Весь прогон обработчика. Два события пересекаются → второй обработчик ждёт резолва первого. |
actions.queue.foo(p) | Действие foo во всех вызовах. Два вызова foo пересекаются → второй ждёт резолва промиса первого. |
Очередь уровня действия существует, потому что один обработчик часто диспатчит несколько побочных эффектов, и только часть из них требует строгого порядка. Засунуть весь обработчик за concurrency: 'queue' блокировало бы чтения на записях; actions.queue.foo оставляет чтения параллельными, сериализуя записи.
Per-action keying
Заголовок раздела «Per-action keying»Рантайм ключует таймеры по имени действия + значению ms для timed-обёрток. Debounced-вызов playSound не пересекается с debounced-вызовом showToast даже на той же задержке — они живут в разных слотах.
Вызов того же действия с разными значениями ms производит отдельные таймеры — ключ включает ms. В большинстве случаев это не проблема (обычно ты не меняешь задержку посреди обработчика), но это стоит знать, если ты строишь settings-driven debounce:
Для payload-based keying (“дебаунси per channel id”) положи каждый канал за свой экземпляр триггера через <TriggerScope id={channelId}>. У каждого скоупа своя карта таймеров.
Тогда сценарий дебаунсит per scope — ровно то, что нужно для “один beep на канал, даже если шумят несколько каналов”.
Выбор между четырьмя
Заголовок раздела «Выбор между четырьмя»Маленькое дерево решений для выбора правильной обёртки:
Обёртки компонуются друг с другом только через разные имена действий:
Нельзя цеплять обёртки на одном и том же вызове (actions.debounce(100).throttle(200).foo — невалидная конструкция). Если нужен такой уровень контроля — запусти follow-up событие и положи его на собственный триггер с подходящим concurrency.
Таймеры и отмена
Заголовок раздела «Таймеры и отмена»Каждый ожидающий debounce / defer таймер, который держит рантайм, регистрируется в карте timers триггера. Когда происходит любое из следующего, рантайм вызывает cancelAllTimers(trigger):
- Триггер диспозится (
trigger.dispose()— обычно вручную не вызывается). - Рантайм диспозится (
runtime.dispose()—unmount, teardown скоупа, очистка теста). - Скоуп, в котором живёт триггер, размонтируется.
- Vite/Webpack HMR заменяет модуль, который определил триггер.
Это значит: никакого “зомби-дебаунсенного-звука”, висящего после навигации. Таймер умирает вместе со скоупом.
Тебе никогда не понадобится писать clearTimeout самому для этих обёрток. Если ловишь себя на этом — предпочитай defer(ms) только тогда, когда раньше написал бы setTimeout(... , ms); никакого cancelDefer нет, потому что рантайм отменяет всё на dispose.
Живой пример: debounced звук уведомления
Заголовок раздела «Живой пример: debounced звук уведомления»Сценарий чата. Пачка входящих сообщений должна выдавать один тост на сообщение, но один beep на всю пачку.
Что ты увидишь:
- 6 сообщений за 200 мс → 6 тостов, один beep в
t ≈ 1000мс(200 мс ввода + 800 мс debounce-тишины). - После того как beep отработал, слот таймера пуст. Следующее сообщение либо происходит сразу (debounce не активен), либо стартует новое окно 800 мс.
- Выключи звук посреди пачки → guard
check.is(...)пропускает debounced-вызов; любой существующий таймер довыполнит свой запланированный экшен только еслиplaySoundвсё ещё зарегистрирован. Если реактор размонтирует хендлер действия, поздний вызов — no-op.
Распространённая ловушка: дебаунс на функцию, которая должна срабатывать на каждое событие
Заголовок раздела «Распространённая ловушка: дебаунс на функцию, которая должна срабатывать на каждое событие»Если твоё действие — именно то, что ты хочешь звать на каждое событие (поиск per-keystroke, метрика per-tick, audit-лог), дебаунс молча дропает события.
Если два audit-события приходят в 300 мс, второе заменяет первое. Первое не происходит. Ты теряешь audit-данные.
Фикс зависит от того, чего ты на самом деле хочешь:
- Каждое событие в audit → просто зови
actions.logAuditEvent?.(...)без debounce. - Пачки схлопнуть в батчи → накапливай в замыкании, потом
defer(300)на единичный батч-flush. Лучше: эмить событие'batch-flush'и отдельный триггер, который зовёт flush сconcurrency: 'queue'. - Rate limited →
actions.throttle(300).logAuditEvent?.(...). Теперь первый вызов проходит, последующие в 300 мс сбрасываются — всё ещё с потерями, но на leading edge, а не trailing. Для audit обычно неверно.
Правило: debounce для де-дупликации, не для rate-limiting. Если ты не можешь позволить себе терять события — не дебаунси.
Распространённая ловушка: считать, что debounce(ms).foo идемпотентен между прогонами обработчика
Заголовок раздела «Распространённая ловушка: считать, что debounce(ms).foo идемпотентен между прогонами обработчика»Ключ debounce — (имя действия, ms), не (прогон обработчика, имя действия, ms). Два последовательных прогона того же обработчика, оба зовущие actions.debounce(800).playSound?.('beep'), производят один beep через 800 мс после второго вызова.
Почти всегда это именно то, что ты хочешь — debounce и есть коалессинг между прогонами. Но если ты ожидал, что у каждого прогона будет свой независимый таймер (например, для defer-style one-shot per run), используй defer:
Распространённая ловушка: полагаться, что debounce “гарантирует” последний payload
Заголовок раздела «Распространённая ловушка: полагаться, что debounce “гарантирует” последний payload»Таймер debounce запускает payload последнего вызова — но только последнего вызова внутри окна тишины. Если обработчик прогонится снова после того, как таймер сработал, это новый вызов, который планирует новый таймер. Нет поведения “залочить payload на первом вызове”.
Если хочешь, чтобы выиграл payload первого вызова — переключайся на throttle:
Если нужно коалессить payload’ы (например, копить список) — делай это явно через буфер в замыкании плюс defer:
(Для большинства сценариев явный batch-паттерн лучше выражается двумя триггерами: один копит, второй сбрасывает.)
Распространённая ловушка: комбинация throttle с take-latest
Заголовок раздела «Распространённая ловушка: комбинация throttle с take-latest»Leading-edge throttle, обёрнутый вокруг действия внутри take-latest обработчика, всё ещё запускает действие каждый раз, когда обработчик пробежал без abort’а. Throttle не смотрит на concurrency обработчика.
Если take-latest прерывает предыдущий прогон, счётчик throttle не сбрасывается — окно throttle в полёте per-action, не per-run. Ты можешь в итоге звать reportFetch максимум раз в секунду по всем прогонам, что обычно и есть правильная семантика throttle. Если конкретно нужно “report только успешных прогонов”, проверяй !signal.aborted перед вызовом.