防抖节流看这篇就够了

1,873 阅读6分钟

前言

在日常开发中我们经常遇到一些频繁事件的触发情况,比如在滚动事件中需要做个复杂计算、防止一个按钮的多次点击提交操作、监听窗口的 resize 事件处理一些逻辑等等。这些需求都可以通过函数防抖动或者节流来实现。

防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户频繁触发这个函数,且每次触发函数的间隔小于等待时间,防抖的情况下只会调用一次,而节流的会间隔一定时间触发函数,接下来我们来分析一下两者的区别。

防抖

触发高频事件后 n 秒内函数只会执行一次,如果 n 秒内高频事件再次被触发,则重新计算时间

思路:大部分的实现方式是每次触发事件时清除定时器然后调用方法。这次我们换个思路优化一下,每次触发事件的时候只更新触发时间,然后每次就开启一个定时比对触发时间。

为了更直观的感受事件频繁调用,我们来写个例子: 下面是个 React 组件,添加了 onMouseMove 事件监听鼠标停留的坐标位置。

/** App.tsx */
const App = () => {
	const [position, setPosition] = useState<string>('')
	const [fontSize, setFontSize] = useState<number>(0)

	const handleMove = (event: React.MouseEvent) => {
            setPosition(`${event.pageX}, ${event.pageY}`)
            // 随机设置当前坐标字体大小
            setFontSize(Math.ceil(Math.min(Math.random() * 100, 100)))
	}

	return (
            <div className={ styles.container }>
                <div className={ styles.content } onMouseMove={handleMove} style={{ fontSize }}>
                  { position }
                </div>
                <button className={ styles.btn }>{ fontSize }, 够大了</button>
            </div>
	)
}

我们来看看效果: debounce-1.gif 接下来我们修改 handleMove 添加防抖逻辑:

const handleMove = useCallback(debounce((event: React.MouseEvent) => {
  setPosition(`${event.pageX}, ${event.pageY}`)
  setFontSize(Math.ceil(Math.min(Math.random() * 100, 100)))
}, 2000), [])

新旧版比对

先来看下旧版的防抖实现代码逻辑,每次触发事件时清除当前延时,开启下一个定时:

export default function debounce(func, waitTime) {
  let timer = null; 
  return (...args) => {
    clearTimeout(timer); 
    timer = setTimeout(() => {
      func.apply(this, args);
    }, waitTime);
  };
}

接下来我们把逻辑优化一下,每次触发事件的时候只更新触发时间,开启定时,判断距离上次触发是否已经过了设置的等待时间,如果过了等待时间就执行 func

export default function debounce(func, waitTime) {
  let timer, preTime, args, result;
  let now = () => new Date().getTime()
  
  let later = () => {
    let passedTime = now() - preTime;
    if (waitTime > passedTime) {
      timer = setTimeout(later, waitTime - passedTime);
    } else {
      clearTimeout(timer);
      timer = null;
      // func 可能会有返回值
      result = func.apply(this, args);
      // 这个检查是必需的,因为 func 可能递归调用 debounce
      if (!timer) {
        args = null;
      }
    }
  };

  let debounced = (..._args) => {
      args = _args
      preTime = now();
      if (!timer) {
          timer = setTimeout(later, waitTime);
      }
      return result;
  };

  return debounced;
}

以上两种方式实现的效果如下: debounce-2.gif

立即执行

以上两个函数触发的时机都是在停止触发后 n 秒才执行 func ,如果想要反过来,先立刻执行函数,然后等到停止触发 n 秒后,才可以重新触发执行那要怎么做?

接下来我们按照上面的需求修改下 debounce 函数,添加一个 immediate 参数控制是否先立即执行,代码如下:

export default function debounce(func, waitTime, immediate = false) {
  let timer, preTime, args, result;
  let now = () => new Date().getTime()
  
  let later = () => {
    let passedTime = now() - preTime;
    if (waitTime > passedTime) {
      timer = setTimeout(later, waitTime - passedTime);
    } else {
      clearTimeout(timer);
      timer = null;
      if (!immediate) {
        result = func.apply(this, args);
      }
      // 这个检查是必需的,因为 func 可能递归调用 debounce
      if (!timer) {
        args = null;
      }
    }
  };

  let debounced = (..._args) => {
      args = _args
      preTime = now();
      if (!timer) {
          timer = setTimeout(later, waitTime);
          if (immediate) {
            result = func.apply(this, args);
          }
      }
      return result;
  };

  return debounced;
}

这里设置了 immediate: true 最终效果如下: debounce-3.gif

取消防抖

现在又有一个问题,比如 debounce 的间隔时间是 10 秒,immediatetrue,这样的话,只有等 10 秒后才能重新触发下一个事件。如果能有一个按钮,点击后,取消防抖,这样再去触发 debounce,是不是就又可以立刻执行事件了?按照上面的思路,我们给 debounce 添加一个取消逻辑吧。

export default function debounce(func, waitTime, immediate = false) {
  
  ...
  
  debounced.cancel = () => {
    clearTimeout(timer);
    timer = args = null;
  };

  return debounced;
}

最终效果如下: debounce-4.gif

节流

高频事件触发,但在 n 秒内只会执行一次,所以节流会稀释函数的执行频率

思路:节流的实现,有两种主流的实现方式

  1. 标记触发时间,每次触发事件时比对当前时间
  2. 设置定时器:每次触发事件时都判断当前是否有等待的定时器

另外以上两种方式效果上会有所不同,效果分别是首次执行以及结束后执行,实现的方式也有所不同。我们通过参数 leading 表示首次是否执行,trailing 表示结束后是否再执行一次。

标记触发时间

当触发事件的时候,计算距离上次触发时间(当前时间戳 - 上次标记的时间戳: 初始值为 0,如果大于设置的时间间隔,就执行函数,并更新触发时间,否则,不执行。

export default function throttle(func, waitTime) {
    let preTime = 0
    const now = () => new Date().getTime()
    
    return (...args) => {
        if (now() - preTime > waitTime) {
            func.apply(this, args);
            preTime = now();
        }
    }
}

最终效果如下: throttle-1.gif

设置定时器

当触发事件的时候,设置一个定时器,再触发事件的时候,判断定时器是否存在,存在就不执行,直到触发定时器,执行函数 func,清空定时器,这样下次触发事件就会开启一个新的定时器。

export default function throttle(func, waitTime) {
    let timer
    
    return (...args) => {
        if (!timer) {
            timer = setTimeout(() => {
                timer = null;
                func.apply(this, args)
            }, waitTime)
        }
    }
}

最终效果如下: throttle-2.gif 从上面的最终效果可以看出两种方式的区别:

  1. 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
  2. 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后还会再执行一次事件

合二为一

如果结合以上两种方式的逻辑,我们就可以实现一个能立刻执行,停止触发后还能在最后再执行一次,且看代码如下:

export default function throttle(func, waitTime) {
    let timer, args, result;
    let preTime = 0;
    const now = () => new Date().getTime()

    let later = () => {
        preTime = now()
        timer = null;
        result = func.apply(this, args);
        if (!timer) {
            args = null;
        }
    };

    let throttled = (..._args) => {
        let passedTime = now() - preTime;
        args = _args;
        // 修改过系统本地时间,passedTime 会小于 0
        if (passedTime > waitTime || passedTime < 0) {
            clearTimeout(timer);
            timer = null;
            preTime = now();
            result = func.apply(this, args);
            if (!timer) {
                args = null;
            }
        } else if (!timer) {
            timer = setTimeout(later, waitTime - passedTime);
        }
        return result;
    };

    return throttled;
}

最终效果如下: throttle-3.gif

优化

我们还可以给 throttle 添加一个 options 来控制是标记触发时间还是设置定时器的方式,我们约定: leading:false 表示标记触发时间方式trailing: false 表示禁用设置定时器方式,且两者不应该同时禁用。代码逻辑如下:

export default function throttle(func, waitTime, options = { }) {
    let timer, args, result;
    let preTime = 0;
    const now = () => new Date().getTime()
  
    let later = () => {
      preTime = options.leading === false ? 0 : now();
      timer = null;
      result = func.apply(this, args);
      if (!timer) {
        args = null;
      }
    };
  
    let throttled = (..._args) => {
      if (!preTime && options.leading === false) {
          preTime = now()
      };
      let passedTime = now() - preTime;
      args = _args;
      if (passedTime > waitTime) {
        clearTimeout(timer);
        timer = null;
        preTime = now();
        result = func.apply(this, args);
        if (!timer) {
          args = null;
        }
      } else if (!timer && options.trailing !== false) {
        timer = setTimeout(later, waitTime - passedTime);
      }
      return result;
    };
  
    return throttled;
  }

这里设置了 leading:false 最终效果如下: throttle-4.gif

取消节流

同样的,在 throttle 我们也加个 cancel 方法吧

export default function throttle(func, waitTime, options = { }) {
  
  ...
  
  throttled.cancel = () => {
    clearTimeout(timer);
    preTime = 0;
    timer = null;
  };

  return throttled;
}