В этой серии статей я хочу решать BFE задачи (https://bigfrontend.dev) и разбирать решения. BFE - это сайт с задачками, которые позволяют подготовиться к фронтовому интервью и прокачать свои знания в JavaScript.
Сегодня я хочу рассмотреть 2 задачи, это throttle и debounce.
1. Throttle
Теория:
Throttle позволяет вызывать нужную функцию не более одного раза за заданный период времени.
На практике:
Например нам нужно обрабатывать скролл страницы и выполнять какую-то функцию fn, которая работает с координатами скролла. Мы можем подписаться напрямую на событие onscroll, но вызываться он будет очень часто (зависит от того, как мы быстро будем прокручивать), но даже если это сделать быстро, событий будет больше, чем 1-2. Можно задекорировать вызываемую функцию fn в throttle и вызывать один раз в N мс.
Это будет работать так:
При первом вызове onscroll декорированный вариант сразу передает вызов в fn. Пользователь сразу видит изменения.
Если мы продолжаем прокручивать страницу, то в течение N мс ничего не происходит. События onscroll игнорируются.
Когда скролл остановится, декорированная функция подождет, пока не пройдет N мс и затем вызывает fn с последними координатами. Тем самым обработает конечные координаты, где остановился скролл.
Реализация:
Разберем решение:
Функция throttle принимает 2 аргумента: функция, которую необходимо вызывать и время ожидания.
В строках 7 - 9, нам нужно сохранить наш таймер, который мы создаем для триггера вызовов функции с заданным интервалом, и нужно сохранять контекст вызова и аргументы, чтоб когда отработает таймер, мы могли вызвать функцию с корректным контекстом и параметрами.
В строке 11 возвращается функция, т.к нам необходимо сделать замыкание и задекорировать исходную функцию в throttle.
const throttled = throttle(func, 3)
И теперь остается обработать 2 варианта: первый вызов и последующие. Первый вызов должен отработать сразу же, не дожидаясь завершения таймера. Последующие должны "перезаписываться" и вызываться должен самый последний по истечению таймера.
Проверка на первый вызов это или нет осуществляется по запущенному таймеру, поэтому в строке 12 можно ее и увидеть, если это уже не первый вызов, мы просто сохраняем контекст и аргументы.
Если же это первый вызов, то сразу же выполняется функция (строка 16) и запускается таймер (строка 18) с указанным временным интервалом. Дополнительная проверка на аргументы внутри таймера, это для того, чтоб если у нас был всего один вызов (а мы первый выполнили сразу же), чтоб не вызвался он повторно, после истечения времени. Если же вызовы были, то когда время пройдет, функция будет вызвана с сохраненными контекстом и аргументами (строка 20), таймер очищен (строка 22), данные последнего вызова тоже очищены (строки 23, 24).
2. Debounce
Теория:
Debounce позволяет вызывать функцию fn не более одного раза в заданное количество миллисекунд.
На практике:
Нам нужно обрабатывать скролл страницы и выполнять какую-то функцию fn, которая работает с координатами скролла. Использование debounce будет вызывать функцию fn через N мс, если после запуска таймера не тригерилось еще одно событие, которое отменяет предыдущее.
Это будет работать так:
При первом вызове onscroll декорированный вариант запускает таймер с вызовом fn.
Если мы продолжаем прокручивать страницу, то предыдущий таймер отменяется и запускается новый.
Если в заданное время N мс не происходит событий onscroll, то вызывается функция fn с контекстом и аргументами последнего события.
Реализация:
Функция debounce принимает 2 аргумента: функция, которую необходимо вызывать и время ожидания.
В строках 8 - 10, нам нужно сохранить наш таймер, который мы создаем для триггера вызовов функции по истечении времени, и нужно сохранять контекст вызова и аргументы, чтоб когда отработает таймер, мы могли вызвать функцию с корректным контекстом и параметрами.
В строке 12 возвращается функция, т.к нам необходимо сделать замыкание и задекорировать исходную функцию в debounce.
const debounce = debounce(func, 3)
Функция, которая будет выполняться по истечению времени это строка 16. Мы вызываем исходную функцию fn с сохраненными контекстом и аргументами последнего вызова и обнуляем их.
Так как при каждом последующем вызове мы должны отменять таймер и запускать заново, то в строке 23 реализована отмена.
Строка 25 запускает таймер с вызовом нашей функции fnCall через заданное время wait.
Заключение
Как вы видите, нет ничего сложного в реализации данных методов. Надеюсь описанный алгоритм из жизни позволит лучше запомнить как работают throttle и debounce и в чем их отличие.
В следующей статье разберем варианты с follow up (реализацию throttle/debounce с leading и trailing опциями).
Комментарии (8)
OkunevPY
27.10.2021 20:01А чего вы ждёте от npm где публикуциются такие же хабра-писатели, где-то подсмотренный, недопонятый код, не внятно описанный что-же он делает и где тут разница, да ещё и с ляпами, за баги или ошибки я бы это не засчитал, баги и ошибки бывают в рабочем коде, а это просто наляпано .
aio350
30.10.2021 01:41-1// debounce // with `this` const debounce = (fn, ms) => function (...args) { let prevCall = this.lastCall this.lastCall = Date.now() if (prevCall && this.lastCall - prevCall <= ms) { clearTimeout(this.timer) } this.timer = setTimeout(() => fn(...args), ms) } // or // without `this` const _debounce = (fn, ms) => { let id = null return (...args) => { clearTimeout(id) id = setTimeout(() => { fn(...args) clearTimeout(id) }, ms) } } // throttle // with `this` const throttle = (fn, ms) => function (...args) { let prevCall = this.lastCall this.lastCall = Date.now() if (!prevCall || this.lastCall - prevCall > ms) { fn(...args) } } // or // without `this` const _throttle = (fn, ms) => { let id = null return (...args) => { if (id) return fn(...args) id = setTimeout(() => { id = null clearTimeout(id) }, ms) } }
Riim
this будет от внешней функции, у стрелочной нет своего, а здесь как раз нужно запоминать this производной функции. Тоже самое для debounce.
Для throttle clearTimeout внутри setTimeout не имеет никакого смысла.
Для throttle сразу создаётся таймаут, хотя второго вызова который необходимо отложить ещё нет, то есть если вызовы будут происходить с задержкой большей чем wait, то таймауты будут молотить впустую.
Ещё возможная ситуация для throttle: происходит второй вызов который откладывается, дальше поток выполнения забивается сложными вычислениями, настаёт время выполнить таймаут, но так как поток занят таймаут просто стоит в очереди, и вот тут прямо в расчётах происходит третий вызов. Ваша реализация просто заменит контекст и аргументы, но вызова исходной функции так и не произойдёт, таймаут делающий его так и будет стоять в очереди, хотя прошедшее время позволяет прямо посреди вычислений вызвать исходную функцию отменив стоящий в очереди таймаут.
Для debounce то же есть хорошее улучшение. Допустим происходит 100 вызовов производной функции и задержка между ними всегда меньше wait, то есть вызов исходной функции произойдёт только 1 раз. В вашей реализации будет создано 100 таймаутов из которых 99 будут отменены и 1 сработает. Можно улучшить реализацию следующим образом: при первом вызове создаётся таймаут, при втором он не отменяется, а вместо этого просто обновляется время последнего вызова производной функции, таких вызовов до срабатывания таймаута может произойти несколько, дольше вызывается таймаут и в нём видя, что время последнего вызова обновилось не вызывается исходная функция, а создаётся следующий таймаут на время `wait - (Date.now() - timestamp)`. Таким образом количество создаваемых и отменяемых таймаутов сильно падает, иногда в десятки раз при том же конечном результате.
Грамотные реализации этих двух функций не так уж тривиальны как кажется на первый взгляд. Несколько лет назад используя одну из популярных реализаций throttle с npm столкнулся со странным поведением, полез разбираться в исходники, баг нашёл, а заодно ещё один. Полез смотреть исходники других вариантов, просмотрел всю первую страницу выдачи npm и почти во всех пакетах удалось найти минимум один полноценный баг. В тот момент стало очень грустно от качества кода на npm. Запилил свои реализации: @riim/debounce-throttle , на идеальность не претендую, но делал очень вдумчиво и думаю качество всё же повыше среднего на npm.
yantishko Автор
Спасибо за ревью и рекомендации по улучшению! В след статье с фоллоу апом попробую реализовать эти улучшения и исправления
yantishko Автор
попробовал переделать debounce, вроде по логике все верно, но тесты не проходит. I need help :)
Riim
fnCall создаётся внутри производной функции, во-первых, создавать его (fnCall) на каждый её (производной функции) вызов нет необходимости, во-вторых, так в таймаут попадает первая созданная функция, которая запомнила первые this и args, а нужны последние.
По ссылке в моём комменте выше есть рабочий вариант. Или вы пробуете самостоятельно сделать?
yantishko Автор
я пробую самостоятельно, в рамках решения задачи на интервью за пару минут, я как раз по вашему решению и разбирался с этим подходом