最近是金三银四面试季,相信不少公司面试题都会涉及到防抖节流的问题,有的面试题甚至是手写实现,今天我们就来看下防抖节流的应用场景以及它内部实现的逻辑。
什么是防抖节流
用户在页面上进行窗口大小的调整、滚动页面或者在输入框搜索联想词等一系列操作时,都会频繁的触发事件处理函数;如果这时候又需要在事件处理函数里去异步获取数据或者进行DOM的操作等耗性能的操作时,容易导致页面卡顿等影响用户的体验;这时就可以通过防抖(debounce)和节流(throttle)函数来限制事件处理函数的调用频率,提升用户的体验。
最上面正常执行每一条竖线代表了每一次事件处理函数的调用,中间是经过防抖函数处理后实际的调用情况,最下面是经过节流函数处理后的调用情况;发现比最上面密集调用的情况要少了很多。
实现防抖
防抖,最开始是用在相机上,我们在拍照时(包括用手机拍),经常会发现由于手的抖动,拍摄出来的画面发生重影或者模糊的情况;而现在的相机或手机基本都会加入防抖技术,除非我们抖动特别的厉害,防抖技术的加入可以让我们拍摄更多清晰的照片。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
而在我们的JS中,防抖是指触发事件后n秒
后才执行函数,如果在 n 秒内又触发了事件,则会重新计算函数执行时间;这段话比较绕口,我们以scroll函数为例:
| function scrollHandler() { console.log('handle') } window.addEventListener('scroll', scrollHandler)
|
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
我们在页面滚动时会不断触发scrollHandler
函数,但是我们不希望每次都触发,因此我们可以通过包装防抖函数来进行限制,当延迟时间超过n秒才真正执行scrollHandler函数。
而防抖函数实现的方式也很简单,在每次触发事件时,都设置一个定时器,延迟执行,并且取消之前的定时器。
| function debounce(fn, wait) { var timeout return function () { var context = this, args = arguments clearTimeout(timeout) timeout = setTimeout(function () { fn.apply(context, args) }, wait) } } window.addEventListener('scroll', debounce(scrollHandler, 500))
|
debounce
主要的目的就是延迟执行传入的fn
函数,我们发现它返回了一个函数,是典型的闭包结构;页面滚动将每次触发scrollHandler变成每次都会触发debounce中返回的闭包函数;由于闭包的存在,因此timeout
定时器变量会一直存在,触发闭包函数时都会清除上次设置的定时器。
这里调用fn时,很多fn函数都是滚动或者点击的回调函数,会提供Event对象进行处理,因此我们需要将原来的参数传入以及this进行绑定;因此分别赋值了变量context
和args
,这里arguments
是类数组对象,那么什么是类数组对象呢,我们在函数中console.log出来看一下:
我们发现它的属性名是按照从0开始的index,第一个参数的属性是’0’,第二个属性名是’1’,并且它还有个length属性;但是它和数组不同的是它__proto__
直接指向了Object,而数组的__proto__
指向了Array;因此Array原型上的一些map、find等方法arguments也是没有的。
我们回到防抖函数,上面的防抖函数是非立即执行的,也就是触发事件后不会马上执行,但是我们某些场景下需要立即执行;立即执行后当n秒内触发事件才能再次执行。
因此我们来看下立即执行版本的防抖函数时如何来实现的:
| function debounce(fn, wait) { let timeout, result; return function () { const context = this const args = arguments clearTimeout(timeout) const callNow = !timeout timeout = setTimeout(function() { timeout = null }, wait) if (callNow) result = fn.apply(context, args) return result } }
|
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
在上面代码中我们还是通过闭包返回了一个匿名函数,但是在里面增加了一个变量callNow的判断,判断上一次的定时器是否已经被清除,如果没有定时器则立即执行fn函数。
在开发过程中我们需要根据不同的场景来切换不同版本的防抖函数,因此将两个防抖函数结合起来,根据参数来进行判断:
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 debounce(fn, wait, immediate) { var timeout, result; return function () { var context = this var args = arguments clearTimeout(timeout) if (immediate) { var callNow = !timeout timeout = setTimeout(function () { timeout = null }, wait) if (callNow) result = fn.apply(context, args) } else { timeout = setTimeout(function () { fn.apply(context, args) }, wait) } return result } }
|
到这里我们的防抖函数已经接近完美了,但是最后如果我们希望能够取消这里的debounce函数,比如我们传入wait是10秒,immediate为true,刚开始是立即执行fn函数的,但是我们需要等待10秒才能重新去触发fn函数,中间做的所有操作都是无效的;我们希望能有一个按钮,点击后能够取消上一次的防抖,然后我们就能够再次触发了。
这里改动也很简单,我们需要对返回闭包函数进行处理,但是由于是匿名函数,我们给他具名,同时赋值一个cancel
函数用来清除闭包外的定时器timeout即可:
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 debounce(fn, wait, immediate) { var timeout, result; var debounced = function () { var context = this var args = arguments clearTimeout(timeout) if (immediate) { var callNow = !timeout timeout = setTimeout(function () { timeout = null }, wait) if (callNow) result = fn.apply(context, args) } else { timeout = setTimeout(function () { fn.apply(context, args) }, wait) } return result } debounced.cancel = function() { clearTimeout(timeout) timeout = null } return debounced }
|
那么如何来调用这个cancel函数呢?我们还是以scroll函数为例,通过给页面上的btn取消按钮增加点击事件进行触发:
| var cancelBtn = document.getElementById('btn') var setDebounce = debounce(function () { console.log('handle') }, 10000, true) cancelBtn.addEventListener('click', function () { setDebounce.cancel() }) window.addEventListener('scroll', setDebounce)
|
到这里我们的防抖函数就很完美了。
实现节流
节流函数是指当持续触发事件时,保证一定时间段内只调用一次事件处理函数;也就是会稀释处理函数的执行频率。我们通过时间轴来清晰的看下它的执行过程:
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
我们可以看出,节流函数不管在一个周期内触发了多少次scroll函数,也不管触发的时间间隔,最后只会执行周期内的最后一次(或者第一次);节流函数应用场景一般在窗口resize时进行布局的调整或者移动端监听touchmove事件时移动DOM元素等;节流函数同样也有时间戳和定时器两个版本,我们先来看定时器版的节流函数实现方式:
| function throttle(fn, wait) { let timeout; return function () { let context = this let args = arguments if (!timeout) { timeout = setTimeout(function() { timeout = null fn.apply(context, args) }, wait) } } }
|
和防抖函数每次清除timeout不同,这里对timeout进行非空判断,只有它为空的时候才能设置定时器,这样保证了在一段时间内同时只有一个定时器,在时间到之后会释放定时器并且执行fn函数,重新设置定时器。
我们再来看下时间戳版本的节流函数:
| function throttle(fn, wait) { var context, args var previous = 0 return function () { var now = +new Date() context = this args = arguments if (now - previous > wait) { fn.apply(context, args) previous = now } } }
|
时间戳版本的函数是在闭包函数的外部存储了一个previous
变量,是上次执行的一个时间戳;每次触发内部闭包函数时与上次的时间戳进行对比判断,如果间隔时间大于我们设置的等待时间则执行fn函数,同时更新时间戳;同时由于我们初始化previous
是0,而now
当前的时间戳减去0肯定是会大于wait时间的,因此时间戳版本的节流函数fn一开始就会被触发。
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
通过上面我们很容易就能发现,两个版本的节流函数最大的不同就是fn函数执行的时间点,定时器版本由于setTimeout延时的特性,在时间段结束的时候触发fn函数,而时间戳版本是在时间段开始的时候触发。
同样的,我们可以将两种节流函数结合到一个函数,我们可以加上cancel取消方法:
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 31 32 33
|
function throttle(fn, wait, immediate) { let timeout let previous = 0 var throttled = function () { let context = this let args = arguments if (immediate) { let now = Date.now() if (now - previous > wait) { fn.apply(context, args) previous = now } } else { if (!timeout) { timeout = setTimeout(() => { timeout = null fn.apply(context, args) }, wait) } } } throttled.cancel = function() { clearTimeout(timeout) previous = 0 timeout = null } return throttled }
|