更新:謝謝大家的支援,最近折騰了一個部落格官網出來,方便大家系統閱讀,後續會有更多內容和更多優化,猛戳這裡檢視
------ 以下是正文 ------
引言
上一節我們學習了 Lodash 中防抖和節流函式是如何實現的,並對原始碼淺析一二,今天這篇文章會通過七個小例子為切入點,換種方式繼續解讀原始碼。其中原始碼解析上篇文章已經非常詳細介紹了,這裡就不再重複,建議本文配合上文一起服用,猛戳這裡學習
有什麼想法或者意見都可以在評論區留言,歡迎大家拍磚。
節流函式 Throttle
我們先來看一張圖,這張圖充分說明了 Throttle(節流)和 Debounce(防抖)的區別,以及在不同配置下產生的不同效果,其中 mousemove
事件每 50 ms 觸發一次,即下圖中的每一小隔是 50 ms。今天這篇文章就從下面這張圖開始介紹。
角度 1
lodash.throttle(fn, 200, {leading: true, trailing: true})
mousemove 第一次觸發
先來看下 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
這部分。
先打個預防針,後面即將開始比較難的部分,看下 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})
來說,會經歷如下過程。
- 1、
shouldInvoke(time)
中,因為滿足條件lastCallTime === undefined
,所以返回 true - 2、
lastCallTime = time
,所以lastCallTime
等於當前時間,假設為 0 - 3、
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
}
複製程式碼
- 4、在
leadingEdge(time)
中,設定lastInvokeTime
為當前時間即 0,開啟 200 毫秒定時器,執行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
}
複製程式碼
- 5、在
invokeFunc(time)
中,執行func.apply(thisArg, args)
,即 fn 函式第一次執行,並把結果賦值給result
,便於後續觸發時直接返回。同時重置lastInvokeTime
為當前時間即 0,清空lastArgs
和lastThis
。 - 6、第一次觸發已經完成,注意此時
lastCallTime
和lastInvokeTime
都為 0,200 毫秒的定時器還在執行中。
mousemove 第二次觸發
50 毫秒後第二次觸發到來,此時當前時間 time
為 50,wait
為 200, maxWait
為 200,maxing
為 true,lastCallTime
和 lastInvokeTime
都為 0,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) )
}
複製程式碼
- 1、
shouldInvoke(time)
中,timeSinceLastCall
為 50,timeSinceLastInvoke
為 50,4 種條件都不滿足,返回 false。 - 2、此時
isInvoking
為 false,同時timerId === undefined
不滿足,直接返回第一次觸發時的result
- 3、第二次觸發完成,並不會執行 fn,只會返回上次執行的結果
result
- 4、第三次和第四次觸發時,效果一樣,就不再重複了。
mousemove 第五次觸發
距第一次觸發 200 毫秒後第五次觸發到來,此時當前時間 time
為 200,wait
為 200, maxWait
為 200,maxing
為 true,lastCallTime
為 150, lastInvokeTime
為 0,timerId
定時器存在,我們來看下執行步驟。
- 1、
shouldInvoke(time)
中,timeSinceLastInvoke
為 200,滿足(maxing && timeSinceLastInvoke >= maxWait)
,所以返回 true
// debounced 方法中執行到這部分
if (maxing) {
// 迴圈定時器中處理呼叫
timerId = startTimer(timerExpired, wait)
return invokeFunc(lastCallTime)
}
複製程式碼
- 2、滿足
maxing
條件,重新開啟 200 毫秒的定時器,並執行invokeFunc(lastCallTime)
函式 - 3、
invokeFunc(time)
中,重置lastInvokeTime
為當前時間即 200,清空lastArgs
和lastThis
- 4、第六、七、八次觸發時,同第二次觸發效果一致,就不再重複了。
mousemove 停止觸發
假設第八次觸發之後就停止了滾動,在第八次觸發時 time
為 350,所以如果有第九次觸發,那麼此時是應該執行fn 的,但是此時 mousemove 已經停止了觸發,那麼還會執行 fn 嗎?答案是依舊執行,因為最開始設定了 {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
函式,來看下具體的程式碼。
// 定時器回撥函式,表示定時結束後的操作
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。
這裡需要說明以下兩點
- 如果設定了
{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)
方法就可以了。
// 執行連續事件剛開始的那次回撥
function leadingEdge(time) {
// 1、設定上一次執行 func 的時間
lastInvokeTime = time
// 2、開啟定時器,為了事件結束後的那次回撥
timerId = startTimer(timerExpired, wait)
// 3、如果配置了 leading 執行傳入函式 func
// leading 來源自 !!options.leading
return leading ? invokeFunc(time) : result
}
複製程式碼
在這裡,會開啟 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
值,所以具體觸發過程中的判斷就不一樣了,來詳細看一遍。
- 1、在入口函式
debounced
中,執行shouldInvoke(time)
,前面討論過因為第一次觸發所以會返回 true,之後執行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
}
複製程式碼
- 2、在
leadingEdge
中,因為leading
為 false,所以並不執行 fn,只開啟 200 毫秒的定時器,並返回undefined
。此時lastInvokeTime
為當前時間,假設為 0。
// 判斷此時是否應該執行 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) )
}
複製程式碼
- 3、之後每次觸發時,
timeSinceLastCall
總是為 50 毫秒,maxing
為 false,所以shouldInvoke(time)
總是返回 false,並不會執行傳入函式 fn,只返回 result,即為undefined
。 - 4、到現在為止,fn 一次還沒有執行,200 毫秒後,定時器回撥函式觸發,執行
timerExpired
函式
// 定時器回撥函式,表示定時結束後的操作
function timerExpired() {
const time = Date.now()
// 1、是否需要執行
// 執行事件結束後的那次回撥,否則重啟定時器
if (shouldInvoke(time)) {
return trailingEdge(time)
}
// 2、否則 計算剩餘等待時間,重啟定時器,保證下一次時延的末尾觸發
timerId = startTimer(timerExpired, remainingWait(time))
}
複製程式碼
- 5、此時存在兩種情況,第一種是
mousemove
事件一直在觸發,根據前面介紹shouldInvoke(time)
會返回 false,之後就將計算剩餘等待時間,重啟定時器。時間計算公式為wait - (time - lastCallTime)
,即 200 - 50,所以只要shouldInvoke(time)
返回 false,就每隔 150 毫秒後執行一次timerExpired()
。 - 6、第二種情況是
mousemove
事件不再觸發,因為timerExpired()
在迴圈執行,所以肯定會存在一種情況滿足timeSinceLastCall >= wait
,即shouldInvoke(time)
返回 true,終結timerExpired()
的迴圈,並執行trailingEdge(time)
。
// 執行連續事件結束後的那次回撥
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
}
複製程式碼
- 7、在
trailingEdge
中trailing
和lastArgs
都是 true,所以會執行invokeFunc(time)
,即執行傳入函式 fn。 - 8、所以整個過程中只在最後執行一次傳入函式 fn,效果同上面第一張圖所示。
角度 5
lodash.debounce(fn, 200, {leading: true, trailing: false})
此時相比角度 4 來說,差異在於 {leading: true, trailing: false}
,但是 wait
和 maxWait
都和角度 4 一致,所以只存在下面 2 種區別,效果同上面第一張圖所示。
- 區別 1:
leadingEdge
中會執行傳入函式 fn - 區別 2:
trailingEdge
中不再執行傳入函式 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,我們來看下執行過程。
- 1、第一次觸發時,因為
{leading: false}
,所以肯定不會執行 fn,此時開啟了一個 200 毫秒的定時器。
// 判斷此時是否應該執行 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) )
}
複製程式碼
- 2、之後每隔 50 毫秒觸發一次,每次都會執行
shouldInvoke(time)
函式,只有在第 400 毫秒時,才會滿足maxing && timeSinceLastInvoke >= maxWait
,返回 true。
// 計算仍需等待的時間
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
}
複製程式碼
- 3、但是在這之前的第 200 毫秒,定時器觸發回撥函式,執行
timerExpired
,因為此時shouldInvoke(time)
返回 false,所以會重新計算剩餘等待時間並重啟計時器,其中timeWaiting
是 150 毫秒,maxWait - timeSinceLastInvoke
是 200 毫秒,所以計算結果是150 毫秒。 - 4、150 毫秒之後,即自開始之後的第 350 毫秒時,會重新計算時間,其中
timeWaiting
依舊是 150 毫秒,maxWait - timeSinceLastInvoke
是 50 毫秒,所以重新開啟 50 毫秒的定時器,即在第 400 毫秒時觸發。 - 5、此時會發現定時器觸發的時間是第 400 毫秒,
shouldInvoke(time)
中返回 true 的時間也是在第 400 毫秒,為什麼要這樣呢?這樣會衝突嗎?首先定時器剩餘時間判斷和shouldInvoke(time)
判斷中,只要有一處滿足執行 fn 條件,就會立馬執行,同時lastInvokeTime
值也會發生改變,所以另一處判斷就不會生效了。另外本身定時器是不精準的,所以通過Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
取最小值的方式來減少誤差。 - 6、於此同時,需要在 debounced 入口函式新增這麼一句
if (timerId === undefined) {timerId = startTimer(timerExpired, wait)}
,避免trailingEdge
執行後定時器被清空。 - 7、最終效果和節流是一樣的,只是時間間隔變大了而已,具體效果同第一張圖所示。
上期答疑
第一題
問:如果 leading
和 trailing
選項都是 true,在 wait
期間只呼叫了一次 debounced
函式時,總共會呼叫幾次 func
,1 次還是 2 次,為什麼?
答案是 1 次,為什麼?文中已給出詳細解答,詳情請看角度 1 和角度 6。
第二題
問:如何給 debounce(func, time, options)
中的 func
傳引數?
第一種方案,因為 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);
複製程式碼
參考
推薦閱讀
❤️ 看完三件事
如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個小忙:
- 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
- 關注我的 GitHub,讓我們成為長期關係
- 關注公眾號「高階前端進階」,每週重點攻克一個前端面試重難點,公眾號後臺回覆「資料」 送你精選前端優質資料。