在用户交互日益复杂的今天,每一毫秒的性能都关乎体验。你是否遇到过输入框频繁请求接口导致服务器压力过大?是否见过滚动加载时页面卡顿?本文将从一个真实的代码示例出发,逐行解析 JavaScript 中两大性能优化利器------防抖(Debounce)与节流(Throttle),彻底搞懂它们的区别、应用场景及手写实现。
📌 目录
- 为什么需要防抖与节流
- 防抖与节流------一张图看懂区别
- 完整案例预览:三个输入框的对比实验
- [逐行解剖 HTML 结构](#逐行解剖 HTML 结构 "#4-%E9%80%90%E8%A1%8C%E8%A7%A3%E5%89%96-html-%E7%BB%93%E6%9E%84")
- [核心 JavaScript 代码全解析](#核心 JavaScript 代码全解析 "#5-%E6%A0%B8%E5%BF%83-javascript-%E4%BB%A3%E7%A0%81%E5%85%A8%E8%A7%A3%E6%9E%90")
- 5.1 模拟请求函数
ajax - 5.2 防抖函数
debouce(含闭包与高阶函数详解) - 5.3 节流函数
throttle(含时间差 + 尾调用实现)
- 5.1 模拟请求函数
- 事件绑定与三种模式效果详解
- 隐蔽的陷阱:参数传递问题与改进方案
- 知识扩展:防抖与节流的进阶用法
- 总结与最佳实践
- 完整项目链接:gitee.com/hong-strong...
1. 为什么需要防抖与节流
在前端开发中,许多事件(如 keyup、scroll、resize、mousemove)会被高频触发。如果每次触发都直接执行复杂的回调(例如 AJAX 请求、DOM 操作、复杂计算),会导致:
- 性能浪费:短时间内发出大量请求,占用网络带宽和服务器资源。
- 用户体验下降:页面可能出现卡顿、延迟甚至无响应。
- 逻辑错误:某些场景下只需要最终状态(如搜索建议只需要最后一次输入的结果),中间过程是无意义的。
执行太密集 + 任务比较复杂 → 需要优化
例如百度搜索建议、代码编辑器的自动补全,请求太频繁开销大,太慢体验差。
于是防抖和节流应运而生。它们都是限制函数执行频率的技术,但原理截然不同。
2. 防抖与节流------一张图看懂区别
| 防抖 (Debounce) | 节流 (Throttle) | |
|---|---|---|
| 核心 | 在一定时间内,只执行最后一次 | 每隔一定时间,保证执行一次 |
| 类比 | 坐电梯:有人进来就重新计时,直到没人进才关门 | 射速限制:扣下扳机不管多快,子弹固定间隔射出 |
| 场景 | 搜索输入框、表单验证、窗口resize后重绘 | 滚动加载、鼠标移动、游戏射击、拖拽缩放 |
| 实现 | 每次触发重置定时器 | 通过时间戳比较或定时器队列 |
总结:防抖是某段时间内只执行一次,节流是间隔时间执行。
为了让你直观感受,接下来我们将解剖一段完整的可直接运行的 HTML/JS 代码。
3. 完整案例预览:三个输入框的对比实验
以下是 核心效果:
- 第一个输入框 :原始模式,每次
keyup都会调用ajax。 - 第二个输入框:应用防抖(延迟 500ms),只有停止输入后 500ms 才执行。
- 第三个输入框:应用节流(间隔 500ms),输入过程中每 500ms 执行一次。
三个输入框共享同一个 ajax 函数(控制台输出)。你可以亲手运行这段代码,体验差异。
4. 逐行解剖 HTML 结构
我们先看 HTML 部分,确保每一行代码都不遗漏。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>防抖节流</title>
</head>
<body>
<div>
<input type="text" id="undebouce" />
<br>
<input type="text" id="debounce" />
<br>
<input type="text" id="throttle" />
</div>
<script> ... </script>
</body>
</html>
<!DOCTYPE html>:声明 HTML5 文档类型。<html lang="en">:根元素,语言英文(中文可改,但不影响功能)。<head>中的 meta 确保移动端适配与字符集。<body>里只有一个<div>包裹了三个<input>元素,每个输入框都有唯一的id:undebouce:无优化的对照输入框。debounce:应用防抖的输入框。throttle:应用节流的输入框。
- 两个
<br>标签:简单换行,让三个输入框上下排列。 <script>标签内嵌所有 JavaScript 逻辑 ------ 接下来我们逐句解剖。
5.1 模拟请求函数 ajax
js
function ajax(content) {
console.log('ajax request', content);
}
- 这是一个模拟向后端发送请求的函数,实际开发中可能是
fetch或XMLHttpRequest。 - 参数
content是输入框的值。 - 目前仅打印到控制台,方便观察触发次数与传递的值。
5.2 防抖函数 debouce(含闭包与高阶函数详解)
js
function debouce(fn, delay) {
var id; //自由变量
return function(args) {
if(id) clearTimeout(id);
var that = this;
id = setTimeout(function(){
fn.call(that, args)
}, delay);
}
}
逐行解读:
function debouce(fn, delay):接受一个需要优化的函数fn和延迟时间delay(毫秒)。这是一个高阶函数(参数或返回值为函数)。var id;:在闭包中保存定时器 ID。由于闭包的特性,每次调用debouce返回的新函数都会持久引用 这个id变量,实现"记忆"效果。return function(args) { ... }:返回一个新函数,该函数将在事件触发时被调用。if(id) clearTimeout(id);:如果已经存在定时器,则清除它。这是防抖的核心:不断重新计时。var that = this;:保存当前上下文(即调用返回函数时的this),因为定时器回调中的this会丢失。id = setTimeout(function(){ ... }, delay);:重新设置一个定时器,延迟delay毫秒后执行。fn.call(that, args):使用call方法调用原始函数fn,保持正确的this指向,并传递参数args。这样原始函数就可以接收到输入框的当前值。
闭包知识点 :这里的
id、that和fn都通过闭包被返回的函数"记住"。每次触发都会访问和修改这些自由变量,从而控制定时器的生命周期。
5.3 节流函数 throttle(含时间差 + 尾调用实现)
js
function throttle(fn, delay) {
let last, deferTimer;
return function () {
let that = this;
let _args = arguments;
let now = + new Date();
if(last && now < last + delay){
clearTimeout(deferTimer);
deferTimer = setTimeout(function(){
last = now;
fn.call(that, _args)
}, delay);
}else{
last = now;
fn.call(that, _args)
}
}
}
逐行拆解:
let last, deferTimer;:last:记录上一次执行的时间戳(毫秒)。deferTimer:用于最后的"尾调用"定时器。
return function () { ... }:返回实际绑定给事件的函数。let that = this;和let _args = arguments;:保存上下文和传入的参数(arguments是类数组对象,包含事件对象或输入值等)。注意这里没有像防抖那样显式声明参数,而是直接使用arguments获取所有参数。let now = + new Date();:+运算符将Date对象转换为毫秒数时间戳,等价于Date.now()。- 条件判断
if(last && now < last + delay):
如果存在上次执行记录,并且当前时刻距离上次执行不足delay毫秒,则表示仍在节流"冷却期"内。- 进入分支后:清除已有的
deferTimer,并重新设置一个定时器,等待剩余的延迟时间结束后执行。这个定时器保证了最后一次触发后的一小段延迟内,最终还会执行一次。 - 定时器内部会更新
last = now(注意这里now是在回调时重新获取的?实际上代码里定时器内的now是闭包引用外部已经计算的now,可能会存在微小偏差,但基本功能正确)并调用fn。
- 进入分支后:清除已有的
else分支:如果没有上次记录,或者已经超过了delay间隔,则立即执行:last = now;更新上次执行时间。fn.call(that, _args)立即调用原函数。
- 这种实现同时使用了 时间戳立即执行 和 定时器末尾执行 ,确保在连续高频触发中,每隔
delay毫秒至少触发一次,且最后一次触发后还会补执行一次。
节流特点:就像"FPS 射击游戏的射速限制",保证一定频率的稳定响应。
6. 事件绑定与三种模式效果详解
js
const inputa = document.getElementById('undebouce');
const inputb = document.getElementById('debounce');
const inputc = document.getElementById('throttle');
let debounceAjax = debouce(ajax, 500);
let throttleAjax = throttle(ajax, 500);
inputc.addEventListener('keyup', function(e){
throttleAjax(e.target.value)
})
inputa.addEventListener('keyup', function(e){
ajax(e.target.value) // 蛮复杂
})
inputb.addEventListener('keyup', function(e){
debounceAjax(e.target.value)
})
逐行解析:
- 通过
document.getElementById获取三个输入框的 DOM 元素。 let debounceAjax = debouce(ajax, 500);:调用防抖函数,传入原始ajax和延迟 500ms,得到一个新的防抖函数。let throttleAjax = throttle(ajax, 500);:同理得到节流函数。- 为
inputc(节流输入框)绑定keyup事件,回调中调用throttleAjax(e.target.value)。这里e.target.value是输入框当前文本。 - 为
inputa绑定原生事件,直接调用ajax------ 不做任何优化,每次按键都会输出。 - 为
inputb绑定事件,调用防抖函数debounceAjax(e.target.value)。
三种模式效果总结:
| 输入框 | 行为 | 适用场景 |
|---|---|---|
无优化 (undebouce) |
每按一个键立刻触发 ajax → 控制台疯狂打印 |
基本不用,仅作对比 |
防抖 (debounce) |
快速输入时无输出,停止输入后 500ms 输出一次最终值 | 搜索建议、实时保存 |
节流 (throttle) |
输入过程中每 500ms 输出一次当前值,停止后还会多输出一次 | 滚动加载、拖拽、游戏 |
7. 隐蔽的陷阱:参数传递问题与改进方案
虽然上述代码演示了防抖/节流的逻辑,但有一个重大缺陷(这也是我们深入讲解每一行代码的意义):
在 throttle 函数中,返回的函数里使用了 let _args = arguments;,然后调用 fn.call(that, _args)。这里 _args 是一个 类数组对象,而非原始函数的预期参数。
假如原始函数 ajax 定义为 function ajax(content) { console.log(content) },那么 fn.call(that, _args) 将把整个 arguments 对象作为第一个参数传给 ajax。控制台打印出来的不再是字符串,而是类似:
arduino
{ '0': 'h', '1': 'e', '2': 'l', '3': 'l', '4': 'o' }
这显然不是我们想要的结果。
正确做法 :使用 apply 或扩展运算符。例如:
js
fn.apply(that, _args); // 将类数组展开为多个参数
或者如果明确知道只有一个参数(如输入值),可以直接写成:
js
fn.call(that, _args[0]);
同样地,防抖函数中使用了 fn.call(that, args),但防抖返回的函数定义时显式声明了参数 args,而外部调用时传入了 e.target.value,所以防抖的参数传递是正确的 。节流函数因为使用了 arguments 但未正确处理展开,导致了 bug。
修正后的节流函数(只改动调用那一行):
js
// 将 fn.call(that, _args) 改为 fn.apply(that, _args)
// 或者 if 内部和 else 内部都改
这个陷阱提醒我们:手写工具函数时,必须充分考虑参数的转发。
8. 防抖与节流的进阶用法
8.1 立即执行版的防抖
有时需要第一次触发立即执行,后续停止后才可再次触发(例如表单提交按钮)。可以添加 immediate 参数。
js
function debounce_immediate(fn, delay, immediate = false) {
let id;
return function(...args) {
if(id) clearTimeout(id);
if(immediate && !id) fn.apply(this, args);
id = setTimeout(() => {
if(!immediate) fn.apply(this, args);
id = null;
}, delay);
}
}
8.2 节流的时间戳版(无尾调用)
如果不需要"最后一次必定执行",可以实现更简洁的节流(立即执行,固定间隔):
js
function throttle_timestamp(fn, delay) {
let last = 0;
return function(...args) {
const now = Date.now();
if(now - last >= delay) {
last = now;
fn.apply(this, args);
}
}
}
8.3 使用 requestAnimationFrame 做节流
对于动画或滚动事件,使用 requestAnimationFrame 更优(约 16.6ms 一次):
js
function throttle_raf(fn) {
let ticking = false;
return function(...args) {
if(!ticking) {
requestAnimationFrame(() => {
fn.apply(this, args);
ticking = false;
});
ticking = true;
}
}
}
8.4 生产环境推荐
直接使用成熟的库,如 Lodash 的 _.debounce 和 _.throttle,它们提供了 leading、trailing、maxWait 等丰富选项,经过全面测试。
9. 总结与最佳实践
通过逐行分析项目,我们不仅理解了防抖和节流的内部机制,还发现并修正了参数传递的问题。回顾关键点:
- 防抖:合并高频操作为一次(最后一次),常用在输入框搜索、窗口调整。
- 节流:以固定频率执行,常用在滚动加载、按钮点击限制。
- 闭包与高阶函数 是实现两者的核心技术。
- 手写时务必注意 this 绑定 和 参数正确透传。
- 实际项目中,优先考虑 Lodash 等稳定实现,自己写需覆盖边缘情况(如取消、立即执行)。
"函数的防抖和节流都是防止某一时间频繁触发,但是原理不一样"