目录索引
一、前言
在前端开发中会遇到一些频繁的事件触发,比如:
- 搜索框的输入操作
input
- 浏览器的滚动 监听操作
scroll
- 浏览器窗口的 缩放操作
resize
- 鼠标经过操作监听
mouseover
- …
上面的事件触发频率很高,假如在开发中我们监听这些事件的触发去执行一些操作时,比如发送请求去服务器拿数据,那就会给服务器造成压力,那我们如何解决这种现象呢?
二、频繁触发的栗子
我们先看一个频繁触发的代码栗子:
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="IE=edge, chrome=1">
<title>debounce</title>
<style>
#container{
width: 100%; height: 200px; line-height: 200px; text-align: center; color: #fff; background-color: #444; font-size: 30px;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="debounce.js"></script>
</body>
</html>
debounce.js
文件的代码如下:
var count = 1;
var container = document.getElementById('container');
function getUserAction() {
container.innerHTML = count++;
};
container.onmousemove = getUserAction;
我们来看看效果:
从左边滑到右边就触发了 165 次 getUserAction
函数!
因为这个例子很简单,所以浏览器完全反应的过来,可是如果是复杂的回调函数或是 ajax
请求呢?假设 1 秒触发了 60 次,每个回调就必须在 1000 / 60 = 16.67ms 内完成,否则就会有卡顿出现。
因此我们前端上可以做一些处理,比如在规定时间内用户不在触发操作时,停止操作后的时间超过设定时间后就再去触发请求服务器的相关逻辑处理,也就是我们常说的【防抖】去处理
三、手写防抖函数
▶ 我们一步步拆解【防抖函数】怎么设计,这样才知道每一步做了什么为了什么,然后达到什么效果,我相信你认真读完的话,也是可以手写出来的!
第一版 ★ 开门见山
防抖的原理:你尽管触发事件,但是我一定只在你最后一次事件触发后的 n 秒才执行,如果你在一个事件触发的 n 秒内又触发了这个事件,那我就以新的事件的时间为准,n 秒后才执行,总之,就是要等你触发完事件 n 秒内不再触发事件,我才执行
【思路】1、首先监听的
onmousemove
事件最后是要赋值给一个函数的,那我们的防抖函数返回值就必须是一个函数Function,然后返回的函数中可以执行我们传入的函数func
2、其次根据防抖的原理,我们可以想到用定时器去实现,对于频繁的事件触发,我们需要让每一次触发时将我们的定时器清除,然后再去设定一个定时器,这样只有事件不再触发时,就会按我们设定的时间wait
去执行我们要执行的函数func
,而如果在wait
时间内再次触发就用clearTimeout
清楚掉的逻辑来设计第一版代码
// 第一版
/**
* @param {Function} func // 我们在监听事件触发时要执行的函数逻辑
* @param {Number} wait // 规定的时间,就是我们当用户不再触发监听事件时需要等待多少时间再去触发func
* @returns {Function} 防抖函数debounce返回值是一个新函数
*/
function debounce(func, wait) {
let timeout = null
function dbFn() {
clearTimeout(timeout)
timeout = setTimeout(function(){
func()
}, wait)
}
return dbFn
}
写完后我们在监听函数中使用这个防抖函数:
container.onmousemove = debounce(getUserAction, 1000);
现在随你怎么移动都不会触发,只有你不再移动时,等待1000ms后我才执行传入的函数。来看看使用效果:
我们可以看到顿时就从 165 次降低成了 1 次! 棒棒哒,我们接着完善它。
第二版 ★ this 指向问题
如果我们在 getUserAction
函数中 console.log(this)
,在不使用 debounce
函数的时候,this
的值为当前的元素节点:
<div id="container"></div>
但是如果使用我们的 debounce
函数,this
就会指向 window
对象!
所以我们需要将 this
指向正确的对象。
我们修改下代码:
【思路】根据函数作用域,我们知道了
setTimeout
的this
指向是window
对象,而我们返回的函数dbFn
中的this
才是我们想要的,因此我们先用一个变量去保存这个this
值,然后通过apply
函数的第一个参数将这个this -> context
传入,达到修改func
函数的指向问题
// 第二版
function debounce(func, wait) {
let timeout = null
function dbFn() {
let context = this
clearTimeout(timeout)
timeout = setTimeout(function(){
func.apply(context)
}, wait)
}
return dbFn
}
此时再输出的话,可以发现 this
已经可以正确指向了。让我们看下个问题~
第三版 ★ event 事件对象接收
JavaScript
在事件处理函数中会提供事件对象 event
,我们修改下 getUserAction
函数:
function getUserAction(event) {
console.log(event);
container.innerHTML = count++;
};
如果我们不使用 debouce
函数,这里会打印 MouseEvent
对象,如图所示:
但是在我们实现的 debounce
函数中,却只会打印 undefined
所以我们再修改一下代码:
【思路】我们可以知道监听事件在触发时会给事件处理函数传入的
event
事件对象,那我们在返回的新函数dbFn
中也接收一下,并使用ES6的剩余参数符...args
拿到一个参数数组集合,然后在传给apply
函数的第二个参数,它要求的传入的是一个数组,那刚好用args
这个参数数组集合即可
// 第三版
function debounce(func, wait) {
let timeout = null
function dbFn(...args) {
let context = this
clearTimeout(timeout)
timeout = setTimeout(function(){
func.apply(context, args)
}, wait)
}
return dbFn
}
此时我们再打印一下使用 debounce
函数时 event
的值,就不再是 undefined了
!
到此为止,我们已经修复了两个小问题
- this 指向
- event 对象
1、到这一步此时的【防抖函数】基本上在开发中可以直接使用了,达到防抖的目的是没有任何问题的
2、下面的拓展就是按实际开发中是否要用到再自行补充啦
第四版 ★ 立刻执行开关
这个时候,代码已经很是完善了,我们接下来再来思考一个新的需求:
我不希望非要等到事件停止触发后才执行,我希望第一次立刻执行函数,然后等到停止触发 n 秒后,再继续我们定时器触发的逻辑
【思路】因为是拓展补充,我们加个
immediate
参数来设定我们防抖函数是否需要立刻执行,默认不传就为false
就是不需要立即执行,传入true
就需要开启这个立即执行的功能;首先我们需要在dbFn
函数中定一个标识flag
,这个标识记录函数是否被立即执行过,然后根据这个变量去完成我们的需求
// 第四版
function debounce(func, wait, immediate = false) {
let timeout = null
let flag = false // 标志func函数是否立即执行过
function dbFn(...args) {
let context = this
clearTimeout(timeout)
if(immediate && !flag){
func.apply(context, args)
flag = true // 执行过func后要将它设为true状态
} else {
timeout = setTimeout(function(){
func.apply(context, args)
// 为什么要在设置为false,下面有解析哦~
flag = false
}, wait)
}
}
return dbFn
}
此时再来看看使用效果:
!!细心的小伙伴会发现,1是鼠标进入后立即执行的,2是根据等待时间后执行的,此时我们再输出2后在次移动鼠标应该要再次立即执行才对,不能还是让我们去等待执行吧,所以我们只需要修改上面的代码在 setTimeout
定时器执行完后,再将立即执行的 flag
标识设置为 false
,这样我们就可以实现通过定时器输出2后,再次移动鼠标就会立即输出3了
就一小段赋值代码,我就不再重写一个代码片段了,我在上面的代码直接加上
【问题】有小伙伴就会问,为什么要重新定义一个 flag
去做是否立即执行的开关逻辑,这里直接用传入进来的 immediate
不就好了吗,同样可以达到效果,为何要多此一举?
【回答】ECMA规范中有说到,尽量不要修改传给函数的形参值,因为这写值是调用函数传进来的,如果直接修改就违反了单向传递数据流的设计了;如果因为内部的修改导致外部变量变化,说不定还会出现BUG,所以最好函数内定义自己的变量去维护里面的逻辑
第五版 ★ 增加取消操作
假如有一个更细节的优化,如果鼠标经过后停止的那段等待 wait
时间里,我们想中断这次定时器将要做的操作,不让他执行 func
;
现实开发中也是有这种细节优化的,比如搜索框的联想面板,一般用的就是防抖的逻辑,那是不是有这么一种情况,用户输完要搜索的关键字后,立即退出搜索界面,此时还没有触发逻辑去请求服务器端的代码,那我们是不是可以在他退出的时候直接去清楚这个定时器,那这个请求就不会执行,毕竟请求了他也看不到,都离开了界面,那干脆不让它执行
为了这个需求,我们写最后一版的代码:
【思路】这个就很简单了,我们在返回的
dbFn
在新增一个方法,这个方法就是操作直接清空定时器。因为我们的逻辑即将会在定时器触发的,所以clearTimeout
就会中止这次的操作
// 第五版
function debounce(func, wait, immediate = false) {
let timeout = null
let flag = false // 标志func函数是否立即执行过
function dbFn(...args) {
let context = this
clearTimeout(timeout)
if(immediate && !flag){
func.apply(context, args)
flag = true // 执行过func后要将它设为true状态
} else {
timeout = setTimeout(function(){
func.apply(context, args)
flag = false
}, wait)
}
}
dbFn.cancel = function() {
clearTimeout(timeout)
timeout = null
flag = false
}
return dbFn
}
那么该如何使用这个 cancel
函数呢?
为了更好的展示测试结果,我们将原本等待的1000ms 改成 10000ms,然后再加一个按钮来触发这个取消操作:
var count = 1;
var container = document.getElementById('container');
function getUserAction(e) {
container.innerHTML = count++;
};
var setUseAction = debounce(getUserAction, 10000, true);
container.onmousemove = setUseAction;
document.getElementById("button").addEventListener('click', function(){
setUseAction.cancel();
})
演示效果如下:
四、结语
▶ 通过上面五个版本的迭代,我们成功手写了一个 防抖 debounce
函数,恭喜,撒花!
看完后是不是豁然开朗,该有的功能也有了,小伙伴们可以根据上面提到的思路去自己手写一个来加深一下印象噢~
这里贴出一下【防抖和节流】的应用场景,以后遇到需要用到地方时,使用我们手写的防抖函数,可以给我们的产品性能带来很大的提升!
跳转节流链接 ↓