被问了无数次的函数防抖与函数节流,这次你应该学会了吧
前言
本篇文章内容,或许早已是烂大街的解读文章。不过参加几场面试下来发现,不少伙伴们还是似懂非懂地栽倒在(~面试官~)深意的笑容之下,权当温故知新吧。
文章从防抖、节流的原理说起再结合实际开发的场景,分别逐步实现完整的防抖和节流函数。
函数防抖
- 原理:当持续触发一个事件时,在n秒内,事件没有再次触发,此时才会执行回调;如果n秒内,又触发了事件,就重新计时
- 适用场景:
- search远程搜索框:防止用户不断输入过程中,不断请求资源,n秒内只发送1次,用防抖来节约资源
- 按钮提交场景,比如点赞,表单提交等,防止多次提交
- 监听resize触发时, 不断的调整浏览器窗口大小会不断触发resize,使用防抖可以让其只执行一次
- 辅助理解:在你坐电梯时,当一直有人进电梯(连续触发),电梯门不会关闭,在一定时间间隔内没有人进入(停止连续触发)才会关闭。
下面我们先实现一个简单的防抖函数,请看栗子:
// 简单防抖函数
const debounce = (fn, delay) => {
let timer;
return function () {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.call(context, ...args);
//等同于上一句 fn.apply(context, args)
}, delay);
};
};
// 请求接口方法
const ajax = (e) => {
console.log(`send ajax ${new Date()} ${e.target.value}`);
};
// 绑定监听事件
const noneDebounce = document.getElementsByClassName("none_debounce")[0];
const debounceInput = document.getElementsByClassName("debounce")[0];
noneDebounce.addEventListener("keyup", ajax);
debounceInput.addEventListener("keyup", debounce(ajax, 500));
运行效果如下:
点击这里,试试效果点击demo
可以很清晰的看到,当我们频繁输入时, 不使用节流就会不断的发送数据请求,但是使用节流后,只有当你在指定间隔时间内没有输入,才会执行发送数据请求的函数。
上面有个注意点:
- this指向问题,在定时器中如果使用箭头函数
()=>{fn.call(this, ...args)}
与上面代码效果一样, 原因时箭头函数的this是**「继承父执行上下文里面的this」**
关于防抖函数的疑问:
- 为什么要使用
fn.apply(context, args)
, 而不是直接调用fn(args)
如果我们不使用防抖函数debounce
时, 在ajax
函数中打印this的值为dom
节点:
<input class="debounce" type="text">
在使用debounce函数后,如果我们不使用fn.apply(context, args)
修改this的指向, this就会指向window(ES6下为undefined)
- 为什么要传入arguments参数
我们同样与未使用防抖函数的场景进行对比
const ajax = (e) =>{
console.log(e)
}
- 怎么给ajax函数传参
有的小伙伴就说了, 你的ajax只能接受绑定事件的参数,不是我想要的,我还要传入其他参数,so easy!
const sendAjax = debounce(ajax, 1000)
sendAjax(参数1, 参数2,...)
因为sendAjax 其实就是debounce
中return的函数, 所以你传入什么参数,最后都给了fn
在未使用时,调用ajax函数对打印一个KeyboardEvent
对象
使用debounce
函数时,如果不传入arguments, ajax中的参数打印为undefined,所以我们需要将接收到的参数,传递给fn
函数防抖的理解:
❝
我个人的理解其实和平时上电梯的原理一样:当一直有人进电梯时(连续触发),电梯门不会关闭,在一定时间间隔内没有人进入(停止连续触发)才会关闭。
❞
从上面的例子,对防抖有了初步的认识,但是在实际开发中,需求往往要更加的复杂,比如我们要提交一个表单按钮,为了防止用户多次提交表单,可以使用节流, 但如果使用上面的节流,就会导致用户停止连续点击才会提交,而我们希望让用户点击时,立即提交, 等到n秒后,才可以重新提交。
对上面的代码进行改造,得到立即提交版:
const debounce = (fn, delay, immediate) => {
let timer;
return function () {
const context = this;
const args = arguments;
clearTimeout(timer);
if (immediate) {
let startNow = !timer;
timer = setTimeout(function () {
timer = null;
}, delay);
if (startNow) {
fn.apply(context, args);
}
} else {
timer = setTimeout(function () {
fn.call(context, ...args);
//等同于上一句 fn.apply(context, args)
}, delay);
}
};
};
从上面的代码可以看到,通过immediate
参数判断是否是立刻执行。
timer = setTimeout(function () {
timer = null
}, delay)
立即执行的逻辑中,如果去掉上面这小段代码, 也是立即执行,但是之后就不会再执行提交了,当我们提交失败了怎么办(哭),所以加上上面这段代码,在设定的时间间隔内,将timer
设置为null, 过了设定的时间间隔,可以再次触发提交按钮的立即执行,这才是完整的。
这是一个使用立即提交版本的防抖实现的了一个提交按钮demo
目前我们已经实现了包含非立即执行
和立即执行
功能的防抖函数,感兴趣的小伙伴可以和我一起继续探究一下去,完善防抖函数~
❝
做直播功能时,产品的小伙伴给提出这样一个需求:
直播的小窗口可以拖动, 点击小窗口以及拖动时, 显示关闭小窗口按钮,当拖动结束2s后, 隐藏关闭按钮;当点击关闭按钮时, 关闭小窗口
❞
分析需求, 我们可以使用防抖来实现, 用户连续拖动小窗口过程中, 不执行隐藏关闭按钮,拖动结束后2s才执行隐藏关闭按钮;但是点击关闭按钮后,我们希望可以取消防抖, 所以需要继续完善防抖函数, 使其可以被取消。
「可取消版本」
const debounce = (fn, delay, immediate) => {
let timer, debounced;
// 修改--
debounced = function () {
const context = this;
const args = arguments;
clearTimeout(timer);
if (immediate) {
let startNow = !timer;
timer = setTimeout(function () {
timer = null;
}, delay);
if (startNow) {
fn.apply(context, args);
}
} else {
timer = setTimeout(function () {
fn.call(context, ...args);
}, delay);
}
};
// 新增--
debounced.cancel = function () {
clearTimeout(timer);
timer = null;
};
return debounced;
};
从上面代码可以看到,修改的地方是将return的函数赋值给debounced
对象, 并且给debounced
扩展了一个cancel
方法, 内部执行了清除定时器timer
, 并且将其设置为null; 为什么要将timer设置为null
呢? 由于debounce
内部形成了闭包, 避免造成内存泄露
上面的需求我写了个小demo, 需要的小伙伴可以看看可取消版本demo, 效果如下所示:
这个demo中,在拖拽过程中还可以使用节流,减少页面重新计算位置的次数,在下边学完节流,大家不妨试试
介绍节流原理、区别以及应用
前面学习了防抖,也知道了我们为什么要使用防抖来限制事件触发频率,那我们接下来学习另一种限制的方式节流(throttle)
函数节流
- 原理:当频繁的触发一个事件,每隔一段时间, 只会执行一次事件。
- 适用场景:
- 拖拽场景:固定时间内只执行一次, 防止高频率的的触发位置变动
- 监听滚动事件:实现触底加载更多功能
- 屏幕尺寸变化时, 页面内容随之变动,执行相应的逻辑
- 射击游戏中的mousedown、keydown时间
- 辅助理解:
下面我们就来实现一个简单的节流函数,由于每隔一段时间执行一次,那么就需要计算时间差,我们有两种方式来计算时间差:一种是使用时间戳,另一种是设置定时器
使用时间戳实现
function throttle(func, delay) {
let args;
let lastTime = 0;
return function () {
// 获取当前时间
const currentTime = new Date().getTime();
args = arguments;
if (currentTime - lastTime > delay) {
func.apply(this, args);
lastTime = currentTime;
}
};
}
使用时间搓的方式来实现的思路比较简单,当触发事件时,获取当前时间戳,然后减去之前的时间戳(第一次设置为0), 如果差值大于设置的等待时间, 就执行函数,然后更新上一次执行时间为为当前的时间戳,如果小于设置的等待时间,就不执行。
使用定时器实现
下面我们来看使用定时器实现的方式:与时间戳实现的思路是有差别的, 我们在事件触发时设置一个定时器, 当再次触发事件时, 如果定时器存在,就不执行;等过了设置的等待时间,定时器执行,我们需要在定时器执行时,清空定时器,这样就可以设置下一个定时器了
function throttle1(fn, delay) {
let timer;
return function () {
const context = this;
let args = arguments;
if (timer) return;
timer = setTimeout(function () {
console.log("hahahhah");
fn.apply(context, args);
clearTimeout(timer);
timer = null;
}, delay);
};
}
虽然两种方式都实现了节流, 但是他们达到的效果还是有一点点差别的,第一种实现方式,事件触发时,会立即执行函数,之后每隔指定时间执行,最后一次触发事件,事件函数不一定会执行;假设你将等待时间设置为1s, 当3.2s时停止事件的触发,那么函数只会被执行3次,以后不会再执行。
第二种实现方式,事件触发时,函数不会立即执行, 需要等待指定时间后执行,最后一次事件触发会被执行;同样假设等待时间设置为1s, 在3.2秒是停止事件的触发,但是依然会在第4秒时执行事件函数
总结
对两种实现方式比较得出:
- 第一种方式, 事件会立即执行, 第二种方式事件会在n秒后第一次执行
- 第一种方式,事件停止触发后,就不会在执行事件函数, 第二种方式停止触发后仍然会再执行一次
接下来我们写一个下拉加载更多的小demo来验证上面两个节流函数:点击查看代码
let state = 0 // 0: 加载已完成 1:加载中 2:没有更多
let page = 1
let list =[{...},{...},{...}]
window.addEventListener('scroll', throttle(scrollEvent, 1000))
function scrollEvent() {
// 当前窗口高度
let winHeight =
document.documentElement.clientHeight || document.body.clientHeight
// 滚动条滚动的距离
let scrollTop = Math.max(
document.body.scrollTop,
document.documentElement.scrollTop
)
// 当前文档高度
let docHeight = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
)
console.log('执行滚动')
if (scrollTop + winHeight >= docHeight - 50) {
console.log('滚动到底部了!')
if (state == 1 || state == 2) {
return
}
getMoreList()
}
}
function getMoreList() {
state = 1
tipText.innerHTML = '加载数据中'
setTimeout(() => {
renderList()
page++
if (page > 5) {
state = 2
tipText.innerHTML = '没有更多数据了'
return
}
state = 0
tipText.innerHTML = ''
}, 2000)
}
function renderList() {
// 渲染元素
...
}
使用第一种方式效果如下:
一开始滚动便会触发滚动事件, 但是在滚动到底部时停止, 不会打印"滚动到底部了"; 这就是由于事件停止触发后,就不会在执行事件函数
使用第二种方式, 为了看到效果,将事件设置为3s, 这样更能直观感受到事件函数是否立即执行:
// window.addEventListener('scroll', throttle(scrollEvent, 1000))
window.addEventListener('scroll', throttle1(scrollEvent, 3000))
一开始滚动事件函数并不会被触发,而是等到3s后才触发;而当我们快速的滚动到底部后停止滚动事件, 最后还是会执行一次
上面的这个例子是为了辅助理解这两种实现不方式的不同。
时间戳 + 定时器实现版本
在实际开发中, 上面两种实现方案都不满足我们的需求,我们希望一开始滚动就立即执行,停止触发的时候也还能执行一次。结合时间搓方式和定时器方式实现如下:
function throttle(fn, delay) {
let timer, context, args;
let lastTime = 0;
return function () {
context = this;
args = arguments;
let currentTime = new Date().getTime();
if (currentTime - lastTime > delay) {
// 防止时间戳和定时器重复
// -----------
if (timer) {
clearTimeout(timer);
timer = null;
}
// -----------
fn.apply(context, args);
lastTime = currentTime;
}
if (!timer) {
timer = setTimeout(() => {
// 更新执行时间, 防止重复执行
// -----------
lastTime = new Date().getTime();
// -----------
fn.apply(context, args);
clearTimeout(timer);
timer = null;
}, delay);
}
};
}
使用演示效果如下:
实现思路是结合两种实现方式,同时避免两种方式重复执行, 所以当调用时间戳执行函数时,需要将定时器清空;当使用到定时器执行函数时,需要增加修改执行记录的时间lastTime
我们可以看到,开始滚动立即会打印页面滚动了
,停止滚动后,时间会再执行一次,滚动到底部时停止,也会执行到滚动到底部了
最终完善版
上面的节流函数满足了我们的基本需求, 但是我们可以进一步对节流函数进行优化,使得节流函数可以满足下面三种情况:
- 事件函数立即执行,并且事件停止后再执行一次(以满足)
- 事件函数立即执行,但是事件停止后不再执行(待探究)
- 事件函数不立即执行,但是事件停止后再执行一次(待探究)
❝
注意点:事件函数不立即执行,事件停止不再执行一次 这种情况不能满足,在后面从代码角度会做分析。
❞
我们设置两个参数start
和last
分别控制是否立即执行与最后是否执行;修改上一版代码, 实现如下:
function throttle(fn, delay, option = {}) {
let timer, context, args;
let lastTime = 0;
return function () {
context = this;
args = arguments;
let currentTime = new Date().getTime();
// 增加是否立即执行判断
if (option.start == false && !lastTime) lastTime = currentTime;
if (currentTime - lastTime > delay) {
if (timer) {
clearTimeout(timer);
timer = null;
}
fn.apply(context, args);
lastTime = currentTime;
}
// 增加最后是否再执行一次判断
if (!timer && option.last == true) {
timer = setTimeout(() => {
// 确保再次触发事件时, 仍然不立即执行
lastTime = option.start == false ? 0 : new Date().getTime();
fn.apply(context, args);
clearTimeout(timer);
timer = null;
}, delay);
}
};
}
上面代码就修改了三个地方,一个是立即执行之前增加一个判断:
if (option.start == false && !lastTime) lastTime = currentTime
如果传入参数是非立即执行, 并且lastTime
为0, 将当前时间戳赋值给lastTime
, 这样就不会进入 if (currentTime - lastTime > delay)
第二个修改地方, 增加最后一次是否执行的判断:
// 原来
// if (!timer) {...}
// 修改后
if (!timer && option.last == true) {
...
}
当传入last
为true时,才使用定时器计时方式, 反之通过时间戳实现逻辑即可满足
第三个修改的地方, 也是容易被忽视的点, 如果start
传入false,last
传入true(即不立即执行,但最后还会执行一次), 需要在执行定时器逻辑调用事件函数时, 将lastTime
设置为0:
// 确保再次触发事件时, 仍然不立即执行
lastTime = option.start ==false? 0 : new Date().getTime()
这里解决的是再次触发事件时, 也能保证不立即执行。
疑问点
相信有的小伙伴会存在疑问,为什么没有讨论不立即执行, 最后一次也不执行的情况呢(即 start
为true, last
为true), 因为这种情况满足不了。
当最后一次不执行, 也就不会进入到 定时器执行逻辑,也就无法对 lastTime
重置为0,所以,当再一次触发事件时,就会立即执行,与我们的需求矛盾了。关于这一点,大家了解即可了。
到这里,我们的节流函数功能就差不多了, 如果有兴趣的小伙伴可以自己实现一下可取消功能, 与防抖函数实现方式一致, 这里就不赘述了。
来源:juejin.cn/post/7230419964300951613