Кратко
СкопированоНа современных сайтах встречаются элементы, которые обновляются по мере прокрутки страницы или при изменении её размеров.
Просто запускать сложные и дорогостоящие операции на события 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()
СкопированоКонкретно в этом примере мы не заметим особой разницы в производительности. В recalculate не выполняется много особо дорогостоящих операций. Мы используем простой пример, чтобы было проще вникнуть в концепцию и не отвлекаться от самого throttle.
Однако мы можем посмотреть, сколько раз функция выполняется в обоих случаях, используя console:

Мы прокрутили совсем немного (около 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) // По окончании сбрасываем таймер: 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)
// По окончании сбрасываем таймер:
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 для оптимизации обработчика:
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-функция ведёт себя точно так же, как и простая функция-обработчик.
Это удобно, потому что меняется лишь одна небольшая часть программы, не затрагивая системы в целом.
Результат
СкопированоПример такого прогресс-бара получится таким:
На практике
Скопированосоветует
СкопированоИспользуйте throttle, когда вам нужно вызывать функцию раз в какое-то количество времени, пропуская вызовы между.
Для некоторых задач лучше подойдёт debounce — например, для строки поиска, которая предлагает варианты запросов.