Open yygmind opened 5 years ago
上一节我们学习了 Lodash 中防抖和节流函数是如何实现的,并对源码浅析一二,今天这篇文章会通过七个小例子为切入点,换种方式继续解读源码。其中源码解析上篇文章已经非常详细介绍了,这里就不再重复,建议本文配合上文一起服用,猛戳这里学习
有什么想法或者意见都可以在评论区留言,欢迎大家拍砖。
我们先来看一张图,这张图充分说明了 Throttle(节流)和 Debounce(防抖)的区别,以及在不同配置下产生的不同效果,其中 mousemove 事件每 50 ms 触发一次,即下图中的每一小隔是 50 ms。今天这篇文章就从下面这张图开始介绍。
mousemove
lodash.throttle(fn, 200, {leading: true, trailing: true})
先来看下 throttle 源码
function throttle(func, wait, options) { // 首尾调用默认为 true let leading = true let trailing = true if (typeof func !== 'function') { throw new TypeError('Expected a function') } // options 是否是对象 if (isObject(options)) { leading = 'leading' in options ? !!options.leading : leading trailing = 'trailing' in options ? !!options.trailing : trailing } // maxWait 为 wait 的防抖函数 return debounce(func, wait, { leading, trailing, 'maxWait': wait, }) }
所以 throttle(fn, 200, {leading: true, trailing: true}) 返回内容是 debounce(fn, 200, {leading: true, trailing: true, maxWait: 200}),多了 maxWait: 200 这部分。
throttle(fn, 200, {leading: true, trailing: true})
debounce(fn, 200, {leading: true, trailing: true, maxWait: 200})
maxWait: 200
先打个预防针,后面即将开始比较难的部分,看下 debounce 入口函数。
// 入口函数,返回此函数 function debounced(...args) { // 获取当前时间 const time = Date.now() // 判断此时是否应该执行 func 函数 const isInvoking = shouldInvoke(time) // 赋值给闭包,用于其他函数调用 lastArgs = args lastThis = this lastCallTime = time // 执行 if (isInvoking) { // 无 timerId 的情况有两种: // 1、首次调用 // 2、trailingEdge 执行过函数 if (timerId === undefined) { return leadingEdge(lastCallTime) } // 如果设置了最大等待时间,则立即执行 func // 1、开启定时器,到时间后触发 trailingEdge 这个函数。 // 2、执行 func,并返回结果 if (maxing) { // 循环定时器中处理调用 timerId = startTimer(timerExpired, wait) return invokeFunc(lastCallTime) } } // 一种特殊情况,trailing 设置为 true 时,前一个 wait 的 trailingEdge 已经执行了函数 // 此时函数被调用时 shouldInvoke 返回 false,所以要开启定时器 if (timerId === undefined) { timerId = startTimer(timerExpired, wait) } // 不需要执行时,返回结果 return result }
对于 debounce(fn, 200, {leading: true, trailing: true, maxWait: 200}) 来说,会经历如下过程。
shouldInvoke(time)
lastCallTime === undefined
lastCallTime = time
lastCallTime
timerId === undefined
leadingEdge(lastCallTime)
// 执行连续事件刚开始的那次回调 function leadingEdge(time) { // 1、设置上一次执行 func 的时间 lastInvokeTime = time // 2、开启定时器,为了事件结束后的那次回调 timerId = startTimer(timerExpired, wait) // 3、如果配置了 leading 执行传入函数 func // leading 来源自 !!options.leading return leading ? invokeFunc(time) : result }
leadingEdge(time)
lastInvokeTime
invokeFunc(time)
// 执行 Func 函数 function invokeFunc(time) { // 获取上一次执行 debounced 的参数 const args = lastArgs // 获取上一次的 this const thisArg = lastThis // 重置 lastArgs = lastThis = undefined lastInvokeTime = time result = func.apply(thisArg, args) return result }
func.apply(thisArg, args)
result
lastArgs
lastThis
50 毫秒后第二次触发到来,此时当前时间 time 为 50,wait 为 200, maxWait 为 200,maxing 为 true,lastCallTime 和 lastInvokeTime 都为 0,timerId 定时器存在,我们来看下执行步骤。
time
wait
maxWait
maxing
timerId
function shouldInvoke(time) { // 当前时间距离上一次调用 debounce 的时间差 const timeSinceLastCall = time - lastCallTime // 当前时间距离上一次执行 func 的时间差 const timeSinceLastInvoke = time - lastInvokeTime // 下述 4 种情况返回 true return ( lastCallTime === undefined || (timeSinceLastCall >= wait) || (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait) ) }
timeSinceLastCall
timeSinceLastInvoke
isInvoking
距第一次触发 200 毫秒后第五次触发到来,此时当前时间 time 为 200,wait 为 200, maxWait 为 200,maxing 为 true,lastCallTime 为 150, lastInvokeTime 为 0,timerId 定时器存在,我们来看下执行步骤。
(maxing && timeSinceLastInvoke >= maxWait)
// debounced 方法中执行到这部分 if (maxing) { // 循环定时器中处理调用 timerId = startTimer(timerExpired, wait) return invokeFunc(lastCallTime) }
invokeFunc(lastCallTime)
假设第八次触发之后就停止了滚动,在第八次触发时 time 为 350,所以如果有第九次触发,那么此时是应该执行fn 的,但是此时 mousemove 已经停止了触发,那么还会执行 fn 吗?答案是依旧执行,因为最开始设置了 {trailing: true}。
{trailing: true}
// 开启定时器 function startTimer(pendingFunc, wait) { // 没传 wait 时调用 window.requestAnimationFrame() if (useRAF) { // 若想在浏览器下次重绘之前继续更新下一帧动画 // 那么回调函数自身必须再次调用 window.requestAnimationFrame() root.cancelAnimationFrame(timerId); return root.requestAnimationFrame(pendingFunc) } // 不使用 RAF 时开启定时器 return setTimeout(pendingFunc, wait) }
在第五次触发时开启了 200 毫秒的定时器,所以在时间 time 到 400 时会执行 pendingFunc,此时的 pendingFunc 就是 timerExpired 函数,来看下具体的代码。
pendingFunc
timerExpired
// 定时器回调函数,表示定时结束后的操作 function timerExpired() { const time = Date.now() // 1、是否需要执行 // 执行事件结束后的那次回调,否则重启定时器 if (shouldInvoke(time)) { return trailingEdge(time) } // 2、否则 计算剩余等待时间,重启定时器,保证下一次时延的末尾触发 timerId = startTimer(timerExpired, remainingWait(time)) }
此时在 shouldInvoke(time) 中,time 为 400,lastInvokeTime 为 200,timeSinceLastInvoke 为 200,满足 (maxing && timeSinceLastInvoke >= maxWait),所以返回 true。
// 执行连续事件结束后的那次回调 function trailingEdge(time) { // 清空定时器 timerId = undefined // trailing 和 lastArgs 两者同时存在时执行 // trailing 来源自 'trailing' in options ? !!options.trailing : trailing // lastArgs 标记位的作用,意味着 debounce 至少执行过一次 if (trailing && lastArgs) { return invokeFunc(time) } // 清空参数 lastArgs = lastThis = undefined return result }
之后执行 trailingEdge(time),在这个函数中判断 trailing 和 lastArgs ,此时这两个条件都是 true,所以会执行 invokeFunc(time),最终执行函数 fn。
trailingEdge(time)
trailing
这里需要说明以下两点
{trailing: false}
throttle
debounce
debounced
undefined
lodash.throttle(fn, 200, {leading: true, trailing: false})
在「角度 1 之 mousemove 停止触发」这部分中说到,如果不设置 trailing 和设置 {trailing: true} 效果是一样的,事件回调结束后都会再执行一次传入函数 fn,但是如果设置了{trailing: false},那么事件回调结束后是不会再执行 fn 的。
此时的配置对比角度 1 来说,区别在于设置了{trailing: false},所以实际效果对比 1 来说,就是最后不会额外再执行一次,效果见第一张图。
lodash.throttle(fn, 200, {leading: false, trailing: true})
此时的配置和角度 1 相比,区别在于设置了 {leading: false},所以直接看 leadingEdge(time) 方法就可以了。
{leading: false}
在这里,会开启 200 毫秒的定时器,同时因为 leading 为 false,所以并不会执行 invokeFunc(time) ,只会返回 result,此时的 result 值是 undefined。
leading
这里开启一个定时器的目的是为了事件结束后的那次回调,即如果设置了 {trailing: true} 那么最后一次回调将执行传入函数 fn,哪怕 debounced 函数只触发一次。
这里指定了 {leading: false},那么 leading 的初始值是什么呢?在 debounce 中是 false,在 throttle 中是 true。所以在 throttle 中不需要刚开始就触发时,必须指定 {leading: false},在 debounce 中就不需要了,默认不触发。
lodash.debounce(fn, 200, {leading: false, trailing: true})
此时相比较 throttle 来说,缺少了 maxWait 值,所以具体触发过程中的判断就不一样了,来详细看一遍。
leadingEdge
// 判断此时是否应该执行 func 函数 function shouldInvoke(time) { // 当前时间距离上一次调用 debounce 的时间差 const timeSinceLastCall = time - lastCallTime // 当前时间距离上一次执行 func 的时间差 const timeSinceLastInvoke = time - lastInvokeTime // 下述 4 种情况返回 true return ( lastCallTime === undefined || (timeSinceLastCall >= wait) || (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait) ) }
wait - (time - lastCallTime)
timerExpired()
timeSinceLastCall >= wait
trailingEdge
lodash.debounce(fn, 200, {leading: true, trailing: false})
此时相比角度 4 来说,差异在于 {leading: true, trailing: false},但是 wait 和 maxWait 都和角度 4 一致,所以只存在下面 2 种区别,效果同上面第一张图所示。
{leading: true, trailing: false}
lodash.debounce(fn, 200, {leading: true, trailing: true})
此时相比角度 4 来说,差异仅仅在于设置了 {leading: true},所以只存在一个区别,那就是在 leadingEdge 中会执行传入函数 fn,当然在 trailingEdge 中依旧执行传入函数 fn,所以会出现在 mousemove 事件触发过程中首尾都会执行的情况,效果同上面第一张图所示。
{leading: true}
当然一种情况除外,那就是 mousemove 事件永远只触发一次的情况,关键在于 lastArgs 变量。
对于 lastArgs 变量来说,在入口函数 debounced 中赋值,即每次触发都会重新赋值一次,那什么时候清空呢,在 invokeFunc(time) 中重置为 undefined,所以如果 debounced 只触发了一次,而且在 {leading: true} 时执行过一次 fn,那么即使设置了 {trailing: true} 也不会再执行传入函数 fn。
lodash.debounce(fn, 200, {leading: false, trailing: true, maxWait: 400})
此时 wait 为 200,maxWait 为 400,maxing 为 true,我们来看下执行过程。
maxing && timeSinceLastInvoke >= maxWait
// 计算仍需等待的时间 function remainingWait(time) { // 当前时间距离上一次调用 debounce 的时间差 const timeSinceLastCall = time - lastCallTime // 当前时间距离上一次执行 func 的时间差 const timeSinceLastInvoke = time - lastInvokeTime // 剩余等待时间 const timeWaiting = wait - timeSinceLastCall // 是否设置了最大等待时间 // 是(节流):返回「剩余等待时间」和「距上次执行 func 的剩余等待时间」中的最小值 // 否:返回剩余等待时间 return maxing ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting }
timeWaiting
maxWait - timeSinceLastInvoke
Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
if (timerId === undefined) {timerId = startTimer(timerExpired, wait)}
问:如果 leading 和 trailing 选项都是 true,在 wait 期间只调用了一次 debounced 函数时,总共会调用几次 func,1 次还是 2 次,为什么?
func
答案是 1 次,为什么?文中已给出详细解答,详情请看角度 1 和角度 6。
问:如何给 debounce(func, time, options) 中的 func 传参数?
debounce(func, time, options)
第一种方案,因为 debounced 函数可以接受参数,所以可以用高阶函数的方式传参,如下
const params = 'muyiy'; const debounced = lodash.debounce(func, 200)(params) window.addEventListener('mousemove', debounced);
不过这种方式不太友好,params 会将原来的 event 覆盖掉,此时就拿不到 scroll 或者 mousemove 等事件对象 event 了。
第二种方案,在监听函数上处理,使用闭包保存传入参数并返回需要执行的函数即可。
function onMove(param) { console.log('param:', param); // muyiy function func(event) { console.log('param:', param); // muyiy console.log('event:', event); // event } return func; }
使用时如下
const params = 'muyiy'; const debounced = lodash.debounce(onMove(params), 200) window.addEventListener('mousemove', debounced);
函数防抖 (debounce) 和节流 (throttle) 以及 lodash 的 debounce 源码赏析
【进阶 6-3 期】深入浅出节流函数 throttle 【进阶 6-4 期】深入浅出防抖函数 debounce 【进阶 6-5 期】[译] Throttle 和 Debounce 在 React 中的应用 【进阶 6-6 期】深入篇 | 阿里 P6 必会 Lodash 防抖节流函数实现原理
【进阶 6-3 期】深入浅出节流函数 throttle
【进阶 6-4 期】深入浅出防抖函数 debounce
【进阶 6-5 期】[译] Throttle 和 Debounce 在 React 中的应用
【进阶 6-6 期】深入篇 | 阿里 P6 必会 Lodash 防抖节流函数实现原理
如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:
博主怎么断更了?
引言
上一节我们学习了 Lodash 中防抖和节流函数是如何实现的,并对源码浅析一二,今天这篇文章会通过七个小例子为切入点,换种方式继续解读源码。其中源码解析上篇文章已经非常详细介绍了,这里就不再重复,建议本文配合上文一起服用,猛戳这里学习
有什么想法或者意见都可以在评论区留言,欢迎大家拍砖。
节流函数 Throttle
我们先来看一张图,这张图充分说明了 Throttle(节流)和 Debounce(防抖)的区别,以及在不同配置下产生的不同效果,其中
mousemove
事件每 50 ms 触发一次,即下图中的每一小隔是 50 ms。今天这篇文章就从下面这张图开始介绍。角度 1
lodash.throttle(fn, 200, {leading: true, trailing: true})
mousemove 第一次触发
先来看下 throttle 源码
所以
throttle(fn, 200, {leading: true, trailing: true})
返回内容是debounce(fn, 200, {leading: true, trailing: true, maxWait: 200})
,多了maxWait: 200
这部分。先打个预防针,后面即将开始比较难的部分,看下 debounce 入口函数。
对于
debounce(fn, 200, {leading: true, trailing: true, maxWait: 200})
来说,会经历如下过程。shouldInvoke(time)
中,因为满足条件lastCallTime === undefined
,所以返回 truelastCallTime = time
,所以lastCallTime
等于当前时间,假设为 0timerId === undefined
满足,执行leadingEdge(lastCallTime)
方法leadingEdge(time)
中,设置lastInvokeTime
为当前时间即 0,开启 200 毫秒定时器,执行invokeFunc(time)
并返回invokeFunc(time)
中,执行func.apply(thisArg, args)
,即 fn 函数第一次执行,并把结果赋值给result
,便于后续触发时直接返回。同时重置lastInvokeTime
为当前时间即 0,清空lastArgs
和lastThis
。lastCallTime
和lastInvokeTime
都为 0,200 毫秒的定时器还在运行中。mousemove 第二次触发
50 毫秒后第二次触发到来,此时当前时间
time
为 50,wait
为 200,maxWait
为 200,maxing
为 true,lastCallTime
和lastInvokeTime
都为 0,timerId
定时器存在,我们来看下执行步骤。shouldInvoke(time)
中,timeSinceLastCall
为 50,timeSinceLastInvoke
为 50,4 种条件都不满足,返回 false。isInvoking
为 false,同时timerId === undefined
不满足,直接返回第一次触发时的result
result
mousemove 第五次触发
距第一次触发 200 毫秒后第五次触发到来,此时当前时间
time
为 200,wait
为 200,maxWait
为 200,maxing
为 true,lastCallTime
为 150,lastInvokeTime
为 0,timerId
定时器存在,我们来看下执行步骤。shouldInvoke(time)
中,timeSinceLastInvoke
为 200,满足(maxing && timeSinceLastInvoke >= maxWait)
,所以返回 truemaxing
条件,重新开启 200 毫秒的定时器,并执行invokeFunc(lastCallTime)
函数invokeFunc(time)
中,重置lastInvokeTime
为当前时间即 200,清空lastArgs
和lastThis
mousemove 停止触发
假设第八次触发之后就停止了滚动,在第八次触发时
time
为 350,所以如果有第九次触发,那么此时是应该执行fn 的,但是此时 mousemove 已经停止了触发,那么还会执行 fn 吗?答案是依旧执行,因为最开始设置了{trailing: true}
。在第五次触发时开启了 200 毫秒的定时器,所以在时间
time
到 400 时会执行pendingFunc
,此时的pendingFunc
就是timerExpired
函数,来看下具体的代码。此时在
shouldInvoke(time)
中,time
为 400,lastInvokeTime
为 200,timeSinceLastInvoke
为 200,满足(maxing && timeSinceLastInvoke >= maxWait)
,所以返回 true。之后执行
trailingEdge(time)
,在这个函数中判断trailing
和lastArgs
,此时这两个条件都是 true,所以会执行invokeFunc(time)
,最终执行函数 fn。这里需要说明以下两点
{trailing: false}
,那么最后一次是不会执行的。对于throttle
和debounce
来说,默认值是 true,所以如果没有特意指定trailing
,那么最后一次是一定会执行的。lastArgs
来说,执行debounced
时会赋值,即每次触发都会重新赋值一次,那什么时候清空呢,在invokeFunc(time)
中执行 fn 函数时重置为undefined
,所以如果debounced
只触发了一次,即使设置了{trailing: true}
那也不会再执行 fn 函数,这个就解答了上篇文章留下的第一道思考题。角度 2
lodash.throttle(fn, 200, {leading: true, trailing: false})
在「角度 1 之 mousemove 停止触发」这部分中说到,如果不设置
trailing
和设置{trailing: true}
效果是一样的,事件回调结束后都会再执行一次传入函数 fn,但是如果设置了{trailing: false}
,那么事件回调结束后是不会再执行 fn 的。此时的配置对比角度 1 来说,区别在于设置了
{trailing: false}
,所以实际效果对比 1 来说,就是最后不会额外再执行一次,效果见第一张图。角度 3
lodash.throttle(fn, 200, {leading: false, trailing: true})
此时的配置和角度 1 相比,区别在于设置了
{leading: false}
,所以直接看leadingEdge(time)
方法就可以了。在这里,会开启 200 毫秒的定时器,同时因为
leading
为 false,所以并不会执行invokeFunc(time)
,只会返回result
,此时的result
值是undefined
。这里开启一个定时器的目的是为了事件结束后的那次回调,即如果设置了
{trailing: true}
那么最后一次回调将执行传入函数 fn,哪怕debounced
函数只触发一次。这里指定了
{leading: false}
,那么leading
的初始值是什么呢?在debounce
中是 false,在throttle
中是 true。所以在throttle
中不需要刚开始就触发时,必须指定{leading: false}
,在debounce
中就不需要了,默认不触发。防抖函数 Debounce
角度 4
lodash.debounce(fn, 200, {leading: false, trailing: true})
此时相比较 throttle 来说,缺少了
maxWait
值,所以具体触发过程中的判断就不一样了,来详细看一遍。debounced
中,执行shouldInvoke(time)
,前面讨论过因为第一次触发所以会返回 true,之后执行leadingEdge(lastCallTime)
。leadingEdge
中,因为leading
为 false,所以并不执行 fn,只开启 200 毫秒的定时器,并返回undefined
。此时lastInvokeTime
为当前时间,假设为 0。timeSinceLastCall
总是为 50 毫秒,maxing
为 false,所以shouldInvoke(time)
总是返回 false,并不会执行传入函数 fn,只返回 result,即为undefined
。timerExpired
函数mousemove
事件一直在触发,根据前面介绍shouldInvoke(time)
会返回 false,之后就将计算剩余等待时间,重启定时器。时间计算公式为wait - (time - lastCallTime)
,即 200 - 50,所以只要shouldInvoke(time)
返回 false,就每隔 150 毫秒后执行一次timerExpired()
。mousemove
事件不再触发,因为timerExpired()
在循环执行,所以肯定会存在一种情况满足timeSinceLastCall >= wait
,即shouldInvoke(time)
返回 true,终结timerExpired()
的循环,并执行trailingEdge(time)
。trailingEdge
中trailing
和lastArgs
都是 true,所以会执行invokeFunc(time)
,即执行传入函数 fn。角度 5
lodash.debounce(fn, 200, {leading: true, trailing: false})
此时相比角度 4 来说,差异在于
{leading: true, trailing: false}
,但是wait
和maxWait
都和角度 4 一致,所以只存在下面 2 种区别,效果同上面第一张图所示。leadingEdge
中会执行传入函数 fntrailingEdge
中不再执行传入函数 fn角度 6
lodash.debounce(fn, 200, {leading: true, trailing: true})
此时相比角度 4 来说,差异仅仅在于设置了
{leading: true}
,所以只存在一个区别,那就是在leadingEdge
中会执行传入函数 fn,当然在trailingEdge
中依旧执行传入函数 fn,所以会出现在 mousemove 事件触发过程中首尾都会执行的情况,效果同上面第一张图所示。当然一种情况除外,那就是
mousemove
事件永远只触发一次的情况,关键在于lastArgs
变量。对于
lastArgs
变量来说,在入口函数debounced
中赋值,即每次触发都会重新赋值一次,那什么时候清空呢,在invokeFunc(time)
中重置为undefined
,所以如果debounced
只触发了一次,而且在{leading: true}
时执行过一次 fn,那么即使设置了{trailing: true}
也不会再执行传入函数 fn。角度 7
lodash.debounce(fn, 200, {leading: false, trailing: true, maxWait: 400})
此时
wait
为 200,maxWait
为 400,maxing
为 true,我们来看下执行过程。{leading: false}
,所以肯定不会执行 fn,此时开启了一个 200 毫秒的定时器。shouldInvoke(time)
函数,只有在第 400 毫秒时,才会满足maxing && timeSinceLastInvoke >= maxWait
,返回 true。timerExpired
,因为此时shouldInvoke(time)
返回 false,所以会重新计算剩余等待时间并重启计时器,其中timeWaiting
是 150 毫秒,maxWait - timeSinceLastInvoke
是 200 毫秒,所以计算结果是150 毫秒。timeWaiting
依旧是 150 毫秒,maxWait - timeSinceLastInvoke
是 50 毫秒,所以重新开启 50 毫秒的定时器,即在第 400 毫秒时触发。shouldInvoke(time)
中返回 true 的时间也是在第 400 毫秒,为什么要这样呢?这样会冲突吗?首先定时器剩余时间判断和shouldInvoke(time)
判断中,只要有一处满足执行 fn 条件,就会立马执行,同时lastInvokeTime
值也会发生改变,所以另一处判断就不会生效了。另外本身定时器是不精准的,所以通过Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
取最小值的方式来减少误差。if (timerId === undefined) {timerId = startTimer(timerExpired, wait)}
,避免trailingEdge
执行后定时器被清空。上期答疑
第一题
问:如果
leading
和trailing
选项都是 true,在wait
期间只调用了一次debounced
函数时,总共会调用几次func
,1 次还是 2 次,为什么?答案是 1 次,为什么?文中已给出详细解答,详情请看角度 1 和角度 6。
第二题
问:如何给
debounce(func, time, options)
中的func
传参数?第一种方案,因为
debounced
函数可以接受参数,所以可以用高阶函数的方式传参,如下不过这种方式不太友好,params 会将原来的 event 覆盖掉,此时就拿不到 scroll 或者 mousemove 等事件对象 event 了。
第二种方案,在监听函数上处理,使用闭包保存传入参数并返回需要执行的函数即可。
使用时如下
参考
推荐阅读
❤️ 看完三件事
如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙: