再谈防抖/节流

对于一名合格的前端工程师,
没有不知道防抖与节流的吧。

我并不是一名真正的前端,
正处于成为合格前端的路上。

在前端的学习过程中,关于[防抖/节流]
也听老师讲过,也看过一些大佬写的指南,
虽然当时明白,过后即忘;
关键在于理解和思路,今天整理一下,
写一篇不懵圈指北分享给大家。

防抖

什么是防抖

我有两个儿子,大宝跟二仁。
这二人都是吃货。一天,

场景1

我 :大宝,爸爸今天升级加薪
   迎娶白富 美了,给你买好吃的,
   想吃点什么水果,选一个?
大宝:我想吃,西瓜,
   不对,是哈密瓜,
   不对,是芒果,
   不对,是荔枝,
   不对,是草莓。
我 :好的,草莓是吧,这就去买。

这就是【防抖】,永远只响应最新的(最后一次)请求。

防抖的JS实现

实现思路

实现一个防抖函数,作为包装器
接收两个参数:
实际要执行的函数(回调)
要延迟的时间限制
利用延时定时器,创造异步执行
如果已有定时器,则清空定时器
设置定时器,到执行时,清空当前定时器重新定时
返回包装后的响应函数

代码

Talk is cheap,show you the code

/**
 * 简单的`debounce`函数实现
 *
 * @param {function} cb 要执行的回调函数
 * @param {number} delay 要等待的防抖延迟时间
 * @returns {function}
 */
const debounce = function(cb, delay) {
    // 参数检查
    //   cb:function
    //   delay:number
    if (!cb || toString.call(cb) !== '[object Function]') {
        throw new Error(`${cb} is not a function.`)
    }
    // 没有传 delay 参数时(包括等于0)
    if (!delay) {
        delay = 500 // 设置默认延时
    }
    // delay 参数须为正整数
    else if (!Number.isInteger(delay) || delay < 0) {
        throw new Error(`${delay} is invalid as optional parameter [delay]`)
    }

    // 定时器
    let timer = null
    
    // 返回debounce包装过的执行函数
    return function(...args) {
        // 如果存在定时器
        if (timer) {
            // 清除定时器,
            // 即:忽略之前的触发
            clearTimeout(timer)
        }

        // 设置定时器
        timer = setTimeout(() => {
            // 当到了设定的时间:
            //   清除本次定时器,
            //   并执行函数
            clearTimeout(timer)
            timer = null
            cb.call(null, ...args)
        }, delay);
    }
}

防抖典型的应用场景:
 输入框的提示或搜索功能

如果随着输入实时检索,
将白费很多次请求;

这样利用防抖函数,可以设定500毫秒的延迟,当用户输入时不进行实时检索,超过500毫秒没有输入(停顿了)时,再发起检索请求,减少无谓的请求数量,节省网络和服务器资源。

放个窗口滚动的防抖示例,如下:

创建一个debounce_sample.html
chrome 浏览器打开,甩起你的滚动轮并查看 console,自己感受一下。

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>debounce sample</title>
</head>

<body>
    <h3>
        <ul>
            <li>打开console</li>
            <li>快速滚动鼠标滚动轮</li>
        </ul>
    </h3>
    <div style="height: 1500px;"></div>

    <script>
        /**
         * 简单的`debounce`函数实现
         *
         * @param {function} cb 要执行的回调函数
         * @param {number} delay 要等待的防抖延迟时间
         * @returns {function}
         */
        const debounce = function (cb, delay) {
            // ...此处省略以节省篇幅...
            // ...函数内容请参照上文...
        }

        // scroll in debounce
        const realEventHandler = function() {
            console.log('scroll in debounce')
        }
        const debouncedScrollHandler = debounce(realEventHandler, 500)
        window.addEventListener('scroll', debouncedScrollHandler)
    </script>
</body>

</html>

节流

什么是节流

场景2

[第一天]
二仁:靶鼻,我好想吃冰淇淋,
   给我买一个好吗?
我 :好吧,那就买一个,别告诉你哥。

[第二天]
二仁:靶鼻,我今天还想吃冰淇淋,
   再给我买一个好吗?
我 :想啥呢二仁。。。
   哪能天天吃?!!
   咱家经济状况你又不是不知道,
   再加上你出生后,更揭不开锅了,
   你也知道,
   你现在喝的一段奶粉是最贵的,
   你哥幼儿园一个月两千多,
   你爸我一个月才几千工资啊,
   还得养活全家,还房贷交房租少吗,
   balabala。。。
   (总之没买)
二仁 :好吧。。。

[第三天]
二仁:靶鼻,我有个小心愿,
   不是当讲不当讲。。。
我 :说来听听
二仁:就昨天那事儿,
   我还是想吃冰淇淋,
   再给我买一个好吗?
我 :看你可怜的样,今天我就答应你。
   回去可得让我尝尝,另外,
   悄悄地吃别让你哥看见。

[第四天]
二仁:靶鼻,我想吃冰淇淋。。。
我 :不行。
   昨天刚吃了。
   明天再说。

这就是【节流】,

一定时间内,只响应一次请求。
经过该给定时间间隔之后,才能再次响应。

节流的JS实现

实现思路

为了更方便理解,我们可以参考游戏里放大招,眼看敌人残血了,把握好机会立马放大准备收人头,说正事儿,放完大招,要等一个冷却时间过了,才能再次使用。

节流throttle看起来也是这么回事儿,请看下面的代码实现(包括注释):

代码

/**
 * `throttle`简单的节流函数实现
 *
 * @param {function} cb 要执行的回调函数
 * @param {number} wait 要设置的节流时间间隔
 * @returns {function}
 */
const throttle = function (cb, wait) {
    // 参数检查
    //   cb:function
    //   wait:number
    if (!cb || toString.call(cb) !== '[object Function]') {
        throw new Error(`${cb} is not a function.`)
    }
    // 没有传 wait 参数时(包括等于0)
    if (!wait) {
        wait = 500 // 设置默认延时
    }
    // wait 参数须为正整数
    else if (!Number.isInteger(wait) || wait < 0) {
        throw new Error(`${wait} is invalid as optional parameter [wait]`)
    }
    
    // 用来记录上次执行的时刻
    let lasttime = Date.now()

    return function (...args) {
        const now = Date.now()
        // 两次执行的时间间隔
        const timespan = now - lasttime
        // 当间隔小于等待时间即处于冷却中
        const isCoolingDown = timespan < wait

        console.log(timespan, isCoolingDown ? 'is cooling down' : 'execute')

        // 如果还没冷却好,就等待
        if (isCoolingDown) return

        // 记录本次执行的时刻
        lasttime = Date.now()

        // 冷却好了
        cb.apply(null, args)
    }
}

节流用在resize或者鼠标拖动之类的事件上是合适的,因为如果没有节流,体验会变得很糟糕。

下面我们创建一个throttle_sample.html来体验一下效果。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>throttle sample</title>
</head>
<body>
    <h3>
        <ul>
            <li>打开console</li>
            <li>快速滚动鼠标滚动轮</li>
        </ul>
    </h3>
    <div style="height: 1500px;"></div>

    <script src="throttle.js"></script>
    <script>
        const realEventHandler = function() {
            console.log('scroll in throttle')
        }
        const scrollHandler = throttle(realEventHandler, 2000)  // 两秒钟内只响应一次(打印一次log)
        window.addEventListener('scroll', scrollHandler)
    </script>
</body>
</html>

防抖+节流

如果你亲自体验了上面防抖的示例,可能会发现这样一个问题:

当我一直滚动鼠标滚动轮不松手时,那就一直不会触发事件,耗一年也不会。
这,是不是问题?
在实际场景中,这,确实是个问题。

解决场景1存在的问题

场景1问题

我 :大宝,爸爸今天升级加薪
   迎娶白富 美了,给你买好吃的,
   想吃点什么水果,选一个?
大宝:我想吃,西瓜,
   不对,是哈密瓜,
   不对,是樱桃,
   不对,是葡萄,
   不对,是橙子,
   不对,是香蕉,
   不对,是芒果,
   不对,是荔枝,
   。。。
  (一个小时过去了
   。。。
   不对,是草莓,
我 :先停,谁家爸爸这么有耐心,
   都听你你说一个小时了,
   就按最后一个也就是第1024个:
   【草莓】,这就去买。

解决办法:防抖 + 节流

这就是【防抖 + 节流】,

在规定的时间内,
只响应最后一次请求,之前的都忽略
只响应最后一次请求,
规定的时间内,必须要响应一次

说人话:

防抖:只响应最后一次请求
节流:单位时间间隔内只响应一次
–> 只响应单位间隔期间里最后一次请求

实现一个【防抖 + 节流】

这里有一点不太好理解的地方,
就是需要两个定时器,
分别记录 防抖/节流。

如果没有节流函数定时器,且超出规定的时间间隔,说明用户操作没有中断,此时需要强制执行一次回调函数响应
其他情况,则按照防抖函数处理
注意执行回调函数响应时,两个定时器都要清空

/**
 * 防抖+节流的组合实现
 *
 * @param {function} cb 要执行的回调函数
 * @param {number} wait 要设置的防抖节流时间
 * @returns {function}
 * 
 * 思路:
 *   1. 第一次触发或者一直触发,考虑 throttle,定时响应一次(强制响应)
 *   2. 如果没有一直触发,则使用 debounce,响应最后一次请求
 */
const combinedDebounceThrottle = function(cb, wait) {
    // TODO:参数检查
    //   cb:function
    //   wait:number
    // 略(参照 debounce.js 或 throttle.js 部分)

    let lasttime = 0
    let timerDebounce = null
    let timerThrottle = null

    // 执行一次回调响应的处理部分
    function executeCb(...args) {
        clearTimeout(timerDebounce)
        clearTimeout(timerThrottle)
        timerDebounce = null
        timerThrottle = null
        lasttime = Date.now()
        cb.apply(null, args)
    }

    return function(...args) {
        const now = Date.now()
        const timespan = now - lasttime
        const isCoolingDown = timespan < wait

        clearTimeout(timerDebounce)

        // 如果一直 debounce 而没有执行响应,
        // 且,超过冷却时间,则强制执行一次
        if (!timerThrottle && !isCoolingDown) {
            timerThrottle = setTimeout(executeCb, wait, ...args)
        }
        // 如果不是一直触发,则在延迟时间后做一次响应(使用debounce)
        else {
            timerDebounce = setTimeout(executeCb, wait, ...args)
        }
    }
}

还是按照惯例,创建一个combined_sample.html文件体验效果:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>throttle sample</title>
</head>
<body>
    <h3>
        <ul>
            <li>打开console</li>
            <li>快速滚动鼠标滚动轮</li>
        </ul>
    </h3>
    <div style="height: 1500px;"></div>

    <script src="combinedDebounceThrottle.js"></script>
    <script>
        const realEventHandler = function(e) {
            console.log('scroll in debounce-throttle combined mode', e)
        }
        const scrollHandler = combinedDebounceThrottle(realEventHandler, 2000)  // 两秒钟内只响应一次(打印一次log)
        window.addEventListener('scroll', scrollHandler)
    </script>
</body>
</html>

可以看到,即便一直滚动,也会在2秒后打印出控制台信息。
这样就实现了防抖+节流的组合模式。

白话了半天,读者朋友们是不是都理解了?

其他补充

underscore.js中为我们提供了功能更为丰富的防抖与节流函数。
是不是在想:你怎么不早说?!
实现得越复杂就越不容易理解嘛~现在看了我的讲解,再去看 underscore.js 库里封装的优秀方法,也就能更好地理解了。

underscore 里的防抖与节流,为我们提供了更为丰富的配置选项和自定义的可能,能够应对更多的业务场景,下面就来探究一下 underscore 中是怎样的吧。

防抖debounce

_.debounce(func, wait, [immediate])
参数 说明
function 处理函数
wait 指定的毫秒数间隔
immediate 立即执行Flag
可选。
默认:false

功能解析:

前两个参数与前面介绍的throttle是一样的,第三个参数,
immediate指定第一次触发或没有等待中的时候可以立即执行。

知道了原理,我们来简单写一下代码实现。

// debounce 防抖:
// 用户停止输入&wait毫秒后,响应,
// 或 immediate = true 时,没有等待的回调的话立即执行
// 立即执行并不影响去设定时器延迟执行
_.debounce = function(func, wait, immediate){
    var timer, result

    var later = function(...args){
        clearTimeout(timer)
        timer = null
        result = func.apply(null, args)
    }

    return function(...args){
        // 因为防抖是响应最新一次操作,所以清空之前的定时器
        if(timer) clearTimeout(timer)

        // 如果配置了 immediate = true
        if(immediate){
            // 没有定时函数等待执行才可以立即执行
            var callNow = !timer

            // 是否立即执行,并不影响设定时器的延迟执行
            timer = setTimeout(later, wait, ...args)

            if(callNow){
                result = func.apply(null, args)
            }
        }
        else{
            timer = setTimeout(later, wait, ...args)
        }

        return result
    }
}

节流throttle

_.throttle(function, wait, [options])
参数 说明
function 处理函数
wait 指定的毫秒数间隔
options 配置
可选。
默认:
{
 leading: false,
 trailing: false  
}

针对第一次触发,

leading : true 相当于先执行,再等待wait毫秒之后才可再次触发
trailing : true 相当于先等待wait毫秒,后执行

默认:
leading : false => 阻止第一次触发时立即执行,等待wait毫秒才可触发
trailing : false => 阻止第一次触发时的延迟执行,经过延迟的wait毫秒之后才可触发

可能的配置方式:
(区别在首次执行和先执行还是先等待)

配置 结果

{
  leading: false,
  trailing: false }                           

第一次触发不执行,后面同普通throttle,执行 + 间隔wait毫秒
{
  leading: true,
  trailing: false
}
第一次触发立即执行,后面同普通throttle,执行 + 间隔wait毫秒
{
  leading: false,
  trailing: true
}
每次触发延迟执行,每次执行间隔wait毫秒
{
  leading: true,
  trailing: true
}
每一次有效触发都会执行两次,先立即执行一次,后延时wait毫秒执行一次

知道了原理,我们来简单写一下代码实现。

_.now = Date.now

_.throttle = function(func, wait, options){
    var lastTime = 0
    var timeOut = null
    var result
    if(!options){
        options = { leading: false, trailing: false }
    }

    return function(...args){  // 节流函数
        var now = _.now()

        // 首次执行看是否配置了 leading = false = 默认,阻止立即执行
        if(!lastTime && options.leading === false){
            lastTime = now
        }
        // 配置了 leading = true 时,初始值 lastTime = 0,即可以立即执行

        var remaining = lastTime + wait - now
        // > 0 即间隔内
        // < 0 即超出间隔时间

        // 超出间隔时间,或首次的立即执行
        if(remaining <= 0){     // trailing=false
            if(timeOut){
                // 如果不是首次执行的情况,需要清空定时器
                clearTimeout(timeOut)
                timeOut = null
            }
            lastTime = now      // #
            result = func.apply(null, args)
        }
        else if(!timeOut && options.trailing !== false){    // leading
            // 没超出间隔时间,但配置了 leading=fasle 阻止了立即执行,
            // 即需要执行一次却还未执行,等待中,且配置了 trailing=true
            // 那就要在剩余等待毫秒时间后触发
            timeOut = setTimeout(()=>{
                lastTime = options.leading === false ? 0 : _.now()      // # !lastTime 的判断中需要此处重置为0
                timeOut = null
                result = func.apply(null, args)
            }, remaining);
        }

        return result
    }
}

除了上文介绍的配置,还加入了可取消功能(cancel)(from 1.9.0)

小结

throttledebounce是解决请求和响应速度不匹配问题的两个方案

二者的差异在于选择不同的策略

debounce的关注点是空闲的间隔时间,
throttle的关注点是连续的执行间隔时间。

应用场景

游戏设计,keydown事件
文本输入、自动完成,keyup事件
鼠标移动mousemove事件
DOM元素动态定位,window对象的resizescroll事件

总结比较

对于我们最开始的简单防抖/节流实现

相同

debounce防抖与throttle节流都实现了单位时间内,函数只执行一次

不同

debounce防抖:
单位时间内,忽略前面的,响应最新的,并在延迟wait毫秒后执行
throttle节流:
响应第一次的,单位时间内,不再响应,直到wait毫秒后才再响应

以上。再说不懂防抖节流算我输。


最后,感谢您的阅读和支持~