Skip to content

防抖 debounce与节流 throttle(正则表达式 3/3) ​

本页对应 课堂视频,共 5 段视频,约 1 小时 14 分。

学习目标 ​

  • 能用自己的话说明防抖与节流在触发时机、执行次数上的区别,并能针对输入搜索、点赞、滚动监听等场景选择合适方案。
  • 能使用定时器编写基础防抖函数,实现“频繁触发时只在停止触发后执行最后一次”的效果。
  • 能使用时间戳编写基础节流函数,实现“单位时间内最多执行一次”的效果。
  • 能封装防抖高阶函数,使普通事件回调获得防抖能力,并正确处理 this 与事件对象。
  • 能封装节流高阶函数,使普通事件回调获得节流能力,并正确处理 this 与事件对象。

知识点 ​

防抖是什么:先停下来,再执行 ​

防抖(debounce)指的是:持续触发同一个事件时,如果 n 秒内事件没有再次触发,才执行事件回调;如果 n 秒内又触发了,就重新计时。换句话说,用户频繁操作时先不执行,等用户停下来后,只执行最后一次。

为什么需要它?因为有些事件触发得太频繁,比如搜索框的 input 事件、窗口 resize、点赞或提交表单。用户每输入一个字符就发一次请求,开销很大;用户不停点赞,也没必要每次都提交。防抖的目标是:短时间内频繁触发时,回调只执行一次,通常保留最后一次。

怎么想?把“执行”往后推,用定时器计时;每次新触发都把上一次定时器清掉,重新开一个。这样只有最后一次定时器能走到终点。

基础实现:定时器标记要放在外层 ​

先看最直接的做法:给按钮绑定点击事件,点击后不立即执行,而是延迟执行。

js
const btn = document.querySelector('#btn');
let timeId = null;

btn.addEventListener('click', function () {
  clearTimeout(timeId);
  timeId = setTimeout(function () {
    console.log('触发一次', Date.now());
  }, 1000);
});

推导过程是这样的:一开始只写 setTimeout,发现不停点击时虽然不会立即执行,但每次点击都开了一个定时器,最后会排队执行很多次。于是要在每次触发时先 clearTimeout(timeId),把上一次还没执行的定时器清掉,再开启新的定时器。这样用户不停点,就一直往后推;松手后等 1 秒,才执行最后一次。

易错点:timeId 必须定义在事件回调外层。如果写在回调里面,每次点击都是新的执行上下文,变量互不相干,清不掉上一次定时器。另外,第一次 clearTimeout(null) 不会报错,找不到定时器就不清,这是可以接受的。

封装成防抖函数:this 和事件对象不能丢 ​

实际开发中不会每个事件都手写一遍,而是封装一个高阶函数:参数是“原本的事件回调函数”和“延迟时间”,返回值是一个带防抖功能的新函数。

js
function debounce(func, delay) {
  let timeId = null;
  return function () {
    const that = this;
    const args = arguments;
    clearTimeout(timeId);
    timeId = setTimeout(function () {
      func.apply(that, args);
    }, delay);
  };
}

const input1 = document.querySelector('#input01');
const input2 = document.querySelector('#input02');

function changeInput() {
  console.log(this.value, arguments[0]);
}

input1.addEventListener('input', changeInput);
input2.addEventListener('input', debounce(changeInput, 400));

为什么这么写?因为返回的新函数才是真正交给事件的回调。它内部先清掉上一次定时器,再开启新的定时器;时间到了才调用原函数。

调试时会发现两个问题:第一,原函数里的 this 变成了 window,拿不到输入框元素;第二,事件对象丢了。解决方法是:在返回函数里保存 const that = this,保存 const args = arguments,最后用 func.apply(that, args) 调用原函数。这样原函数里的 this 仍然是绑定事件的元素,arguments 里的事件对象也能原样传进去。

易错点:不要在高阶函数外层直接写 this 或 arguments,那已经不是事件回调里的了;apply 的第二个参数要是数组或类数组,arguments 正好可以。还有,延迟时间正常是几百毫秒,演示时用 2 秒是为了看清效果,真实项目不能设太长。

和节流对比:防抖只留最后一次 ​

转写稿里反复对比了节流:节流是单位时间内最多执行一次,用户不停点击时,每隔一段时间仍可能执行;防抖是用户不停触发时一直往后推,停下来才执行最后一次。比如点赞适合防抖,只保留一次提交;滚动加载更适合节流,避免滚动过程中请求过密。

易错点:不要把所有频繁触发都无脑加防抖。搜索联想、点赞、提交表单适合防抖;滚动监听、拖拽这类需要“过程中也定期执行”的场景,要优先考虑节流。最终用哪个,要看产品行为和用户体验。

节流是什么:为什么需要 ​

为什么需要:有些事件触发频率极高,比如滚动、拖拽、鼠标移动。用户一滚动,scroll 事件可能在一秒内触发很多次;如果每次触发都去请求数据或做复杂计算,服务器压力和浏览器计算压力都会很大。
怎么想:节流的核心不是“等用户停下来”,而是“在一个单位时间内,不论触发一次还是多次,只执行一次”。也就是说,它像给事件加了一个时间闸门:时间没到,触发多少次都不执行;时间到了,才放行一次。
怎么做:先记住一句话——节流是固定时间间隔执行一次。例如规定 1000ms 内只执行一次,那么用户持续滚动时,可能每隔 1000ms 执行一次,而不是每次滚动都执行。
易错点:不要把节流和防抖混为一谈。防抖是“频繁触发时不执行,等停下来后执行最后一次”;节流是“频繁触发过程中,按固定间隔执行”。转写稿里演示过:防抖设置 2 秒时,不停点击不会执行,松手后等 2 秒才执行一次;节流则是不停点击时,每隔 2 秒执行一次。

怎么想:节流和防抖的对比 ​

为什么需要对比:实际开发中经常要判断用防抖还是节流。点赞更适合防抖,因为一个人只能点一个赞,频繁点击只保留一次提交;滚动加载更多更适合节流,因为用户持续滚动时,需要隔一段时间判断一次“是否到底”。
怎么想:防抖关注“最后一次”,节流关注“单位时间内的第一次/每一次间隔”。转写稿中说过:防抖在触发过程中不执行,停下来才执行;节流不管用户是否还在触发,只要超过时间间隔就执行一次。
怎么做:判断标准可以简化为:如果业务只关心最终结果,用防抖;如果业务需要在持续操作中按频率采样,用节流。
易错点:时间间隔不能设置太长。转写稿中为了演示明显,设置了 2 秒;真实项目通常是几百毫秒,比如 100ms、200ms、300ms、400ms、500ms,具体看场景,不能让正常用户明显感觉到“卡顿”或“延迟”。

怎么做:先用时间戳写一个最小节流 ​

为什么需要:先不封装高阶函数,直接写最朴素的节流,能帮助理解“当前时间减上一次执行时间”的判断逻辑。
怎么想:需要两个时间:上一次执行时间 prev 和当前触发时间 now。用 now - prev 判断是否超过规定间隔;超过才执行,执行后把 prev 更新为 now。
怎么做:以滚动加载更多为例:

js
let prev = Date.now();

window.onscroll = function () {
  const now = Date.now();

  if (now - prev > 1000) {
    console.log('加载新数据', now);
    prev = now;
  }
};

这样用户不停滚动时,不会每次滚动都输出,而是大约每隔 1000ms 才执行一次。
易错点:执行完必须更新 prev = now,否则下一次判断仍然拿第一次的时间比较,会导致逻辑不符合预期;prev 要定义在事件回调外层,不能每次触发都重新初始化。

调试过程:为什么滚动时只触发几次 ​

为什么需要调试:只看代码容易觉得“判断写了就行”,但实际要看触发频率是否真的降下来。
怎么想:转写稿中先监听 scroll,不做处理时不停滚动会不停输出;加上 now - prev > 1000 后,从上滚到下用了约 3 秒,就只触发几次。
怎么做:在回调里输出时间戳,观察两次输出之间的间隔。如果间隔明显接近设定值,说明节流生效;如果仍然很频繁,检查是否忘记更新 prev,或判断条件写反。
易错点:第一次触发时 prev 是页面打开时间,可能不会立刻执行,这是正常的;如果业务要求首次立即执行,需要额外设计,不能想当然。

怎么做:封装成节流高阶函数 ​

为什么需要:直接写节流会把业务代码和节流逻辑混在一起,复用性差。高阶函数可以把“原回调函数”传进去,返回一个“带节流功能的新函数”。
怎么想:节流函数接收两个参数:fn(原事件回调)和 delay(时间间隔)。内部保存 prev,返回新函数;新函数里判断时间间隔,满足条件才调用 fn。
怎么做:

js
function throttle(fn, delay) {
  let prev = Date.now();

  return function () {
    const now = Date.now();

    if (now - prev > delay) {
      fn.apply(this, arguments);
      prev = now;
    }
  };
}

使用:

js
box.onmousemove = throttle(function (e) {
  console.log(e.offsetX, e.offsetY);
}, 600);

易错点:转写稿中特别调试过 this 和事件对象。若直接写 fn(),原回调里的 this 会丢失,可能变成 window,事件对象也拿不到。应使用 fn.apply(this, arguments),既保留监听元素的 this,又把事件对象传进去。若在定时器或嵌套函数中,还要先用 const that = this、const args = arguments 保存。

应用场景与选择 ​

为什么需要:节流不是万能药,要知道它适合什么。
怎么想:拖拽时固定时间执行一次,防止高频触发位置计算;滚动监听判断是否滑到底部、自动加载更多时,用节流节省请求资源。转写稿还提到,滚动加载更多如果不节流,一秒可能发起很多请求,服务器压力很大。
怎么做:拖拽节流的时间间隔要短,短到用户感觉不到;滚动加载可以用几百毫秒。点赞这种“只保留一次提交”的场景更适合防抖。
易错点:不要为了用而用。转写稿的建议是:当用户频繁触发事件引起问题时,再考虑防抖或节流;有争议的场景可以和产品经理商量,不要机械套用。

先分清:防抖和节流解决的不是同一个问题 ​

为什么需要:用户频繁触发事件时,如果每次都执行回调,开销会很大。转写稿里反复提到输入框 input、点赞、resize、滚动加载、拖拽和鼠标移动:输入一次就请求一次服务器,滚动一下就加载一次数据,服务器和计算压力都会变大。

怎么想:防抖是“等你停下来,只执行最后一次”;节流是“单位时间内最多执行一次”。转写稿的对比很直观:不停点赞时,防抖要等松手才执行一次;节流则每隔设定时间执行一次。点赞更适合防抖,滚动加载、拖拽、鼠标移动更适合节流。

怎么做:先判断问题是“频繁触发导致最后一次才有意义”,还是“频繁触发但需要按固定频率采样”。前者防抖,后者节流。时间间隔通常几百毫秒,转写稿说常见是 100~500ms,不要设成 2 秒。

易错点:不要把防抖和节流混为一谈;也不要死记应用场景,出现“用户频繁触发引起问题”时再选型,有争议就和产品经理商量。

不封装时:先理解防抖的定时器推导 ​

为什么需要:直接写 setTimeout 只能延迟执行,不能取消上一次。用户不停触发时,会开启很多定时器,最后仍然多次执行。

怎么想:每次触发先清掉上一次定时器,再开新的定时器。只要在间隔内再次触发,就一直往后推;最后一次没人清除,才会执行。

怎么做:把定时器标记放在事件回调外层。转写稿调试时强调:timeId 不能写在回调里面,否则每次回调都是新的执行上下文。第一次 clearTimeout(空) 不会报错;超过间隔后再清除已执行的定时器也没有意义。

易错点:timeId 作用域写错,防抖失效;把“延迟执行”误当成“防抖”;间隔设置过长,用户能明显感知。

不封装时:用时间差做节流 ​

为什么需要:滚动监听不能每滚一次就请求。转写稿中用 window.onscroll 演示,滚动时触发频率极高,需要限制。

怎么想:记录上一次执行时间 prev,每次触发取当前时间 now,判断 now - prev >= delay,满足才执行,执行后更新 prev。

怎么做:prev 要放在返回函数外层,保证多次触发共享;now 在每次触发时获取;执行操作后写 prev = now。转写稿调试中滚 3 秒、间隔 1000ms,大约触发 3 次。

易错点:忘记更新 prev,会一直执行;prev 写在回调内部,每次都重置;把节流写成“只执行一次”,那是防抖的效果。

封装成高阶函数:把回调函数“加工”成新函数 ​

为什么需要:上面的防抖、节流都把业务代码和定时器/时间差混在一起,多个事件复用不方便,还容易产生全局变量。高阶函数接收原回调函数和间隔,返回一个带防抖或节流功能的新函数。

怎么想:事件绑定时不要直接绑原回调,而是绑定高阶函数返回的新函数;新函数内部保存 this 和 arguments,再通过 apply 调用原函数。转写稿中调试发现:防抖后输入框里拿不到 value,this 变成了 window;事件对象也拿不到。解决方法是 var that = this; var args = arguments;,定时器里用 method.apply(that, args)。

怎么做:保留笔记中的两段封装代码:

js
function debounce(method, delay) {
    var timeId;
    return function() {
        var that = this;
        var args = arguments;
        clearTimeout(timeId);
        timeId = setTimeout(function () {
            method.apply(that, args);
        }, delay)
    }
}
js
 function throttle(method, delay) {
     var prev = Date.now();
     return function() {
         var that = this;   
         var args = arguments;
         var now = Date.now();
         if (now - prev >= delay) {
             method.apply(that, args);
             prev = now;
         }
     }
 };

防抖的 timeId 在闭包里,只对返回的新函数可见;节流的 prev 也在闭包里。这样既复用了逻辑,又避免全局变量污染。

易错点:事件绑定写成 btn.onclick = debounce(fn, 400)() 是错的,返回的是函数,不要加小括号;setTimeout 里不能直接用 this 和 arguments,必须提前保存;节流执行后要更新 prev;apply 第二个参数要是数组或类数组,arguments 正好可用。转写稿还特别提醒:防抖里 setTimeout 又包了一层函数,所以 this、arguments 的保存更关键;节流没有定时器嵌套,但同样要保存 that 和 args。

调试与检查清单 ​

先用不封装的版本确认现象:防抖是“不停触发不执行,停下后执行一次”;节流是“持续触发期间每隔 delay 执行一次”。再换高阶函数版本,检查三件事:this 是否还是绑定元素,事件对象是否还能拿到,业务回调是否只执行预期次数。最后把间隔调到正常范围,确认用户基本感知不到限制。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例 1:防抖原理(不加高阶函数) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>防抖原理</title>
</head>
<body>
  <button id="btn">点赞</button>

  <script>
    var btn = document.querySelector('#btn');

    // 定时器标记:必须定义在事件回调函数的外面
    var timeId = null;

    btn.onclick = function () {
      // 先清掉上一次的定时器(第一次是 null,clearTimeout 不会报错)
      clearTimeout(timeId);

      // 再开启一个新的定时器
      timeId = setTimeout(function () {
        console.log('触发了', Date.now());
      }, 1000);
    };
  </script>
</body>
</html>

要点解释 / 易错点

  • timeId 一定要写在 onclick 外面。如果写在回调里,每次点击都是新的执行上下文,变量互不相干,根本清不掉上一次的定时器,防抖就失效了。
  • clearTimeout(null) 不会报错,找不到定时器就什么也不干,所以第一次点击可以直接清。
  • 逻辑本质是“拆炸弹”:每次点击先把上一颗炸弹拆掉再重新埋一颗,只有停下来满 1 秒没再点,炸弹才真正爆炸(执行回调)。超过 1 秒再点虽然也能清除,但上一次早已执行,没有意义。
  • 时间间隔正常用几百毫秒(100~500ms),这里写 1000ms 只是为了演示效果更明显。
  • 缺点:timeId 是全局变量,对外暴露,后续用高阶函数可以把它包进闭包里。

示例 2:节流原理(不加高阶函数) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>节流原理</title>
  <style>
    p { width: 400px; padding: 20px; background: #ccc; }
  </style>
</head>
<body>
  <p>1</p><p>2</p><p>3</p><p>4</p><p>5</p><p>6</p>

  <script>
    // 记录上一次触发事件的时间
    var prev = Date.now();

    window.onscroll = function () {
      // 此刻的时间戳
      var now = Date.now();

      // 用当前时间减去上一次时间,看是否超过规定间隔
      if (now - prev >= 1000) {
        console.log('加载新数据', Date.now());
        // 执行完必须更新上一次执行时间
        prev = now;
      }
    };
  </script>
</body>
</html>

要点解释 / 易错点

  • prev 必须初始化(Date.now())。如果不初始化,now - undefined 是 NaN,NaN >= 1000 永远为 false,一次都不会执行。
  • prev = now 一定要在执行后更新,否则过了第一次之后 now - prev 永远大于 1000,节流就退化成“每次都触发”。
  • 节流是“看时间”:距上次执行满 1 秒就执行一次;防抖是“往后推”:每次触发都重新计时,只有停下来才执行。用户不停滚动时,节流会执行多次,防抖只会执行最后一次。
  • 正常间隔同样是几百毫秒,拖拽场景要更短,短到用户感知不到,目的是节省计算资源。

示例 3:封装防抖 / 节流高阶函数(带 this 与事件对象) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>防抖 / 节流高阶函数</title>
  <style>
    input {
      width: 300px;
      padding: 10px;
      outline: none;
      border: 1px solid #999;
    }
    .box {
      width: 400px;
      height: 250px;
      display: inline-block;
      background: #900;
      color: #fff;
      padding: 20px;
      box-sizing: border-box;
    }
  </style>
</head>
<body>
  <input type="text" id="input01">
  <input type="text" id="input02">
  <hr>
  <div class="box" id="box01"></div>
  <div class="box" id="box02"></div>

  <script>
    /* ============ 防抖高阶函数 ============ */
    function debounce(method, delay) {
      var timeId = null;               // 闭包变量,写在返回函数外面
      return function () {
        var that = this;               // 保存绑定事件的元素
        var args = arguments;          // 保存事件对象等实参
        clearTimeout(timeId);
        timeId = setTimeout(function () {
          method.apply(that, args);    // apply 同时指定 this 和透传参数
        }, delay);
      };
    }

    /* ============ 节流高阶函数 ============ */
    function throttle(method, delay) {
      var prev = Date.now();           // 闭包变量,只在赋值给事件时初始化一次
      return function () {
        var that = this;
        var args = arguments;
        var now = Date.now();
        if (now - prev >= delay) {
          method.apply(that, args);
          prev = now;                  // 执行后更新上一次执行时间
        }
      };
    }

    /* ============ 使用 ============ */
    var input01 = document.querySelector('#input01');
    var input02 = document.querySelector('#input02');

    function changeInput(e) {
      console.log('输入内容:', this.value, '事件对象:', e);
    }

    input01.oninput = changeInput;                 // 不做处理
    input02.oninput = debounce(changeInput, 400);  // 防抖处理

    var box01 = document.querySelector('#box01');
    var box02 = document.querySelector('#box02');

    function moveFunc(e) {
      console.log('鼠标移动', Date.now(), e.offsetX, e.offsetY);
      this.innerHTML = e.offsetX + ',' + e.offsetY;
    }

    box01.onmousemove = moveFunc;                  // 不做处理
    box02.onmousemove = throttle(moveFunc, 600);   // 节流处理
  </script>
</body>
</html>

要点解释 / 易错点

  • 高阶函数 = 参数是函数或返回值是函数。这里把事件回调 method 传进去,返回一个“多了防抖 / 节流能力”的新函数,再赋给事件,原回调的功能一点不丢。
  • var that = this 必不可少:只有返回的那个函数才是真正绑给 input02 / box02 的,它的 this 才是元素。若直接 method() 调用,method 里的 this 会变成 window,this.value 就取不到。
  • var args = arguments 必不可少:事件对象 e 只在返回函数的 arguments 里,不传下去,method 里拿到的 e 就是 undefined。
  • 用 method.apply(that, args) 而不是 call:apply 第二个参数是数组 / 类数组,正好把 arguments 原样透传。
  • timeId、prev 写在返回函数外面形成闭包:写在里面的话每次触发都重新初始化,防抖 / 节流直接失效;写在外面也顺带解决了示例 1 中全局变量污染的问题。
  • 节流的 prev 只在 throttle() 被调用(即赋值给事件)时初始化一次,之后一直复用,别在返回函数里重新赋值。
  • 两个盒子用 inline-block 并排时要注意对齐问题,加 box-sizing: border-box 或改用其他布局可避免错位。

常见错误 ​

  • 现象:防抖完全不生效,每次触发都执行。→ 原因:timeId 写在事件回调内部,每次触发都是新的执行上下文,清不到上一次定时器。→ 修法:把定时器标记放到外层闭包中。
  • 现象:高阶函数处理后 this 变成 window,拿不到监听元素。→ 原因:返回的新函数里直接调用原回调,未保留调用时的 this。→ 修法:在返回函数中保存 that = this,用 method.apply(that, args) 调用。
  • 现象:防抖/节流后事件对象 event 丢失。→ 原因:只传递了 this,没有把返回函数中的 arguments 传给原回调。→ 修法:保存 args = arguments,通过 apply(that, args) 原样传入。
  • 现象:节流第一次正常,后续触发频率异常或不再按间隔执行。→ 原因:执行后忘记更新 prev = now,时间基准一直是初始值。→ 修法:在真正执行回调后更新时间基准。
  • 现象:把 debounce(fn, 400) 直接写成 debounce(fn, 400) 赋给事件却未接收返回值,或误写成 debounce(fn, 400)()。→ 原因:高阶函数返回的是新函数,未正确赋给事件;多加括号会立即调用。→ 修法:写成 el.oninput = debounce(fn, 400),不要额外加执行括号。
  • 现象:在高阶函数内部直接用 arguments 或 this,结果指向错误。→ 原因:arguments/this 属于当前所在函数,不一定属于事件回调那一层。→ 修法:在真正作为事件回调的返回函数中捕获 this 和 arguments。

练习 ​

  1. 给两个输入框分别绑定 input 事件:一个不处理,一个使用防抖高阶函数处理;要求防抖回调中能拿到输入框内容和 event。
  2. 实现页面滚动节流加载:时间间隔设为 600ms,要求说明 prev 应在什么位置初始化、什么位置更新,并验证滚动时触发频率。
  3. 改造防抖高阶函数,使其支持“首次立即执行、后续等待停止后再执行”的选项;说明改动点。
  4. 针对“点赞按钮”和“滚动加载更多”两个场景,分别选择防抖或节流,并写出选择理由和关键时间间隔考虑。
  5. 给出一个高阶函数版本,故意遗漏 this 或 arguments 的传递;要求定位问题并修正,使原回调语义保持不变。

原视频对照 ​

#视频时长
1防抖和节流对比12:56
2防抖实现原理15:44
3节流实现原理06:38
4防抖函数29:20
5节流函数10:03

小结 ​

  • 先写裸防抖/裸节流,再封装高阶函数,理解成本更低。
  • 高阶函数的关键不是“多写一层”,而是返回一个保留原回调语义的新函数。
  • 定时器标记、prev 等状态必须放在闭包外层,否则跨触发无法共享。
  • 节流是否准确,取决于执行后是否及时更新时间基准。
  • this 和事件对象是否保留,是判断高阶函数是否“可用”的重要标准。
  • 演示时可用较长间隔观察差异,真实业务通常用几百毫秒。
  • 选防抖还是节流,应结合业务目标与产品预期,不必只按事件类型机械选择。
  • 出现频繁触发引发性能或请求问题时,再引入防抖/节流更合理。