当面试官让你手写防抖、节流时,是在考察什么

防抖、节流,都是用于节省函数调用次数的方案,达到优化程序、提升性能甚至是避免bug的目的。作为一个经典的主题,也是面试常考项,部分面试官会让你手写,这时, 他是在考察什么?你能轻易地写出比较好的防抖、节流函数吗?

一、概念辨析

要写出符合条件的防抖、节流函数,首先记忆中应清晰的展现出两者的定义和适用场景,这是第一步。如果连防抖和节流都搞混淆了,那么这个面试题肯定挂了。

考察点1:防抖和节流的概念

防抖:顾名思义,防止抖动,指连续调用时,只有最后一次生效。

节流:节省流量,固定时间间隔触发一次。

要清晰的记忆起他们的定义,只需要理解到:

防抖是想要最终的那一次,节流是想要减小频率。

通俗的解释是:

防抖就像是防止手抖动,多放盐一样,目的是只想放一次盐。手抖的时候就不放,只有不抖了才放。

节流就像是规律饮食一样,一天只吃早中晚三顿,中间的零食、小吃都不让吃。

二、函数结构、参数

清晰的想起概念之后,我们就可以开始写函数的总体结构和参数了。那么,我们是否知道这两个函数返回什么、需要传递哪些参数呢?

考察点2:函数返回什么

由于不采用防抖、节流时,原函数是作为频繁调用的事件处理函数,而采用了防抖节流时,得到的是节省了调用次数的新事件处理函数。因此返回值仍然是函数

考察点3:函数需要哪些参数?

对于基础版本的防抖和节流,我们都是需要传递一个函数进去,返回一个防抖、节流后的新函数,因此第一个参数是需要处理的原函数,为区分防抖写作func,节流写作fn,不区分也可。

光有原函数还不行,因为我们需要规定一个时间间隔,否则时间间隔就会被我们的函数写死了。对于防抖而言:时间间隔用于表明最后一次调用时,隔多久没有再次触发,我们才真正调用函数。譬如,放盐时,手一直抖动,直到连续5秒没有抖动了,那么才放盐。这个5秒就是时间间隔。

对于节流而言:时间间隔用于标志,每隔多久我们真正触发函数调用。就像是,一天只吃3顿,那么只有每顿间隔4个小时,我们才能吃饭。在没到4个小时时,不允许吃东西。

所以,第二个参数是时间间隔,防抖可写为wait,节流可写为delay,当然不区分都写为time也可以。

有了以上两个参数,还没完,这是因为我们需要考虑是否立即执行一次。

对于防抖:我们放盐,需要直到手不抖动5秒才能放盐,这就导致如果手一直抖动,那么将一直不会执行。某些情况下,我们可能需要立即执行一次,以避免长时间一次也没有执行。

对于节流:我们每隔固定时间,必然执行,因此不需要考虑立即执行的问题。

故,第三个参数是针对防抖而言,是否立即执行的布尔值immediate。

现在,我们可以写出防抖和节流函数的基本结构和参数:

// 防抖
function debounce(func, wait, immediate) {
    return function () {}
}

// 节流
function throttled(fn, delay) {
    return function () {
    }
}

 备注:immediate将在第五章开始实现,第三、四章只实现简化版的,没有该参数。

三、执行条件

我们知道:新函数体内,是在满足一定条件下调用原函数,不满足时则不调用原函数,来达到节省函数调用次数的目的。现在我们的问题是,新函数内执行原函数的条件是什么?

考察点4:原函数的执行条件是什么?

对于防抖,当连续触发时,我们的目标是,不抖动时才调用。这也就是说,不满足时间间隔时,后一次触发会覆盖掉前一次触发。当满足时间间隔时,才会真正执行。

满足时间间隔,指的是某一次触发之后,时间间隔范围内,没有下一次触发,这样不产生覆盖,因此会真正执行。由于有时间间隔的判断,因此即便真正执行,也需要延迟到时间间隔之后。这显然是定时器的概念。

对于节流,当连续触发时,我们的目标是,每隔时间间隔时调用一次。这也就是说,我们每次设定固定时间的一个定时器,当定时器存在就什么都不做,当定时器不存在,就设定下一次的定时器。

综上,我们可以大致写出函数的执行时机,结构如下:

// 防抖

function debounce(func, wait) {
    let timeout;

    return function () {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
         // 原函数执行部分
        }, wait);
    }
}


// 节流

function throttled2(fn, delay = 500) {
    let timer = null
    return function () {
        if (!timer) {
            timer = setTimeout(() => {
              // 原函数执行部分
            }, delay);
        }
    }
}

四、 原函数执行部分

上面,我们已经写出了基本的函数结构,现在的问题是新函数内怎样去调用原函数?

我们只需要直接调用fn,即fn()即可。但这样不够完善,需要注意两点:

其一是this指向,新函数中调用原函数,两者的this指向应该一致;

其二是参数,新函数中调用原函数,两者接收的参数应该一致。

故而,我们应该努力让新旧函数在这两方面保持一致。

考察点6 this指向和原函数的参数接收

我们前面的代码中,为了简洁,原函数执行部分使用了箭头函数。

当使用箭头函数时,原函数执行部分this指向与箭头函数外层保持一致,因此就和新函数是一致的,可直接使用this;

当使用箭头函数时,原函数执行部分没有arguments对象,因此就和新函数是一致的,可直接使用arguments;也可以直接使用参数解构的写法,更加稳妥。

这样代码就变成了:

// 防抖
function debounce(func, wait) {
    let timeout;
    return function (...args) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
            func.apply(this, ...args)
        }, wait);
    }
}

// 节流
function throttled2(fn, delay = 500) {
    let timer = null
    return function (...args) {
        if (!timer) {
            timer = setTimeout(() => {
                fn.apply(this, ...args)
            }, delay);
        }
    }
}

备注:关于this和arguments的分析参考

注1:setTimeout回调中使用arguments:

若不是箭头函数,接收的是回调的实际参数,而不是新函数的实际参数。

若是箭头函数,由于箭头函数没有arguments对象,接收的就是新函数的实际参数。

注2:setTimeout回调中使用this:

若是箭头函数,由于箭头函数没有this,因此回调中的this是外层的this,

若不是箭头函数,回调中的this指向window。

考察点7 计时器何时重置?

在上面的代码中,防抖和节流何时进行计时器的重置呢?

对于防抖:每次调用都会清除掉上一次的定时器,因此每次触发都会重置,可以看到clearTimeout写在定时器之前。

对于节流:并非每次都会调用,只有触发了原函数执行后,才应该清除定时器。上面我们没有写清除操作,这将导致定时器始终存在,只能执行一次。

故针对节流函数,增加清除操作,即增加timer=null重置语句:

// 防抖
function debounce(func, wait) {
    let timeout;
    return function (...args) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
            func.apply(this, ...args)
        }, wait);
    }
}

// 节流
function throttled2(fn, delay = 500) {
    let timer = null
    return function (...args) {
        if (!timer) {
            timer = setTimeout(() => {
                fn.apply(this, args);
                timer = null;
            }, delay);
        }
    }
}

 对于要求不太严格的场景,以上代码已经可以了

五、防抖优化:立即执行判断

前面我们提到,防抖应该有一个标志是否立即执行的标记,而在第三、四章中,我们并没有去实现这个,现在来实现。

显然应该对immediate参数进行判断,如果为false,那么保持前面的逻辑即可:

function debounce(func, wait, immediate) {
    let timeout;
    return function (...args) {
        clearTimeout(timeout);
        if (immediate) {
          //立即执行时的逻辑
        }
        else {
            timeout = setTimeout(() => {
                func.apply(this, ...args);
            }, wait);
        }
    }
}

考察点8 防抖立即执行时的逻辑是什么?

当需要立即执行时,我们理论上和非立即执行时保持一致,并多调用一次原函数即可。但这样,有一个严重的问题,immediate参数始终是固定为true的,那将一直执行下去。

因此,需要重新梳理思路:

其一,调用时机问题:当没有定时器时,原函数执行。这包括第一次执行时,还没开启定时器,以及当后面反复触发,定时器反复被覆盖,定时器最后一次被覆盖后的时间达到间隔时间时,清除定时器。

其二,定时器回调中的逻辑:由于我们这里是立即执行,执行放在了定时器之外,因此定时器中只干一件事,就是清除定时器;

其三, clearTimeout增加判断:由于第一点中依赖于定时器的判断,故前面的 clearTimeout需要增加不为null才清除的判断,否则逻辑失败。

综上,代码变为:

function debounce(func, wait, immediate) {

    let timeout;

    return function (...args) { 
        if (timeout) clearTimeout(timeout); 
        if (immediate) {                 
            let callNow = !timeout;  
            timeout = setTimeout(() => {
                timeout = null;
            }, wait)
            if (callNow) {
                func.apply(this, ...args)
            }
        }
        else {
            timeout = setTimeout(() => {
                func.apply(this, ...args)
            }, wait);
        }
    }
}

六、节流优化:时间戳实现

在上面的节流实现中,我们采用了定时器来计时,由于定时器处于异步的宏任务队列,js中队列的执行并不那么精确,因此我们也可以采用时间戳来更加精准的实现。

考察点9 时间戳的节流判断逻辑

用时间戳实现节流时,基本思路如下:

其一,当前时间参数:每次触发时获取当前时间。

其二,上一次执行的时间参数:每次真正执行时,设置该参数。对于初始值而言,可将其放在新函数体外面,这表明调用节流函数时,认为第一次计时开始。

其三,距离下一次执行的剩余时间:每次触发时,用当前的时间减去上一次真正执行的时间,可以得到距离上一次执行的时间间隔,再用节流时间间隔参数减去距离上一次执行的时间间隔,即可得到距离下一次执行的剩余时间。

其四,如果剩余时间<=0,那么立即调用原函数。

其五,如果剩余时间>0,那么设置剩余时间后执行的定时器。注意,这里设置的定时器,只在最后一次触发时有效,即达停止触发后再执行一次的效果。而对于非最后一次的触发,定时器都会被清除掉。

其六,如果只触发一次,那么同样不是立即执行,而是在剩余时间之后执行一次。

综上,我们可以写出代码如下:

function throttled(fn, delay) {
    let timer = null
    let starttime = Date.now()
    return function (...args) {
        let curTime = Date.now() 
        let remaining = delay - (curTime - starttime)  
        clearTimeout(timer)
        if (remaining <= 0) {
            fn.apply(this, args)
            starttime = Date.now()
        } else {
            timer = setTimeout(fn, remaining);
        }
    }
}

全文完,如有错误,欢迎指出。 

你可能感兴趣的:(手写系列,面试,防抖,节流,手写,前端)