防抖和节流

一、防抖(debounce)

指定时间内,方法只能执行一次,多余的事件直接忽略掉(underscore, lodash库的debounce都是这样做的)。而这个时间的计算,是从最后一次触发监听事件开始算起。
比如:

  • 按关闭电梯门的按钮时,电梯并不会里面关门,而是等一等看看是否有人要上电梯(即操作要延迟执行);
  • resize事件(如果只是看resize后最终的效果,可以利用debounce);
  • 提交按钮防抖操作(一般操作需要立马执行)。

1.2 基于概念实现:

实现debounce的关键点是如何计算delay时间(实现throttle的关键点也一样)。

function debounce(cb, delay) {
    var timeoutId;
    return function debounced() {
        // 每次调用都清除上次的延迟,然后重新创建延迟
        timeoutId&& clearTimeout(timeoutId);
        timeoutId= setTimeout(() => {
            cb.apply(this, arguments);
        }, delay)
    }
}

1.3 各种实现中增加一些额外功能:

  1. 前缘(或者“immediate”)

是先执行操作后等待,还是先等待后执行操作。

前缘debounce:
image

延迟debounce:
image

两者计算delay时间的方式是一样的,唯一的区别就是触发调用函数的时间点

  1. 取消操作

比如在计算延迟过程中组件卸载等导致的了清除操作。

带有"取消操作"和“前缘”的防抖方法(非一次写出,多次优化的结果):

function debounce(cb, delay, immediate) {
    var timeoutId;
    // cancel操作,只依赖timeoutId,所以应避免cancel函数放在debounced函数里声明(这回导致每次调用debounced都会重新声明定义cancel)
    var cancel = debounced.cancel = function cancel() {
        if(timeoutId) {
            clearTimeout(timeoutId);
            timeoutId= null;
        }
    }

    function debounced() {
        var args = arguments;
        var self = this;    
         
        function action() {
            cb.apply(self, args);
        }      
         
        // 利用timeoutId标记是否已经调用了cb
        if(immediate && !timeoutId) {
            action();
        }

        // 清除上一个delay
        cancel();

        /*
        * 开启新的delay
        * immediate=true表示delay取消操作,否则delay函数执行。
        */
        timeoutId= setTimeout(immediate ? cancel : action, delay)       
    }
        
    return debounced;
}

上面实现存在个问题,它是利用setTimeout创建/取消延时的,而不是动态计算延时。这回导致如果连续的操作中存在耗时运算,会导致setTimeout回调不能及时触发。

function sleep(delay) {
    var pre = Date.now();
    while(Date.now() - pre < delay) {}
}

var debounced = debounce((a, b) => {
     console.log(`a+b=${a+b}`)
     return a + b;
}, 100, true)

// Case1
debounced(1, 23)
sleep(delay) // 同步方式延迟,导致`debounced`里的`setTimeout(immediate ? cancel : action, delay)`回调函数不会被执行
debounced(1, 24) // 虽然时间过去600ms了,但是还是被忽略掉了。

// Case2
debounced(1, 25)
setTimeout(() => {
    debounced(1, 26) // 改成异步,就可以触发了
}, 600)

不过这样的实现相对简单清晰,并且绝大部分情况不会存在问题。
underscore debounce也是采用类似方式。

二、节流(throttle)

指定时间内(执行时间窗口),方法只能执行一次,多余的事件直接忽略掉(underscore, lodash库的throttle都是这样做的)。而这个时间的计算是从上次执行方法开始算起。
比如:上划展示更多商品时调用接口的频率会降低,一般处理scroll, resize, touchmove, mousemove等事件的处理函数。

2.1 基于概念的实现

function throttle(func, delay) {
    var timeoutId;

    var cancel = throttled.cancel = function cancel() {
        if(timeoutId) {
            clearTimeout(timeoutId);
            timeoutId = null;
        }
    }

    function throttled() {
        var context = this;
        var args = arguments;
        
        if(!timeoutId) {
            timeoutId = setTimeout(function() {
                cancel();
                action();
            }, delay)
        }

        function action() {
            func.apply(context, args);
        }
    }
    return throttled;
}
  1. 逻辑简单,但func只能延迟执行;
  2. 严格来说时间计算规则是不对的,这个实现的时间计算是从首次throttled函数调用计算的,而正确的应该是从上一次func调用开始计算。

2.2 改进:并借鉴增加前缘(throttle里称为leading,trailing调用)控制

leading方式调用时,delay的时间得是动态的,因为在一个时间段内,后调用的throttled函数被delay时间就得短。

function throttle(func, delay, leading) {
    var timeoutId, preCallTime;

    var cancel = throttled.cancel = function cancel() {
        if(timeoutId) {
            clearTimeout(timeoutId);
            timeoutId = null;
        }
    }

    function throttled() {
        var context = this;
        var args = arguments;
        var now = Date.now();

        // 头部方式调用
        if(leading) {
            if(!preCallTime) {
                preCallTime = 0;
            }
            
            var remain = delay - (now - preCallTime);
            // 利用剩余时间控制是否可以调用action(可防止多次触发action)
            if(remain <= 0) {
                action();
            }
            preCallTime = now;
            return;
        }

        
        if(!timeoutId) {
            timeoutId = setTimeout(function() {
                cancel();
                action();
            }, delay)
        }

        function action() {
            func.apply(context, args);
        }
    }
    return throttled;
}
  1. leading方式通过计算剩余时间控制执行func,已经不依赖setTimeout了;
  2. trailing方式回调函数必须延迟执行,依赖setTimeout不能避免;
  3. trailing方式代码逻辑中delay本质也是剩余时间,还可以优化下代码组织。

2.3 再改进

function throttle(func, delay, leading) {
    var timeoutId, preCallTime;

    var cancel = throttled.cancel = function cancel() {
        if(timeoutId) {
            clearTimeout(timeoutId);
            timeoutId = null;
        }
    }

    function throttled() {
        var context = this;
        var args = arguments;
        var now = Date.now();

        if(!preCallTime) {
            preCallTime = leading ? 0 : now;
        }

        var remain = delay - (now - preCallTime);

        if(remain <= 0) {
            action();  
        // 未达到规定时间 & trailing方式时的首次调用     
        } else if(!leading && !timeoutId) {
            timeoutId = setTimeout(action, remain);
        }
        
        function action() {
            func.apply(context, args);
            preCallTime = now;
            cancel();
        }
    }
    return throttled;
}

总结

image
从上面的描述我们可以理解到防抖和节流都是控制“指定时间内,方法只能执行一次”,它们的区别在于如何计算时间间隔:

  • debounce从最后一次调用debounced函数开始算起(新触发的会覆盖上一次触发的);

防抖动

  • throttle是从上次调用throttled方法开始算起(新出发的时间 - 上次触发的时间)。

节流,控制频率

实现合一?
lodash确实合一了,但是代码可读性很差。

整理自gitHub笔记


普拉斯强
2.7k 声望53 粉丝

Coder