手写防抖和节流函数

195 阅读2分钟

节流与防抖的作用都是防止函数多次调用。  区别在于,假如用户一直触发这个函数,且每次触发函数的间隔小于阙值,防抖的情况下只会调用一次,而节流会每隔一定时间调用函数。 函数防抖 在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。

我理解的防抖是用户停止操作后在n秒后执行一次,而节流是不管函数调用几次都是按照n秒执行一次的规律执行,直到用户操作结束;我主观理解是,防抖像直接将水龙头关闭,节流将水龙头调小

一、防抖函数 用户停止操作n秒后函数执行一次

  1. 基础版本
  2. 立即执行
  3. 接收返回值
  4. 取消功能

1、基础版本

function debounce(fn, delay) {
    // 1.定义一个定时器, 保存上一次的定时器
    let timer = null

    // 2.执行真正的定时器
    const _debounce = (...args) => {
        // 取消上一次定时器
        if(timer) clearTimeout(timer)
        // 延迟执行
        timer = setTimeout(() => {
            // 外部传入真正要执行的函数
            fn.apply(this, args)
        }, delay)
    }
    return _deboune
}

在html中调用

<script src="./01_debounce-V1-基本实现.js"></script>
<script>
        const inputEl = document.querySelector('input')
        let count = 0
        function demo(event){
            count++;
            console.log(`发送了${count}次`);
        }
        // 给输入框绑定input事件,发送请求
        inputEl.addEventListener("input", debounce(demo, 2000)) 
    </script>

这是一个基础版的防抖函数,利用定时器设置n秒后执行,根据timer是否有值判断用户是否停止输入,如果有值取消定时器,如果没值说明用户已经停止操作,执行定时器

考虑函数this指向和参数传递问题,所以我们用apply来对函数进行绑定this和传参,你当时学的时候这里疑惑了一下,现在做笔记特地提出一下,之所以_debounce能够传递参数是因为它是在input事件调用的,事件函数会默认传递一个event,而真正需要执行的函数又在其内部执行,所以能够进行传参,是_debounce传demo,不是demo传_debounce

function debounce(fn, delay, immediate = false, resultCallBack) {
    // 1.定义一个定时器, 保存上一次的定时器
    let timer = null
    let isInvoke = false
        const _debounce = function(...args){
            return new Promise((resovle, reject) => {
                 // 取消上一次定时器
        if(timer) clearTimeout(timer)
        // 延迟执行
        if(immediate && !isInvoke) {
            const result = fn.apply(this, args)
            if(resultCallBack && typeof resultCallBack === 'function') resultCallBack(result)
            try {
                resovle(result)
                } catch (error) {
                    reject(error)
                }
            isInvoke = true
        } else {
            timer = setTimeout(() => {
                // 外部传入真正要执行的函数
                const result = fn.apply(this, args)
                if(resultCallBack && typeof resultCallBack === 'function') resultCallBack(result)
                try {
                resovle(result)
                } catch (error) {
                    reject(error)
                }
                isInvoke = false
                timer = null
            }, delay)
        }
            })
    }
    // 封装取消功能
    _debounce.cancel = function() {
        if (timer) {
            clearTimeout(timer)
            console.log("我执行了~",timer);
        }
        timer = null
        isInvoke = false

    }
    return _debounce
}

在html中引用

    <script src="./04_debounce-V4-函数返回值.js"></script>
    <script>
        const inputEl = document.querySelector('input')
        const btnEL = document.querySelector('button')
        let count = 0
        function demo(event){
            count++;
            console.log(`发送了${count}次`,this,event);
            return `我是返回值${count},你知道吗~`
        }
        // 第一种接收参数的方案:用回调函数接收参数
        const debounceChang =  debounce(demo,1000,false,(e) => {
            console.log("拿到真正执行函数的返回值:",e);
        })
        // 接收返回值的第二种方案,用promise
        const tempCallBack = (...args) => {
            debounceChang.apply(inputEl, args).then((res) => {
                console.log("Promise拿到的结果:",res);
            })
        }
        // 给输入框绑定input事件,发送请求
        inputEl.addEventListener("input", tempCallBack) 

        // 给按钮绑定点击事件,取消请求
        btnEL.addEventListener("click", function (){
            debounceChang.cancel()
        })
    </script>

那么第二个就是是重磅升级版本,考虑了更多情况,并且增加了第一次是否执行、接收返回值、取消等功能;

2、立即执行

通过接收第三个参数判断第一次是否执行

// 给输入框绑定input事件,发送请求
        inputEl.addEventListener("input", debounce(demo, 2000, true))
        inputEl.addEventListener("input", debounce(需要执行的函数, 间隔时间, 是否立即执行))

给第一次是否执行参数一个默认值,那么在调用时便可以选择不传,我们声明一个变量isInvoke用来和immediate参数一起判断是否需要立即执行,这样防止了直接修改函数的参数

function debounce(fn, delay, immediate = false) {
    // 1.定义一个定时器, 保存上一次的定时器
    let timer = null
    let isInvoke = false
    // 2.执行真正的定时器
    return (...args) => {
        // 取消上一次定时器
        if(timer) clearTimeout(timer)
        // 延迟执行
        if(immediate && !isInvoke) {
            fn.apply(this, args)
            isInvoke = true
        } else {
            timer = setTimeout(() => {
                // 外部传入真正要执行的函数
                fn.apply(this, args)
                isInvoke = false
                timer = null
            }, delay)
        } 
    }
}

3、接收返回值

那么第四个参数是用来接收函数返回值的,通过回调函数传参的方式进行接收

const result = fn.apply(this, args)
if(resultCallBack && typeof resultCallBack === 'function') resultCallBack(result)

还有第二种接收返回值的方案,用Promise,只是使用该方法后,调用时会稍麻烦一点

 const result = fn.apply(this, args)
 try {
 resovle(result)
} catch (error) {
  reject(error)
}

需要在使用时额外声明一个回调函数tempCallBack,用来执行then方法

 const debounceChang =  debounce(demo,1000,false,(e) => {
            console.log("拿到真正执行函数的返回值:",e);
        })
        // 接收返回值的第二种方案,用promise
        const tempCallBack = (...args) => {
            debounceChang.apply(inputEl, args).then((res) => {
                console.log("Promise拿到的结果:",res);
            })
        }
        // 给输入框绑定input事件,发送请求
        inputEl.addEventListener("input", tempCallBack)

4、取消功能

防抖函数的最后一个功能取消功能的实现原理是在_debounce函数对象上添加一个cancel方法,cancel方法实现取消定时器和变量恢复默认值

 _debounce.cancel = function() {
        if (timer) {
            clearTimeout(timer)
            console.log("我执行了~",timer);
        }
        timer = null
        isInvoke = false
    }
btnEL.addEventListener("click", function (){
            debounceChang.cancel()
        })

给按钮绑定点击事件,取消函数调用

btnEL.addEventListener("click", function (){
            debounceChang.cancel()
        })

二、节流函数 以n秒的频率规律的调用函数

在实现这个函数时需要考虑好实际情况,需要用到开始的时间lastTime、现在的时间nowTime、和设定需要间隔的时间interval这三个时间来准确控制,当然也可以简单粗暴的用定时器做一个简单版本的

  1. 控制第一次是否执行
  2. 控制最后一次是否执行
  3. 函数返回值
  4. 取消功能
function throttle(fn, interval) {
    let timer = null
    return (...args) => {
        if(!timer) {
            timer = setTimeout(() => {
                fn.apply(this,args)
                timer = null
            },interval)
        }
    }
}

上面版本因为定时器在事件队列中属于宏任务,并不是立即执行的,所以有了第二版本

function throttle(fn, interval){
    let lastTime = 0 
    const  _throttle = function() {
        const nowTime = new Date().getTime()
        // 等待时间相差大于interval,只有经过设定时间nowTime - lastTime 才会大于 interval(设定间隔时间)
        const remainTime = interval - (nowTime - lastTime)

        if (remainTime <= 0) {
            // 需要执行的函数
            fn()
            // 保留上次触发时间
            lastTime = nowTime
        }
    }
    return _throttle
}
    <input type="text">
    <script src="./08_throttle.js"></script>
    <script>
        const inputEl = document.querySelector('input')
        let count = 0
        function demo(event){
            count++;
            console.log(`发送了${count}次`,this,event);
            return `我是返回值${count},你知道吗~`
        }
        inputEl.addEventListener("input", throttle(demo, 3000))
    </script>
节流函数的实现逻辑.jpg
来源:王红元手作

当remainTime等于0或小于0时,执行函数,由于nowTime为时间戳不断地在增加,而第一次lastTime为0,所以会在开始时立即执行一次,执行一次之后lastTime被赋值为上一次的nowTime,等待interval时间过后,新的nowTime减lastTime会大于interval,所以函数再次执行,此时lastTime再次被赋值为这一刻的nowTime以此实现在同一频率下执行函数

当然了,这也不是完整版,完整版加入了leading、traling、接收函数返回值、取消功能;先再次贴出完整版,并对各个功能进行描述

// 剩余功能:this-参数、leading、traling、函数返回值、取消功能

function throttle(fn, interval, options){
    // 记录上一次的开始时间
    let lastTime = 0 
    let timer = null
    const {leading, traling, callBack} = options
    
    // 事件触发时,真正执行的函数
    const  _throttle = function(...args) {
        return new Promise((resolve, reject) => {
            // 获取当前事件触发时的时间
            const nowTime = new Date().getTime()
            // 判断第一次是否需要执行
            if(!lastTime && !leading) lastTime = nowTime
            // 等待时间相差大于interval,只有经过设定时间nowTime - lastTime 才会大于 interval(设定间隔时间)
            // 使用当前触发的时间和之前的时间间隔以及上一次开始的时间,计算出还剩余多长时间去触发函数
            const remainTime = interval - (nowTime - lastTime)
            
            if (remainTime <= 0) {
                if(timer){
                    clearTimeout(timer)
                    timer = null
                }

                // 真正触发的函数
                const result = fn.apply(this, args)
                // 返回值
                if(callBack && typeof callBack === "function") callBack(result)
                resolve(result)
                // 保留上次触发时间
                lastTime = nowTime
                // 判断最后一次是否需要执行,并且判断timer是否已经有值,防止产生多个定时器
            }else if(traling && !timer){
                    timer = setTimeout(() => {
                        const result = fn.apply(this, args)
                        // 返回值
                        if(callBack && typeof callBack === "function") callBack(result)
                        resolve(result)
                        
                        timer = null
                        // 根据第一次是否执行进行赋值
                        lastTime = !leading ? 0 : new Date().getTime()
                    },remainTime)
            }        
        })
    }
    
    _throttle.cancel = function() {
        if(timer) clearTimeout(timer)
        timer = null
        lastTime = 0
    }
    
    return _throttle
}
<script src="./08_throttle.js"></script>
    <script>
        const inputEl = document.querySelector('input')
        const btnEL = document.querySelector('button')
        let count = 0
        function demo(event){
            count++;
            console.log(`发送了${count}次`,this,event);
            return `我是返回值${count},你知道吗~`
        }

        const throttleChang = throttle(demo, 3000, {
            leading: false, 
            traling: true,
            callBack: function(res){
                console.log("callBack:",res);
            }
        })
        const tempCallBack = (...args) => {
            throttleChang.apply(inputEl, args).then((res) => {
                console.log("Promise拿到的结果:",res);
            })
        }
        inputEl.addEventListener("input", tempCallBack)
    </script>

1、 控制第一次是否执行

leading为函数第一次是否执行,用户传入一个布尔值判断即可;基础版本之所以会立即执行是因为nowTime - lastTime远远的大于了interval,那么我们只需要nowTime与lastTime相等,即可实现执行函数的条件,因为传入参数过多,为了代码可读性,我们第三个参数用对象的方式来传参,并在执行时用对象结构赋值的方法取出来

function throttle(fn, interval, options){
    let lastTime = 0 
    const {leading} = options
    const  _throttle = function() {
        const nowTime = new Date().getTime()
        if(!lastTime && !leading) lastTime = nowTime
        // 等待时间相差大于interval,只有经过设定时间nowTime - lastTime 才会大于 interval(设定间隔时间)
        const remainTime = interval - (nowTime - lastTime)

        if (remainTime <= 0) {
            fn()
            // 保留上次触发时间
            lastTime = nowTime
        }
    }
    return _throttle
}

2、控制最后一次是否执行

traling的实现逻辑有些复杂,我现阶段能力还有限,可能还描述不正确,未来的你再看这篇文章时记得改正 traling参数也是个布尔值用来控制最后一次函数是否执行,首先采用的方法是定时器,在用户停止操作时执行,那么第一个问题就是为了预防添加多个定时器。我们需要一个timer变量来接收定时器,好在timer为空时才添加定时器,为了预防用户操作一段时间后再次操作,我们需要对lastTime赋值,这是就需要根据用户第一次是否立即执行来设置值。最后用户再次操作时防止定时器和一开始的判定条件同时执行,所以我们需要将timer定时器关闭并将timer置空

   const  _throttle = function(...args) {
            // 获取当前事件触发时的时间
            const nowTime = new Date().getTime()
            // 判断第一次是否需要执行
            if(!lastTime && !leading) lastTime = nowTime
            // 等待时间相差大于interval,只有经过设定时间nowTime - lastTime 才会大于 interval(设定间隔时间)
            // 使用当前触发的时间和之前的时间间隔以及上一次开始的时间,计算出还剩余多长时间去触发函数
            const remainTime = interval - (nowTime - lastTime)
            
            if (remainTime <= 0) {
                if(timer){
                    clearTimeout(timer)
                    timer = null
                }

                // 真正触发的函数
                const result = fn.apply(this, args)
                // 保留上次触发时间
                lastTime = nowTime
                // 判断最后一次是否需要执行,并且判断timer是否已经有值,防止产生多个定时器
                // 此例为3秒执行一次,防止在1秒2秒时创建多个定时器
            }else if(traling && !timer){
                    timer = setTimeout(() => {
                        const result = fn.apply(this, args)
                        timer = null
                        // 根据第一次是否执行进行赋值
                        lastTime = !leading ? 0 : new Date().getTime()
                    },remainTime)
            }  

3、函数返回值

返回值的实现与防抖函数一致,第一种方法同样是用传参数的方式传入一个CallBack来接收执行函数的返回值,第二种方式依旧是Promise的方式便不再描述

4、取消功能

取消功能也与防抖函数一致,在_throttle函数对象上添加一个cancel方法,直接调用即可

最后的话 此篇文章是学习王红元老师的课程后,为了帮助自己捋顺思路而做的记录,也为了方便此后自己复习,愿自己砥砺前行、如愿以偿!