JavaScript 防抖与节流彻底搞懂:原理、区别、手写实现 + 实战避坑指南

防抖(debounce)和节流(throttle)是前端面试的钉子户,也是实际开发里真能救命的优化手段。但很多人能把概念背得滚瓜烂熟,一让手写就卡壳;或者干脆把两个用反了;又或者会写、却不知道在框架里有个坑能让它彻底失效。这篇不绕弯子,把原理、时间线图解、各种版本的手写实现、框架里的坑、面试的追问,一次讲透。看完你应该能做到:闭着眼写出来,知道每个场景该用哪个,也讲得清它背后的取舍。文章有点长,但这是个"一次搞懂、终身受用"的知识点。

为什么需要防抖和节流

有些事件触发得特别勤:input(每敲一个字符一次)、scroll(滚一下几十次)、resizemousemove(移一下几百次)、touchmove。这些事件本身没有问题 —— 问题在于"在它们的回调里干重活"。

浏览器一帧的预算只有约 16.6ms(60fps 下)

  事件触发 ──► 你的回调(发请求 / 改 DOM / 算布局)──► 浏览器要渲染这一帧
              如果回调干的活太重、又触发得太频繁,
              一帧的预算被你的回调吃光 ──► 来不及渲染 ──► 掉帧、卡顿、页面发涩

  scroll 滚一下,可能触发几十次;mousemove 移一下,几百次;
  input 每敲一个字一次;resize 拖一下,连续几十次。
  事件本身没问题,问题是"在它们的回调里干重活"。

浏览器渲染一帧的时间预算,在 60fps 下只有约 16.6ms。这 16.6ms 里要跑你的 JavaScript、算样式、布局、绘制。如果你在一个高频事件的回调里发网络请求、大量操作 DOM、做复杂计算,而这个事件一秒触发几十上百次 —— 一帧的预算被吃光,浏览器来不及渲染,结果就是用户能直观感受到的卡顿、掉帧、页面发涩

防抖和节流,本质上都是"降频":事件该触发还触发(我们拦不住用户滚动、打字),但你的重活别跟着每一次触发都干。它俩的全部区别,只在于"用什么策略来降频"。理解了这一句,后面全是细节。

一张时间线图,先把区别看明白

文字描述容易绕,先上一张时间线图。横轴是时间,▲ 表示事件被触发,★ 表示你的函数真正执行了一次:

时间线图解(▲ = 事件触发,★ = fn 真正执行,delay/interval = 300ms)

【原始】每次触发都执行:
  ▲★ ▲★ ▲★ ▲★ ▲★ ▲★ ▲★ ▲★          ← 触发几次就执行几次,最浪费

【防抖 debounce】只在"停下来 300ms 后"执行一次:
  ▲  ▲  ▲  ▲  ▲              ▲
                 └─安静300ms─┘★        └─安静300ms─┘★
  ← 频繁触发期间一次不执行,等彻底停了才执行。一阵狂点 = 只执行 1 次

【节流 throttle】每 300ms 最多执行一次:
  ▲★ ▲ ▲ ▲★ ▲ ▲ ▲★ ▲ ▲ ▲★
   └─300ms─┘ └─300ms─┘ └─300ms─┘
  ← 频繁触发期间也执行,但被压成固定节奏

看这张图,两者的区别一目了然:防抖在"频繁触发期间"是完全沉默的,只在"安静下来之后"动一次;节流在"频繁触发期间"也动,但被掐成了固定的节拍。把这张图记在脑子里,后面所有的实现细节都是为了达成这两种效果。

防抖:等你彻底消停了,再做一次

防抖的策略是:每次触发都重新计时,只有"安静"了一段时间(delay)没再触发,才真正执行一次。

打个比方 —— 电梯关门。有人进来,门就重新等几秒;又有人进来,再重新等;直到没人进了、安静够久了,门才关。在频繁有人进的这段时间里,门一次都不关。防抖就是这个逻辑。

基础版防抖:
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);              // 又触发了?把上一个计时器作废
    timer = setTimeout(() => {
      fn.apply(this, args);           // 安静 delay 毫秒后,才真正执行一次
    }, delay);
  };
}
input.addEventListener('input', debounce(() => search(input.value), 300));

核心机制就一句话:clearTimeout 把上一个计时器作废,重新 setTimeout只要还在频繁触发,计时器就一直被重置,永远到不了"执行"那一刻;一旦停下来超过 delay,最后那个没被清掉的计时器才走完、执行。

最经典的场景是搜索框联想:用户还在打字就别发请求 —— 既省掉大量无效请求,又避免了"结果乱跳"(早发的请求后回来,把新结果覆盖了)。其他典型场景:表单实时校验、窗口 resize 后重排、编辑器自动保存草稿。

防抖进阶:立即执行 + 可取消

基础版有两个在实战和面试里都常被要求补的能力。第一个是首次立即执行(immediate / leading edge):用于"第一次操作要马上响应,后续才防抖"的场景 —— 比如按钮第一次点立刻生效,但短时间内的连点被屏蔽。第二个是可手动取消(cancel):用于组件卸载时把还挂着的计时器清掉,否则可能在组件已销毁后那个迟到的回调还在执行,轻则报错,重则内存泄漏。

完整版防抖:支持 immediate(首次立即执行)+ cancel(可取消)
function debounce(fn, delay, immediate = false) {
  let timer = null;
  function debounced(...args) {
    const callNow = immediate && !timer;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) fn.apply(this, args);
    }, delay);
    if (callNow) fn.apply(this, args);
  }
  debounced.cancel = function () {     // 组件卸载时调用,清掉挂起的计时器
    clearTimeout(timer);
    timer = null;
  };
  return debounced;
}

这个带 immediatecancel 的版本,是面试里"请写一个完整的防抖"的标准答案。注意 callNow 的判断要在 clearTimeout 之前算好 —— 因为 clearTimeout 不会把 timer 变量本身置空,我们靠 timer 是否存在来判断"是不是第一次触发"。

防抖的进阶变体:返回 Promise

实战里有个很常见的需求:"防抖地发请求,而且我还要拿到请求的结果"。基础版防抖的返回值是 undefined,没法 await。这时候需要一个返回 Promise 的版本:

进阶变体:返回 Promise 的防抖(常用于"防抖地发请求、还要拿到结果")
function debounceAsync(fn, delay) {
  let timer = null;
  return function (...args) {
    return new Promise((resolve, reject) => {
      clearTimeout(timer);
      timer = setTimeout(async () => {
        try { resolve(await fn.apply(this, args)); }
        catch (e) { reject(e); }
      }, delay);
    });
  };
}
// 用法:const search = debounceAsync(api.search, 300);
//       const list = await search(keyword);

它的思路是:每次调用都返回一个新的 Promise,但只有最后一次(防抖通过的那次)对应的 Promise 会被 resolve。这样调用方就能用 const list = await search(keyword) 这种顺手的写法,既享受了防抖、又能拿到结果。这个变体在做"搜索 + 拿数据渲染"的场景里特别实用,面试里也算亮点。

节流:再急,也按固定节奏来

节流的策略是:不管你触发多频繁,我固定每隔一段时间(interval)才执行一次。打比方 —— 水龙头拧到最小,水(事件)一直在流,但它匀速地滴。节流不像防抖那样"频繁期间一次不干",它在频繁触发期间也照干,只是把频率压到固定节奏上。

节流有两种经典实现,差别值得搞清楚:

节流实现一:时间戳版 —— 第一次立即执行,停止后不补最后一次
function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) { last = now; fn.apply(this, args); }
  };
}

节流实现二:定时器版 —— 第一次延迟 interval 才执行,但会补最后一次
function throttle(fn, interval) {
  let timer = null;
  return function (...args) {
    if (timer) return;
    timer = setTimeout(() => { timer = null; fn.apply(this, args); }, interval);
  };
}

时间戳版:记一个"上次执行的时间戳",每次触发都看"距离上次够不够久"。特点是 —— 第一次触发会立即执行,但停止触发后不会补最后一次。定时器版:用一个计时器"占位",计时器在跑就忽略新触发。特点是 —— 第一次触发会延迟 interval 才执行,但停止触发后会补最后一次。

你在意"第一下要立刻有反应",用时间戳版;在意"最后一下不能丢",用定时器版。而生产级的库(比如 lodash 的 throttle)其实是把两者合一:

合体版节流:既首次立即执行,又补最后一次(lodash throttle 的核心思路)
function throttle(fn, interval) {
  let last = 0, timer = null;
  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - last);
    if (remaining <= 0) {              // 到点了,立即执行
      if (timer) { clearTimeout(timer); timer = null; }
      last = now;
      fn.apply(this, args);
    } else if (!timer) {              // 没到点,挂一个定时器把"最后一次"补上
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

这个"合体版"既保证首次立即执行,又用一个收尾的定时器把最后一次补上,是最完整的节流实现。能在面试里写出这一版,基本就是"节流大师"了 —— 因为大多数人只会背单独的某一版。

防抖 vs 节流:一张表说清

对比项 防抖 debounce 节流 throttle
核心策略 频繁触发期间不执行,停下来才执行一次 频繁触发期间也执行,但按固定节奏
关注点 "结束"——只要最终结果 "过程"——过程中也要持续响应
比喻 电梯门(有人进就重新等) 水龙头拧小(匀速滴)
一直触发会怎样 永远不执行 每隔 interval 执行一次
典型场景 搜索联想、表单校验、resize 后重排 滚动加载、拖拽、视差、游戏开火

一句话记忆,刻进脑子:防抖看"结束",节流看"过程"。

实战场景大全:到底该用哪个

场景 该用 原因
搜索框输入联想 防抖 只关心最终输入,中途发请求纯浪费,还会结果乱跳
表单输入实时校验 防抖 等用户写完一段再校验,体验更好
窗口 resize 后重新布局 防抖 只需在拖动结束后算一次
按钮防重复提交 防抖(immediate) 首次立即响应,之后短时间内屏蔽连点
编辑器自动保存草稿 防抖 用户停止编辑一段时间后存一次,够了
滚动加载更多 / 懒加载判断 节流 过程中要持续判断位置,但不必每像素都判断
滚动进度条 / 视差效果 节流(rAF 版) 要持续更新,跟着刷新率走最跟手
拖拽元素 / 画板取坐标 节流 过程中要持续跟随,但可以压频
射击游戏开火 / 技能 CD 节流 本质就是"固定频率才能触发一次"

进阶:requestAnimationFrame 版节流

对于"滚动时更新视觉效果"这类纯渲染相关的高频任务,用固定毫秒数节流其实不够优雅 —— 你定 16ms 还是 32ms?和屏幕刷新率对不齐,要么浪费要么掉帧。更好的做法是用 requestAnimationFrame 节流,让执行频率自动对齐浏览器的渲染节奏:

requestAnimationFrame 版节流 —— 改"视觉"的高频任务专用
function rafThrottle(fn) {
  let ticking = false;
  return function (...args) {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => { fn.apply(this, args); ticking = false; });
  };
}
window.addEventListener('scroll', rafThrottle(updateScrollProgress), { passive: true });

rAF 版的好处:天然和屏幕刷新率同步(60Hz 屏约每 16.6ms 一次,120Hz 屏更密);页面切到后台标签时 requestAnimationFrame 会自动暂停,省电省 CPU;执行时机正好在浏览器下一次绘制之前,改样式最跟手、最不容易出现中间态闪烁。

选择规则:改"视觉"的高频任务用 rAF 节流;干"逻辑"的高频任务用普通毫秒节流。另外注意上面给 scroll 加了 { passive: true } —— 这是另一个独立的滚动优化,告诉浏览器"这个监听器不会调 preventDefault",和节流是叠加生效的。

框架里的坑:React / Vue 中正确使用

这是实战中最容易踩、又最隐蔽的坑:在组件的渲染过程中直接 debounce(fn, 300),每一次渲染都会生成一个全新的防抖函数。新函数内部的 timer 是全新的、和上一次接不上 —— 防抖完全失效,而你还以为自己写对了。

// ✗ 错:每次渲染都新建一个防抖函数,内部 timer 是全新的,接不上 —— 等于没防抖
function SearchBox() {
  const onInput = debounce((v) => search(v), 300);  // 每次 render 都是新函数
  return <input onChange={(e) => onInput(e.target.value)} />;
}
// ✓ 对(React):用 useMemo 让它只创建一次,卸载时 cancel
function SearchBox() {
  const onInput = useMemo(() => debounce((v) => search(v), 300), []);
  useEffect(() => () => onInput.cancel(), [onInput]);
  return <input onChange={(e) => onInput(e.target.value)} />;
}
// ✓ 对(Vue 3):在 setup 顶层创建一次即可
const onInput = debounce((v) => search(v), 300);
onUnmounted(() => onInput.cancel());

解决思路对 React 和 Vue 一样:保证防抖/节流函数在组件整个生命周期里只被创建一次。React 用 useMemo(或 useRef);Vue 3 在 setup 顶层创建一次即可。别忘了组件卸载时调 cancel() 清掉挂起的计时器。

怎么验证防抖节流真的生效了

写完之后怎么确认它真的在工作,而不是"看起来加了其实没生效"(比如踩了上面那个框架的坑)?三个办法:

  • 最简单:在被防抖/节流的函数里 console.log 打个标记,加时间戳。然后疯狂触发事件,看 console 里这个标记出现的频率 —— 防抖应该是"停手后才出现一次",节流应该是"匀速出现"。如果它还是跟着事件疯狂打印,说明没生效。
  • Network 面板:如果防抖的是发请求,直接看 Network 面板里请求的数量和时间间隔,最直观。
  • Performance 面板:录一段操作,看主线程的火焰图 —— 没优化前是密密麻麻的长任务,优化后应该明显稀疏。这也是定位"到底是不是这里卡"的硬办法。

养成"加了优化就验证一下"的习惯,能帮你避免"自以为优化了、其实没生效"的尴尬。

防抖节流之外:有时候有更好的工具

防抖节流是"通用降频手段",但针对某些具体场景,现代浏览器提供了更专门、更高效的 API:

  • 判断元素是否进入视口 → 用 IntersectionObserver,不要监听 scroll。图片懒加载、无限滚动、曝光埋点,过去要 scroll + 节流 + 算位置,现在直接用 IntersectionObserver,原生、更准、更省。
  • 监听元素尺寸变化 → 用 ResizeObserver,不要监听 window 的 resize。而如果只是想根据尺寸切样式,用 CSS 容器查询连 JS 都不用。
  • 纯视觉的持续动画 → 用 requestAnimationFrame 驱动,而不是在 scroll/mousemove 里硬算。

这不是说防抖节流过时了 —— 它们依然是"输入框、按钮、通用回调降频"的首选。只是提醒你:遇到"判断可见性""监听尺寸"这种具体需求时,先想想有没有更对口的专用 API。

面试常见追问

Q:防抖里为什么用 fn.apply(this, args) 而不是直接 fn()?为了保证回调里的 this 指向和参数(尤其是事件对象 event)能正确传进去。直接 fn() 会丢掉 thisevent,你回调里想拿 event.target 就拿不到了。

Q:能不能实现一个"既首次立即执行、又结束时补一次"的防抖?能,就是 lodash debounceleadingtrailing 都设为 true 的效果。在进阶版基础上把两个分支都打开,并处理好"只触发一次时别执行两遍"的边界。

Q:节流和防抖能组合使用吗?能,且复杂交互里很常见。比如"持续滚动时用节流每 200ms 给一次反馈,滚动彻底停下后再用防抖做一次精确的最终处理"。

Q:有 lodash 现成的,为什么还要会手写?手写一遍能让你真正理解它的行为边界 —— 什么时候 leading、什么时候 trailing、cancel 清的是什么、为什么在 React 里直接用会失效。用库时遇到诡异问题,这些理解能让你知道去哪儿找原因。

Q:防抖的 delay 设多少合适?看场景。搜索联想一般 200-400ms;按钮防连点几百毫秒到 1 秒;resize 重排 150-300ms。原则是"在不让用户觉得卡顿迟钝的前提下,尽量长"。

Q:节流和 setInterval 有什么区别?setInterval 是"不管有没有事件,我都固定间隔执行";节流是"有事件触发时,才按固定间隔执行,没触发就不执行"。节流是"事件驱动 + 限频",setInterval 是"纯定时"。

实战里还会踩的两个 bug

bug 一:在列表里给每一项都用了同一个防抖函数。如果列表每项需要独立防抖,它们必须各自持有独立的防抖实例(独立的 timer)。共用一个的话,操作 A 项会把 B 项的计时器也清了。要么给每项创建独立实例,要么用一个以"项 id"为 key 的 Map 管理多个 timer。

bug 二:防抖包裹的函数依赖了"创建时的闭包变量",但那个变量后来变了。防抖函数通常只创建一次,它闭包里捕获的变量也是创建那一刻的。如果回调依赖某个会变的状态,要么把状态作为参数传进来,要么用 ref 之类的手段让它能拿到最新值 —— 这和 React 里的"闭包陷阱"是同一类问题。

写在最后

防抖和节流的原理其实不难,难的是用对场景、写全实现、避开框架里那个"重复创建"的坑。把这篇的几段代码亲手敲一遍,把那张时间线图和"场景大全"表记住,这个知识点就真正是你的了。

核心再收一遍:高频事件回调干重活会卡顿,所以要降频;防抖"等结束"、节流"按节奏";防抖记得带 immediate 和 cancel,还有返回 Promise 的变体;节流分时间戳版、定时器版和合体版;改视觉用 rAF 节流;在 React/Vue 里务必让防抖函数只创建一次、卸载时 cancel;加了优化记得验证它真生效。

无论是面试被追问,还是实际开发里遇到页面卡顿,你都能立刻反应过来:这里该降频、该用哪个、该怎么写、坑在哪。这就是把一个知识点真正吃透的价值。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

CSS 容器查询(Container Queries)完全指南:2026 响应式布局新标准 + 实战案例

2026-5-14 16:04:43

技术教程

Git 救命指南:12 个迟早会用到的命令(改错 / 误删 / 丢代码全场景修复)

2026-5-14 16:04:43

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索