JavaScript 防抖和节流
JS 防抖和节流
防抖和节流 anti-shake and throttling
防抖
防抖:是指在事件触发后的n秒内,该功能(事件)只能执行一次。如果在n秒内再次出发该事件,或者反复出发该事件,将阻止该事件。通常使用 setTimeout
重新计算函数执行时间。
常见的场景包括:
- 文本框智能提示/自动补全(keyup):比如搜索软件的搜索框,输入“google”,会自动关联“google play”、“google play store”、“google chrome”等,如果用户输入太快,每按一个键都去后台搜索一次,则会浪费很多带宽资源。我们希望延迟一会儿,让用户把关键词输入完整之后再去搜索。
- 浏览器窗口调整大小(resize)或鼠标移动(mousemove)等事件:前段会有调整浏览器窗口大小的时候触发某个事件,或者鼠标移动的时候触发某个事件,但这些事件会非常频繁的触发,会导致网页特别的卡,并且我们完全不需要如此频繁的处罚,我们希望延迟几秒后再触发。
- 查询或提交按钮(click):阻止过于频繁的点击查询按钮。
实现方法:使用 setTimeout
延迟事件触发,期间有新的事件触发则重新刷新 delay time。并且需要使用闭包(closure)保存 timeoutID
。
测试没有防抖
用一个按钮来测试是最简单的,其他功能都会增加额外的代码。
测试下面这段代码,打开浏览器开发者工具(F12),查看输入,每点一次按钮都会在控制台输出两段文本。
<input type="button" id="btn" value="console">
<script>
document.querySelector('#btn').addEventListener('click', consoleTimeNow)
function consoleTimeNow() {
console.log('Hi June.')
console.log('Long time no see and i miss you very much.')
console.log(new Date())
}
</script>
假设这个按钮每点一次按钮都是一次 ajax 查询,那么就会造成不断的请求网络。
添加防抖函数
假设学校的选修课申请页面,在姓名这一栏没有输入完整的情况下,会自动模糊查询,用户在没有输入完整的情况下就可以匹配完整的结果。一个学生输入“Trump”之后,自动匹配“Donald John Trump”。
或者搜索引擎的搜索框,用户可能自己也不知道完整的关键字是什么,“智能提示”可以帮助到用户。
实现这些功能我们会给这个文本框注册keyup事件,但这个事件在用户每次按下按键都会触发,用户输入“Trump”就触发了5次,这样频繁的发起请求,会给数据库和网络增加很多压力,实际上用户完全不需要如此频繁的“自动补全”,甚至用户在连续输入的时候,频繁的“智能提示/自动补全”可能会影响到用户原本连贯的输入,如果我们可以延迟一段时间触发事件,只有在用户输入一段文本停顿一下的时候才触发,那就完美了。
所以我们需要实现的是每一次触发事件都延迟触发,在这期间再触发事件则重新计算延迟时间,直到用户停下输入,停止触发事件,不在刷新延迟时间,那在一段延迟之后,才会真正的触发事件。这就是防抖,这种情况非常适合“智能提示“和”自动补全”。
实现方式很简单,使用setTimeout来延迟运行,重复触发则清除上一个 timerout再新建一个,这样就实现了每一次都延时并且重复触发则不断重新计算timeout。
<input type="button" id="btn" value="console">
<script>
document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow))
function consoleTimeNow() {
console.log('Hi June.')
console.log('Long time no see and i miss you very much.')
console.log(new Date())
}
// 防抖函数
function debounce(fn) {
// 在函数外创建一个 timeoutId,这个id会被闭包保存
var timerId
return function () {
if (timerId != null)
clearTimeout(timerId)
timerId = setTimeout(fn, 1000)
}
}
</script>
这是防抖函数最核心的代码,有了思路之后,就是这么简单。
回调函数传参 方法1
添加传参时要注意,debounce 返回的嵌套函数是用来绑定事件的,所以这个嵌套函数不能接受我们传递的实参,前台函数的实参只可能是事件对象。我们要传参,需要从 debounce 函数传入,然后再又 debounce 内部 callback 的时候将函数传入 callback。
所以,如果使用 function 函数嵌套,要注意我们要使用外层debounce 函数的作用域下的 arguments,而不是嵌套函数作用域下的 arguments,为此我们需要在外层函数将 arguments 保存下来,返回函数会通过闭包保存这个值。
不过 ES6 的剪头函数并没有自己的作用域,它直接使用了父级函数的作用域,这样在剪头函数里头,可以直接使用 arguments 对象来读取实参。
<input type="button" id="btn" value="console">
<script>
document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow, 'June'))
function consoleTimeNow(args) {
// 此时需要注意,args 是一个like array,第一个参数是callback function,第二个参数才是 "June"
var name = args[1]
console.log('Hi {0}.'.replace('{0}', name))
console.log('Long time no see and i miss you very much.')
console.log(new Date())
}
// 防抖函数
function debounce(fn) {
var timerId
// 保存当前函数的形参,如果使用剪头函数则不需要在当前作用域保存。
var args = arguments
return function () {
if (timerId != null)
clearTimeout(timerId)
// setTimeout 第一个参数接受一个函数,需要给他一个函数指针
// 此时 args 是外层作用域的 arguments,会被保存在闭包里
// arguments 是一个 like array
timerId = setTimeout(function () {
fn(args)
}, 1000)
}
}
</script>
优化一下代码
<input type="button" id="btn" value="console">
<script>
// 因为现在必须要传2个参数,fn和delay,所以如果想要把参数传入 callback,必须要从第3个参数开始传递
// 如果只有一个参数(callback fn),第二个参数可以省略,因为防抖函数里设置了默认值
document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow, 500, 'June'))
function consoleTimeNow(name) {
// 使用 apply 之后,会把数组扩展开
console.log('Hi {0}.'.replace('{0}', name))
console.log('Long time no see and i miss you very much.')
console.log(new Date())
}
// 防抖函数-之前的版本改进
function debounceES5(fn, delay) {
// 默认 1000 毫秒
delay = delay || 1000
var timerId
// 从第3个参数开始才是 callback function 的传入实参
var args
if (arguments.length > 2) {
args = Array.prototype.slice.call(arguments).splice(2)
}
return function () {
// 我们确定这个变量只会保存 timeoutId,所以简化一下完全不会有问题
if (timerId) clearTimeout(timerId)
var that = this
timerId = setTimeout(function () {
// fn(args)
// 帮助调用对象绑定 this
fn.apply(that, args)
}, delay)
}
}
// 防抖函数-ES6版本
// ES6 可以使用参数默认值 delay = 200
function debounce(fn, delay = 200) {
let timerId = null;
// ES6 的箭头函数没有自己的作用域和this指针,使用的都是父级的,所以不需要闭包保存 arguments 和 this
return () => {
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
// ES6可以使用扩展运算符
fn.apply(this, [...arguments].splice(2));
}, delay);
}
}
</script>
后续的code都在 ES6的基础上改动。
回调函数传参 方法2
第一种方式并不好,有一种更好的解决方案是 debounce 函数不接受 callback function 的参数,而是当调用 debounce 函数的时候,通过 band
函数传递参数。
<input type="button" id="btn" value="console">
<script>
/*| 关键方法在这里,
|*| 使用 bing 来传递 callback function 参数,这样就可以不受 debounceES5 参数的影响
|*| bind 还可以指定 this
|*/
var btn = document.querySelector('#btn')
btn.addEventListener('click', debounce(consoleTimeNow).bind(btn, 'June'))
/*| 也可以用以下方式调用
|*| - 不需要绑定参数
|*| document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow))
|*| - 不需要绑定 this
|*| document.querySelector('#btn').addEventListener('click', debounce(consoleTimeNow).bind(null, 'June'))
|*/
function consoleTimeNow(name) {
console.log('Hi {0}.'.replace('{0}', name))
console.log('Long time no see and i miss you very much.')
console.log(new Date())
}
// 防抖函数-之前的版本改进
function debounceES5(fn, delay) {
delay = delay || 1000
var timerId
return function () {
if (timerId) clearTimeout(timerId)
var that = this
// 和方法1相比,不使用 debounceES5 函数的作用域,使用嵌套函数的作用域,调用时会通过bind绑定参数
var args = arguments
timerId = setTimeout(function () {
fn.apply(that, args)
}, delay)
}
}
// 防抖函数-ES6版本
function debounce(fn, delay = 1000) {
let timerId = null;
// 相比方法1,这里需要改成function函数,因为需要使用这个前台函数的this和arguments
return function () {
if (timerId) clearTimeout(timerId);
// 这样就可以接受bing null,方便调用
let that = this == null ? window : this
// 这里要用箭头函数,使用的是它的父函数的this和arguments
timerId = setTimeout(() => {
fn.apply(that, arguments);
}, delay);
}
}
</script>
立即执行版本
前面的代码是延迟执行,特别适合“自动填充”和“智能提示”。
而现在希望事件被立即执行,但是执行之后一段时间内需要防抖处理,不能被重复点击。需要过一段时间之后才能继续触发事件。这种防抖比较适合“提交”和“查询”按钮的防抖。
立即执行版本最简单的实现方式就是,回调函数立即执行而不是延迟执行,然后使用一个状态变量来确定是否允许执行,之前 setTimeout 那套逻辑用来控制状态变量。
<input type="button" id="btn" value="console">
<script>
document.querySelector('#btn').addEventListener('click', debounceDelay(consoleTimeNow).bind(null, 'June'))
function consoleTimeNow(name) {
console.log('Hi {0}.'.replace('{0}', name))
console.log('Long time no see and i miss you very much.')
console.log(new Date())
}
// 防抖函数-ES5版本
function debounceDelayEs5(fn, delay) {
delay = delay || 1000
var timerId
return function () {
if (timerId) clearTimeout(timerId)
var that = this
var args = arguments
// 先计时再callback,计时需要刷新timerId,所以这里需要保存状态
var allowRun = !timerId
// 原本先是无脑的 create/clear timeout 来实现延迟的,timeoutId只是用来 clear的,并不需要用来判断状态
// 但现在需要通过 timerId 来判断状态,所以执行完成之后必须要 timerId = null
timerId = setTimeout(function () {
timerId = null
}, delay)
if (allowRun) fn.apply(that, args)
}
}
// 防抖函数-ES6版本
function debounceDelay(fn, delay = 1000) {
let timerId = null;
// ES6版本的改动和ES5版本没啥区别
return function () {
if (timerId) clearTimeout(timerId);
let that = this == null ? window : this
var allowRun = !timerId
timerId = setTimeout(() => {
timerId = null
}, delay);
if (allowRun) fn.apply(that, arguments)
}
}
</script>
节流
节流:是指一段时间内只能触发一次。在一段时间内连续触发同一个事件,触发一次之后会阻止后面的事件再次被触发。
使用场景:比如查询按钮,查询可能需要2秒左右时间,避免用户在未出结果前多次点击查询按钮,限制用户2秒内只能查询一次。
实现方式:防抖是使用 setTimeout,但是每次重复触发防抖函数都会重新刷新 timeout。节流其实差不多,也可以使用 setTimeout,但每次触发防抖函数不刷新 timeout,而是阻止再次 callback,直到限制时间结束。
<input type="button" id="btn" value="console">
<script>
document.querySelector('#btn').addEventListener('click', throttle(consoleTimeNow, 2000).bind(null, 'June'))
function consoleTimeNow(name) {
console.log('Hi {0}.'.replace('{0}', name))
console.log('long time no see and i miss you very much.')
console.log(new Date())
}
// Throttle
function throttle(fn, delay = 1000) {
let allowRun = true
return function () {
if (!allowRun) return
setTimeout(() => {
allowRun = true
}, delay);
allowRun = false
fn.apply(this, arguments)
}
}
</script>