디바운스 기능에 대해

일반적으로 API 호출을 처리하기 위해 디바운싱이나 스로틀링을 처리하는 경우가 많지만 이 글에서는 디바운싱 기능을 간략하게 분석하고 정리합니다.

디바운싱 및 제한은 일반적으로 여러 이벤트를 묶고 동시에 처리하는 데 사용됩니다.

그 이하에서 디바운스(debounce)는 사용자 이벤트를 일정한 간격으로 그룹화하여 하나의 입력만 처리하는 기술을 의미합니다.

스로틀링의 경우 일정 시간 동안 첫 번째 입력만 실행하고 나머지 기간 동안 입력을 무시하는 기술을 말합니다.

JavaScript에서 디바운스 함수를 간단하게 구현하면 일반적으로 이와 같은 코드가 생성됩니다.

function debounce(func, wait) {
  let debounceId = null;

  const debounced = (...args) => {
    if (debounceId !== null) clearTimeout(debounceId);
    debounceId = setTimeout(() => func(...args), wait);
  };

  return debounced;
}

디바운스할 함수(func)와 기다릴 시간(wait)을 인수로 받아들입니다.

디바운스된 함수는 지정된 시간 후에 실행되며, 그 이전에 동일한 이벤트가 발생하면 이전 대기 이벤트를 재설정하고 setTimeout 및 clearTimeout을 통해 디바운스 함수를 등록하는 논리를 간단히 구현한 함수를 반환합니다.

사실 특별한 것은 없습니다. 그러나 React와 같은 스파의 경우 보통 컴포넌트 렌더링이라는 프로세스를 거치며 컴포넌트는 라이프사이클에 따라 지속적으로 업데이트됩니다. 그래서 컴포넌트 내부에서 디바운싱할 때 이벤트가 발생할 때마다 디바운싱 로직이 초기화되는 경우가 있기 때문에 컴포넌트 외부에서 컨트롤 컴포넌트 내부에서 이벤트를 디바운싱하려면 라이브러리나 프레임워크에서 제공하는 라이프사이클 관련 함수를 사용해야 한다.

* React의 경우 useCallback, useEffect와 같은 hook으로 debounce 기능 구현이 가능합니다.

구현 방법과 주의해야 할 사항에 대해 간단히 살펴보았습니다. 디바운스 기능 자체는 그리 복잡하지 않기 때문에 구현도 그리 어렵지 않습니다. 그렇다면 Lodash에서 일반적으로 사용되는 디바운스 함수의 구조는 무엇일까요?


function debounce(func, wait, options) {
  let lastArgs,
    lastThis,
    maxWait,
    result,
    timerId,
    lastCallTime

  let lastInvokeTime = 0
  let leading = false
  let maxing = false
  let trailing = true

  // Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
  const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  wait = +wait || 0
  if (isObject(options)) {
    leading = !!options.leading
    maxing = 'maxWait' in options
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }
  // ...
  }

첫째, 디바운스할 함수를 의미하는 func, 디바운스 간격을 의미하는 wait, 추가 옵션을 설정할 수 있는 option의 세 가지 인수를 취하는 구조입니다.

내부적으로 func 인수가 함수가 아니면 오류가 발생하고 options 인수를 확인하여 maxwait 인수로 디바운싱 또는 디바운싱의 선행 또는 후행 입력을 처리할지 여부를 결정합니다. 지연될 수 있습니다.

디바운스 함수가 ​​결과를 반환하기 위한 result, 디바운스 등록된 함수가 인수를 받기 위한 준비를 위한 lastArgs, 방금 디바운스된 이벤트를 식별하기 위한 timerId와 같은 지역 변수는 클로저 메서드에서 선언됩니다.

약간 특이한 점은 requestAnimationFrame을 지원하기 위해 useRAF라는 인수가 있다는 것입니다.

// Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
  const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')

기본적으로 Lodash에서 디바운스의 경우 setTimeout API를 통하지 않고 requestAnimationFrame이라는 API를 통해 처리하는데 내장 API의 경우 브라우저 리페인트보다 빠르게 실행되기 때문에, 케이스는 setTimeout이나 setInterval보다 약간 더 정밀한 프레임과 이벤트 루프 구조의 다른 비동기 요소로, API보다 먼저 실행되는 이점이 있습니다.(루트는 전역 전용)

따라서 RAF를 사용할 수 있는 경우 디바운스 논리는 RAF에서 처리하고 그렇지 않으면 setTimeout에서 처리합니다.

  function startTimer(pendingFunc, wait) {
    if (useRAF) {
      root.cancelAnimationFrame(timerId)
      return root.requestAnimationFrame(pendingFunc)
    }
    return setTimeout(pendingFunc, wait)
  }

  function cancelTimer(id) {
    if (useRAF) {
      return root.cancelAnimationFrame(id)
    }
    clearTimeout(id)
  }

디바운싱을 위해 등록된 함수의 실행은 첫 번째 입력을 처리하는지 또는 마지막 입력을 처리하는지에 따라 분기되며 invokeFunc라는 함수는 디바운스된 함수를 호출합니다.

  function trailingEdge(time) {
    timerId = undefined

    // Only invoke if we have `lastArgs` which means `func` has been
    // debounced at least once.
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }
    function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time
    // Start the timer for the trailing edge.
    timerId = startTimer(timerExpired, wait)
    // Invoke the leading edge.
    return leading ? invokeFunc(time) : result
  }
  function invokeFunc(time) {
    const args = lastArgs
    const thisArg = lastThis

    lastArgs = lastThis = undefined
    lastInvokeTime = time
    result = func.apply(thisArg, args)
    return result
  }

약간의 특이점이 있다면 RAF의 경우 setInterval과 마찬가지로 RAF 이벤트 루프가 중복(setInterval)된다.

별도로 시작하면 이전 루프를 끊기 위한 프로세스가 추가되며, setTimeout을 사용하는 경우 디바운싱된 이벤트는 이벤트가 실행되는 동안 그대로 조건을 고려하여 디바운싱 기능만 실제로 실행되지 않도록 관리한다. 타임아웃. 이는 아마도 성능이나 이벤트 관리 측면에서 태스크에 이미 등록된 함수를 삭제하는 것보다 이벤트 루프를 건드리지 않는 것이 낫기 때문일 것입니다.

실제로 Invoke 함수와 관련된 로직은 RAF 및 루프로 이동하는 timerExpired 함수와 실제 함수를 호출할지 여부를 결정하는 shouldInvoke 함수의 두 가지 함수로 구성됩니다.

  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime

    // Either this is the first call, activity has stopped and we're at the
    // trailing edge, the system time has gone backwards and we're treating
    // it as the trailing edge, or we've hit the `maxWait` limit.
    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
  }

  function timerExpired() {
    const time = Date.now()
    if (shouldInvoke(time)) {
      return trailingEdge(time)
    }
    // Restart the timer.
    timerId = startTimer(timerExpired, remainingWait(time))
  }

또한 설정과 상관없이 디바운스된 함수의 입력을 즉시 실행할 수 있는 Flush 함수, 현재 입력이 디바운스되고 있는지 확인할 수 있는 Pending 함수, 입력을 중지할 수 있는 Cancel 함수라는 내장 함수가 있습니다. 디 바운싱 입력이 중단됩니다.

  function cancel() {
    if (timerId !== undefined) {
      cancelTimer(timerId)
    }
    lastInvokeTime = 0
    lastArgs = lastCallTime = lastThis = timerId = undefined
  }

  function flush() {
    return timerId === undefined ? result : trailingEdge(Date.now())
  }

  function pending() {
    return timerId !== undefined
  }

위의 함수와 실제로 사용자 입력을 요구하는 디바운스 함수의 경우 다음과 같은 구조를 제공합니다.

 function debounced(...args) {
    const time = Date.now()
    const isInvoking = shouldInvoke(time)

    lastArgs = args
    lastThis = this
    lastCallTime = time

    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime)
      }
      if (maxing) {
        // Handle invocations in a tight loop.
        timerId = startTimer(timerExpired, wait)
        return invokeFunc(lastCallTime)
      }
    }
    if (timerId === undefined) {
      timerId = startTimer(timerExpired, wait)
    }
    return result
  }
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending

단순한 기능을 담당하는 기능이지만 많은 부분에서 디테일하게 고려되었음을 알 수 있습니다.

https://github.com/lodash/lodash/blob/master/debounce.js

GitHub – lodash/lodash: 모듈성, 성능 및 추가 기능을 제공하는 최신 JavaScript 유틸리티 라이브러리입니다.

모듈성, 성능 및 추가 기능을 제공하는 최신 JavaScript 유틸리티 라이브러리입니다. – GitHub – lodash/lodash: 모듈성, 성능 및 추가 기능을 제공하는 최신 JavaScript 유틸리티 라이브러리입니다.

github.com