前端性能优化 - 每一个前端开发者需要知道的防抖与节流知识

2,137 阅读6分钟

本文收录于 Github Blog

防抖和节流都是应用在高频事件触发场景中,例如 **scroll(滚动加载、回到顶部)****input(联想输入)** 事件等。防抖和节流核心实现思想是在事件和函数之间增加了一个控制层,达到延迟执行的功能,目的是防止某一时间内频繁执行一些操作,造成资源浪费

事件与函数之间的控制层通常有两种实现方式:一是使用定时器,每次事件触发时判断是否已经存在定时器,是本文我们实现的方式。另外一种是记录上一次事件触发的时间戳,每次事件触发时判断当前时间戳距离上次执行的时间戳之间的一个差值(deplay - (now - previous)),是否达到了设置的延迟时间。

可视化效果对比

下图是通过一个可视化工具 debounce_throttle 截取的一个效果图,展示了移动鼠标事件在常规操作防抖处理(debounce)、**节流处理(throttle)**三种情况下的一个对比。

image.png

防抖(debounce)

防抖是在事件触的指定时间后执行回掉函数,如果指定时间内再次触发事件,按照最后一次重新计时

生活场景示例:公交车到站点后,师傅不会上一个人就立马关闭车门起步,会等待最后一个人上去了或车上人已经满了,才会关闭车门起步。

联想输入 - 常规示例

例如搜索框联想提示,当我们输入数据后,可能会请求接口获取数据,如果没有做任何处理,当在输入开始时就会不断的触发接口请求,这中间必然会造成资源的浪费,如果这样频繁操作 DOM 也是不可取的。

// Bad code
<html>
  <body>
    <div> search: <input id="search" type="text"> </div>
    <script>
      const searchInput = document.getElementById("search");
      searchInput.addEventListener('input', ajax);
      function ajax(e) { // 模仿数据查询
        console.log(`Search data: ${e.target.value}`);
      }
    </script>
  </body>
</html>

上面这段代码我们没有做过任何优化,使用 ajax() 方法模拟数据请求,让我们看下执行效果。

常规联想输入操作

如果是调用的真实接口,从输入的那一刻起就会不停掉用服务端接口,浪费不必要的性能,还很容易触发接口的限流措施,例如 Github 提供的 API 会有每小时最大请求数限制。

联想输入 - 防抖处理示例

让我们实现一个防抖函数(**debounce****)**优化下上面的代码。**原理是通过标记,判断指定的时间内是否存在多次调用,当存在多次调用时清除掉上一次的定时器,重新开始计时,在指定的时间内如果没有再次调用,就执行传入的回调函数 ****fn**

function debounce(fn, ms) {
  let timerId;

  return (...args) => {
    if (timerId) {
      clearTimeout(timerId);
    }

    timerId = setTimeout(() => {
      fn(...args);
    }, ms);
  }
}

这对于搜索场景是比较合适的,我们希望以最后一次输入结果为准,修改最开始的联想输入示例。

const handleSearchRequest = debounce(ajax, 500)
searchInput.addEventListener('input', handleSearchRequest);

这次就好多了,当连续输入停顿时以最后一次的输入接口为准请求接口,避免了不停的刷新接口。

联想输入-防抖.gif

适当的时候记得要清除事件,例如 React 中,我们在组件挂载时监听 input,同样的组件卸载时也要清除对应的事件监听器函数。

componentDidMount() {
  this.handleSearchRequest = debounce(ajax, 500)
	searchInput.addEventListener('input', this.handleSearchRequest);
}

componentWillUnmount() {
  searchInput.removeEventListener('input', this.handleSearchRequest);
}

节流(throttle)

节流是在事件触发后,在指定的间隔时间内执行回调函数

生活场景示例:当我们乘坐地铁时,列车总是按照指定的间隔时间每 5 分钟(也许是其它时间)这样运行,当时间到达之后,列车就要开走了。

滚动到顶部 - 常规示例

例如,页面有很多个列表项,当我们向下滚动之后希望出现一个 Top 按钮 点击之后能够回到顶部,这时我们需要获取滚动位置与顶部的距离判断是否展示 Top 按钮

<body>
  <div id="container"></div>
  <script>
    const container = document.getElementById('container');
    window.addEventListener('scroll', handleScrollTop);
    function handleScrollTop() {
      console.log('scrollTop: ', document.body.scrollTop);
      if (document.body.scrollTop > 400) {
        // 处理展示按钮操作
      } else {
        // 处理不展示按钮操作
      }
    }
  </script>
</body>

可以看到,如果不加任何处理,滚动一下可能就会触发上百次,每次都去做处理,显然是白白浪费性能的。

滚动未处理节流.gif

滚动到顶部 - 节流处理示例

实现一个简单的节流(throttle)函数,与防抖很相似,区别的地方是,这里通过标志位判断是否已经被触发,当已经触发后,再进来的请求直接结束掉,直到上一次指定的间隔时间到达且回调函数执行之后,再接受下一个处理。

function throttle(fn, ms) {
  let flag = false;
  return (...args) => {
    if (flag) return;
    flag = true;
    setTimeout(() => {
      fn(...args)
      flag = false;
    }, ms);
  }
}

改造下上面的示例,再来看看执行结果。

const handleScrollTop = throttle(() => {
  console.log('scrollTop: ', document.body.scrollTop);
  // todo:
}, 500);
window.addEventListener('scroll', handleScrollTop);

与上面 “常规滚动到顶部示例” 做对比,现在效果已经好多了。

滚动到顶部-节流处理.gif

记得清除事件

以 React 为例,组件挂载时我们监听 window 的 scroll 事件,在组件卸载时记得要移除对应的事件监听器函数。如果组件卸载时忘记移除,原先 A 页面引入了 ScrollTop 组件,单页面应用跳转到 B 页面后,虽然 B 页面没有引入 ScrollTop 组件,但是也会受到影响,因为该事件已经在 window 全局对象注册了,另外这样也存在内存泄漏。

class ScrollTop extends PureComponent {
  componentDidMount() {
    this.handleScrollTop = throttle(this.props.updateScrollTopValue, 500);
    window.addEventListener('scroll', this.handleScrollTop);
  }

  componentWillUnmount() {
    window.removeEventListener('scroll', this.handleScrollTop);
  }
  
  // ...
}

requestAnimationFrame

requestAnimationFrame 是浏览器提供的一个 API,它的应用场景是告诉浏览器,我需要运行一个动画。该方法会要求浏览器在下次重绘之前调用指定的回调函数更新动画。这个 API 在 JavaScript 异步编程指南 - 探索浏览器中的事件循环机制 中有讲过。

它会受到浏览器的刷新频率影响,如果是 60fps 那就是每间隔 16.67ms 执行一次,如果在 16.67ms 内有多次 DOM 操作,也是不会渲染多次的。

当浏览器的刷新频率为 60fps 时等价于 throttle(fn, 16.67)。在使用时需要先处理下,不能让它立即执行,由事件触发。

const handleScrollTop = () => requestAnimationFrame(() => {
  console.log('scrollTop: ', document.body.scrollTop);
  // todo:
});
window.addEventListener('scroll', handleScrollTop);

requestAnimationFrame 这个是浏览器的 API,在 Node.js 中是不支持的。

社区工具集支持

社区中一些 JavaScript 的工具集框架,也都提供了防抖与节流的支持,例如 underscorejslodash

刚开始有提到,另外一种实现方式是记录上一次事件触发的时间戳,每次事件触发时判断当前时间戳距离上次执行的时间戳之间的一个差值,来判断是否达到了设置的延迟时间,以 underscorejs throttle 实现为例,只保留部分代码示例,一个关键代码片段是 remaining = wait - (_now - previous)

// https://github.com/jashkenas/underscore/blob/master/modules/throttle.js#L23
export default function throttle(func, wait, options) {
  var timeout, context, args, result;
  var previous = 0;
  
  var throttled = function() {
    var _now = now();
    if (!previous && options.leading === false) previous = _now;
    var remaining = wait - (_now - previous);
    context = this;
    args = arguments;
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = _now;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    }
  };

  return throttled;
}

总结

防抖是在事件触的指定时间后执行回掉函数,如果指定时间内再次触发事件,按照最后一次重新计时。节流是在事件触发后的间隔时间内执行回调函数。这两个概念在前端开发中都是会遇到的,选择合理的方案解决实际问题。

防抖与节流还不是太理解的,对着文中的示例自己实践下,有什么疑问在留言区提问。

Reference