Open toFrankie opened 1 year ago
写在前面,实际项目应使用 Lodash 等主流工具库,它们经过社区的反复验证,肯定比自己写的要完善很多。
相信无论在实际应用场景、亦或是面试,都会经常遇得到函数防抖、函数节流等,下面我们来聊一聊吧。
先放出一个示例:
import React, { useEffect, useRef } from 'react' import debounce from '../../utils/debounce' import throttle from '../../utils/throttle' import style from './index.scss' export default function Demo(props) { const inputElem1 = useRef() const inputElem2 = useRef() const inputElem3 = useRef() useEffect(() => { inputElem1.current.addEventListener('keyup', request) inputElem2.current.addEventListener('keyup', debounce(request, 1000)) inputElem3.current.addEventListener('keyup', throttle(request, 3000)) }, []) function request(event) { const { value } = event.target console.log(`Http request: ${value}.`) } return ( <div className={style.container}> <div className={style.list}> <label htmlFor="input1">普通输入框:</label> <input name="input1" ref={inputElem1} defaultValue="" /> </div> <div className={style.list}> <label htmlFor="input2">防抖输入框:</label> <input name="input2" ref={inputElem2} defaultValue="" /> </div> <div className={style.list}> <label htmlFor="input3">节流输入框:</label> <input name="input3" ref={inputElem3} defaultValue="" /> </div> </div> ) }
以上 Demo 只有三个输入框,很简单。我给每个输入框绑定了一个 keyup 键盘事件,该事件执行会发起网络请求(为了更简洁,这里只是打印一下而已),而对应防抖、节流输入框则经过相应的处理。
keyup
如果我们在普通输入框快速键入 12345,可以从控制台上的打印结果看到,它会发起 5 次网络请求(假设我们这个是一个简单的搜索引擎)。
12345
还不知道用什么截屏/录屏软件可以生成 GIF 动图,有时间再研究下...
从实际场景考虑,如果每键入一个字符就立刻发起网络请求,去检索结果,这是非常影响体验的。假设我们限制为:用户在停止输入后 1s 后才发起网络请求。
要实现这样的需求,我们只有使用函数防抖即可。
概念:在一定时间间隔内,事件处理函数只会执行一次。若在该时间间隔内(多次)重新触发,则重新计时。
怎么理解?
a
b
c
debounce(func, wait)
实现思路:
首先,接收两个参数 func(要防抖的函数,一般是事件回调函数)和 wait(需要延迟的时间间隔,单位毫秒)。然后 func 在 setTimeout 中执行,而 setTimeout 的延迟时间就是 wait。而重新计时的话,则在每次触发的时候 clearTimeout 即可实现。
func
wait
setTimeout
clearTimeout
需要注意下,func 的执行上下文(this)及其入参。
this
function debounce(func, wait) { let timerId // 为确保 this 指向正确,此处不能用箭头函数 return function (...args) { // 在 wait 时间内,若重新触发,清除 clearTiemout,以达到重新计时的效果 if (timerId) clearTimeout(timerId) timerId = setTimeout(() => { func.apply(this, args) }, wait) } }
依次在对应输入框内键入 12345,对比下防抖前后的结果:
两次键入速度差不多,而且每个字符键入时间间隔小于 1s(可调大延迟执行时间,更容易对比)。
// 普通输入框 inputElem1.current.addEventListener('keyup', request) // 防抖输入框 inputElem2.current.addEventListener('keyup', debounce(request, 1000))
对比以上无防抖处理和防抖处理的结果,可以看到前者每键入一个字符都会执行回调函数,而后者则会在最后一次触发的 N 毫秒(即 wait 延迟时间)之后才会执行一次回调函数。
还有一种是“立即执行”的函数防抖:区别在于第一次触发时,是否立即执行回调函数。
再结合以上的“非立即执行”的防抖,完整方法如下:
function debounce(func, wait, immediate = false) { let timerId return function (...args) { if (timerId) clearTimeout(timerId) if (immediate && !timerId) { func.apply(this, args) } timerId = setTimeout(() => { func.apply(this, args) }, wait) } }
当我们修改成:
inputElem2.current.addEventListener('keyup', debounce(request, 1000, true))
从以下结果可以看到,当我在防抖输入框键入 12345 的时候,它会在键入 1 时立刻发起一次网络请求,由于每个字符键入的时间间隔都在 1s 之内,因此它只会在最后停止键入的 1s 后才会发起网络请求。
1
1s
概念:在一定时间间隔内只会触发一次函数。若在该时间间隔内触发多次函数,只有第一次生效。
function throttle(func, wait) { // 记录上一次执行 func 的时间 let prev = 0 return function (...args) { // 当前触发的时间(时间戳) const now = Number(new Date()) // +new Date() // 单位时间内只会执行一次 if (now >= prev + wait) { // 符合条件执行 func 时,需要更新 prev 时间 prev = now func.apply(this, args) } } }
以上节流方法有个问题,假设节流控制间隔时间为 1s,若最后一次触发时间在 1.5s,则最后一次触发并不会执行。因此,需要在节流中嵌入防抖思想,以保证最后一次会被触发。
function throttle(func, wait) { // 记录上一次执行 func 的时间 let prev = 0 let timerId return function (...args) { // 当前触发的时间(时间戳) const now = Number(new Date()) // +new Date() // 保证最后一次也会触发 // 我看到很多文章,将清除定时器的步骤放到 2️⃣ 里面 // 我认为应该放在这里才对,原因看我下面举例的场景。 if (timerId) clearTimeout(timerId) if (now >= prev + wait) { // 1️⃣ // 符合条件执行 func 时,需要更新 prev 时间 prev = now func.apply(this, args) } else { // 2️⃣ // 单位时间内只会执行一次 // if (timerId) clearTimeout(timerId) // 不应该放在这里 timerId = setTimeout(() => { prev = now func.apply(this, args) }, wait) } } }
假设我将 clearTimeout() 放在了 2️⃣ 里面,而不是在外层。基于 throttle(func, 1000) 考虑以下场景:
clearTimeout()
throttle(func, 1000)
我在 4s 时触发了一次,应该走 1️⃣ 逻辑。然后在 4.9s 时又触发了一次,这会走的 2️⃣ 逻辑并记录了一个定时任务。然后时间到了 5s,我又触发了一次(后面就停止操作了),它会走 1️⃣ 逻辑一次,接着时间来的 5.9s,它还会执行一遍 fn.apply(this, args),因为在 5s 触发时,没有 clearTimeout()。因此,清除定时器的步骤应该放在外层,以保证每次被触发是都清掉最后一次的定时器,避免在一些边界 Case 触发两次。
4s
4.9s
5s
5.9s
fn.apply(this, args)
当然,以上场景是在理想的状态,实际场景可能几乎碰不到这些边界。但从严谨的角度去看问题,应该也要考虑的。
写到这里,我又在想刚刚的“立即执行的函数防抖”,跟这个优化版的节流是不是有点像,第一次触发都会执行回调函数。但区别是防抖会重新计时,而节流在第一次触发后面的每个间隔时间点都会触发,非间隔点的最后一次触发也将会被执行。
我在节流输入框内,依次键入 1234567890,可以看到:在键入字符 1 时执行了回调;接着键入的 234、67 字符都属在上一个时间间隔内,因此无法执行回调。其中键入的 90 字符应属于 8 之后的 1s 周期之内,由于键入 0 字符属于最后一次的非时间间隔内的触发动作,因此回调会在键入 0 的 1s 后被执行。(可打印时间戳的形式,更精细地对比)
1234567890
234
67
90
8
0
inputElem3.current.addEventListener('keyup', throttle(request, 1000))
其实,函数防抖和函数节流都是为了防止某个时间段频繁触发某个事件。它俩在某个时间间隔内多次重复触发,都只会执行一次回调函数。区别在于函数防抖最后一次触发有效,而函数节流则是第一次触发有效。
而在上面,都对函数防抖和函数节流做了“拓展”,例如:
immediate
应用场景:
函数防抖(debounce)
函数节流(throttle)
如果还是不太明白 debounce 和 throttle 的差异,可以在以下这个页面,可视化体验。
function debounce<A extends any[], R>( func: (...args: A) => R, wait: number = 0, immediate: boolean = false ): (...args: A) => void { let timerId: number | undefined return function (this: any, ...args) { if (timerId) clearTimeout(timerId) if (immediate && !timerId) { func.apply(this, args) } timerId = setTimeout(() => { func.apply(this, args) }, wait) } }
function throttle<A extends any[], R>(func: (...args: A) => R, wait: number): (...args: A) => void { let prev = 0 let timerId: number | undefined return function (this: any, ...args) { const now = Number(new Date()) if (timerId) clearTimeout(timerId) if (now >= prev + wait) { prev = now func.apply(this, args) } else { timerId = setTimeout(() => { prev = now func.apply(this, args) }, wait) } } }
看到有些库像下面这样标注,这里有个问题是 debounce 或 throttle 里 return 的函数是没有返回值的,也就是说,理应返回 (...args: A) => void,但实际返回了 (...args: A) => R,这样做其实不太对。但这样体验上有一个好处:在调用 throttledFn 时可以看到原函数返回值是什么类型。
(...args: A) => void
(...args: A) => R
function throttle<T>(func: T, wait: number): T { // ... } const throttledFn = throttle(function doSomething() {}, 100)
使用 JSDoc 注释(节流同理)
/** * 函数防抖 * @template A, R * @param {(...args: A) => R} func 要防抖的函数 * @param {number} wait 防抖时间 * @param {boolean} [immediate=false] 立即执行 * @returns {(...args: A) => void} 返回被防抖处理的函数 */ export const debounce = (func, wait, immediate = false) => { let timerId return function (...args) { if (timerId) clearTimeout(timerId) if (immediate && !timerId) { func.apply(this, args) } timerId = setTimeout(() => { func.apply(this, args) }, wait) } }
写在前面,实际项目应使用 Lodash 等主流工具库,它们经过社区的反复验证,肯定比自己写的要完善很多。
前言
相信无论在实际应用场景、亦或是面试,都会经常遇得到函数防抖、函数节流等,下面我们来聊一聊吧。
先放出一个示例:
以上 Demo 只有三个输入框,很简单。我给每个输入框绑定了一个
keyup
键盘事件,该事件执行会发起网络请求(为了更简洁,这里只是打印一下而已),而对应防抖、节流输入框则经过相应的处理。函数防抖(debounce)
如果我们在普通输入框快速键入
12345
,可以从控制台上的打印结果看到,它会发起 5 次网络请求(假设我们这个是一个简单的搜索引擎)。从实际场景考虑,如果每键入一个字符就立刻发起网络请求,去检索结果,这是非常影响体验的。假设我们限制为:用户在停止输入后 1s 后才发起网络请求。
要实现这样的需求,我们只有使用函数防抖即可。
什么是函数防抖?
概念:在一定时间间隔内,事件处理函数只会执行一次。若在该时间间隔内(多次)重新触发,则重新计时。
怎么理解?
a
后就停止输入了,那么网络请求会在停止键入操作的 1s 后发起。这个很好理解。b
后,若有所思地停了一会(这个时间在 1s 之内,假设为 800ms 吧),接着键入字母c
,之后就停止键入了。网络请求会发生在键入字母c
的 1s 后被发起,而不是键入字母b
之后的 1s 发起。因为函数防抖会在键入c
之后重新计时。函数防抖实现
实现思路:
首先,接收两个参数
func
(要防抖的函数,一般是事件回调函数)和wait
(需要延迟的时间间隔,单位毫秒)。然后func
在setTimeout
中执行,而setTimeout
的延迟时间就是wait
。而重新计时的话,则在每次触发的时候clearTimeout
即可实现。依次在对应输入框内键入
12345
,对比下防抖前后的结果:对比以上无防抖处理和防抖处理的结果,可以看到前者每键入一个字符都会执行回调函数,而后者则会在最后一次触发的 N 毫秒(即
wait
延迟时间)之后才会执行一次回调函数。再结合以上的“非立即执行”的防抖,完整方法如下:
当我们修改成:
从以下结果可以看到,当我在防抖输入框键入
12345
的时候,它会在键入1
时立刻发起一次网络请求,由于每个字符键入的时间间隔都在1s
之内,因此它只会在最后停止键入的1s
后才会发起网络请求。函数节流(throttle)
概念:在一定时间间隔内只会触发一次函数。若在该时间间隔内触发多次函数,只有第一次生效。
函数节流实现
函数节流优化
以上节流方法有个问题,假设节流控制间隔时间为 1s,若最后一次触发时间在 1.5s,则最后一次触发并不会执行。因此,需要在节流中嵌入防抖思想,以保证最后一次会被触发。
假设我将
clearTimeout()
放在了 2️⃣ 里面,而不是在外层。基于throttle(func, 1000)
考虑以下场景:当然,以上场景是在理想的状态,实际场景可能几乎碰不到这些边界。但从严谨的角度去看问题,应该也要考虑的。
我在节流输入框内,依次键入
1234567890
,可以看到:在键入字符1
时执行了回调;接着键入的234
、67
字符都属在上一个时间间隔内,因此无法执行回调。其中键入的90
字符应属于8
之后的 1s 周期之内,由于键入0
字符属于最后一次的非时间间隔内的触发动作,因此回调会在键入0
的 1s 后被执行。(可打印时间戳的形式,更精细地对比)防抖与节流
其实,函数防抖和函数节流都是为了防止某个时间段频繁触发某个事件。它俩在某个时间间隔内多次重复触发,都只会执行一次回调函数。区别在于函数防抖最后一次触发有效,而函数节流则是第一次触发有效。
而在上面,都对函数防抖和函数节流做了“拓展”,例如:
immediate
的参数,用于控制第一次是否执行回调。应用场景:
函数防抖(debounce)
函数节流(throttle)
如果还是不太明白 debounce 和 throttle 的差异,可以在以下这个页面,可视化体验。
类型标注
看到有些库像下面这样标注,这里有个问题是 debounce 或 throttle 里 return 的函数是没有返回值的,也就是说,理应返回
(...args: A) => void
,但实际返回了(...args: A) => R
,这样做其实不太对。但这样体验上有一个好处:在调用 throttledFn 时可以看到原函数返回值是什么类型。使用 JSDoc 注释(节流同理)
参考