节流与防抖

张玥 2026年9月17日 阅读时间 30分钟
性能优化 节流 防抖 JavaScript 高频事件
JavaScript编程性能优化之节流与防抖

滚动页面时卡顿、搜索框每敲一个字就发一次请求、窗口拖动时页面掉帧、按钮被连点导致重复下单……这些前端性能事故的背后,往往都指向同一个根源:高频事件没有做频率控制。节流(Throttle)与防抖(Debounce)是解决这类问题的两把利器,也是前端面试与工程实践中绕不开的核心知识点。本文将从浏览器事件模型、函数执行时机、时间戳与定时器的底层差异讲起,手写生产级实现,剖析 Lodash 源码思路,并结合滚动加载、搜索联想、按钮防重复点击、拖拽、Canvas 绘制等真实场景,系统性地讲透这两个技术。30分钟深度学习,让你的页面真正"丝滑"起来。

为什么需要节流与防抖

浏览器中的很多事件是"高频触发"的:用户每一次滚动、每一次移动鼠标、每一次敲击键盘,都会触发对应的事件回调。如果回调中包含了 DOM 操作、网络请求或复杂计算,页面就会迅速陷入卡顿。理解问题的规模,是优化的第一步。

/* 一个"危险"的滚动监听 */
window.addEventListener('scroll', () => {
  // 每次滚动都执行:读取布局属性
  const top = document.body.scrollTop;
  const h = window.innerHeight;

  // 强制同步布局(Layout Thrashing)
  document.querySelectorAll('.item').forEach(el => {
    el.style.top = top + h + 'px';
  });
});

/* 结果:1 秒滚动可能触发 60~100 次回调 */
/* 每次回调都写样式 + 读布局 → 掉帧、卡顿 */
高频事件触发频率参考
mousemove约 60~120 次/秒
scroll约 50~100 次/秒
input约 5~20 次/秒
resize约 30~60 次/秒

大部分场景只需要其中 1~10 次的执行结果

节流与防抖的本质,就是用一个高阶函数包裹业务回调,在时间维度上限制它的执行次数。它们不改变业务逻辑,只改变"什么时候执行、执行几次"。

防抖(Debounce):等你不闹了再执行

防抖的核心思想是:事件被触发后不立即执行,而是等待一个延迟时间;如果在这段时间内事件又被触发,则重新计时。只有当事件"安静"超过延迟时间后,回调才真正执行一次。

/* 基础版防抖实现 */
function debounce(fn, delay = 300) {
  let timer = null;

  return function (...args) {
    // 关键:每次触发都清掉上一个定时器
    if (timer) clearTimeout(timer);

    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

/* 使用 */
const onSearch = debounce(function (kw) {
  console.log('发起请求:', kw);
}, 500);

input.addEventListener('input', e => onSearch(e.target.value));
输入序列(每次输入都在重新计时)
持续输入中… ↑ 只执行 1 次

适合"等用户停下来再处理"的场景

节流(Throttle):按固定节奏执行

节流的核心思想是:在指定的时间间隔内,回调最多执行一次。无论事件触发多频繁,执行频率都被"卡"在一个固定的节奏上,就像给水龙头装上了一个限流阀。

/* 方案一:时间戳实现(立即执行,最后一次可能丢失) */
function throttle(fn, wait = 200) {
  let last = 0;

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

    if (now - last >= wait) {
      last = now;
      fn.apply(this, args);
    }
  };
}

/* 方案二:定时器实现(延迟执行,保证最后一次) */
function throttleTimer(fn, wait = 200) {
  let timer = null;

  return function (...args) {
    if (timer) return;

    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, wait);
  };
}
持续高频触发(每 200ms 执行一次)
0ms200ms400ms600ms

节奏恒定,不因触发频率变化而改变

核心差异:一张表看懂两者

很多人能背出定义,却在实际场景中选错方案。关键要理解两者在"执行时机"上的根本差异。

对比维度 防抖 Debounce 节流 Throttle
执行时机 事件停止后延迟执行 固定间隔内执行一次
执行次数 连续触发只执行 1 次 按频率执行多次
是否丢失中间态 丢失,只保留最后结果 保留,周期性采样
典型场景 搜索联想、表单校验、窗口 resize 完成 滚动加载、拖拽、鼠标跟随、进度上报
类比 电梯门:有人进来就重新等 10 秒 水龙头限流阀:每秒固定出水量
/* 同一场景下的行为差异演示 */
const log = debounce(() => console.log('防抖'), 300);
const logT = throttle(() => console.log('节流'), 300);

// 假设 1 秒内连续触发 20 次
// 防抖 → 控制台只打印 1 次 "防抖"
// 节流 → 控制台约打印 3~4 次 "节流"

/* 选择口诀 */
/* 只关心"最终结果" → 防抖 */
/* 需要"过程反馈" → 节流 */
搜索联想 → 防抖
用户还在打字时不要发请求,敲完了再查一次
滚动加载 → 节流
滚动过程中要持续判断是否到达底部
按钮点击 → 防抖(立即执行)
第一次点击立即生效,后续连点被忽略

生产级实现:带 immediate、cancel 与返回值

基础版实现只够应付面试。真实项目中,我们还需要"立即执行"、"取消"、"获取返回值"等能力。下面给出两个可直接落地的完整实现。

/* 完整版防抖:支持 immediate + cancel + 返回值 */
function debounce(fn, delay = 300, immediate = false) {
  let timer = null;
  let result;

  function debounced(...args) {
    const context = this;

    if (timer) clearTimeout(timer);

    if (immediate) {
      const callNow = !timer;
      timer = setTimeout(() => { timer = null; }, delay);

      if (callNow) {
        result = fn.apply(context, args);
      }
    } else {
      timer = setTimeout(() => {
        fn.apply(context, args);
        timer = null;
      }, delay);
    }

    return result;
  }

  debounced.cancel = function () {
    clearTimeout(timer);
    timer = null;
  };

  return debounced;
}
/* 完整版节流:时间戳 + 定时器双保险 */
function throttle(fn, wait = 200, options = {}) {
  let last = 0;
  let timer = null;
  const { leading = true, trailing = true } = options;

  function throttled(...args) {
    const context = this;
    const now = Date.now();

    if (!last && !leading) last = now;

    const remaining = wait - (now - last);

    if (remaining <= 0 || remaining > wait) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      last = now;
      fn.apply(context, args);
    } else if (!timer && trailing) {
      timer = setTimeout(() => {
        last = leading ? Date.now() : 0;
        timer = null;
        fn.apply(context, args);
      }, remaining);
    }
  }

  throttled.cancel = function () {
    clearTimeout(timer);
    timer = null;
    last = 0;
  };

  return throttled;
}
leading
首次触发是否立即执行
trailing
结束后是否补执行一次
immediate
防抖的"先执行再冷却"
cancel
组件卸载时清理定时器

这四个开关,是"能上生产"的分水岭

实战场景一:搜索联想与输入校验

搜索框是防抖最经典的应用场景。用户输入"javascript"这 10 个字符,如果不做处理会发出 10 次请求,而实际上只需要最后一次结果。

/* 搜索联想:防抖 + 竞态处理 */
let requestId = 0;

const handleSearch = debounce(async function (keyword) {
  if (!keyword.trim()) return;

  const currentId = ++requestId;

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
    const data = await res.json();

    // 只渲染最新一次请求的结果
    if (currentId !== requestId) return;

    renderSuggestions(data);
  } catch (err) {
    console.error('搜索失败', err);
  }
}, 400);

/* 输入框绑定 */
searchInput.addEventListener('input', e => {
  handleSearch(e.target.value);
});

/* 组件卸载 / 页面隐藏时清理 */
window.addEventListener('beforeunload', () => {
  handleSearch.cancel();
});
java
javascript
java 入门教程
javascript 数组方法
10 次
未优化请求
1 次
防抖后请求

实战场景二:滚动加载与位置上报

滚动监听必须用节流,因为我们需要在滚动过程中"周期性"判断是否触底。如果错用防抖,用户一直滚动就不会触发加载,体验反而更差。

/* 无限滚动:节流 + 触底判断 */
let loading = false;

const handleScroll = throttle(async function () {
  if (loading) return;

  const { scrollTop, scrollHeight, clientHeight } = document.documentElement;

  // 距离底部小于 200px 时加载下一页
  if (scrollTop + clientHeight >= scrollHeight - 200) {
    loading = true;
    await loadNextPage();
    loading = false;
  }
}, 200);

window.addEventListener('scroll', handleScroll, { passive: true });

/* 注意:passive: true 告诉浏览器不会 preventDefault */
/* 浏览器可以立即滚动,不必等待 JS 执行完成 */
触发加载
scrollTop + clientHeight ≥ scrollHeight - 200

实战场景三:按钮防重复点击与表单提交

用户疯狂点击"提交订单",可能造成重复下单。这种场景需要"立即执行版防抖":第一次点击马上生效,冷却期内的点击全部忽略。

/* 立即执行版防抖:防重复提交 */
function debounceImmediate(fn, delay = 1000) {
  let timer = null;

  return function (...args) {
    if (timer) return; // 冷却中,直接忽略

    fn.apply(this, args);

    timer = setTimeout(() => {
      timer = null;
    }, delay);
  };
}

/* 应用到提交按钮 */
const submitBtn = document.querySelector('#submit');

submitBtn.addEventListener('click', debounceImmediate(async () => {
  submitBtn.disabled = true;
  submitBtn.textContent = '提交中…';

  try {
    await submitOrder();
    toast('提交成功');
  } finally {
    submitBtn.disabled = false;
    submitBtn.textContent = '提交';
  }
}, 1000));
点击 1 ✅ 执行 点击 2 ❌ 忽略 点击 3 ❌ 忽略
冷却期 1000ms 内所有点击均被忽略

进阶:用 requestAnimationFrame 做帧级节流

对于与视觉渲染强相关的场景(拖拽、鼠标跟随、Canvas 动画),用 setTimeout 做节流并不理想——它可能与浏览器的重绘节奏错位。此时 requestAnimationFrame 是更优解,它保证回调在下一帧渲染前执行,天然对齐 60fps。

/* rAF 节流:每帧最多执行一次 */
function rafThrottle(fn) {
  let ticking = false;

  return function (...args) {
    if (ticking) return;

    ticking = true;

    requestAnimationFrame(() => {
      fn.apply(this, args);
      ticking = false;
    });
  };
}

/* 拖拽场景 */
const onDrag = rafThrottle(e => {
  box.style.transform =
    `translate(${e.clientX}px, ${e.clientY}px)`;
});

document.addEventListener('mousemove', onDrag);

/* 对比:setTimeout 节流 16ms 也可能与重绘错位 */
/* rAF 由浏览器统一调度,与渲染帧严格同步 */
frame 1frame 2frame 3frame 4frame 5frame 6

每帧一次,稳定 60fps,不掉帧

框架生态中的节流与防抖

实际项目中,我们通常不需要从零手写。Lodash、Vue、React 生态都提供了成熟的方案,理解它们的差异能帮你做出更合适的选择。

/* 1. Lodash:最常用的生产级方案 */
import { debounce, throttle } from 'lodash-es';

const search = debounce(keyword => {
  fetchResult(keyword);
}, 300, {
  leading: false,
  trailing: true,
  maxWait: 1000 // 最长等待时间,防止无限延迟
});

search.cancel(); // 取消
search.flush(); // 立即执行

/* 2. Vue 3:自定义指令封装 */
const debounceDirective = {
  mounted(el, binding) {
    const { value, arg = 300 } = binding;
    el._handler = debounce(value, Number(arg));
    el.addEventListener('click', el._handler);
  },
  unmounted(el) {
    el.removeEventListener('click', el._handler);
    el._handler.cancel(); // 必须清理,防止内存泄漏
  }
};
/* 3. React:自定义 Hook */
import { useRef, useCallback, useEffect } from 'react';

function useDebounce(fn, delay = 300, deps = []) {
  const fnRef = useRef(fn);

  useEffect(() => {
    fnRef.current = fn;
  }, [fn]);

  return useCallback(
    debounce((...args) => fnRef.current(...args), delay),
    deps
  );
}

/* 组件中使用 */
function SearchBox() {
  const [keyword, setKeyword] = useState('');

  const search = useDebounce((val) => {
    api.search(val).then(setList);
  }, 400, []);

  return (
    <input
      value={keyword}
      onChange={e => {
        setKeyword(e.target.value);
        search(e.target.value);
      }}
    />
  );
}
Lodash
功能最全
支持 maxWait
Vue 指令
模板友好
自动清理
React Hook
useRef 存引用
避免闭包陷阱

避坑指南:六个常见错误

节流与防抖看似简单,但实际使用中有大量细节容易踩坑。下面这些错误,几乎每个人都犯过至少一个。

/* ❌ 坑 1:丢失 this 指向 */
const obj = {
  name: '张三',
  say: debounce(function () {
    console.log(this.name); // undefined
  }, 300)
};
/* ✅ 解决:内部使用 fn.apply(this, args) */

/* ❌ 坑 2:每次渲染都创建新的防抖函数 */
/* React 中:onChange={debounce(handle, 300)} → 每次都新建 */
/* ✅ 解决:useMemo / useCallback 缓存实例 */

/* ❌ 坑 3:忘记清理定时器 */
/* 组件卸载后定时器仍执行 → 内存泄漏 / 报错 */
/* ✅ 解决:暴露 cancel 方法,在卸载钩子中调用 */

/* ❌ 坑 4:滚动场景误用防抖 */
/* 用户持续滚动 → 永远不触发加载 */
/* ✅ 解决:滚动用节流,或用 IntersectionObserver */
/* ❌ 坑 5:await 与防抖的顺序问题 */
const bad = debounce(async () => {
  const a = await fetchA();
  const b = await fetchB(a);
}, 300);
/* 防抖只保证"开始执行"的时机 */
/* 不保证异步任务不并发,仍需竞态处理 */

/* ❌ 坑 6:节流参数过大导致"无响应感" */
/* 拖拽场景用 500ms 节流 → 元素明显迟滞 */
/* ✅ 解决:视觉类场景用 rAF,交互类 16~50ms */

/* 时间参数参考表 */
/* 输入搜索 300 ~ 500 ms */
/* 窗口 resize 200 ~ 300 ms */
/* 滚动监听 100 ~ 200 ms */
/* 拖拽 / 动画 使用 rAF */
/* 按钮防连点 500 ~ 1000 ms */
内存泄漏:定时器未清理,回调持有已卸载组件的引用
闭包陷阱:缓存了旧的状态值,导致逻辑使用过期数据
节奏错配:用错方案,导致"该响应的不响应,该节流的没节流"
正确姿势:缓存实例 + 暴露 cancel + 合理时间 + 竞态处理

调试、监控与单元测试

写完不代表做对。我们需要手段去验证"到底执行了几次""延迟是否符合预期",并在长期运行中监控性能表现。

/* 1. 用计数器快速验证执行次数 */
let count = 0;

const tracked = debounce(() => {
  count++;
  console.log(`执行次数:${count}`);
}, 300);

/* 2. 用 performance 测量真实耗时 */
const measure = throttle(() => {
  const start = performance.now();

  heavyTask();

  const cost = performance.now() - start;
  if (cost > 16) {
    console.warn(`任务耗时 ${cost.toFixed(2)}ms,可能掉帧`);
  }
}, 200);

/* 3. 用 PerformanceObserver 监控长任务 */
const observer = new PerformanceObserver(list => {
  list.getEntries().forEach(entry => {
    if (entry.duration > 50) {
      console.log('长任务:', entry.duration);
    }
  });
});

observer.observe({ entryTypes: ['longtask'] });
/* 4. 单元测试:验证防抖行为(Jest 假定时器) */
import { debounce } from './utils';

describe('debounce', () => {
  jest.useFakeTimers();

  test('连续调用只执行一次', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced();
    debounced();
    debounced();

    jest.advanceTimersByTime(300);

    expect(fn).toHaveBeenCalledTimes(1);
  });

  test('cancel 后不再执行', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced();
    debounced.cancel();
    jest.advanceTimersByTime(500);

    expect(fn).not.toHaveBeenCalled();
  });
});
计数器
最直接的执行次数验证
performance
精确到微秒的耗时测量
长任务监控
捕捉超过 50ms 的阻塞
单元测试
假定时器锁定行为契约

最佳实践与总结

  • 先判断场景:只关心最终结果用防抖,需要过程反馈用节流
  • 视觉动效用 rAF:拖拽、动画、鼠标跟随优先考虑 requestAnimationFrame
  • 暴露 cancel:任何需要清理的场景(组件卸载、路由切换)都必须能取消
  • 缓存函数实例:在 React / Vue 中避免每次渲染重新创建防抖函数
  • 合理设置时间:输入 300~500ms,滚动 100~200ms,按钮 500~1000ms
  • 注意竞态:防抖只控制触发时机,异步请求仍需处理返回顺序
  • 优先考虑原生方案:IntersectionObserver 替代滚动监听,ResizeObserver 替代 resize
  • 性能监控常态化:用 PerformanceObserver 持续发现长任务,而不是等用户投诉
/* 一个可直接复用的工具模块 */
export { debounce, throttle, rafThrottle };

/* 使用建议速查 */
/* 搜索输入 → debounce(fn, 400) */
/* 按钮防连点 → debounce(fn, 800, true) */
/* 滚动加载 → throttle(fn, 200) */
/* 窗口 resize → debounce(fn, 250) 或 throttle */
/* 拖拽 / 动画 → rafThrottle(fn) */

/* 核心心法 */
/* 不是"让代码少执行",而是"让关键代码在对的时机执行" */

节流与防抖是前端性能优化中最基础、也最容易被低估的技术。它们的价值不只是"少执行几次",更在于让页面的响应节奏与用户的感知节奏保持一致——该即时反馈的绝不延迟,该合并处理的绝不重复。掌握它们背后的时间控制思想,你就能在滚动、拖拽、输入、动画等各类高频场景中游刃有余,写出真正流畅的 Web 应用。