- 防抖: 连续触发某个事件的时候,等待固定时间才处理响应函数
- 频繁点击按钮, 触发事件
- 输入框频繁输入内容, 进行搜索
- 节流: 固定时间内,只会触发一次操作
比如打飞机, 无论用户按下多少次空格, 一秒之内只会发送一次子弹
认识防抖和节流函数
防抖和节流的概念其实最早并不是出现在软件工程中,防抖是出现在电子元件中,节流出现在流体流动中。而JavaScript是事件驱动的,大量的操作会触发事件,加入到事件队列中处理。而对于某些频繁的事件处理会造成性能的损耗,我们就可以通过防抖和节流来限制事件频繁的发生。
防抖和节流函数目前已经是前端实际开发中两个非常重要的函数,也是面试经常被问到的面试题。但是很多前端开发者面对这两个功能,有点摸不着头脑:
- 某些开发者根本无法区分防抖和节流有什么区别(面试经常会被问到);
- 某些开发者可以区分,但是不知道如何应用;
- 某些开发者会通过一些第三方库来使用,但是不知道内部原理,更不会编写;
接下来我们会一起来学习防抖和节流函数,我们不仅仅要区分清楚防抖和节流两者的区别,也要明白在实际工作中哪些场景会用到,并且我会带着大家一点点来编写一个自己的防抖和节流的函数,不仅理解原理,也学会自己来编写。
认识防抖debounce函数
我们用一幅图来理解一下它的过程:当事件触发时,相应的函数并不会立即触发,而是会等待一定的时间;当事件密集触发时,函数的触发会被频繁的推迟;只有等待了一段时间也没有事件触发,才会真正的执行响应函数。
防抖的应用场景很多:
- 输入框中频繁的输入内容,搜索或者提交信息;
- 频繁的点击按钮,触发某个事件;
- 监听浏览器滚动事件,完成某些特定操作;
- 用户缩放浏览器的resize事件;
防抖函数的案例
我们都遇到过这样的场景,在某个搜索框中输入自己想要搜索的内容,比如想要搜索一个MacBook,当我输入m时,为了更好的用户体验,通常会出现对应的联想内容,这些联想内容通常是保存在服务器的,所以需要一次网络请求;当继续输入ma时,再次发送网络请求;那么macbook一共需要发送7次网络请求。
这大大损耗我们整个系统的性能,无论是前端的事件处理,还是对于服务器的压力,但是我们需要这么多次的网络请求吗?
不需要,正确的做法应该是在合适的情况下再发送网络请求。
- 比如如果用户快速的输入一个macbook,那么只是发送一次网络请求;
- 比如如果用户是输入一个m想了一会儿,这个时候m确实应该发送一次网络请求;
- 也就是我们应该监听用户在某个时间,比如500ms内,没有再次触发时间时,再发送网络请求;
这就是防抖的操作:只有在某个时间内,没有再次触发某个函数时,才真正的调用这个函数。
认识节流throttle函数
我们用一幅图来理解一下节流的过程,当事件触发时,会执行这个事件的响应函数,如果这个事件会被频繁触发,那么节流函数会按照一定的频率来执行函数,不管在这个中间有多少次触发这个事件,执行函数的频繁总是固定的。
节流的应用场景:
- 监听页面的滚动事件;
- 鼠标移动事件;
- 用户频繁点击按钮操作;
- 游戏中的一些设计;
节流函数的应用场景
很多人都玩过类似于飞机大战的游戏,在飞机大战的游戏中,我们按下空格会发射一个子弹,很多飞机大战的游戏中会有这样的设定,即使按下的频率非常快,子弹也会保持一定的频率来发射,比如1秒钟只能发射一次,即使用户在这1秒钟按下了10次,子弹会保持发射一颗的频率来发射,但是事件是触发了10次的,响应的函数只触发了一次。
生活中的例子:防抖和节流
生活中防抖的例子:
比如说有一天我上完课,我说大家有什么问题来问我,我会等待五分钟的时间。如果在五分钟的时间内,没有同学问我问题,那么我就下课了;在此期间,a同学过来问问题,并且帮他解答,解答完后,我会再次等待五分钟的时间看有没有其他同学问问题;如果我等待超过了5分钟,就点击了下课(才真正执行这个时间)。
生活中节流的例子:
比如说有一天我上完课,我说大家有什么问题来问我,但是在一个5分钟之内,不管有多少同学来问问题,我只会解答一个问题;如果在解答完一个问题后,5分钟之后还没有同学问问题,那么就下课。
案例准备
我们通过一个搜索框来延迟防抖函数的实现过程:监听input的输入,通过打印模拟网络请求,测试发现快速输入一个macbook共发送了7次请求,显示我们需要对它进行防抖操作。
Underscore库的介绍
事实上我们可以通过一些第三方库来实现防抖操作:
lodash
underscore
这里使用underscore,我们可以理解成lodash是underscore的升级版,它更重量级,功能也更多,目前我看到underscore还在维护,但是lodash已经很久没有更新了。
Underscore的官网: underscorejs.org/
Underscore的安装有很多种方式:
- 下载Underscore,本地引入;
- 通过CDN直接引入;
- 通过包管理工具(npm)管理安装;
这里我们直接通过CDN:
<script src="https://cdn.jsdelivr.net/npm/underscore@1.13.1/underscore-umd-min.js"></script>
Underscore实现防抖和节流
自定义防抖函数
我们按照如下思路来实现:
- 防抖基本功能实现:绑定this和传入参数
- 优化一:添加第一次立即执行
- 优化二:添加取消功能
- 优化三:优化返回值
基本实现
function debounce(fn, delay) {
// 1.定义一个定时器, 保存上一次的定时器
let timer = null
// 2.input事件真正执行的函数, 里面有参数
const _debounce = function(...args) {
// 取消上一次的定时器
if (timer) clearTimeout(timer)
// 延迟执行
timer = setTimeout(() => {
// 调用外部传入的函数
fn.apply(this, args)
}, delay)
}
return _debounce
}
使用:
// 定义我们的事件
let count = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
}
inputEl.input = debounce(inputChange ,2000)
注意:添加this和参数之后,防抖函数就能解决实际项目中80%的需求了,一定要记住。
优化一:添加立即执行功能
默认情况下,防抖函数是最后一次事件再延迟一定的时间再执行的,如果我们希望第一次立即执行,可以添加一个参数:
function debounce(fn, delay, immediate = false) {
// 1.定义一个定时器, 保存上一次的定时器
let timer = null
// 是否立即执行过
let isInvoke = false
// 2.真正执行的函数
const _debounce = function(...args) {
// 取消上一次的定时器
if (timer) clearTimeout(timer)
// 判断是否需要立即执行
if (immediate && !isInvoke) {
fn.apply(this, args)
isInvoke = true
} else {
// 延迟执行
timer = setTimeout(() => {
// 外部传入的真正要执行的函数
fn.apply(this, args)
// 重新赋默值, 没有立即执行过
isInvoke = false
}, delay)
}
}
return _debounce
}
使用:
// 定义我们的事件
let count = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
}
// 第三个参数传true
inputEl.input = debounce(inputChange ,2000, true)
优化二:添加取消功能
因为函数也是一个对象,所以我们给返回的_debounce函数再添加一个cancel。
function debounce(fn, delay, immediate = false) {
...
...
// 封装取消功能
_debounce.cancel = function() {
if (timer) clearTimeout(timer)
timer = null
isInvoke = false
}
return _debounce
}
使用:
let counter = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
}
const debounceChange = debounce(inputChange, 3000)
inputEl.oninput = debounceChange
// 取消功能
const cancelBtn = document.querySelector("#cancel")
cancelBtn.onclick = function() {
debounceChange.cancel()
}
优化三:获取返回值
拿到传入的fn的返回值,我们使用两种方法,一种是callBack,一种是Promise
使用callBack比较简单,不做解释。使用Promise就是_debounce函数执行完毕,我们返回Promise。
function debounce(fn, delay, immediate = false, resultCallback) {
// 1.定义一个定时器, 保存上一次的定时器
let timer = null
let isInvoke = false
// 2.真正执行的函数
const _debounce = function(...args) {
return new Promise((resolve, reject) => {
// 取消上一次的定时器
if (timer) clearTimeout(timer)
// 判断是否需要立即执行
if (immediate && !isInvoke) {
const result = fn.apply(this, args)
// 将真正执行函数的返回值通过callBack返回出去
if (resultCallback) resultCallback(result)
resolve(result)
isInvoke = true
} else {
// 延迟执行
timer = setTimeout(() => {
// 外部传入的真正要执行的函数
const result = fn.apply(this, args)
if (resultCallback) resultCallback(result)
resolve(result)
isInvoke = false
timer = null
}, delay)
}
})
}
// 封装取消功能
_debounce.cancel = function() {
if (timer) clearTimeout(timer)
timer = null
isInvoke = false
}
return _debounce
}
使用:
let counter = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
// 返回值
return "aaaaaaaaaaaa"
}
// 方式一: 通过回调函数拿到返回值
const debounceChange = debounce(inputChange, 3000, false, (res) => {
console.log("拿到真正执行函数的返回值:", res)
})
inputEl.oninput = debounceChange
// 方式二: 通过Promise拿到返回值
const tempCallback = (...args) => {
// debounceChange就是_debounce
// _debounce执行的时候才会返回Promise,就相当于我们手动调用_debounce
// 注意:是debounceChange().then,而不是debounceChange.then
debounceChange.apply(inputEl, args).then(res => {
console.log("Promise的返回值结果:", res)
})
}
inputEl.oninput = tempCallback
// 取消功能
const cancelBtn = document.querySelector("#cancel")
cancelBtn.onclick = function() {
debounceChange.cancel()
}
添加返回值这个比较复杂,作用也不大,我们只要记住:绑定this,立即执行,添加取消,就行了。
自定义节流函数
我们按照如下思路来实现:
- 节流函数的基本实现:绑定this和传入参数
- 优化一:第一次可以不执行
- 优化二:节流最后一次也可以执行
- 优化三:优化添加取消功能
- 优化四:优化返回值问题
节流函数的实现逻辑:
基本实现
function throttle(fn, interval) {
// 1.记录上一次的开始时间
let lastTime = 0
// 2.事件触发时, 真正执行的函数
const _throttle = function(...args) {
// 2.1.获取当前事件触发时的时间
const nowTime = new Date().getTime()
// 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
const remainTime = interval - (nowTime - lastTime)
if (remainTime <= 0) {
// 2.3.真正触发函数
fn.apply(this, args)
// 2.4.保留上次触发的时间
lastTime = nowTime
}
}
return _throttle
}
// 默认第一个时间间隔一定会触发,因为第一次是个很大的负值,最后一次事件不触发
使用:
let counter = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
}
inputEl.oninput = throttle(inputChange, 3000)
上面我们实现的效果是绑定this和参数了,并且第一个时间间隔一定会执行,最后一个时间间隔不会执行,如果我们想第一个时间间隔和最后一个时间间隔的执行都可以控制,可以优化如下:
优化一:第一次不触发
function throttle(fn, interval, options = { leading: true, trailing: false }) {
const { leading, trailing } = options
// 1.记录上一次的开始时间
let lastTime = 0
// 2.事件触发时, 真正执行的函数
const _throttle = function(...args) {
// 2.1.获取当前事件触发时的时间
const nowTime = new Date().getTime()
// 让第一次不触发
if (lastTime == 0 && leading === false) lastTime = nowTime
// 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
const remainTime = interval - (nowTime - lastTime)
if (remainTime <= 0) {
// 2.3.真正触发函数
fn.apply(this, args)
// 2.4.保留上次触发的时间
lastTime = nowTime
}
}
return _throttle
}
// 默认第一个时间间隔一定会触发,因为第一次是个很大的负值, 最后一次如果没超过时间间隔是不会触发的
使用:
let counter = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
}
inputEl.oninput = throttle(inputChange, 3000, {leading: false})
优化二:让最后一个时间间隔也执行
其实就是加个定时器:
function throttle(fn, interval, options = { leading: true, trailing: false }) {
// 1.记录上一次的开始时间
const { leading, trailing } = options
let lastTime = 0
let timer = null
// 2.事件触发时, 真正执行的函数
const _throttle = function(...args) {
// 2.1.获取当前事件触发时的时间
const nowTime = new Date().getTime()
if (lastTime == 0 && leading === false) lastTime = nowTime
// 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
const remainTime = interval - (nowTime - lastTime)
if (remainTime <= 0) {
// 已经要执行了,前面添加的定时器就取消掉
if (timer) {
clearTimeout(timer)
timer = null
}
// 2.3.真正触发函数
fn.apply(this, args)
// 2.4.保留上次触发的时间
lastTime = nowTime
// 事件触发的时候,后面没必要添加定时器了,直接return掉
return
}
// 最后只添加一个定时器
if (trailing && !timer) {
timer = setTimeout(() => {
timer = null
// 如果第一次执行,leading为true,这个定时器就相当于下一次循环的第一次,所以肯定会执行,执行完我们把lastTime设置为当前时间
// 如果第一次不执行,直接设置默认的0
lastTime = !leading ? 0: new Date().getTime()
fn.apply(this, args)
}, remainTime)
}
}
return _throttle
}
// 默认第一个时间间隔会触发, 最后一次如果没超过时间间隔是不会触发的
使用:
let counter = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
}
inputEl.oninput = throttle(inputChange, 3000, {leading: true, trailing: true})
优化三:添加取消功能
如果我们设置trail:true, 就是最后也会执行一次,如果我们在执行之前想取消掉,怎么做呢?
function throttle(fn, interval, options = { leading: true, trailing: false }) {
// 1.记录上一次的开始时间
const { leading, trailing } = options
let lastTime = 0
let timer = null
// 2.事件触发时, 真正执行的函数
const _throttle = function(...args) {
// 2.1.获取当前事件触发时的时间
const nowTime = new Date().getTime()
if (!lastTime && !leading) lastTime = nowTime
// 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
const remainTime = interval - (nowTime - lastTime)
if (remainTime <= 0) {
if (timer) {
clearTimeout(timer)
timer = null
}
// 2.3.真正触发函数
fn.apply(this, args)
// 2.4.保留上次触发的时间
lastTime = nowTime
return
}
if (trailing && !timer) {
timer = setTimeout(() => {
timer = null
lastTime = !leading ? 0: new Date().getTime()
fn.apply(this, args)
}, remainTime)
}
}
// 添加取消方法
_throttle.cancel = function() {
if(timer) clearTimeout(timer)
timer = null
lastTime = 0
}
return _throttle
}
使用:
let counter = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
}
const _throttle = throttle(inputChange, 3000, { leading: false, trailing: true,})
inputEl.oninput = _throttle
// 取消功能
const cancelBtn = document.querySelector("#cancel")
cancelBtn.onclick = function() {
_throttle.cancel()
}
优化四:添加返回值
两种方式,一种是回调函数,一种是Promise
function throttle(fn, interval, options = { leading: true, trailing: false }) {
// 1.记录上一次的开始时间
const { leading, trailing, resultCallback } = options
let lastTime = 0
let timer = null
// 2.事件触发时, 真正执行的函数
const _throttle = function(...args) {
return new Promise((resolve, reject) => {
// 2.1.获取当前事件触发时的时间
const nowTime = new Date().getTime()
if (!lastTime && !leading) lastTime = nowTime
// 2.2.使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
const remainTime = interval - (nowTime - lastTime)
if (remainTime <= 0) {
if (timer) {
clearTimeout(timer)
timer = null
}
// 2.3.真正触发函数
const result = fn.apply(this, args)
if (resultCallback) resultCallback(result)
resolve(result)
// 2.4.保留上次触发的时间
lastTime = nowTime
return
}
if (trailing && !timer) {
timer = setTimeout(() => {
timer = null
lastTime = !leading ? 0: new Date().getTime()
const result = fn.apply(this, args)
if (resultCallback) resultCallback(result)
resolve(result)
}, remainTime)
}
})
}
_throttle.cancel = function() {
if(timer) clearTimeout(timer)
timer = null
lastTime = 0
}
return _throttle
}
使用:
const inputEl = document.querySelector("input")
let counter = 0
const inputChange = function(event) {
console.log(`发送了第${++counter}次网络请求`, this, event)
return 11111111111
}
// 这里实际调用的其实是_throttle
const _throttle = throttle(inputChange, 3000, {
leading: false,
trailing: true,
// 1. 通过回调函数拿到返回值(推荐)
resultCallback: function(res) {
console.log("resultCallback:", res)
}
})
inputEl.oninput = _throttle
// 2. 通过Promise拿到返回值(麻烦)
const tempCallback = (...args) => {
// 当inputEl.oninput = tempCallback的时候, 实际调用的是tempCallback,参数是在这里面的
// 但是我们最终要调用_throttle, 所以我们手动调用,并绑定this和参数
_throttle.apply(inputEl, args).then(res => {
console.log("Promise:", res)
})
}
inputEl.oninput = tempCallback
// 取消功能
const cancelBtn = document.querySelector("#cancel")
cancelBtn.onclick = function() {
_throttle.cancel()
}
自定义深拷贝函数
前面我们已经学习了对象相互赋值的一些关系,分别包括:
- 引入的赋值:指向同一个对象,相互之间会影响;
- 对象的浅拷贝:只是浅层的拷贝,内部引入对象时,依然会相互影响(Object.assign()或者展开运算符...);
- 对象的深拷贝:两个对象不再有任何关系,不会相互影响;
前面我们已经可以通过一种方法来实现深拷贝了:JSON.stringify和JSON.parse
,这种深拷贝的方式其实对于函数、Symbol等是没有任何处理的,并且如果存在对象的循环引用,也会报错的,因为不能将循环的结构转成JSON(比如对象里面有个属性指向自己,其实这是可以的,比如window里面就有一个window指向自己,所以我们可以一直window.window.window)
自定义深拷贝函数步骤:
- 自定义深拷贝的基本功能;
- 数组、函数、Set、Map、Symbol的value和key做特殊处理
- 对循环引用的处理;
function isObject(value) {
const valueType = typeof value
return (value !== null) && (valueType === "object" || valueType === "function")
}
function deepClone(originValue, map = new WeakMap()) {
// map放到全局中肯定不行,因为把所有拷贝信息放到一个map中肯定是不行的
// map放到局部也是不行,因为每次递归调用都会创建一个新的map,那我们就拿不到上层的map信息了
// 所以我们把map放到参数中,并且第二次调用deepClone再把这个map传过去
// 判断如果是函数类型, 那么直接使用同一个函数
if (typeof originValue === "function") {
return originValue
}
// 判断是否是一个Set类型
if (originValue instanceof Set) {
return new Set([...originValue])
}
// 判断是否是一个Map类型
if (originValue instanceof Map) {
return new Map([...originValue])
}
// 这里对Set、Map其实是浅层拷贝,开发中其实也足够用了
// 判断如果是Symbol的value, 那么创建一个新的Symbol
if (typeof originValue === "symbol") {
return Symbol(originValue.description)
}
// 判断传入的originValue是否是一个对象类型
if (!isObject(originValue)) {
return originValue
}
// 如果原来已经有值了,那么直接使用原来创建的值
if (map.has(originValue)) {
return map.get(originValue)
}
// 判断传入的对象是数组, 还是对象,因为数组也是对象
const newObject = Array.isArray(originValue) ? []: {}
// 1. 将每个值对应的拷贝的对象先保存起来
map.set(originValue, newObject)
for (const key in originValue) {
newObject[key] = deepClone(originValue[key], map)
}
// 对Symbol作为key进行特殊的处理
const symbolKeys = Object.getOwnPropertySymbols(originValue)
for (const sKey of symbolKeys) {
// const newSKey = Symbol(sKey.description)
newObject[sKey] = deepClone(originValue[sKey], map)
}
return newObject
}
自定义事件总线
Vue2的时候有事件总线,用于数据之间的跨组件传递,Vue3的时候移除了事件总线,因为事件总线不应该包含在Vue框架中的,Vue3中官方推荐我们使用Mitt。当然我们也可以使用自己的事件总线,下面我们就实现一下:
自定义事件总线属于一种观察者模式,其中包括三个角色:
- 发布者(Publisher):发出事件(Event);
- 订阅者(Subscriber):订阅事件(Event),并且会进行响应(Handler);
- 事件总线(EventBus):无论是发布者还是订阅者都是通过事件总线作为中台的;
当然我们可以选择一些第三方的库:
- Vue2默认是带有事件总线的功能;
- Vue3中推荐一些第三方库,比如mitt;
当然我们也可以实现自己的事件总线:
- 事件的监听方法on;
- 事件的发射方法emit;
- 事件的取消监听off;
class HYEventBus {
constructor() {
// 对象的key是事件名称
// 对象的value是事件数组,数组中是对象,对象里面有eventCallback和thisArg
this.eventBus = {}
}
// 监听事件:传入事件名称,回调的函数,绑定的this
on(eventName, eventCallback, thisArg) {
let handlers = this.eventBus[eventName]
if (!handlers) {
handlers = []
this.eventBus[eventName] = handlers
}
handlers.push({
eventCallback,
thisArg
})
}
// 发送事件:传入事件名称和参数
emit(eventName, ...payload) {
const handlers = this.eventBus[eventName]
if (!handlers) return
handlers.forEach(handler => {
// 调用回调函数,并绑定this
handler.eventCallback.apply(handler.thisArg, payload)
})
}
// 销毁事件:传入事件名称和回调函数
off(eventName, eventCallback) {
const handlers = this.eventBus[eventName]
if (!handlers) return
// 因为我们是边遍历数组,边移除数组中的数据,所以最好对数组做一个拷贝
const newHandlers = [...handlers]
for (let i = 0; i < newHandlers.length; i++) {
const handler = newHandlers[i]
if (handler.eventCallback === eventCallback) {
// 因为有[fn1,fn2,fn3,fn2]也就是数组中有相同的函数的情况
// 所以下标我们都以handlers中的为准
// 或者使用filter,然后重新赋值,也可以
const index = handlers.indexOf(handler)
handlers.splice(index, 1)
}
}
}
}
const eventBus = new HYEventBus()
// main.js
eventBus.on("abc", function() {
console.log("监听abc1", this)
}, {name: "why"})
const handleCallback = function() {
console.log("监听abc2", this)
}
eventBus.on("abc", handleCallback, {name: "why"})
// utils.js
eventBus.emit("abc", 123, 321, 456)
// 移除监听
eventBus.off("abc", handleCallback)
eventBus.emit("abc", 123)