Javascript 裝飾器模式和轉發(fā),call/apply

2023-02-17 10:51 更新

JavaScript 在處理函數時提供了非凡的靈活性。它們可以被傳遞,用作對象,現(xiàn)在我們將看到如何在它們之間 轉發(fā)(forward) 調用并 裝飾(decorate) 它們。

透明緩存

假設我們有一個 CPU 重負載的函數 slow(x),但它的結果是穩(wěn)定的。換句話說,對于相同的 x,它總是返回相同的結果。

如果經常調用該函數,我們可能希望將結果緩存(記?。┫聛?,以避免在重新計算上花費額外的時間。

但是我們不是將這個功能添加到 slow() 中,而是創(chuàng)建一個包裝器(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 函數執(zhí)行的仍然是與之前相同的操作。它只是在其行為上添加了緩存功能。

總而言之,使用分離的 cachingDecorator 而不是改變 slow 本身的代碼有幾個好處:

  • ?cachingDecorator? 是可重用的。我們可以將它應用于另一個函數。
  • 緩存邏輯是獨立的,它沒有增加 ?slow? 本身的復雜性(如果有的話)。
  • 如果需要,我們可以組合多個裝飾器(其他裝飾器將遵循同樣的邏輯)。

使用 “func.call” 設定上下文

上面提到的緩存裝飾器不適用于對象方法。

例如,在下面的代碼中,?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); // 現(xiàn)在對其進行緩存

alert( worker.slow(2) ); // 蛤!Error: Cannot read property 'someMethod' of undefined

錯誤發(fā)生在試圖訪問 this.someMethod 并失敗了的 (*) 行中。你能看出來為什么嗎?

原因是包裝器將原始函數調用為 (**) 行中的 func(x)。并且,當這樣調用時,函數將得到 this = undefined

如果嘗試運行下面這段代碼,我們會觀察到類似的問題:

let func = worker.slow;
func(2);

因此,包裝器將調用傳遞給原始方法,但沒有上下文 this。因此,發(fā)生了錯誤。

讓我們來解決這個問題。

有一個特殊的內建函數方法 func.call(context, …args),它允許調用一個顯式設置 ?this? 的函數。

語法如下:

func.call(context, arg1, arg2, ...)

它運行 func,提供的第一個參數作為 this,后面的作為參數(arguments)。

簡單地說,這兩個調用幾乎相同:

func(1, 2, 3);
func.call(obj, 1, 2, 3)

它們調用的都是 func,參數是 12 和 3。唯一的區(qū)別是 func.call 還會將 this 設置為 obj

例如,在下面的代碼中,我們在不同對象的上下文中調用 sayHisayHi.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); // 現(xiàn)在 "this" 被正確地傳遞了
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // 現(xiàn)在對其進行緩存

alert( worker.slow(2) ); // 工作正常
alert( worker.slow(2) ); // 工作正常,沒有調用原始函數(使用的緩存)

現(xiàn)在一切都正常工作了。

為了讓大家理解地更清晰一些,讓我們更深入地看看 ?this? 是如何被傳遞的:

  1. 在經過裝飾之后,?worker.slow? 現(xiàn)在是包裝器 ?function (x) { ... }?。
  2. 因此,當 ?worker.slow(2)? 執(zhí)行時,包裝器將 ?2? 作為參數,并且 ?this=worker?(它是點符號 ?.? 之前的對象)。
  3. 在包裝器內部,假設結果尚未緩存,?func.call(this, x)? 將當前的 ?this?(?=worker?)和當前的參數(?=2?)傳遞給原始方法。

傳遞多個參數

現(xiàn)在讓我們把 ?cachingDecorator? 寫得更加通用。到現(xiàn)在為止,它只能用于單參數函數。

現(xiàn)在如何緩存多參數 ?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) 來檢索并獲取結果。但是現(xiàn)在,我們需要記住 參數組合 (min,max) 的結果。原生的 Map 僅將單個值作為鍵(key)。

這兒有許多解決方案可以實現(xiàn):

  1. 實現(xiàn)一個新的(或使用第三方的)類似 map 的更通用并且允許多個鍵的數據結構。
  2. 使用嵌套 map:?cache.set(min)? 將是一個存儲(鍵值)對 ?(max, result)? 的 ?Map?。所以我們可以使用 ?cache.get(min).get(max)? 來獲取 ?result?。
  3. 將兩個值合并為一個。為了靈活性,我們可以允許為裝飾器提供一個“哈希函數”,該函數知道如何將多個值合并為一個值。

對于許多實際應用,第三種方式就足夠了,所以我們就用這個吧。

當然,我們需要傳入的不僅是 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)

現(xiàn)在這個包裝器可以處理任意數量的參數了(盡管哈希函數還需要被進行調整以允許任意數量的參數。一種有趣的處理方法將在下面講到)。

這里有兩個變化:

  • 在 ?(*)? 行中它調用 ?hash? 來從 ?arguments? 創(chuàng)建一個單獨的鍵。這里我們使用一個簡單的“連接”函數,將參數 ?(3, 5)? 轉換為鍵 ?"3,5"?。更復雜的情況可能需要其他哈希函數。
  • 然后 ?(**)? 行使用 ?func.call(this, ...arguments)? 將包裝器獲得的上下文和所有參數(不僅僅是第一個參數)傳遞給原始函數。

func.apply

我們可以使用 ?func.apply(this, arguments)? 代替 ?func.call(this, ...arguments)?。

內建方法 func.apply 的語法是:

func.apply(context, args)

它運行 func 設置 this=context,并使用類數組對象 args 作為參數列表(arguments)。

call 和 apply 之間唯一的語法區(qū)別是,call 期望一個參數列表,而 apply 期望一個包含這些參數的類數組對象。

因此,這兩個調用幾乎是等效的:

func.call(context, ...args);
func.apply(context, args);

它們使用給定的上下文和參數執(zhí)行相同的 func 調用。

只有一個關于 args 的細微的差別:

  • Spread 語法 ?...? 允許將 可迭代對象 ?args? 作為列表傳遞給 ?call?。
  • ?apply? 只接受 類數組 ?args?。

……對于即可迭代又是類數組的對象,例如一個真正的數組,我們使用 call 或 apply 均可,但是 apply 可能會更快,因為大多數 JavaScript 引擎在內部對其進行了優(yōu)化。

將所有參數連同上下文一起傳遞給另一個函數被稱為“呼叫轉移(call forwarding)”。

這是它的最簡形式:

let wrapper = function() {
  return func.apply(this, arguments);
};

當外部代碼調用這種包裝器 wrapper 時,它與原始函數 func 的調用是無法區(qū)分的。

借用一種方法

現(xiàn)在,讓我們對哈希函數再做一個較小的改進:

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)

我們從常規(guī)數組 [].join 中獲?。ń栌茫﹋oin 方法,并使用 [].join.call 在 arguments 的上下文中運行它。

它為什么有效?

那是因為原生方法 arr.join(glue) 的內部算法非常簡單。

從規(guī)范中幾乎“按原樣”解釋如下:

  1. 讓 ?glue? 成為第一個參數,如果沒有參數,則使用逗號 ?","?。
  2. 讓 ?result? 為空字符串。
  3. 將 ?this[0]? 附加到 ?result?。
  4. 附加 ?glue? 和 ?this[1]?。
  5. 附加 ?glue? 和 ?this[2]?。
  6. ……以此類推,直到 ?this.length? 項目被粘在一起。
  7. 返回 ?result?。

因此,從技術上講,它需要 this 并將 this[0]this[1] ……等 join 在一起。它的編寫方式是故意允許任何類數組的 this 的(不是巧合,很多方法都遵循這種做法)。這就是為什么它也可以和 this=arguments 一起使用。

裝飾器和函數屬性

通常,用裝飾的函數替換一個函數或一個方法是安全的,除了一件小東西。如果原始函數有屬性,例如 func.calledCount 或其他,則裝飾后的函數將不再提供這些屬性。因為這是裝飾器。因此,如果有人使用它們,那么就需要小心。

例如,在上面的示例中,如果 slow 函數具有任何屬性,而 cachingDecorator(slow) 則是一個沒有這些屬性的包裝器。

一些包裝器可能會提供自己的屬性。例如,裝飾器會計算一個函數被調用了多少次以及花費了多少時間,并通過包裝器屬性公開(expose)這些信息。

存在一種創(chuàng)建裝飾器的方法,該裝飾器可保留對函數屬性的訪問權限,但這需要使用特殊的 Proxy 對象來包裝函數。我們將在后面的 Proxy 和 Reflect 中學習它。

總結

裝飾器 是一個圍繞改變函數行為的包裝器。主要工作仍由該函數來完成。

裝飾器可以被看作是可以添加到函數的 “features” 或 “aspects”。我們可以添加一個或添加多個。而這一切都無需更改其代碼!

為了實現(xiàn) cachingDecorator,我們研究了以下方法:

通用的 呼叫轉移(call forwarding) 通常是使用 apply 完成的:

let wrapper = function() {
  return original.apply(this, arguments);
};

我們也可以看到一個 方法借用(method borrowing) 的例子,就是我們從一個對象中獲取一個方法,并在另一個對象的上下文中“調用”它。采用數組方法并將它們應用于參數 arguments 是很常見的。另一種方法是使用 Rest 參數對象,該對象是一個真正的數組。

在 JavaScript 領域里有很多裝飾器(decorators)。通過解決本章的任務,來檢查你掌握它們的程度吧。

任務


間諜裝飾器

重要程度: 5

創(chuàng)建一個裝飾器 ?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 轉發(fā)調用。

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

創(chuàng)建一個裝飾器 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。

如果我們傳遞一個常規(guī)函數,setTimeout 將調用它且不帶參數,并且 this=window(假設我們在瀏覽器環(huán)境)。

我們仍然可以通過使用中間變量來傳遞正確的 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 毫秒的非活動狀態(tài)(沒有函數調用,“冷卻期”),然后使用最新的參數調用 f 一次。

換句話說,debounce 就像一個“接聽電話”的秘書,并一直等到 ms 毫秒的安靜時間之后,才將最新的呼叫信息傳達給“老板”(調用實際的 f)。

舉個例子,我們有一個函數 f,并將其替換為 f = debounce(f, 1000)。

然后,如果包裝函數分別在 0ms、200ms 和 500ms 時被調用了,之后沒有其他調用,那么實際的 f 只會在 1500ms 時被調用一次。也就是說:從最后一次調用開始經過 1000ms 的冷卻期之后。


……并且,它將獲得最后一個調用的所有參數,其他調用的參數將被忽略。

以下是其實現(xiàn)代碼(使用了 Lodash library 中的防抖裝飾器 ):

let f = _.debounce(alert, 1000);

f("a");
setTimeout( () => f("b"), 200);
setTimeout( () => f("c"), 500);
// 防抖函數從最后一次函數調用以后等待 1000ms,然后執(zhí)行:alert("c")

現(xiàn)在我們舉一個實際中的例子。假設用戶輸入了一些內容,我們想要在用戶輸入完成時向服務器發(fā)送一個請求。

我們沒有必要為每一個字符的輸入都發(fā)送請求。相反,我們想要等一段時間,然后處理整個結果。

在 Web 瀏覽器中,我們可以設置一個事件處理程序 —— 一個在每次輸入內容發(fā)生改動時都會調用的函數。通常,監(jiān)聽所有按鍵輸入的事件的處理程序會被調用的非常頻繁。但如果我們?yōu)檫@個處理程序做一個 1000ms 的 debounce 處理,它僅會在最后一次輸入后的 1000ms 后被調用一次。

任務是實現(xiàn)一個 debounce 裝飾器。

提示:如果你好好想想,實現(xiàn)它只需要幾行代碼 :)


解決方案

function debounce(func, ms) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, arguments), ms);
  };
}

調用 debounce 會返回一個包裝器。當它被調用時,它會安排一個在給定的 ms 之后對原始函數的調用,并取消之前的此類超時。


節(jié)流裝飾器

重要程度: 5

創(chuàng)建一個“節(jié)流”裝飾器 throttle(f, ms) —— 返回一個包裝器。

當被多次調用時,它會在每 ms 毫秒最多將調用傳遞給 f 一次。

與防抖(debounce)裝飾器相比,其行為完全不同:

  • ?debounce? 會在“冷卻(cooldown)”期后運行函數一次。適用于處理最終結果。
  • ?throttle? 運行函數的頻率不會大于所給定的時間 ?ms? 毫秒。適用于不應該經常進行的定期更新。

換句話說,throttle 就像接電話的秘書,但是打擾老板(實際調用 f)的頻率不能超過每 ms 毫秒一次。

讓我們看看現(xiàn)實生活中的應用程序,以便更好地理解這個需求,并了解它的來源。

例如,我們想要跟蹤鼠標移動。

在瀏覽器中,我們可以設置一個函數,使其在每次鼠標移動時運行,并獲取鼠標移動時的指針位置。在使用鼠標的過程中,此函數通常會執(zhí)行地非常頻繁,大概每秒 100 次(每 10 毫秒)。

我們想要在鼠標指針移動時,更新網頁上的某些信息。

……但是更新函數 update() 太重了,無法在每個微小移動上都執(zhí)行。高于每 100ms 更新一次的更新頻次也沒有意義。

因此,我們將其包裝到裝飾器中:使用 throttle(update, 100) 作為在每次鼠標移動時運行的函數,而不是原始的 update()。裝飾器會被頻繁地調用,但是最多每 100ms 將調用轉發(fā)給 update() 一次。

在視覺上,它看起來像這樣:

  1. 對于第一個鼠標移動,裝飾的變體立即將調用傳遞給 ?update?。這很重要,用戶會立即看到我們對其動作的反應。
  2. 然后,隨著鼠標移動,直到 ?100ms? 沒有任何反應。裝飾的變體忽略了調用。
  3. 在 ?100ms? 結束時 —— 最后一個坐標又發(fā)生了一次 update。
  4. 然后,最后,鼠標停在某處。裝飾的變體會等到 ?100ms? 到期,然后用最后一個坐標運行一次 ?update?。因此,非常重要的是,處理最終的鼠標坐標。

一個代碼示例:

function f(a) {
  console.log(a);
}

// f1000 最多每 1000ms 將調用傳遞給 f 一次
let f1000 = throttle(f, 1000);

f1000(1); // 顯示 1
f1000(2); // (節(jié)流,尚未到 1000ms)
f1000(3); // (節(jié)流,尚未到 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。

  1. 在第一次調用期間,?wrapper? 只運行 ?func? 并設置冷卻狀態(tài)(?isThrottled = true?)。
  2. 在這種狀態(tài)下,所有調用都記憶在 ?savedArgs/savedThis? 中。請注意,上下文和參數(arguments)同等重要,應該被記下來。我們同時需要他們以重現(xiàn)調用。
  3. ……然后經過 ?ms? 毫秒后,觸發(fā) ?setTimeout?。冷卻狀態(tài)被移除(?isThrottled = false?),如果我們忽略了調用,則將使用最后記憶的參數和上下文執(zhí)行 ?wrapper?。

第 3 步運行的不是 func,而是 wrapper,因為我們不僅需要執(zhí)行 func,還需要再次進入冷卻狀態(tài)并設置 timeout 以重置它。


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號