avatar

光和尘

有花满渚、有酒盈瓯

目录检索关于我

🔖 codingdebouncethrottle

前言

防抖(debounce)和节流(throttle)在前端开发中十分常见,它们都是针对一个事件被连续触发时限制执行次数的算法,不同的是 debounce 只处理最后一次事件触发,而 throttle 则以一个固定的频率处理事件触发。你可以在这里直观地观察到它们之间的区别。

在开始正文之前,先看一下 Typescript 的两个工具类型 ParametersReturnType,引入它们的目的是因为 debouncethrottle 的实现都以高阶函数的方式展现,本着作死无极限精益求精的态度,在实现时自然要考虑返回的函数和原函数应具有相同的参数类型的调用体验啦。

  • Parameters: Typescript@3.1 引入的工具类型,用于获取一个函数的参数类型[1]

    Parameters.d.ts 
    type Parameters<T extends (...args: any[]) => any> =
    T extends (...args: infer P) => any ? P : never
    type A = Parameters<(x: number, y: number) => void>
    // A 的类型为 [x: number, y: number]
  • ReturnType: Typescript@2.8 引入的工具类型,用于获取一个函数的返回类型:

    ReturnType.d.ts 
    type ReturnType<T extends (...args: any) => any> =
    T extends (...args: any) => infer R ? R : any
    type A = ReturnType<(x: number, y: number) => string>
    // A 的类型为 string

防抖 (debounce)

防抖是指事件连续触发时,只在最后一次事件触发后再执行响应动作。比如,事件被触发的 t 秒后才执行回调,若 t 秒内此事件被再次触发,则重新计时。

适用场景:

  • 提交表单时,快速点击多次,但只执行一次提交。
  • 搜索框中输入内容实时展示关联条目,只在输入停顿时才发起查询请求(和 throttle 中列出的场景有所不同)。

简化版

在了解了 debounce 要解决的问题后,不难想到可以利用 setTimeout 来倒计时,当函数被顺利执行时,则重置定时器,表示当前处于空闲状态;否则,有一个新的事件在倒计时未结束时触发,则同样将计时器重置,但只开始计时,不执行任务。由此不难实现一个简单的版本:

debounce-1.ts 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function debounce<T extends (...args: any[]) => any>(
fn: T,
wait?: number,
): (...args: Parameters<T>) => void {
// 浏览器中 setTimeout 返回的是 number
// 而 NodeJs 中返回的是 NodeJs.Timeout
let timeout: ReturnType<typeof setTimeout>
return debounced
function debounced(...args: Parameters<T>): void {
if (timeout) clearTimeout(timeout)
const context = this
timeout = setTimeout(() => fn.apply(context, args), wait)
}
}

注意上面代码的高亮部分,将 debouncedthis 指针绑定到 fn 中再执行,这是 Javascript 老生常谈的问题了:当 fn 中通过 this 引用变量时,this 指针将默认指向 Window [2],而在有些情况下,执行 fn 时会显式地绑定一个 this 指针,如 DOM 事件的回调函数中,会把触发事件的元素作为 this 绑定到 fn 中,于是我们可以通过 this 指针去访问当前触发事件的元素:

 
1
2
3
document.body.addEventListener('click', debounce(function () {
console.log(this.innerText)
}))

带返回值

上面的实现版本中,debounced 返回的是 void,对于大多数场景已经够用了,但如果要返回值的话,应该如何考虑呢?debounced 在事件连续多次触发时只会执行一次,我们可以记录下最后一次执行时的结果,然后在每次非实际执行时,返回上一次的结果:

debounce-2.ts 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export function debounce<T extends (...args: any[]) => any>(
fn: T,
wait?: number,
): (...args: Parameters<T>) => ReturnType<T> | void {
let timeout: ReturnType<typeof setTimeout>
let lastResult: ReturnType<T> | undefined
return debounced
function debounced(...args: Parameters<T>): ReturnType<T> | void {
if (timeout) clearTimeout(timeout)
const context = this
timeout = setTimeout(function () {
lastResult = fn.apply(context, args)
}, wait)
return lastResult
}
}

节流 (throttle)

节流是指事件连续触发时,在一个固定的时间间隔内只执行一次响应动作。比如事件被触发的 t 秒后再次执行回调,若 t 秒内此事件被再次触发,直接无视,并不重新计时;而对于 t 秒后第一次触发的事件会执行响应,并重新计时。

适用场景:

  • UI 的拖拽事件、鼠标点击事件、滚动事件等,无需为每一次事件触发进行响应,但需要在一个时间范围内至少作出一次响应,否则会影响用户体验(掉帧)。
  • 搜索框中输入内容实时展示关联条目,每隔一个固定的时间间隔(如 1 秒)发起一次查询请求(和 debounce 中列出的场景有所不同)。

简化版

既然节流只需保证一个固定周期内只执行一次函数,则可以在空闲状态时接收事件触发,并在事件触发时启动一个倒计时的定时器,在倒计时未结束前,忽略所有的其它事件触发,等到上一次处理操作完成后,再将计时器重置,表明重新进入空闲状态。由此可以实现一个简化版的节流函数:

throttle-1.ts 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function throttle<T extends (...args: any[]) => any>(
fn: T,
wait?: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>
return throttled
function throttled(...args: Parameters<T>): void {
if (timeout) return
const context = this
timeout = setTimeout(function () {
fn.apply(context, args)
timeout = null
}, wait)
}
}

上面的代码中仍然考虑了 this 指针的指向问题,此处不再赘述。

带返回值

同样地,我们可以记录下上一次 throttled 返回值,在未实际执行的事件触发中,简单地返回一次记录的返回值。

throttle-2.ts 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export function throttle<T extends (...args: any[]) => any>(
fn: T,
wait?: number,
): (...args: Parameters<T>) => ReturnType<T> | void {
let timeout: ReturnType<typeof setTimeout>
let lastResult: ReturnType<T> | undefined
return throttled
function throttled(...args: Parameters<T>): ReturnType<T> | void {
if (timeout) return lastResult
const context = this
timeout = setTimeout(function () {
lastResult = fn.apply(context, args)
timeout = null
}, wait)
}
}
© 2017-2022 光和尘有花满渚、有酒盈瓯

Comments