代码挑战:手写实现一个具备“防抖”功能的 `useDebounceEffect`

各位同仁,各位技术爱好者,欢迎来到我们今天的技术讲座。今天,我们将深入探讨一个在现代前端开发中极其常见且至关重要的问题:如何优雅地处理高频事件。我们不仅会理解其背后的原理,更会亲手实现一个功能强大、逻辑严谨的React Hook:useDebounceEffect

在构建交互式用户界面时,我们经常会遇到这样的场景:用户在搜索框中连续输入字符,浏览器窗口被频繁地调整大小,或者一个复杂的计算需要根据用户输入实时更新。在这些情况下,如果我们的应用程序对每一个微小的事件都立即做出响应,那么很快就会暴露出性能瓶颈。频繁的网络请求可能会耗尽API配额,密集的DOM操作可能导致界面卡顿,不必要的计算则会浪费宝贵的CPU资源。

为了解决这些问题,我们通常会借助两种强大的技术:防抖(Debounce)节流(Throttle)。它们就像是事件处理的“智能过滤器”,能够控制函数执行的频率。今天,我们的焦点将完全集中在防抖上。

1. 防抖的本质:延迟与取消

想象一下,你正在乘坐电梯,电梯门即将关闭。如果有人在门即将关闭的瞬间按下开门按钮,电梯门会重新打开并保持一段时间,等待潜在的乘客。如果在等待期间又有人按下了开门按钮,那么电梯门会再次延长保持开启的时间,之前的等待计时器会被取消,重新开始计时。只有在设定的时间内没有任何人再按下开门按钮,电梯门才会最终关闭。

这就是防抖的核心思想:在事件被触发后,不立即执行函数,而是等待一段指定的时间。如果在等待期间该事件再次被触发,则取消上次的等待,并重新开始计时。只有当事件在指定时间内不再被触发时,函数才会被执行。

用更技术化的语言来说,防抖确保了在一个连续触发的事件流中,只有当事件“平静”下来,即在某段时间内没有新的事件触发时,才执行一次回调函数。这对于那些只需要在用户停止操作后才执行的操作(例如搜索建议、自动保存、窗口大小调整后的布局更新)来说,是完美的解决方案。

让我们通过一个简单的表格来对比一下防抖与节流,以便更好地理解防抖的独特之处:

特性 防抖 (Debounce) 节流 (Throttle)
触发时机 在事件停止触发后的一段时间执行 在一段时间内只执行一次
执行频率 不保证执行频率,只在事件流结束后执行一次 保证在指定周期内最多执行一次
典型应用 搜索框输入、窗口resize结束、自动保存、拖拽结束 滚动加载、射击游戏(限制子弹发射频率)、高频点击
核心机制 clearTimeout 取消前一个 setTimeout 记录上次执行时间,判断是否达到冷却时间

今天,我们专注于防抖,因为它在处理“用户停止操作”这一场景中有着不可替代的优势。

2. 纯JavaScript防抖函数的实现

在深入React Hooks之前,我们先从最基础的纯JavaScript防抖函数开始。理解这个基础版本对于构建React Hook至关重要。

一个基本的防抖函数需要以下几个要素:

  1. 一个定时器ID:用于存储 setTimeout 返回的ID,以便后续可以通过 clearTimeout 取消。
  2. 一个延迟时间:指定函数执行前需要等待的时间。
  3. 一个回调函数:真正要执行的业务逻辑。
/**
 * 一个基础的防抖函数实现
 * @param {Function} func 要防抖的函数
 * @param {number} delay 防抖延迟时间(毫秒)
 * @returns {Function} 防抖后的函数
 */
function debounce(func, delay) {
    let timeoutId = null; // 用于存储定时器ID

    // 返回一个新的函数,这个函数才是会被实际调用的防抖版本
    return function(...args) {
        // 保存当前的 this 上下文
        const context = this;

        // 如果存在上一个定时器,则清除它,重新开始计时
        if (timeoutId) {
            clearTimeout(timeoutId);
        }

        // 设置新的定时器
        timeoutId = setTimeout(() => {
            // 在延迟结束后执行原始函数
            // 使用 apply 确保 func 内部的 this 和参数正确传递
            func.apply(context, args);
            timeoutId = null; // 执行后重置定时器ID
        }, delay);
    };
}

代码解析:

  1. let timeoutId = null;: 在 debounce 函数的闭包作用域中声明一个变量 timeoutId。这是关键,因为它会在多次调用返回的防抖函数之间保持其值,从而能够跟踪并清除前一个定时器。
  2. return function(...args) { ... };: debounce 函数返回一个新的函数。用户实际调用的是这个返回的函数。
  3. const context = this;: 在返回的函数内部,this 关键字指向的是该函数被调用时的上下文。我们使用 context 变量保存它,以便在 setTimeout 的回调中正确地将 this 传递给原始函数 func
  4. if (timeoutId) { clearTimeout(timeoutId); }: 这是防抖的核心逻辑。每次调用防抖函数时,如果之前已经设置了一个定时器(即 timeoutId 不为 null),就立即取消它。
  5. timeoutId = setTimeout(() => { ... }, delay);: 设置一个新的定时器。
  6. func.apply(context, args);: 当定时器到期时,执行原始函数 funcapply 方法允许我们指定 func 执行时的 this 上下文 (context) 和参数 (args)。
  7. timeoutId = null;: 在函数执行完毕后,将 timeoutId 重置为 null,这对于确保下一次防抖操作的正确开始非常重要,尤其是在连续触发后“静止”一段时间再次触发的场景。

使用示例:

function fetchData(query) {
    console.log(`Fetching data for: ${query}`);
    // 模拟网络请求
}

const debouncedFetchData = debounce(fetchData, 500);

// 模拟用户输入
debouncedFetchData('a');
debouncedFetchData('ab');
debouncedFetchData('abc'); // 前两次的定时器会被取消,只有这次的会在500ms后执行

setTimeout(() => {
    debouncedFetchData('def'); // 500ms后执行
}, 1000);

在上面的示例中,尽管 debouncedFetchData 被连续调用了三次,但由于防抖机制,fetchData 实际上只会在最后一次调用 debouncedFetchData('abc') 后的500毫秒执行一次。然后,在1秒后再次调用,又会等待500毫秒才执行。

3. 从纯JS到React:为什么需要Hook?

现在我们有了一个功能完善的纯JavaScript防抖函数。那么,为什么不能直接在React组件中使用它呢?

// 假设我们在一个React组件中使用
function SearchInput() {
    const [query, setQuery] = React.useState('');

    // 错误的使用方式:每次组件渲染都会创建一个新的 debouncedFetchData
    const debouncedFetchData = debounce((searchQuery) => {
        console.log(`Fetching data for: ${searchQuery}`);
    }, 500);

    const handleChange = (e) => {
        setQuery(e.target.value);
        debouncedFetchData(e.target.value); // 调用防抖函数
    };

    return <input type="text" value={query} onChange={handleChange} />;
}

这种直接在组件函数体内部调用 debounce 的方式存在严重问题:

  1. 每次渲染都会创建新的防抖函数:React组件在每次状态或props更新时都会重新渲染。这意味着,debounce 函数会在每次渲染时被调用,从而创建一个全新的 debouncedFetchData 函数。
  2. 丢失状态:由于 timeoutId 变量是闭包在 debounce 函数每次执行时创建的新作用域中的,每次渲染都会创建一个新的 timeoutId,导致无法清除前一次渲染设置的定时器。这会使防抖功能失效,因为每个 setTimeout 都会独立运行,最终可能导致多次执行。
  3. 内存泄漏:旧的、未被清除的定时器可能会持续存在,直到它们自然到期,造成不必要的资源消耗。

为了在React中正确地使用防抖,我们需要一个能够管理其生命周期、跨渲染保持状态的机制。React Hooks正是为此而生。useCallbackuseRef 可以帮助我们保持函数和变量的引用稳定,而 useEffect 则用于处理副作用和其清理。

4. useDebounceEffect 的设计目标与初步构想

我们的目标是创建一个名为 useDebounceEffect 的自定义Hook,它的行为应类似于 useEffect,但其回调函数(即“副作用”)的执行是防抖的。

useEffect 的基本签名是 useEffect(callback, dependencies)。我们的 useDebounceEffect 也应遵循类似的模式:

useDebounceEffect(effect, delay, deps);
  • effect: 一个函数,包含要防抖执行的副作用逻辑。它也可以返回一个清理函数,就像 useEffect 一样。
  • delay: 防抖的延迟时间(毫秒)。
  • deps: 依赖项数组。当这些依赖项中的任何一个发生变化时,当前的防抖计时器应该被清除,并重新开始计时。

初步构想:

我们可以在 useEffect 内部设置 setTimeout,并在其清理函数中清除 setTimeout

import React, { useEffect, useRef } from 'react';

/**
 * 初步构想的 useDebounceEffect
 * 问题:当 deps 改变时,旧的 effect 会被防抖执行,而不是新的 effect。
 */
function useDebounceEffect_V1(effect, delay, deps) {
    // 依赖项数组应作为 useEffect 的依赖,以触发重新运行
    useEffect(() => {
        const handler = setTimeout(() => {
            // 执行 effect
            const cleanup = effect();
            // 如果 effect 返回了清理函数,则在组件卸载或下次 effect 运行前执行
            return typeof cleanup === 'function' ? cleanup : undefined;
        }, delay);

        // 清理函数:在组件卸载或依赖项改变时清除定时器
        return () => {
            clearTimeout(handler);
        };
    }, [...(deps || []), delay]); // 将 delay 也作为依赖,确保 delay 改变时重新设置定时器
}

分析 useDebounceEffect_V1 的问题:

这个版本看起来合理,但它有一个微妙但关键的问题。考虑以下场景:

function MyComponent() {
    const [searchTerm, setSearchTerm] = React.useState('');
    const [count, setCount] = React.useState(0);

    useDebounceEffect_V1(
        () => {
            console.log(`Searching for "${searchTerm}" with count ${count}`);
            // 模拟 API 调用
        },
        500,
        [searchTerm, count]
    );

    return (
        <div>
            <input
                type="text"
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
            />
            <button onClick={() => setCount(c => c + 1)}>Increment Count ({count})</button>
        </div>
    );
}
  1. 用户快速输入 a, ab, abcsearchTerm 快速变化。
  2. 每次 searchTerm 变化,useDebounceEffect_V1useEffect 都会重新运行。
  3. useEffect 的清理函数 clearTimeout(handler) 会被调用,清除前一个定时器。
  4. 然后一个新的 setTimeout 会被设置。
  5. 最终,effect 会在用户停止输入后500毫秒执行,并且会使用最新的 searchTermcount这看起来没问题!

那么问题究竟在哪里?

问题在于,useEffectdeps 数组的语义是:当 deps 变化时,重新运行 useEffect 的回调函数。如果 effect 函数本身或 delay 变化了,也会触发 useEffect 的重新运行。

useDebounceEffect 的核心需求是:deps 数组中的任何一项发生变化时,我们应该取消当前正在进行的防抖计时,并重新开始一个计时器,等待新的 effectdelay 后执行。

useDebounceEffect_V1 已经做到了这一点,因为 [...(deps || []), delay] 包含了所有相关依赖。当 depsdelay 变化时,useEffect 会重新运行,旧的定时器会被清除,新的定时器会启动。这实际上就是我们期望的行为。

但是,我们通常会看到一些 useRef 的使用,那为什么呢?

这是因为 useDebounce (防抖一个值) 和 useDebounceEffect (防抖一个副作用) 之间存在一些混淆。

对于 useDebounce 来说,它通常会返回一个防抖后的最新值:

// 示例:useDebounce (防抖一个值)
function useDebounce(value, delay) {
    const [debouncedValue, setDebouncedValue] = React.useState(value);

    React.useEffect(() => {
        const handler = setTimeout(() => {
            setDebouncedValue(value); // 在延迟后更新防抖值
        }, delay);

        return () => {
            clearTimeout(handler);
        };
    }, [value, delay]); // 依赖 value 和 delay

    return debouncedValue;
}

在这个 useDebounce 的例子中,valueuseEffect 的依赖。当 value 变化时,useEffect 会重新运行,清除旧定时器,设置新定时器,并在 delay 后更新 debouncedValue。这是正确的。

回到 useDebounceEffect,我们传递给它的 effect 函数本身可能是一个新的函数引用,或者它内部使用了新的状态/props。由于我们将 effect 函数也作为了 useEffect 的依赖,当 effect 函数本身发生变化时,useEffect 也会重新运行,从而清除旧定时器并设置新定时器。

所以,实际上 useDebounceEffect_V1 在处理 deps 变化时是正确的。它会确保:

  1. 如果 deps 变化,旧的定时器被清除,新的定时器启动。
  2. 如果 delay 变化,旧的定时器被清除,新的定时器启动。
  3. 如果 effect 函数本身发生引用变化(因为它通常会包含 deps 数组中的变量),旧的定时器被清除,新的定时器启动。

这正是防抖效果所需要的。

5. 完善 useDebounceEffect:处理 effect 的清理函数

useEffect 的一个重要特性是其回调函数可以返回一个清理函数。这个清理函数会在组件卸载时,或者在下一次 useEffect 回调执行前执行。我们的 useDebounceEffect 也应该支持这个功能。

useDebounceEffect_V1 中,我们已经包含了 const cleanup = effect(); return typeof cleanup === 'function' ? cleanup : undefined; 这一行。这部分是正确的。

让我们来构建一个更加健壮和标准化的 useDebounceEffect。为了清晰起见,我们将把 effect 函数和 delay 传入的 deps 分开考虑。

import React, { useEffect, useRef } from 'react';

/**
 * 一个健壮的 useDebounceEffect 实现
 * 类似于 useEffect,但会防抖其执行。
 *
 * @param {Function} effect 要防抖执行的副作用函数。它可以返回一个清理函数。
 * @param {number} delay 防抖延迟时间(毫秒)。
 * @param {Array<any>} deps 依赖项数组。当这些依赖项变化时,会重新启动防抖计时器。
 */
function useDebounceEffect(effect, delay, deps) {
    // 使用 useRef 来保存最近的 effect 函数引用。
    // 这可以避免将 effect 放到 useEffect 的依赖数组中,从而避免不必要的重新执行,
    // 同时确保在定时器触发时,我们总是调用最新的 effect 函数。
    const latestEffect = useRef(effect);
    latestEffect.current = effect;

    // 使用 useRef 来保存最近的清理函数。
    // 这样可以在定时器被清除(例如依赖变化或组件卸载)时,
    // 确保调用的是上一次 effect 执行返回的清理函数。
    const latestCleanup = useRef();

    useEffect(() => {
        // 在每次依赖变化时,如果存在上一次的清理函数,则执行它。
        // 这模拟了 useEffect 在重新运行前执行清理的机制。
        if (latestCleanup.current) {
            latestCleanup.current();
            latestCleanup.current = undefined; // 清理后重置
        }

        const handler = setTimeout(() => {
            // 当定时器到期时,执行最新保存的 effect 函数
            const cleanup = latestEffect.current();
            // 保存清理函数,以便在下次 effect 运行或组件卸载时调用
            if (typeof cleanup === 'function') {
                latestCleanup.current = cleanup;
            }
        }, delay);

        // 返回清理函数:在组件卸载或依赖项变化时执行。
        // 它会清除当前正在进行的定时器,并执行上次 effect 返回的清理函数。
        return () => {
            clearTimeout(handler);
            if (latestCleanup.current) {
                latestCleanup.current();
                latestCleanup.current = undefined; // 清理后重置
            }
        };
    }, deps); // 只有当 deps 数组中的项发生变化时,才重新运行 useEffect
}

深入解析这个健壮的 useDebounceEffect

  1. latestEffect = useRef(effect); latestEffect.current = effect;:
    • useRef 创建一个可变的引用对象,其 .current 属性可以存储任何值。
    • 在每次组件渲染时,latestEffect.current 都会被更新为最新的 effect 函数。
    • 这样做的目的是确保当 setTimeout 的回调函数最终执行时,它总是能够访问到最新的 effect 函数,即使 effect 函数本身在 delay 期间发生了变化。
    • 为什么不直接把 effect 放在 useEffectdeps 里? 如果 effect 依赖了组件状态或props,那么 effect 函数的引用在每次渲染时都可能发生变化。如果我们将 effect 放在 deps 数组中,那么 useEffect 就会在 effect 改变时重新运行,从而清除旧定时器并设置新定时器。这正是我们想要的防抖行为。
    • 那么 useRef 在这里的作用是什么? 它的主要目的是为了在 useEffectdeps 数组中省略 effect 本身,从而避免 effect 引用变化时触发 useEffect 重新运行(因为我们只希望 deps 数组的变化来触发)。然而,对于 useDebounceEffect 而言,通常我们是希望 effect 的内部依赖变化时也重新启动防抖的。
    • 纠正: 实际上,将 effect 放在 deps 中是更直接且通常更正确的做法,因为它确保了 effect 函数本身的任何内部依赖变化都会被考虑进来。useRef 方案在某些场景下是为了优化性能,避免不必要的 useEffect 重新运行,但它也可能导致更复杂的逻辑来确保 effect 内部依赖的最新性。对于 useDebounceEffect 来说,我们确实希望当 effect 内部的数据发生变化时,防抖能够重新启动。因此,我们将重新审视 useRef 的使用。

让我们重新思考 effectdeps 的关系。effect 函数本身通常会依赖于一些状态或props。如果这些状态或props变化了,那么 effect 函数的引用也会变化(如果它是一个内联函数),或者它内部捕获的变量会是旧的。

更合理的 useDebounceEffect 版本:

这个版本将更紧密地模拟 useEffect 的行为,并确保 effect 函数本身在 delay 期间始终保持最新,并且其内部返回的清理函数能被正确调用。

import React, { useEffect, useRef } from 'react';

/**
 * 健壮且符合 React 最佳实践的 useDebounceEffect 实现。
 * 类似于 useEffect,但其回调函数的执行是防抖的。
 *
 * @param {Function} effect 要防抖执行的副作用函数。它应该是一个稳定的函数引用,
 *                         或者使用 useCallback 包装以避免不必要的重新渲染。
 *                         它可以返回一个清理函数。
 * @param {number} delay 防抖延迟时间(毫秒)。
 * @param {Array<any>} deps 依赖项数组。当这些依赖项变化时,会重新启动防抖计时器。
 */
function useDebounceEffect(effect, delay, deps) {
    // 使用 useRef 来保存 effect 返回的清理函数。
    // 这样,在 useEffect 的 cleanup 阶段,我们能够访问到上一次 effect 执行后返回的清理函数。
    const cleanupRef = useRef();

    // 使用 useRef 来保存最近一次被触发的 effect 函数。
    // 这可以确保在 debounce 计时器到期时,总是执行最新的 effect 函数。
    // 即使 effect 函数本身在 delay 期间因组件重新渲染而发生了变化,
    // 通过 latestEffect.current 我们也能访问到最新的版本。
    const latestEffectRef = useRef(effect);
    latestEffectRef.current = effect;

    useEffect(() => {
        // 在新的定时器设置之前,如果存在上一次 effect 运行返回的清理函数,
        // 则执行它。这模拟了 useEffect 在依赖项变化时,先清理旧副作用再运行新副作用的机制。
        if (cleanupRef.current) {
            cleanupRef.current();
            cleanupRef.current = undefined; // 清理后重置
        }

        // 设置防抖定时器
        const handler = setTimeout(() => {
            // 当定时器到期时,执行最新保存的 effect 函数
            const cleanup = latestEffectRef.current();
            // 如果 effect 返回了清理函数,则保存它,以便在下次清理时调用
            if (typeof cleanup === 'function') {
                cleanupRef.current = cleanup;
            }
        }, delay);

        // 返回 useEffect 的清理函数。
        // 这个函数会在组件卸载时,或者在 deps 数组中的依赖项发生变化,
        // 导致 useEffect 再次运行时,清除当前的防抖定时器并执行上一次 effect 返回的清理函数。
        return () => {
            clearTimeout(handler); // 清除当前的防抖定时器
            if (cleanupRef.current) {
                cleanupRef.current(); // 执行上一次 effect 返回的清理函数
                cleanupRef.current = undefined; // 清理后重置
            }
        };
    }, [...(deps || []), delay]); // 依赖项包括传入的 deps 和 delay
}

再次深入解析这个优化后的 useDebounceEffect

  1. cleanupRef = useRef();: 这个 useRef 的作用是保存 effect 函数执行后返回的清理函数。为什么需要它?因为 useEffect 的清理函数会在 deps 变化或者组件卸载时执行,此时我们需要执行的清理函数是上一次成功防抖执行的 effect 所返回的清理函数

    • 考虑一个场景:effect 运行并返回 cleanupA
    • 此时 deps 变化,useEffect 的清理函数被调用。它需要清除当前定时器,并且执行 cleanupA
    • effect 再次防抖运行,返回 cleanupB
    • 如果 deps 再次变化,useEffect 的清理函数被调用。它需要清除当前定时器,并且执行 cleanupB
    • cleanupRef 正是用来在 useEffect 的整个生命周期内持久化这个 effect 返回的清理函数。
  2. latestEffectRef = useRef(effect); latestEffectRef.current = effect;:

    • 这个 useRef 的主要目的是确保在 setTimeout 回调执行时,我们总是调用最新的 effect 函数。
    • 为什么不直接在 setTimeout 中闭包 effect 如果 effect 函数依赖于组件的状态或props,并且这些状态或props在 delay 期间发生了多次变化,那么 effect 函数的引用本身也可能在每次渲染时发生变化。如果 effectuseEffect 的依赖项,那么 useEffect 会重新运行,这将清除旧定时器并启动新定时器,导致 latestEffectRef 总是最新的。
    • effect 不在 deps 数组中时,latestEffectRef 的重要性就体现出来了。 某些场景下,我们可能不希望 effect 引用变化就重新启动防抖,而只希望 deps 数组的变化来触发。在这种情况下,latestEffectRef 确保了即使 effect 变了,但定时器仍在运行,当定时器到期时,它会调用最新的 effect
    • 然而,对于 useDebounceEffect 而言,通常我们是希望 effect 的内部依赖变化时也重新启动防抖的。 因此,将 effect 放在 deps 中是更直接且通常更正确的做法。
    • 结论: 尽管 latestEffectRef 在某些 useRef 模式中很有用,但对于 useDebounceEffect 来说,更符合直觉和 useEffect 原则的做法是,如果 effect 函数是内联的并且依赖于组件状态/props,那么它的引用变化应该被视为 deps 的一部分,从而触发防抖的重新计时。如果 effect 是一个通过 useCallback 稳定化的函数,那么它的引用不会频繁变化。

重新审视 deps 数组的构成:

useEffectdeps 数组应该包含所有在 useEffect 回调函数中使用的、且在组件生命周期中可能变化的外部变量(props, state, 函数)。

在我们的 useDebounceEffect 中:

  • effect 函数本身:如果 effect 函数是内联的,它的引用会随每次渲染变化。如果它依赖于组件状态或props,那么当这些状态/props变化时,effect 内部捕获的值也可能变化。因此,将 effect 包含在 deps 中是合理的。
  • delay:如果 delay 变化,我们显然希望重新设置定时器。
  • 传入的 deps:这些是用户明确指定的,当它们变化时,应该重新启动防抖。

所以,最符合 useEffect 语义的 deps 数组应该是 [effect, delay, ...deps]

基于此,我们再来一次最终的、最简洁且符合 useEffect 语义的 useDebounceEffect 实现。这个版本将不需要 latestEffectRef,因为 effect 会作为 useEffect 的依赖,确保在 delay 期间 effect 总是最新的。

6. 最终的、简洁且符合 useEffect 语义的 useDebounceEffect

import React, { useEffect, useRef } from 'react';

/**
 * 最终版 useDebounceEffect 实现。
 * 类似于 useEffect,但其回调函数的执行是防抖的。
 *
 * @param {Function} effect 要防抖执行的副作用函数。
 *                         它应该是一个稳定的函数引用 (例如用 useCallback 包装),
 *                         或者在 deps 数组中包含其所有依赖,以便正确触发防抖。
 *                         它可以返回一个清理函数。
 * @param {number} delay 防抖延迟时间(毫秒)。
 * @param {Array<any>} deps 依赖项数组。当这些依赖项变化时,会重新启动防抖计时器。
 */
function useDebounceEffect(effect, delay, deps) {
    // useRef 用于保存 effect 返回的清理函数。
    // 这样可以在组件卸载或下次 effect 运行前执行它。
    const cleanupRef = useRef();

    // useRef 用于保存当前的定时器ID。
    // 这是防抖的核心:在 deps 变化时清除旧定时器。
    const timeoutIdRef = useRef();

    // 核心 useEffect 逻辑
    useEffect(() => {
        // 在设置新的防抖定时器之前,首先清理上一次防抖执行后可能返回的清理函数。
        // 这模仿了 useEffect 在 deps 变化时,先执行旧副作用的清理,再运行新副作用的行为。
        if (cleanupRef.current) {
            cleanupRef.current();
            cleanupRef.current = undefined; // 清理后重置
        }

        // 每次 deps 变化时,清除之前设置的所有定时器
        if (timeoutIdRef.current) {
            clearTimeout(timeoutIdRef.current);
            timeoutIdRef.current = undefined;
        }

        // 设置新的防抖定时器
        timeoutIdRef.current = setTimeout(() => {
            // 当定时器到期时,执行 effect 函数
            const cleanup = effect();
            // 如果 effect 返回了清理函数,则保存它
            if (typeof cleanup === 'function') {
                cleanupRef.current = cleanup;
            }
            timeoutIdRef.current = undefined; // 执行后重置定时器ID
        }, delay);

        // useEffect 的清理函数:在组件卸载或 deps 再次变化时执行
        return () => {
            // 清除当前正在等待的防抖定时器
            if (timeoutIdRef.current) {
                clearTimeout(timeoutIdRef.current);
                timeoutIdRef.current = undefined;
            }
            // 执行上一次成功防抖执行的 effect 返回的清理函数
            if (cleanupRef.current) {
                cleanupRef.current();
                cleanupRef.current = undefined;
            }
        };
    }, [...(deps || []), delay, effect]); // 依赖项:传入的 deps, delay, 以及 effect 函数本身
}

这个最终版本的优点:

  1. 完整模拟 useEffect 行为

    • effect 函数在 delay 后执行。
    • effect 可以返回一个清理函数,这个清理函数会在组件卸载或下一次 useDebounceEffect 因为依赖变化而重新设置定时器之前被调用。
    • deps 数组(包括 delayeffect 本身)中的任何项发生变化时,当前的防抖计时器会被清除,并重新开始计时。
  2. 避免了 latestEffectRef 的复杂性:通过将 effect 函数本身作为 useEffect 的依赖,我们确保了在 setTimeout 回调中引用的 effect 始终是最新版本。这意味着如果 effect 依赖于组件状态或props,当这些依赖变化时,effect 的引用也会变化,从而触发 useEffect 重新运行,防抖计时器也会被正确重置。这比使用 useRef 来“穿透”闭包访问最新 effect 更符合 useEffect 的设计哲学。

  3. 清晰的清理逻辑

    • 每次 deps 变化,useEffect 重新运行前:会先执行上一次 useEffect 返回的清理函数。这个清理函数会清除当前等待的 setTimeout,并执行 cleanupRef.current(即上一次 effect 成功执行后的清理函数)。
    • useEffect 回调内部设置新定时器前:我们再次 clearTimeout(timeoutIdRef.current) 并执行 cleanupRef.current。这是为了确保在极少数情况下,如果 useEffect 在极短时间内多次触发(例如在严格模式下),也能正确处理。
    • setTimeout 成功执行后:如果 effect 返回了清理函数,它会被保存到 cleanupRef.current 中。
    • useEffect 返回的清理函数中clearTimeout 确保了任何未决的防抖操作都被取消。cleanupRef.current 确保了 effect 函数自己返回的清理逻辑也能被执行。

何时使用 useCallback 包装 effect

如果你的 effect 函数是内联的,并且它没有依赖任何外部变量(或者只依赖于 deps 数组中已经包含的变量),那么把它作为 useEffect 的依赖是没问题的。

然而,如果 effect 函数依赖于 propsstate,并且你希望在这些 props/state 变化时,仅仅是 deps 数组中的其他项来触发防抖,而不是 effect 函数自身的引用变化,那么你应该使用 useCallback 来稳定 effect 函数的引用。

例如:

function MyComponent({ onSearch }) {
    const [searchTerm, setSearchTerm] = React.useState('');
    const [page, setPage] = React.useState(1);

    // 假设我们希望 searchTerm 变化时防抖,但 page 变化时立即触发
    // 这不是 useDebounceEffect 的典型用法,但为了说明 useCallback
    // 更好的例子是,effect 内部依赖的变量,我们不希望它导致防抖重置
    // 实际应用中,通常 effect 内部依赖的变量,我们都希望它导致防抖重置。
    // 所以,大多数情况下,直接把 effect 放在 deps 中即可。
    // 只有当 effect 内部有不希望触发防抖重置的变量时,才需要 useCallback 结合 useRef。
    // 但这会引入复杂性,通常不推荐。

    // 让我们简化,假设 effect 依赖 searchTerm,我们希望 searchTerm 变化就重置防抖
    const debouncedSearchEffect = React.useCallback(() => {
        console.log(`Performing search for: ${searchTerm}, page: ${page}`);
        onSearch(searchTerm, page);
        // 返回一个清理函数
        return () => {
            console.log(`Cleaning up search for: ${searchTerm}`);
        };
    }, [searchTerm, page, onSearch]); // 这里的依赖项是 effect 内部使用的所有变量

    useDebounceEffect(debouncedSearchEffect, 500, [searchTerm, page]);
    // 在这个例子中,debouncedSearchEffect 的引用只会在 searchTerm, page, onSearch 变化时改变
    // 我们的 useDebounceEffect 依赖了 effect 本身,所以当这些变量变化时,防抖会重置。
    // 这正是我们想要的行为。所以,这里的 useCallback 主要是为了优化 React 的渲染性能,
    // 避免在没有实际依赖变化时创建新的函数引用,而不是为了 useDebounceEffect 的行为。
    // 对于 useDebounceEffect 来说,只要 effect 引用变化,它就会重置计时。
    // 这种情况下,其实可以不加 useCallback。
    // useDebounceEffect(
    //     () => {
    //         console.log(`Performing search for: ${searchTerm}, page: ${page}`);
    //         onSearch(searchTerm, page);
    //         return () => {
    //             console.log(`Cleaning up search for: ${searchTerm}`);
    //         };
    //     },
    //     500,
    //     [searchTerm, page]
    // ); // 这种写法更常见,且在我们的 hook 中能正确工作。
}

结论: 对于 useDebounceEffect 来说,将 effect 函数作为 useEffect 的依赖项是简单且正确的做法。如果你不希望 effect 引用变化导致防抖重置,那么你需要更复杂的 useRef 模式来存储 effect,并且不将其放入 deps 数组。但这种场景较少,因为它意味着 effect 内部逻辑更新了,但你不想重新防抖。这通常不是我们期望的行为。

因此,我们的最终版本,将 effect 也放入 deps 数组,是最符合 useEffect 哲学且最健壮的实现。

7. 示例应用

让我们看几个 useDebounceEffect 的实际应用场景。

7.1. 搜索框自动建议/API调用

这是防抖最经典的用例。用户输入时,我们不希望每次按键都发送API请求,而是在用户停止输入一段时间后才发送。

import React, { useState } from 'react';
import { useDebounceEffect } from './useDebounceEffect'; // 假设你的 hook 在这个文件

function SearchBar() {
    const [searchTerm, setSearchTerm] = useState('');
    const [results, setResults] = useState([]);
    const [loading, setLoading] = useState(false);

    // 模拟一个 API 调用函数
    const searchApi = async (query) => {
        if (!query) return [];
        console.log(`Performing API search for: "${query}"`);
        setLoading(true);
        return new Promise(resolve => {
            setTimeout(() => {
                const mockResults = [
                    `${query} result 1`,
                    `${query} result 2`,
                    `${query} result 3`
                ].filter(r => r.includes(query));
                setLoading(false);
                resolve(mockResults);
            }, 800); // 模拟网络延迟
        });
    };

    useDebounceEffect(
        () => {
            let isActive = true; // 用于处理异步操作中的竞态条件
            if (searchTerm) {
                searchApi(searchTerm).then(data => {
                    if (isActive) {
                        setResults(data);
                    }
                });
            } else {
                setResults([]); // 清空搜索词时清空结果
            }

            return () => {
                // 清理函数:如果组件卸载或 searchTerm 再次变化,取消旧的 API 请求(如果可能)
                // 在这个模拟中,我们用 isActive 标志来阻止过期的 Promise 更新状态
                isActive = false;
                console.log(`Cleanup for search term: "${searchTerm}"`);
            };
        },
        500, // 500ms 延迟
        [searchTerm] // 只有当 searchTerm 变化时才重新触发防抖
    );

    return (
        <div style={{ padding: '20px' }}>
            <h1>Debounced Search</h1>
            <input
                type="text"
                placeholder="Type to search..."
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
                style={{ width: '300px', padding: '8px', fontSize: '16px' }}
            />
            {loading && <p>Loading...</p>}
            <ul>
                {results.map((result, index) => (
                    <li key={index}>{result}</li>
                ))}
            </ul>
        </div>
    );
}

在这个示例中,当用户连续输入时,searchTerm 会不断变化。useDebounceEffect 会在每次 searchTerm 变化时清除前一个定时器并重新开始计时。只有当用户停止输入500毫秒后,searchApi 才会真正被调用。isActive 标志用于防止在 searchTerm 快速变化时,旧的、较慢的请求在完成后更新状态,从而导致显示过时的结果(这是一种常见的竞态条件处理模式)。

7.2. 文本区域自动保存

另一个常见场景是实时编辑器的自动保存功能。我们不希望每次按键都触发保存操作,而是在用户停止输入一段时间后才触发。

import React, { useState } from 'react';
import { useDebounceEffect } from './useDebounceEffect';

function AutoSaveEditor() {
    const [content, setContent] = useState('');
    const [lastSaved, setLastSaved] = useState(null);

    // 模拟保存到后端
    const saveContent = (textToSave) => {
        console.log(`Saving content: "${textToSave.substring(0, 50)}..."`);
        return new Promise(resolve => {
            setTimeout(() => {
                setLastSaved(new Date());
                resolve();
            }, 600); // 模拟保存延迟
        });
    };

    useDebounceEffect(
        () => {
            if (content) { // 只有内容不为空时才保存
                saveContent(content);
            }
            // 不需要返回清理函数,因为保存操作通常不需要取消
        },
        1000, // 1秒延迟后自动保存
        [content] // 只有当 content 变化时才触发防抖
    );

    return (
        <div style={{ padding: '20px' }}>
            <h1>Auto-Save Editor</h1>
            <textarea
                value={content}
                onChange={(e) => setContent(e.target.value)}
                placeholder="Start typing..."
                rows="10"
                cols="60"
                style={{ fontSize: '16px' }}
            />
            {lastSaved && (
                <p>Last saved: {lastSaved.toLocaleTimeString()}</p>
            )}
        </div>
    );
}

这里,用户在 textarea 中输入内容。content 状态会实时更新。但 saveContent 函数只会在用户停止输入1秒后执行。

7.3. 窗口大小调整后的布局更新

在响应式设计中,当浏览器窗口大小改变时,我们可能需要重新计算布局或重新渲染某些组件。但 resize 事件触发频率非常高,直接响应会导致性能问题。

import React, { useState } from 'react';
import { useDebounceEffect } from './useDebounceEffect';

function ResizableLayout() {
    const [windowDimensions, setWindowDimensions] = useState({
        width: window.innerWidth,
        height: window.innerHeight,
    });

    // 定义一个副作用来更新尺寸
    const updateDimensions = () => {
        console.log(`Window resized to: ${window.innerWidth}x${window.innerHeight}`);
        setWindowDimensions({
            width: window.innerWidth,
            height: window.innerHeight,
        });
    };

    useDebounceEffect(
        () => {
            // 添加 resize 事件监听器
            window.addEventListener('resize', updateDimensions);

            // 返回清理函数,移除事件监听器
            return () => {
                console.log('Removing resize listener');
                window.removeEventListener('resize', updateDimensions);
            };
        },
        300, // 300ms 延迟
        [] // 依赖项为空数组,表示只在组件挂载和卸载时执行一次添加/移除监听器
    );

    // 另一个 useDebounceEffect 来处理 updateDimensions 触发后的行为
    // 注意:这里我们防抖的是事件监听器的回调,而不是 useEffect 的执行本身。
    // 更好的做法是直接防抖 updateDimensions 函数,然后用 useEffect 监听防抖后的函数。
    // 但为了演示 useDebounceEffect,我们这样用。

    // 更直接的做法是:
    const debouncedUpdateDimensions = React.useCallback(
        debounce(() => {
            console.log(`Debounced window resized to: ${window.innerWidth}x${window.innerHeight}`);
            setWindowDimensions({
                width: window.innerWidth,
                height: window.innerHeight,
            });
        }, 300),
        [] // 确保 debouncedUpdateDimensions 函数引用稳定
    );

    useEffect(() => {
        window.addEventListener('resize', debouncedUpdateDimensions);
        return () => {
            window.removeEventListener('resize', debouncedUpdateDimensions);
        };
    }, [debouncedUpdateDimensions]); // 依赖防抖后的函数

    return (
        <div style={{ padding: '20px', border: '1px solid #ccc', minHeight: '100px' }}>
            <h1>Resizable Window Info</h1>
            <p>Current Window Dimensions:</p>
            <p>Width: {windowDimensions.width}px</p>
            <p>Height: {windowDimensions.height}px</p>
            <p>Try resizing your browser window.</p>
        </div>
    );
}

ResizableLayout 的例子中,我们展示了两种处理方式:

  1. 直接在 useDebounceEffect 中注册/注销事件监听器(注释掉的部分):这其实是错误的用法。useDebounceEffect 防抖的是其 effect 回调本身的执行。我们希望 addEventListenerremoveEventListener 是立即执行的,不防抖。我们想要防抖的是 updateDimensions 函数。
  2. 正确的做法 (在代码中保留的部分):使用一个普通的 debounce 工具函数包装 updateDimensions,得到 debouncedUpdateDimensions。然后,再用一个标准的 useEffect 来注册和注销这个防抖后的事件监听器。这才是处理高频DOM事件的推荐方式。

useDebounceEffect 更适用于那些需要基于React状态变化来触发的副作用,例如 searchTerm 变化后触发API请求,而不是直接处理原生DOM事件。

8. 测试 useDebounceEffect

为了确保 useDebounceEffect 的行为符合预期,我们需要编写测试。在React Hooks的测试中,我们通常会使用 @testing-library/react-hooks(或现在是 @testing-library/react 配合 actrenderHook)以及 Jest 的 useFakeTimers

测试要点:

  1. 延迟执行:确认 effectdelay 后才执行。
  2. 取消旧定时器:确认在 delay 期间依赖项变化时,旧的 effect 不会被执行。
  3. 执行最新 effect:确认最终执行的是最新的 effect
  4. 清理函数:确认 effect 返回的清理函数在正确时机被调用(依赖变化前、组件卸载时)。
  5. delay 变化:确认 delay 变化时,防抖逻辑重新启动。
// useDebounceEffect.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useDebounceEffect } from './useDebounceEffect'; // 假设你的 hook 在这个文件

describe('useDebounceEffect', () => {
    beforeEach(() => {
        jest.useFakeTimers(); // 模拟定时器
    });

    afterEach(() => {
        jest.runOnlyPendingTimers(); // 确保所有定时器都被执行
        jest.useRealTimers(); // 恢复真实定时器
    });

    it('should call the effect after the specified delay', () => {
        const effect = jest.fn();
        const delay = 500;
        const { rerender } = renderHook(({ effect, delay, deps }) => useDebounceEffect(effect, delay, deps), {
            initialProps: { effect, delay, deps: [] },
        });

        expect(effect).not.toHaveBeenCalled(); // 立即不应该调用

        act(() => {
            jest.advanceTimersByTime(delay - 1); // 提前一点点
        });
        expect(effect).not.toHaveBeenCalled(); // 还没到时间

        act(() => {
            jest.advanceTimersByTime(1); // 到时间
        });
        expect(effect).toHaveBeenCalledTimes(1); // 应该被调用一次
    });

    it('should cancel previous effects if dependencies change within the delay', () => {
        const effect = jest.fn();
        const delay = 500;
        let count = 0;
        const { rerender } = renderHook(({ effect, delay, deps }) => useDebounceEffect(effect, delay, deps), {
            initialProps: { effect, delay, deps: [count] },
        });

        expect(effect).not.toHaveBeenCalled();

        act(() => {
            jest.advanceTimersByTime(delay / 2); // 走了一半时间
            count = 1; // 依赖变化
            rerender({ effect, delay, deps: [count] }); // 重新渲染,触发防抖重置
            jest.advanceTimersByTime(delay / 2); // 再走一半时间
        });
        expect(effect).not.toHaveBeenCalled(); // 第一次的定时器被取消了

        act(() => {
            jest.advanceTimersByTime(delay / 2); // 再次走一半时间
        });
        expect(effect).toHaveBeenCalledTimes(1); // 只有第二次的定时器触发了

        // 验证 effect 接收到的 count 是最新的
        expect(effect).toHaveBeenCalledWith(); // effect 内部通过闭包访问最新的 count
    });

    it('should execute cleanup function on dependency change', () => {
        const cleanup = jest.fn();
        const effect = jest.fn(() => cleanup); // effect 返回清理函数
        const delay = 500;
        let value = 0;

        const { rerender } = renderHook(({ effect, delay, deps }) => useDebounceEffect(effect, delay, deps), {
            initialProps: { effect, delay, deps: [value] },
        });

        // 第一次执行 effect
        act(() => {
            jest.advanceTimersByTime(delay);
        });
        expect(effect).toHaveBeenCalledTimes(1);
        expect(cleanup).not.toHaveBeenCalled(); // 清理函数此时不应该被调用

        // 改变依赖,触发重新运行,旧的清理函数应该被调用
        act(() => {
            value = 1;
            rerender({ effect, delay, deps: [value] });
            // 在重新设置定时器之前,useEffect 的清理阶段会执行
            // 因此,如果上一次的 effect 已经执行且返回了 cleanup,这里会立即调用它
            // 实际上,我们的 hook 在每次 deps 变化后,设置新定时器前,也会主动调用 cleanupRef.current
        });
        expect(cleanup).toHaveBeenCalledTimes(1); // 此时清理函数应该被调用

        // 等待新的 effect 执行
        act(() => {
            jest.advanceTimersByTime(delay);
        });
        expect(effect).toHaveBeenCalledTimes(2);
        expect(cleanup).toHaveBeenCalledTimes(1); // 清理函数不应该再次被调用
    });

    it('should execute cleanup function on unmount', () => {
        const cleanup = jest.fn();
        const effect = jest.fn(() => cleanup);
        const delay = 500;
        const { unmount } = renderHook(() => useDebounceEffect(effect, delay, []));

        act(() => {
            jest.advanceTimersByTime(delay); // 执行 effect
        });
        expect(effect).toHaveBeenCalledTimes(1);
        expect(cleanup).not.toHaveBeenCalled();

        act(() => {
            unmount(); // 组件卸载
        });
        expect(cleanup).toHaveBeenCalledTimes(1); // 清理函数应该被调用
    });

    it('should reset debounce when delay changes', () => {
        const effect = jest.fn();
        let delay = 500;
        const { rerender } = renderHook(({ effect, delay, deps }) => useDebounceEffect(effect, delay, deps), {
            initialProps: { effect, delay, deps: [] },
        });

        act(() => {
            jest.advanceTimersByTime(delay / 2);
            delay = 1000; // 改变 delay
            rerender({ effect, delay, deps: [] });
        });
        expect(effect).not.toHaveBeenCalled(); // 旧的定时器被取消

        act(() => {
            jest.advanceTimersByTime(delay - 1); // 接近新的 delay
        });
        expect(effect).not.toHaveBeenCalled();

        act(() => {
            jest.advanceTimersByTime(1); // 达到新的 delay
        });
        expect(effect).toHaveBeenCalledTimes(1);
    });
});

这些测试用例覆盖了 useDebounceEffect 的核心行为,确保了其在不同场景下的正确性。

9. 结语

至此,我们已经完整地探讨了防抖的原理,从纯JavaScript实现到最终在React中构建一个健壮的 useDebounceEffect Hook。我们深入分析了其内部机制,包括定时器的管理、依赖项的处理以及清理函数的正确调用。通过这个过程,我们不仅掌握了一个实用的工具,更深刻理解了React Hooks的工作原理以及副作用管理的精髓。在日常开发中,选择使用社区已有的成熟防抖库(如 use-debouncelodash/debounce 结合 useCallback)通常是更高效的选择,但亲手实现它,无疑是提升我们编程功力和对框架理解的宝贵旅程。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注