Debounce и Throttle

Современные сайтоделы вообще не парятся об оптимизации фронтэнда – наваливают туда библиотек типа GSAP и юзают их по самые помидоры, из-за чего их красивые сайты начинают нагревать мобильные устройства, да и ноутбуки. Я такого избегаю всеми правдами и неправдами, и наконец-то ChatGPT подсказал мне для этого идеальное решение. Возьмём простой код, который смотрит прокрутил ли пользователь страницу ниже .hero и если да, то вешает класс на body.

$(window).on('scroll', function() {
     const heroHeight = $('.hero').outerHeight();
     $('body').toggleClass('hero-scrolled', window.scrollY > heroHeight);
});

Довольно распространённая штука для сайтов, которая стреляет десятки раз в секунду при скролле. Как это оптимизировать? Ну, можно попробовать через таймер.

setInterval(() => {
    const heroHeight = $('.hero').outerHeight();
    $('body').toggleClass('hero-scrolled', window.scrollY > heroHeight);
}, 300);

Теперь эвент стреляет всего 3 раза в секунду. Но, он стреляет каждую секунду, не важно крутится скролл или нет. А теперь к оптимальным решениям.

Debounce:

Эта штука откладывает выполнение события, пока не закончится серия действий (например, пока крутится скролл).

const debounce = (func, delay) => {
    let timer;
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => func.apply(this, args), delay);
    };
};

$(window).on('scroll', debounce(() => {
    const heroHeight = $('.hero').outerHeight();
    $('body').toggleClass('hero-scrolled', window.scrollY > heroHeight);
}, 100));

Прелесть тут ещё в том, что эту константу надо задать в своём коде всего раз, а потом её можно использовать для всего – скролл, движение мыши, ресайз окна. Конфликтов не будет, потому что каждое обращение к константе создаёт замкнутую функцию со своим отдельным таймером.

Throttle + Debounce:

Throttle это механизм, который ограничивает частоту вызова функции. Допустим, вы используете только Throttle для отслеживания движения мыши. Задержка означает, что функция начнёт вызываться сразу, как только вы начнёте движение мышью, и дальше она будет вызываться каждые 300 мс (допустим), пока движение продолжается. Но, что если функция выстрелила, ушла на кулдаун на 300мс, а вы после этого двигали курсор ещё 200 мс? Этот последний отрезок не будет обработан. Поэтому механизм Throttle нужно сразу объединять с Debounce.

const throttle = (func, limit) => {
    let lastCall = 0;
    let timeout; // Для debounce

    return function (...args) {
        const now = Date.now();

        // Вызываем функцию сразу, если прошло `limit` мс
        if (now - lastCall >= limit) {
            lastCall = now;
            func.apply(this, args);
        }

        // Дебаунс: вызов в конце серии событий (после `limit` мс простоя)
        clearTimeout(timeout);
        timeout = setTimeout(() => {
            func.apply(this, args);
        }, limit);
    };
};

$(window).on('scroll', throttle(() => {
    const heroHeight = $('.hero').outerHeight();
    $('body').toggleClass('hero-scrolled', window.scrollY > heroHeight);
}, 300));

Throttle + Debounce + Scroll direction

Можно пойти ещё дальше и подумать над тем, как работает скролл. Он работает вверх и вниз – логично?) То есть, наверно будет неправильно, если мы начали крутить скролл вниз, скрипт ушёл на задержку, а мы в этот момент крутанули скроллом вверх и ничего не произошло? Я думаю, смена направления скролла должна отрабатываться без задержки.

const throttleWithDirection = (func, limit) => {
    let lastCall = 0;
    let prevScrollY = window.scrollY;
    let timeout; // Для debounce

    return function (...args) {
        const now = Date.now();
        const currentScrollY = window.scrollY;
        const scrollDown = currentScrollY > prevScrollY;
        prevScrollY = currentScrollY;

        // Выполняем функцию сразу, если направление изменилось или прошло `limit` мс
        if (now - lastCall >= limit || scrollDown !== this.lastDirection) {
            lastCall = now;
            this.lastDirection = scrollDown;
            func.apply(this, args);
        }

        // Дебаунс: вызов в конце серии событий (после `limit` мс простоя)
        clearTimeout(timeout);
        timeout = setTimeout(() => {
            func.apply(this, args);
        }, limit);
    };
};

Можно и на этом не успокоиться и сделать объединённую функцию “для всего”. Теперь она принимает 3 параметра. В третьем параметре нужно передать true, если вы отслеживаете скролл и вам нужно также отслеживать моментальную смену направления.

const throttle = (func, limit, scrollMode = false) => {
    let lastCall = 0;
    let timeout; // Для debounce
    let prevScrollY = window.scrollY; // Для отслеживания направления скролла

    return function (...args) {
        const now = Date.now();
        
        if (scrollMode) {
            const currentScrollY = window.scrollY;
            const scrollDown = currentScrollY > prevScrollY;
            prevScrollY = currentScrollY;

            // Если направление изменилось, вызываем сразу
            if (scrollDown !== this.lastDirection) {
                this.lastDirection = scrollDown;
                func.apply(this, args);
            }
        }

        // Вызываем функцию сразу, если прошло `limit` мс
        if (now - lastCall >= limit) {
            lastCall = now;
            func.apply(this, args);
        }

        // Дебаунс: вызов в конце серии событий (после `limit` мс простоя)
        clearTimeout(timeout);
        timeout = setTimeout(() => {
            func.apply(this, args);
        }, limit);
    };
};