На мониторе игра-файтинг, где главная героиня бьёт врага-снеговика. Рука на мышке кликает
Иллюстрация: Кира Кустова

Throttle на примере изменения страницы при прокрутке

Время чтения: 6 мин

Кратко

Секция статьи "Кратко"

На современных сайтах встречаются элементы, которые обновляются по мере прокрутки страницы или при изменении её размеров.

Просто запускать сложные и дорогостоящие операции на события scroll и resize — расточительно, потому что это может сильно нагрузить браузер и плохо сказаться на производительности.

Вместо этого можно обрабатывать изменения «раз в какое-то количество времени», используя throttle.

Дизайн и задача

Секция статьи "Дизайн и задача"

Представим, что дизайнеры попросили нас сделать горизонтальный прогресс-бар, который бы показывал, какую часть статьи пользователь успел прочесть.

Этот элемент в начале страницы должен показывать 0%, а при прокрутке менять значение. Вот так:

демо прокрутки с горизонтальной полосой прогресса

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

Разметка и стили

Секция статьи "Разметка и стили"

В разметке у нас будет только шапка и статья:

        
          
          <header>  <!-- В качестве прогресс-бара      будем использовать элемент progress 😃 -->  <progress value="0" max="100"></header><main>  <!-- Много-много-много текста... --></main>
          <header>
  <!-- В качестве прогресс-бара
      будем использовать элемент progress 😃 -->
  <progress value="0" max="100">
</header>
<main>
  <!-- Много-много-много текста... -->
</main>

        
        
          
        
      

В стилях ограничим всё по ширине и отцентрируем:

        
          
          /* Зафиксируем прогресс-бар наверху страницы: */progress {  position: fixed;  top: 0;  left: 20px;  right: 20px;  width: calc(100% - 40px);  max-width: 800px;  margin: auto;}main {  padding-top: 15px;  max-width: 800px;  margin: auto;}
          /* Зафиксируем прогресс-бар наверху страницы: */
progress {
  position: fixed;
  top: 0;
  left: 20px;
  right: 20px;
  width: calc(100% - 40px);
  max-width: 800px;
  margin: auto;
}

main {
  padding-top: 15px;
  max-width: 800px;
  margin: auto;
}

        
        
          
        
      

Обработчик прокрутки

Секция статьи "Обработчик прокрутки"

Сперва напишем обработчик прокрутки без оптимизаций.

        
          
          // В переменной progress будем хранить// ссылку на элемент, показывающий прогресс чтения.const progress = document.querySelector("progress");// Функция recalculateProgress будет пересчитывать,// какую часть страницы пользователь уже успел прочесть.function recalculateProgress() {  // Высота экрана:  const viewportHeight = window.innerHeight;  // Высота страницы:  const pageHeight = document.body.offsetHeight;  // Текущее положение прокрутки:  const currentPosition = window.scrollY;  // Из высоты страницы вычтем высоту экрана,  // чтобы при прокручивании до самого низа  // прогресс-бар заполнялся до конца.  const availableHeight = pageHeight - viewportHeight;  // Считаем процент «прочитанного» текста:  const percent = (currentPosition / availableHeight) * 100;  // Проставляем посчитанное значение  // в качестве значения для value прогресс-бара:  progress.value = percent;}
          // В переменной progress будем хранить
// ссылку на элемент, показывающий прогресс чтения.
const progress = document.querySelector("progress");

// Функция recalculateProgress будет пересчитывать,
// какую часть страницы пользователь уже успел прочесть.
function recalculateProgress() {
  // Высота экрана:
  const viewportHeight = window.innerHeight;
  // Высота страницы:
  const pageHeight = document.body.offsetHeight;
  // Текущее положение прокрутки:
  const currentPosition = window.scrollY;

  // Из высоты страницы вычтем высоту экрана,
  // чтобы при прокручивании до самого низа
  // прогресс-бар заполнялся до конца.
  const availableHeight = pageHeight - viewportHeight;

  // Считаем процент «прочитанного» текста:
  const percent = (currentPosition / availableHeight) * 100;

  // Проставляем посчитанное значение
  // в качестве значения для value прогресс-бара:
  progress.value = percent;
}

        
        
          
        
      

Теперь повесим пересчёт на событие прокрутки scroll, а также на событие изменения размеров страницы resize — чтобы следить за изменениями высоты и страницы, и статьи.

        
          
          window.addEventListener("scroll", recalculateProgress);window.addEventListener("resize", recalculateProgress);
          window.addEventListener("scroll", recalculateProgress);
window.addEventListener("resize", recalculateProgress);

        
        
          
        
      

Пишем throttle

Секция статьи "Пишем throttle"

Конкретно в этом примере мы не заметим особой разницы в производительности. В recalculateProgress не выполняется много особо дорогостоящих операций. Мы используем простой пример, чтобы было проще вникнуть в концепцию и не отвлекаться от самого throttle.

Однако мы можем посмотреть, сколько раз функция выполняется в обоих случаях, используя console.log:

большое количество печати в консоль, как результат множества событий

Мы прокрутили совсем немного (около 40–50 пикселей), но функция вызвалась аж 7 раз

С интервалом пропускания в 50 мс, ситуация улучшилась в 2,5 раза (3 события), а с интервалом в 150 мс стало лучше в 3,5 раза (2 события).

Если представить, что при прокрутке мы «много считаем чего-то сложного», то прокрутка начнёт заметно тормозить.

throttle решает эту проблему, «пропуская» некоторые вызовы функции-обработчика. Она будет принимать функцию, которую необходимо «попропускать».

Итак, throttle — это функция высшего порядка, которая будет принимать аргументом функцию, которую надо «попропускать».

        
          
          // Функция throttle будет принимать 2 аргумента:// - callee, функция, которую надо вызывать;// - timeout, интервал в мс, с которым следует пропускать вызовы.function throttle(callee, timeout) {  // Таймер будет определять,  // надо ли нам пропускать текущий вызов.  let timer = null;  // Как результат возвращаем другую функцию.  // Это нужно, чтобы мы могли не менять другие части кода,  // чуть позже мы увидим, как это помогает.  return function perform(...args) {    // Если таймер есть, то функция уже была вызвана,    // и значит новый вызов следует пропустить.    if (timer) return;    // Если таймера нет, значит мы можем вызвать функцию:    timer = setTimeout(() => {      // Аргументы передаём неизменными в функцию-аргумент:      callee(...args);      // По окончании очищаем таймер:      clearTimeout(timer);      timer = null;    }, timeout);  };}
          // Функция throttle будет принимать 2 аргумента:
// - callee, функция, которую надо вызывать;
// - timeout, интервал в мс, с которым следует пропускать вызовы.
function throttle(callee, timeout) {
  // Таймер будет определять,
  // надо ли нам пропускать текущий вызов.
  let timer = null;

  // Как результат возвращаем другую функцию.
  // Это нужно, чтобы мы могли не менять другие части кода,
  // чуть позже мы увидим, как это помогает.
  return function perform(...args) {
    // Если таймер есть, то функция уже была вызвана,
    // и значит новый вызов следует пропустить.
    if (timer) return;

    // Если таймера нет, значит мы можем вызвать функцию:
    timer = setTimeout(() => {
      // Аргументы передаём неизменными в функцию-аргумент:
      callee(...args);

      // По окончании очищаем таймер:
      clearTimeout(timer);
      timer = null;
    }, timeout);
  };
}

        
        
          
        
      

Теперь мы можем использовать его вот так:

        
          
          // Функция, которую мы хотим «пропускать»:function doSomething(arg) {  // ...}doSomething(42);// А вот — та же функция, но обёрнутая в throttle:const throttledDoSomething = throttle(doSomething, 250);// throttledDoSomething — это именно функция,// потому что из throttle мы возвращаем функцию.// throttledDoSomething принимает те же аргументы,// что и doSomething, потому что perform внутри throttle// прокидывает все аргументы без изменения в doSomething,// так что и вызов throttledDoSomething будет таким же,// как и вызов doSomething:throttledDoSomething(42);
          // Функция, которую мы хотим «пропускать»:
function doSomething(arg) {
  // ...
}

doSomething(42);

// А вот — та же функция, но обёрнутая в throttle:
const throttledDoSomething = throttle(doSomething, 250);

// throttledDoSomething — это именно функция,
// потому что из throttle мы возвращаем функцию.

// throttledDoSomething принимает те же аргументы,
// что и doSomething, потому что perform внутри throttle
// прокидывает все аргументы без изменения в doSomething,
// так что и вызов throttledDoSomething будет таким же,
// как и вызов doSomething:
throttledDoSomething(42);

        
        
          
        
      

Применяем throttle

Секция статьи "Применяем throttle"

Теперь мы можем применить throttle для оптимизации обработчика:

        
          
          function throttle(callee, timeout) {  /* ... */}// Указываем, что нам нужно ждать 50 мс,// прежде чем вызвать функцию заново:const optimizedHandler = throttle(recalculateProgress, 50);// Передаём новую throttled-функцию в addEventListener:window.addEventListener("scroll", optimizedHandler);window.addEventListener("resize", optimizedHandler);
          function throttle(callee, timeout) {
  /* ... */
}

// Указываем, что нам нужно ждать 50 мс,
// прежде чем вызвать функцию заново:
const optimizedHandler = throttle(recalculateProgress, 50);

// Передаём новую throttled-функцию в addEventListener:
window.addEventListener("scroll", optimizedHandler);
window.addEventListener("resize", optimizedHandler);

        
        
          
        
      

Обратите внимание, что API функции не поменялось. То есть для внешнего мира throttled-функция ведёт себя точно так же, как и простая функция-обработчик.

Это удобно, потому что меняется лишь одна небольшая часть программы, не затрагивая системы в целом.

Результат

Секция статьи "Результат"

Пример такого прогресс-бара получится таким:

Открыть демо в новой вкладке

На практике

Секция статьи "На практике"