前言
多动图+步骤细节原理详解,带你秒懂前端面试手写“节流防抖”。
防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户一直触发这个函数,且每次触发函数的间隔小于设置的时间,防抖的情况下只会调用一次,而节流的情况会每隔一定时间调用一次函数。
防抖
防抖(debounce): 所谓防抖,就是指触发事件后在 n 秒内函数只能执行一次,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。
防抖函数分为非立即执行版和立即执行版。
非立即执行版的意思是触发事件后函数不会立即执行,而是在 n 秒后执行,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。
非立即执行版
// 防抖动函数
function debounce(func,delay) {
let timer;
return function() {
let context = this;
if(timer) clearTimeout(timer) // 每次执行的时候把前一个 setTimeout clear 掉
timer = setTimeout(()=> {
func.apply(context,arguments)
},delay)
}
}
分析上述实现防抖函数的要点:
0. 在理解防抖函数之前,我们需要知道两个基础概念,这两个基础概念是深入理解防抖函数的前提和必要,这部分的内容写在本文讲立即执行版的防抖函数那里,因为写了一个测试代码来查看效果,测试代码用的例子是立即执行版的防抖函数。点我跳转到该部分内容
1. 在连续点击事件下,会return多个独立的执行函数,如果想要这些独立的执行函数有联系,使clearTimeout(timer)完成我们的需求,这就需要利用到作用域链,也就是闭包,我们要做的就是只需要把timer这个变量的定义放在返回函数的外围,这样我们在定义监听事件的时候就同时定义了这个timer变量,因为作用域链的关系,所有独立的执行函数都能访问到这个timer变量,而且现在这个timer变量只创建了一次,是唯一的,我们只不过不断给timer赋值进行延时而已,每个清除延时就是清除上一个定义的延时,相当于多个函数公用同一个外部变量
2. 在定时器(setTimeout)中,this指向window,正常情况下我们给button绑定一个事件,函数里this的指向应该是这个button标签,在定时器中,如果我们直接调用要执行的方法,会发现this指向了window,因此我们可以在setTimeout前面就把this保存下来let context = this;,然后我们在setTimeout里面用apply来绑定这个this给要执行的方法,这样this的指向就正确了,this指向正确才能完成正常的防抖函数功能,要不然绑定的东西都改变了,我们还怎么实现防抖呢?
3. 使用箭头函数,就不需要在setTimeout方法前“let args=arguments”了,因为箭头函数里的arguemtns就是外层函数的arguments
4. 关于定时器setTimeout()方法 返回的timeoutID,需要明确的是这个timeoutID是在setTimeout()方法执行的时候就产生的,而不是定时器setTimeout()在延迟时间到期后才产生的值。具体可以看下面代码:
<body>
<script>
let timer = setTimeout(() => {
console.log("时间到");
}, 3000)
console.log('timer :>> ', timer);
let timer2 = setTimeout(() => {
console.log("时间到");
}, 3000)
console.log('timer2 :>> ', timer2);
</script>
</body>
代码运行,先输出两个timeoutID,也就是上述代码里的两个timer,延迟三秒后后输出要执行的代码,执行的情况如下(注意,下面这个动图时常大概3秒,不要看一眼就走哦~):
防抖函数 实例应用:
让我们先来看看在事件持续触发的过程中频繁执行函数是怎样的一种情况。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="content"
style="height:150px;line-height:150px;text-align:center; color: #fff;background-color:#ccc;font-size:80px;">
</div>
<script>
let num = 1;
let content = document.getElementById('content');
function count() {
content.innerHTML = num++;
};
content.onmousemove = count;
</script>
</body>
</html>
在上述代码中,div 元素绑定了 mousemove 事件,当鼠标在 div(灰色)区域中移动的时候会持续地去触发该事件导致频繁执行函数。效果如下
对上面的例子使用防抖函数,我们可以这么使用:
content.onmousemove = debounce(count,1000);
实例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="content"
style="height:150px;line-height:150px;text-align:center; color: #fff;background-color:#ccc;font-size:80px;">
</div>
<script>
let num = 1;
let content = document.getElementById('content');
function count() {
content.innerHTML = num++;
};
// 防抖动函数
function debounce(func, delay) {
let timer;
return function () {
let context = this;
if (timer) clearTimeout(timer) // 每次执行的时候把前一个 setTimeout clear 掉
timer = setTimeout(() => {
func.apply(context, arguments)
}, delay)
}
}
content.onmousemove = debounce(count, 500);
</script>
</body>
</html>
效果如下:
关于获取执行函数中Event e的问题详解
如果我们在定时器中不使用箭头函数,也不提前定义arguments的话,我们会获取不到事件参数Event e,实际上就是获取不到arguments,这个问题我们前面也讲到过,错误的写法:
// 防抖动函数
function debounce(func, delay) {
let timer;
return function () {
let context = this;
// let args = arguments;
if (timer) clearTimeout(timer) // 每次执行的时候把前一个 setTimeout clear 掉
// timer = setTimeout(() => {
// func.apply(context, arguments)
// }, delay)
timer = setTimeout(function () {
func.apply(context, arguments)
}, delay)
}
}
content.onmousemove = debounce(count, 500);
使用箭头函数,或者提前定义arguments,才能正确获得e,也就是arguments
// 防抖动函数
function debounce(func, delay) {
let timer;
return function () {
let context = this;
// let args = arguments;
if (timer) clearTimeout(timer) // 每次执行的时候把前一个 setTimeout clear 掉
timer = setTimeout(() => {
func.apply(context, arguments)
}, delay)
// timer = setTimeout(function () {
// func.apply(context, arguments)
// }, delay)
}
}
content.onmousemove = debounce(count, 500);
立即执行版
立即执行版的意思是触发事件后函数会立即执行,然后 n 秒内不触发事件才能继续执行函数的效果。
// 防抖动函数-立即执行版
function debounce(func, delay) {
let timer;
return function () {
let context = this;
if (timer) clearTimeout(timer); // 每次执行的时候把前一个 setTimeout clear 掉
let callNow = !timer;
timer = setTimeout(() => {
timer = null;
}, delay)
if (callNow) func.apply(context, arguments);
}
}
先看实例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="content"
style="height:150px;line-height:150px;text-align:center; color: #fff;background-color:#ccc;font-size:80px;">
</div>
<script>
let num = 1;
let content = document.getElementById('content');
function count() {
content.innerHTML = num++;
};
// 防抖动函数
function debounce(func, delay) {
let timer;
return function () {
let context = this;
if (timer) clearTimeout(timer); // 每次执行的时候把前一个 setTimeout clear 掉
let callNow = !timer;
timer = setTimeout(() => {
timer = null;
}, delay)
if (callNow) func.apply(context, arguments)
}
}
content.onmousemove = debounce(count, 500);
</script>
</body>
</html>
我们需要知道的两个非常基础的概念是:
1. js事件绑定时,函数名加括号和不加括号的区别
js事件绑定时,函数加括号表示立即执行,事件绑定的函数会在页面加载到带括号的函数时就立即执行一次函数(即页面加载到这里的时候没有触发事件就执行了一次函数);
不加括号的话就相当于得到的是这个函数体,是这个函数本身,并不会执行函数。
<body>
<div>函数调用是否要加括号</div>
<button>点击变色</button>
<script type="text/javascript">
var div = document.getElementsByTagName('div')[0];
var btn = document.getElementsByTagName('button')[0];
function reset(){
div.style.color='green'
}
btn.onclick = reset //1.这种情况相当于 btn.onclick = function reset(){...} ,点击之后执行这个事件,得到是函数体
btn.onclick = reset() //2.这种情况可以理解成给函数外面加了括号成了立即执行函数,页面加载到这一行就立即执行了一次函数,不用点击就得到了一个函数执行后面的结果
</script>
</body>
2. 回到防抖函数,在这行代码里content.onmousemove = debounce(count, 500);,我们鼠标移动的监听事件content.onmousemove绑定的是已经传入执行函数count()的防抖函数debounce(count, 500);
如果onmousemove 绑定的就是一个普通函数count(),那么每次鼠标移动事件监听都会完整的执行这个函数count(),但是我们现在绑定的是一个高阶函数,即闭包,这个防抖函数debounce返回了另一个函数,和绑定普通函数加括号一样,代码在编译渲染的过程中就会执行一次,不同的是,只会执行且仅一次debounce函数return前的代码,即let timer;,随后的每次鼠标移动事件监听,只会执行它return的另一个函数了,即完整的防抖函数debounce只会在页面加载的时候执行一次,仅此一次,剩下的监听任务就交给它return的函数了。这就是前面讲到的防抖函数的比较核心的概念理解。
这个高阶函数debounce的函数体本身其实只有return前的代码,即let timer;这一行声明变量代码,所以,会在页面加载的时候执行且仅执行一次let timer;,并给监听函数content.onmousemove返回另一个函数。
我们加入一些测试代码,写一个测试程序来证明我们前面得到的结论,也展示了一下防抖函数利用到闭包作用域链的效果:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="content"
style="height:150px;line-height:150px;text-align:center; color: #fff;background-color:#ccc;font-size:80px;">
</div>
<script>
let num = 1;
let content = document.getElementById('content');
function count() {
content.innerHTML = num++;
};
let count2 = 0;
function debounce(func, delay) {
count2++;
let timer;
console.log("return前 第" + count2 + "次" + "timer:" + timer);
return function () {
let context = this;
console.log("return后,执行setTimeout前 第" + count2 + "次" + "timer:" + timer);
if (timer) clearTimeout(timer); // 每次执行的时候把前一个 setTimeout clear 掉
let callNow = !timer;
timer = setTimeout(() => {
timer = null;
}, delay)
console.log("return后,执行setTimeout后 第" + count2 + "次" + "timer:" + timer);
if (callNow) func.apply(context, arguments)
count2++;
}
}
content.onmousemove = debounce(count, 500);
</script>
</body>
</html>
效果:
页面加载后,执行且仅一次debounce函数return前的代码,即let timer;,效果如下:
触发监听,可以发现只会执行return后的函数,注意,控制台里只打印了一次return前 第几次timer,其余都是return后的;另外我们也可以看到利用到闭包的作用域链作用实现了防抖功能,注意看每次执行setTimeout前的timer变量都是上一次点击产生的定时器ID,也就是这样实现了 每次执行的时候把前一个 setTimeout clear 掉 的功能。
双剑合璧版
在开发过程中,我们需要根据不同的场景来决定我们需要使用哪一个版本的防抖函数,一般来讲上述的防抖函数都能满足大部分的场景需求。但我们也可以将非立即执行版和立即执行版的防抖函数结合起来,实现最终的双剑合璧版的防抖函数。
/**
* @desc 函数防抖
* @param func 函数
* @param wait 延迟执行毫秒数
* @param immediate true 表立即执行,false 表非立即执行
*/
function debounce(func, delay, immediate) {
// 双剑合璧版
let timer;
return function () {
let context = this;
if (timer) clearTimeout(timer);
if (immediate) {
let callNow = !timer;
timer = setTimeout(() => {
timer = null;
}, delay)
if (callNow) func.apply(context, arguments);
} else {
timer = setTimeout(() => {
func.apply(context, arguments);
}, delay)
}
}
}
防抖的应用场景
- 限制 鼠标连击 触发
- 每次 resize/scroll 触发统计事件
- 文本输入的验证(连续输入文字后发送 AJAX 请求进行验证,验证一次就好)
节流
节流(throttle): 所谓节流,就是指连续触发事件但是在 n 秒中只执行一次函数。节流会稀释函数的执行频率。
对于节流,一般有两种方式可以实现,分别是时间戳版和定时器版。
时间戳版
function throttle(func, wait) {
// 时间戳版
let previous = 0;
return function () {
let now = new Date();
if (now - previous > wait) {
previous = now;
func.apply(this, arguments)
}
}
}
时间戳版没有用到定时器,所以不需要再声明一个this变量。
定时器版
function throttle(func, wait) {
// 定时器版
let timer;
return function () {
let context = this;
if (!timer) {
timer = setTimeout(() => {
timer = null;
func.apply(context, arguments)
}, wait)
}
}
}
节流的应用场景
- 射击游戏的 mousedown/keydown 事件(单位时间只能发射一颗子弹)
- 搜索联想(keyup)
- 监听滚动事件判断是否到页面底部自动加载更多:给 scroll 加了 debounce 后,只有用户停止滚动后,才会判断是否到了页面底部;如果是 throttle 的话,只要页面滚动就会间隔一段时间判断一次