理解並優化函式節流Throttle
一、函式為什麼要節流
有如下程式碼
let n = 1
window.onmousemove = () => {
console.log(`第${n}次觸發回撥`)
n++
}
複製程式碼
當我們在PC端頁面上滑動滑鼠時,一秒可以可以觸發約60次事件。大家也可以訪問下面的線上例子進行測試。
檢視線上例子: 函式節流-監聽滑鼠移動觸發次數測試 by Logan (@logan70) on CodePen.
這裡的回撥函式只是列印字串,如果回撥函式更加複雜,可想而知瀏覽器的壓力會非常大,可能降低使用者體驗。
resize
、scroll
或mousemove
等事件的監聽回撥會被頻繁觸發,因此我們要對其進行限制。
二、實現思路
函式節流簡單來說就是對於連續的函式呼叫,每間隔一段時間,只讓其執行一次。初步的實現思路有兩種:
1. 使用時間戳
設定一個對比時間戳,觸發事件時,使用當前時間戳減去對比時間戳,如果差值大於設定的間隔時間,則執行函式,並用當前時間戳替換對比時間戳;如果差值小於設定的間隔時間,則不執行函式。
function throttle(method, wait) {
// 對比時間戳,初始化為0則首次觸發立即執行,初始化為當前時間戳則wait毫秒後觸發才會執行
let previous = 0
return function(...args) {
let context = this
let now = new Date().getTime()
// 間隔大於wait則執行method並更新對比時間戳
if (now - previous > wait) {
method.apply(context, args)
previous = now
}
}
}
複製程式碼
檢視線上例子: 函式節流-初步實現之時間戳 by Logan (@logan70) on CodePen.
2. 使用定時器
當首次觸發事件時,設定定時器,wait毫秒後執行函式並將定時器置為null
,之後觸發事件時,如果定時器存在則不執行,如果定時器不存在則再次設定定時器。
function throttle(method, wait) {
let timeout
return function(...args) {
let context = this
if (!timeout) {
timeout = setTimeout(() => {
timeout = null
method.apply(context, args)
}, wait)
}
}
}
複製程式碼
檢視線上例子: 函式節流-初步實現之定時器 by Logan (@logan70) on CodePen.
3. 兩種方法對比
- 首次觸發:使用時間戳實現時會立即執行(將previous設為0的情況);使用定時器實現會設定定時器,wait毫秒後執行。
- 停止觸發:使用時間戳實現時,停止觸發後不會再執行;使用定時器實現時,由於存在定時器,停止觸發後還會執行一次。
三、函式節流 Throttle 應用場景
- DOM 元素的拖拽功能實現(
mousemove
) - 射擊遊戲的
mousedown/keydown
事件(單位時間只能發射一顆子彈) - 計算滑鼠移動的距離(
mousemove
) - Canvas 模擬畫板功能(
mousemove
) - 搜尋聯想(
keyup
) - 監聽滾動事件判斷是否到頁面底部自動載入更多:給
scroll
加了debounce
後,只有使用者停止滾動後,才會判斷是否到了頁面底部;如果是throttle
的話,只要頁面滾動就會間隔一段時間判斷一次
四、函式節流最終版
程式碼說話,有錯懇請指出
function throttle(method, wait, {leading = true, trailing = true} = {}) {
// result 記錄method的執行返回值
let timeout, result
// 記錄上次原函式執行的時間(非每次更新)
let methodPrevious = 0
// 記錄上次回撥觸發時間(每次都更新)
let throttledPrevious = 0
let throttled = function(...args) {
let context = this
// 使用Promise,可以在觸發回撥時拿到原函式執行的返回值
return new Promise(resolve => {
let now = new Date().getTime()
// 兩次相鄰觸發的間隔
let interval = now - throttledPrevious
// 更新本次觸發時間供下次使用
throttledPrevious = now
// 重置methodPrevious為now,remaining = wait > 0,假裝剛執行過,實現禁止立即執行
// 統一條件:leading為false
// 加上以下條件之一
// 1. 首次觸發(此時methodPrevious為0)
// 2. trailing為true時,停止觸發時間超過wait,定時器內函式執行(methodPrevious被置為0),然後再次觸發
// 3. trailing為false時(不設定時器,methodPrevious不會被置為0),停止觸發時間超過wait後再次觸發(interval > wait)
if (leading === false && (!methodPrevious || interval > wait)) {
methodPrevious = now
// 保險起見,清除定時器並置為null
// 假裝剛執行過要假裝的徹底XD
if (timeout) {
clearTimeout(timeout)
timeout = null
}
}
// 距離下次執行原函式的間隔
let remaining = wait - (now - methodPrevious)
// 1. leading為true時,首次觸發就立即執行
// 2. 到達下次執行原函式時間
// 3. 修改了系統時間
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
// 更新對比時間戳,執行函式並記錄返回值,傳給resolve
methodPrevious = now
result = method.apply(context, args)
resolve(result)
// 解除引用,防止記憶體洩漏
if (!timeout) context = args = null
} else if (!timeout && trailing !== false) {
timeout = setTimeout(() => {
// leading為false時將methodPrevious設為0的目的在於
// 若不將methodPrevious設為0,如果定時器觸發後很長時間沒有觸發回撥
// 下次觸發時的remaining為負,原函式會立即執行,違反了leading為false的設定
methodPrevious = leading === false ? 0 : new Date().getTime()
timeout = null
result = method.apply(context, args)
resolve(result)
// 解除引用,防止記憶體洩漏
if (!timeout) context = args = null
}, remaining)
}
})
}
// 加入取消功能,使用方法如下
// let throttledFn = throttle(otherFn)
// throttledFn.cancel()
throttled.cancel = function() {
clearTimeout(timeout)
previous = 0
timeout = null
}
return throttled
}
複製程式碼
呼叫節流後的函式的外層函式也需要使用Async/Await語法等待執行結果返回
使用方法見程式碼:
function square(num) {
return Math.pow(num, 2)
}
// let throttledFn = throttle(square, 1000)
// let throttledFn = throttle(square, 1000, {leading: false})
// let throttledFn = throttle(square, 1000, {trailing: false})
let throttledFn = throttle(square, 1000, {leading: false, trailing: false})
window.onmousemove = async () => {
try {
let val = await throttledFn(4)
// 原函式不執行時val為undefined
if (typeof val !== 'undefined') {
console.log(`原函式返回值為${val}`)
}
} catch (err) {
console.error(err)
}
}
// 滑鼠移動時,每間隔1S輸出:
// 原函式的返回值為:16
複製程式碼
檢視線上例子: 函式節流-最終版 by Logan (@logan70) on CodePen.
具體的實現步驟請往下看
五、函式節流 Throttle 的具體實現步驟
1. 優化第一版:融合兩種實現方式
這樣實現的效果是首次觸發立即執行,停止觸發後會再執行一次
function throttle(method, wait) {
let timeout
let previous = 0
return function(...args) {
let context = this
let now = new Date().getTime()
// 距離下次函式執行的剩餘時間
let remaining = wait - (now - previous)
// 如果無剩餘時間或系統時間被修改
if (remaining <= 0 || remaining > wait) {
// 如果定時器還存在則清除並置為null
if (timeout) {
clearTimeout(timeout)
timeout = null
}
// 更新對比時間戳並執行函式
previous = now
method.apply(context, args)
} else if (!timeout) {
// 如果有剩餘時間但定時器不存在,則設定定時器
// remaining毫秒後執行函式、更新對比時間戳
// 並將定時器置為null
timeout = setTimeout(() => {
previous = new Date().getTime()
timeout = null
method.apply(context, args)
}, remaining)
}
}
}
複製程式碼
我們來捋一捋,假設連續觸發回撥:
- 第一次觸發:對比時間戳為0,剩餘時間為負數,立即執行函式並更新對比時間戳
- 第二次觸發:剩餘時間為正數,定時器不存在,設定定時器
- 之後的觸發:剩餘時間為正數,定時器存在,不執行其他行為
- 直至剩餘時間小於等於0或定時器內函式執行(由於回撥觸發有間隔,且setTimeout有誤差,故哪個先觸發並不確定)
- 若定時器內函式執行,更新對比時間戳,並將定時器置為null,下一次觸發繼續設定定時器
- 若定時器內函式未執行,但剩餘時間小於等於0,清除定時器並置為null,立即執行函式,更新時間戳,下一次觸發繼續設定定時器
- 停止觸發後:若非在上面所述的兩個特殊時間點時停止觸發,則會存在一個定時器,原函式還會被執行一次
檢視線上例子: 函式節流-優化第一版:融合兩種實現方式 by Logan (@logan70) on CodePen.
2. 優化第二版:提供首次觸發時是否立即執行的配置項
// leading為控制首次觸發時是否立即執行函式的配置項
function throttle(method, wait, leading = true) {
let timeout
let previous = 0
return function(...args) {
let context = this
let now = new Date().getTime()
// !previous代表首次觸發或定時器觸發後的首次觸發,若不需要立即執行則將previous更新為now
// 這樣remaining = wait > 0,則不會立即執行,而是設定定時器
if (!previous && leading === false) previous = now
let remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
method.apply(context, args)
} else if (!timeout) {
timeout = setTimeout(() => {
// 如果leading為false,則將previous設為0,
// 下次觸發時會與下次觸發時的now同步,達到首次觸發(對於使用者來說)不立即執行
// 如果直接設為當前時間戳,若停止觸發一段時間,下次觸發時的remaining為負值,會立即執行
previous = leading === false ? 0 : new Date().getTime()
timeout = null
method.apply(context, args)
}, remaining)
}
}
}
複製程式碼
檢視線上例子: 函式節流-優化第二版:提供首次觸發時是否立即執行的配置項 by Logan (@logan70) on CodePen.
3. 優化第三版:提供停止觸發後是否還執行一次的配置項
// trailing為控制停止觸發後是否還執行一次的配置項
function throttle(method, wait, {leading = true, trailing = true} = {}) {
let timeout
let previous = 0
return function(...args) {
let context = this
let now = new Date().getTime()
if (!previous && leading === false) previous = now
let remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
method.apply(context, args)
} else if (!timeout && trailing !== false) {
// 如果有剩餘時間但定時器不存在,且trailing不為false,則設定定時器
// trailing為false時等同於只使用時間戳來實現節流
timeout = setTimeout(() => {
previous = leading === false ? 0 : new Date().getTime()
timeout = null
method.apply(context, args)
}, remaining)
}
}
}
複製程式碼
檢視線上例子: 函式節流-優化第三版:提供停止觸發後是否還執行一次的配置項 by Logan (@logan70) on CodePen.
4. 優化第四版:提供取消功能
有些時候我們需要在不可觸發的這段時間內能夠手動取消節流,程式碼實現如下:
function throttle(method, wait, {leading = true, trailing = true} = {}) {
let timeout
let previous = 0
// 將返回的匿名函式賦值給throttled,以便在其上新增取消方法
let throttled = function(...args) {
let context = this
let now = new Date().getTime()
if (!previous && leading === false) previous = now
let remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
method.apply(context, args)
} else if (!timeout && trailing !== false) {
timeout = setTimeout(() => {
previous = leading === false ? 0 : new Date().getTime()
timeout = null
method.apply(context, args)
}, remaining)
}
}
// 加入取消功能,使用方法如下
// let throttledFn = throttle(otherFn)
// throttledFn.cancel()
throttled.cancel = function() {
clearTimeout(timeout)
previous = 0
timeout = null
}
// 將節流後函式返回
return throttled
}
複製程式碼
檢視線上例子: 函式節流-優化第四版:提供取消功能 by Logan (@logan70) on CodePen.
5. 優化第五版:處理原函式返回值
需要節流的函式可能是存在返回值的,我們要對這種情況進行處理,underscore
的處理方法是將函式返回值在返回的debounced
函式內再次返回,但是這樣其實是有問題的。如果原函式執行在setTimeout
內,則無法同步拿到返回值,我們使用Promise處理原函式返回值。
function throttle(method, wait, {leading = true, trailing = true} = {}) {
// result記錄原函式執行結果
let timeout, result
let previous = 0
let throttled = function(...args) {
let context = this
// 返回一個Promise,以便可以使用then或者Async/Await語法拿到原函式返回值
return new Promise(resolve => {
let now = new Date().getTime()
if (!previous && leading === false) previous = now
let remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
result = method.apply(context, args)
// 將函式執行返回值傳給resolve
resolve(result)
} else if (!timeout && trailing !== false) {
timeout = setTimeout(() => {
previous = leading === false ? 0 : new Date().getTime()
timeout = null
result = method.apply(context, args)
// 將函式執行返回值傳給resolve
resolve(result)
}, remaining)
}
})
}
throttled.cancel = function() {
clearTimeout(timeout)
previous = 0
timeout = null
}
return throttled
}
複製程式碼
使用方法一:在呼叫節流後的函式時,使用then
拿到原函式的返回值
function square(num) {
return Math.pow(num, 2)
}
let throttledFn = throttle(square, 1000, false)
window.onmousemove = () => {
throttledFn(4).then(val => {
console.log(`原函式的返回值為:${val}`)
})
}
// 滑鼠移動時,每間隔1S後輸出:
// 原函式的返回值為:16
複製程式碼
使用方法二:呼叫節流後的函式的外層函式使用Async/Await語法等待執行結果返回
使用方法見程式碼:
function square(num) {
return Math.pow(num, 2)
}
let throttledFn = throttle(square, 1000)
window.onmousemove = async () => {
try {
let val = await throttledFn(4)
// 原函式不執行時val為undefined
if (typeof val !== 'undefined') {
console.log(`原函式返回值為${val}`)
}
} catch (err) {
console.error(err)
}
}
// 滑鼠移動時,每間隔1S輸出:
// 原函式的返回值為:16
複製程式碼
檢視線上例子: 函式節流-優化第五版:處理原函式返回值 by Logan (@logan70) on CodePen.
6. 優化第六版:可同時禁用立即執行和後置執行
模仿underscore
實現的函式節流有一點美中不足,那就是 leading:false
和 trailing: false
不能同時設定。
如果同時設定的話,比如當你將滑鼠移出的時候,因為 trailing
設定為 false
,停止觸發的時候不會設定定時器,所以只要再過了設定的時間,再移入的話,remaining
為負數,就會立刻執行,就違反了 leading: false
,這裡我們優化的思路如下:
計算連續兩次觸發回撥的時間間隔,如果大於設定的間隔值時,重置對比時間戳為當前時間戳,這樣就相當於回到了首次觸發,達到禁止首次觸發(偽)立即執行的效果,程式碼如下,有錯懇請指出:
function throttle(method, wait, {leading = true, trailing = true} = {}) {
let timeout, result
let methodPrevious = 0
// 記錄上次回撥觸發時間(每次都更新)
let throttledPrevious = 0
let throttled = function(...args) {
let context = this
return new Promise(resolve => {
let now = new Date().getTime()
// 兩次觸發的間隔
let interval = now - throttledPrevious
// 更新本次觸發時間供下次使用
throttledPrevious = now
// 更改條件,兩次間隔時間大於wait且leading為false時也重置methodPrevious,實現禁止立即執行
if (leading === false && (!methodPrevious || interval > wait)) {
methodPrevious = now
}
let remaining = wait - (now - methodPrevious)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
methodPrevious = now
result = method.apply(context, args)
resolve(result)
// 解除引用,防止記憶體洩漏
if (!timeout) context = args = null
} else if (!timeout && trailing !== false) {
timeout = setTimeout(() => {
methodPrevious = leading === false ? 0 : new Date().getTime()
timeout = null
result = method.apply(context, args)
resolve(result)
// 解除引用,防止記憶體洩漏
if (!timeout) context = args = null
}, remaining)
}
})
}
throttled.cancel = function() {
clearTimeout(timeout)
methodPrevious = 0
timeout = null
}
return throttled
}
複製程式碼
檢視線上例子: 函式節流-優化第六版:可同時禁用立即執行和後置執行 by Logan (@logan70) on CodePen.
六、參考文章
JavaScript專題之跟著 underscore 學節流
如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。