보통 API 콜을 처리하기 위해서 디바운스나 쓰로틀링을 처리하는 경우가 많은데 이글은 그 중 디바운스 함수에 대해서 간단히 분석하고 정리 한 내용이다.
디바운스와 쓰로틀링은 공통적으로 여러 이벤트를 묶어서 한번에 처리하는 방법이다.
그중 디바운스의 경우 사용자 이벤트를 일정한 간격으로 그룹화해서 그중 한번의 입력만을 처리하는 기법을 말하며
쓰로틀링의 경우 일정기간동안 첫 한번의 입력만 수행하고 나머지 기간동안 입력을 무시하는 기법을 말한다.
자바스크립트에서 디바운스 기능을 간단히 구현해보면 보통 이런 코드가 나온다.
function debounce(func, wait) {
let debounceId = null;
const debounced = (...args) => {
if (debounceId !== null) clearTimeout(debounceId);
debounceId = setTimeout(() => func(...args), wait);
};
return debounced;
}
인자로 디바운스 시킬 함수(func)와 기다릴 시간(wait)을 받아주고
일정시간 이후에 디바운스 된 함수를 실행시키고 그전에 동일한 이벤트가 발생하면 기존에 wait 중인 이벤트를 리셋하고 다시 디바운스 함수를 등록하는 로직을setTimeout과 clearTimeout을 통해서 간단히 구현한 함수를 반환해준다.
사실 특별한 내용은 없다. 다만 리액트 등의 spa의 경우 보통 컴포넌트 렌더링이라는 과정을 거치며 컴포넌트를 라이프 사이클에 따라 계속 갱신하기에 컴포넌트 내부에서 디바운스 시킬 경우 이벤트가 발생할때 마다 디바운스 로직이 초기화되는 경우가 발생하는데 그래서 컴포넌트 외부에서 디바운스 시켜야하며 제어 컴포넌트 내부의 이벤트를 디바운스 시키고자 한다면 라이브러리나 프레임워크에서 제공하는 라이프사이클 관련된 기능들을 이용해야한다.
* 리액트의 경우 useCallback, useEffect등 훅을 이용해서 디바운스 기능 구현이 가능하다.
간단하게 구현 하는 방법이나 주의점에 대해서 간단히 알아보았다. 디바운스 기능 자체가 그렇게 복잡한 기능은 아니다 보니 구현하기 그렇게 어렵지는 않다. 그렇다면 보통 많이 사용되는 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이라는 3가지 arguments를 받는 구조로 되어 있다.
내부적으로는 간단히 func 인자가 함수가 아닐시 에러를 출력하고 옵션 인자를 확인해서 디바운싱의 가장 처음(leading) 입력을 처리할지 마지막(trailing) 입력을 처리할지 설정하거나, maxwait라는 인자로 디바운싱이 지연될 수 있는 최대한의 시간제한을 줄 수 있게 되어 있다.
디바운스 함수가 결과를 반환할걸 대비한 result, 디바운스 함수로 등록한 함수가 arguments를 입력받을것을 대비한 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들 보다 먼저 실행된다는 장점이 있다.(root는 그냥 글로벌 this)
그래서인지 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)
따로 start할때 앞의 루프를 취소하는 과정이 추가되는데 setTimeout을 사용할 경우 그저 Timeout을 통해서 이벤트를 그대로 실행하면서 조건을 따져서 실제로 디바운스 함수만 실행하지 않는 방법으로 디바운스된 이벤트를 관리한다. 아마도 이미 태스크에 등록한 함수를 clear하는 것보다 그대로 이벤트루프를 건드리지 않는게 성능측면이나 이벤트 관리 측면에서 더 좋기 때문일것이라고 판단된다.
실제로 Invoke 함수에 관련 된 로직은 2개의 함수로 이루어져있는데 RAF에 올라가서 루프를 돌리는 timerExpired 함수와 실제 함수를 Invoke를 할지 말지를 결정하는 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
비교적 간단한 기능을 담당하는 함수지만 많은 부분에서 세삼한 고려를 했다는 것을 알 수 있다.
'프론트엔드' 카테고리의 다른 글
[OBS] 치지직 투명 채팅창 설정 하기(CSS) (1) | 2024.11.30 |
---|---|
프론트엔드 테스트에 대한 생각 (0) | 2023.02.01 |
프론트엔드 설계 고민 -6- (2) | 2022.10.03 |
프론트엔드 개발에 대한 생각 (2) | 2022.09.14 |
[프론트엔드] 코드 스플릿에 대한 정리 (0) | 2022.07.20 |