JavaScript 在處理函式時提供了極佳的彈性。它們可以傳遞、用作物件,而我們現在將看到如何轉發函式之間的呼叫,並對它們進行「裝飾」。
透明快取
假設我們有一個函式 slow(x)
,它會大量使用 CPU,但其結果是穩定的。換句話說,對於相同的 x
,它總是會傳回相同的結果。
如果函式經常被呼叫,我們可能想要快取(記住)結果,以避免花費額外的時間重新計算。
但我們不會將該功能新增到 slow()
中,而是會建立一個包裝函式,以新增快取。正如我們將看到的,這樣做有很多好處。
以下是程式碼,後續會提供說明
function slow(x) {
// there can be a heavy CPU-intensive job here
alert(`Called with ${x}`);
return x;
}
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) { // if there's such key in cache
return cache.get(x); // read the result from it
}
let result = func(x); // otherwise call func
cache.set(x, result); // and cache (remember) the result
return result;
};
}
slow = cachingDecorator(slow);
alert( slow(1) ); // slow(1) is cached and the result returned
alert( "Again: " + slow(1) ); // slow(1) result returned from cache
alert( slow(2) ); // slow(2) is cached and the result returned
alert( "Again: " + slow(2) ); // slow(2) result returned from cache
在上述程式碼中,cachingDecorator
是裝飾器:一個特殊的函式,用於取得另一個函式並變更其行為。
構想是,我們可以對任何函式呼叫 cachingDecorator
,它會傳回快取包裝器。這很棒,因為我們可以有許多函式可以使用此類功能,而我們只需要對它們套用 cachingDecorator
即可。
透過將快取與主函式程式碼分開,我們也可以讓主程式碼更簡單。
cachingDecorator(func)
的結果是「包裝器」:function(x)
,它將 func(x)
的呼叫「包裝」到快取邏輯中
從外部程式碼來看,包裝後的 slow
函式仍然執行相同的工作。它只是在行為中新增了快取面向。
總之,使用獨立的 cachingDecorator
而不是變更 slow
本身的程式碼,有以下好處
cachingDecorator
是可重複使用的。我們可以將它套用至另一個函式。- 快取邏輯是獨立的,它沒有增加
slow
本身的複雜度(如果有)。 - 如果需要,我們可以結合多個裝飾器(其他裝飾器將隨後說明)。
使用「func.call」作為內容
上述的快取裝飾器不適合用於物件方法。
例如,在下列程式碼中,worker.slow()
在裝飾後會停止運作
// we'll make worker.slow caching
let worker = {
someMethod() {
return 1;
},
slow(x) {
// scary CPU-heavy task here
alert("Called with " + x);
return x * this.someMethod(); // (*)
}
};
// same code as before
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) ); // the original method works
worker.slow = cachingDecorator(worker.slow); // now make it caching
alert( worker.slow(2) ); // Whoops! 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
,以及後續引數作為引數。
簡單來說,這兩個呼叫幾乎執行相同的工作
func(1, 2, 3);
func.call(obj, 1, 2, 3)
它們都以引數 1
、2
和 3
呼叫 func
。唯一的差別是 func.call
也將 this
設定為 obj
。
舉例來說,在下列程式碼中,我們在不同物件的內容中呼叫 sayHi
:sayHi.call(user)
提供 this=user
執行 sayHi
,而下一行設定 this=admin
function sayHi() {
alert(this.name);
}
let user = { name: "John" };
let admin = { name: "Admin" };
// use call to pass different objects as "this"
sayHi.call( user ); // John
sayHi.call( admin ); // Admin
在此,我們使用 call
以提供的內容和短語呼叫 say
function say(phrase) {
alert(this.name + ': ' + phrase);
}
let user = { name: "John" };
// user becomes this, and "Hello" becomes the first argument
say.call( user, "Hello" ); // John: Hello
在我們的案例中,我們可以在 wrapper 中使用 call
將 context 傳遞給原始函式
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" is passed correctly now
cache.set(x, result);
return result;
};
}
worker.slow = cachingDecorator(worker.slow); // now make it caching
alert( worker.slow(2) ); // works
alert( worker.slow(2) ); // works, doesn't call the original (cached)
現在一切都很好。
為了讓一切更清楚,讓我們更深入了解 this
如何傳遞
- 在裝飾後,
worker.slow
現在是 wrapperfunction (x) { ... }
。 - 因此,當執行
worker.slow(2)
時,wrapper 會取得2
作為引數,而this=worker
(這是點之前的物件)。 - 在 wrapper 內部,假設結果尚未快取,
func.call(this, x)
會將目前的this
(=worker
)和目前的引數(=2
)傳遞給原始方法。
使用多個引數
現在讓我們讓 cachingDecorator
更加通用。到目前為止,它只適用於單一引數函式。
現在如何快取多個引數的 worker.slow
方法?
let worker = {
slow(min, max) {
return min + max; // scary CPU-hogger is assumed
}
};
// should remember same-argument calls
worker.slow = cachingDecorator(worker.slow);
先前,對於單一引數 x
,我們可以只使用 cache.set(x, result)
來儲存結果,並使用 cache.get(x)
來擷取結果。但現在我們需要記住引數組合 (min,max)
的結果。原生的 Map
只接受單一值作為鍵。
有很多可能的解決方案
- 實作一個新的(或使用第三方)類似 map 的資料結構,它更靈活且允許多個鍵。
- 使用巢狀 map:
cache.set(min)
將會是一個儲存配對(max, result)
的Map
。因此,我們可以取得result
作為cache.get(min).get(max)
。 - 將兩個值合併成一個值。在我們的特定案例中,我們可以使用字串
"min,max"
作為Map
鍵。為了靈活性,我們可以允許為裝飾器提供一個 雜湊函式,它知道如何從多個值中產生一個值。
對於許多實際應用,第 3 種變體已經夠好,所以我們將採用它。
我們還需要傳遞的不只是 x
,還有 func.call
中的所有引數。讓我們回想一下,在 function()
中,我們可以取得其引數的偽陣列作為 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)
傳遞 wrapper 取得的內容和所有參數(不只是第一個)給原始函數。
func.apply
我們可以使用 func.apply(this, arguments)
取代 func.call(this, ...arguments)
。
內建方法 func.apply 的語法為
func.apply(context, args)
它執行 func
設定 this=content
並使用類陣列物件 args
作為參數清單。
call
和 apply
之間唯一的語法差異是 call
預期參數清單,而 apply
則使用類陣列物件。
因此這兩個呼叫幾乎是等效的
func.call(context, ...args);
func.apply(context, args);
它們執行相同的 func
呼叫,並提供給定的內容和參數。
關於 args
只有細微的差異
- 散佈語法
...
允許將 可迭代args
作為清單傳遞給call
。 apply
僅接受 類陣列args
。
…而對於可迭代且類陣列的物件,例如真實陣列,我們可以使用其中任何一個,但 apply
可能會更快,因為大多數 JavaScript 引擎在內部會最佳化它。
將所有參數連同內容傳遞給另一個函數稱為呼叫轉發。
這是最簡單的形式
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);
不過,有一個簡單的方法可以使用陣列連接
function hash() {
alert( [].join.call(arguments) ); // 1,2
}
hash(1, 2);
這個技巧稱為方法借用。
我們從常規陣列([].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]
等連接在一起。它故意以允許任何類陣列 this
的方式撰寫(這不是巧合,許多方法都遵循此慣例)。這就是它也能與 this=arguments
一起運作的原因。
裝飾器和函式屬性
通常可以安全地用裝飾過的函式或方法取代函式或方法,除了小事一樁。如果原始函式有屬性,例如 func.calledCount
或其他,那麼裝飾過的函式將不會提供它們。因為那是一個包裝器。所以如果有人使用它們,就需要小心。
例如,在上面的範例中,如果 slow
函式有任何屬性,那麼 cachingDecorator(slow)
就是沒有它們的包裝器。
有些裝飾器可能會提供自己的屬性。例如,裝飾器可以計算函式被呼叫的次數以及花費多少時間,並透過包裝器屬性公開這些資訊。
有一種方法可以建立保留函式屬性存取權限的裝飾器,但這需要使用特殊的 Proxy
物件來包裝函式。我們將在文章 Proxy 和 Reflect 中稍後討論它。
摘要
裝飾器是函式周圍的包裝器,用於改變其行為。主要工作仍然由函式執行。
裝飾器可以視為可以新增到函式的「功能」或「面向」。我們可以新增一個或多個。而且所有這些都不會改變其程式碼!
要實作 cachingDecorator
,我們研究了方法
- func.call(context, arg1, arg2…) – 以給定的上下文和引數呼叫
func
。 - func.apply(context, args) – 呼叫
func
,將context
傳遞為this
,並將類陣列args
傳遞為引數清單。
一般的呼叫轉發通常使用 apply
來完成
let wrapper = function() {
return original.apply(this, arguments);
};
當我們從物件中取得方法並在另一個物件的背景下呼叫
它時,我們也看到了方法借用的範例。將陣列方法取出並套用於參數
是很常見的作法。另一種作法是使用實際上為陣列的 rest 參數物件。
在野外有許多裝飾器。透過解決本章節的任務,檢查你對它們的掌握程度。
留言
<code>
標籤,若要插入多行程式碼,請使用<pre>
標籤將其包覆,若要插入超過 10 行的程式碼,請使用沙盒 (plnkr、jsbin、codepen…)