avaScript 中的函数大多数情况下都是由用户主动调用触发的,比如说点击、拖拽、改变浏览器尺寸、提交表单等。除非是函数本身的实现不合理,否则一般不会遇到跟性能相关的问题。

但是在一些少数情况下,函数的触发不是由用户直接控制的。在这些场景下,函数有可能被非常频繁地调用,而造成性能问题:

  • mousemove 事件。如果要实现一个拖拽功能,需要一路监听 mousemove 事件,在回调中获取元素当前位置来进行样式改变。如果不加以控制,每移动一定像素而触发的回调数量非常惊人,回调中又伴随着 DOM 操作,继而引发浏览器的重排与重绘,性能差的浏览器可能就会直接假死。
  • window.onresize 事件。为 window 对象绑定了 resize 事件,当浏览器窗口大小被拖动而改变尺寸的时候,这个事件就会一直被触发。如果在 window.onresize 事件函数里又有一些跟 DOM 节点相关的操作,浏览器可能就会吃不消而造成卡顿现象。
  • 射击游戏的 mousedown/keydown 事件(单位时间只能发射一颗子弹)不能因为连续点击或快速按键连续发射多颗子弹。
  • 搜索联想(keyup事件)
  • 监听滚动(scroll事件)

解决这些情况的方案就可以使用函数防抖(debounce),其核心就是限制某一个方法的频繁触发。防止函数在极短的时间内反复调用,造成资源的浪费。

废话结束,进入正文

场景设想

场景1

考虑一下电梯关门的场景,现代的大部分电梯都可以通过红外,感知到是否有人进入。为了避免夹到人,同时为了等待后面的人,电梯关门的时间往往有一种规则:始终保证电梯门在最后一个人进入后 3 秒以后关闭。如果有人进入后,还没有等到 3 秒,又有人进来了,电梯门会以最后一次进入的时间为计时起点,重新等待3秒。

场景2

再考虑一个页面上的场景。页面上的某些事件触发频率非常高,比如滚动条滚动、窗口尺寸变化、鼠标移动等,如果我们需要注册这类事件,不得不考虑效率问题,又特别是事件处理中涉及到了大量的操作,比如:

  1. window.onresize = function(){
  2. // 大量的 DOM 操作
  3. }

当窗口尺寸发生哪怕一丢丢变化时,都会造成多次对处理函数的调用,这对网页性能的影响是极其巨大的。于是,我们可以考虑,每次窗口尺寸变化、滚动条滚动、鼠标移动时,不要立即执行相关操作,而是等一段时间,以窗口尺寸停止变化、滚动条不再滚动、鼠标不再移动为计时起点,一段时间后再去执行操作,就像电梯关门那样。

场景3

再考虑一个搜索的场景,当用户在一个文本框中输入文字(keydown 事件)时,需要将文字发送到服务器,并从服务器得到搜索结果。这样的话,用户直接输入搜索文字就可以了,不用再去点搜索按钮,可以提升用户体验:

函数防抖 - 图1
可是如何来实现上面的场景呢?如果文本框的文字每次被改变(keydown 或者 keyup),都要把数据发送到服务器,得到搜索结果,想想看,当搜索 “google” 这样的单词,至少需要按 6 次按键,就这一个词就需要向服务器请求 6 次,并让服务器去搜索 6 次,但其实只需要最后一次的结果就可以了。如果考虑用户按错的情况,发送请求的次数更加恐怖,浪费了很多资源。
所以真正的搜索行为并不是每次输入都会触发的,只有当用户停止按键一段时间后才会触发。

推测代码

为了满足这种类型场景,可以设计需要满足以下功能:

  1. 调用该函数后,不立即做事,而是一段时间后去做事
  2. 如果在等待时间内调用了该函数,则重新开始计时

这样的功能,就叫做函数防抖,其实就是防止函数短时间内被调用多次。要完成该函数,需要给予两个条件:

  1. 告诉我一段时间后要做什么事(这里应该是一个 callback,即函数作为参数)
  2. 告诉我要等待多长时间

那思考一下,什么东西可以 “在某个时间后触发一次” ?延时型计时器 setTimeout!没错,所以……代码可能?可以写成:

  1. // 省略了标签 <input type="text" id='txt'> 别问为啥,就是想。
  2. txt.onkeyup = function () {
  3. setTimeout(() => {
  4. console.log(txt.value);
  5. }, 3000); // 3s 后执行
  6. }

函数防抖 - 图2
通过结果会发现,当输入一个字符时,确实可以实现 3s 后 log 出值,但当连续输入,就会“囤积”多个计时器在最后统一执行,就像第二次我输入了 5 个字符,3s 后连续 log 了5 次。
那为了停止计时器,代码就又可以改成:

  1. let timer = null; // 设置全局变量,记录 setTimeout 得到的id
  2. txt.onkeyup = function () {
  3. if (timer) { // 如果 timer 有值(是一个 setTimeout 的函数)
  4. clearTimeout(timer);
  5. }
  6. timer = setTimeout(() => {
  7. console.log(txt.value);
  8. }, 3000);
  9. }

函数防抖 - 图3
看起来可以满足需求了,哪怕是连续输入,也不会一次性囤几个计时器在最后时刻统一触发。
但当前对于全局 timer 变量,它意味着页面中所有需要防抖的地方,都共用了这个 timer,假设需要同时对 resize 事件和文本框 keyup 事件进行防抖,看来就不行了。
计时器中做的事儿是可变的(计时器第一个参数 function 的函数体内容可变),延时的时间也是可变的,所以可以封装成函数进行传参:

  1. /*
  2. 代码分解:
  3. 1. timer 的声明、timer 的判断、timer 的赋值都是固定的,所以可以丢进 function debounce(){}里
  4. 2. txt.onkeyup 的事件处理函数,做的就是调用 debounce() 这个事
  5. 3. 既然 debounce() 是个函数,必定可以传参,所以将会发生变化的在计时器里做的事作为实参传给
  6. debounce(),再将间隔时间作为实参传递
  7. */
  8. txt.onkeyup = function () {
  9. debounce(function () {
  10. console.log(txt.value);
  11. }, 3000);
  12. }
  13. function debounce(fn, ms) { // fn 是函数调用时的第一个函数参数
  14. let timer = null;
  15. if (timer) {
  16. clearTimeout(timer);
  17. }
  18. timer = setTimeout(() => {
  19. fn();
  20. }, ms);
  21. }

但这样的运行结果同上面一样,会“囤积”多个计时器一起触发,每次调用防抖函数 debounce() 都会得到一个崭新的计时器(let timer = null)
所以代码可能可以改成:

  1. function debounce(fn, ms) {
  2. let timer = null;
  3. // 把防抖的工作 return 出去,这样一来 timer 每次都是新的了
  4. return function () {
  5. if (timer) {
  6. clearTimeout(timer);
  7. }
  8. timer = setTimeout(() => {
  9. fn();
  10. }, ms);
  11. }
  12. }

debounce 函数仅仅负责生成一个计时器的 timerId,保证每次调用 debounce 都会有一个新的 timerId 产生即可,将防抖的工作,交给了返回的函数去完成。今后如果需要对某一个操作进行防抖,只需要调用 debounce() 来得到一个操作函数,再去调用操作函数即可。

  1. // 防抖函数
  2. function debounce(fn, ms) {
  3. let timer = null;
  4. return function () {
  5. if (timer) {
  6. clearTimeout(timer);
  7. }
  8. timer = setTimeout(() => {
  9. fn();
  10. }, ms);
  11. }
  12. }
  13. // 真正操作防抖 textChange 得到的是一个函数
  14. let textChange = debounce(function () { // function 参数作为 debounce 的第一个实参
  15. console.log(txt.value);
  16. }, 3000);
  17. txt.onkeyup = function () {
  18. textChange();
  19. }

假设当前同时需要对输入框的 keyup 和 窗口的 resize 防抖:

  1. let textChange = debounce(() => {
  2. console.log(txt.value);
  3. }, 3000);
  4. txt.onkeyup = () => {
  5. txtChange();
  6. }
  7. let winChange = debounce(() => {
  8. console.log("窗口尺寸改变了");
  9. }, 500);
  10. window.onresize = () => {
  11. winChange();
  12. }

函数防抖 - 图4

好了,结束了。