原文:别只掌握基础的防抖和节流了,要懂原理。 - 每天一个JavaScript小知识@Js中文网 · 码农进阶题库

原文地址:https://www.javascriptc.com/interview-tips/zh_cn/javascript/what-throttle-difference/

前言

开门见山,相信很多人现在还看防抖和节流的知识,基本上都是在准备面试的人儿吧?为了刷题的快速,节约时间,相信很多人都是匆匆看一些文章,掌握了最基础最基础的防抖节流函数,觉得这样是ok的。实际上这是正确的选择,但是面试官基本上从这些情况就知道你是一个什么水平了,浅而不深,临时抱佛脚刷题。

此篇介绍一个稍微深入一点,但又便于理解的防抖节流函数,起码别让面试官觉得你很初级对吧。如果你想找个更加全面的防抖节流,意味着你要花更多时间去理解它,如果你不是仅仅为了面试而做准备,建议你去看看loadash的防抖节流函数源码

是什么

防抖和节流,不知道你看了很多资料的描述后(基本上大同小异),会不会还是觉得似懂非懂。

我们知道,在一个高频触发的监听事件中,会不断触发所绑定的方法,如onscrollonmouseoveronkeyup等,在你连续触发该事件,就会不断执行绑定方法。也许你绑定的事件不复杂,加上浏览器性能处理越来越好,你会觉得这没什么,但是,如果绑定的方法执行的内容比较复杂,涉及DOM重排重绘严重或者请求频繁,那么如此高频触发,必将带来性能和交互的严重不友好。

为了解决出现这种情况,防抖和节流,这两种方案为此诞生。

它们都是有一个时间规定,在这个规定下限制某个方法的执行时机。

它们有个共同点,就是指定时间内,只能执行一次。

防抖

指定时间内,方法只能执行一次。而这个时间的计算,是从最后一次触发监听事件开始算起。

一般表现为,在一段连续触发的事件中,最终会转化为一次方法执行,就像防止抖动一样,你做一个事,防止你手抖不小心重复干了

节流

指定时间内,方法只能执行一次。而这个时间的计算,是从上次执行方法开始算起。

一般表现为,在一段连续触发的事件中,根据你设定的时间间隔,降低触发频率,重复执行。

从上面的描述我们可以理解到,他们的区别在于,如何计算时间间隔。

场景

我们从实际应用中,加深一下对他们的认识。

如在一个输入框内输入文字,你想在输入停止一段时间过后再去获取数据(如过滤),而不是每输入一个文字就去请求一次,那么这时候你就可以利用防抖,指定keyup事件不断触发的过程中不要重复发请求,到最后一次停止输入再去请求。

如你需要做无限加载,监听到滚动条到达底部就加载更多数据,这时候其实你不必要时时刻刻都执行scroll事件绑定的函数,这样没必要,只要把执行频率降低点同样可以达到效果,节约资源。这就是利用节流

代码实现

基础的代码我这里不说了,一堆一堆资料都会讲到,我就当你已经掌握最简单的实现方式。

我这里直接贴代码。但是会附上很详细的注释来方便你的理解。

防抖

根据防抖分为两种,一种是立刻执行,一种是延后执行。大家看很多资料介绍的基本的防抖函数一般就是延后执行,大家都觉得只要掌握这个就够了,但是其实,实际场景立即执行也是很常见的。

例如,你点击一个按钮后发请求,防止手误点多了几次,就做防抖处理,那一般都是第一次点的时候就开始发请求了,即立刻执行了。总不能用延后执行吧,不然你一直点请求都发不出去了。

所以下面的防抖函数,设置了immediate参数,让用户自己选择立即执行还是延后执行。

/**
 * 防抖函数
 * @param {Function} fn - 实际要执行的函数
 * @param {Number} wait - 规定在什么时间内执行一次函数,单位是秒
 * @param {Boolean} immediate - 是否立即执行,true为立即执行,立即执行指触发监听事件是先执行
 * @return {Function} 经过防抖处理后的要执行的函数
 */
function debounce(fn, wait, immediate) {
    let timerId = null; // 记录定时器id
    wait = +wait || 0; // 如果wait没有传,那么初始化0值
    if (typeof fn !== 'function') {
        throw new Error('debounce的第一个参数请传入函数');
        return;
    }
    // 防抖后的执行函数
    function debounced() {
        timerId && clearTimeout(timerId);
        // 如果是立即执行
        if (immediate) {
            // 如果已经过了规定时间,则执行函数 或 第一次触发监听事件
            !timerId && fn.apply(this, arguments);
            // 规定时间后情况定时器id,表明到达了规定时间
            timerId = setTimeout(() ={
                timerId = null;
            }, wait);
        } else { // 延后执行
            // 只有到达了规定时间后才会执行fn函数
            timerId = setTimeout(() ={
                fn.apply(this, arguments);
                timerId = null;
            }, wait);
        }
    }
    // 手动取消该次设定的防抖时间,取消后当成是“第一次触发”一样
    function cancel() {
        clearTimeout(timerId);
        timerId = null;
    }
    debounced.cancel = cancel;
    return debounced;
}

节流

一般大家看资料介绍的基本节流,是触发事件后立刻执行,然后开始按设定的间隔时间继续执行。那么这样会有一个情况,你最后一次触发事件的时候还没到规定的间隔时间,这样的话,就没有下文了,但是现实情况往往更多是,你要做的操作肯定想监听最后一次触发的情况,如你做滚动加载,当然想知道最后一次触发滚动是否达到底部,而不是仅仅知道滚动过程中的某次触发情况吧。

因此下面的节流函数,原意是按照一个“有头有尾”的情况设计的,即能立即执行,也能在最后触发一次监听事件后执行一次。

都既然设计出了“有头有尾”了,那干脆就做个配置让用户自己选择到底是要“头”还是“尾”,还是都要算了。因为也难说的确有此类偏好的需求。

于是就有下面的一个节流函数了。

/**
 * 节流函数
 * @param {Function} fn - 实际要执行的函数,对其进行节流处理
 * @param {Number} wait - 规定的执行时间间隔
 * @param {Object} option - 用于设置节流的函数的触发时机,
 *                        - 默认是{leading: true, trailing: true},表示第一次触发监听事件马上执行,停止后最后也执行一次
 *                        - leading为false时,表示第一次触发不马上执行
 *                        - trailing为false时,表示最后停止触发后不执行
 * @return {Function} 返回经过节流处理后的函数
 */
function throttle(fn, wait, option) {
    let timerId = null; // 用于记录定时器的id
    let lastTime = 0; // 上次触发fn的时间戳
    wait = +wait || 0; // 如果wait没有传,那么初始化0值
    option = option || {}; // 如果option没有传,那么初始化{}值
    if (typeof fn !== 'function') {
        throw new Error('throttle的第一个参数请传入函数');
        return;
    }
    if (option.leading === false && option.trailing === false) {
        throw new Error('option的leading 和 trailing不能同时为false');
        return;
    }
    // 节流后的执行函数
    function throttled() {
        let now = +new Date(); // 获取当前时间
        // 如果没有上次触发执行时间(即第一次运行),以及leading设置为false
        !lastTime && option.leading === false && (lastTime = now);
        // 距离到达规定的wait时间剩余时间
        let remainingTime = wait - (now - lastTime);
        // 条件①:如果到达了规定的间隔时间或用户自己设定了系统时间导致的不合理时间差,则立刻执行一次触发函数
        if (remainingTime <= 0 || remainingTime wait) {
            fn.apply(this, arguments);
            lastTime = now;
            if (timerId) {
                clearTimeout(timerId);
                timerId = null;
            }
            // 条件②:如果未达到规定时间,以及要求停止后延迟执行(trailing=false)
        } else if(!timerId && option.trailing !== false) {
            timerId = setTimeout(() ={
                timerId = null;
                fn.apply(this, arguments);
                lastTime = option.leading === false ? 0 : +new Date();
            }, remainingTime);
        }
    }
    // 手动提前终止节流时间,恢复初始状态
    function cancel() {
        clearTimeout(timerId);
        timerId = null;
        lastTime = 0;
    }
    throttled.cancel = cancel;
    return throttled;
}
throttle的场景流程说明
  1. 在{leading: true, trailing: true}下,为大多数正常需求所用。在这种情况下,条件①只有在第一次触发,以及后续超过规定间隔时间后的第一次触发,才会走到该流程下;其余都是在条件②下触发fn。
  2. 在{leading: false}下,都是在条件②下触发fn,走不到条件①下的。
  3. 在{trailing: false}下,都是在条件①下触发fn,走不到条件②下的。

总结

希望大家别觉得根本不必这么复杂的防抖节流函数,但是按照实际需求来讲,其实上述的函数反而是更贴近实际应用的。而往往大家记住的“最基本”的,能满足的场景还是比较少的,当然你的需求恰巧是那样的话,肯定越简单越好;其次,只会记住简单的设计,面试官面试多了,也会嗤之以鼻。

扩展阅读: