面试八股之手写一个防抖和节流

257 阅读11分钟

今天我们来讲一个面试中老生常谈的考点——手写一个防抖和节流。

那在动手之前,我们得先搞清楚什么是防抖和节流?它的应用场景是什么?

1. 什么是防抖和节流

在一些具有文本搜索框的网站中,当用户输入一串内容点击’搜索‘按钮时,此时前端已经向后端发送了http请求,但可能因为网速的问题,页面可能毫无动静,他可能就会去反复点击’搜索‘按钮,或者有些用户习惯于经常点击’搜索‘按钮。但其实用户点击一次’搜索‘按钮之后前端已经向后端请求了数据,那之后反复点击的操作是不是就不应该让它向后端发送请求了,否则就是无端的浪费资源了。

我们用来解决这种现象的方法,就叫防抖和节流。当用户在一段时间内反复点击’搜索‘按钮时,我们不要让它执行对应的操作。

那防抖的原理是什么呢?我们可以在用户点击’搜索‘按钮之后,开始计时。假设计时一秒,当用户在计时开始的1秒之内再一次点击了按钮,那我们就让上一次的点击操作作废,从这一次开始重新计时。如果用户在计时开始的1秒之内没有再次点击按钮我们就放送这个请求。这就叫防抖。

节流的原理就是节约流量,我们人为的规定去每隔一秒发送一次请求,当用户在10秒之内点击了100次的话,我们也只会发送10次请求。

2. 手写防抖

那了解完了防抖和节流的原理与应用场景,我们就自己来手写一个防抖和节流函数。

我们先来写一个防抖函数。

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端放送请求');
        }

        btn.addEventListener('click', handle)
        
    </script>
</body>

我们就设计这样一个简单的场景来模拟一下。有一个button按钮,给它绑定一个点击事件,当用户点击了button按钮之后,就会触发handle函数,就向后端发送一个请求。

PixPin_2024-12-21_08-41-08.gif

此时,当我反复点击它时,handle函数就会反复执行。但这些执行的操作都是没有意义的,所以我们要解决这个问题。

那我们来给它增加一个防抖效果。

我们写一个防抖函数debounce用来修饰一下它。

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端放送请求');
        }

        btn.addEventListener('click', debounce(handle))
        
        function debounce() {
            
        }
    </script>
</body>

我们知道的,addEventListener接收的第二个参数应该是一个函数体,当点击事件触发了,这个函数才会执行。而在这里,我们放的是一个函数的执行结果,是不符合语法的。所以我们得这样干,在debounce函数里返回一个匿名函数。这样debounce的执行结果就会是这个匿名函数,addEventListener的第二个参数就会是这个匿名函数,这样就符合了语法要求。

debounce函数就接收一个形参fn,就代表handle函数,我们就在匿名函数里触发掉fn。

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端放送请求');
        }

        btn.addEventListener('click', debounce(handle))
        
        function debounce(fn) {
            return function () {
                fn()
            }
        }
    </script>
</body>

此时实现的效果是不是和我们没有添加debounce函数时一样的效果。当触发了点击事件之后,这个匿名函数就会触发,然后我们在匿名函数里调用了handle函数,所以此时的效果会和一开始一样。

当用户点击按钮触发了点击事件之后,我们开始计时,一秒之后才执行。所以我们要写一个计时器,计时1秒。我们给debounce函数传一个参数代表延时时间。可能它放到其它地方去就不是延时1秒。

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端放送请求');
        }

        btn.addEventListener('click', debounce(handle, 1000))
        
        function debounce(fn, wait) {
            return function () {
               setTimeout(() => {
                    fn()
                }, wait)
            }
        }
    </script>
</body>

这样实现的效果是当用户点击了按钮,1秒钟之后handle函数才会执行。这样实现了当用户点击按钮时,能有一个计时器帮我们计时,但这样还没有实现我们想要的效果。当用户反复点击按钮时,只不过是让handle函数延时1秒执行了,所以还是反复向后端发送了请求。

我们得监测到当用户在计时器的1秒钟之内又点击了按钮的话,就得让上一次的操作作废,从这一次开始计时。所以我们得记录一下用户上一次点击按钮的操作。那我们怎么去记录上一次的操作并且还能让这一次去访问呢?

你盯着这个debounce函数看,你看一下它像什么。在debounce函数内部我们返回了一个函数出来使用,这是不是一个闭包呀。如果debounce函数内部的这个匿名函数访问了debounce函数中的变量,debounce函数就会将这个变量存在闭包里供匿名函数去访问。

如果我们将上一次的计时器存到闭包里,当用户在计时器计时时间内又点击了按钮的话,也就是又触发了匿名函数,我们就更新闭包中这个计时器,让它重新变为1秒,handle函数也就不会执行了。

所以我们可以在debounce函数内部定义一个变量timer,初始值为null。然后在匿名函数被调用时赋值为这个计时器。

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端放送请求');
        }

        btn.addEventListener('click', debounce(handle, 1000))
        
       function debounce(fn, wait) {
            let timer = null;
            return function () {
                if (timer) clearTimeout(timer);

                timer = setTimeout(() => {
                    fn()
                }, wait)
            }
        }
    </script>
</body>

在匿名函数内我们去判断,如果timer存在,说明用户在1秒钟之内又点击了一下按钮,那我们就把上一次的timer给清除掉,也就是上一次的定时器,这样上一次中的handle函数就不会触发,然后再给timer赋值为这一次的定时器,留给下一次去调用。这样我们就相当于更新了一下定时器,并且把上一次的定时器给清除掉了。

这样就能实现防抖的效果,当用户快速地反复点击按钮时,只会执行最后一次,前面的计时器都被清除掉了。注意这里是快速反复的点击才会有效果,如果用户是点了一下1秒钟之后再点一下的话,那还是会执行两次。因为我们设计的延时时间为1秒钟。

所以我们来看一下有没有这个效果:

PixPin_2024-12-21_11-49-33.gif

我在快速反复地点击‘提交’按钮,只会执行最后一次。

这样这段代码防抖地效果已经实现了,但它还不够完美。为什么呢?我们来看:

我在handle函数里加一条语句:console.log(this),然后不用debounce函数修饰它,直接把handle函数放到addEventListener的第二个参数上。

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端放送请求');
            console.log(this)
        }

        btn.addEventListener('click', handle)
        
       // function debounce(fn, wait) {
        //     let timer = null;
        //     return function (...args) {
        //         if (timer) clearTimeout(timer);

        //         timer = setTimeout(() => {
        //             fn.call(this, ...args)
        //         }, wait)
        //     }
        // }
    </script>
</body>

你说这个this会指向谁?我们来分析一下,当点击事件被触发了,handle函数才会执行,说明在addEventListener的源代码中帮我们执行掉了这个handle函数,所以handle函数一定是隐式调用的,它的this会指向btn。

image.png

那我们加上debounce函数修饰之后,handle的this会指向谁?

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端放送请求');
            console.log(this)
        }

        btn.addEventListener('click', debounce(handle, 1000))
        
       function debounce(fn, wait) {
            let timer = null;
            return function () {
                if (timer) clearTimeout(timer);

                timer = setTimeout(() => {
                    fn()
                }, wait)
            }
        }
    </script>
</body>

你注意看,放在addEventListener第二个参数上的这个函数是debounce函数内部的匿名函数吧,我们在匿名函数里调用了handle函数,而且此时handle函数是独立调用的,this就会触发默认绑定,就会指向全局吧。

image.png

那因为我们添加了一个防抖效果就将原本函数该有的一个逻辑而改变了,是不是可能会出问题吧。所以我们得将this指回去,原本没加防抖函数的handle的this指向btn,加了防抖函数之后我们也得让this指向btn。

应该怎么做呢?是不是就要用到显示绑定了呀,我们可以使用call方法强行改变一个函数的this指向,所以在匿名函数里我们这么干:

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端放送请求');
            console.log(this)
        }

        btn.addEventListener('click', debounce(handle, 1000))
        
       function debounce(fn, wait) {
            let timer = null;
            return function () {
                if (timer) clearTimeout(timer);

                timer = setTimeout(() => {
                    fn.call(this)
                }, wait)
            }
        }
    </script>
</body>

我们用call方法强行让handle的this指向this,为什么可以这么干呢?请问此时fn绑定的this指向谁?

fn绑定的这个this是匿名函数的吧,而匿名函数是放在addEventListener第二个参数上的吧,所以匿名函数会被addEventListener调用吧。所以匿名函数中的this就会指向btn。然后我们再让fn的this指向这个this,那fn的this不就指向btn了吗,也就是handle函数的this会指向btn。

这样我们不就将this的指向改回来了吗。

image.png

你看,确实改回来了。

但现在,这个防抖函数还不够完美。我们知道,当一个函数被绑定在一个事件上时,它是会有一个事件参数的。

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle(e) {
            console.log('向后端放送请求');
            console.log(e)
        }

        btn.addEventListener('click', handle)
        
       // function debounce(fn, wait) {
        //     let timer = null;
        //     return function (...args) {
        //         if (timer) clearTimeout(timer);

        //         timer = setTimeout(() => {
        //             fn.call(this, ...args)
        //         }, wait)
        //     }
        // }
    </script>
</body>

image.png

那我们这样写是不是就把这个事件参数给搞没了:

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle(e) {
            console.log('向后端放送请求');
            // console.log(this)
            console.log(e)
        }

        btn.addEventListener('click', debounce(handle, 1000))
        
       function debounce(fn, wait) {
            let timer = null;
            return function () {
                if (timer) clearTimeout(timer);

                timer = setTimeout(() => {
                    fn.call(this)
                }, wait)
            }
        }
    </script>
</body>

image.png

确实搞没了,所以我们还得把人家该有的参数还给它。我们可以把参数作为形参传给匿名函数,在匿名函数内用call方法将参数还给handle函数。它可能还会有多个参数,所以我们这样写:

<body>
    <button id="btn">提交</button>
    <script>
        let btn = document.getElementById('btn');

        function handle(e) {
            console.log('向后端放送请求');
            // console.log(this)
            console.log(e)
        }

        btn.addEventListener('click', debounce(handle, 1000))
        
       function debounce(fn, wait) {
            let timer = null;
            return function (...args) {
                if (timer) clearTimeout(timer);

                timer = setTimeout(() => {
                    fn.call(this, ...args)
                }, wait)
            }
        }
    </script>
</body>

在匿名函数里传一个rest参数,它会将接收到的所有形参存到args数组里,我们再解构数组传给call方法。这样不管handle函数接收了多少参数,最终都会物归原主。

image.png

这样我们写的这个防抖函数debounce才能叫大功告成了。

3. 手写节流

写完了防抖函数,我们紧接着来写一下节流函数。

我们说节流函数的原理是人为的设定每隔1秒钟执行一次。

我们还是这个场景:

<body>
    <button id="btn">提交</button>

    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端发送请求');
        }

        btn.addEventListener('click', throttle(handle));

        function throttle(fn) {
          
        }
    </script>
</body>

这个节流函数应该怎么写呢?

开头应该和防抖函数相同,在throttle函数中我们也要return一个函数出来。

<body>
    <button id="btn">提交</button>

    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端发送请求');
        }

        btn.addEventListener('click', throttle(handle));

        function throttle(fn) {
              return function () {
                    fn()
                }
            }
        }
    </script>
</body>

然后在这个匿名函数里将handle函数触发掉。

那我们怎么去实现每隔一秒钟执行一次handle函数呢?

我们这样干:匿名函数每一次被调用的时候我们就获取一下当前的时间,然后将它存放在闭包里。如果用户又点击了一下,我们也获取一下当前的时间,去和上一次的时间比较,如果相差1秒,我们就执行,如果在1秒之内,我们就不执行。

所以我们要准备两个变量,nowTime和preTime。nowTime用来获取当前的时间,preTime放在闭包里保存上一次的时间,初始值为null。

<body>
    <button id="btn">提交</button>

    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端发送请求');
        }

        btn.addEventListener('click', throttle(handle, 1000));

        function throttle(fn, wait) {
            let preTime = null
            return function () {
                let nowTime = Date.now()
                if (nowTime - preTime > wait) {
                    fn()
                    preTime = nowTime
                }
            }
        }
    </script>
</body> 

这样当用户第一次点击的时候,if语句一定会成立,fn就一定会调用吧。然后我们去更新preTime的值。然后当用户第二次点击时,如果时间在1秒之内,if语句是不是就走不进来,fn函数就不会执行,除非你是间隔了1秒之后执行。

这样就实现了一个节流的效果。但这样代码是不是还不够完美,和debounce函数一样也要做一下this和参数的操作,所以我们还得这样干:

<body>
    <button id="btn">提交</button>

    <script>
        let btn = document.getElementById('btn');

        function handle() {
            console.log('向后端发送请求');
        }

        btn.addEventListener('click', throttle(handle, 1000));

        function throttle(fn, wait) {
            let preTime = null
            return function (...args) {
                let nowTime = Date.now()
                if (nowTime - preTime > wait) {
                    fn.call(this, ...args)
                    preTime = nowTime
                }
            }
        }
    </script>
</body>

这样节流函数就完美了,我们来看看效果:

PixPin_2024-12-21_12-48-09.gif

我快速的反复点击按钮,handle函数只会每隔1秒执行一次。

4. 总结

本次我们一起来手写了一个防抖函数和节流函数。

防抖

  • 在规定的时间内,如果没有二次触发行为则执行,否则放弃上一次的事件行为,直接从当前行为开始重新计时

节流

  • 在规定的时间内只能执行一次

如果对你有帮助的话不妨点个赞吧!