理解函式防抖Debounce
有如下程式碼
window.onscroll = () => { console.log('觸發滾動監聽回撥函式') } 複製程式碼
當我們在PC上滾動頁面時,一秒可以輕鬆觸發30次事件。在手機上進行測試時,一秒觸發事件可以達到100次甚至更多。
這裡的回撥函式只是列印字串,如果回撥函式更加複雜,可想而知瀏覽器的壓力會非常大,使用者體驗會很糟糕。
resize
或scroll
等Dom事件的監聽回撥會被頻繁觸發,因此我們要對其進行限制。
二、實現思路
函式去抖簡單來說就是對於一定時間段的連續的函式呼叫,只讓其執行一次,初步的實現思路如下:
第一次呼叫函式,建立一個定時器,在指定的時間間隔之後執行程式碼。當第二次呼叫該函式時,它會清除前一次的定時器並設定另一個。如果前一個定時器已經執行過了,這個操作就沒有任何意義。然而,如果前一個定時器尚未執行,其實就是將其替換為一個新的定時器。目的是隻有在執行函式的請求停止了一段時間之後才執行。
三、Debounce 應用場景
- 每次 resize/scroll 觸發統計事件
- 文字輸入的驗證(連續輸入文字後傳送 AJAX 請求進行驗證,驗證一次就好)
四、函式防抖最終版
程式碼說話,有錯懇請指出
function debounce(method, wait, immediate) { let timeout // debounced函式為返回值 // 使用Async/Await處理非同步,如果函式非同步執行,等待setTimeout執行完,拿到原函式返回值後將其返回 // args為返回函式呼叫時傳入的引數,傳給method let debounced = async function(...args) { // 用於記錄員原函式執行結果 let result // 將method執行時this的指向設為debounce返回的函式被呼叫時的this指向 let context = this // 如果存在定時器則將其清除 if (timeout) { clearTimeout(timeout) } // 立即執行需要兩個條件,一是immediate為true,二是timeout未被賦值或被置為null if (immediate) { // 如果定時器不存在,則立即執行,並設定一個定時器,wait毫秒後將定時器置為null // 這樣確保立即執行後wait毫秒內不會被再次觸發 let callNow = !timeout timeout = setTimeout(() => { timeout = null }, wait) // 如果滿足上述兩個條件,則立即執行並記錄其執行結果 if (callNow) { result = method.apply(context, args) } } else { // 如果immediate為false,則等待函式執行並記錄其執行結果 // 並將Promise狀態置為fullfilled,以使函式繼續執行 await new Promise(resolve => { timeout = setTimeout(() => { // args是一個數組,所以使用fn.apply // 也可寫作method.call(context, ...args) result = method.apply(context, args) resolve() }, wait) }) } // 將原函式執行結果返回 return result } // 在返回的debounced函式上新增取消方法 debounced.cancel = function() { clearTimeout(timeout) timeout = null } return debounced } 複製程式碼
需要注意的是如果傳入的immediate
引數為false
時,呼叫防抖後的函式的外層函式也需要使用Async/Await語法等待執行結果返回
使用方法見程式碼:
function square(num) { return Math.pow(num, 2) } let debouncedFn = debounce(square, 1000, false) window.addEventListener('scroll', async () => { let val = await debouncedFn(4) // 停止滾動1S後輸出: // 原函式的返回值為:16 console.log(`原函式返回值為${val}`) }, false) 複製程式碼
具體的實現步驟請往下看
五、Debounce 的實現
1. 《JavaScript高階程式設計》(第三版)中的實現
function debounce(method, context) { clearTimeout(method.tId) method.tId = setTimeout(() => { method.call(context) }, 1000) } function print() { console.log('Hello World') } window.onscroll = debounce(print) 複製程式碼
我們不停滾動視窗,當停止1S後,打印出Hello World。
有個可以優化的地方: 此實現方法有副作用(Side Effect),改變了輸入值(method),給method新增了屬性
2. 優化第一版:消除副作用,將定時器隔離
function debounce(method, wait, context) { let timeout return function() { if (timeout) { clearTimeout(timeout) } timeout = setTimeout(() => { method.call(context) }, wait) } } 複製程式碼
3. 優化第二版:自動調整this正確指向
之前的函式我們需要手動傳入函式執行上下文context
,現在優化將 this 指向正確的物件。
function debounce(method, wait) { let timeout return function() { // 將method執行時this的指向設為debounce返回的函式被呼叫時的this指向 let context = this if (timeout) { clearTimeout(timeout) } timeout = setTimeout(() => { method.call(context) }, wait) } } 複製程式碼
4. 優化第三版:函式可傳入引數
即便我們的函式不需要傳參,但是別忘了JavaScript 在事件處理函式中會提供事件物件 event,所以我們要實現傳參功能。
function debounce(method, wait) { let timeout // args為返回函式呼叫時傳入的引數,傳給method return function(...args) { let context = this if (timeout) { clearTimeout(timeout) } timeout = setTimeout(() => { // args是一個數組,所以使用fn.apply // 也可寫作method.call(context, ...args) method.apply(context, args) }, wait) } } 複製程式碼
5. 優化第四版:提供立即執行選項
有些時候我不希望非要等到事件停止觸發後才執行,我希望立刻執行函式,然後等到停止觸發n毫秒後,才可以重新觸發執行。
function debounce(method, wait, immediate) { let timeout return function(...args) { let context = this if (timeout) { clearTimeout(timeout) } // 立即執行需要兩個條件,一是immediate為true,二是timeout未被賦值或被置為null if (immediate) { // 如果定時器不存在,則立即執行,並設定一個定時器,wait毫秒後將定時器置為null // 這樣確保立即執行後wait毫秒內不會被再次觸發 let callNow = !timeout timeout = setTimeout(() => { timeout = null }, wait) if (callNow) { method.apply(context, args) } } else { // 如果immediate為false,則函式wait毫秒後執行 timeout = setTimeout(() => { // args是一個類陣列物件,所以使用fn.apply // 也可寫作method.call(context, ...args) method.apply(context, args) }, wait) } } } 複製程式碼
6. 優化第五版:提供取消功能
有些時候我們需要在不可觸發的這段時間內能夠手動取消防抖,程式碼實現如下:
function debounce(method, wait, immediate) { let timeout // 將返回的匿名函式賦值給debounced,以便在其上新增取消方法 let debounced = function(...args) { let context = this if (timeout) { clearTimeout(timeout) } if (immediate) { let callNow = !timeout timeout = setTimeout(() => { timeout = null }, wait) if (callNow) { method.apply(context, args) } } else { timeout = setTimeout(() => { method.apply(context, args) }, wait) } } // 加入取消功能,使用方法如下 // let myFn = debounce(otherFn) // myFn.cancel() debounced.cancel = function() { clearTimeout(timeout) timeout = null } } 複製程式碼
至此,我們已經比較完整地實現了一個underscore中的debounce函式。
六、遺留問題
需要防抖的函式可能是存在返回值的,我們要對這種情況進行處理,underscore
的處理方法是將函式返回值在返回的debounced
函式內再次返回,但是這樣其實是有問題的。如果引數immediate
傳入值不為true
的話,當防抖後的函式第一次被觸發時,如果原始函式有返回值,其實是拿不到返回值的,因為原函式是在setTimeout
內,是非同步延遲執行的,而return
是同步執行的,所以返回值是undefined
。
第二次觸發時拿到的返回值其實是第一次執行的返回值,第三次觸發時拿到的返回值其實是第二次執行的返回值,以此類推。
1. 使用回撥函式處理函式返回值
function debounce(method, wait, immediate, callback) { let timeout, result let debounced = function(...args) { let context = this if (timeout) { clearTimeout(timeout) } if (immediate) { let callNow = !timeout timeout = setTimeout(() => { timeout = null }, wait) if (callNow) { result = method.apply(context, args) // 使用回撥函式處理函式返回值 callback && callback(result) } } else { timeout = setTimeout(() => { result = method.apply(context, args) // 使用回撥函式處理函式返回值 callback && callback(result) }, wait) } } debounced.cancel = function() { clearTimeout(timeout) timeout = null } return debounced } 複製程式碼
這樣我們就可以在函式防抖時傳入一個回撥函式來處理函式的返回值,使用程式碼如下:
function square(num) { return Math.pow(num, 2) } let debouncedFn = debounce(square, 1000, false, val => { console.log(`原函式的返回值為:${val}`) }) window.addEventListener('scroll', () => { debouncedFn(4) }, false) // 停止滾動1S後輸出: // 原函式的返回值為:16 複製程式碼
2. 使用Promise處理返回值
function debounce(method, wait, immediate) { let timeout, result let debounced = function(...args) { // 返回一個Promise,以便可以使用then呼叫原函式返回值 return new Promise((resolve, reject) => { let context = this if (timeout) { clearTimeout(timeout) } if (immediate) { let callNow = !timeout timeout = setTimeout(() => { timeout = null }, wait) if (callNow) { result = method.apply(context, args) // 將原函式的返回值傳給resolve resolve(result) } } else { timeout = setTimeout(() => { result = method.apply(context, args) // 將原函式的返回值傳給resolve resolve(result) }, wait) } }) } debounced.cancel = function() { clearTimeout(timeout) timeout = null } return debounced } 複製程式碼
這樣我們就可以在呼叫防抖後的函式時,使用then
拿到原函式的返回值
function square(num) { return Math.pow(num, 2) } let debouncedFn = debounce(square, 1000, false) window.addEventListener('scroll', () => { debouncedFn(4).then(val => { console.log(`原函式的返回值為:${val}`) }) }, false) // 停止滾動1S後輸出: // 原函式的返回值為:16 複製程式碼
3. 使用Async/Await處理返回值
function debounce(method, wait, immediate) { let timeout, result // 使用Async/Await處理非同步,如果函式非同步執行,等待setTimeout執行完,拿到原函式返回值後將其返回 let debounced = async function(...args) { let context = this if (timeout) { clearTimeout(timeout) } if (immediate) { let callNow = !timeout timeout = setTimeout(() => { timeout = null }, wait) if (callNow) { result = method.apply(context, args) } } else { // 如果immediate為false,則等待函式執行並記錄其返回值 // 並將Promise狀態置為fullfilled,以使函式繼續執行 await new Promise(resolve => { timeout = setTimeout(() => { result = method.apply(context, args) resolve() }, wait) }) } return result } debounced.cancel = function() { clearTimeout(timeout) timeout = null } return debounced } 複製程式碼
需要注意的是如果傳入的immediate
引數為false
時,呼叫防抖後的函式的外層函式也需要使用Async/Await語法等待執行結果返回
使用方法見程式碼:
function square(num) { return Math.pow(num, 2) } let debouncedFn = debounce(square, 1000, false) window.addEventListener('scroll', async () => { let val = await debouncedFn(4) console.log(`原函式返回值為${val}`) }, false) // 停止滾動1S後輸出: // 原函式的返回值為:16 複製程式碼