JavaScript函数防抖、节流操作的原因与实现

最早听到这两个名词的时候还是在牛客的面经里,初次听起来很陌生,但后来慢慢发现,这两个操作还是相当重要的,关键在于要弄懂背后的理念,至于如何实现反而倒是次要的。

这篇文章详细的记录一下为什么会用到防抖和节流,以及它们的实现方式和常见使用场景。我相信正确的面试题并不是凭空产生的,问题的背后总能折射出一种业务实现中的细节。

防抖

由来和设计

对于防抖操作,比较正规的解释是在一段时间内多次触发一个事件时,只在最后一次触发结束时才执行,它可以起到一种多次触发但结果只执行一次的效果。可以把防抖操作理解为一个函数的包装器,它返回的也是一个函数,但这个函数拥有一个防抖的额外功能。

如果看不懂的话,我再来形象地说明一下。我们看这个词的字面意思,防抖,它是防什么抖的呢?个人观点,你可以简单理解为防止手抖

比如提交表单这个操作,通常页面上会有一个提交按钮。但偏偏某人手速比较快,又或者网络环境不太好,点击提交之后没能给出明显的视觉反馈,导致此人感觉点击操作没有生效,就再点了几次。又点了几次后还是发现不行,他就开始疯狂点击按钮,直到页面提示提交成功了才罢休。

可是如果这是一个未经防抖优化的系统,甚至我们再把脑洞放大点,整个提交状态的全过程都没有任何限制或优化,会发生什么呢?没错,数据库里被插入了很多很多重复的数据,他点了多少次,就会插入多少条数据。

这听起来就不太妙了。对于上面的例子,当然拥有很多优化方案,不过这不是我们本次讨论的重点。我们希望通过防抖这个操作对提交进行优化,让用户无论疯狂点击多少次,提交事件只能响应一次。

还有一个比较好的例子,这个例子和手抖就没太大关系了,但它也是常见的防抖优化使用场景。我们设想有一个搜索框,我们需要完成一个搜索词联想的功能。这个东西大家应该并不陌生,正如同下面的样子:

当我在搜索框中输入“防抖”这个关键词,我需要监听用户输入的关键词,并使用ajax调用后端给我们的搜索词联想接口,将返回的结果展示给用户。但如果你实际使用过input框的oninput方法时,你会发现它其实是每次输入后都会触发。那如果这里直接在触发事件时做ajax请求,会产生相当大量的无用请求。

此时可能会有人杠,你为什么要说这些请求没有用?我们这样想,用户的输入是一个快速的活动过程,输入框中的文字有可能会增加或者减少(毕竟有打错的可能),那么假设把用户一段输入结束之前的所有状态都记录一下,每次变化都要进行一次请求,而新的变化将在瞬间再次发生,联想结果应当总是基于最新状态下的输入才是合理的。所以这就表明,在一段输入结束之前,只有最后一次的输入对我们的业务来说才是有价值的,之前的全部输入都不需要处理。这样假如用户有n次输入,我们可以直接减少n-1次请求,考虑高并发的情况,这样的优化是相当可观的。

接下来就到了另一个细节问题,这次我们将上面两个例子共同考虑进去,这个细节就是时间间隔。比如疯狂点击按钮那个例子,我们到底怎么判定用户停止了点击呢?似乎检测用户何时停止点击是个比较困难的事情。但我们可以把思路转变一下,停止点击意味着什么呢?其实,停止点击可以理解为在一段很短的时间内没有发生点击操作。至于这个时间具体要多短,只能通过实践测出来了。

同理,我们怎么考虑用户的一段输入结束了呢?仍然是判断在一段很短的时间内没有发生输入操作。这其实就是防抖的delay时间参数。我们把这个事情抽象一下,那就是在delay规定的时间之内如果没有再次触发事件,那才能允许执行规定的操作。再把这个规定的操作封装成一个函数,这两个参数就是防抖操作的参数。之前说到防抖操作是一个函数包装器,所以它应当也返回一个函数。

1
2
3
4
5
fucntion debounce(fn, delay) {
return function() {
/* ... */
}
}

实现和简单应用

接下来考虑如何实现防抖的主要功能。刚刚说到这个函数的功能是限制某个操作的执行,只有在一段时间内没有操作后才能执行,那这里必然要使用到延时操作setTimeout。但是仅仅这样还不能达到我们的要求,如果我们在防抖操作的return中返回一个延时的函数去执行,这只能让这个快速的操作执行周期变慢了,但仍然每次操作都会响应。此时应当怎么办呢?仍然回到防抖函数的定义上去思考,如果在一段时间内没有发生操作,那么执行,但如果操作继续发生,那么事件就不应当执行。可是我们此时还有一个计时器在运转,“事件不应当执行”这个操作对计时器应该是有影响的,作何影响?应当是重置计时器。

为什么事件不应当执行时需要重置计时器?很简单,如果计时器不重置,当时间到的时候事件就执行了,这当然不是我们想要的结果。所以还需要一个clearTimeout操作来帮我们完成这个操作。只要计时器重置,延时就会重新开始,进行一轮新的等待,再次等待时间结束后再执行,如果等待过程中又被打断了,也就是又触发了一次防抖事件,那么防抖操作又会再开启一个新的计时。这就是为什么防抖操作可以保证一段时间内操作只能发生一次。

口述思路:一个debounce函数应当拥有一个变量timer来记录每次setTimeout返回的id,以便于清除时传入clearTimeout使用。它应当return一个匿名函数,首先清除上次的延时,然后再新建一个延时,当时间到的时候就执行传入的fn。

看起来防抖操作这样就完成了,然而实际上这样是有缺陷的。

我们先把不完整的代码贴出来:

1
2
3
4
5
6
7
8
9
function debounce(fn, delay) {
let timer = null;
return function() {
clearTimeout(timer)
timer = setTimeout(() => {
fn();
},delay);
}
}

此时的fn有两个潜在的问题。一,它可能带参数,二,它的this可能有变化。

参数问题还算比较好办,在返回的函数里面直接获得一下arguments然后传入fn就可以。但this的问题要怎么办呢?

我们知道,在JavaScript里面this的指向是可变的,它永远指向调用它时的函数上下文(或者叫作用域,英文都叫context)。而箭头函数的this是指向它定义时的函数上下文。我们看上面的代码,此时如果做如下调用:

1
2
3
function foo() { console.log(this) }
let debFun = debounce(foo, 500);
debFun(); // 直接执行一次这个防抖函数,约0.5秒后打印出Window对象

所以需要在返回的函数中获取一下函数的参数,这里直接使用arguments就可以拿到,然后对fn使用call或者apply做一个手动绑定都可以。call和apply的功能是一样的,只是接收的参数不同,可以任意选用。网络上的资料用哪个的都有,也有对arguments做展开处理的,其实都大同小异,看自己的代码风格就可以了。

下面给出正确的防抖操作函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function debounce(fn, delay) {
let timer = null;
return function() {
const args = arguments;
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args); // 如果要验证this的绑定,将这句换成fn(args);
},delay);
}
}

// 以下是测试代码
function foo() { console.log(this) }
let debFun = debounce(foo,500);
debFun.call({a: 1},1,2,3,4,5);
// 可以自行验证一下,apply操作会将{a: 1}作为this,如果不使用,则foo中打印的还是Window

实际使用时,需要把防抖函数给出,然后将写好的handler函数用防抖封装一下,当事件触发时,调用防抖函数代替原来的函数即可。比如刚刚提到的表单联想操作,在Vue(option API)中可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
<input @input="handleInput" />
</template>
<script>
function debounce(fn, delay) {
let timer = null;
return function() {
const args = arguments;
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
},delay);
}
}
export default {
data() {
return {
delay: 1000,
}
},
methods: {
handleInput() {
debounce(this.getKeywordAsso, this.delay);
},
getKeywordAsso() {
// 这里是获取关键词联想的ajax请求和业务代码,其实应该再带个参,我这里省略了
}
}
}
</script>

还有一个特殊版本的,当事件触发后会立即执行,但下一次执行需要等待一段时间,如果等待过程中触发事件了就要再重新等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function immeDebounce(fn, delay) {
let timer = null;
return function() {
const args = arguments;
clearTimeout(timer);
const firstCall = !timer;
if (firstCall) {
fn.apply(this, args)
}
timer = setTimeout(() => {
fn.apply(this, args);
},delay);
}
}

节流

由来和设计

说完了防抖,接下来再来聊聊节流。有前面防抖的讨论基础,节流就好说很多。

节流操作的比较正规的解释是让一个函数在一段时间内只能执行一次,如果在这个时间段内执行了多次,那么只有一次是生效的(通常是最后一次触发生效,当然也可以是第一次触发生效)。

再直白的解释一下,节流就是节省流量。我们可以把函数执行操作想象为流量消耗或者水源消耗,当这个函数被安装了节流操作后,原本点一次触发一次的函数就变成了一段时间内只能响应一次,假如我们设定这个函数一秒钟只能触发一次,那给你十秒钟,这个函数就只能最多执行十次。但如果不设定节流,十秒钟内能执行多少次那就取决于你的手速有多快。

这玩意有啥用呢?其实无限滚动组件中就用到了节流的概念。当你在疯狂刷动态的时候,网页或者App就会将原本展示完的列表再填充一部分供你观看,懒加载嘛,当你看到的时候才会加载。但如果你要快速刷屏,为了服务器能扛得住还不牺牲你的用户体验,就要用节流来限制你的手速。当你在短时间内快速向下刷手机,如果不使用节流,则请求会不断产生,新的内容将源源不断地插入到页面的DOM中,但同样,由于请求响应是有时间间隔的,你在请求下一屏内容的时候反复请求个几十次,返回的结果都是一样的,你仍然看到的是这些内容,但你烧掉的流量变多了(因为有很多重复的HTTP请求用不上)。使用节流就可以做到当你一段时间内刷屏时,只响应你的最后一次触发,然后执行操作。

节流和防抖的形式类似,都是一种函数的包装器,所以大体结构和防抖是基本一样的。

1
2
3
4
5
function throttle(fn, delay) {
return function() {
/* ... */
}
}

实现和简单应用

节流的目的是限制函数执行频率,手段是控制时间间隔。这里其实除了使用定时器之外,还有一种时间戳的方式,也就是使用两次执行的时间戳之差来判断。

先把定时器方式的实现给出,它可以在防抖的基础上进行修改,区别在于当定时器触发时就需要把timer改成空。防抖则是当触发事件就开始计时,只是每次触发时都需要清除掉上次的timer,节流不再需要清除timer了,而是通过if来判断是否需要设置定时器,当timer存在时就什么也不要做,在定时器触发的时候就将timer设为不存在(也就是上文说的改成空)。其余的代码和防抖基本相同。

这里给出通常版本和立即执行版本(也就是第一次触发就立即处理,而不是需要等一个timer)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function throttle(fn, delay) {
let timer = null;
return function() {
const args = arguments;
if (!timer) {
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, delay);
}
}
}

function immeThrottle(fn, delay) {
let timer = null;
return function() {
const args = arguments;
if (!timer) {
fn.apply(this, args);
timer = setTimeout(() => {
timer = null;
}, delay);
}
}
}

时间戳版本要怎么做呢?这里就不再是定义timer了,而是通过一前一后两个时间戳作差对比,我们默认单位都是毫秒,所以也不用考虑单位转换的问题。

1
2
3
4
5
6
7
8
9
10
11
function throttle(fn, delay) {
let prev = 0;
return function() {
const now = Date.now();
const args = arguments;
if (now - prev > delay) {
fn.apply(this, args);
prev = now;
}
}
}

这种方式实现的节流自动就可以实现立即执行后再计时,因为prev的初始值是0,当前时间戳的毫秒值一定是远大于delay的。

实际使用时,直接在需要绑定的事件处理函数中调用节流操作就可以,比较直观,我就不贴代码了。

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2018-2023 Shawn Zhou
  • Hexo 框架强力驱动 | 主题 - Ayer
  • 访问人数: | 浏览次数:

感谢打赏~

支付宝
微信