JavaScript是事件驱动的,大量的操作触发事件,加入到事件队列中处理。
有一些比较频繁的事件处理就会造成性能损耗,我们就可以通过防抖和节流来限制事件频繁发生,所以防抖和节流也是性能优化的方式之一。
目录
前面说明了防抖和节流是用来限制事件频繁发生的,还不清除防抖和节流的概念,下面先来理解概念。
1.认识防抖debounce函数
我们先用一幅图来理解一下防抖的过程。
蓝色的柱子代表事件触发,而橙色的柱子代表响应函数触发。
上图可以看到无论事件触发得多频繁,最后响应函数都是要等待一段固定的时间后才会触发。
事件触发就好像在哭闹的小孩子闹的次数,只有小孩安静下来一段时间之后才可以给小孩糖吃。如果一直哭闹,就一直推后吃糖的时间。
总结:
- 当事件触发时,相应的函数并不会立即触发,而是会等待一定的时间;
- 当事件频繁触发时,函数的触发会被频繁地推迟;
- 只有等待一段时间没有事件触发,才会真正执行响应函数。
当事件被触发 n 秒后再执行回调,如果在 n 秒内又被触发,则重新计时
1.1.防抖函数的应用场景
防抖函数的应用场景有很多,有时候防抖和节流两种方案在同样的场景都可以实现性能优化,主要看具体的需求。
适合防抖函数的场景:
- 输入框中频繁的输入内容,搜索或者提交信息;
- 频繁的点击按钮,触发某个事件;
- 监听浏览器滚动事件,完成某些特定操作;
- 用户缩放浏览器的resize事件;
输入框使用防抖函数可能是开发时应用比较多的场景,下面举输入框场景的案例让对防抖函数的理解更深。
1.2.防抖函数的案例
我们都可能会遇到这样的场景,在一些购物网站的搜索框输入想要的商品。
在搜索框下面的联想商品是随着网站的加载一起下载下来的吗?
- 不是的,商品的品类太多了,所以联想商品都是根据搜索框的值去服务器请求数据的。
在上面输入JavaScript高级,单单JavaScript就10个单词了,难道需要发送10次网络请求吗?
- 很明显是不现实的,如果这么高频率地发送网络请求,会大大损耗性能。
通过上面的两个问答可以得出,我们应该要在用户输入缓慢或者停下来一段时间再发送网络请求;
比如用户在快速输入JavaScript的时候可以只发送一次网络请求,给用户输入内容的时间,用户在输入的时候没有结果也不会觉得奇怪。
1.3.防抖函数的实现
我们可以先实现简单的防抖函数,后面有其他的需求可以加上去。只要会实现基本的防抖函数就足够应对面试题了。
<input type="text">
<script>
function mydebounce(fn, delay) {
//1.用于记录上一次时间触发的timer
let timer = null
//2.触发事件时执行的函数
const _debounce = () => {
//2.1.如果有再次触发事件,需要取消上一次的事件
if(timer) clearTimeout(timer)
//2.2.延迟去执行对应的fn函数(传入的回调函数)
timer = setTimeout(()=> {
fn()
timer = null // 执行函数之后,将timer重新置为null
}, delay)
}
return _debounce
}
</script>
<script>
const inputEl = document.querySelector("input")
let count = 0
const inputChange = function() {
count++
console.log("发送网络请求",count)
}
//实现防抖
inputEl.oninput = mydebounce(inputChange, 1000)
</script>
我们模拟搜索框发送网络请求的场景。
- 设置一个输入框。
- 获取到这个输入框元素,count记录发送网络请求的次数,在inputChange函数里面模拟发送网络请求,并且发送网络请求次数count自增。
- 我们要实现防抖就需要传入两个参数,一个是实现防抖的函数(fn),一个是延迟时间(delay)。还需要返回一个新的函数(_debounce)。
- 防抖的操作就是将要实现防抖的函数(fn)通过setTimeout定时器设置延迟时间(delay)再执行。
- 需要不断触发fn时,需要将前面设置的定时器取消掉,需要一个变量timer来记录定时器的id来取消对应的定时器。通过clearTimeout取消完前面的定时器后,依然会重新再设置一个定时器的。
至此,最基本的防抖函数就实现了,在输入框输入了内容,过了一段时间才发送了一次网络请求。
最基本的防抖函数便于理解防抖函数的原理,对于初学者来说,上面的理解完已经足够了。但是还有一些细节没有实现。这些优化应用场景比较少,或者说更高阶,可以只了解。
1.3.1.防抖函数的this指向优化
function mydebounce(fn, delay) {
let timer = null
const _debounce = () => {
if(timer) clearTimeout(timer)
timer = setTimeout(()=> {
fn()
timer = null
}, delay)
}
return _debounce
}
//实现防抖
inputEl.oninput = mydebounce(function() {
count++
console.log("发送网络请求", count, this.value)
}, 1000)
上面是自己实现防抖的函数,模拟发送网络请求的函数作为mydebounce的第一个参数(fn),fn在mydebounce函数中是独立调用的。
所以this是指向window的,this.value为undefined,不想this指向window,我们需要自己绑定this。
如果不清楚this的绑定规则,可以先看一下JavaScript中this指向。
_debounce是一个箭头函数,是没有this的,我们将_debounce改为普通函数,并且在fn的调用时通过apply显式绑定this。
function mydebounce(fn, delay) {
let timer = null
const _debounce = function(){
if(timer) clearTimeout(timer)
timer = setTimeout(()=> {
fn.apply(this)
}, delay)
}
return _debounce
}
这样this.value就有值了。
1.3.2.防抖函数的参数优化
有时候我们需要拿到event,但是自己实现的防抖函数没有event这个参数。
其实oninput是将event传递到_debounce的参数了的,所以我们将其他参数(args)一起绑定到fn中。
function mydebounce(fn, delay) {
let timer = null
const _debounce = function(...args) {
if(timer) clearTimeout(timer)
timer = setTimeout(()=> {
fn.apply(this, [args])
timer = null
}, delay)
}
return _debounce
}
//实现防抖
inputEl.oninput = mydebounce(function(event) {
count++
console.log("发送网络请求", count, this.value, event)
}, 1000)
event参数就有值了。
1.3.3.防抖函数的取消操作优化
可以实现防抖函数,也需要实现防抖函数的取消,我们只需要拿到timer,取消掉timer就可以实现防抖函数的取消了。
function mydebounce(fn, delay) {
let timer = null
const _debounce = function(...args) {
if(timer) clearTimeout(timer)
timer = setTimeout(()=> {
fn.apply(this, [args])
timer = null
}, delay)
}
//3.给_debounce绑定一个取消的函数
_debounce.cancel = function() {
if(timer) clearTimeout(timer)
timer = null
}
return _debounce
}
//实现防抖
const debounceFn = mydebounce(function(event) {
count++
console.log("发送网络请求", count, this.value, event)
}, 1000)
inputEl.oninput = debounceFn
给_debounce绑定一个取消的函数,如果有定时器就清除定时器。
在使用这个cancel函数的时候,可以用点击事件来触发取消。
cancelBtn.onclick = function() {
debounceFn.cancel()
}
1.3.4.防抖函数的立即(第一次)执行效果优化
有时候可能会有这种需求,想要用户在输入第一个内容的时候,就直接执行一次函数(比如发送一次网络请求),后面的内容再防抖。
function mydebounce(fn, delay, immediate = false) {
let timer = null
//isInvoke用来记录是否执行过
let isInvoke = false
const _debounce = function(...args){
if(timer) clearTimeout(timer)
//第一次操作不需要延迟
if(immediate && !isInvoke) {
fn.apply(this, [args])
isInvoke = true
return
}
timer = setTimeout(()=> {
fn.apply(this, [args])
timer = null
isInvoke = false //执行函数之后,将isInvoke重新置为false
}, delay)
}
//3.给_debounce绑定一个取消的函数
_debounce.cancel = function() {
if(timer) clearTimeout(timer)
timer = null
isInvoke = false
}
return _debounce
}
我们可以传入一个immediate参数用来判断是否要立即执行第一次函数,如果true,就立即执行第一次函数;false就不执行第一次函数。同时定义一个isInvoke变量来记录是否已经执行过第一次函数了。
在用户设置了immediate为true并且没有执行过第一次函数(即isInvoke为false)的时候,不设置定时器。
其他的函数执行完之后,isInvoke都要重新设置为false。
当设置immediate为true的时候,就实现了这个功能。
inputEl.oninput = mydebounce(function(event) {
count++
console.log("发送网络请求", count, this.value, event)
}, 1000, true)
2.认识节流throttle函数
相同的,我们依然先用一幅图来理解一下节流的过程。
蓝色的柱子代表事件触发,而橙色的柱子代表响应函数触发。
上图可以看到第一次事件触发会触发响应函数,后面的事件触发无论触发多频繁,都需要等等待时间过后才会再次触发一次响应函数。一段时间内只会触发一次响应函数。
事件触发就好像小孩子不断请求吃糖,第一次满足他吃糖的需求,后面再怎么请求,都不给他吃,要等第二天才能吃第二次糖。
总结:
- 当事件触发时,会执行这个事件的响应函数;
- 如果这个事件会被频繁触发,那么节流函数会按照一定的频率来执行函数;
- 不管在这个中间有多少次触发这个事件,执行函数的频繁总是固定的;
在一个单位时间内,触发事件至多只能触发一次响应函数
2.1.节流函数的应用场景
不知道有没有喜欢玩游戏的同学有这样的感受,节流好像是普通攻击的内置cd(冷却)一样,不论你点普通攻击再快,在内置cd时间到之前,你也普攻不出去。
还有一些其他的应用场景,比如:
- 监听页面的滚动事件;
- 鼠标移动事件;
- 用户频繁点击按钮操作;
2.2.节流函数的案例
我们可能玩过这样的游戏:飞机大战。
点击空格就发射出子弹,是不是按得越快发射的子弹越多?
- 如果发射的频率比较慢的时候,确实是按得越快发射的子弹越多。如果超过了一定的频率,按得再快,一段时间内也只会发射一次。
这就是节流的操作:触发了多次事件,在单位时间内,响应函数只会触发一次。
2.3.节流函数的实现
节流函数会比防抖函数难理解一点,我们使用按钮的点击来模拟飞机大战中频繁点击发射子弹的场景。
和防抖函数一样,优先把节流函数的基本实现学习清楚, 再考虑节流函数的功能拓展。
<button>点击</button>
<script>
function mythrottle(fn, interval) {
//1.设置上次触发的时间
let lastTime = 0
//2.触发事件时执行的函数
const _throttle = function() {
//2.1.获取当前时间
const nowTime = new Date().getTime()
console.log(nowTime)
//2.2.获取等待的时间 时间间隔-(当前时间 - 上次触发的时间)
const waitTime = interval - (nowTime - lastTime)
if(waitTime <= 0){
fn()
//2.3.把当前时间赋值给上次响应的时间
lastTime = nowTime
}
}
return _throttle
}
</script>
<script>
const buttonEl = document.querySelector("button")
let count = 0
//实现节流
const throttleFn = function(){
count++
console.log("响应次数", count)
}
buttonEl.onclick = mythrottle(throttleFn, 1000)
</script>
确实是比较难理解,请多点耐心。我们用点击按钮来模拟飞机大战时发射子弹高频触发的场景。点击按钮是事件触发,比如点击许多次发射子弹的按钮,可以触发很多很多次但不响应;事件响应是实际响应,比如发射了子弹就是事件响应了。
- 设置一个按钮。
- 获取按钮的元素,count记录响应的次数,throttleFn函数模拟响应,比如发射子弹。
- 把自己实现节流的函数命名为mythrottle,返回一个新的函数_throttle可供按钮点击时触发。想要实现基本的节流函数需要传入两个参数,一个是要实现节流的函数(fn),一个是事件触发时间间隔(interval)。
- 我们设置一个变量上次事件响应时间(lastTime),在没有运行过之前,默认为0。获取当前时间,就是事件触发时的时间(nowTime)。事件响应时间间隔为(interval)。事件响应还需要等待的时间为(waitTime)。还需要等待的时间(waitTime)有点难理解,在下面分情况讨论。
- 当还需要等待的时间(waitTime)小于等于0的时候就可以触发响应事件了,根据waitTime的公式说明上次响应时间已经等于或者大于interval的时间了。再把当前事件触发时的时间(nowTime)赋值给上次事件响应时间(lastTime)。
上面的公式waitTime = interval - (nowTime - lastTime)太难理解了,我们分成三种情况讨论。
第一种情况是第一次触发:
- 上一次事件响应时间(lastTime)设置为0,当前事件的触发时间(nowTime)是通过new Date().getTime()获取的,这个值很大。而(interval)一般都不可能设置超过(nowTime)的值。
- 所以根据公式waitTime = interval - (nowTime - lastTime)还需要等待的时间(waitTime)是不会超过0的,所以可以执行fn(),第一次触发是可以直接响应的。
- 再把当前事件触发时的时间(nowTime)赋值给上次事件响应时间(lastTime),用来记录上次事件响应时间(lastTime)。
- (假设interval为1000ms,nowTime为1661345294068,还需等待的时间waitTIme为负数,上面的单位皆为ms)
第二种情况是未超过时间间隔触发:
- 上一次触发事件响应,响应完成之后都会把事件的响应时间由nowTime赋值记录到lastTime。
- 当两次事件触发时间间隔小于interval的时候,不会响应事件。根据公式waitTime = interval - (nowTime - lastTime),需要等待的时间超过0,所以不会执行fn()。
- (假设interval为1000ms,还需等待的时间waitTime为正数,上面的单位皆为ms)
第三种情况是超过时间间隔触发:
- 事件响应会记录上一次响应事件的时间(lastTime)。
- 当两次事件触发时间间隔大于等于interval的时候,会响应事件。根据公式waitTime = interval - (nowTime - lastTime),两次事件触发时间间隔大于等于interval就是nowTime - lastTime大于interval,所以waitTime小于等于0,可以响应事件,执行fn()。
- (假设interval为1000ms,还需等待的时间waitTime为负数,上面的单位皆为ms)
上面的节流函数的解释我觉得已经很详细了,先把上面的理解了再学习怎么优化。
2.3.1.节流函数的this指向优化
function mythrottle(fn, interval) {
let lastTime = 0
const _throttle = function() {
const nowTime = new Date().getTime()
const waitTime = interval - (nowTime - lastTime)
if(waitTime <= 0){
fn()
lastTime = nowTime
}
}
return _throttle
}
这是上面自己实现节流函数的代码,_throttle函数已经是普通函数了,有自己的this。所以在执行fn()函数的时候用apply显式绑定this。
function mythrottle(fn, interval) {
let lastTime = 0
const _throttle = function() {
const nowTime = new Date().getTime()
const waitTime = interval - (nowTime - lastTime)
if(waitTime <= 0){
fn.apply(this)
lastTime = nowTime
}
}
return _throttle
}
显式绑定了this,this就有值了。
//实现节流
const throttleFn = function(){
count++
console.log("响应次数", count, this)
}
buttonEl.onclick = mythrottle(throttleFn, 1000)
2.3.2.节流函数的参数优化
有时候我们需要拿到event,但是自己实现的节流函数没有event这个参数。
其实onclick是将event传递到_throttle的参数了的,所以我们将其他参数(args)一起绑定到fn中。
function mythrottle(fn, interval) {
//1.上次触发的时间
let lastTime = 0
//2.触发事件时执行的函数
const _throttle = function(...args) {
//2.1.获取当前时间
const nowTime = new Date().getTime()
console.log(nowTime)
//2.2.获取等待的时间 时间间隔-(当前时间 - 开始时间)
const waitTime = interval - (nowTime - lastTime)
if(waitTime <= 0){
fn.apply(this, [args])
//2.3.把当前时间赋值给开始时间
lastTime = nowTime
}
}
return _throttle
}
//实现节流
const throttleFn = function(event){
count++
console.log("响应次数", count, this, event)
}
buttonEl.onclick = mythrottle(throttleFn, 1000)
这样就可以拿到event了,当然还有其他的参数。
3.underscore第三方库实现防抖和节流
我们可以使用第三方库来实现防抖和节流操作;underscore还在维护而且功能也比较完善。
Underscore的官网:https://underscorejs.org/
3.1.underscore的引入
Underscore有几种安装方式:
1.下载Underscore,本地引入;
右键在新的标签页打开第一个链接。复制里面的内容。
创建一个js文件,把代码粘贴进去。
<script src="./防抖和节流.js"></script>
2.通过CDN直接引入;
<script src="https://cdn.jsdelivr.net/npm/underscore@latest/underscore-umd-min.js"></script>
3.通过包管理工具(npm)管理安装 ;
npm install underscore
3.2.underscore的使用
我们编写一个案例来模拟一下搜索框的场景怎么使用防抖和节流函数。
防抖函数的使用
<input type="text">
<script src="https://cdn.jsdelivr.net/npm/underscore@latest/underscore-umd-min.js">
</script>
<script>
//获取input元素
const inputEl = document.querySelector("input")
//记录网络请求的次数
let count = 0
const inputChange = function() {
count++
//模拟网络请求
console.log("发送网络请求",count)
}
//实现防抖
inputEl.oninput = _.debounce(inputChange, 1000)
</script>
_.debounce(要实现防抖的函数,延迟间隔ms)
在输入完成之后,等待了1秒才发送了网络请求,在实际开发中时间可以短一点,比如300ms就差不多了。
节流函数的使用
<input type="text">
<script src="https://cdn.jsdelivr.net/npm/underscore@latest/underscore-umd-min.js">
</script>
<script>
//获取input元素
const inputEl = document.querySelector("input")
//记录网络请求的次数
let count = 0
const inputChange = function() {
count++
//模拟网络请求
console.log("发送网络请求",count)
}
//实现节流
inputEl.oninput = _.throttle(inputChange, 1000)
</script>
_.throttle(要实现节流的函数,间隔ms)
输入第一个a后,发送了第一次网络请求,后面的1秒内不论输入了几个a,还是没有发送网络请求,等到1秒时间到的时候才发送了第二次网络请求。