手写源码1--防抖debounce与节流throttle

992 阅读7分钟

在开发中我们偶尔会遇到这样的场景,比如监听输入框的keyup事件然后查询用户名是否重复,如果每次keyup事件触发的时候都向服务器发起一次查询,频繁的请求势必有造成服务器压力过大的风险。

let input=document.getElementById('username');
input.addEventListener('keyup',()=>{
    // 每次触发keyup事件,该方法都会执行
    console.log('query username');
},false);

在这种连续的、高频的事件触发的场景中,我们就需要用到防抖或者节流,让响应事件的方法延后执行、或者根据我们限定的频率执行。

let input=document.getElementById('username');
input.addEventListener('keyup',debounce(()=>{
    // keyup事件触发后300毫秒该方法才会执行
    console.log('query username');
},300),false);

防抖debounce与节流throttle的区别

防抖的目的是延迟执行事件方法,在连续高频事件的触发完毕之后再执行事件方法函数,而节流是在连续高频事件触发的过程中改变事件方法执行的频率、让事件方法按设定的间隔去执行。可以结合下图来理解防抖与节流的区别:

image.png

防抖debounce

防抖,即在连续高频事件触发的末尾,延迟一定时间再去执行事件方法,通过防抖处理的事件方法执行时机如下图所示:

image.png

实现原理大概是,第一次事件触发的时候,设置一个定时器,定时器延迟执行的时间即防抖设定的时间,后续连续高频触发事件,如果连续触发的时间间隔没有达到延迟时间,清理掉定时器重新设置一个定时器,如果时间间隔达到超过延迟时间,在定时器中执行真正的事件方法,代码如下:

function debounce(fn,delay=300){
    // 闭包环境
    let timer=null;
    // 返回一个函数,这个函数每次事件触发时执行
    return function(){ 
        console.log(this) 
        // this指向触发事件的对象
        console.log(event) 
        // 这里可以拿到event对象,因为return出去的这个function是事件目标直接触发的
        // 定时器存在就清理掉
        if(timer){
            clearTimeout(timer)
        }
        // 设置一个新的定时器
        timer=setTimeout(()=>{
            fn.apply(this,arguments) 
            // return出去的function没有声明参数的时候,这里要用arguments对象
            timer=null; 
            // 最后执行完成了清理定时器
        },delay)
    }
}

但是某些场景中,可能需要在连续高频事件第一次触发的时候就要执行一次事件方法,如图所示:

image.png

这时我们可以通过一个参数来控制是否立即执行一次:

function debounce(fn, delay = 500, immediate = true) {
    // 闭包环境
    let timer = null;
    // 记录事件方法是否执行过一次
    let isCallbackExecut = false;
    // 返回一个函数,这个函数每次事件触发时执行
    return function () {
        // 如果事件方法还没有执行、且需要立即执行,定时器延迟时间为0
        let wait = !isCallbackExecut && immediate ? 0 : delay;
        if (timer) {
            clearTimeout(timer)
        }
        // 重新设置一个定时器
        timer = setTimeout(() => {
            fn.apply(this, arguments);
            timer = null;
            if (!isCallbackExecut) {
                isCallbackExecut = true;
            }
        }, wait)
    }
}

节流throttle

节流,即连续高频事件触发的过程中,按照设定的频率有规律的执行事件方法,达到一个限频的效果。

image.png

实现原理大概是,高频事件第一次触发的时候设置一个延迟执行事件方法的定时器,后续事件频繁触发的时候,如果存在定时器则中断执行、什么也不做,直到设置的定时器触发,然后重复这个过程。

function throttle(fn, delay = 500) {
    // 闭包环境
    let timer = null;
    // return 出去的函数,每次事件触发都会执行
    return function () { 
        // console.log(this) // 指向触发该函数的事件目标
        // console.log(event) // 事件对象
        if (timer) { 
            // 定时器存在中断执行
            return;
        }
        // 设置定时器
        timer = setTimeout(() => {
            fn.apply(this, arguments);
            timer = null; // 定时器执行之后清理掉
        }, delay)
    }
}

某些场景下同样需要在高频事件触发的第一次就先执行一次事件方法:

image.png

与防抖类似,同样可以通过一个参数来控制是否立即执行一次事件方法,如下:

function throttle(fn, delay = 500, immediate = true) {
    // 闭包环境
    let timer = null;
    let isCallbackExecut = false; // 是否执行过一次 
    return function () {
        let wait = !isCallbackExecut && immediate ? 0 : delay;
        if (timer) {
            // 定时器存在中断执行
            return;
        }
        timer = setTimeout(() => {
            fn.apply(this, arguments);
            timer = null;
            if (!isCallbackExecut) {
                isCallbackExecut = true;
            }
        }, wait)
    }
}

以上通过定时器方法就实现了基本的节流效果,但是在JavaScript的事件循环机制中,setTimeout任务的执行时机并不是非常准确的,有可能在原有延迟时间基础上再延后执行,为了让通过节流throttle处理的方法在比较精确的时机去执行,我们可以通过对比上一次执行事件方法与当前事件触发的时间戳,判断时间间隔是否达到设定的值,来决定是否执行事件方法,如下:

function throttle(fn, delay = 500) {
    // 上一次事件方法执行的时间戳
    let preTime = 0;
    // 返回的函数在每次事件触发时都会执行
    return function () {
        // 判断本次事件触发距离上一次事件方法执行的间隔是否达到设定的值
        const now = Date.now();
        if (now - preTime >= delay) {
            // 默认会执行一次,因为preTime默认为0,差值肯定大于delay
            fn.apply(this, arguments);
            preTime = now;
        }
    }
}

以上这段节流函数的执行时机如图所示:

image.png

同样可以通过参数来控制第一次是否立即执行:

function throttle(fn, delay = 500, immediate = false) {
    let preTime = null;
    return function () {
        const now = Date.now();
        if (preTime == null) { // 只需要赋值一次
            preTime = immediate ? 0 : now; // 是否立即执行
        }
        if (now - preTime >= delay) {
            fn.apply(this, arguments);
            preTime = now;
        }
    }
}

image.png

以上节流方法中,如果高频事件触发的最后一次触发时间与最后一次事件处理方法执行的时间间隔没有达到设定的值,事件处理方法就不会再继续执行了,而某些场景可能需要事件处理方法再执行一次,这种情况下可以将定时器和时间戳对比结合起来,让事件处理方法在高频事件停止之后继续执行一次:

image.png

代码实现如下:

function throttle(fn, delay = 500) {
    let preTime = 0;
    let timer = null;
    return function () {
        const now = Date.now();
        const wait = delay - (now - preTime); 
        // 会立即执行一次
        // 本次事件触发与前一次事件触发的间隔事件、与延迟时间的差值,
        // 即还有多长时间达到delay时间
        // 如果wait小于等于0,则说明达到了delay时间,需要执行回调
        if (wait <= 0) {
            // 第一次事件触发的时候回立即执行一次,因为now-0=now,dely-now小于0
            console.log('timestamp call')
            // 先清理定时器,避免回调fn执行时间过长导致定时器执行
            if (timer) { 
            // 第3次触发的时候,如果存在定时器,需要清理定时器,并重置null
                clearTimeout(timer);
                timer = null;
            }
            preTime = now;
            //记录当前触发时候的时间  (函数执行之前记录,间隔时间是从函数开始执行的时候计算的,而不是从函数执行结束的时候计算)                  
            fn.apply(this, arguments);
            // preTime=now;//记录当前触发时候的时间
        } else if (!timer) {
            //第2次事件触发命中,开启一个剩余的定时器,如果没有第三次事件,这个定时器会触发
            // 时间戳最后一次如果没有达到delay是不会执行的
            // 开启一个定时器,使得时间戳最后一次时间结束后达到delay时间还会执行一次
            if (timer) {
                return;
            }
            timer = setTimeout(() => {
                console.log('setTimeout call')
                preTime = Date.now();
                //记录最后一次执行的时间 (函数执行之前记录,间隔时间是从函数开始执行的时候计算的,而不是从函数执行结束的时候计算)
                fn.apply(this, arguments);
                timer = null;
            }, wait);
        }

    }
}

同样可以通过从参数来控制是否立即执行一次和高频事件停止之后是否在继续执行一次事件方法:

function throttle(fn, delay = 500, immediate = true, oncemore = true) {
    let preTime = null;
    let timer = null;
    return function () {
        const now = Date.now();
        if (preTime == null) { // 只需要设置一次
            preTime = immediate ? 0 : now;
        }
        const wait = delay - (now - preTime);
        if (wait <= 0) {
            console.log('timeStamp call:')
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }
            preTime = now;
            fn.apply(this, arguments);
        } else if (!timer && oncemore) {
            if (timer) {
                return;
            }
            timer = setTimeout(() => {
                console.log('setTimeout call')
                preTime = Date.now();
                fn.apply(this, arguments);
                timer = null;
            }, wait)
        }
    }
}

需要注意的是,连续高频触发的事件它们之间的时间间隔是不确定的,有的间隔可能是几毫秒,有的间隔可能几十毫秒,所以在通过时间戳对比的方式中,事件方法执行的时机也并不是非常精确的,仔细分析通过时间戳和setTimeout结合实现的节流方法,可以发现、通过时间戳对比触发事件方法基本只会在事件触发第一次才会执行,后续事件方法的触发基本都是通过定时器去执行的,原因就在于即将触发事件方法时的那个wait的值可能小于两次事件触发的时间间隔。