概念
防抖和节流是为了避免函数频繁执行,作为页面性能优化的一种手段。两者本质上都是规定在一定时间内函数只执行一次,只是侧重点不相同而已。在 lodash 的源码中,节流函数就是调用防抖函数实现的。对于防抖和节流的区别可以通过这个网站的 demo 去感受。
在早期的教程,函数防抖和节流推测并没有分开来讲。在 JavaScript 高级程序设计(第三版)22.3.3 函数节流看,虽然小节名是节流,但是代码从现在的角度看实现的是防抖的功能,在第四版中这小节也被删除了。
函数节流背后的基本思想是指,某些代码不可以在没有间断的情况连续重复执行。第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除前一次的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的是只有在执行函数的请求停止了一段时间之后才执行 --- JavaScript 高级程序设计(第三版)22.3.3 函数节流
var processor = {
timeoutId: null,
//实际进行处理的方法
performProcessing: function(){
//实际执行的代码
},
//初始处理调用的方法
process: function(){
clearTimeout(this.timeoutId);
var that = this;
this.timeoutId = setTimeout(function(){
that.performProcessing();
}, 100);
}
};
//尝试开始执行
processor.process();
函数防抖
函数防抖侧重点在于函数只取一次。在一定时间内连续触发多次,只有一次有效,多次触发会清除掉之前需要执行的函数。按照函数执行的先后顺序可以分为立即执行和非立即执行。
通过监听 input 表单的输入,来说明下防抖函数是如何限制函数执行的频率的。
<input type="text">
<script>
function ajax(e) {
//用console.log模拟发射请求
console.log(e.target.value);
}
document.querySelector('input').addEventListener('input', ajax);
</script>
运行如上 demo ,只要输入框发生变化,ajax 函数就执行。按照正常的要求,应该是当用户全部输入完成后 ajax 函数再执行。这种场景就非常适合用防抖实现。
用户在输入时,input 事件是连续触发,只取最后一刻的结果。防抖的代码如下:
function debounce(fn, wait, immediately = true) {
// 防抖:只取一次,在一定时间内连续触发多次,只有一次有效,多次触发会清除掉之前需要执行的函数.
// 按照函数执行的先后顺序可以分为立即执行和非立即执行
let timer = null;
return function (...args) {
// 存在定时器直接清除
if (timer) clearTimeout(timer);
if (immediately) {
// 没有定时器才执行函数,2种情况:首次调用和下次执行的间隔时间大于 wait 的时间
!timer && fn(...args);
timer = setTimeout(() => {
timer = null;
}, wait)
} else {
timer = setTimeout(() => {
fn(...args);
timer = null;
}, wait)
}
}
}
const _ajax = debounce(ajax, 500, false);
document.querySelector('input').addEventListener('input', _ajax);
函数节流
函数节流侧重点在于到点执行,在规定时间内(例如 500ms),只执行一次,500ms 后再次执行。
通过监听滚动条的变化,来说明下节流函数是如何限制函数执行的频率的。
window.addEventListener('scroll', ajax)
运行如上 demo ,只要滚动条发生移动,ajax 函数就执行。为了降低函数的执行频率,规定每隔 500ms 才执行一次。这种场景就非常适合用函数节流了。
function throttle(fn, wait, type = 'timestamp') {
// 节流:到点执行,在规定时间内(例如 500ms),只执行一次,500ms 后再次执行
// 用了时间戳(timestamp)和定时器(timer)两种实现方式
// 目标函数执行也有两种情况:首次调用和下次执行的间隔时间大于 wait 的时间
// 下面的方法没有实现首次调用的情况
let flag = type === 'timestamp' ? 0 : null;
return function (...args) {
if (type === 'timestamp') {
let nowTime = Date.now();
if (nowTime - flag >= wait) {
flag = nowTime;
fn(...args);
}
} else {
!flag &&
(flag = setTimeout(() => {
flag = null;
fn(...args);
}), wait)
}
}
}
const _ajax2 = throttle(ajax, 500, 'timer');
window.addEventListener('scroll', _ajax2)
以上代码只是简单的实现了基本的防抖和节流,下面我们看看 lodash 中如何实现的
lodash 防抖和节流实现
在 lodash 中,防抖和节流都是通过 debounce 函数实现的。目标函数执行有两种情况:首次调用和上次延时调用结束。
先看防抖的实现,理解防抖的时候可以暂时忽略 maxWait,节流会解释maxWait的作用
首次调用时:
执行 leadingEdge 函数,启动延时器函数 startTimer,leading 选项为true时表示目标函数 func 立即执行。延时器的作用是:在延时结束之后执行 trailingEdge 函数,trailing 选项为true时表示在延迟结束之后调用目标函数 func,最终结束一次 func 调用延迟的过程。
再次调用:
如果上一次的func延时调用已经结束,再次执行leadingEdge函数来启动延时过程。否则,忽略此次调用。(如果设置了maxWait且当前满足调用的时间条件,那么立即调用func并且启动新的延时器)
function debounce(func, wait, options) {
let lastArgs,
lastThis,
maxWait,
result,
timerId,
lastCallTime
let lastInvokeTime = 0
// leading 为真代表目标函数立即执行
let leading = false
let maxing = false
// trailing 为真代表目标函数非立即执行
let trailing = true
if (typeof func !== 'function') {
throw new TypeError('Expected a function')
}
wait = +wait || 0
if (isObject(options)) {
leading = !!options.leading
maxing = 'maxWait' in options
maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
trailing = 'trailing' in options ? !!options.trailing : trailing
}
// 调用目标函数
function invokeFunc(time) {
const args = lastArgs
const thisArg = lastThis
lastArgs = lastThis = undefined
lastInvokeTime = time
result = func.apply(thisArg, args)
return result
}
// 启动延时
function startTimer(pendingFunc, wait) {
return setTimeout(pendingFunc, wait)
}
//清除定时器
function cancelTimer(id) {
clearTimeout(id)
}
function leadingEdge(time) {
// Reset any `maxWait` timer.
lastInvokeTime = time
// Start the timer for the trailing edge.
timerId = startTimer(timerExpired, wait)
// Invoke the leading edge.
return leading ? invokeFunc(time) : result
}
//计算剩余的延时时间:
//1. 不存在maxWait:(上一次debouncedFunc调用后)延时不能超过wait
//2. 存在maxWait:func调用不能被延时超过maxWait
//根据这两种情况计算出最短时间
function remainingWait(time) {
const timeSinceLastCall = time - lastCallTime
const timeSinceLastInvoke = time - lastInvokeTime
const timeWaiting = wait - timeSinceLastCall
return maxing
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting
}
//判断当前时间是否能调用func:
//1.首次调用debouncedFunc
//2.距离上一次debouncedFunc调用后已延迟wait毫秒
//3.func调用总延迟达到maxWait毫秒
//4.系统时间倒退
function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime
const timeSinceLastInvoke = time - lastInvokeTime
return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
(timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
}
// 延时器回调
function timerExpired() {
const time = Date.now()
// 如果满足时间条件,结束延时
if (shouldInvoke(time)) {
return trailingEdge(time)
}
// Restart the timer.没满足时间条件,计算剩余等待时间,继续延时
timerId = startTimer(timerExpired, remainingWait(time))
}
//延时结束后
function trailingEdge(time) {
timerId = undefined
// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (trailing && lastArgs) {
return invokeFunc(time)
}
lastArgs = lastThis = undefined
return result
}
function debounced(...args) {
const time = Date.now()
const isInvoking = shouldInvoke(time)
lastArgs = args
lastThis = this
lastCallTime = time
if (isInvoking) {
//timerId不存在有两种原因:
//1. 首次调用
//2. 上次延时调用结束
if (timerId === undefined) {
return leadingEdge(lastCallTime)
}
// 存在func调用最长延时限制时,执行func并启动下一次延时,可实现throttle
if (maxing) {
// Handle invocations in a tight loop.
timerId = startTimer(timerExpired, wait)
return invokeFunc(lastCallTime)
}
}
if (timerId === undefined) {
timerId = startTimer(timerExpired, wait)
}
return result
}
return debounced
}
如果leading和trailing选项同时为true,那么func在一次防抖过程能被调用多次。lodash在debounce的基础上添加了maxWait选项,用于规定func调用不能延迟超过maxWait毫秒,也就是说每段maxWait时间内func一定会被调用一次。所以只要设置了maxWait选项,那么效果就等同于函数节流了。
这一点也可以通过lodash的throttle源码得到验证: throttle的wait作为debounce的maxWait传入。
function throttle(func, wait, options) {
let leading = true
let trailing = true
if (typeof func !== 'function') {
throw new TypeError('Expected a function')
}
if (isObject(options)) {
leading = 'leading' in options ? !!options.leading : leading
trailing = 'trailing' in options ? !!options.trailing : trailing
}
return debounce(func, wait, {
leading,
trailing,
'maxWait': wait
})
}