女友都懂系列之防抖与节流分析

5,878 阅读12分钟

前言

在日常开发或者面试中,防抖与节流应该都是属于高频出现的点。这篇文章主要是基于冴羽(后续用他代称)大神的两篇文章 防抖节流来写的。因为自己在看他文章的时候也对其中的代码产生了一些困惑,有一些卡住的地方,所以想把自己遇到的问题都抛出来,一步步的去理解。 文中具体的场景demo以他的为例,就不单独在举场景例子了。

防抖与节流的定义

  • 防抖:事件持续触发,但只有当事件停止触发后n秒才执行函数。
  • 节流:事件持续触发时,每n秒执行一次函数。

防抖

持续触发事件不执行,等到事件停止触发后n秒才去执行函数。

// 第一版
const debounce = function(func, delay) {
    let timeout;
    return function () {
        const context = this;
        const args = arguments;
        clearTimeout(timeout)
        timeout = setTimeout(() => {
            func.apply(context, args)
        }, delay);
    }
}

第一版没什么难点,当用户持续触发就一直清除计时器,当他最后一次触发后,会生成一个计时器,同时计时器中的方法将在delay秒执行。

新增需求:不等到事件停止触发后才执行,希望立即执行函数。然后等到停止触发n秒后,才重新触发执行。

先来拆分需求:

  • 立即执行函数
  • 停止触发n秒后,才重新触发

立即执行函数很容易实现func.apply(context, args)即可。但是不可能当用户持续触发的时候一直去调用func这个函数,所以这里想到需要一个字段来判断何时能够去执行func函数。

// 第二版
const debounce = function (func, delay) {
    let timer,
        callNow = true; // 是否立即执行函数的标识
    return function () {
        const context = this;
        const args = arguments;
        if (timer) clearTimeout(timer);
        if(callNow) {
            func.apply(context, args); // 触发事件立即执行
            callNow = false; // 将标识设置为false,保证后续在delay秒内触发事件都无法执行函数。    
        } else {
            timer = setTimeout(() => {
                callNow = true; // 过delay秒后才能再次触发函数执行。
            }, delay) 
        }
    }
}

新增需求:加个immediate参数来判断是否立刻执行。

其实通过上面那个简化版,这次加个参数字段来区分就很好实现了。

const debounce2 = function (func, delay, immediate = false) {
    let timer,
        callNow = true;
    return function () {
        const context = this;
        const args = arguments;
        if (timer) clearTimeout(timer);
        if (immediate) {
            if(callNow) func.apply(context, args); // 触发事件立即执行
            callNow = false;
            timer = setTimeout(() => {
                callNow = true; // 过n秒后才能再次触发函数执行。
            }, delay)
        } else {
            timer = setTimeout(() => {
                func.apply(context, args);
            }, delay)
        }
    }
}

返回值

getUserAction函数可能是有返回值的,所以这里也需要返回函数的结果。但当immediatefalse的时候,因为setTimeout的缘故,在最后return的时候值会一直是undefined。所以只在immediatetrue的时候返回函数的执行结果。

const getUserAction = function(e) {
    this.innerHTML = count++;
    return 'Function Value';
}

const debounce = function (func, delay, immediate = false) {
    let timer,
        result,
        callNow = true;
    return function () {
        const context = this;
        const args = arguments;
        if (timer) clearTimeout(timer);
        if (immediate) {
            if(callNow) result = func.apply(context, args);
            callNow = false;
            timer = setTimeout(() => {
                callNow = true; // 过n秒后才能再次触发函数执行。
            }, delay)
        } else {
            timer = setTimeout(() => {
                func.apply(context, args);
            }, delay)
        }
        return result;
    }
}
    
// demo test    
const setUseAction = debounce(getUserAction, 2000, true);
    // 展示函数返回值
    box.addEventListener('mousemove', function (e) {
        const result = setUseAction.call(this, e);
        console.log('result', result);
    })

取消

希望能够取消debounce函数,可以让用户执行此方法(cancel)后,取消防抖,当用户再次去触发时,就可以又立刻执行了。

需求思考:取消防抖,其实说白了就是清除掉之前存在的计时器。这样当用户再次触发的时候就能立刻执行函数啦。嘿嘿😝是不是很简单啊!

const debounce = function (func, delay, immediate = false) {
    let timer,
        result,
        callNow = true;
    const debounced = function () {
        const context = this;
        const args = arguments;
        if (timer) clearTimeout(timer);
        if (immediate) {
            if(callNow) result = func.apply(context, args);
            callNow = false;
            timer = setTimeout(() => {
                callNow = true; // 过n秒后才能再次触发函数执行。
            }, delay)
        } else {
            timer = setTimeout(() => {
                func.apply(context, args);
            }, delay)
        }
        return result;
    };
    debounced.cancel = function(){
        clearTimeout(timer);
        timer = null;
    }
}

经过这样的一系列拆分是不是顿时觉得防抖也就那么回事嘛,并没有多难~

节流

节流的两种主流实现方式:1.时间戳; 2.设置定时器。

时间戳

触发事件时,取出当前的时间戳,然后减去之前的时间戳(最开始设置为0)。若大于设置的时间周期,则执行函数,同时更新时间戳为当前的时间戳。若小于,则不执行。

const throttle = function(func, delay) {
    let prev = 0; // 将初始的时间戳设为0,保证第一次触发就一定执行函数
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        if (now - prev > delay) {
            func.apply(context, args);
            prev = now;
        }
    }
}

存在的问题

每过delay秒会执行一次函数,但是当最后一次触发的时间少于delay,则now - prev < delay,导致最后一次触发并没有执行函数。

定时器

触发事件时,设置一个定时器。当再次触发事件时,若定时器存在就不执行;直到定时器内部方法执行完,然后清空定时器,设置下一个定时器。

const throttle = function(func, delay){
    let timer;
    return function(){
        const context = this;
        const args = arguments;
        if (!timer) {
            timer = setTimeout(() => {
                timer = null; // delay秒重置timer值为null,为了重新设置一个新的定时器。
                func.apply(context, args);
            }, delay);
        }
    }
}

存在的问题

当首次触发事件的时候不会执行函数。

双剑合璧

这版要实现两个需求:

  • 首次触发事件立即执行
  • 停止触发事件后依然再执行一次事件

这里先贴下他的代码。

双剑合璧

说实话刚看到这段代码的时候我自己也是懵的,后面仔细思考了一会儿才完全想通。这边我将自己如何理解这段代码的思路写下来,帮助大家层层实现这个需求。

先看第二个需求(停止触发事件后依然再执行一次事件),其实说白了就是延迟执行事件,此时我就会先想到这块要用上setTimeout。但是有一个问题在于setTimeout的第二个参数延迟多少秒后触发呢?假设每3s执行一次函数,执行了3次,我在第9.5的时候停止触发事件。那么后续将要过多少秒才能执行这最后一次触发对应的事件呢?(12 - 9.5 = 2.5s)

// 伪代码片段如下
const throttle1 = function(func, delay){
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        const remaining = delay - (now - prev); // 关键点:剩余时间
        // 设置!timer条件是为了防止在已有定时器的情况下,再次触发事件又去生成一个新的定时器。
        if (remaining > 0 && !timer) {
            timer = setTimeout(() => {
                prev = +new Date();
                timer = null;
                func.apply(context, args);    
            }, remaining)
        }
    }
}

再来看第一个需求(首次触发事件立即执行),想要首次触发只需要将prev设为0,这样就能确保在第一次的时候delay - (now - prev)的值一定是小于0的。

// 伪代码片段如下
const throttle2 = function(func, delay){
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        const remaining = delay - (now - prev); // 关键点:下次触发 func 剩余时间
        // 设置!timer条件是为了在已有定时器的情况下,再次触发事件又去新生成了一个定时器。
        if (remaining <= 0) {
            // 这段代码的实际意义?
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }
            prev = now;
            func.apply(context, args);    
        }
    }
} 

完整版本

const throttle = function(func, delay) {
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        const remaining = delay - (now - prev);
        if (remaining <= 0) {
            prev = now;
            func.apply(context, args);    
        } else if(!timer) {
            timer = setTimeout(() => {
                prev = +new Date();
                timer = null;
                func.apply(context, args);    
            }, remaining)    
        }
    }
}

现在基于上面两段代码来模拟操作下(假设delay值为3):

  • 首次触发:remaining值小于0,直接执行func函数同时更新prev的值(prev = now)。
  • 过1s后触发:remaining值为2且timer值为undefined。此时会设置一个定时器(2s后执行),定时器中的代码将会在2s后执行(更新prev值;执行func函数;重置timer的值)。
  • 过2s后触发:remaining值为1且timer有值,此时不会走进任何分支,即不会发生任何事情。
  • 过3s后触发:remaining值为0且timer值为null,此时更新prev的值,将timer设置为null且执行func函数。
  • 过4s后触发:remaining值为1且timer值为null,这个时候又会重复上面 过1s后触发 的步骤,生成一个新的定时器,定时器中的代码将在2s后执行。
  • 过9.2s后触发(停止触发后还能再执行一次):remaining值为2.8且timer值为null,生成一个新的定时器,并且定时器中的代码将在2.8s后执行。

不知道大家会不会有这样的疑问,我9.2s时停止触发了,然后我10s的时候又再次触发那会不会多产生新的定时器呢? 其实这个操作和上面的第二步与第三步类似,当10s再次触发的时候,虽然remaining的值为2,但是此时timer是有值的,所以并不会进入任何一条分支,即不会发生任何事。

不知道经过我这一拆分讲解,各位观众老爷有没有对上面截图的代码更清晰了一点呢😊?

优化版本

有时候希望无头有尾或者有尾无头。通过设置options作为第三个参数,然后根据传的值进行判断想要的效果。leading:false 表示禁用第一次执行; trailing:false 表示禁用停止触发的回调。

优化

老规矩先看下他的代码,当初刚看这版代码的时候我产生了如下几点疑问:。

  • 为什么later函数中,不直接写previous = new Date().getTime(),而写成previous =options.leading === false ? 0 : new Date().getTime()呢?;
  • 为什么要有if (!timeout) context = args = null这段代码呢?
  • 下面这段代码的意义?可能会走到这里吗?
if (timeout) {
    clearTimeout(timeout);
    timeout = null;
}

先将需求拆分下,先来看看设置leading = false如何实现禁用第一次执行的。这里可以想到导致首次触发就执行的关键就在于remaining的值小于0,那么其实只要想办法在首次触发的时候保证remaining的值大于0就好啦!(将prev的初始值设置等于now的值即可)

const throttle = function(func, delay, option = {}) {
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        // 首次触发时将prev值设置等于now值,禁止首次触发执行函数
        if (!prev && option.leading === false) {
            prev = now; // 确保首次触发时remaining的值大于0.    
        }
        const remaining = delay - (now - prev);
        if (remaining <= 0) {
            prev = now;
            func.apply(context, args);    
        } else if(!timer) {
            timer = setTimeout(() => {
                prev = option.leading === false ? 0 : +new Date(); // 这里为什么这样做,下面会解释到。
                timer = null;
                func.apply(context, args);    
            }, remaining)    
        }
    }
}

再看trailing = false是如何禁用停止触发的回调。同样思考下导致停止触发后还会再一次执行的原因在哪?其实就在于remaining的值是大于0,当它大于0时,就会去产生一个计时器,从而导致就算停止了触发仍然能在remaining秒后执行函数。所以只需要在产生计时器代码的条件判断上加上option.trailing !== false就可以禁止停止触发的回调啦。

const throttle = function(func, delay, option = {}) {
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        if (!prev && option.leading === false) {
            prev = now;
        }
        const remaining = delay - (now - prev);
        if (remaining <= 0) {
            prev = now;
            func.apply(context, args);    
        // 当option.trailing值被设置为false时,永远走不进这条分支,也就不会产生计时器。    
        } else if(!timer && option.trailing !== false) {
            timer = setTimeout(() => {
                prev = option.leading === false ? 0 : +new Date();
                timer = null;
                func.apply(context, args);    
            }, remaining)    
        }
    }
}

解释疑问1

为什么要将prev = option.leading === false ? 0 : +new Date(),而不是prev = +new Date()。其实关键点在于当prev = 0时,触发事件时就一定会执行if(!pre && option.leading === false) prev = now这段代码,进而能够确保remaining的值恒大于0,即用户不管下一次是什么时候再次触发事件时,都能保证代码走到else if这条分支。举个场景解释下(delay为3s)~

  • 用户首次触发滑动事件,remaining值大于0,所以会产生一个定时器且3秒后执行定时器内部代码。
  • 此时假设用户并没有持续3s都在触发事件,而是在第2s的时候就离开了可滑动的区域,再过1s后,计时器中的对应函数仍会照常执行。这时分水岭就出来了,若直接将prev = +new Date(),同时假设用户过了10s后再次去触发事件,因为现在prev有值,且deay - (now - prev)少于0(因为这时now-prev的值为10,大于3),所以会走入if(remaining <= 0)分支,这个时候就会立即执行func函数。这样就不符合需求所说的首次触发(注意这里的首次触发并不只是指第一次触发,如果后续离开了触发区域,过段时间再去触发,也还是被当作了首次触发。这个点一定要明白)不执行函数啦。
  • 再来看看prev = option.leading === false ? 0 : +new Date(),过10s后prev的值早已经为0,这时用户再次去触发事件,会执行prev = now这段代码,所以此时能确保remaining的值大于0,这样就能够保证用户再次首次触发事件时不会执行函数啦。而是生成一个定时器,3s后执行定时器中的方法。

解释疑问2

context = args = null主要是为了释放内存,因为JavaScript有自动垃圾收集机制,会找出那些不再继续使用的值,然后释放掉其占用的内存。垃圾收集器每隔固定的时间段就会执行一次释放操作。

解释疑问3

其实这一点我到现在也不是很确定。个人猜想这样做是为了防止定时器中的代码timeout = null并没有在指定时间内立刻执行(即timeout仍有值),感觉这段代码就是处理这种极端状况下的,能够确保timeout的值一定会被置为null。

结语

以上就是我对于防抖与节流的理解。接下来会出一篇 防抖与节流实战篇。 希望大家能在评论区中一起讨论起来,有任何好的idea也可以抛出来哦😬~

参考文章