在前端开发中,用户体验至关重要。然而,许多高频触发的用户交互——例如快速输入搜索关键词、调整浏览器窗口大小、或者快速滚动页面——往往会产生大量的事件。如果我们直接将复杂的任务(如 AJAX 请求、DOM 操作)绑定到这些事件上,极易导致页面卡顿、服务器压力过大,甚至浏览器崩溃。
为了解决这一问题,防抖(Debounce) 和 节流(Throttle) 应运而生。它们是利用高阶函数和闭包实现的两种经典性能优化方案。
本文将深入剖析这两个概念的原理、实现代码以及它们的应用场景。
问题的根源:无节制的事件触发
让我们先看一个常见的场景:类似百度或 Google 的“搜索建议”功能。每当用户输入一个字符,前端就需要向服务器发送请求。
如果采用最原始的写法:
// 普通函数,模拟请求
function ajax(content) {
console.log('ajax request', content);
}
const inputa = document.getElementById("undebounce");
// 频繁触发 + 复杂任务
inputa.addEventListener('keyup', function(e) {
ajax(e.target.value);
})
后果分析:
如果用户快速输入 "JavaScript",keyup 事件可能会触发 10 次,这意味着发送了 10 次网络请求。
- 太快了: 请求开销巨大,浪费服务器资源。
- 太慢了: 浏览器忙于处理过多的请求,导致用户体验变差。
我们需要一种机制来控制执行的频率。
一、防抖(Debounce)
1. 核心概念
防抖的核心思想是: “等动作停止后再执行” 。
在事件被频繁触发时,防抖机制保证函数只在最后一次触发后的指定等待时间结束时执行。如果在这段等待时间内事件再次被触发,则重新计时。
生活中的类比:
这就好比坐电梯。电梯门设置了 5 秒的关闭倒计时。如果有人在第 3 秒冲进电梯,电梯会重新开始 5 秒倒计时。只有当连续 5 秒没有人进入时,电梯才会关门运行。
2. 代码实现
防抖的实现依赖于定时器和闭包。以下是一个经典的防抖函数实现:
// 高阶函数,参数或返回值是函数的函数
function debounce(fn, delay) {
var id; // 定时器ID,作为自由变量保存在闭包中
return function(...args) {
// 关键逻辑:如果定时器存在(说明之前触发过且未执行),清除它!
if(id) clearTimeout(id);
// 开启新的定时器,重新倒计时
id = setTimeout(() => {
fn.apply(this, args);
}, delay);
}
}
代码解析:
- 闭包(Closure): 变量
id被保存在闭包环境中,因此它可以跨多次函数调用“记住”上一次的定时器状态。 - 重新计时:
clearTimeout(id)是防抖的灵魂。每次触发事件,都必须清除之前的定时器,防止旧的任务执行。 - 最终执行: 只有当用户停止触发的时间超过
delay时,fn才会真正被执行。
3. 应用场景
- 搜索建议(Search Suggest): 用户不断输入值时,使用防抖来节约请求资源。我们只关心用户输完后的内容,不关心中间过程(如输入 "Java" 时,不关心 "J", "Ja" 的请求)。
- 表单验证: 避免每输入一个字就报错,而是等待用户输入完毕后再校验。
二、节流(Throttle)
1. 核心概念
节流的核心思想是: “按固定频率执行” 。
在事件被频繁触发时,节流机制保证函数在指定的时间间隔内最多只执行一次。无论触发频率多高,它都严格按照自己的节奏执行。
生活中的类比:
文档中提到的 FPS 游戏射速 是一个绝佳的例子。即使你一直按着鼠标射击(高频触发),枪支也只会按照规定的射速(例如每 0.5 秒一发)射出子弹。你无法突破这个物理限制。
2. 代码实现
节流通常结合时间戳或定时器来实现。以下是一个结合了时间判断的健壮实现:
function throttle(fn, delay) {
let last, deferTimer; // 上次执行时间,定时器ID
return function(...args) {
// 获取当前时间(毫秒数)
let now = + new Date();
// 判断距离上次执行是否已经超过了 delay 时间
if(last && now - last < delay) {
// 如果还没到时间,清除之前的延时操作
clearTimeout(deferTimer);
// 设立一个新的延时器,确保最后一次操作也能被执行
deferTimer = setTimeout(() => {
last = now;
fn.apply(this, args);
}, delay);
} else {
// 如果是第一次执行,或者时间间隔已超过 delay,立即执行
clearTimeout(deferTimer);
last = now;
fn.apply(this, args);
}
}
}
代码解析:
- 状态记录: 变量
last记录了上一次函数执行的时间戳。 - 频率控制:
now - last < delay用于检查是否处于“冷却期”。如果是,则阻止立即执行。 - 兜底逻辑:
deferTimer的存在是为了保证“拖尾”执行,即当用户停止动作时,最后一次状态也能被捕获并执行。
3. 应用场景
- 滚动加载(Infinite Scroll): 用户不断滚动页面时,使用节流来节约请求资源。例如每隔 500ms 检查一次滚动位置,判断是否需要加载更多内容。
- 页面元素拖拽(Drag & Drop): 限制计算频率,避免由高频的 mousemove 事件引发卡顿。
三、防抖 vs 节流:深度对比
虽然两者的目的都是为了防止某一时间频繁触发,但它们的原理和关注点截然不同。
| 特性 | 防抖 (Debounce) | 节流 (Throttle) |
|---|---|---|
| 执行时机 | 动作停止后执行一次 | 动作持续期间,按固定频率执行 |
| 关注点 | 关注结果(你输完了吗?) | 关注过程(别太快,慢慢来) |
| 关键逻辑 | 清除旧定时器,开启新定时器 | 检查距离上次执行是否已过指定间隔 |
| 典型场景 | 输入框搜索、表单验证 | 页面滚动、拖拽元素、点击按钮 |
总结记忆法:
- setTimeout (防抖) 是“延时器”:等一会,只做一次。
- setInterval (节流) 是“定时器”:每隔一会,一直做。
结语
防抖和节流是前端面试的高频考点,也是实际开发中优化性能的利器。通过合理利用闭包保存状态(定时器 ID 或时间戳),我们可以有效地控制函数的执行频率,在保证功能的前提下极大提升用户体验。