前端面试必备基础题 --- 防抖和节流

239 阅读7分钟

概念

防抖和节流是为了避免函数频繁执行,作为页面性能优化的一种手段。两者本质上都是规定在一定时间内函数只执行一次,只是侧重点不相同而已。在 lodash 的源码中,节流函数就是调用防抖函数实现的。对于防抖和节流的区别可以通过这个网站的 demo 去感受。

在早期的教程,函数防抖和节流推测并没有分开来讲。在 JavaScript 高级程序设计(第三版)22.3.3 函数节流看,虽然小节名是节流,但是代码从现在的角度看实现的是防抖的功能,在第四版中这小节也被删除了。

函数节流背后的基本思想是指,某些代码不可以在没有间断的情况连续重复执行。第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除前一次的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的是只有在执行函数的请求停止了一段时间之后才执行 --- JavaScript 高级程序设计(第三版)22.3.3 函数节流

var processor = {
    timeoutId: null,
    //实际进行处理的方法
    performProcessing: function(){
        //实际执行的代码
    },
    //初始处理调用的方法
    process: function(){
        clearTimeout(this.timeoutId);
        var that = this;
        this.timeoutId = setTimeout(function(){
                that.performProcessing();
        }, 100);
    }
};
//尝试开始执行
processor.process();

函数防抖

函数防抖侧重点在于函数只取一次。在一定时间内连续触发多次,只有一次有效,多次触发会清除掉之前需要执行的函数。按照函数执行的先后顺序可以分为立即执行和非立即执行。

通过监听 input 表单的输入,来说明下防抖函数是如何限制函数执行的频率的。

<input type="text">
<script>
    function ajax(e) {
        //用console.log模拟发射请求
        console.log(e.target.value);
    }
    document.querySelector('input').addEventListener('input', ajax);
</script>

运行如上 demo ,只要输入框发生变化,ajax 函数就执行。按照正常的要求,应该是当用户全部输入完成后 ajax 函数再执行。这种场景就非常适合用防抖实现。

用户在输入时,input 事件是连续触发,只取最后一刻的结果。防抖的代码如下:

function debounce(fn, wait, immediately = true) {
    // 防抖:只取一次,在一定时间内连续触发多次,只有一次有效,多次触发会清除掉之前需要执行的函数.
    // 按照函数执行的先后顺序可以分为立即执行和非立即执行
    let timer = null;
    return function (...args) {
        // 存在定时器直接清除
        if (timer) clearTimeout(timer);
        if (immediately) {
            // 没有定时器才执行函数,2种情况:首次调用和下次执行的间隔时间大于 wait 的时间
            !timer && fn(...args);
            timer = setTimeout(() => {
                timer = null;
            }, wait)
​
        } else {
            timer = setTimeout(() => {
                fn(...args);
                timer = null;
            }, wait)
        }
    }
}
const _ajax = debounce(ajax, 500, false);
document.querySelector('input').addEventListener('input', _ajax);

函数节流

函数节流侧重点在于到点执行,在规定时间内(例如 500ms),只执行一次,500ms 后再次执行。

通过监听滚动条的变化,来说明下节流函数是如何限制函数执行的频率的。

window.addEventListener('scroll', ajax)

运行如上 demo ,只要滚动条发生移动,ajax 函数就执行。为了降低函数的执行频率,规定每隔 500ms 才执行一次。这种场景就非常适合用函数节流了。

function throttle(fn, wait, type = 'timestamp') {
    // 节流:到点执行,在规定时间内(例如 500ms),只执行一次,500ms 后再次执行
    // 用了时间戳(timestamp)和定时器(timer)两种实现方式
    // 目标函数执行也有两种情况:首次调用和下次执行的间隔时间大于 wait 的时间
    // 下面的方法没有实现首次调用的情况
    let flag = type === 'timestamp' ? 0 : null;
    return function (...args) {
        if (type === 'timestamp') {
            let nowTime = Date.now();
            if (nowTime - flag >= wait) {
                flag = nowTime;
                fn(...args);
            }
        } else {
            !flag &&
                (flag = setTimeout(() => {
                    flag = null;
                    fn(...args);
                }), wait)
        }
    }
}
const _ajax2 = throttle(ajax, 500, 'timer');
window.addEventListener('scroll', _ajax2)

以上代码只是简单的实现了基本的防抖和节流,下面我们看看 lodash 中如何实现的

lodash 防抖和节流实现

在 lodash 中,防抖和节流都是通过 debounce 函数实现的。目标函数执行有两种情况:首次调用和上次延时调用结束。

先看防抖的实现,理解防抖的时候可以暂时忽略 maxWait,节流会解释maxWait的作用

首次调用时:

执行 leadingEdge 函数,启动延时器函数 startTimerleading 选项为true时表示目标函数 func 立即执行。延时器的作用是:在延时结束之后执行 trailingEdge 函数,trailing 选项为true时表示在延迟结束之后调用目标函数 func,最终结束一次 func 调用延迟的过程。

再次调用:

如果上一次的func延时调用已经结束,再次执行leadingEdge函数来启动延时过程。否则,忽略此次调用。(如果设置了maxWait且当前满足调用的时间条件,那么立即调用func并且启动新的延时器)

function debounce(func, wait, options) {
    let lastArgs,
        lastThis,
        maxWait,
        result,
        timerId,
        lastCallTime
​
    let lastInvokeTime = 0
    // leading 为真代表目标函数立即执行
    let leading = false
    let maxing = false
    // trailing 为真代表目标函数非立即执行
    let trailing = true
​
    if (typeof func !== 'function') {
        throw new TypeError('Expected a function')
    }
    wait = +wait || 0
    if (isObject(options)) {
        leading = !!options.leading
        maxing = 'maxWait' in options
        maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
        trailing = 'trailing' in options ? !!options.trailing : trailing
    }
    // 调用目标函数
    function invokeFunc(time) {
        const args = lastArgs
        const thisArg = lastThis
​
        lastArgs = lastThis = undefined
        lastInvokeTime = time
        result = func.apply(thisArg, args)
        return result
    }
    // 启动延时
    function startTimer(pendingFunc, wait) {
        return setTimeout(pendingFunc, wait)
    }
    //清除定时器
    function cancelTimer(id) {
        clearTimeout(id)
    }
​
    function leadingEdge(time) {
        // Reset any `maxWait` timer.
        lastInvokeTime = time
        // Start the timer for the trailing edge.
        timerId = startTimer(timerExpired, wait)
        // Invoke the leading edge.
        return leading ? invokeFunc(time) : result
    }
    //计算剩余的延时时间:
    //1. 不存在maxWait:(上一次debouncedFunc调用后)延时不能超过wait
    //2. 存在maxWait:func调用不能被延时超过maxWait
    //根据这两种情况计算出最短时间
    function remainingWait(time) {
        const timeSinceLastCall = time - lastCallTime
        const timeSinceLastInvoke = time - lastInvokeTime
        const timeWaiting = wait - timeSinceLastCall
​
        return maxing
            ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
            : timeWaiting
    }
​
    //判断当前时间是否能调用func:
    //1.首次调用debouncedFunc
    //2.距离上一次debouncedFunc调用后已延迟wait毫秒
    //3.func调用总延迟达到maxWait毫秒
    //4.系统时间倒退
    function shouldInvoke(time) {
        const timeSinceLastCall = time - lastCallTime
        const timeSinceLastInvoke = time - lastInvokeTime
​
        return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
            (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
    }
    // 延时器回调
    function timerExpired() {
        const time = Date.now()
        // 如果满足时间条件,结束延时
        if (shouldInvoke(time)) {
            return trailingEdge(time)
        }
        // Restart the timer.没满足时间条件,计算剩余等待时间,继续延时
        timerId = startTimer(timerExpired, remainingWait(time))
    }
    //延时结束后
    function trailingEdge(time) {
        timerId = undefined
​
        // Only invoke if we have `lastArgs` which means `func` has been
        // debounced at least once.
        if (trailing && lastArgs) {
            return invokeFunc(time)
        }
        lastArgs = lastThis = undefined
        return result
    }
​
    function debounced(...args) {
        const time = Date.now()
        const isInvoking = shouldInvoke(time)
​
        lastArgs = args
        lastThis = this
        lastCallTime = time
​
        if (isInvoking) {
            //timerId不存在有两种原因:
            //1. 首次调用
            //2. 上次延时调用结束
            if (timerId === undefined) {
                return leadingEdge(lastCallTime)
            }
            // 存在func调用最长延时限制时,执行func并启动下一次延时,可实现throttle
            if (maxing) {
                // Handle invocations in a tight loop.
                timerId = startTimer(timerExpired, wait)
                return invokeFunc(lastCallTime)
            }
        }
        if (timerId === undefined) {
            timerId = startTimer(timerExpired, wait)
        }
        return result
    }
​
    return debounced
}

如果leading和trailing选项同时为true,那么func在一次防抖过程能被调用多次。lodash在debounce的基础上添加了maxWait选项,用于规定func调用不能延迟超过maxWait毫秒,也就是说每段maxWait时间内func一定会被调用一次。所以只要设置了maxWait选项,那么效果就等同于函数节流了。

这一点也可以通过lodash的throttle源码得到验证: throttle的wait作为debounce的maxWait传入。

function throttle(func, wait, options) {
    let leading = true
    let trailing = true
​
    if (typeof func !== 'function') {
        throw new TypeError('Expected a function')
    }
    if (isObject(options)) {
        leading = 'leading' in options ? !!options.leading : leading
        trailing = 'trailing' in options ? !!options.trailing : trailing
    }
    return debounce(func, wait, {
        leading,
        trailing,
        'maxWait': wait
    })
}

参考:lodash防抖节流源码理解