1. 前言

有时候频繁触发的事件(如 resizescrollmousemovedragovertouchmove 等),我们不希望对应的回调函数(如执行 DOM 操作、表单提交、资源加载等)也频繁执行,从而导致出错、UI 卡顿甚至浏览器崩溃。对于此类情况,有防抖(debounce)和节流(throttle)两种解决方案。

2. 防抖

2.1 定义

创建一个防抖函数,该函数会从上一次被调用后,延迟 wait 毫秒后调用 func 方法。

接口定义:

/**
 * 防抖控制:函数连续调用时,延迟时间必须大于或等于 wait,func 才会执行
 * @param func function 要防抖的函数
 * @param wait number 防抖时间,单位毫秒
 * @return function 返回防抖的函数
 */
 debounce(func, wait)

2.2 简单实现

function debounce (func, wait) {
    let timer;
    return (...args) => {
        // 防抖的精华在于重置定时器
        clearTimeout(timer);
        timer = setTimeout(() => {
            func.call(this, ...args);
        }, wait);
    }
}

3. 节流

3.1 定义

创建一个节流函数,在 wait 毫秒内最多执行 func 一次。

接口定义:

/**
 * 频率控制:函数连续调用时,在 wait 毫秒内最多执行一次 func
 * @param func function 要节流的函数
 * @param wait number 节流时间,单位毫秒
 * @return function 返回节流的函数
 */
 throttle(func, wait)

3.2 简单实现

function throttle (func, wait) {
    let called = false;
    return (...args) => {
        if (called) {
            return;
        }
        called = true;
        setTimeout(() => {
            func.call(this, ...args);
            // 重点在于执行完才能执行下一个
            called = false;
        }, wait);
    }
};

4. lodash 源码

4.1 debounce 函数

function debounce(func, wait, options) {
    var lastArgs,
        lastThis,
        maxWait,
        result,
        timerId,
        lastCallTime,
        lastInvokeTime = 0,
        leading = false,
        maxing = false,
        trailing = true;

    if (typeof func != "function") {
        throw new TypeError(FUNC_ERROR_TEXT);
    }
    wait = toNumber(wait) || 0;
    if (isObject(options)) {
        leading = !!options.leading;
        maxing = "maxWait" in options;
        maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
        trailing = "trailing" in options ? !!options.trailing : trailing;
    }

    function invokeFunc(time) {
        var args = lastArgs,
            thisArg = lastThis;

        lastArgs = lastThis = undefined;
        lastInvokeTime = time;
        result = func.apply(thisArg, args);
        return result;
    }

    function leadingEdge(time) {
        // Reset any `maxWait` timer.
        lastInvokeTime = time;
        // Start the timer for the trailing edge.
        timerId = setTimeout(timerExpired, wait);
        // Invoke the leading edge.
        return leading ? invokeFunc(time) : result;
    }

    function remainingWait(time) {
        var timeSinceLastCall = time - lastCallTime,
            timeSinceLastInvoke = time - lastInvokeTime,
            timeWaiting = wait - timeSinceLastCall;

        return maxing ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting;
    }

    function shouldInvoke(time) {
        var timeSinceLastCall = time - lastCallTime,
            timeSinceLastInvoke = time - lastInvokeTime;

        // Either this is the first call, activity has stopped and we're at the
        // trailing edge, the system time has gone backwards and we're treating
        // it as the trailing edge, or we've hit the `maxWait` limit.
        return (
            lastCallTime === undefined ||
            timeSinceLastCall >= wait ||
            timeSinceLastCall < 0 ||
            (maxing && timeSinceLastInvoke >= maxWait)
        );
    }

    function timerExpired() {
        var time = now();
        if (shouldInvoke(time)) {
            return trailingEdge(time);
        }
        // Restart the timer.
        timerId = setTimeout(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 cancel() {
        if (timerId !== undefined) {
            clearTimeout(timerId);
        }
        lastInvokeTime = 0;
        lastArgs = lastCallTime = lastThis = timerId = undefined;
    }

    function flush() {
        return timerId === undefined ? result : trailingEdge(now());
    }

    function debounced() {
        var time = now(),
            isInvoking = shouldInvoke(time);

        lastArgs = arguments;
        lastThis = this;
        lastCallTime = time;

        if (isInvoking) {
            if (timerId === undefined) {
                return leadingEdge(lastCallTime);
            }
            if (maxing) {
                // Handle invocations in a tight loop.
                clearTimeout(timerId);
                timerId = setTimeout(timerExpired, wait);
                return invokeFunc(lastCallTime);
            }
        }
        if (timerId === undefined) {
            timerId = setTimeout(timerExpired, wait);
        }
        return result;
    }
    debounced.cancel = cancel;
    debounced.flush = flush;
    return debounced;
}

4.2 throttle 函数

function throttle(func, wait, options) {
    var leading = true,
        trailing = true;

    if (typeof func != "function") {
        throw new TypeError(FUNC_ERROR_TEXT);
    }
    if (isObject(options)) {
        leading = "leading" in options ? !!options.leading : leading;
        trailing = "trailing" in options ? !!options.trailing : trailing;
    }
    return debounce(func, wait, {
        leading: leading,
        maxWait: wait,
        trailing: trailing
    });
}

5. Vue 3 实例

在用户输入内容时防抖,输入内容 200 毫秒后打印输入内容:

<template>
    <input @input="handleInput" />
</template>

<script setup>
import { debounce } from "lodash-es";

let handleInput = debounce((event) => {
    console.log(event.target.value);
}, 200);
</script>

6. 总结

防抖和节流均是通过减少实际逻辑处理过程的执行来提高事件处理函数运行性能的手段,并没有实质上减少事件的触发次数。防抖要连续操作结束后再执行,而节流确保一段时间内只执行一次。以网页滚动为例,防抖要等到用户停止滚动后才执行,节流则是如果用户一直在滚动网页,那么在滚动过程中还是会执行。

原文地址