一、场景
在前端页面中,用户的点击等操作经常会触发服务端请求。正常点击一次,只会执行一次处理函数:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>高频点击示例</title>
</head>
<body>
<button id="btn">提交</button>
<script>
const btn = document.getElementById('btn')
btn.addEventListener('click', handle)
function handle() {
console.log('提交了')
}
</script>
</body>
</html>
但如果用户在短时间内连续点击,handle 就会连续执行,请求也会被频繁发送。单次请求没有问题,过高的请求频率却可能给服务端带来额外的性能压力。
因此,需要对高频行为进行控制。防抖和节流解决的是同一个问题,但控制执行的方式不同。
二、防抖
防抖是指:在规定时间内没有新的事件触发,才执行处理函数。如果等待期间再次触发事件,就取消上一次计时并重新开始。
在看防抖的实现之前,我们要先理解闭包。闭包可以简单理解为:一个函数能够记住并继续访问它定义时所在作用域中的变量。即使外层函数已经执行结束,只要内部函数仍然被外部引用,它所引用的外层变量就可以继续使用。
未做处理时,连续点击会让 handle 反复执行。怎样才能把多次点击合并,只在用户停止操作后执行一次?
防抖需要在多次点击之间保存上一次的计时器。利用闭包保存 timer,就能让连续点击共享同一个计时器:每次点击先清除旧计时器,再重新计时。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>防抖基础实现</title>
</head>
<body>
<button id="btn">提交</button>
<script>
const btn = document.getElementById('btn')
btn.addEventListener('click', debounce(handle, 1000))
function handle() {
console.log('提交了')
}
function debounce(fn, delay) {
let timer
return function () {
clearTimeout(timer)
timer = setTimeout(() => {
fn()
}, delay)
}
}
</script>
</body>
</html>
在这段代码中,debounce 返回的事件处理函数引用了外层的 timer,两者形成闭包。事件监听器保存着这个返回函数,之后每次点击调用的都是它,访问的也是同一个 timer。
只要点击还在继续,旧计时器就会被清除并重新创建;停止点击 1 秒后,最后一次计时完成,handle 才会执行。
优化解决handle中的 this 指向问题
上面的代码实现了基本防抖。不过,新的问题也出现了:当前版本直接使用 fn() 调用原函数,假如 handle 需要通过 this 操作当前按钮,该怎么办?
handle 直接作为事件处理函数时,this 指向触发点击的按钮;被包装后直接调用 fn(),这个指向就无法保留。要让 handle 继续使用当前按钮,可以通过 call 指定函数执行时的 this:
html
<script>
const btn = document.getElementById('btn')
btn.addEventListener('click', debounce(handle, 1000))
function handle() {
console.log('提交了', this)
}
function debounce(fn, delay) {
let timer
return function () {
clearTimeout(timer)
timer = setTimeout(() => {
fn.call(this)
}, delay)
}
}
</script>
优化解决handle中可能存在的事件参数问题
浏览器触发点击事件时,会把返回的函数作为事件处理函数执行。定时器中的箭头函数沿用外层的 this,再由 fn.call(this) 传给 handle,因此 handle 仍然可以访问当前按钮。
现在 this 已经保留下来了。但还有一个问题:假如 handle 还需要读取点击事件对象 e,当前包装函数并没有接收和传递这个参数,又该怎么办?
让返回的事件处理函数接收 e,执行原函数时再把它传进去:
html
<script>
const btn = document.getElementById('btn')
btn.addEventListener('click', debounce(handle, 1000))
function handle(e) {
console.log('提交了', this, e)
}
function debounce(fn, delay) {
let timer
return function (e) {
clearTimeout(timer)
timer = setTimeout(() => {
fn.call(this, e)
}, delay)
}
}
</script>
浏览器把事件对象交给防抖后的处理函数,fn.call(this, e) 再将当前按钮和事件对象一起交给 handle。至此,防抖函数终于大成了,现在既控制了执行频率,也保留了原事件处理函数需要的信息。
三、节流
防抖会一直等待操作停下来。如果希望事件持续触发时,处理函数仍能按照一定的时间间隔执行,就需要节流。
节流是指:在规定时间内只执行一次。事件可以持续触发,但只有距离上一次执行达到指定间隔,处理函数才会再次执行。
下面通过时间差判断是否放行本次点击:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>节流示例</title>
</head>
<body>
<button id="btn">提交</button>
<script>
const btn = document.getElementById('btn')
btn.addEventListener('click', throttle(handle, 1000))
function handle(e) {
console.log('提交了', this, e)
}
function throttle(fn, delay) {
let preTime = Date.now()
return function (e) {
let curTime = Date.now()
if (curTime - preTime >= delay) {
fn.call(this, e)
preTime = curTime
}
}
}
</script>
</body>
</html>
preTime 保存用于比较的时间基准,curTime 记录本次点击时间。当两次时间的差值达到 1 秒时,handle 才会执行,并把 preTime 更新为本次执行时间;未达到间隔的点击会被忽略。