- A+
本文可以配合本人录制的视频一起食用
作用
节流和防抖是前端开发中常用的优化技术,主要用于优化一些高频触发的事件。
字面理解
节流与防抖,先从字面上理解一下,节流就是节制流入或流出,在前端方面我个人理解一下,指的是节制功能或请求的触发次数,所以节流函数字面上的意思就是防止功能或请求被频繁触发的函数;防抖呢,更好理解,防止抖动,它的字面意思更贴近前端的需求,就是防止页面抖动,以达到更好的用户体验。
适用场景
从字面上的理解可以联想到分别适合这两个功能的场景。
先看节流,比如我们打开搜索引擎页面,百度或者Google,当我们在搜索框输入内容,会出现自动补全的下拉框,下拉框里的数据是请求接口获取的,如果不加以限制,就会在频繁输入的时候发送出大量请求,所以节流就可以应用在这类场景中。
再看防抖,当我们在快速上下滚动页面的过程中,如果页面滚动行为绑定了事件监听器,就可能频繁触发回调导致大量的计算从而引发页面的抖动甚至卡顿,防抖函数就可以应用在这类场景中。
所以总体来说,节流和防抖都是用于控制事件触发的频率,只是控制的点不同。
防抖更适合于反馈较快的场景,就是说用户操作之后很快就会有反馈,我们不希望反馈太快,并且不希望频繁操作导致要去处理太多的反馈(合并处理);而节流更适合耗时较久的场景,就仿佛某个人在说省点流量吧,我不是没反馈,只是需要多点时间来处理,不要频繁给我发送相同的操作指令。
实现
根据以上理解,我们可以分别来实现这两个函数。
节流
首先是节流。
节流是在某次事件触发时执行指定操作后,再次触发事件时,若两次事件的触发时间点的间隔不小于给定的时间间隔,就再次执行指定操作,否则就不执行。
function throttle(fn, interval) { // fn是待执行的操作,interval是给定的时长,在给定的时长内只发送一次操作指令,也就是说只执行一次fn // 设置一个变量用于记录 let last = 0; // 记录上次动作的执行时间 return function() { // 首先保留调用时的this上下文和传入的参数 let context = this; let args = arguments; // 记录当前事件触发的时间点 let now = Date.now(); // 检查当前时间点与上次执行操作的时间点之间的间隔 if (now - last >= interval) { // 如果当前时间与上次触发动作的时间间隔大于或等于interval // 就触发操作 fn.apply(context, args); // 并且更新last为当前时间 last = now; } // 否则就不做任何操作,即两次事件触发的时间间隔小于interval时,就不触发fn执行,保证在interval设置的时长内只执行一次fn } }
我们可以在页面上测试一下
<button id="requestButton"> 点我请求 </button> <script> // 用throttle包装click的回调,防止频繁请求 const better_request = throttle(() => { console.log(Date.now()); }, 3000); document.querySelector('#requestButton').addEventListener('click', better_request); </script>
防抖
然后是防抖。
防抖就是在频繁触发事件后,等不再触发事件时合并执行动作。
function debounce(fn, delay) { // fn是待执行的操作,delay是指延迟的时长,我们希望在给定的延时之后再执行fn // 在防抖函数中需要设置一个定时器,用于延迟执行fn let timer = null; return function() { // 保留调用时的this上下文和传入的参数 let context = this; let args = arguments; // 每次事件被触发时,都去清除之前的旧定时器 if (timer) clearTimeout(timer); // 设定新定时器 // 在给定的delay延时之后,fn才会被执行 // 当事件首次被触发,fn会在delay毫秒后执行 timer = setTimeout(() => { fn.apply(context, args); }, delay); } }
- 使用防抖后,在事件触发时,fn在delay毫秒的延迟后才会执行,可以保证回调反馈不会太快
- 如果在delay毫秒内,比如第x毫秒时第二次事件回调被触发,此时前一个fn还未被执行,若不清理计时,第二个fn操作会在delay毫秒后被执行,这样就会导致delay毫秒内有两个fn会被触发;
- 第一个fn在delay-x毫秒后执行
- 第二个fn在delay毫秒后执行
- 两个fn的执行间隔理论上为x毫秒,x小于delay
- 所以为了保证fn不被频繁执行,我们要将前一个计时清理掉,使得delay延时内只有一个fn将被执行,相当于将多个反馈合并处理
- 如果delay延时内再无事件触发,则延时结束后fn就被执行
这样做看上去似乎没有问题,但实际上是存在问题的,问题就在于如果用户操作过于频繁,就会导致fn的执行被无限推迟,因为新的事件触发总会清除掉上一次的计时器,这样用户的操作需要很久才得到反馈,或者根本得不到反馈,比如用户在频繁滚动页面后,没等到fn执行就跳转其他页面了。
合并版
为了保证在给定的时间内必须执行一次fn,我们可以使用throttle来优化防抖,也可以说是两者的合并。
最终要达到的目标:
- 将多次事件触发的fn操作合并执行
- 在给定的时间间隔内一定会执行一次fn
function enhanceThrottle(fn, delay) { // 设置两个变量 // last用于记录上一次fn执行的时间 // timer用于延迟执行fn let last = 0, timer = null; return function() { // 保留回调时的this上下文和传入的参数 let context = this; let args = arguments; // 记录当前事件触发的时间点 let now = Date.now(); if (now - last < delay) { // 如果当前时间点与上一次fn执行时间的间隔小于给定的时间间隔 // 不执行fn操作 // 重置定时器,在delay延时后执行fn // 这样执行两次fn预计的时间差就是now - last + delay,也就是说时间差会大于delay clearTimeout(timer); timer = setTimeout(() => { last = Date.now(); fn.apply(context, args); }, delay); } else { // 当前时间点与上一次fn执行时间的间隔超出给定的时间间隔 // 就立即执行一次fn fn.apply(context, args); // 并更新last的值 last = now; } } }
优化之后,在第一次触发事件时,就会立即执行一次fn。
但是这样优化之后依旧存在问题:
就是在else语句这个分支,当前时间点与上一次fn执行时间的间隔超出给定的时间间隔,就立即执行一次fn,假设此次事件的触发时间点是now2,上一次事件的触发时间点是now1,如果经过now1-last+delay这个延迟之后刚好是now2,就会在立即执行fn的同时,有个延迟的fn也要执行。
可以继续优化,在立即执行fn这个分支里,也去重置计时,clearTimeout(timer)
,当然实践中可能还是会有问题,比如在清理计时器之前这个延迟的fn操作已经进入任务队列了。
对比
两个初始版的节流和防抖。
看上去,节流函数就像在一段时间间隔的开始时间点执行操作,防抖函数像是在一段时间间隔内最后一次事件触发后执行操作。两者似乎是一个头一个尾,但其实上并没有很相似,节流的时间间隔是给定的,而防抖的时间间隔是不确定的,而是视用户的操作而定。
也就是说节流直接丢掉后面的操作,防抖更类似于合并了前面的操作。