函数节流
还记得上篇文章中说到的图片懒加载吗?我们在文章的最后实现了一个页面滚动时按需加载图片的方式,即在触发滚动事件时,执行一个判断图片高度并根据这个高度决定是否加载图片的函数。
从实现效果上来看,这样做是没有任何问题的,但还有没有可以优化的地方呢?当然。
我们的回调函数和事件进行绑定,导致每次触发事件时,就会去执行回调函数,如果滚动比较频繁,那么回调函数就会一直执行,非常浪费页面性能。同时,在低版本浏览器中还可能出现假死。
看一个栗子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>取个什么名字好呢</title>
<style>
#box{
width: 300px;
max-height: 500px;
border: 1px solid red;
margin: 0 auto;
overflow: auto;
}
#display{
height: 2000px;
}
</style>
</head>
<body>
<div id = "box">
<ul id = "display">
</ul>
</div>
</body>
<script>
let count = 0;
const box = document.getElementById("box");
const display = document.getElementById("display");
const print = (e) => { display.innerHTML += `<li>回调函数执行了 ${++count} 次</li>` }
box.addEventListener("scroll",print)
</script>
</html>
执行效果是酱紫的:
如果回调函数中包含了大量的DOM操作、循环操作或者网络请求等,那么是灰常浪费资源的。
我们想让函数有规律的调用,但不要太频繁,而是每隔一段时间调用一次,这种实现就是函数节流。比如:我们可以让 scroll 中的回调函数每隔 500ms 调用一次,而不是每触发一次滚动就进行一次函数调用。
函数节流实现
实现函数节流需要这两个要素:
- 被节流的函数
- 延迟时间
关键点在于延迟时间,我们需要在延迟之间之后调用被节流函数,如果在延迟时间之内,就不做操作。因此我们需要用到两个时间戳:
- 节流之前的时间戳
- 节流之后的时间戳
可以将时间戳保存在全局变量中,或者函数的属性上,或者使用闭包。为了复习下闭包,这里给出一个闭包的实现:
function throttle(fn,delay){
let startTime = 0;
return (...args) => {
let timeNow = +new Date();
if(timeNow - startTime >= delay){
fn(...args);
startTime = timeNow;
}
}
}
将需要节流的函数使用 throttle 函数进行包装,throttle 函数执行返回一个匿名函数,该匿名函数首先会计算当前的时间(timeNow),并和起始时间(timeStart)进行比较,如果时间差大于延迟时间(delay)就执行被节流的函数,否则不进行任何操作。被节流函数执行成功后,更新开始时间(timeStart)。
注:这里假定被节流的函数中没有异步操作,如果被节流函数中有异步操作(需返回 Promise),可以进行下面的改造:
function throttle(fn,delay){
let startTime = 0;
return async (...args) => {
let timeNow = +new Date();
if(timeNow - startTime >= delay){
await fn(...args);
startTime = timeNow;
}
}
}
这种情况适用于限制网络请求,比如点击按钮时请求某一个接口,如果一直点击按钮,就会重复请求接口,如果后端 GG 说需要限制下接口请求频率,就可以对异步请求操作进行节流,在满足后端 GG 的同时还优化了前端用户体验。
现在对前面的 print 函数进行节流:
let count = 0;
const box = document.getElementById("box");
const display = document.getElementById("display");
function throttle(fn,delay){
let startTime = 0;
return (...args) => {
let timeNow = +new Date();
if(timeNow - startTime >= delay){
fn(...args);
startTime = timeNow;
}
}
}
const print = (e) => { display.innerHTML += `<li>回调函数执行了 ${++count} 次</li>` }
box.addEventListener("scroll",throttle(print,1000))
看下执行效果:
现在,print 函数不再是每次触发滚动就执行了,而是每隔一秒执行一次。
注:虽然 print 函数不再是每次触发滚动操作就执行,但包装它的函数是每次触发滚动都在执行的,这个包装函数的每次执行都会进行时间戳比对,如果大于等于延迟时间就执行被节流函数,相比于每次执行被节流函数 print,这个包装函数的主要开销就是计算当前时间,而不是执行被节流函数中复杂的逻辑,显然性能更好了。
函数节流配合拖拽
拖拽的核心功能是 mousemove 事件,当鼠标在页面上移动时,不断计算 left 和 top 值并改变元素的位置,这个过程中页面会不断的重绘,这篇文章中讲到了拖拽的两种实现方式。我们也可以将函数节流和拖拽结合使用,用来限制 move 函数触发的频率:
class Drag{
constructor(subEle,supEle) {
// 根元素
const rootEle = document.documentElement || document.body;
// 被拖动元素
this.subEle = subEle;
// 父级元素,默认为根元素
this.supEle = supEle || rootEle;
this.offsetX = null;
this.offsetY = null;
// 被拖动元素的宽高
this.subOffsetWidth = this.subEle.offsetWidth;
this.subOffsetHeight = this.subEle.offsetHeight;
// 父级元素的可视区宽高
this.supClientWidth = this.supEle.clientWidth;
this.supClientHeight = this.supEle.clientHeight;
// 速度相关
this.lastX = 0;
this.lastY = 0;
this.speedX = 0;
this.speedY = 0;
this.drag();
}
// 节流函数
throttle(fn,delay){
let startTime = 0;
return (...args) => {
let timeNow = +new Date();
if(timeNow - startTime >= delay){
fn(...args);
startTime = timeNow;
}
}
}
drag(){
// 为拖动元素添加事件,初始化
this.subEle.addEventListener("mousedown",this.dragDown.bind(this));
}
// 处理鼠标按下
dragDown(e){
this.offsetX = e.clientX - this.subEle.offsetLeft;
this.offsetY = e.clientY - this.subEle.offsetTop;
// 将 dragDown 和 dragUp 函数另存一份
// 解决抬起鼠标后无法 removeEventListener 的问题
// 对 dragMove 函数进行节流,时间为 50 毫秒
this.move = this.throttle(this.dragMove.bind(this),50);
this.up = this.dragUp.bind(this);
document.addEventListener("mousemove",this.move);
document.addEventListener("mouseup",this.up);
}
// 处理鼠标移动
dragMove(e){
let left = e.clientX - this.offsetX;
let top = e.clientY - this.offsetY;
if(left <= 0){
left = 0;
}else if(left >= this.supClientWidth - this.subOffsetWidth){
left = this.supClientWidth - this.subOffsetWidth;
}
if(top <= 0){
top = 0;
}else if(top >= this.supClientHeight - this.subOffsetHeight){
top = this.supClientHeight - this.subOffsetHeight;
}
this.subEle.style.left = left + "px";
this.subEle.style.top = top + "px";
// 更新 speedX、speedY、lastX、lastY
this.speedX = left - this.lastX;
this.speedY = top - this.lastY;
this.lastX = left;
this.lastY = top;
// 防止选择拖动
window.getSelection ? window.getSelection().removeAllRanges():document.selection.empty();
}
// 清除事件
dragUp(e){
document.removeEventListener("mousemove",this.move);
document.removeEventListener("mouseup",this.up);
}
}
// 新建一个对象,让其可以拖动
new Drag(document.getElementById("inner"),document.getElementById("par"));
注:对拖拽进行函数节流时,延迟时间(delay)不可设置的过大,否则在拖动过程中会出现不连贯的情况。
函数防抖
函数防抖就是在事件完成某段时间后执行相应的函数,一个最普遍的例子就是注册用户时的用户名验证或者下拉模糊搜索。
这类效果一般是在向搜索框中输入字符时,从后台服务器拉取相应的验证结果或者模糊查询的结果。通常做法是在键盘抬起(keyup)时触发某个函数,用来向后端请求数据。
这样做的缺陷是:如果每次键盘抬起都进行一次请求,那我们在搜索过程中就会进行炒鸡炒鸡多的请求,而我们实际需要的只是对最后一次键盘抬起时输入框中的文字进行请求。
常用的做法是:在键盘抬起后的一段时间中,如果不进行按键操作,就执行回调函数。这种做法就是函数防抖(debounce)。
先来看一下不应用函数防抖的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>取个什么名字好呢</title>
<style>
#box{
width: 300px;
max-height: 500px;
border: 1px solid red;
margin: 0 auto;
overflow: auto;
text-align: center;
}
#display{
height: 2000px;
}
</style>
</head>
<body>
<div id = "box">
<input type="text" id = "inp">
<ul id="display"></ul>
</div>
</body>
<script>
let count = 0;
const inp = document.getElementById("inp");
const display = document.getElementById("display");
const print = (e) => { display.innerHTML += `<li>回调函数执行了 ${++count} 次</li>` }
inp.addEventListener("keyup",print)
</script>
</html>
执行效果如图:
可见,每次键盘抬起都会执行回调函数,如果回调函数中是一些高耗操作,性能可想而知。
函数防抖实现
函数防抖和函数节流的实现方式相似,都是采用一个外层函数对目标函数进行包装,然后根据条件决定是否执行目标函数。
不同点是,函数节流是采用计算时间差来决定是否执行目标函数,而函数防抖是根据定时器来决定是否执行目标函数。
下面是闭包版函数防抖的实现:
function debounce(fn,delay){
let timer = null;
return (...args) =>{
clearTimeout(timer);
timer = setTimeout(()=>{
fn(...args)
},delay);
}
}
调用 debounce 函数时,创建一个空的定时器对象,debounce 函数执行返回一个匿名函数,该匿名函数执行时,首先清除定时器,而后重新创建一个定时器对象,在指定的延迟之后执行目标函数。如果在定时器等待执行期间再次执行了匿名函数,就清除这个定时器对象,重新创建一个定时器对象,直到指定延迟(delay)时间后执行目标函数。
在代码中使用函数防抖:
let count = 0;
const inp = document.getElementById("inp");
const display = document.getElementById("display");
function debounce(fn,delay){
let timer = null;
return (...args) =>{
clearTimeout(timer);
timer = setTimeout(()=>{
fn(...args)
},delay);
}
}
const print = (e) => { display.innerHTML += `<li>回调函数执行了 ${++count} 次</li>` }
inp.addEventListener("keyup",debounce(print,500))
看下执行效果:
这样的效果是不是更加友好呢?
总结
本文讲到了两种处理高耗函数操作的两种方式:函数节流和函数防抖。
二者都广泛应用于事件处理相关的操作上,不同点是:
- 函数节流是降低事件回调函数的执行频率,当事件一直被触发时,回调函数将以某个频率不断的执行。
- 函数防抖是在某事件结束后的一段时间内,如果不再触发该事件,就执行相应的函数。
二者具有各自的应用场景,但实现方式都类似:
- 都是将目标函数进行包装,根据条件判断决定是否执行该函数
- 都用到了闭包的特性
- 函数节流是通过时间差决定是否执行目标函数,函数防抖是通过不断的开启/关闭定时器,最终执行目标函数
完。