← Курс/Реализуй debounce и throttle#133 из 257+40 XP

Реализуй debounce и throttle

Краткий ответ

**Debounce** откладывает выполнение функции до тех пор, пока не пройдёт N миллисекунд после последнего вызова — идеален для поиска и resize. **Throttle** гарантирует, что функция вызывается не чаще одного раза за N миллисекунд — идеален для scroll и кликов. Оба паттерна реализуются через замыкания и таймеры.

Полный разбор

Debounce — «подождать тишины»

Представь строку поиска: пользователь печатает «iPhone 15 Pro». Без debounce будет 13 запросов к API. С debounce — только один, когда пользователь остановился.

function debounce(fn, delay) {
  let timerId = null

  return function(...args) {
    // Отменяем предыдущий таймер при каждом вызове
    clearTimeout(timerId)

    // Запускаем новый — выполнится только если не будет новых вызовов
    timerId = setTimeout(() => {
      fn.apply(this, args)
      timerId = null
    }, delay)
  }
}

// Использование
const search = debounce((query) => {
  console.log('Запрос к API:', query)
}, 300)

search('i')       // сброс, новый таймер
search('ip')      // сброс, новый таймер
search('iph')     // сброс, новый таймер
// через 300ms: 'Запрос к API: iph'

Throttle — «не чаще раза в N ms»

Scroll-handler срабатывает 100+ раз в секунду. Throttle ограничивает до 1 раза в 100ms.

function throttle(fn, interval) {
  let lastCallTime = 0

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

    if (now - lastCallTime >= interval) {
      lastCallTime = now
      fn.apply(this, args)
    }
  }
}

// Trailing edge throttle (выполнять и после последнего вызова):
function throttleWithTrailing(fn, interval) {
  let lastCallTime = 0
  let timerId = null

  return function(...args) {
    const now = Date.now()
    const remaining = interval - (now - lastCallTime)

    if (remaining <= 0) {
      clearTimeout(timerId)
      timerId = null
      lastCallTime = now
      fn.apply(this, args)
    } else if (!timerId) {
      // Запланировать вызов в конце интервала
      timerId = setTimeout(() => {
        lastCallTime = Date.now()
        timerId = null
        fn.apply(this, args)
      }, remaining)
    }
  }
}

Cancellable debounce

function debounce(fn, delay) {
  let timerId = null

  function debounced(...args) {
    clearTimeout(timerId)
    timerId = setTimeout(() => {
      fn.apply(this, args)
      timerId = null
    }, delay)
  }

  // Метод отмены
  debounced.cancel = function() {
    clearTimeout(timerId)
    timerId = null
  }

  // Немедленный вызов (flush)
  debounced.flush = function() {
    if (timerId !== null) {
      clearTimeout(timerId)
      timerId = null
      fn()
    }
  }

  return debounced
}

Leading edge debounce

По умолчанию debounce — trailing (вызов после паузы). Leading — вызов сразу, потом пауза:

function debounce(fn, delay, { leading = false, trailing = true } = {}) {
  let timerId = null
  let leadingCalled = false

  return function(...args) {
    if (leading && !timerId && !leadingCalled) {
      fn.apply(this, args)
      leadingCalled = true
    }

    clearTimeout(timerId)

    timerId = setTimeout(() => {
      if (trailing && leadingCalled) {
        // не вызываем trailing если leading уже вызвал
      } else if (trailing) {
        fn.apply(this, args)
      }
      timerId = null
      leadingCalled = false
    }, delay)
  }
}

Ключевые различия

| Критерий | Debounce | Throttle |

|----------|----------|----------|

| Принцип | Ждёт паузу N ms | Не чаще раза в N ms |

| Когда вызывать | После окончания ввода | При непрерывных событиях |

| Примеры | Поиск, resize | Scroll, mousemove, кнопка |

| Количество вызовов | 1 (в конце) | Равномерно за период |

Связанные уроки курса

  • setTimeout и setInterval — таймеры, которые лежат в основе обоих паттернов
  • Замыкания — debounce и throttle возвращают замыкания, хранящие timerId и lastCallTime
  • Как отвечать на собеседовании

    Начни с объяснения разницы на бытовом примере: «debounce — как лифт, который ждёт, пока все зайдут; throttle — как светофор, переключающийся строго по времени». Затем напиши реализацию с нуля. Упомяни leading/trailing edge и cancellable версию — это покажет глубину понимания. Обязательно скажи про конкретные use cases: debounce для поиска, throttle для scroll.

    Красные флаги ответа

  • Путаница между debounce и throttle или незнание разницы — базовое требование для любого JS-разработчика
  • Реализация только через setTimeout без clearTimeout — функция будет вызываться многократно, не будет работать debounce
  • Незнание про this и args — функция теряет контекст вызова, что критично для методов объектов
  • Примеры

    Реализация debounce и throttle с тестированием на симулированных вызовах

    // ===== DEBOUNCE =====
    function debounce(fn, delay) {
      let timerId = null
    
      function debounced(...args) {
        clearTimeout(timerId)
        timerId = setTimeout(() => {
          fn.apply(this, args)
          timerId = null
        }, delay)
      }
    
      debounced.cancel = function() {
        clearTimeout(timerId)
        timerId = null
      }
    
      return debounced
    }
    
    // ===== THROTTLE =====
    function throttle(fn, interval) {
      let lastCallTime = 0
      let timerId = null
    
      return function(...args) {
        const now = Date.now()
        const remaining = interval - (now - lastCallTime)
    
        if (remaining <= 0) {
          if (timerId) {
            clearTimeout(timerId)
            timerId = null
          }
          lastCallTime = now
          fn.apply(this, args)
        } else if (!timerId) {
          timerId = setTimeout(() => {
            lastCallTime = Date.now()
            timerId = null
            fn.apply(this, args)
          }, remaining)
        }
      }
    }
    
    // ===== ТЕСТИРОВАНИЕ =====
    
    // Тест debounce: симулируем быструю печать
    console.log('=== Debounce тест ===')
    const callLog = []
    
    const debouncedFn = debounce((val) => {
      callLog.push({ type: 'debounce', val, time: Date.now() })
      console.log('Debounce вызван с:', val)
    }, 100)
    
    // Быстрые вызовы — должен выполниться только последний
    const startTime = Date.now()
    debouncedFn('a')   // отменяется
    debouncedFn('ab')  // отменяется
    debouncedFn('abc') // выполнится через 100ms
    
    setTimeout(() => {
      console.log('После паузы вызовов:', callLog.length, '(ожидаем 1)')
    }, 200)
    
    // Тест throttle: симулируем scroll
    console.log('\n=== Throttle тест ===')
    const throttleLog = []
    
    const throttledFn = throttle((pos) => {
      throttleLog.push(pos)
      console.log('Throttle вызван, позиция:', pos)
    }, 100)
    
    // Вызовы каждые 20ms в течение 300ms
    let callCount = 0
    const throttleInterval = setInterval(() => {
      callCount++
      throttledFn(callCount * 20)
      if (callCount >= 15) {
        clearInterval(throttleInterval)
        setTimeout(() => {
          console.log('Всего событий:', 15, '| Вызовов функции:', throttleLog.length, '(ожидаем ~3-4)')
        }, 150)
      }
    }, 20)
    
    // ===== СРАВНЕНИЕ ПОВЕДЕНИЯ =====
    console.log('\n=== Разница на практике ===')
    console.log('debounce: поиск - запрос к API только после паузы ввода')
    console.log('throttle: scroll - обновление UI не чаще 1 раза в 100ms')
    
    // Демо: замер производительности с мемоизацией
    function expensiveCalc(n) {
      // имитация тяжёлых вычислений
      let result = 0
      for (let i = 0; i < n; i++) result += i
      return result
    }
    
    const throttledCalc = throttle((n) => {
      const result = expensiveCalc(n)
      console.log('Результат вычислений:', result)
    }, 50)
    
    throttledCalc(1000)  // выполнится сразу
    throttledCalc(2000)  // пропустится (в рамках 50ms)
    throttledCalc(3000)  // пропустится
    setTimeout(() => throttledCalc(4000), 60)  // выполнится (прошло 60ms > 50ms)