主题
防抖 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:防抖原理(不加高阶函数)
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。
练习
- 给两个输入框分别绑定
input事件:一个不处理,一个使用防抖高阶函数处理;要求防抖回调中能拿到输入框内容和event。 - 实现页面滚动节流加载:时间间隔设为 600ms,要求说明
prev应在什么位置初始化、什么位置更新,并验证滚动时触发频率。 - 改造防抖高阶函数,使其支持“首次立即执行、后续等待停止后再执行”的选项;说明改动点。
- 针对“点赞按钮”和“滚动加载更多”两个场景,分别选择防抖或节流,并写出选择理由和关键时间间隔考虑。
- 给出一个高阶函数版本,故意遗漏
this或arguments的传递;要求定位问题并修正,使原回调语义保持不变。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 防抖和节流对比 | 12:56 |
| 2 | 防抖实现原理 | 15:44 |
| 3 | 节流实现原理 | 06:38 |
| 4 | 防抖函数 | 29:20 |
| 5 | 节流函数 | 10:03 |
小结
- 先写裸防抖/裸节流,再封装高阶函数,理解成本更低。
- 高阶函数的关键不是“多写一层”,而是返回一个保留原回调语义的新函数。
- 定时器标记、
prev等状态必须放在闭包外层,否则跨触发无法共享。 - 节流是否准确,取决于执行后是否及时更新时间基准。
this和事件对象是否保留,是判断高阶函数是否“可用”的重要标准。- 演示时可用较长间隔观察差异,真实业务通常用几百毫秒。
- 选防抖还是节流,应结合业务目标与产品预期,不必只按事件类型机械选择。
- 出现频繁触发引发性能或请求问题时,再引入防抖/节流更合理。