• home > webfront > ECMAS > javascript >

    Debounce 和 Throttle 的原理及实现

    Author:zhoulujun Date:

    在处理诸如 resize、scroll、mousemove 和 keydown keyup keypress 等事件的时候,通常我们不希望这些事件太过频繁地触发,尤其是监听

    在处理诸如 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 的原理及实现

    详解JavaScript节流函数中的Throttle


    转载本站文章《Debounce 和 Throttle 的原理及实现》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/js/2017_0712_8028.html