从点击到执行:如何优雅地控制高频事件触发频率
在网页开发中,某些事件(如窗口调整大小、滚动、键盘输入或鼠标移动)可能会频繁的被触发。如果每次事件触发都执行相应的处理函数,尤其是这些函数涉及复杂的计算或网络请求时,会导致性能问题,甚至可能使页面变得缓慢或无响应。为了解决这个问题,通常会采用两种技术:防抖(Debouncing) 和 节流(Throttling)。
一、防抖
防抖的目的是确保某个函数在短时间内不会被频繁调用。只有在停止触发一段时间后才执行一次。就是说事件停止促发后并过了设置的时间。适用于搜索框的输入、窗口大小调整、表单提交按钮防止双击。
实现原理:当一个事件发生时,设置一个定时器,在指定的时间间隔后执行特定的函数。如果在这个时间间隔内该事件再次被触发,则重置定时器。这意味着只有当事件停止触发超过设定的时间后,函数才会被执行。
-
以键盘输入为例看看不做防抖是什么情况
每次按键都会立即触发
console.log(this.value);
,如果用户快速输入(如输入 "hello"),console.log
会被调用 5 次,它进行了多次无意义的执行。html<input type="text" id="test"> <script> document.getElementById('test').addEventListener('keyup',function(){ console.log(this.value); }) </script>
-
防抖实现
当用户在输入框(
<input id="test">
)中键入内容时,每次按键释放(keyup
事件)都会触发事件监听器。该监听器获取输入框的当前值(e.target.value
)并传递给debouncedDoWhat
函数。debouncedDoWhat
是一个经过防抖处理的函数,由debounce(doWhat, 3000)
生成。防抖的核心逻辑是:- 延迟执行 :每次调用
debouncedDoWhat
时,它会检查是否已有待执行的定时器(timer
)。如果有,则清除之前的定时器,重新开始计时。 - 稳定后执行 :只有在用户停止输入 3 秒(3000ms) 后,才会真正调用
doWhat
函数,并打印输入框的最新值。
html<input type="text" id="test" /> <script> document.getElementById("test").addEventListener("keyup", (e) => { debouncedDoWhat(e.target.value); }); const debouncedDoWhat = debounce(doWhat, 3000); function doWhat(value) { console.log(value); } function debounce(fn, delay) { let timer = null; return function (...args) { const context = this; if (timer) { clearTimeout(timer); } timer = setTimeout( ()=> { fn.apply(context, args); }, delay); }; } </script>
防抖后的效果:
关于上述代码this指向问题
由
const debouncedDoWhat = debounce(doWhat, 3000);
可知debouncedDoWhat
实际是就是debounce
返回的闭包函数。闭包函数中执行const context = this;
捕获的是debouncedDoWhat
调用时的this,而debouncedDoWhat
是作为普通函数被调的,所以此时捕获的this指向window。在定时器中的函数是箭头函数,它的this指向的是返回函数也就是debouncedDoWhat
,所以这里可以不使用context来保存this。最后执行fn.apply(context, args);
将fn的this指向debouncedDoWhat
指向的this。jsfunction debounce(fn, delay) { let timer = null; return function (...args) { if (timer) { clearTimeout(timer); } timer = setTimeout(()=> { fn.apply(this, args); }, delay); }; }
- 延迟执行 :每次调用
二、节流
节流的目的是使函数在一段时间内触发一次,也就是说在规定的时间间隔内最多只执行一次事件处理函数,即使在这段时间内事件被多次触发。适用于滚动加载、鼠标移动等事件。
-
防抖实现
代码实现的功能是在一段时间内( 5秒)只执行一次函数,即使事件被频繁触发。
html<div> <input type="text" id="inputC" /> </div> <script> function throttle(fn, delay) { let last; // 存储上一次函数成功执行的时间戳 let deferTimer; // 存储 setTimeout 返回的 ID,用于清除定时器 return function (...args) { let that = this; // 保存 this 上下文,确保在 setTimeout 中也能正确访问 this let now = +new Date(); // 获取当前时间戳(+new Date() 是一种快速获取时间戳的方式) // 判断是否在节流周期内 if (last && now < last + delay) { clearTimeout(deferTimer); // 如果还在限制时间内,则清除之前的定时器,并重新设置新的定时器 deferTimer = setTimeout(function () { // 当定时器触发时,更新 last 时间为当前时间 last = now; // 执行原始函数,并传递参数 fn.apply(that, [...args]); }, delay); // 延迟执行到下一个周期开始 } else { // 如果是第一次触发或者不在节流周期内,直接执行函数 // 更新 last 为当前时间 last = now; fn.apply(that, [...args]);// 执行原始函数,并传递参数 } }; } // 使用示例 document.getElementById("inputC").addEventListener( "keyup", throttle(function (e) { console.log(e.target.value); }, 5000) ); </script>
代码的执行逻辑
-
调用
throttle
函数后还会返回一个闭包函数,并形成一个闭包,其中的自由变量last
记录的是上一层原始函数 执行的时间。deferTimer
记录的是定时器id,用于清除定时器 -
执行返回的闭包函数,首先保存当前上下文
that
并获取当前时间戳now
-
判断是否在节流周期内,若是第一次触发(last为undefined时)则直接执行原始函数。若不是第一次触发事件则进入if中
-
在if中首先清除上一次的定时器并设置新的定时器,在延迟时间(
delay
)后执行:在定时器中更新
last
为当前时间并使用apply
调用原始函数,确保正确的this
和参数传递
总的来说就是
-
若是事件是第一次触发则直接走else线执行一次,并设置上一次的执行时间点。
-
若事件一直触发那么代码走的一直都是if线 ,这个定时器也一直处于刷新的状态而不会执行里面的原始函数 。所以last一直都是上一次执行时的last,随着时间的推进会有
last + delay < now
,也就是说距离上一次函数执行已经过去了delay
,这时候就会走else线执行原始函数
。 -
若事件一直触发了几次之后停止触发 (此时
last + delay < now
),由于存在一个定时器,这个定时器会在触发停止的delay
时间后执行原始函数这里最后一次会延迟执行,也就是说这次执行到上一次执行的时间间隔大于
delay
,可以修改定时器的定时时间来解决jsdeferTimer = setTimeout(function () { // 当定时器触发时,更新 last 时间为当前时间 last = now; // 执行原始函数,并传递参数 fn.apply(that, [...args]); }, delay+delay-now); // 距离下一次执行时间越近越小,直到为0
运行效果
-