Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM

Throttling i Debouncing

4 min czytania ·

Czasami wydarzenia występują zbyt często (np. scroll, resize, mousemove). Używamy technik throttling i debouncing, żeby ograniczyć częstotliwość wykonywania funkcji.

Weźmy wyszukiwarkę gatunków w centrum kontroli. Każdy znak wpisany w pole wysyła wydarzenie input, więc słowo "Velociraptor" to dwanaście zapytań do bazy laboratorium, choć interesuje nas tylko ostatnie. Przewijanie strony potrafi zgłosić scroll wiele razy na sekundę. "Nie każdy szelest w krzakach wymaga alarmu," mówi Ray Arnold. "Trzeba wiedzieć, kiedy zareagować."

Implementacja technik optymalizacyjnych

Obie techniki to funkcje, które przyjmują Twoją funkcję i zwracają jej ostrożniejszą wersję. Zacznijmy od debouncingu, który czeka na ciszę jak raptor czający się w trawie: atakuje dopiero wtedy, gdy ofiara znieruchomieje.

// Debouncing - wykonuje funkcję dopiero po określonym czasie bez nowych wywołań
function debounce(func, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => func.apply(this, args), delay);
  };
}

Zmienna timeoutId żyje w domknięciu, więc pamięta zegar między kolejnymi wywołaniami. Każde wywołanie kasuje poprzedni zegar przez clearTimeout() i nastawia nowy. Funkcja wykona się dopiero wtedy, gdy przez delay milisekund nie przyjdzie żadne nowe wywołanie. Zapis func.apply(this, args) przekazuje dalej zarówno this, jak i wszystkie argumenty zebrane przez ...args.

Throttling działa inaczej. Przypomina harmonogram karmienia T-Rexa: nieważne, jak często ryczy, porcję dostaje najwyżej raz na określony czas.

// Throttling - ogranicza częstotliwość wykonywania funkcji
function throttle(func, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

Pierwsze wywołanie wykonuje się od razu i podnosi flagę inThrottle. Kolejne wywołania w czasie limit są po prostu odrzucane, a nie odkładane na później. Po upływie limitu flaga opada i następne wywołanie znów przechodzi. Uwaga na konsekwencję tej wersji: ostatnie wywołanie z serii może przepaść, więc po zakończeniu przewijania funkcja nie zobaczy końcowej pozycji.

Debouncing w wyszukiwarce

Teraz podłączamy debouncing do pola wyszukiwania dinozaurów.

// Użycie debouncing dla wyszukiwania
const searchInput = document.getElementById('dino-search');
const debouncedSearch = debounce((query) => {
  console.log(`Wyszukiwanie dinozaurów: ${query}`);
  searchDinosaurs(query);
}, 300);

searchInput.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

Gdy szybko wpiszesz "Rex", listener wywoła debouncedSearch() trzy razy, ale wyszukiwanie ruszy raz, 300 milisekund po ostatnim znaku. Samo wydarzenie input nadal odpala przy każdym znaku, zmieniamy tylko to, jak często uruchamiamy kosztowną pracę. Sprawdź się: wpisujesz "T", po 200 milisekundach "R" i przestajesz pisać. Ile razy ruszy wyszukiwanie? Raz, dla "TR", bo przerwa 200 milisekund była za krótka, żeby pierwszy zegar zdążył zadziałać.

Tę samą technikę da się zapisać bez funkcji pomocniczej, z jedną zmienną na zegar.

let timer;
searchInput.addEventListener('input', () => {
  clearTimeout(timer);
  timer = setTimeout(() => searchDinosaurs(searchInput.value), 300);
});

To dokładnie ten sam mechanizm: kasujemy stary zegar i nastawiamy nowy. Funkcja debounce() po prostu zamyka go w opakowaniu wielokrotnego użytku, które możesz nałożyć na dowolną funkcję.

Throttling przy przewijaniu

Przy przewijaniu chcemy czegoś innego. Pasek postępu ma się aktualizować regularnie w trakcie ruchu, a nie dopiero po jego zakończeniu.

// Użycie throttling dla scroll
const throttledScroll = throttle(() => {
  const scrollPosition = window.scrollY;
  updateNavigationHighlight(scrollPosition);
  updateProgressBar(scrollPosition);
}, 100);

window.addEventListener('scroll', throttledScroll);

Nawet jeśli przeglądarka zgłosi scroll wiele razy, pozycja z window.scrollY trafi do nawigacji najwyżej raz na 100 milisekund, czyli przy sekundzie przewijania około dziesięciu razy. Strona wciąż przewija się płynnie, bo ograniczamy tylko naszą funkcję, a nie samo przewijanie.

Którą technikę wybrać?

Debouncing wybieraj, gdy liczy się tylko wartość końcowa: wyszukiwanie, walidacja po skończonym pisaniu, zapis szkicu czy przeliczenie układu po zakończonej zmianie rozmiaru okna. Throttling wybieraj, gdy potrzebujesz regularnych aktualizacji w trakcie ciągłej akcji: postęp przewijania, śledzenie kursora podczas mousemove, pozycja przeciąganego elementu.

Moja rada: w projektach produkcyjnych sięgaj po sprawdzone implementacje, na przykład debounce i throttle z biblioteki lodash, które obsługują też wywołanie końcowe. Własne wersje napisz jednak przynajmniej raz, bo dopiero wtedy naprawdę rozumiesz, co dzieje się z zegarem. W piaskownicy poniżej zobaczysz obie techniki obok siebie i porównasz liczbę wywołań. W lekcji o wydajności poznasz kolejne narzędzia do okiełznania częstych wydarzeń.

Pamiętaj: debouncing to raptor, który czeka na ciszę, a throttling to harmonogram karmienia, który nie pozwala nakarmić T-Rexa częściej niż trzeba.

Kod do tej lekcji: index.html
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Throttling i Debouncing</title>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
            margin: 0;
            padding: 20px;
            color: white;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
        }

        h1 {
            text-align: center;
            margin-bottom: 40px;
        }

        .demo-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
            gap: 30px;
        }

        .demo-box {
            background: rgba(0, 0, 0, 0.7);
            border-radius: 15px;
            padding: 25px;
            border: 2px solid #34495e;
        }

        h2 {
            color: #3498db;
            margin-bottom: 15px;
            font-size: 20px;
        }

        .description {
            color: #bdc3c7;
            font-size: 14px;
            margin-bottom: 20px;
            line-height: 1.5;
        }

        input[type="text"], textarea {
            width: 100%;
            padding: 12px;
            border: 2px solid #34495e;
            border-radius: 8px;
            background: rgba(255, 255, 255, 0.1);
            color: white;
            font-size: 16px;
            margin-bottom: 10px;
        }

        input:focus, textarea:focus {
            outline: none;
            border-color: #3498db;
        }

        .scroll-area {
            height: 200px;
            overflow-y: auto;
            background: rgba(255, 255, 255, 0.05);
            border-radius: 8px;
            padding: 15px;
            margin-bottom: 15px;
        }

        .scroll-content {
            height: 800px;
        }

        .counter-display {
            background: rgba(52, 152, 219, 0.2);
            border: 1px solid #3498db;
            border-radius: 8px;
            padding: 15px;
            text-align: center;
            margin-bottom: 15px;
        }

        .counter-number {
            font-size: 48px;
            font-weight: bold;
            color: #3498db;
        }

        .counter-label {
            font-size: 14px;
            color: #bdc3c7;
            margin-top: 5px;
        }

        .comparison-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 10px;
            margin-bottom: 15px;
        }

        .comparison-box {
            background: rgba(255, 255, 255, 0.05);
            border-radius: 8px;
            padding: 12px;
            text-align: center;
        }

        .comparison-number {
            font-size: 32px;
            font-weight: bold;
            color: #e74c3c;
        }

        .comparison-number.throttled {
            color: #27ae60;
        }

        .log {
            background: #000;
            border-radius: 8px;
            padding: 12px;
            max-height: 150px;
            overflow-y: auto;
            font-family: 'Courier New', monospace;
            font-size: 11px;
        }

        .log-entry {
            padding: 2px 0;
            color: #95a5a6;
        }

        .log-entry.throttled {
            color: #27ae60;
        }

        .log-entry.debounced {
            color: #3498db;
        }

        .info-tag {
            display: inline-block;
            padding: 4px 10px;
            border-radius: 12px;
            font-size: 12px;
            font-weight: bold;
            margin-bottom: 10px;
        }

        .tag-throttle {
            background: #27ae60;
            color: white;
        }

        .tag-debounce {
            background: #3498db;
            color: white;
        }

        .reset-btn {
            width: 100%;
            padding: 10px;
            background: #7f8c8d;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-weight: bold;
        }

        .reset-btn:hover {
            background: #95a5a6;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Throttling vs Debouncing w Parku Jurajskim</h1>

        <div class="demo-grid">
            <!-- Demo 1: Search z Debouncing -->
            <div class="demo-box">
                <div class="info-tag tag-debounce">DEBOUNCING</div>
                <h2>1. Wyszukiwarka Dinozaurów</h2>
                <p class="description">
                    Wyszukiwanie rozpoczyna się dopiero po 500ms od ostatniego naciśnięcia klawisza. Oszczędza to wywołania API.
                </p>

                <input type="text" id="search-input" placeholder="Wpisz nazwę dinozaura...">

                <div class="counter-display">
                    <div class="counter-number" id="search-count">0</div>
                    <div class="counter-label">Wykonanych wyszukiwań</div>
                </div>

                <div class="log" id="search-log"></div>
            </div>

            <!-- Demo 2: Scroll z Throttling -->
            <div class="demo-box">
                <div class="info-tag tag-throttle">THROTTLING</div>
                <h2>2. Monitorowanie Pozycji</h2>
                <p class="description">
                    Pozycja jest sprawdzana maksymalnie co 200ms podczas scrollowania. Zwiększa wydajność.
                </p>

                <div class="scroll-area" id="scroll-area">
                    <div class="scroll-content">
                        <p>Przewiń w dół, aby zobaczyć throttling w akcji!</p>
                        <p style="margin-top: 100px;">Przewijaj dalej...</p>
                        <p style="margin-top: 100px;">Jeszcze trochę...</p>
                        <p style="margin-top: 100px;">Dalej!</p>
                        <p style="margin-top: 100px;">Koniec!</p>
                    </div>
                </div>

                <div class="counter-display">
                    <div class="counter-number" id="scroll-count">0</div>
                    <div class="counter-label">Sprawdzeń pozycji (throttled)</div>
                </div>

                <div class="log" id="scroll-log"></div>
            </div>

            <!-- Demo 3: Porównanie bez/z optymalizacją -->
            <div class="demo-box">
                <h2>3. Porównanie: Input bez/z Debouncing</h2>
                <p class="description">
                    Wpisz coś, aby zobaczyć różnicę między normalnym handlerem a debounced.
                </p>

                <input type="text" id="comparison-input" placeholder="Testuj różnicę...">

                <div class="comparison-grid">
                    <div class="comparison-box">
                        <div class="comparison-number" id="normal-count">0</div>
                        <div class="counter-label">Normalne wywołania</div>
                    </div>
                    <div class="comparison-box">
                        <div class="comparison-number throttled" id="debounced-count">0</div>
                        <div class="counter-label">Debounced (300ms)</div>
                    </div>
                </div>

                <div class="log" id="comparison-log"></div>
            </div>

            <!-- Demo 4: Resize z Throttling -->
            <div class="demo-box">
                <div class="info-tag tag-throttle">THROTTLING</div>
                <h2>4. Textarea Resize Handler</h2>
                <p class="description">
                    Przy zmianie rozmiaru, funkcja jest wywoływana maksymalnie co 250ms.
                </p>

                <textarea id="resize-textarea" rows="4" placeholder="Zmień rozmiar okna przeglądarki..."></textarea>

                <div class="comparison-grid">
                    <div class="comparison-box">
                        <div class="comparison-number" id="resize-normal">0</div>
                        <div class="counter-label">Normalne resize</div>
                    </div>
                    <div class="comparison-box">
                        <div class="comparison-number throttled" id="resize-throttled">0</div>
                        <div class="counter-label">Throttled (250ms)</div>
                    </div>
                </div>

                <button class="reset-btn" id="reset-resize">Reset Liczników</button>
            </div>
        </div>
    </div>

    <script>
        console.log('System optymalizacji Event Handlers - START');

        // IMPLEMENTACJA DEBOUNCE
        function debounce(func, delay) {
            let timeoutId;
            return function(...args) {
                clearTimeout(timeoutId);
                timeoutId = setTimeout(() => {
                    func.apply(this, args);
                }, delay);
            };
        }

        // IMPLEMENTACJA THROTTLE
        function throttle(func, limit) {
            let inThrottle;
            return function(...args) {
                if (!inThrottle) {
                    func.apply(this, args);
                    inThrottle = true;
                    setTimeout(() => inThrottle = false, limit);
                }
            };
        }

        // DEMO 1: Search z Debouncing
        const searchInput = document.getElementById('search-input');
        const searchCount = document.getElementById('search-count');
        const searchLog = document.getElementById('search-log');
        let searchCounter = 0;

        function logSearch(message, type = '') {
            const entry = document.createElement('div');
            entry.className = `log-entry ${type}`;
            entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
            searchLog.insertBefore(entry, searchLog.firstChild);
            if (searchLog.children.length > 10) {
                searchLog.removeChild(searchLog.lastChild);
            }
        }

        function performSearch(query) {
            searchCounter++;
            searchCount.textContent = searchCounter;
            logSearch(`Wyszukiwanie: "${query}"`, 'debounced');
            console.log(`API Call: Wyszukiwanie dinozaura "${query}"`);
        }

        const debouncedSearch = debounce(performSearch, 500);

        searchInput.addEventListener('input', function(e) {
            const query = e.target.value;
            logSearch(`⌨Wpisano: "${query}"`);
            debouncedSearch(query);
        });

        // DEMO 2: Scroll z Throttling
        const scrollArea = document.getElementById('scroll-area');
        const scrollCount = document.getElementById('scroll-count');
        const scrollLog = document.getElementById('scroll-log');
        let scrollCounter = 0;

        function logScroll(message, type = '') {
            const entry = document.createElement('div');
            entry.className = `log-entry ${type}`;
            entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
            scrollLog.insertBefore(entry, scrollLog.firstChild);
            if (scrollLog.children.length > 8) {
                scrollLog.removeChild(scrollLog.lastChild);
            }
        }

        function checkScrollPosition() {
            scrollCounter++;
            scrollCount.textContent = scrollCounter;
            const position = scrollArea.scrollTop;
            const percentage = Math.round((position / (scrollArea.scrollHeight - scrollArea.clientHeight)) * 100);
            logScroll(`Pozycja: ${percentage}%`, 'throttled');
            console.log(`Scroll position: ${percentage}%`);
        }

        const throttledScroll = throttle(checkScrollPosition, 200);
        scrollArea.addEventListener('scroll', throttledScroll);

        // DEMO 3: Porównanie Input
        const comparisonInput = document.getElementById('comparison-input');
        const normalCount = document.getElementById('normal-count');
        const debouncedCountDisplay = document.getElementById('debounced-count');
        const comparisonLog = document.getElementById('comparison-log');
        let normalCounter = 0;
        let debouncedCounter = 0;

        function logComparison(message, type = '') {
            const entry = document.createElement('div');
            entry.className = `log-entry ${type}`;
            entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
            comparisonLog.insertBefore(entry, comparisonLog.firstChild);
            if (comparisonLog.children.length > 8) {
                comparisonLog.removeChild(comparisonLog.lastChild);
            }
        }

        function normalHandler() {
            normalCounter++;
            normalCount.textContent = normalCounter;
            logComparison('Normalne wywołanie');
        }

        function debouncedHandler() {
            debouncedCounter++;
            debouncedCountDisplay.textContent = debouncedCounter;
            logComparison('Debounced wywołanie', 'debounced');
        }

        const debouncedCompare = debounce(debouncedHandler, 300);

        comparisonInput.addEventListener('input', function() {
            normalHandler();
            debouncedCompare();
        });

        // DEMO 4: Resize z Throttling
        const resizeTextarea = document.getElementById('resize-textarea');
        const resizeNormal = document.getElementById('resize-normal');
        const resizeThrottled = document.getElementById('resize-throttled');
        const resetResizeBtn = document.getElementById('reset-resize');
        let resizeNormalCounter = 0;
        let resizeThrottledCounter = 0;

        function handleResize() {
            resizeNormalCounter++;
            resizeNormal.textContent = resizeNormalCounter;
            const width = window.innerWidth;
            const height = window.innerHeight;
            resizeTextarea.value = `Rozmiar okna: ${width}x${height}px\nNormalne wywołania: ${resizeNormalCounter}`;
        }

        function handleResizeThrottled() {
            resizeThrottledCounter++;
            resizeThrottled.textContent = resizeThrottledCounter;
            console.log(`Throttled resize: ${window.innerWidth}x${window.innerHeight}`);
        }

        const throttledResize = throttle(handleResizeThrottled, 250);

        window.addEventListener('resize', function() {
            handleResize();
            throttledResize();
        });

        resetResizeBtn.addEventListener('click', function() {
            resizeNormalCounter = 0;
            resizeThrottledCounter = 0;
            resizeNormal.textContent = '0';
            resizeThrottled.textContent = '0';
            resizeTextarea.value = '';
            console.log('Liczniki resize zresetowane');
        });

        // Inicjalizacja
        logSearch('System wyszukiwania gotowy', 'debounced');
        logScroll('System monitoringu gotowy', 'throttled');
        logComparison('System porównania gotowy');

        console.log('Wszystkie systemy optymalizacji aktywne');
        console.log('Debounce: Opóźnia wykonanie do momentu ciszy');
        console.log('Throttle: Ogranicza częstotliwość wykonania');
    </script>
</body>
</html>

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co robi technika debounce zastosowana do zdarzenia input?

  2. 2. Które zdarzenie najlepiej ograniczyć techniką throttle, aby uruchamiać funkcję np. co 100 ms?

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż wywołanie funkcji debounce (jedna linia, od lewej):

Przydatne artykuły