JavaScript 在處理函數時提供了非凡的靈活性。它們可以被傳遞,用作對象,現在我們將看到如何在它們之間 轉發(forward) 調用並 裝飾(decorate) 它們。
假設我們有壹個 CPU 重負載的函數 slow(x)
,但它的結果是穩定的。換句話說,對于相同的 x
,它總是返回相同的結果。
如果經常調用該函數,我們可能希望將結果緩存(記住)下來,以避免在重新計算上花費額外的時間。
但是我們不是將這個功能添加到 slow()
中,而是創建壹個包裝器(wrapper)函數,該函數增加了緩存功能。正如我們將要看到的,這樣做有很多好處。
下面是代碼和解釋:
function slow(x) { // 這裏可能會有重負載的 CPU 密集型工作 alert(`Called with ${x}`); return x; } function cachingDecorator(func) { let cache = new Map(); return function(x) { if (cache.has(x)) { // 如果緩存中有對應的結果 return cache.get(x); // 從緩存中讀取結果 } let result = func(x); // 否則就調用 func cache.set(x, result); // 然後將結果緩存(記住)下來 return result; }; } slow = cachingDecorator(slow); alert( slow(1) ); // slow(1) 被緩存下來了,並返回結果 alert( "Again: " + slow(1) ); // 返回緩存中的 slow(1) 的結果 alert( slow(2) ); // slow(2) 被緩存下來了,並返回結果 alert( "Again: " + slow(2) ); // 返回緩存中的 slow(2) 的結果
在上面的代碼中,cachingDecorator
是壹個 裝飾器(decorator):壹個特殊的函數,它接受另壹個函數並改變它的行爲。
其思想是,我們可以爲任何函數調用 cachingDecorator
,它將返回緩存包裝器。這很棒啊,因爲我們有很多函數可以使用這樣的特性,而我們需要做的就是將 cachingDecorator
應用于它們。
通過將緩存與主函數代碼分開,我們還可以使主函數代碼變得更簡單。
cachingDecorator(func)
的結果是壹個“包裝器”:function(x)
將 func(x)
的調用“包裝”到緩存邏輯中:
從外部代碼來看,包裝的 slow
函數執行的仍然是與之前相同的操作。它只是在其行爲上添加了緩存功能。
總而言之,使用分離的 cachingDecorator
而不是改變 slow
本身的代碼有幾個好處:
cachingDecorator
是可重用的。我們可以將它應用于另壹個函數。
緩存邏輯是獨立的,它沒有增加 slow
本身的複雜性(如果有的話)。
如果需要,我們可以組合多個裝飾器(其他裝飾器將遵循同樣的邏輯)。
上面提到的緩存裝飾器不適用于對象方法。
例如,在下面的代碼中,worker.slow()
在裝飾後停止工作:
// 我們將對 worker.slow 的結果進行緩存 let worker = { someMethod() { return 1; }, slow(x) { // 可怕的 CPU 過載任務 alert("Called with " + x); return x * this.someMethod(); // (*) } }; // 和之前例子中的代碼相同 function cachingDecorator(func) { let cache = new Map(); return function(x) { if (cache.has(x)) { return cache.get(x); } let result = func(x); // (**) cache.set(x, result); return result; }; } alert( worker.slow(1) ); // 原始方法有效 worker.slow = cachingDecorator(worker.slow); // 現在對其進行緩存 alert( worker.slow(2) ); // 蛤!Error: Cannot read property 'someMethod' of undefined
錯誤發生在試圖訪問 this.someMethod
並失敗了的 (*)
行中。妳能看出來爲什麽嗎?
原因是包裝器將原始函數調用爲 (**)
行中的 func(x)
。並且,當這樣調用時,函數將得到 this = undefined
。
如果嘗試運行下面這段代碼,我們會觀察到類似的問題:
let func = worker.slow; func(2);
因此,包裝器將調用傳遞給原始方法,但沒有上下文 this
。因此,發生了錯誤。
讓我們來解決這個問題。
有壹個特殊的內建函數方法 func.call(context, …args),它允許調用壹個顯式設置 this
的函數。
語法如下:
func.call(context, arg1, arg2, ...)
它運行 func
,提供的第壹個參數作爲 this
,後面的作爲參數(arguments)。
簡單地說,這兩個調用幾乎相同:
func(1, 2, 3); func.call(obj, 1, 2, 3)
它們調用的都是 func
,參數是 1
、2
和 3
。唯壹的區別是 func.call
還會將 this
設置爲 obj
。
例如,在下面的代碼中,我們在不同對象的上下文中調用 sayHi
:sayHi.call(user)
運行 sayHi
並提供了 this=user
,然後下壹行設置 this=admin
:
function sayHi() { alert(this.name); } let user = { name: "John" }; let admin = { name: "Admin" }; // 使用 call 將不同的對象傳遞爲 "this" sayHi.call( user ); // John sayHi.call( admin ); // Admin
在這裏我們用帶有給定上下文和 phrase 的 call
調用 say
:
function say(phrase) { alert(this.name + ': ' + phrase); } let user = { name: "John" }; // user 成爲 this,"Hello" 成爲第壹個參數 say.call( user, "Hello" ); // John: Hello
在我們的例子中,我們可以在包裝器中使用 call
將上下文傳遞給原始函數:
let worker = { someMethod() { return 1; }, slow(x) { alert("Called with " + x); return x * this.someMethod(); // (*) } }; function cachingDecorator(func) { let cache = new Map(); return function(x) { if (cache.has(x)) { return cache.get(x); } let result = func.call(this, x); // 現在 "this" 被正確地傳遞了 cache.set(x, result); return result; }; } worker.slow = cachingDecorator(worker.slow); // 現在對其進行緩存 alert( worker.slow(2) ); // 工作正常 alert( worker.slow(2) ); // 工作正常,沒有調用原始函數(使用的緩存)
現在壹切都正常工作了。
爲了讓大家理解地更清晰壹些,讓我們更深入地看看 this
是如何被傳遞的:
在經過裝飾之後,worker.slow
現在是包裝器 function (x) { ... }
。
因此,當 worker.slow(2)
執行時,包裝器將 2
作爲參數,並且 this=worker
(它是點符號 .
之前的對象)。
在包裝器內部,假設結果尚未緩存,func.call(this, x)
將當前的 this
(=worker
)和當前的參數(=2
)傳遞給原始方法。
現在讓我們把 cachingDecorator
寫得更加通用。到現在爲止,它只能用于單參數函數。
現在如何緩存多參數 worker.slow
方法呢?
let worker = { slow(min, max) { return min + max; // scary CPU-hogger is assumed } }; // 應該記住相同參數的調用 worker.slow = cachingDecorator(worker.slow);
之前,對于單個參數 x
,我們可以只使用 cache.set(x, result)
來保存結果,並使用 cache.get(x)
來檢索並獲取結果。但是現在,我們需要記住 參數組合 (min,max)
的結果。原生的 Map
僅將單個值作爲鍵(key)。
這兒有許多解決方案可以實現:
實現壹個新的(或使用第三方的)類似 map 的更通用並且允許多個鍵的數據結構。
使用嵌套 map:cache.set(min)
將是壹個存儲(鍵值)對 (max, result)
的 Map
。所以我們可以使用 cache.get(min).get(max)
來獲取 result
。
將兩個值合並爲壹個。爲了靈活性,我們可以允許爲裝飾器提供壹個“哈希函數”,該函數知道如何將多個值合並爲壹個值。
對于許多實際應用,第三種方式就足夠了,所以我們就用這個吧。
當然,我們需要傳入的不僅是 x
,還需要傳入 func.call
的所有參數。讓我們回想壹下,在 function()
中我們可以得到壹個包含所有參數的僞數組(pseudo-array)arguments
,那麽 func.call(this, x)
應該被替換爲 func.call(this, ...arguments)
。
這是壹個更強大的 cachingDecorator
:
let worker = { slow(min, max) { alert(`Called with ${min},${max}`); return min + max; } }; function cachingDecorator(func, hash) { let cache = new Map(); return function() { let key = hash(arguments); // (*) if (cache.has(key)) { return cache.get(key); } let result = func.call(this, ...arguments); // (**) cache.set(key, result); return result; }; } function hash(args) { return args[0] + ',' + args[1]; } worker.slow = cachingDecorator(worker.slow, hash); alert( worker.slow(3, 5) ); // works alert( "Again " + worker.slow(3, 5) ); // same (cached)
現在這個包裝器可以處理任意數量的參數了(盡管哈希函數還需要被進行調整以允許任意數量的參數。壹種有趣的處理方法將在下面講到)。
這裏有兩個變化:
在 (*)
行中它調用 hash
來從 arguments
創建壹個單獨的鍵。這裏我們使用壹個簡單的“連接”函數,將參數 (3, 5)
轉換爲鍵 "3,5"
。更複雜的情況可能需要其他哈希函數。
然後 (**)
行使用 func.call(this, ...arguments)
將包裝器獲得的上下文和所有參數(不僅僅是第壹個參數)傳遞給原始函數。
我們可以使用 func.apply(this, arguments)
代替 func.call(this, ...arguments)
。
內建方法 func.apply 的語法是:
func.apply(context, args)
它運行 func
設置 this=context
,並使用類數組對象 args
作爲參數列表(arguments)。
call
和 apply
之間唯壹的語法區別是,call
期望壹個參數列表,而 apply
期望壹個包含這些參數的類數組對象。
因此,這兩個調用幾乎是等效的:
func.call(context, ...args); func.apply(context, args);
它們使用給定的上下文和參數執行相同的 func
調用。
只有壹個關于 args
的細微的差別:
Spread 語法 ...
允許將 可叠代對象 args
作爲列表傳遞給 call
。
apply
只接受 類數組 args
。
……對于即可叠代又是類數組的對象,例如壹個真正的數組,我們使用 call
或 apply
均可,但是 apply
可能會更快,因爲大多數 JavaScript 引擎在內部對其進行了優化。
將所有參數連同上下文壹起傳遞給另壹個函數被稱爲“呼叫轉移(call forwarding)”。
這是它的最簡形式:
let wrapper = function() { return func.apply(this, arguments); };
當外部代碼調用這種包裝器 wrapper
時,它與原始函數 func
的調用是無法區分的。
現在,讓我們對哈希函數再做壹個較小的改進:
function hash(args) { return args[0] + ',' + args[1]; }
截至目前,它僅適用于兩個參數。如果它可以適用于任何數量的 args
就更好了。
自然的解決方案是使用 arr.join 方法:
function hash(args) { return args.join(); }
……不幸的是,這不行。因爲我們正在調用 hash(arguments)
,arguments
對象既是可叠代對象又是類數組對象,但它並不是真正的數組。
所以在它上面調用 join
會失敗,我們可以在下面看到:
function hash() { alert( arguments.join() ); // Error: arguments.join is not a function } hash(1, 2);
不過,有壹種簡單的方法可以使用數組的 join 方法:
function hash() { alert( [].join.call(arguments) ); // 1,2 } hash(1, 2);
這個技巧被稱爲 方法借用(method borrowing)。
我們從常規數組 [].join
中獲取(借用)join 方法,並使用 [].join.call
在 arguments
的上下文中運行它。
它爲什麽有效?
那是因爲原生方法 arr.join(glue)
的內部算法非常簡單。
從規範中幾乎“按原樣”解釋如下:
讓 glue
成爲第壹個參數,如果沒有參數,則使用逗號 ","
。
讓 result
爲空字符串。
將 this[0]
附加到 result
。
附加 glue
和 this[1]
。
附加 glue
和 this[2]
。
……以此類推,直到 this.length
項目被粘在壹起。
返回 result
。
因此,從技術上講,它需要 this
並將 this[0]
,this[1]
……等 join 在壹起。它的編寫方式是故意允許任何類數組的 this
的(不是巧合,很多方法都遵循這種做法)。這就是爲什麽它也可以和 this=arguments
壹起使用。
通常,用裝飾的函數替換壹個函數或壹個方法是安全的,除了壹件小東西。如果原始函數有屬性,例如 func.calledCount
或其他,則裝飾後的函數將不再提供這些屬性。因爲這是裝飾器。因此,如果有人使用它們,那麽就需要小心。
例如,在上面的示例中,如果 slow
函數具有任何屬性,而 cachingDecorator(slow)
則是壹個沒有這些屬性的包裝器。
壹些包裝器可能會提供自己的屬性。例如,裝飾器會計算壹個函數被調用了多少次以及花費了多少時間,並通過包裝器屬性公開(expose)這些信息。
存在壹種創建裝飾器的方法,該裝飾器可保留對函數屬性的訪問權限,但這需要使用特殊的 Proxy
對象來包裝函數。我們將在後面的 Proxy 和 Reflect 中學習它。
裝飾器 是壹個圍繞改變函數行爲的包裝器。主要工作仍由該函數來完成。
裝飾器可以被看作是可以添加到函數的 “features” 或 “aspects”。我們可以添加壹個或添加多個。而這壹切都無需更改其代碼!
爲了實現 cachingDecorator
,我們研究了以下方法:
func.call(context, arg1, arg2…) —— 用給定的上下文和參數調用 func
。
func.apply(context, args) —— 調用 func
將 context
作爲 this
和類數組的 args
傳遞給參數列表。
通用的 調用傳遞(call forwarding) 通常是使用 apply
完成的:
let wrapper = function() { return original.apply(this, arguments); };
我們也可以看到壹個 方法借用(method borrowing) 的例子,就是我們從壹個對象中獲取壹個方法,並在另壹個對象的上下文中“調用”它。采用數組方法並將它們應用于參數 arguments
是很常見的。另壹種方法是使用 Rest 參數對象,該對象是壹個真正的數組。
在 JavaScript 領域裏有很多裝飾器(decorators)。通過解決本章的任務,來檢查妳掌握它們的程度吧。
重要程度: 5
創建壹個裝飾器 spy(func)
,它應該返回壹個包裝器,該包裝器將所有對函數的調用保存在其 calls
屬性中。
每個調用都保存爲壹個參數數組。
例如:
function work(a, b) { alert( a + b ); // work 是壹個任意的函數或方法 } work = spy(work); work(1, 2); // 3 work(4, 5); // 9 for (let args of work.calls) { alert( 'call:' + args.join() ); // "call:1,2", "call:4,5" }
P.S. 該裝飾器有時對于單元測試很有用。它的高級形式是 Sinon.JS 庫中的 sinon.spy
。
打開帶有測試的沙箱。
由 spy(f)
返回的包裝器應存儲所有參數,然後使用 f.apply
轉發調用。
function spy(func) { function wrapper(...args) { // using ...args instead of arguments to store "real" array in wrapper.calls wrapper.calls.push(args); return func.apply(this, args); } wrapper.calls = []; return wrapper; }
使用沙箱的測試功能打開解決方案。
重要程度: 5
創建壹個裝飾器 delay(f, ms)
,該裝飾器將 f
的每次調用延時 ms
毫秒。
例如:
function f(x) { alert(x); } // create wrappers let f1000 = delay(f, 1000); let f1500 = delay(f, 1500); f1000("test"); // 在 1000ms 後顯示 "test" f1500("test"); // 在 1500ms 後顯示 "test"
換句話說,delay(f, ms)
返回的是延遲 ms
後的 f
的變體。
在上面的代碼中,f
是單個參數的函數,但是妳的解決方案應該傳遞所有參數和上下文 this
。
打開帶有測試的沙箱。
解決方案:
function delay(f, ms) { return function() { setTimeout(() => f.apply(this, arguments), ms); }; } let f1000 = delay(alert, 1000); f1000("test"); // shows "test" after 1000ms
注意這裏是如何使用箭頭函數的。我們知道,箭頭函數沒有自己的 this
和 arguments
,所以 f.apply(this, arguments)
從包裝器中獲取 this
和 arguments
。
如果我們傳遞壹個常規函數,setTimeout
將調用它且不帶參數,並且 this=window
(假設我們在浏覽器環境)。
我們仍然可以通過使用中間變量來傳遞正確的 this
,但這有點麻煩:
function delay(f, ms) { return function(...args) { let savedThis = this; // 將 this 存儲到中間變量 setTimeout(function() { f.apply(savedThis, args); // 在這兒使用它 }, ms); }; }
使用沙箱的測試功能打開解決方案。
重要程度: 5
debounce(f, ms)
裝飾器的結果是壹個包裝器,該包裝器將暫停對 f
的調用,直到經過 ms
毫秒的非活動狀態(沒有函數調用,“冷卻期”),然後使用最新的參數調用 f
壹次。
換句話說,debounce
就像壹個“接聽電話”的秘書,並壹直等到 ms
毫秒的安靜時間之後,才將最新的呼叫信息傳達給“老板”(調用實際的 f
)。
舉個例子,我們有壹個函數 f
,並將其替換爲 f = debounce(f, 1000)
。
然後,如果包裝函數分別在 0ms、200ms 和 500ms 時被調用了,之後沒有其他調用,那麽實際的 f
只會在 1500ms 時被調用壹次。也就是說:從最後壹次調用開始經過 1000ms 的冷卻期之後。
……並且,它將獲得最後壹個調用的所有參數,其他調用的參數將被忽略。
以下是其實現代碼(使用了 Lodash library 中的防抖裝飾器 ):
let f = _.debounce(alert, 1000); f("a"); setTimeout( () => f("b"), 200); setTimeout( () => f("c"), 500); // 防抖函數從最後壹次函數調用以後等待 1000ms,然後執行:alert("c")
現在我們舉壹個實際中的例子。假設用戶輸入了壹些內容,我們想要在用戶輸入完成時向服務器發送壹個請求。
我們沒有必要爲每壹個字符的輸入都發送請求。相反,我們想要等壹段時間,然後處理整個結果。
在 Web 浏覽器中,我們可以設置壹個事件處理程序 —— 壹個在每次輸入內容發生改動時都會調用的函數。通常,監聽所有按鍵輸入的事件的處理程序會被調用的非常頻繁。但如果我們爲這個處理程序做壹個 1000ms 的 debounce
處理,它僅會在最後壹次輸入後的 1000ms 後被調用壹次。
在這個實時演示的示例中,處理程序將結果顯示在了下面的方框中,試試看:
看到了嗎?第二個輸入框調用了防抖函數,所以它的內容是在最後壹次輸入的 1000ms 後被處理的。
因此,debounce
是壹個處理壹系列事件的好方法:無論是系列鍵盤輸入,鼠標移動還是其他類似的事件。
它在最後壹次調用之後等待給定的時間,然後運行其可以處理結果的函數。
任務是實現壹個 debounce
裝飾器。
提示:如果妳好好想想,實現它只需要幾行代碼 :)
打開帶有測試的沙箱。
function debounce(func, ms) { let timeout; return function() { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, arguments), ms); }; }
調用 debounce
會返回壹個包裝器。當它被調用時,它會安排壹個在給定的 ms
之後對原始函數的調用,並取消之前的此類超時。
使用沙箱的測試功能打開解決方案。
重要程度: 5
創建壹個“節流”裝飾器 throttle(f, ms)
—— 返回壹個包裝器。
當被多次調用時,它會在每 ms
毫秒最多將調用傳遞給 f
壹次。
與防抖(debounce)裝飾器相比,其行爲完全不同:
debounce
會在“冷卻(cooldown)”期後運行函數壹次。適用于處理最終結果。
throttle
運行函數的頻率不會大于所給定的時間 ms
毫秒。適用于不應該經常進行的定期更新。
換句話說,throttle
就像接電話的秘書,但是打擾老板(實際調用 f
)的頻率不能超過每 ms
毫秒壹次。
讓我們看看現實生活中的應用程序,以便更好地理解這個需求,並了解它的來源。
例如,我們想要跟蹤鼠標移動。
在浏覽器中,我們可以設置壹個函數,使其在每次鼠標移動時運行,並獲取鼠標移動時的指針位置。在使用鼠標的過程中,此函數通常會執行地非常頻繁,大概每秒 100 次(每 10 毫秒)。
我們想要在鼠標指針移動時,更新網頁上的某些信息。
……但是更新函數 update()
太重了,無法在每個微小移動上都執行。高于每 100ms 更新壹次的更新頻次也沒有意義。
因此,我們將其包裝到裝飾器中:使用 throttle(update, 100)
作爲在每次鼠標移動時運行的函數,而不是原始的 update()
。裝飾器會被頻繁地調用,但是最多每 100ms 將調用轉發給 update()
壹次。
在視覺上,它看起來像這樣:
對于第壹個鼠標移動,裝飾的變體立即將調用傳遞給 update
。這很重要,用戶會立即看到我們對其動作的反應。
然後,隨著鼠標移動,直到 100ms
沒有任何反應。裝飾的變體忽略了調用。
在 100ms
結束時 —— 最後壹個坐標又發生了壹次 update
。
然後,最後,鼠標停在某處。裝飾的變體會等到 100ms
到期,然後用最後壹個坐標運行壹次 update
。因此,非常重要的是,處理最終的鼠標坐標。
壹個代碼示例:
function f(a) { console.log(a); } // f1000 最多每 1000ms 將調用傳遞給 f 壹次 let f1000 = throttle(f, 1000); f1000(1); // 顯示 1 f1000(2); // (節流,尚未到 1000ms) f1000(3); // (節流,尚未到 1000ms) // 當 1000ms 時間到... // ...輸出 3,中間值 2 被忽略
P.S. 參數(arguments)和傳遞給 f1000
的上下文 this
應該被傳遞給原始的 f
。
打開帶有測試的沙箱。
function throttle(func, ms) { let isThrottled = false, savedArgs, savedThis; function wrapper() { if (isThrottled) { // (2) savedArgs = arguments; savedThis = this; return; } isThrottled = true; func.apply(this, arguments); // (1) setTimeout(function() { isThrottled = false; // (3) if (savedArgs) { wrapper.apply(savedThis, savedArgs); savedArgs = savedThis = null; } }, ms); } return wrapper; }
調用 throttle(func, ms)
返回 wrapper
。
在第壹次調用期間,wrapper
只運行 func
並設置冷卻狀態(isThrottled = true
)。
在冷卻狀態下,所有調用都被保存在 savedArgs/savedThis
中。請注意,上下文(this)和參數(arguments)都很重要,應該被保存下來。我們需要它們來重現調用。
經過 ms
毫秒後,setTimeout
中的函數被觸發。冷卻狀態被移除(isThrottled = false
),如果存在被忽略的調用,將使用最後壹次調用保存的參數和上下文運行 wrapper
。
第 3 步運行的不是 func
,而是 wrapper
,因爲我們不僅需要執行 func
,還需要再次進入冷卻狀態並設置 setTimeout
以重置節流。
使用沙箱的測試功能打開解決方案。