閉包的實際應用:throttle 和 debounce

這個例子有趣很多。

throttle

throttle 的直翻是「節流」。

如果你跟我一樣無聊,有研究過汽機車的引擎運作原理的話,那你應該知道「節流閥」是什麼,還有它的用途。throttle 的意思跟它還蠻類似的(我覺得啦)。

throttle

當然,沒研究過的話也沒關係。

簡單來說,throttle 的概念可以想成是一扇門,當有一個人通過這扇門,這扇門就會關起來,必須等一段時間後這扇門才會再次打開,讓下一個人通過。throttle 就是一直循環這個流程:

有人通過 > 把門關起來 > 等待 n 秒 > 把門打開

所以利用 throttle 就能控制每隔幾秒讓一個人通過,這就是它的用途。

回到前端的部分,這可以用在你不希望「某個事件在短時間內重複觸發」,例如最常見的:scroll

先來看沒有 throttle 的情況:

no-throttle

加了 throttle 以後(delay 時間為 250ms):

throttle

實作 throttle

這邊的實作方式是寫一個 throttle 函式,接收兩個參數:

  • fn 要執行的 function
  • delay 要等待的時間

把 function 丟進去後會回傳一個新的 function,這個 function 必須每隔幾秒才能 call 一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function throttle (fn, delay) {
let isTimeout = true;
function newFunc (...argu) {
// 如果門是開著的
if (isTimeout) {
// 把門關上
isTimeout = false;
// call function
fn(...argu);
// 幾秒後再把門重新打開
setTimeout(() => isTimeout = true, delay)
}
}
return newFunc
}
module.exports = throttle;

這邊被關在閉包中的變數為:isTimeoutdelayfn

最後附個測試檔:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const throttle = require('./throttle');
describe("throttle", () => {
it('can not retrigger function during the time', async () => {
// 替身 function,預設回傳值為 undefined
const fn = jest.fn();
const throttleFn = throttle(fn, 250);

// 呼叫第一次
throttleFn(10);
// 檢查第一次是否有執行
expect(fn).toHaveBeenCalledWith(10);
// 呼叫次數:1
expect(fn.mock.calls.length).toBe(1);

// 等待 delay
await sleep(250);
// 再呼叫一次,看看是否執行
throttleFn(20)
expect(fn).toHaveBeenCalledWith(20);
// 呼叫次數:2
expect(fn.mock.calls.length).toBe(2);

// 等待 delay
await sleep(250);
// 多次呼叫,真正被呼叫的應該只有第一個
throttleFn("b", "a");
for (let i=1; i<=100; i++) {
throttleFn("a", "b", "c");
}
expect(fn).toHaveBeenCalledWith('b', 'a');
// 呼叫次數:3
expect(fn.mock.calls.length).toBe(3);
})
})

debounce

debounce 的直翻是「防抖」。

debounce 跟 throttle 的概念有點「類似」(只是觀念類似,但用途差很多),但我暫時想不到比較生活化的例子,所以直接拿前端的實際應用來舉例。

用過 google 搜尋的話應該都知道輸入文字時會有 auto complete 的功能:

google

這個功能的實作大概會長這樣:

1
2
3
4
5
6
7
$('input').on('input', async function () {
// 取得輸入內容
const value = this.value;
// 發 request 到後端拿資料(相關的關鍵字)
const result = await getAutoComplete(value);
// 把結果渲染到畫面上...
})

但這樣有個問題,如果搜尋「花生醬批發」,程式會這樣跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
取得關鍵字「花」
發 request 查詢跟「花」相關的關鍵字
把結果渲染到畫面上

取得關鍵字「生」
發 request 查詢跟「生」相關的關鍵字
把結果渲染到畫面上

取得關鍵字「醬」
發 request 查詢跟「醬」相關的關鍵字
把結果渲染到畫面上

取得關鍵字「批」
發 request 查詢跟「批」相關的關鍵字
把結果渲染到畫面上

取得關鍵字「醬」
發 request 查詢跟「發」相關的關鍵字
把結果渲染到畫面上

問題是指我每打一個字,就會發一個 request 到後端去。

這樣子的效能不好,明明我只是想搜尋「花生醬批發」而已,卻要發 5 次 request。

比較好的做法是:

在輸入文字時,先設定一段時間(這段時間被稱為「threshold 閾值」),在這期間輸入文字的話就得重新等待,必須等到時間結束時都沒有輸入文字,才會發 request 去拿資料。

這個就是 debounce 的作用。

接著來演示一段沒有 debounce 的情況:

no-debounce

加了 debounce 以後:

debounce

實作 debounce

這邊一樣會實作一個函式 debounce,接收兩個參數:

  • fn 要執行的函式
  • delay 要等待的時間

把 function 丟進去後會回傳一個新的 function,這個 function 會以 debounce 的方式來執行:

1
2
3
4
5
6
7
8
9
10
11
function debounce(fn, delay) {
let timer = null;
function newFunc (...argu) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => fn(...argu), delay);
}
return newFunc;
}
module.exports = debounce;

一樣附上測試檔:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const debounce = require('./debounce');
describe("debounce", () => {
it('should trigger function after certain delay', async () => {
// 替身 function,預設回傳值為 undefined
const fn = jest.fn();
const debouncedFn = debounce(fn, 250);

debouncedFn(10);
// 還不能被呼叫
expect(fn).not.toHaveBeenCalledWith();

// 等待 delay
await sleep(250);
// 檢查呼叫時傳入的參數是否為 10
expect(fn).toHaveBeenCalledWith(10);
// 檢查呼叫次數是否為一次
expect(fn.mock.calls.length).toBe(1);

// 多次呼叫
debouncedFn("b", "a");
debouncedFn("d", "e");
debouncedFn("a", "b", "c");
expect(fn.mock.calls.length).toBe(1);

// 等待 delay
await sleep(250);
// 檢查呼叫次數是否為兩次(加上前面的)
expect(fn.mock.calls.length).toBe(2);
// 檢查呼叫時傳入的參數是否為 a b c(確認是最後一個 functino 被呼叫)
expect(fn).toHaveBeenCalledWith("a", "b", "c");
})
})
mentor-program-day98 mentor-program-day97
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×