В этой серии статей я хочу решать BFE задачи (https://bigfrontend.dev) и разбирать решения. BFE - это сайт с задачками, которые позволяют подготовиться к фронтовому интервью и прокачать свои знания в JavaScript.

Сегодня я хочу рассмотреть 2 задачи, это throttle и debounce.

1. Throttle

Теория:

Throttle позволяет вызывать нужную функцию не более одного раза за заданный период времени.

На практике:

Например нам нужно обрабатывать скролл страницы и выполнять какую-то функцию fn, которая работает с координатами скролла. Мы можем подписаться напрямую на событие onscroll, но вызываться он будет очень часто (зависит от того, как мы быстро будем прокручивать), но даже если это сделать быстро, событий будет больше, чем 1-2. Можно задекорировать вызываемую функцию fn в throttle и вызывать один раз в N мс.

Это будет работать так:

  1. При первом вызове onscroll декорированный вариант сразу передает вызов в fn. Пользователь сразу видит изменения.

  2. Если мы продолжаем прокручивать страницу, то в течение N мс ничего не происходит. События onscroll игнорируются.

  3. Когда скролл остановится, декорированная функция подождет, пока не пройдет 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 мс, если после запуска таймера не тригерилось еще одно событие, которое отменяет предыдущее.

Это будет работать так:

  1. При первом вызове onscroll декорированный вариант запускает таймер с вызовом fn.

  2. Если мы продолжаем прокручивать страницу, то предыдущий таймер отменяется и запускается новый.

  3. Если в заданное время 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)


  1. Riim
    27.10.2021 19:20
    +2

    (...args) => {
    ...
    lastThis = this;

    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.


    1. yantishko Автор
      28.10.2021 09:05
      +1

      Спасибо за ревью и рекомендации по улучшению! В след статье с фоллоу апом попробую реализовать эти улучшения и исправления


    1. yantishko Автор
      05.11.2021 11:40

      попробовал переделать debounce, вроде по логике все верно, но тесты не проходит. I need help :)

      function debounce(func, wait) {
        let timeoutId = null;
        let lastCall = null;
      
        return function (...args) {
          const fnCall = () => {
            if (Date.now() - lastCall < wait) {
              timeoutId = setTimeout(fnCall, wait - (Date.now() - lastCall));
            } else {
              func.apply(this, args);
              lastCall = Date.now();
              timeoutId = null;
            }
          }
          if (!timeoutId) {
            timeoutId = setTimeout(fnCall, wait);
          }
          lastCall = Date.now();
        }
      }


      1. Riim
        05.11.2021 12:17

        fnCall создаётся внутри производной функции, во-первых, создавать его (fnCall) на каждый её (производной функции) вызов нет необходимости, во-вторых, так в таймаут попадает первая созданная функция, которая запомнила первые this и args, а нужны последние.
        По ссылке в моём комменте выше есть рабочий вариант. Или вы пробуете самостоятельно сделать?


        1. yantishko Автор
          05.11.2021 12:19

          я пробую самостоятельно, в рамках решения задачи на интервью за пару минут, я как раз по вашему решению и разбирался с этим подходом


  1. OkunevPY
    27.10.2021 20:01

    А чего вы ждёте от npm где публикуциются такие же хабра-писатели, где-то подсмотренный, недопонятый код, не внятно описанный что-же он делает и где тут разница, да ещё и с ляпами, за баги или ошибки я бы это не засчитал, баги и ошибки бывают в рабочем коде, а это просто наляпано .


  1. 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)
      }
    }


    1. aio350
      30.10.2021 10:56

      fix

      id = setTimeout(() => {
        clearTimeout(id)
        
        id = null
      }, ms)