參考連接:https://www.cnblogs.com/zhuanzhuanfe/p/10633019.html
https://blog.csdn.net/Beijiyang999/article/details/79832604
我們經常會處理各種事件,比如常見的click、scroll、 resize等等。仔細一想,會發現像scroll、onchange這類事件會頻繁觸發,如果我們在回調中計算元素位置、做一些跟DOM相關的操作,引起瀏覽器回流和重繪,頻繁觸發回調,很可能會造成瀏覽器掉幀,甚至會使瀏覽器崩潰,影響用戶體驗。
還有以下場景往往由於事件頻繁被觸發,因而頻繁執行DOM操作、資源加載等重行為,導致UI停頓甚至瀏覽器崩潰。
1. window對象的resize、scroll事件
2. 拖拽時的mousemove事件
3. 射擊游戲中的mousedown、keydown事件
4. 文字輸入、自動完成的keyup事件
實際上對於window的resize事件,實際需求大多為停止改變大小n毫秒后執行后續處理;而其他事件大多的需求是以一定的頻率執行后續處理。
針對這兩種需求,常用的解決方案:防抖和節流。
防抖(debounce)
所謂防抖,就是指觸發事件后,就是把觸發非常頻繁的事件合並成一次去執行。即在指定時間內只執行一次回調函數,如果在指定的時間內又觸發了該事件,則回調函數的執行時間會基於此刻重新開始計算。
以我們生活中乘車刷卡的情景舉例,只要乘客不斷地在刷卡,司機師傅就不能開車,乘客刷卡完畢之后,司機會等待幾分鍾,確定乘客坐穩再開車。如果司機在最后等待的時間內又有新的乘客上車,那么司機等乘客刷卡完畢之后,還要再等待一會,等待所有乘客坐穩再開車。
具體應該怎么去實現這樣的功能呢?第一時間肯定會想到使用setTimeout方法,那我們就嘗試寫一個簡單的函數來實現這個功能吧~
思路
用 setTimeout 實現計時,配合 clearTimeout 實現“重新開始計時”。
即只要觸發,就會清除上一個計時器,又注冊新的一個計時器。直到停止觸發 wait 時間后,才會執行回調函數。
不斷觸發事件,就會不斷重復這個過程,達到防止目標函數過於頻繁的調用的目的。
初步實現
function debounce(func, wait) { let timeout return function () { clearTimeout(timeout) timeout = setTimeout(func, wait) //返回計時器 ID } }
示意
container.onmousemove = debounce(doSomething, 1000);
注解:關於閉包
每當事件被觸發,執行的都是那個被返回的閉包函數。
因為閉包帶來的其作用域鏈中引用的上層函數變量聲明周期延長的效果,debounce 函數的 settimeout計時器 ID timeout 變量可以在debounce 函數執行結束后依然留存在內存中,供閉包使用。
優化:修復
相比於未防抖時的
container.onmousemove = doSomething
防抖優化后,指向 HTMLDivElement 的從 doSomething 函數的 this 變成了閉包匿名函數的 this ,前者變成了指向全局變量。
同理,doSomething 函數參數也接收不到 MouseEvent 事件了。
修復代碼
function debounce(func, wait) { let timeout return function () { let context = this //傳給目標函數 clearTimeout(timeout) timeout = setTimeout( () => { func.apply(context, arguments) } //修復 , wait) } }
溫馨提示:
1、上述代碼中arguments只會保存事件回調函數中的參數,譬如:事件對象等,並不會保存fn、delayTime
2、使用apply改變傳入的fn方法中的this指向,指向綁定事件的DOM元素。
優化:立即執行
相比於 一個周期內最后一次觸發后,等待一定時間再執行目標函數;
我們有時候希望能實現 在一個周期內第一次觸發,就立即執行一次,然后一定時間段內都不能再執行目標函數。
這樣,在限制函數頻繁執行的同時,可以減少用戶等待反饋的時間,提升用戶體驗。
代碼
在原來基礎上,添加一個是否立即執行的功能
function debounce(func, wait, immediate) { let time; let debounced = function () { let context = this; if (immediate) { let callNow = !time; if (callNow) func.apply(context, arguments); time = setTimeout( () => { time = null } //見注解 , wait); } else { if (time) clearTimeout(time); time = setTimeout( () => { func.apply(context, arguments) } , wait); } } return debounced; }
注解
把保存計時器 ID 的 time 值設置為 null 有兩個作用:
作為開關變量,表明一個周期結束。使得 callNow 為 true,目標函數可以在新的周期里被觸發時被執行
timeout 作為閉包引用的上層函數的變量,是不會自動回收的。手動將其設置為 null ,讓它脫離執行環境,一邊垃圾收集器下次運行是將其回收。
優化:取消立即執行
添加一個取消立即執行的功能。
函數也是對象,也可以為其添加屬性。
為了添加 “取消立即執行”功能,為 debounced 函數添加了個 cancel 屬性,屬性值是一個函數
debounced.cancel = function () { clearTimeout(time) time = null }
示意:
var setSomething = debounce(doSomething, 1000, true); container.onmousemove = setSomething; document.getElementById("button").addEventListener('click', function () { setSomething.cancel(); });
完整代碼
function debounce(func, wait, immediate) { let time; let debounced = function () { let context = this; if (immediate) { let callNow = !time; if (callNow) func.apply(context, arguments) time = setTimeout( () => { time = null } //見注解 , wait); } else { if (time) clearTimeout(time); time = setTimeout( () => { func.apply(context, arguments) } , wait); } } debounced.cancel = function () { clearTimeout(time); time = null; } return debounced; }
節流(throttle)
所謂節流,是指頻繁觸發事件時,只會在指定的時間段內執行事件回調,即觸發事件間隔大於等於指定的時間才會執行回調函數。
類比到生活中的水龍頭,擰緊水龍頭到某種程度會發現,每隔一段時間,就會有水滴流出。
說到時間間隔,大家肯定會想到使用setTimeout來實現,在這里,我們使用兩種方法來簡單實現這種功能:時間戳和setTimeout定時器。
時間戳
var throttle = (fn, delayTime) => { var _start = Date.now(); return function () { var _now = Date.now(), context = this, args = arguments; if(_now - _start >= delayTime) { fn.apply(context, args); _start = Date.now(); } } }
通過比較兩次時間戳的間隔是否大於等於我們事先指定的時間來決定是否執行事件回調。
定時器
var throttle = function (fn, delayTime) { var flag; return function () { var context = this, args = arguments; if(!flag) { flag = setTimeout(function () { fn.apply(context, args); flag = false; }, delayTime); } } }
在上述實現過程中,我們設置了一個標志變量flag,當delayTime之后執行事件回調,便會把這個變量重置,表示一次回調已經執行結束。
對比上述兩種實現,我們會發現一個有趣的現象:
1、使用時間戳方式,頁面加載的時候就會開始計時,如果頁面加載時間大於我們設定的delayTime,第一次觸發事件回調的時候便會立即fn,並不會延遲。如果最后一次觸發回調與前一次觸發回調的時間差小於delayTime,則最后一次觸發事件並不會執行fn;
2、使用定時器方式,我們第一次觸發回調的時候才會開始計時,如果最后一次觸發回調事件與前一次時間間隔小於delayTime,delayTime之后仍會執行fn。
這兩種方式有點優勢互補的意思,哈哈~
我們考慮把這兩種方式結合起來,便會在第一次觸發事件時執行fn,最后一次與前一次間隔比較短,delayTime之后再次執行fn。
想法簡單實現如下:
function throttle(fn, wait) { let timer; let lastTime; return function () { const context = this; const nowTime = new Date(); if (nowTime - lastTime - wait >= 0) { if (timer) { clearTimeout(timer); timer = null; } fn.apply(context, agruments); lastTime = nowTime; } else if (!timer) { timer = setTimeout(() => { fn.apply(context, agruments); }, wait); } }; }
通過上面的分析,可以很明顯的看出函數防抖和函數節流的區別:
頻繁觸發事件時,函數防抖只會在最后一次觸發事件只會才會執行回調內容,其他情況下會重新計算延遲事件,而函數節流便會很有規律的每隔一定時間執行一次回調函數。
requestAnimationFrame
之前,我們使用setTimeout簡單實現了防抖和節流功能,如果我們不考慮兼容性,追求精度比較高的頁面效果,可以考慮試試html5提供的API--requestAnimationFrame。
與setTimeout相比,requestAnimationFrame的時間間隔是有系統來決定,保證屏幕刷新一次,回調函數只會執行一次,比如屏幕的刷新頻率是60HZ,即間隔1000ms/60會執行一次回調。
var throttle = function(fn, delayTime) { var flag; return function() { if(!flag) { requestAnimationFrame(function() { fn(); flag = false; }); flag = true; } }
上述代碼的基本功能就是保證在屏幕刷新的時候(對於大多數的屏幕來說,大約16.67ms),可以執行一次回調函數fn。使用這種方式也存在一種比較明顯的缺點,時間間隔只能跟隨系統變化,我們無法修改,但是准確性會比setTimeout高一些。
注意:
-
防抖和節流只是減少了事件回調函數的執行次數,並不會減少事件的觸發頻率。
-
防抖和節流並沒有從本質上解決性能問題,我們還應該注意優化我們事件回調函數的邏輯功能,避免在回調中執行比較復雜的DOM操作,減少瀏覽器reflow和repaint。
上面的示例代碼比較簡單,只是說明了基本的思路。目前已經有工具庫實現了這些功能,比如underscore,考慮的情況也會比較多,大家可以去查看源碼,學習作者的思路,加深理解。
underscore的debounce方法源碼:
_.debounce = function(func, wait, immediate) { var timeout, result; var later = function(context, args) { timeout = null; if (args) result = func.apply(context, args); }; var debounced = restArguments(function(args) { if (timeout) clearTimeout(timeout); if (immediate) { var callNow = !timeout; timeout = setTimeout(later, wait); if (callNow) result = func.apply(this, args); } else { timeout = _.delay(later, wait, this, args); } return result; }); debounced.cancel = function() { clearTimeout(timeout); timeout = null; }; return debounced; };
underscore的throttle源碼:
_.throttle = function(func, wait, options) { var timeout, context, args, result; var previous = 0; if (!options) options = {}; var later = function() { previous = options.leading === false ? 0 : _.now(); timeout = null; result = func.apply(context, args); if (!timeout) context = args = null; }; var throttled = function() { var now = _.now(); if (!previous && options.leading === false) previous = now; var remaining = wait - (now - previous); context = this; args = arguments; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; result = func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } return result; }; throttled.cancel = function() { clearTimeout(timeout); previous = 0; timeout = context = args = null; }; return throttled; };