第十一章 用Refs实现高级节流和防抖 上

215 阅读7分钟

在前面几章,我们讨论了Ref的细节,如何使用Ref,以及何时不使用Ref。然而,还有Ref的一个重要用法,我们还没讨论。在处理像 setInterval(设置间隔定时器)或者 debounce(防抖函数)这类函数时,Refs正在存储各种各样的定时器以及超时的标识符。这在处理form元素时,是非常常用的。我们经常希望为输入控件的onChange回调设置截流/防抖,使表单不会因为输入行为而重新渲染。

事实上,在React实现截流/防抖还是非常有挑战性的。你会发现,把回调函数包裹在来自lodashdebounce函数,其实没啥用。所以在这一章,我们要一起实现一个可用的useDebounce钩子,并关注其需要注意的点。我打算使用 Lodash 库中的防抖(debounce)和节流(throttle)函数,并且在这里将重点关注 React 特有的内容。

在这个过程中,我们将学到:

  • 什么是防抖(debounce)和节流(throttle),它们之间的区别是什么?
  • 为什么不能直接在事件处理上使用debounce(防抖)。
  • 如何通过useMemouseCallback来实现防抖(debounce)和节流(throttle),以及这样做的弊端。
  • 如何通过Refs来实现防抖(debounce)和节流(throttle),以及通过Refs实现与通过useMemouseCallback来实现的区别。
  • 如何使用闭包陷阱逃脱技巧来实现防抖功能。

什么是节流和防抖

简单来说,防抖和截流就是在函数被短时调用太多次时,允许跳过一些函数执行的技巧。

想象 一下,我们 正在 实现 一个 简单的 异步 搜索 函数:当 用户 在 一个 输入框 输入 内容时,系统 会把 输入内容 传给 后端,并获取 对应 搜索 结果。我们 可以 用 onChange 回调,实现 一个 简单的 版本:

const Input = () => {
    const onChange = (e) => {
        // send data from input field to the backend here
        // will be triggered on every keystroke
    };
    return <input onChange={onChange} />;
};

但是,一个 熟练的 打字员 可以 一分钟 键入 70个 单词,这 意味着 每秒 就要 键入 6个 单词。没有 一个 后端 能够 处理 如此 频繁 的请求,当然 也 不需要。

与其 在每次 键入 时 发送 后端 请求,我们 可以 等到 用户 停止 输入时 再 发送 后端请求。而这,正是 debounce(防抖)做的。如果 我对 onChange 使用了 debounce(防抖),它会检查 每次 我的 调用 意图。如果 等待 间隔 还没有 过去,它 会 放弃 之前的 调用, 并重新 启动 “计时器”。

const Input = () => {
    const onChange = (e) => {
        // send data from input field to the backend here
        // will be triggered on every keystroke
    };
    
    const debouncedOnChange = debounce(onChange, 500)
    return <input onChange={onChange} />;
};

在 我们 使用 防抖 之前,如果 我 在 输入框 输入 “React”,每 键入 一个 字母,都会 触发 一次 后端 接口,就像:“R”,“Re”,“Rea”, “Reac”, “React”。现在,有了 防抖 功能后,它 会在 我 停止 输入后,等待 500 毫秒,再 以 “React” 为 搜索 内容 调用 后端接口。

下面 是 一个debounce(防抖)函数的 实现:它 接受 一个 函数 作为 参数,并 返回 另一个 函数。而在 返回的 函数 内部,有 一个 计时器 来 计算 当前 调用 是否 在 时间间隔 内。如果在 时间间隔 内,回 跳过 这次 调用, 并 重启 计时器。如果 过了 时间间隔,会 执行 这个 传入的函数。本质上,是 这样 的:

const debounce = (callback, wait) => {
    // initialize the timer
    let timer;
    // lots of code here involving the actual implementation of timer
    // to track the time passed since the last callback call
    const debouncedFunc = () => {
        // checking whether the waiting time has passed
        if (shouldCallCallback(Date.now())) {
            callback();
        } else {
            // if time hasn't passed yet, restart the timer
            timer = startTimer(callback);
        }
    };
    return debouncedFunc;
};

当然了,debounce(防抖)函数的 完整 实现 要 更 复杂 一些。你 可以 去 lodash 看 其 完整 实现。

Throttle(节流)与之类似。并且 保留 内部跟踪器 以及 使用 一个 返回 函数 的 函数 这种 思路 是 相同的。不同 之处 在于 节流会 保证 在 每个 等待间隔 内 定期调用 回调函数,而防抖(debounce)会 不断 重置 定时器 并 一直 等待 直到 最后。

如果想理解 debounce(防抖)函数 和 Throttle(节流)函数 的 区别 的话,可以从 一个 自动 保存 功能 切入。如果,一个 用户 正在 飞速 键入 一些 内容,我们 需要 实现 能够 在 用户 不 点击 保存的 情况下 自动 保存的 功能。这时,使用 debounce(防抖)函数 仅仅会 触发 一次 接口 行为。如果 此时 发声了 一些 意外,刚刚 输入 的 内容 全都会 丢失。而 Throttle(节流) 函数 则会 “定时地” 调用。如果这样,即使 发生了 意外,还是 有 大部分 内容 会被 保存 起来。

代码示例: advanced-react.com/examples/11…

防抖 与 重新渲染

现在,我们 知道了 什么是 节流 和 防抖,其 应用场景 与 实现方式。现在,该 探讨 该 如何 在 React中 使用 它们了。

首先,我们看看 如何 在 输入框 实现 防抖 回调函数(节流的 原理 与之 类似,故不赘述)。

const Input = () => {
    const onChange = (e) => {
        // send data from input to the backend here
    }
    
    const debouncedOnChange = debounce(onChange, 500);
    
    return <input onChange={debouncedOnChange} />
}

这代码 看着 是 没毛病,但 其实 跑 不起来。

想要 让 这段 代码 跑起来,我们 需要 把 一些 值 存在 状态 里。

const Input = () => {
    // addingt state for the value
    const [value, setValue] = useState();
    const onChange = (e) => {
        // send data from input to the backend here
    }
    
    const debouncedOnChange = debounce(onChange, 500);
    
    // turning input into controlled component by passing value from state here
    return <input onChange={debouncedOnChange} value={value} />
}

之后,就是 让 回调 函数 可以 记录 输入的 值 了:

const Input = () => {
    // addingt state for the value
    const [value, setValue] = useState();
    
    const onChange = (e) => {
        // set state value from onChange event
        setValue(e.target.value)
    }

    return <input onChange={debouncedOnChange} value={value} />
}

但是 这段 回调 函数 无法 放在 防抖 函数里:因为 这样 就 无法 实时 更新 刚刚 输入 的 值 了。

const Input = () => {
    // addingt state for the value
    const [value, setValue] = useState();
    
    const onChange = (e) => {
        // set state value from onChange event
        setValue(e.target.value)
    }
    
    const debouncedOnChange = debounce(onChange, 500);
    
    // turning input into controlled component by passing value from state here
    return <input onChange={debouncedOnChange} value={value} />
}

每当 用户 输入 内容 时,就要 立即 setValue。然而,这样 又和 防抖 的 逻辑 相 违背。而 真正 需要 被 防抖 的 时机,只有 在 发送 后端 请求 时。

const Input = () => {
  const [value, setValue] = useState();
  const sendRequest = (value) => { // send value to the backend
  };
    // now send request is debounced
  const debouncedSendRequest = debounce(sendRequest, 500);
    // onChange is not debounced anymore, it just calls debounced function
  const onChange = (e) => {
        const value = e.target.value;
        // state is updated on every value change, so input will work

        setValue(value);

        // call debounced request here
        debouncedSendRequest(value);
  };

    return <input onChange={onChange} value={value} />;
};

这逻辑 看着 很熟悉。也是 无用的!现在 调用 请求 不会 防抖式 调用了,只是 延迟 了 一会儿。如果 我在 输入框 输入 “React”,每 键入 一个 字母,都会 触发 一次 后端 接口,就像:“R”,“Re”,“Rea”, “Reac”, “React”。而不是 一次 传 “React” 过去。这样的 功能 还是 不是 一个 防抖 功能, 只是 接口 调用 延迟 了一下

代码示例: advanced-react.com/examples/11…

而 造成 这个 问题 的 原因 是: React 的 重新渲染。从 第一章可知,一个 状态的 变化,会 触发 组件的 重新渲染。而 这个 例子 中,每次 键入 都会 重新 渲染。因此,每次 键入,我们 调用的 防抖回调,不再是 刚才 的 防抖回调了。而当 防抖 函数 被 调用 时:

  • 产生 一个 新 计时器
  • 创建并返回一个函数,在该函数内部,当定时器完成时将调用传入的回调函数

所以,每次 重新 渲染时,我们 都在 调用 debouncedSendRequest = debounce(sendRequest, 500), 我们 重新 生成了: 新的调用,新的计时器,以及 新的返回函数。但是,旧的 调用 并没有 被清除,所以 它会 存在 内存里,等待 时间 到了后 调用。

我们 在此 实现的 ,更像 是 一个 延迟函数。为了 解决 这个问题,我们 应该 只 调用 一次 debouncedSendRequest = debounce(sendRequest, 500),以 保存 这个 计时器 和 返回函数。

最 简单的 只 调用 一次的 方法,就是 将之 放到 Input 组件 外:

 const sendRequest = (value) => {
     // send value to the backend
 };
 
const debouncedSendRequest = debounce(sendRequest, 500);

const Input = () => {
  const [value, setValue] = useState();

  const onChange = (e) => {
        const value = e.target.value;
        setValue(value);

        // debouncedSendRequest is created once, so state caused re-render won't affect it anymore
        debouncedSendRequest(value);
  };

    return <input onChange={onChange} value={value} />;
};

然而,这个 方案 依然 不行。尤其是,当 sendRequest 里 需要 依赖 组件 的 状态 或者 属性 时。我们 应该 使用的 是 缓存 相关 的 钩子:

const Input = () => {
    const [value, setValue] = useState('initial');
    
    // memoize the callback with useCallback
    // we need it since it's a dependency in useMemo below
    const sendRequest = useCallback((value: string) => {
        console.log('Changed value:', value); 
    }, []);
    
    // memoize the debounce call with useMemo
    const debouncedSendRequest = useMemo(() => {
        return debounce(sendRequest, 1000);
    }, [sendRequest]);
    
    const onChange = (e) => {
        const value = e.target.value;
        setValue(value);
        debouncedSendRequest(value);
    };
    
    return <input onChange={onChange} value={value} />;
};

如此 一来,防抖函数 就 如 预期 运行了。

代码示例: advanced-react.com/examples/11…