JS函数去抖(debounce)和节流(throttle)
Opened this issue · 0 comments
[TOC]
DOM操作比起非DOM交互需要更多的内存和CPU时间,连续尝试进行过多的DOM相关操作可能会导致浏览器挂起,有时候甚至会崩溃。
如果在程序中使用了onresize
事件处理程序,当调整浏览器大小的时候,该事件会连续触发。如果在该事件处理程序内部进行了相关DOM操作,其高频率的更改可能会导致浏览器崩溃。为了绕开这个问题,我们可以考虑使用定时器对该函数进行节流。
函数节流背后的基本**是:某些代码不可以在没有间断的情况下连续重复执行。第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除之前的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的是在只有在执行函数的请求停止了一段时间之后才执行。
以下场景往往由于事件频繁被触发,因而频繁执行DOM操作、资源加载等重行为,导致UI停顿甚至浏览器崩溃。
- window对象的resize、scroll事件;
- 拖拽时的mousemove事件;
- 射击游戏中的mousedown、keydown事件;
- 文字输入、自动完成的keyup事件。
实际上对于window的resize事件,实际需求大多为停止改变大小n毫秒后执行后续处理;而其他事件大多的需求是以一定的频率执行后续处理。针对这两种需求就出现了debounce和throttle两种解决办法。
throttle(又称节流)和debounce(又称去抖)其实都是函数调用频率的控制器。
debounce
强制函数在某段时间内只执行一次,throttle
强制函数以固定的速率执行。在处理一些高频率触发的DOM
事件的时候,它们都能极大提高用户体验。
在处理诸如resize、scroll、mousemove
和 keydown/keyup/keypress
等事件的时候,通常我们不希望这些事件太过频繁地触发,尤其是监听程序中涉及到大量的计算或者有非常耗费资源的操作。
有多频繁呢?以mousemove
为例,根据DOM Level 3
的规定,「如果鼠标连续移动,那么浏览器就应该触发多个连续的mousemove
事件」,这意味着浏览器会在其内部计时器允许的情况下,根据用户移动鼠标的速度来触发mousemove
事件。(当然了,如果移动鼠标的速度足够快,比如刷
一下扫过去,浏览器是不会触发这个事件的)。resize、scroll 和 key*
等事件与此类似。
1. Debounce(函数防抖)
DOM事件里的
debounce
概念其实是从机械开关和继电器的去弹跳(debounce)
衍生而来的,基本思路就是:把多个信号合并为一个信号。
在JavaScript
中,debounce
函数所做的事情就是:强制一个函数在某个连续时间段内只执行一次,哪怕它本来会被调用多次。我们希望在用户停止某个操作一段时间之后才执行相应的监听函数,而不是在用户操作的过程当中,浏览器触发多少次事件,就执行多少次监听函数。
比如:在某个3s
的时间段内连续地移动了鼠标,浏览器可能会触发几十(甚至几百)个 mousemove
事件,不使用debounce
的话,监听函数就要执行这么多次;如果对监听函数使用100ms
的去弹跳
,那么浏览器只会执行一次这个监听函数,而且是在第3.1s
的时候执行的。
现在,我们就来实现一个 debounce 函数。
1.1 具体实现
debounce
函数接收两个参数,第一个是要去弹跳
的回调函数 fn,第二个是延迟的时间delay
。实际上,大部分的完整debounce
实现还有第三个参数immediate
,表明回调函数是在一个时间区间的最开始执行(immediate为true)
还是最后执行(immediate为false)
,比如underscore
的_.debounce
。本文不考虑这个参数,只考虑最后执行的情况,感兴趣的可以自行研究。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
#box, #test {
width: 100px;
height: 100px;
background-color: aqua;
}
</style>
</head>
<body>
<div id="box"></div>
<div id="test"></div>
<script>
function debounce(fn, delay) {
var timer = null;
return function () {
// 保存函数调用时的上下文和参数
var that = this;
var args = arguments;
// 每次这个返回的函数被调用,就清除定时器,以保证不执行fn
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(that, args);
}, delay);
}
}
const box = document.querySelector('#box');
const box2 = document.querySelector('#test');
let time = 0;
let time2 = 0;
// 没有采用防抖
document.addEventListener('mousemove', function() {
box2.innerHTML = ++time;
}, false);
// 采用防抖处理
document.addEventListener('mousemove', debounce(function() {
box.innerHTML = ++time2;
}, 250), false);
document.addEventListener('mouseleave', function() {
time = 0;
time2 = 0;
box.innerHTML = 0;
box2.innerHTML = 0;
}, false);
</script>
</body>
</html>
实现思路:
debounce
返回了一个闭包,这个闭包依然会被连续频繁地调用,但是在闭包内部,却限制了原始函数fn
的执行,强制fn
只在连续操作停止后只执行一次。
再来考虑另外一个场景:根据用户的输入实时向服务器发
ajax
请求获取数据。我们知道,浏览器触发key*
事件也是非常快的,即便是正常人的正常打字速度,key*
事件被触发的频率也是很高的。以这种频率发送请求,一是我们并没有拿到用户的完整输入发送给服务器,二是这种频繁的无用请求实在没有必要。
更合理的处理方式是:在用户停止输入一小段时间以后,再发送请求。那么 debounce
就派上用场了:
$('input').on('keyup', debounce(function(e) {
// 发送 ajax 请求
}, 300))
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
#box {
width: 100px;
height: 100px;
background-color: blueviolet;
}
</style>
</head>
<body>
<div id="box"></div>
<script>
var box = document.querySelector('#box');
var height = 100;
function debounce(fn, delay) {
var timer = null;
return function () {
var that = this;
clearTimeout(timer);
timer = setTimeout(function() {
fn.call(that);
}, delay);
}
}
/*
没有做防抖处理
window.addEventListener('resize', function() {
height += 1;
box.style.height = height + 'px';
}, false);
*/
window.addEventListener('resize', debounce(function() {
height += 1;
box.style.height = height + 'px';
}, 250), false);
</script>
</body>
</html>
2. Throttle(函数节流)
throttle
的概念理解起来更容易,就是固定函数执行的速率,即所谓的节流。正常情况下,假设mousemove
的监听函数每20ms
执行一次,如果设置200ms
的节流,那么它就会每200ms
执行一次。比如在 1s 的时间段内,正常的监听函数可能会执行 50(1000/20) 次,节流
200ms 后则会执行 5(1000/200)次。
2.1 具体实现
与debounce
类似,throttle
也接收两个参数,一个实际要执行的函数fn
,一个执行间隔阈值threshhold
。
同样的,
throttle
的更完整实现可以参看underscore
的_.throttle
。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
.wrapper {
width: 200px;
height: 200px;
float: left;
border: 1px solid #ddd;
overflow: auto;
position: relative;
}
.wrapper .content {
height: 100%;
width: 100%;
overflow: auto;
}
.content .inner {
height: 6000px;
}
.wrapper .desc {
position: absolute;
}
.wrapper .count {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.normal {
margin-right: 20px;
}
</style>
</head>
<body>
<h3>Try scrolling in the 2 boxes...</h3>
<div>
<div class="wrapper normal">
<div class="desc">Normal scroll</div>
<div class="content">
<div class="inner"></div>
</div>
<span id="normal" class="count">0</span>
</div>
<div class="wrapper throttled">
<div class="desc">Throttled scroll</div>
<div class="content">
<div class="inner"></div>
</div>
<span id="throttled" class="count">0</span>
</div>
</div>
<script>
function throttle(fn, threshhold) {
// 记录上次执行的时间
var last;
// 定时器
var timer = null;
// 默认间隔为250ms
threshhold || (threshhold = 250);
// 返回函数,每隔threshhold毫秒就执行一次fn函数
return function () {
// 保存函数调用时的上下文和参数,传递给fn
var that = this;
var args = arguments;
var now = +new Date();
// 如果距离上次执行fn函数的时间小于threshhold,就不执行fn
// 否则执行fn,并重新计时
if (last && now < last + threshhold) {
clearTimeout(timer);
// 保证在当前时间区间结束后,再执行一次fn
timer = setTimeout(function() {
last = now;
fn.apply(that, args);
}, threshhold);
}
else {
last = now;
fn.apply(that, args);
}
}
}
var normalCount = 0;
var throttledCount = 0;
var normalSpan = document.querySelector('#normal');
var throttledSpan = document.querySelector('#throttled');
var normalContent = document.querySelector('.normal .content');
var throttledContent = document.querySelector('.throttled .content');
normalContent.addEventListener('scroll', function() {
normalSpan.innerText = ++normalCount;
}, false);
throttledContent.addEventListener('scroll', throttle(function() {
throttledSpan.innerText = ++throttledCount;
}, 250), false);
document.addEventListener('mouseleave', function() {
normalCount = 0;
throttledCount = 0;
normalSpan.innerText = 0;
throttledSpan.innerText = 0;
}, false);
</script>
</body>
</html>
原理也不复杂,相比 debounce,无非是多了一个时间间隔的判断,其他的逻辑基本一致。throttle 的使用方式如下:
$(document).on('mouvemove', throttle(function(e) {
// 代码
}, 250))
3. debounce和throttle各自的使用场景
- throttle常用的场景是限制 resize 和 scroll 的触发频率。
- debounce常用的场景是限制 mousemove 和keydown/keyup/keypress。
3.1 debounce使用场景
第一次触发后,进行倒计wait毫秒,如果倒计时过程中有其他触发,则重置倒计时;否则执行fn。用它来丢弃一些重复的密集操作、活动,直到流量减慢。例如:
- 对用户输入的验证,不在输入过程中就处理,停止输入后进行验证;
- 提交ajax时,不希望1s中内大量的请求被重复发送。
3.2 throttle使用场景
第一次触发后先执行fn(当然可以通过{leading: false}来取消),然后wait ms后再次执行,在单位wait毫秒内的所有重复触发都被抛弃。即如果有连续不断的触发,每wait ms执行fn一次。与debounce相同的用例,但是你想保证在一定间隔必须执行的回调函数。例如:
- 对用户输入的验证,不想停止输入再进行验证,而是每n秒进行验证;
- 对于鼠标滚动、window.resize进行节流控制。
4. 正真的业务场景:
一个相当常见的例子:用户在你无限滚动的页面上向下滚动鼠标加载页面,你需要判断现在距离页面底部多少。如果用户快接近底部时,我们应该发送请求来加载更多内容到页面。在此debounce没有用,因为它只会在用户停止滚动时触发,但我们需要用户快到达底部时去请求。通过throttle我们可以不间断的监测距离底部多远。
$(document).ready(function(){
// 这里设置时间间隔为300ms
$(document).on('scroll', throttle(function(){
check_if_needs_more_content();
}, 300));
// 是否需要加载更多资源
function check_if_needs_more_content() {
var pixelsFromWindowBottomToBottom = 0 + $(document).height() - $(window).scrollTop() - $(window).height();
// 滚动条距离页面底部小于200,加载更多内容
if (pixelsFromWindowBottomToBottom < 200){
// 加载更多内容
$('body').append($('.item').clone());
}
}
});
5. debounce和throttle的差异(可视化解释)
6. 项目实例
在学习Vue的时候,官网也用到了一个里子,就是用于对用户输入的事件进行了去抖,因为用户输入后需要进行ajax请求,如果不进行去抖会频繁的发送ajax请求,所以通过debounce对ajax请求的频率进行了限制。
methods: {
// `_.debounce` 是一个通过 Lodash 限制操作频率的函数。
// 在这个例子中,我们希望限制访问 yesno.wtf/api 的频率
// AJAX 请求直到用户输入完毕才会发出。想要了解更多关于
getAnswer: _.debounce(function() {
if (!reg.test(this.question)) {
this.answer = 'Questions usually end with a question mark. ;-)';
return;
}
this.answer = 'Thinking ... ';
let self = this;
axios.get('https://yesno.wtf/api')
// then中的函数如果不是箭头函数,则需要对this赋值self
.then((response) = > {
this.answer = _.capitalize(response.data.answer)
}).
catch ((error) = > {
this.answer = 'Error! Could not reach the API. ' + error
})
}, 500) // 这是我们为判定用户停止输入等待的毫秒数
},