skip to main content

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

js

Кратко 🔗

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

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

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

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

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

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

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

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

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

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

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

Пишем 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)
}
}

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

// Функция, которую мы хотим «пропускать»:
function doSomething(arg) {
// ...
}

doSomething(42)

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

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

// throttledDoSomething принимает те же аргументы,
// что и doSomenthing, потому что 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)

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

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

Результат 🔗

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

See the Pen doka-throttle-read-percent by Alexander Bespoyasov (@bespoyasov) on CodePen.

В работе 🔗

Используйте throttle, когда вам нужно вызывать функцию раз в какое-то количество времени, пропуская вызовы между.

Для некоторых задач лучше подойдёт debounce — например, для строки поиска, которая предлагает варианты запросов.