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