从频繁触发到性能优化:防抖与节流的实战指南

291 阅读6分钟

作为前端开发者,你是否遇到过这样的场景:用户在搜索框疯狂输入时,控制台疯狂打印请求日志;页面滚动时,控制台像机关枪一样输出信息,最后页面卡到怀疑人生?别慌,这时候防抖和节流就该登场了!今天咱们就好好聊聊这两位 "性能优化小能手"~

为什么需要防抖和节流?

先看个真实场景:做搜索建议功能时,用户每输入一个字符,我们就发一次请求到后端。如果用户打字速度快(比如一分钟打 60 个字),那 1 分钟就会发 60 次请求。这要是用户多了,服务器不得直接 "罢工"?

再比如滚动事件(scroll)、窗口大小改变(resize),这些事件触发的频率高到离谱。假设每次触发都要做 DOM 操作(重绘 / 重排),页面不卡顿才怪。

说白了,防抖和节流的核心目标就是:减少不必要的函数执行,平衡用户体验和性能消耗

防抖(Debounce):"等你停了再说"

概念:一段时间内只执行最后一次

防抖的逻辑很简单:当函数被频繁触发时,只有在停止触发后等待一段时间,才会执行一次。如果在这段时间内又触发了,就重新计时。

比如打游戏时按技能,频繁按的时候技能不释放,松开手后才放 —— 这就是防抖的精髓~

实现原理与代码

先看个反例:未加防抖的输入事件,每输入一个字符就发请求:

// 模拟后端请求
function ajax(content) {
  console.log('发送请求:', content);
}

// 未防抖:每次按键都触发
const inputA = document.getElementById('inputA');
inputA.addEventListener('keyup', (e) => {
  ajax(e.target.value); // 疯狂触发,服务器压力山大
});

再看防抖后的实现。这里要用到高阶函数闭包:高阶函数负责接收原函数和延迟时间,闭包用来保存定时器状态。

// 防抖函数优化版(修正原代码中fn.id的问题)
function debounce(fn, delay) {
  let timer = null; // 用闭包保存定时器,避免多个实例冲突
  return function(...args) {
    // 每次触发都清除上一次的定时器
    clearTimeout(timer);
    // 重新设置定时器,延迟执行
    timer = setTimeout(() => {
      fn.apply(this, args); // 绑定this,确保原函数上下文正确
    }, delay);
  };
}

// 使用防抖包装请求函数
const debounceAjax = debounce(ajax, 300); // 300ms内不触发才执行

const inputB = document.getElementById('inputB');
inputB.addEventListener('keyup', (e) => {
  debounceAjax(e.target.value); // 只有停止输入300ms后才发请求
});

这里修正了原代码中用fn.id存储定时器的问题:如果同一个函数被多次防抖处理,fn.id会被共享,导致定时器冲突。用闭包中的timer变量更安全~

防抖的应用场景

  • 搜索框输入联想(等用户输完一个词再发请求)
  • 按钮点击防重复提交(避免用户疯狂点击)
  • 文本编辑器自动保存(停止输入后保存)

节流(Throttle):"按固定节奏来"

概念:每隔一段时间必执行一次

节流和防抖不同:不管触发多频繁,每隔指定时间一定会执行一次。就像打游戏时的技能 CD,哪怕一直按,也只能按固定间隔释放。

比如页面滚动时计算元素位置,不需要每次滚动都算,每隔 100ms 算一次就够了。

实现原理与代码

节流的核心是记录 "上一次执行时间",每次触发时判断是否超过间隔:

// 节流函数
function throttle(fn, interval) {
  let lastTime = 0; // 上一次执行时间
  let timer = null; // 用于处理最后一次触发

  return function(...args) {
    const now = Date.now(); // 当前时间
    // 如果距离上次执行不足间隔,设置定时器确保最后一次触发能执行
    if (now - lastTime < interval) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        lastTime = now;
        fn.apply(this, args);
      }, interval);
    } else {
      // 超过间隔,直接执行
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

// 使用节流处理滚动事件
const throttleScroll = throttle((content) => {
  console.log('处理滚动:', content);
}, 200); // 每隔200ms执行一次

window.addEventListener('scroll', () => {
  throttleScroll('当前滚动位置:' + window.scrollY);
});

节流的应用场景

  • 滚动加载(懒加载图片时,每隔一段时间判断一次元素位置)
  • 窗口大小改变(resize事件,每隔 100ms 重新计算布局)
  • 鼠标移动跟踪(如拖拽元素,固定频率更新位置)

防抖 vs 节流:怎么选?

用一张表总结两者的区别:

特性防抖(Debounce)节流(Throttle)
核心逻辑停止触发后延迟执行一次固定间隔内必执行一次
适用场景输入联想、按钮防重复提交滚动、resize、鼠标跟踪
执行时机最后一次触发后延迟执行间隔时间到就执行

简单说:需要 "等用户操作完" 才执行,用防抖;需要 "按节奏稳步执行",用节流

防抖节流与闭包:天生一对

为什么防抖和节流能实现?核心是闭包

闭包的特性是 "内部函数可以访问外部函数的变量,且这些变量不会被垃圾回收"。在防抖节流中:

  • 防抖里的timer(定时器 ID)被闭包保存,每次触发都能访问并清除上一次的定时器

  • 节流里的lastTime(上一次执行时间)被闭包保存,用来计算是否达到执行间隔

这也是为什么防抖节流函数要返回一个新函数 —— 新函数就是闭包,它 "记住" 了timerlastTime的状态。

闭包的其他 "超能力"

除了防抖节流,闭包还有很多实用场景,顺带提几个:

1. 私有变量

通过闭包可以实现类的私有属性,避免外部直接修改:

function Book(title, author, year) {
  // 私有变量(外部无法直接访问)
  let _title = title;
  let _author = author;
  let _year = year;

  // 公开方法(控制对私有变量的访问)
  this.getTitle = () => _title;
  this.updateYear = (newYear) => {
    if (typeof newYear === 'number' && newYear > 0) {
      _year = newYear;
    } else {
      console.error('年份必须是正数哦~');
    }
  };
}

const book = new Book('JS高级程序设计', 'Nicholas', 2011);
console.log(book._title); // undefined(私有变量访问不到)
book.updateYear(2024); // 正确修改

2. 绑定上下文

在事件监听或定时器中,经常遇到this丢失的问题,闭包 + 箭头函数 /bind 可以解决:

const person = {
  name: '前端er',
  sayHi: function() {
    // 用箭头函数(继承外部this)
    setTimeout(() => {
      console.log(`${this.name} 说:Hi~`); // 正确输出"前端er 说:Hi~"
    }, 1000);
  }
};
person.sayHi();

总结

防抖和节流是前端性能优化的基础技能,核心是通过控制函数执行频率解决频繁触发问题。记住:

  • 防抖:等用户 "停手" 再执行(最后一次有效)

  • 节流:按固定节奏执行(间隔有效)

  • 两者都依赖闭包保存状态,是闭包的典型应用

下次遇到频繁触发的事件,别再让函数 "疯狂加班" 了,用防抖节流给它 "降降压" 吧~