1. 程式人生 > >JavaScript,衝鴨系列——函式防抖

JavaScript,衝鴨系列——函式防抖

筆者在這裡按照先感性認識,再介紹原理,最後上手操作並且將寫程式碼的思路一步一步都寫出來的過程來介紹JavaScript中的一個難點函式防抖。話語可能顯得比較囉嗦,但是筆者還是本著授人以漁的方針展示自己的思維過程。目標讀者是JavaScript初級開發人員。希望讀者有好的建議或者不同的觀點可以不吝賜教。

 

例項:模糊搜尋輸入框中對於關鍵字的檢索。若每次keyup事件發生都向伺服器傳送ajax請求會極大浪費資源,造成瀏覽器卡頓以及伺服器的卡頓(如下圖所示)。

模擬模糊搜尋ajax請求
模擬模糊搜尋ajax請求

 

核心程式碼如下:

<input type="text" id="input">

<script>
var input = document.getElementById("input");
input.addEventListener("keyup", ajax);

function ajax() {
    console.log('ajax傳送的資料為: ' + input.value);
}
</script>

作用:防止短時間內多次觸發方法,造成瀏覽器抖動或卡頓。

原理:當觸發某次事件之後一段時間(這裡我們設為wait)內,再沒有觸發事件,那麼該次事件回撥會被執行。總結來說就是:短時間內無論事件觸發多少次,總是隻會執行最後一次事件的回撥方法。

目標:上述例子中,只有當鍵盤停止輸入才會傳送ajax請求。

根據原理我們不難想到將事件的回撥函式作為setTimeout的回撥函式,設定setTimeout的時間為wait。我們還需要一個全域性變數timeout來儲存當前所處的計時階段,如果距離上次事件發生wait時間段之內,那麼我們把timeout清除,並重新計時,如果wait期間沒有發生再次觸發相同事件,那麼執行fn方法,也就是ajax方法。

var input = document.getElementById("input");
input.addEventListener("keyup", debounce(ajax, 1000));

function ajax() {
    console.log('ajax傳送的資料為: ' + input.value);
}

// 全域性變數timeout用來儲存當前所處的計時階段
var timeout = null;

// 版本1
function debounce(fn, wait) {
    if(timeout) clearTimeout(timeout);

    timeout = setTimeout(function() {
        fn();
    }, 1000)
}

而由於JavaScript垃圾回收的機制知道,全域性變數什麼時候需要自動釋放記憶體空間則很難判斷,因此在開發中,需要儘量避免使用全域性變數。這時候可以對上述的程式碼做一些改進,能不能將timeout作為一個區域性變數放在某個函式中,比如說debounce中,然後還可以一直儲存在記憶體中,可以隨時改變狀態呢?答案是可以的。將變數一直儲存在記憶體中正是閉包的特點。我們使用閉包改進程式碼。

// 版本2

// 閉包形式1
function debounce(fn) {
    //父作用域debounce的變數timeout被子作用域匿名函式訪問,形成閉包
    var timeout = null;

    return function() {
        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(function() {
            fn();
        }, 1000)
    }
}

// 閉包形式2
function debounce(fn) {
    var timeout = null;

    var debounced = function() {
        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(function() {
            fn();
        }, 1000)
    }
    return debounced;
}

具體閉包怎麼回事推薦一篇博文前端基礎進階(四):詳細圖解作用域鏈與閉包,思路很清晰。

效果如下圖:

這時候的搜尋顯示邏輯變成了:最後一次鍵盤輸入——>等待wait時間段——>執行ajax方法,也就是說我們在實際的搜尋過程中,只有當停止輸入且過了一段時間之後才能看到搜尋框的顯示內容,這顯然是使用者不友好的。我們需要改進程式碼,變成:最後一次鍵盤輸入之後立即執行ajax方法,然後等待wait時間段,而不是上圖的邏輯。這實際上是程式碼執行順序的改變,程式碼改進後如下圖所示。

// 版本3

function debounce(fn, wait) {
    var timeout = null;

    var debounced = function() {
        // callNow用來儲存當事件觸發一瞬間前的計時狀態
    	var callNow = !timeout;

        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(function(){
        	timeout = null
        }, wait)
        if(callNow){
        	fn();
        };
    }
    return debounced;
}

效果如下圖所示:

 

這時候的搜尋顯示邏輯變成了:如果此次輸入距離上次超過wait,則立即執行ajax,否則重新開始倒計時。

這個時候debounce函式已經到了版本三,我們先來開一個副分支任務,積累一下經驗。

《JavaScript高階程式設計》在5.5.4函式內部屬性中講了這麼一句:

在函式內部,有兩個特殊的物件,arguments和this。

其中arguments是個類陣列物件,包含著傳入函式的所有引數,而this引用的是函資料以執行的執行上下文。筆者採用了Dom2級的事件處理,與Dom0級一樣,事件處理程式在其依附的元素的作用域中執行。因此在執行ajax方法的時候正常情況this將打印出input元素,而arguments[0]將訪問到具體的事件。我們將它們打印出來如下圖。

function ajax(e) {
	console.log('arguments: ', arguments[0]);
    console.log('ajax傳送的資料為: ' + this.value);
}

實際上執行debounce(ajax, 1000)之後,ajax方法在debounced方法中獨立呼叫,arguments實際上是傳給了debounced,而且ajax中的this變成了window物件。因此我們需要對把ajax中的arguments和this改正過來。程式碼如下。

// 版本4

function debounce(fn, wait) {
    var timeout = null;

    var debounced = function() {
        // 用that儲存dom2級事件處理中繫結的元素物件
        // arg儲存預設傳給事件處理程式的引數
    	var that = this,
    		arg = arguments;
    	var callNow = !timeout;

        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(function(){
        	timeout = null
        }, wait)
        if(callNow){
        	fn.apply(that,arg);            
        };
    }
    return debounced;
}

此時arguments和this指向正確的物件。

我們再為debounce函式新增一個immediate引數,用來判斷是採用版本二的”停止鍵盤輸入——>等待wait時間段——>執行ajax方法“(immediate為false)還是版本三的“停止鍵盤輸入——>立即執行ajax方法——>等待wait時間段”(immediate為true)的邏輯。

// 版本五

function debounce(fn, wait, immediate) {
    var timeout = null,
        result;

    var debounced = function() {
        var that = this,
            arg = arguments;
        var callNow = !timeout;

        if (immediate) {
            if (timeout) clearTimeout(timeout);
            timeout = setTimeout(function() {
                timeout = null;
            }, wait)
            if (callNow) {
                result = fn.apply(that, arg);
            };
        } else {
        	timeout = setTimeout(function(){
        		fn.apply(that, arg);
        	}, wait)
        }

        return result;
    }

    return debounced;
}

此外還能再版本五中我們還增加了返回值,因為ajax方法可能是由返回值的。當immediate為false的非立即執行情況下,由於fn.apply(that, arg)是在setTimeout內部,非同步執行的,return result在獲得ajax方法返回值之前就執行了,因此只會返回undefined。所以我們只需要在immediate為true的立即執行情況下對result賦值。

這裡最後還有這麼一個應用場景:某次鍵盤輸入並執行ajax方法之後,我們不想等wait時間才能再次執行ajax方法,而是想又能繼續立即執行ajax方法。這種情況下需要我們為debounced新增一個取消方法,而取消方法的原理很簡單,首先將timeout定時器從非同步佇列中刪除,然後手動將timeout置為null,版本六程式碼如下。

// 版本六

function debounce(fn, wait, immediate) {
    var timeout = null,
        result;

    var debounced = function() {
        var that = this,
            arg = arguments;
        var callNow = !timeout;

        if (immediate) {
            if (timeout) clearTimeout(timeout);
            timeout = setTimeout(function() {
                timeout = null;
            }, wait)
            if (callNow) {
                result = fn.apply(that, arg);
            };
        } else {
        	timeout = setTimeout(function(){
        		fn.apply(that, arg);
        	}, wait)
        }

        return result;
    }

    debounced.cancel = function() {
        clearTimeout(timeout);
        timeout = null;
    }
    return debounced;
}

實現的效果如下:

這個時候我們的防抖函式就大功告成啦:)

可以理直氣壯跟面試官侃了~