在前面几章,我们讨论了Ref的细节,如何使用Ref,以及何时不使用Ref。然而,还有Ref的一个重要用法,我们还没讨论。在处理像 setInterval(设置间隔定时器)或者 debounce(防抖函数)这类函数时,Refs正在存储各种各样的定时器以及超时的标识符。这在处理form元素时,是非常常用的。我们经常希望为输入控件的onChange回调设置截流/防抖,使表单不会因为输入行为而重新渲染。
事实上,在React实现截流/防抖还是非常有挑战性的。你会发现,把回调函数包裹在来自lodash的debounce函数,其实没啥用。所以在这一章,我们要一起实现一个可用的useDebounce钩子,并关注其需要注意的点。我打算使用 Lodash 库中的防抖(debounce)和节流(throttle)函数,并且在这里将重点关注 React 特有的内容。
在这个过程中,我们将学到:
- 什么是防抖(debounce)和节流(throttle),它们之间的区别是什么?
- 为什么不能直接在事件处理上使用
debounce(防抖)。 - 如何通过
useMemo和useCallback来实现防抖(debounce)和节流(throttle),以及这样做的弊端。 - 如何通过
Refs来实现防抖(debounce)和节流(throttle),以及通过Refs实现与通过useMemo和useCallback来实现的区别。 - 如何使用闭包陷阱逃脱技巧来实现防抖功能。
什么是节流和防抖
简单来说,防抖和截流就是在函数被短时调用太多次时,允许跳过一些函数执行的技巧。
想象 一下,我们 正在 实现 一个 简单的 异步 搜索 函数:当 用户 在 一个 输入框 输入 内容时,系统 会把 输入内容 传给 后端,并获取 对应 搜索 结果。我们 可以 用 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} />;
};
如此 一来,防抖函数 就 如 预期 运行了。