JavaScript防抖、节流以及rAF
防抖(Debounce) 和节流(throttle) 是两个可以控制在一段时间内函数执行次数的技巧。也是前端界的出名人物,我们在输入框、DOM事件等场景下经常要用到。今天来完整地梳理一遍防抖和节流,顺便提一下rAF。
1 防抖与节流的定义
- 防抖:事件持续触发,但只有当事件停止触发后n秒才执行函数。
- 节流:事件持续触发时,每n秒执行一次函数。
2 防抖 debounce
场景
监听鼠标移动事件,每次监听到移动屏幕数字+1。
<div id="container"></div>
<script>
let count = 1
let container = document.getElementById('container')
function getUserAction() {
container.innerHTML = count++
}
container.onmousemove = debounce(getUserAction, 1000)
</script>
第一版
// 第一版
function debounce(fn, wait) {
let timeout
return function () {
timeout && clearTimeout(timeout)
timeout = setTimeout(fn, wait)
}
}
第二版(修正 this 指向)
发现问题
将 fn
送入 setTimeout()
之后,getUserAction()
中的 this
指向了 Window
!
解决方案
手动绑定 this
指针
// 第二版 解决 this 指向
function debounce(fn, delay) {
let timeout
return function () {
const self = this
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
fn.apply(self)
}, delay)
}
}
第三版(解决传参)
发现问题
fn
无法传参数
解决问题
使用 ES6 的 剩余参数
将参数传入 fn
// 第三版 解决传参
function debounce(fn, delay) {
let timeout
return function (...args) {
const self = this
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
fn.apply(self, args)
}, delay)
}
}
到了这里,我们的防抖函数已经完成了!下面是三个可能的拓展需求。
新需求:立即执行
不等到事件停止触发后才执行,希望立即执行函数。然后等到停止触发n秒后,才重新触发执行。
也就是—— immediate
参数将执行时机从末尾改成了开头
// 添加 immediate 参数控制是否立即执行
function debounce(fn, delay, immediate = false) {
let timeout
// 首次立即执行
let callNow = true
return function (...args) {
const self = this
if (timeout) clearTimeout(timeout)
// 需要立即执行的情况
if (immediate) {
// 说明已经过了 n 秒,允许执行
if (callNow) {
fn.apply(self, args)
}
callNow = false
timeout = setTimeout(() => {
callNow = true // n 秒后设置 callNow 为 true,也就是可以执行
}, delay)
} else {
timeout = setTimeout(() => {
console.log('延迟执行')
fn.apply(self, args)
}, delay)
}
}
}
新需求:返回值
此时注意一点,就是
getUserAction
函数可能是有返回值的,所以我们也要返回函数的执行结果,但是当immediate
为false
的时候,因为使用了setTimeout
,我们将fu.apply(self, args)
的返回值赋给变量,最后再return
的时候,值将会一直是undefined
,所以我们只在immediate
为true
的时候返回函数的执行结果。
// 在immediate为true的时候返回函数的执行结果。
function debounce(fn, delay, immediate = false) {
let timeout
let callNow = true
let result
return function (...args) {
const self = this
if (timeout) clearTimeout(timeout)
// 需要立即执行的情况
if (immediate) {
// 说明已经过了 n 秒,允许执行
if (callNow) {
result = fn.apply(self, args)
}
callNow = false
timeout = setTimeout(() => {
callNow = true // n 秒后设置 callNow 为 true,也就是可以执行
}, delay)
} else {
timeout = setTimeout(() => {
fn.apply(self, args)
}, delay)
}
return result
}
}
...
// 让函数有返回值
function getUserAction(e) {
let ret = count
container.innerHTML = count++
return `当前移动${ret}次`
}
// 测试一下
const getDebounceResult = debounce(getUserAction, 600, true)
document
.getElementById('container')
.addEventListener('mousemove', function(e) {
const result = getDebounceResult.call(this, e)
console.log('result', result)
})
测试结果
疑问:为什么在immediate
是true
时,才可能存在返回值。如果为false
,在n秒之后执行就没有返回值了吗?
...
} else {
timeout = setTimeout(() => {
fn.apply(self, args)
}, delay)
}
return result
}
}
如果 immediate
为 false
,那么这个事件是 delay
毫秒后才执行的,但是执行完 else
的代码块之后,马上就执行 return result
这一行代码了。而 setTimeout
中的回调函数是延迟执行的,result
没有办法获取到结果,所以会返回 undefined
。也就是说,如果 immediate
为 false
,那你本来就没办法拿到 result
了。
新需求:取消本次防抖计时
举个夸张的例子哈,delay
设置为10分钟。那么用户一旦进入防抖计时,他就必须等十分钟才能执行。我们能否加一个功能,让用户可以马上取消这次计时,重新开始呢?
我们只要清除掉原来的定时器就可以了。
// 添加重置防抖计时功能
function debounce(fn, delay, immediate = false) {
let timeout
let callNow = true
let result
// 正常的防抖函数
const debounced = function (...args) {
const self = this
if (timeout) clearTimeout(timeout)
if (immediate) {
if (callNow) {
result = fn.apply(self, args)
}
callNow = false
timeout = setTimeout(() => {
callNow = true
}, delay)
} else {
timeout = setTimeout(() => {
fn.apply(self, args)
}, delay)
}
return result
}
// 这里再挂上重置定时器的方法
debounced.cancel = function () {
clearTimeout(timeout)
console.log('清除了定时器')
timeout = null
callNow = true
}
return debounced
}
我们把测试用例也写掉
<body>
<div id="container"></div>
<button id="btn">点击取消防抖</button>
<script src="debounce.js"></script>
<script>
let count = 1
let container = document.getElementById('container')
function getUserAction(e) {
let ret = count
container.innerHTML = count++
}
const getDebounceResult = debounce(getUserAction, 2000, true)
container.onmousemove = getDebounceResult
// 点击按钮重置防抖定时
document.getElementById('btn').addEventListener('click', function() {
getDebounceResult.cancel()
})
</script>
</body>
在防抖计时过程中,点一下按钮然后马上进入检测区域,数字会马上+1,说明我们成功重置了防抖计时。
3 节流 throttle
节流的原理很简单:如果你持续触发事件,每隔一段时间,只执行一次事件。
关于节流的实现,有两种主流的实现方式,一种是使用时间戳,一种是设置定时器。
使用时间戳(有头无尾)
使用时间戳,当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳(最一开始值设为 0 ),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。
// 时间戳版
function throttle(fn, delay) {
let previous = 0
let self
return function (...args) {
const now = +new Date()
self = this
// 持续一段时间就触发事件
if (now - previous > delay) {
fn.apply(self, args)
previous = now
}
}
}
这样鼠标移入立即执行,时间间隔不满 delay
就不会执行,所以“有头无尾”。
使用定时器(有尾无头)
当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。
// 定时器版
function throttle(fn, delay) {
let timeout
let self
return function (...args) {
self = this
if (!timeout) {
timeout = setTimeout(() => {
timeout = null
fn.apply(self, args)
}, delay)
}
}
}
鼠标移入不会立即执行,而是等待了 delay
秒后执行。当你移除鼠标,仍然会在满 delay
后再次执行,所以“有尾无头”。
比较两个方法:
- 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
- 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后依然会再执行一次事件
有头有尾(最精彩的一段代码)
就是鼠标移入能立刻执行,停止触发的时候还能再执行一次!
function throttle(fn, delay) {
let timeout
let self
let _args
let previous = 0
const later = function () {
previous = +new Date()
timeout = null
fn.apply(fn, _args)
}
const throttled = function (...args) {
const now = +new Date()
const remaining = delay - (now - previous)
self = this
_args = args
if (remaining <= 0 || remaining > delay) {
if (timeout) {
clearTimeout(timeout)
}
previous = now
fn.apply(self, args)
} else if (!timeout) {
timeout = setTimeout(later, remaining)
}
}
return throttled
}
第一次触发
onmousemove
事件:previous = 0
,那么remaining < 0
,timeout
未定义,更新previous
,执行fn.apply(self, args)
,屏幕显示出 1,那么这是“头”。紧接着第二次触发:此时
0 < remaining < delay
,并且timeout = undefined
,所以走下面的else if
,此时便设置好了定时器!后面的若干次触发:依然是
0 < remaining < delay
,然而timeout
已经有值,因此什么也不会做。终于第二次设置的
setTimeout
设置的时间到了,执行later
函数,这个函数里面更新了previous
,执行fn.apply(self, _args)
,重置了timeout
,因此屏幕显示出 2。要注意的是,在这个时间内,你即使将鼠标不动了 or 移出去,上一步我们设置的定时器依然在,所以这就是有 “尾”。
后续重复上述步骤。
可调节头尾
/**
* options {
* leading: boolean,是否有头,
* trailing: boolean,是否有尾
* }
*/
function throttle(fn, delay, options) {
let timeout
let self
let _args
let previous = 0
options = options || {}
const later = function () {
previous = options.leading ? +new Date() : 0
timeout = null
fn.apply(fn, _args)
if (!timeout) self = _args = null
}
const throttled = function (...args) {
const now = +new Date()
// 如果leading = false,则让首次触发事件时 previous = now
if (previous === 0 && !options.leading) previous = now
const remaining = delay - (now - previous)
self = this
_args = args
if (remaining <= 0 || remaining > delay) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
fn.apply(self, args)
if (!timeout) self = _agrs = null
} else if (!timeout && options.trailing) {
timeout = setTimeout(later, remaining)
}
}
return throttled
}
大致原理和有头有尾版的类似。我们可以进行”控制变量法“来分别看leading
和 trailing
的作用。
context = args = null
主要是为了释放内存,回收垃圾。
4 requestAnimationFrame(rAF)
requestAnimationFrame
是另一种能控制一个 function
执行速率的函数。理论上等价于 throttle(fn, 16.7)
在做动画时经常用到这种方式,防止在一帧时间中渲染多次造成性能浪费。而这个一帧
是取决于显示器帧数的,比如是显示器为60FPS,则一帧时间为 1000 / 60 = 16.7ms 。
// throttle(fn, 帧时间)
function useRAF(fn) {
let timer
return function () {
timer && cancelAnimationFrame(timer)
timer = requestAnimationFrame(fn)
}
}
优势
- 目标为 60fps,而且游览器会优化到最佳渲染时间;
- 使用非常简单,而且是标准Web API,不用担心维护。
劣势
- 不支持
Node.js
- 当
requestAnimationFrame()
运行在后台标签页或者隐藏的<iframe>
里时,requestAnimationFrame()
会被暂停调用以提升性能和电池寿命。当然对于鼠标键盘这类事件来说问题不大。
由以上,可以知道 rAF
适用于网页动画、鼠标键盘事件等等,但是不适用于网络异步请求等场景。