节流与防抖都是通过延迟执行, 减少调用次数, 来优化频繁调用函数时的性能. 不同的是, 对于一段时间内的频繁调用, 防抖是 延迟执行 一次调用, 节流是 延迟定时 多次调用.
前言
不知道有多少人, 简单的写了防抖, 节流函数, 然后遇到在 react hook 里失效的情况.
失效的原因: 每次 render 时, 内部函数会重新生成并绑定到组件上去.
解决方案: 也很简单, 使用 useCallback , 依赖传入空数组, 保证 useCallback 永远返回同一个函数.
上面呢, 算是这个文章的一个契机吧.
关于手写防抖和节流的思路, 个人认为关键在于都是对 闭包 和 高阶函数 的应用, 以这个为切入点去思考, 手写的时候就不会脑子一片空白了.
防抖 (debounce)
触发事件后在 n 秒内函数只能执行一次, 如果在 n 秒内又触发了事件, 则会重新计算函数执行时间.
初步
- import { useCallback } from 'react';
- /**
- * 防抖 hook
- * @param func 需要执行的函数
- * @param wait 延迟时间
- */
- export function useDebounce<A extends Array<any>, R = void>(
- func: (..._args: A) => R,
- wait: number,
- ) {
- let timeOut: null | Node.JS.Timeout = null;
- function debounced(..._args: A) {
- if (timeOut) {
- clearTimeout(timeOut);
- timeOut = null;
- }
- timeOut = setTimeout(() => {
- fn.apply(null, _args);
- }, wait);
- }
- return useCallback(debounced, []);
- }
这可以用, 但并不够好. 想要进阶更高级的工程师, 就需要将问题再想深一层, 考虑到更复杂的情况, 从而自身得到成长.
进阶版
首先想到的是要返回一个 Promise , 用来传递返回值.
其次考虑到异步的情况, 增加 async.
最后是防抖化之后是否可以立即执行和取消, 所以增加 2 个新函数.
- import { useCallback } from 'react';
- /**
- * 防抖 hook
- * @param func 需要执行的函数
- * @param wait 延迟时间
- */
- export function useDebounce<A extends Array<any>, R = void>(
- func: (..._args: A) => R,
- wait: number,
- ) {
- let timeOut: null | Node.JS.Timeout = null;
- let args: A;
- function debounce(..._args: A) {
- args = _args;
- if (timeOut) {
- clearTimeout(timeOut);
- timeOut = null;
- }
- return new Promise<R>((resolve, reject) => {
- timeOut = setTimeout(async () => {
- try {
- const result = await func.apply(null, args);
- resolve(result);
- } catch (e) {
- reject(e);
- }
- }, wait);
- });
- }
- // 取消
- function cancel() {
- if (!timeOut) return;
- clearTimeout(timeOut);
- timeOut = null;
- }
- // 立即执行
- function flush() {
- cancel();
- return func.apply(null, args);
- }
- debounce.flush = flush;
- debounce.cancel = flush;
- return useCallback(debounce, []);
- }
关于防抖函数还有功能更丰富的版本, 可以看下 lodash 的 debounce 函数
节流 (throttle)
连续触发事件但是在 n 秒中只执行一次函数
节流函数的 2 种思路
时间戳: 通过记录上次执行的时间戳, 和当前时间戳比较来判断是否已到执行时间 , 如果是则执行, 并更新上次执行的时间戳.(问题在于: 事件停止触发时无法执行函数)
定时器: 如果已经存在定时器, 则不执行方法, 直到定时器触发后被清除, 然后重新设置定时器.(问题在于: 事件停止触发后必然会再执行函数)
整合版
把两个整合一下, 根据场景, 需求等来决定, 最后是否需要事件停止触发后定时器执行函数.
- /**
- * 节流 hook
- * @param func 需要执行的函数
- * @param wait 延迟时间
- * @param isTimer 是否开启定时器响应事件结束后的回调
- */
- export function useThrottle<A extends Array<any>, R = void>(
- func: (..._args: A) => R,
- wait: number,
- isTimer: boolean = false,
- ) {
- let timeOut: null | Node.JS.Timeout = null;
- let args: A;
- let agoTimestamp: number;
- function throttle(..._args: A) {
- args = _args;
- if (!agoTimestamp) agoTimestamp = +new Date();
- if (timeOut) {
- clearTimeout(timeOut);
- timeOut = null;
- }
- return new Promise<R>((resolve, reject) => {
- if (+new Date() - agoTimestamp>= wait) {
- try {
- const result = func.apply(null, args);
- resolve(result);
- agoTimestamp = +new Date();
- } catch (e) {
- reject(e);
- }
- } else if (isTimer) {
- timeOut = setTimeout(async () => {
- try {
- const result = await func.apply(null, args);
- resolve(result);
- agoTimestamp = +new Date();
- } catch (e) {
- reject(e);
- }
- }, agoTimestamp + wait - +new Date());
- }
- });
- }
- // 取消
- function cancel() {
- if (!timeOut) return;
- clearTimeout(timeOut);
- timeOut = null;
- }
- // 立即执行
- function flush() {
- cancel();
- return func.apply(null, args);
- }
- throttle.flush = flush;
- throttle.cancel = flush;
- return useCallback(throttle, []);
- }
最后
有个地方有人可能有疑问, 为什么没去用 useRef 去保存 timeOut 呢?
有人可能会认为这会有问题: 因为每次组件重新渲染, 都会执行一遍所有的 hooks, 这样 useDebounce 高阶函数里面的 timeOut 就不能起到缓存的作用 (在 useDebounce 里 console.log(timeOut), 每次 render 时都打印出 null). 所以 timeOut 不可靠, 防抖的核心就被破坏了.
但是呢, 如果你在里面的函数 debounce 里 console.log(timeOut) 会发现, 打印出来的, 就是之前的 timeOut , 所以是没问题的.
最后, 写的过程中, ts 才是我真正花费时间思考的地方. 完成后, 有点微妙的满足感.
来源: http://www.jianshu.com/p/bcfa4a6db2f9