一二三三二一,跟我一起念:“啊~”

JavaScript 防抖和节流

JS 防抖和节流

防抖和节流 anti-shake and throttling


防抖

防抖:是指在事件触发后的n秒内,该功能(事件)只能执行一次。如果在n秒内再次出发该事件,或者反复出发该事件,将阻止该事件。通常使用 setTimeout 重新计算函数执行时间。

常见的场景包括:

  • 文本框智能提示/自动补全(keyup):比如搜索软件的搜索框,输入“google”,会自动关联“google play”、“google play store”、“google chrome”等,如果用户输入太快,每按一个键都去后台搜索一次,则会浪费很多带宽资源。我们希望延迟一会儿,让用户把关键词输入完整之后再去搜索。
  • 浏览器窗口调整大小(resize)或鼠标移动(mousemove)等事件:前段会有调整浏览器窗口大小的时候触发某个事件,或者鼠标移动的时候触发某个事件,但这些事件会非常频繁的触发,会导致网页特别的卡,并且我们完全不需要如此频繁的处罚,我们希望延迟几秒后再触发。
  • 查询或提交按钮(click):阻止过于频繁的点击查询按钮。

实现方法:使用 setTimeout 延迟事件触发,期间有新的事件触发则重新刷新 delay time。并且需要使用闭包(closure)保存 timeoutID


测试没有防抖

用一个按钮来测试是最简单的,其他功能都会增加额外的代码。

测试下面这段代码,打开浏览器开发者工具(F12),查看输入,每点一次按钮都会在控制台输出两段文本。

<input type="button" id="btn" value="console">
<script>
    document.querySelector('#btn').addEventListener('click', consoleTimeNow)

    function consoleTimeNow() {
        console.log('Hi June.')
        console.log('Long time no see and i miss you very much.')
        console.log(new Date())
    }
</script>

假设这个按钮每点一次按钮都是一次 ajax 查询,那么就会造成不断的请求网络。


添加防抖函数

假设学校的选修课申请页面,在姓名这一栏没有输入完整的情况下,会自动模糊查询,用户在没有输入完整的情况下就可以匹配完整的结果。一个学生输入“Trump”之后,自动匹配“Donald John Trump”。

或者搜索引擎的搜索框,用户可能自己也不知道完整的关键字是什么,“智能提示”可以帮助到用户。

实现这些功能我们会给这个文本框注册keyup事件,但这个事件在用户每次按下按键都会触发,用户输入“Trump”就触发了5次,这样频繁的发起请求,会给数据库和网络增加很多压力,实际上用户完全不需要如此频繁的“自动补全”,甚至用户在连续输入的时候,频繁的“智能提示/自动补全”可能会影响到用户原本连贯的输入,如果我们可以延迟一段时间触发事件,只有在用户输入一段文本停顿一下的时候才触发,那就完美了。

所以我们需要实现的是每一次触发事件都延迟触发,在这期间再触发事件则重新计算延迟时间,直到用户停下输入,停止触发事件,不在刷新延迟时间,那在一段延迟之后,才会真正的触发事件。这就是防抖,这种情况非常适合“智能提示“和”自动补全”。

实现方式很简单,使用setTimeout来延迟运行,重复触发则清除上一个 timerout再新建一个,这样就实现了每一次都延时并且重复触发则不断重新计算timeout。

<input type="button" id="btn" value="console">
<script>
    document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow))

    function consoleTimeNow() {
        console.log('Hi June.')
        console.log('Long time no see and i miss you very much.')
        console.log(new Date())
    }

    // 防抖函数
    function debounce(fn) {
        // 在函数外创建一个 timeoutId,这个id会被闭包保存
        var timerId
        return function () {
            if (timerId != null)
                clearTimeout(timerId)
            timerId = setTimeout(fn, 1000)
        }
    }
</script>

这是防抖函数最核心的代码,有了思路之后,就是这么简单。


回调函数传参 方法1

添加传参时要注意,debounce 返回的嵌套函数是用来绑定事件的,所以这个嵌套函数不能接受我们传递的实参,前台函数的实参只可能是事件对象。我们要传参,需要从 debounce 函数传入,然后再又 debounce 内部 callback 的时候将函数传入 callback。

所以,如果使用 function 函数嵌套,要注意我们要使用外层debounce 函数的作用域下的 arguments,而不是嵌套函数作用域下的 arguments,为此我们需要在外层函数将 arguments 保存下来,返回函数会通过闭包保存这个值。

不过 ES6 的剪头函数并没有自己的作用域,它直接使用了父级函数的作用域,这样在剪头函数里头,可以直接使用 arguments 对象来读取实参。

<input type="button" id="btn" value="console">
<script>
    document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow, 'June'))

    function consoleTimeNow(args) {
        // 此时需要注意,args 是一个like array,第一个参数是callback function,第二个参数才是 "June"
        var name = args[1]
        console.log('Hi {0}.'.replace('{0}', name))
        console.log('Long time no see and i miss you very much.')
        console.log(new Date())
    }

    // 防抖函数
    function debounce(fn) {
        var timerId
        // 保存当前函数的形参,如果使用剪头函数则不需要在当前作用域保存。
        var args = arguments
        return function () {
            if (timerId != null)
                clearTimeout(timerId)

            // setTimeout 第一个参数接受一个函数,需要给他一个函数指针
            // 此时 args 是外层作用域的 arguments,会被保存在闭包里
            // arguments 是一个 like array
            timerId = setTimeout(function () {
                fn(args)
            }, 1000)
        }
    }
</script>

优化一下代码
<input type="button" id="btn" value="console">
<script>
    // 因为现在必须要传2个参数,fn和delay,所以如果想要把参数传入 callback,必须要从第3个参数开始传递
    // 如果只有一个参数(callback fn),第二个参数可以省略,因为防抖函数里设置了默认值
    document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow, 500, 'June'))

    function consoleTimeNow(name) {
        // 使用 apply 之后,会把数组扩展开
        console.log('Hi {0}.'.replace('{0}', name))
        console.log('Long time no see and i miss you very much.')
        console.log(new Date())
    }

    // 防抖函数-之前的版本改进
    function debounceES5(fn, delay) {
        // 默认 1000 毫秒
        delay = delay || 1000
        var timerId

        // 从第3个参数开始才是 callback function 的传入实参
        var args
        if (arguments.length > 2) {
            args = Array.prototype.slice.call(arguments).splice(2)
        }

        return function () {
            // 我们确定这个变量只会保存 timeoutId,所以简化一下完全不会有问题
            if (timerId) clearTimeout(timerId)

            var that = this

            timerId = setTimeout(function () {
                // fn(args)
                // 帮助调用对象绑定 this
                fn.apply(that, args)
            }, delay)
        }
    }


    // 防抖函数-ES6版本
    // ES6 可以使用参数默认值 delay = 200
    function debounce(fn, delay = 200) {
        let timerId = null;

        // ES6 的箭头函数没有自己的作用域和this指针,使用的都是父级的,所以不需要闭包保存 arguments 和 this
        return () => {
            if (timerId) clearTimeout(timerId);

            timerId = setTimeout(() => {
                // ES6可以使用扩展运算符
                fn.apply(this, [...arguments].splice(2));
            }, delay);
        }
    }
</script>

后续的code都在 ES6的基础上改动。


回调函数传参 方法2

第一种方式并不好,有一种更好的解决方案是 debounce 函数不接受 callback function 的参数,而是当调用 debounce 函数的时候,通过 band 函数传递参数。

<input type="button" id="btn" value="console">
<script>
    /*| 关键方法在这里,
    |*| 使用 bing 来传递 callback function 参数,这样就可以不受 debounceES5 参数的影响
    |*| bind 还可以指定 this
    |*/
    var btn = document.querySelector('#btn')
    btn.addEventListener('click', debounce(consoleTimeNow).bind(btn, 'June'))

    /*| 也可以用以下方式调用
    |*| - 不需要绑定参数
    |*| document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow))
    |*| - 不需要绑定 this
    |*| document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow).bind(null, 'June'))
    |*/
    
    function consoleTimeNow(name) {
        console.log('Hi {0}.'.replace('{0}', name))
        console.log('Long time no see and i miss you very much.')
        console.log(new Date())
    }

    // 防抖函数-之前的版本改进
    function debounceES5(fn, delay) {
        delay = delay || 1000
        var timerId

        return function () {
            if (timerId) clearTimeout(timerId)

            var that = this
            // 和方法1相比,不使用 debounceES5 函数的作用域,使用嵌套函数的作用域,调用时会通过bind绑定参数
            var args = arguments

            timerId = setTimeout(function () {
                fn.apply(that, args)
            }, delay)
        }
    }


    // 防抖函数-ES6版本
    function debounce(fn, delay = 1000) {
        let timerId = null;

        // 相比方法1,这里需要改成function函数,因为需要使用这个前台函数的this和arguments
        return function () {
            if (timerId) clearTimeout(timerId);
            // 这样就可以接受bing null,方便调用
            let that = this == null ? window : this

            // 这里要用箭头函数,使用的是它的父函数的this和arguments
            timerId = setTimeout(() => {
                fn.apply(that, arguments);
            }, delay);
        }
    }
</script>

立即执行版本

前面的代码是延迟执行,特别适合“自动填充”和“智能提示”。

而现在希望事件被立即执行,但是执行之后一段时间内需要防抖处理,不能被重复点击。需要过一段时间之后才能继续触发事件。这种防抖比较适合“提交”和“查询”按钮的防抖。

立即执行版本最简单的实现方式就是,回调函数立即执行而不是延迟执行,然后使用一个状态变量来确定是否允许执行,之前 setTimeout 那套逻辑用来控制状态变量。


<input type="button" id="btn" value="console">
<script>
    document.querySelector('#btn').addEventListener('click', debounceDelay(consoleTimeNow).bind(null, 'June'))

    function consoleTimeNow(name) {
        console.log('Hi {0}.'.replace('{0}', name))
        console.log('Long time no see and i miss you very much.')
        console.log(new Date())
    }

    // 防抖函数-ES5版本
    function debounceDelayEs5(fn, delay) {
        delay = delay || 1000
        var timerId

        return function () {
            if (timerId) clearTimeout(timerId)

            var that = this
            var args = arguments

            // 先计时再callback,计时需要刷新timerId,所以这里需要保存状态
            var allowRun = !timerId

            // 原本先是无脑的 create/clear timeout 来实现延迟的,timeoutId只是用来 clear的,并不需要用来判断状态
            // 但现在需要通过 timerId 来判断状态,所以执行完成之后必须要 timerId = null
            timerId = setTimeout(function () {
                timerId = null
            }, delay)

            if (allowRun) fn.apply(that, args)
        }
    }

    // 防抖函数-ES6版本
    function debounceDelay(fn, delay = 1000) {
        let timerId = null;

        // ES6版本的改动和ES5版本没啥区别
        return function () {
            if (timerId) clearTimeout(timerId);

            let that = this == null ? window : this
            var allowRun = !timerId

            timerId = setTimeout(() => {
                timerId = null
            }, delay);

            if (allowRun) fn.apply(that, arguments)
        }
    }
</script>

节流

节流:是指一段时间内只能触发一次。在一段时间内连续触发同一个事件,触发一次之后会阻止后面的事件再次被触发。

使用场景:比如查询按钮,查询可能需要2秒左右时间,避免用户在未出结果前多次点击查询按钮,限制用户2秒内只能查询一次。

实现方式:防抖是使用 setTimeout,但是每次重复触发防抖函数都会重新刷新 timeout。节流其实差不多,也可以使用 setTimeout,但每次触发防抖函数不刷新 timeout,而是阻止再次 callback,直到限制时间结束。

<input type="button" id="btn" value="console">
<script>
    document.querySelector('#btn').addEventListener('click', throttle(consoleTimeNow, 2000).bind(null, 'June'))

    function consoleTimeNow(name) {
        console.log('Hi {0}.'.replace('{0}', name))
        console.log('long time no see and i miss you very much.')
        console.log(new Date())
    }

    // Throttle
    function throttle(fn, delay = 1000) {
        let allowRun = true
        return function () {
            if (!allowRun) return

            setTimeout(() => {
                allowRun = true
            }, delay);

            allowRun = false
            fn.apply(this, arguments)
        }
    }
</script>


posted @ 2021-09-02 09:56  LucioLu  阅读(298)  评论(0编辑  收藏  举报

正在研究中