浅谈前端性能优化:节流和防抖

什么是节流和防抖

防抖,顾名思义,就是防止异常情况下的抖动,假设你在给女朋友挑礼物的时候,生怕女朋友不满意,不断的在搜索框里改变着想要购买的礼物,这时候不但你很焦虑,搜索框也很焦虑,因为假设每改变一次内容都要像服务端请求一次的话,那压力得多大啊,所以搜索框得等你冷静下来的时候在向服务端请求。那怎么判断你冷静下来了呢?比较合适的方法是通过时间判断,比如你输入了一个商品的关键字,一段时间没有改变内容,搜索框就知道你冷静了,它就可以向服务端去请求需要搜索的内容了

所以防抖的含义,就是在触发高频操作的 n 秒内只执行一次,如果在 n 秒内又被触发,那么就重新计算时间,等到 n 秒确定完成之后再触发

说到节流,想象一下这样一个场景:M2 芯片的 Macbook Air 今晚 8 点开启预售,你从 7 点 55 分开始就准备好选商品 -> 加购物车 -> 下单付款一系列操作,你要不断的疯狂点击按钮才能进行到下一步操作,如果每点一次按钮都要向服务端请求一次,想象一下这会给服务端带来多大的压力。所以这个时候节流就派上用场了

所以所谓节流,就是节约流量,对于高频率的事件来说,在 n 秒内只会执行一次。也就是通过每隔一段时间执行一次的方式,以此来达到节约流量的效果,从而稀释了高频率操作对于服务端带来的压力

所以防抖和节流的区别也就很明显了

  • 防抖是阻止你的疯狂操作,在你冷静下来后的最后一次才执行
  • 节流是稀释你的疯狂操作,不论你有多疯狂,我就是冷静的按照计划执行

如何实现节流和防抖

下面看一下最基础的防抖、节流函数实现逻辑

防抖函数实现逻辑主要是基于 setTimeout 来控制,如果在规定的 delay 时间内的话就清理掉 timer,否则就执行传入的函数

1function debounce(fn, delay = 500) { 2 let timer 3 4 return function () { 5 // 用户输入时清理掉第一个 setTimeout 6 if (timer) { 7 clearTimeout(timer) 8 } 9 timer = setTimeout(() => { 10 // 改变 this 指向为调用 debounce 所指的对象 11 fn.apply(this, arguments) 12 }, delay) 13 } 14}

节流函数虽然也使用了 setTimeout 函数,但主要的实现逻辑还是基于“锁”的方式实现的,在执行完函数的一段时间内,flag 会被锁住,直到时间结束后 flag 锁被打开才能进入下一次循环

1function throttle(fn, delay = 500) { 2 // 加锁,true 表示可以进入下一次循环,false 表示不可以 3 let flag = true 4 return function () { 5 if (!flag) return 6 flag = false 7 setTimeout(() => { 8 fn.apply(this, arguments) 9 // 在 setTimeout 执行完毕后,把标记设置为 true,表示可以执行下一次循环 10 flag = true 11 }, delay) 12 } 13}

还有没有更好的方式

节流函数虽然可以实现稀释的效果,但总是等待一段时间在执行不仅用户体验差了一些,而且万一在间隔等待的时间有其他业务逻辑要实现,那不是就更麻烦了,所有就有了利用防抖函数来优化节流函数的方法,具体来说就是

  • 在规定的时间内,还是按照节流函数的逻辑按照间隔执行
  • 在规定的时间后,按照防抖函数的逻辑立即执行

具体实现方式如下

1function throttle(fn, delay = 500) { 2 let flag = true 3 let last = 0 4 5 return function () { 6 let now = Number(new Date()) 7 8 if (!flag) return 9 flag = false 10 11 // 优化逻辑:规定时间内等待执行,规定时间后立刻执行 12 if (now - last < delay) { 13 setTimeout(() => { 14 fn.apply(this, arguments) 15 }, delay) 16 } else { 17 fn.apply(this, arguments) 18 } 19 20 flag = true 21 last = now 22 } 23}

但是在实际开发过程中,直接使用比较成熟的轮子是比较好的方式,所以要做项目中使用防抖函数和节流函数的话,我会推荐使用 lodash。lodash 一致性、模块化、高性能的 JavaScript 实用工具库,主要是封装了各种工具函数,让开发变的更简单高效,并且封装的工具函数相比自己手写的函数考虑了更多的边界问题,让我们的代码更加健壮

如果要在项目使用的话,直接引入 es 版本的依赖就好(如果是使用 ts 的项目,最好再引入 type 依赖)

1pnpm i lodash-es 2pnpm i -D @types/lodash-es

然后在项目中直接引入防抖函数(debounce)和节流函数()就可以直接使用了

1import { debounce, throttle } from 'lodash-es' 2 3debounce(() => { 4 console.log('debounce!') 5}, 1000) 6 7throttle(() => { 8 console.log('throttle!') 9}, 1000)

既然用到了 lodash 的函数,那就顺便分析看看源码做了哪些方面的提升和优化,首先看看防抖函数(源码地址

封装的防抖函数主要增加了 cancel() 方法来停止函数的调用,或者是通过 flush() 方法立即执行调用,还可以通过配置参数 option 来控制执行的时机,并且还加入了各种边界判断(比如判断传入的 func 参数是否是函数,处理 requestAnimationFrame 的情况等等),下面展示的一些核心的函数逻辑

1function debounce(func, wait, options) { 2 let lastArgs, lastThis, maxWait, result, timerId, lastCallTime 3 4 let lastInvokeTime = 0 5 let leading = false 6 let maxing = false 7 let trailing = true 8 9 // 对于输入参数的判断和处理 10 if (typeof func !== 'function') { 11 throw new TypeError('Expected a function') 12 } 13 wait = +wait || 0 14 if (isObject(options)) { 15 leading = !!options.leading 16 maxing = 'maxWait' in options 17 maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait 18 trailing = 'trailing' in options ? !!options.trailing : trailing 19 } 20 21 // 改变 this 指向,执行 debounce 包裹的函数 22 function invokeFunc(time) { 23 const args = lastArgs 24 const thisArg = lastThis 25 26 lastArgs = lastThis = undefined 27 lastInvokeTime = time 28 result = func.apply(thisArg, args) 29 return result 30 } 31 32 // 开启 setTimeout 33 function startTimer(pendingFunc, wait) { 34 return setTimeout(pendingFunc, wait) 35 } 36 37 // 指定延迟前调用函数 38 function leadingEdge(time) { 39 lastInvokeTime = time 40 timerId = startTimer(timerExpired, wait) 41 return leading ? invokeFunc(time) : result 42 } 43 44 function remainingWait(time) { 45 const timeSinceLastCall = time - lastCallTime 46 const timeSinceLastInvoke = time - lastInvokeTime 47 const timeWaiting = wait - timeSinceLastCall 48 49 return maxing 50 ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) 51 : timeWaiting 52 } 53 54 // 判断函数立即执行函数调用,如果等待时间 > 最大时间的情况下就立即执行 55 function shouldInvoke(time) { 56 const timeSinceLastCall = time - lastCallTime 57 const timeSinceLastInvoke = time - lastInvokeTime 58 59 return ( 60 lastCallTime === undefined || 61 timeSinceLastCall >= wait || 62 timeSinceLastCall < 0 || 63 (maxing && timeSinceLastInvoke >= maxWait) 64 ) 65 } 66 67 // 判断是否超过最大等待时间,超过就立即执行 68 function timerExpired() { 69 const time = Date.now() 70 if (shouldInvoke(time)) { 71 return trailingEdge(time) 72 } 73 timerId = startTimer(timerExpired, remainingWait(time)) 74 } 75 76 // 指定延迟后调用函数 77 function trailingEdge(time) { 78 timerId = undefined 79 80 if (trailing && lastArgs) { 81 return invokeFunc(time) 82 } 83 lastArgs = lastThis = undefined 84 return result 85 } 86 87 function debounced(...args) { 88 const time = Date.now() 89 const isInvoking = shouldInvoke(time) 90 91 lastArgs = args 92 lastThis = this 93 lastCallTime = time 94 95 if (isInvoking) { 96 if (timerId === undefined) { 97 return leadingEdge(lastCallTime) 98 } 99 if (maxing) { 100 timerId = startTimer(timerExpired, wait) 101 return invokeFunc(lastCallTime) 102 } 103 } 104 if (timerId === undefined) { 105 timerId = startTimer(timerExpired, wait) 106 } 107 return result 108 } 109 return debounced 110} 111 112export default debounce

节流函数的实现就更简单了,主要就是基于对防抖函数 debounce 的封装,定义了一个最大延迟实践 maxWait(大佬们写的代码果然就是简洁),所以可以看到节流本质也是防抖函数的一个分支

1function throttle(func, wait, options) { 2 let leading = true 3 let trailing = true 4 5 if (typeof func !== 'function') { 6 throw new TypeError('Expected a function') 7 } 8 if (isObject(options)) { 9 leading = 'leading' in options ? !!options.leading : leading 10 trailing = 'trailing' in options ? !!options.trailing : trailing 11 } 12 return debounce(func, wait, { 13 leading, 14 trailing, 15 maxWait: wait, 16 }) 17} 18 19export default throttle