Debounce和Throttle 的原理及实现
Author:zhoulujun Date:
在处理诸如 resize、scroll、mousemove 和 keydown/keyup/keypress 等事件的时候,通常我们不希望这些事件太过频繁地触发,尤其是监听程序中涉及到大量的计算或者有非常耗费资源的操作。
有多频繁呢?以 mousemove 为例,根据 DOM Level 3 的规定,「如果鼠标连续移动,那么浏览器就应该触发多个连续的 mousemove 事件」,这意味着浏览器会在其内部计时器允许的情况下,根据用户移动鼠标的速度来触发 mousemove 事件。(当然了,如果移动鼠标的速度足够快,比如“刷”一下扫过去,浏览器是不会触发这个事件的)。resize、scroll 和 key* 等事件与此类似。这时候,就需要debounce 和 throttle出场了。
Debounce
DOM 事件里的 debounce 概念其实是从机械开关和继电器的“去弹跳”(debounce)衍生 出来的,基本思路就是把多个信号合并为一个信号。这篇文章 解释得非常清楚,感兴趣的可以一读。
在 JavaScript 中,debounce 函数所做的事情就是,强制一个函数在某个连续时间段内只执行一次,哪怕它本来会被调用多次。我们希望在用户停止某个操作一段时间之后才执行相应的监听函数,而不是在用户操作的过程当中,浏览器触发多少次事件,就执行多少次监听函数。
比如,在某个 3s 的时间段内连续地移动了鼠标,浏览器可能会触发几十(甚至几百)个 mousemove 事件,不使用 debounce 的话,监听函数就要执行这么多次;如果对监听函数使用 100ms 的“去弹跳”,那么浏览器只会执行一次这个监听函数,而且是在第 3.1s 的时候执行的。
现在,我们就来实现一个 debounce 函数。
我们这个 debounce 函数接收两个参数,第一个是要“去弹跳”的回调函数 fn,第二个是延迟的时间 delay。
/** * param action {Function} * param delay {Number} * * */ function debounce(action,delay) { let timer=null; return function () { clearTimeout(timer); timer=setTimeout(function () { action.apply(this,arguments) },delay) } }
实际上,大部分的完整 debounce 实现还有第三个参数 immediate ,表明回调函数是在一个时间区间的最开始执行(immediate 为 true)还是最后执行(immediate 为 false),比如 underscore 的 _.debounce。本文不考虑这个参数,只考虑最后执行的情况,感兴趣的可以自行研究。
其实思路很简单,debounce 返回了一个闭包,这个闭包依然会被连续频繁地调用,但是在闭包内部,却限制了原始函数 fn 的执行,强制 fn 只在连续操作停止后只执行一次。
debounce 的使用方式如下:
window.document.body.onscroll=debounce(function () { console.log('throttle'); },1200);
Throttle
throttle 的概念理解起来更容易,就是固定函数执行的速率,即所谓的“节流”。
比如:如果将水龙头拧紧直到水是以水滴的形式流出,那你会发现每隔一段时间,就会有一滴水流出。
也就是会说预先设定一个执行周期,当调用动作的时刻大于等于执行周期则执行该动作,然后进入下一个新周期。
在浏览器 DOM 事件里面,有一些事件会随着用户的操作不间断触发。比如:重新调整浏览器窗口大小(resize),浏览器页面滚动(scroll),鼠标移动(mousemove)。也就是说用户在触发这些浏览器操作的时候,如果脚本里面绑定了对应的事件处理方法,这个方法就不停的触发。
这并不是我们想要的,因为有的时候如果事件处理方法比较庞大,DOM 操作比如复杂,还不断的触发此类事件就会造成性能上的损失,导致用户体验下降(UI 反映慢、浏览器卡死等)。所以通常来讲我们会给相应事件添加延迟执行的逻辑。
正常情况下,mousemove 的监听函数可能会每 20ms(假设)执行一次,如果设置 200ms 的“节流”,那么它就会每 200ms 执行一次。比如在 1s 的时间段内,正常的监听函数可能会执行 50(1000/20) 次,“节流” 200ms 后则会执行 5(1000/200) 次。
通常来说我们用下面的代码来实现这个功能:
let COUNT = 0; function testFn() { console.log(COUNT++); } // 浏览器resize的时候 // 1. 清除之前的计时器 // 2. 添加一个计时器让真正的函数testFn延后200毫秒触发 window.onresize = function () { let timer = null; clearTimeout(timer); timer = setTimeout(function() { testFn(); }, 200); };
细心的同学会发现上面的代码其实是错误的,这是新手会犯的一个问题:setTimeout 函数返回值应该保存在一个相对全局变量里面,否则每次 resize 的时候都会产生一个新的计时器,这样就达不到我们发的效果了
于是我们修改了代码:
let timer = null; window.onresize = function () { clearTimeout(timer); timer = setTimeout(function() { testFn(); }, 200); };
这时候代码就正常了,但是又多了一个新问题 —— 产生了一个全局变量 timer。这是我们不想见到的,如果这个页面还有别的功能也叫 timer 不同的代码之前就是产生冲突。为了解决这个问题我们要用 JavaScript 的一个语言特性:闭包 closures 。相关知识读者可以去 MDN 中了解,改造后的代码如下:
* 频率控制 返回函数连续调用时,action 执行频率限定为 次 / delay * @param action {function} 请求关联函数,实际应用需要调用的函数 * @param delay {number} 延迟时间,单位毫秒 * @return {function} 返回客户调用函数 */
function throttle(aciton,delay) { let timer,now,last; delay||(delay=200); return function () { now=+new Date(); if(last&&now-last<delay){ clearTimeout(timer); timer=setTimeout(function () { last=now; aciton.apply(this,arguments) },delay) }else { last=now; aciton.apply(this,arguments) } } }
我们用一个闭包函数(throttle节流)把 timer 放在内部并且返回延时处理函数,这样以来 timer 变量对外是不可见的,但是内部延时函数触发时还可以访问到 timer 变量。
这里主要了解一点:throttle 被调用后返回的 function 才是真正的 onresize 触发时需要调用的函数
debounce 和 throttle可视化解
如果还是不能完全体会 debounce 和 throttle 的差异,可以到 这个页面 看一下两者可视化的比较。
debounce 强制函数在某段时间内只执行一次,throttle 强制函数以固定的速率执行。在处理一些高频率触发的 DOM 事件的时候,它们都能极大提高用户体验。
参考文章:
转载本站文章《Debounce和Throttle 的原理及实现》,
请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/js/2017_0712_8028.html