防抖與節流:實踐與勘誤
前言
一般對於監聽某些密集型鍵盤、鼠標、手勢事件需要和後端請求交互、修改 dom
的,防抖、節流就很有必要了。
防抖
使用場景
- 關鍵字遠程搜索下拉框
- resize
對於這類操作,一般希望拿到用戶最終輸入的關鍵字、確定的拖拽大小,然後與服務器交互。
而中間態的值,並不關心,爲了減輕服務器壓力,避免服務器資源浪費,這時就需要防抖了。
案例
- 輸入框防抖
// 記錄時間
let last = new Date().getTime();
//模擬一段ajax請求
function ajax(content) {
const d = new Date().getTime();
const span = d - last;
console.log(`${content} 間隔 ${span}ms`);
last = d;
}
const noActionInput = document.getElementById('noAction');
noActionInput.addEventListener('keyup', function(e) {
ajax(e.target.value);
});
- 未防抖
可以看到爲太多的中間態發送太多的請求。
/**
* 一般利用閉包存儲和私有化定時器 `timer`
* 在 `delay` 時間內再次調用則清除未執行的定時器
* 重新定時器
* @param fn
* @param delay
* @returns {Function}
*/
function debounce(fn, delay) {
let timer = null;
return function() {
// 中間態一律清除掉
timer && clearTimeout(timer);
// 只需要最終的狀態,執行
timer = setTimeout(() => fn.apply(this, arguments), delay);
};
}
const debounceInput = document.getElementById('debounce');
let debounceAjax = debounce(ajax, 100);
debounceInput.addEventListener('keyup', function(e) {
debounceAjax(e.target.value);
});
- 防抖後
可以發現:
- 如果輸入的很慢,差不多每
delay 100ms
執行一次; - 如果輸入的很快,說明用戶在連續性輸入,則會等到用戶差不輸入完慢下來了在執行回調。
節流
使用場景
- 滑動滾動條
- 射擊類遊戲發射子彈
- 水龍頭的水流速
對於某些連續性的事件,爲了表現平滑過渡,這時的中間態我們也需要關心的。
但減弱密集型事件的頻率依舊是性能優化的殺器。
勘誤
非常常見的兩種錯誤寫法,太流行了,忍不住出來勘誤。
// 時間戳版
function throttleError1(fn, delay) {
let lastTime = 0;
return function () {
const now = new Date().getTime();
const space = now - lastTime; // 時間間隔,首次會是很大一個值
if (space > delay) {
lastTime = now;
fn.apply(this, arguments);
}
};
}}
// 定時器版
function throttleError2(fn, delay) {
let timer;
return function () {
if (!timer) {
timer = setTimeout(() => {
timer = null;
fn.apply(this, arguments);
}, delay);
}
};
}
這兩個版本都有的問題,先假設 delay=100ms
,假設定時器都是按時執行的。
- 時間戳版
- 由於首次
now - lastTime === now
該值很大,首次 0ms 立即執行,用戶接在 0-100ms 內執行的交互均無效,假如用戶停留在 99ms,則最後一次丟失了。 - 例如要用滾動條離頂部的高度來設置樣式,滾動條在 99ms 從 0 滾動到 100px 處,你沒辦法處理。
-
PS: 時間戳版,有一個應用場景,在一定時間內防止重複提交。
-
定時器版
- 首次 0ms 不會立即執行有 100ms 延遲,好比開第一槍需要 100ms 後子彈才能出來。
- 聰明的讀者,可能想到了,可以結合兩者來解決問題。
- 首次 0ms 立即執行無延遲;
- 獲取最後狀態,保證最後一次得到執行。
案例
- 滾動滑動條時視覺上連續調整
dom
/**
* 時間戳來處理首次和間隔執行問題
* 定會器來確保最後一次狀態改變得到執行
* @param fn
* @param delay
* @returns {Function}
*/
function throttle(fn, delay) {
let timer, lastTime;
return function() {
const now = new Date().getTime();
const space = now - lastTime; // 間隔時間
if( lastTime && space < delay ) { // 爲了響應用戶最後一次操作
// 非首次,還未到時間,清除掉計時器,重新計時。
timer && clearTimeout(timer);
// 重新設置定時器
timer = setTimeout(() => {
lastTime = now; // 不要忘了記錄時間
fn.apply(this, arguments);
}, delay);
return;
}
// 首次或到時間了
lastTime = now;
fn.apply(this, arguments);
};
}
const throttleAjax = throttle(ajax, 100);
window.addEventListener('scroll', function() {
const top = document.body.scrollTop || document.documentElement.scrollTop;
throttleAjax('scrollTop: ' + top);
});
- 節流前
回調過於密集。(PS:經常聽到 scroll
自帶節流,就是指一幀 16ms
左右觸發一次)
- 節流後
可以發現,無論你滑的慢還是快都類似於定時觸發。
- 細心的讀者可能會發現,假如交互停留在 199ms,定時器在 300ms 段才執行,間隔了約 200ms,定時器延遲不應該設置爲原來的
delay
。
/**
* 時間戳來處理首次和間隔執行問題
* 定會器來確保最後一次狀態改變得到執行
* @param fn
* @param delay
* @returns {Function}
*/
function throttle(fn, delay) {
let timer, lastTime;
return function() {
const now = new Date().getTime();
const space = now - lastTime; // 間隔時間
if( lastTime && space < delay ) { // 爲了響應用戶最後一次操作
// 非首次,還未到時間,清除掉計時器,重新計時。
timer && clearTimeout(timer);
// 重新設置定時器
timer = setTimeout(() => {
lastTime = now; // 不要忘了記錄時間
fn.apply(this, arguments);
- }, delay);
+ }, delay - space);
return;
}
// 首次或到時間了
lastTime = now;
fn.apply(this, arguments);
+ // 當前已執行,清除掉計時器,不清除會有多餘的中間執行
+ timer && clearTimeout(timer);
};
}
- 如果忘了最後清除
- 最終效果
-
查看
lodash
源碼可以發現節流,是靠leading
來控制首次是否需要執行,trailing
來控制 99ms 停止 100ms時需不需要執行,
maxWait
來控制定時執行,看完本篇去分析,是不是就很好理解了呢。 -
舉幾個常用的
lodash
使用方式。
- 實現類似
scroll
自帶一幀16ms
效果:throttle(fn, /* wait = undefined */)
,其實內部用到了requestAnimationFrame
。 - 非常常用,發請求防止重複提交,例如首次點擊執行,
500ms
內的點擊一律不執行:debounce(fn, 500, { leading: true, trailing: false })
- 例如某個
dom
被清除,debounced.cancel()
來取消最後一次(trailing)調用,避免取不到 dom 報錯。 - 等等。
總結
防抖、節流都是利用閉包來實現內部數據獲取與維護。
防抖比較好理解,節流就需要稍微需要思考下。兩者還是有區別的,就不要一錯再錯,粘貼傳播問題代碼啦。
防抖、節流對於頻繁dom事件性能優化是不可或缺的手段。