函数节流
还记得上篇文章中说到的图片懒加载吗?我们在文章的最后实现了一个页面滚动时按需加载图片的方式,即在触发滚动事件时,执行一个判断图片高度并根据这个高度决定能否加载图片的函数。
从实现效果上来看,这样做是没有任何疑问的,但还有没有可以优化的地方呢?当然。
我们的回调函数和事件进行绑定,导致每次触发事件时,就会去执行回调函数,假如滚动比较频繁,那么回调函数就会一直执行,非常白费页面性能。同时,在低版本浏览器中还可能出现假死。
看一个栗子:
<!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>
执行效果是酱紫的:
触发滚动时回调函数总是执行.gif
假如回调函数中包含了大量的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))
看下执行效果:
函数节流效果.gif
现在,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>
执行效果如图:
不应使用函数防抖.gif
可见,每次键盘抬起都会执行回调函数,假如回调函数中是少量高耗操作,性能可想而知。
函数防抖实现
函数防抖和函数节流的实现方式类似,都是采使用一个外层函数对目标函数进行包装,而后根据条件决定能否执行目标函数。
不同点是,函数节流是采使用计算时间差来决定能否执行目标函数,而函数防抖是根据定时器来决定能否执行目标函数。
下面是闭包版函数防抖的实现:
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))
看下执行效果:
用函数防抖.gif
这样的效果是不是更加友好呢?
总结
本文讲到了两种解决高耗函数操作的两种方式:函数节流和函数防抖。
二者都广泛应使用于事件解决相关的操作上,不同点是:
- 函数节流是降低事件回调函数的执行频率,当事件一直被触发时,回调函数将以某个频率不断的执行。
- 函数防抖是在某事件结束后的一段时间内,假如不再触发该事件,就执行相应的函数。
二者具备各自的应使用场景,但实现方式都相似:
- 都是将目标函数进行包装,根据条件判断决定能否执行该函数
- 都使用到了闭包的特性
- 函数节流是通过时间差决定能否执行目标函数,函数防抖是通过不断的开启/关闭定时器,最终执行目标函数
完。