作為一名前端開發者,我們經常會處理各種事件,比如常見的click、scroll、 resize等等。仔細一想,會發現像scroll、scroll、onchange這類事件會頻繁觸發,如果我們在回調中計算元素位置、做一些跟DOM相關的操作,引起瀏覽器迴流和重繪,頻繁觸發回調,很可能會造成瀏覽器掉幀,甚至會使瀏覽器崩潰,影響用戶體驗。針對這種現象,目前有兩種常用的解決方案:防抖和節流。

防抖(debounce)所謂防抖,就是指觸發事件後,就是把觸發非常頻繁的事件合併成一次去執行。即在指定時間內只執行一次回調函數,如果在指定的時間內又觸發了該事件,則回調函數的執行時間會基於此刻重新開始計算。以我們生活中乘車刷卡的情景舉例,只要乘客不斷地在刷卡,司機師傅就不能開車,乘客刷卡完畢之後,司機會等待幾分鐘,確定乘客坐穩再開車。如果司機在最後等待的時間內又有新的乘客上車,那麼司機等乘客刷卡完畢之後,還要再等待一會,等待所有乘客坐穩再開車。

具體應該怎麼去實現這樣的功能呢?第一時間肯定會想到使用setTimeout方法,那我們就嘗試寫一個簡單的函數來實現這個功能吧~

var debounce = function(fn, delayTime) {
var timeId;
return function () {
var context = this, args = arguments;
timeId && clearTimeout(timeout);
timeId = setTimeout(function {
fn.apply(context, args);
}, delayTime)
}
}

思路解析:執行debounce函數之後會返回一個新的函數,通過閉包的形式,維護一個變數timeId,每次執行該函數的時候會結束之前的延遲操作,重新執行setTimeout方法,也就實現了上面所說的指定的時間內多次觸發同一個事件,會合併執行一次。溫馨提示:1、上述代碼中arguments只會保存事件回調函數中的參數,譬如:事件對象等,並不會保存fn、delayTime2、使用apply改變傳入的fn方法中的this指向,指向綁定事件的DOM元素。節流(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。想法簡單實現如下:

var throttle = function (fn, delayTime) {
var flag, _start = Date.now();
return function () {
var context = this,
args = arguments,
_now = Date.now(),
remainTime = delayTime - (_now - _start);
if(remainTime <= 0) {
fn.apply(this, args);
} else {
setTimeout(function () {
fn.apply(this, args);
}, remainTime)
}
}
}

通過上面的分析,可以很明顯的看出函數防抖和函數節流的區別:頻繁觸發事件時,函數防抖只會在最後一次觸發事件只會才會執行回調內容,其他情況下會重新計算延遲事件,而函數節流便會很有規律的每隔一定時間執行一次回調函數。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高一些。注意:
  1. 防抖和節流只是減少了事件回調函數的執行次數,並不會減少事件的觸發頻率。
  2. 防抖和節流並沒有從本質上解決性能問題,我們還應該注意優化我們事件回調函數的邏輯功能,避免在回調中執行比較複雜的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;
};

推薦閱讀:

相关文章