2022 年 6 月 8 日

裝飾器和轉發,呼叫/套用

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)

它們都以引數 123 呼叫 func。唯一的差別是 func.call 也將 this 設定為 obj

舉例來說,在下列程式碼中,我們在不同物件的內容中呼叫 sayHisayHi.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 如何傳遞

  1. 在裝飾後,worker.slow 現在是 wrapper function (x) { ... }
  2. 因此,當執行 worker.slow(2) 時,wrapper 會取得 2 作為引數,而 this=worker(這是點之前的物件)。
  3. 在 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 只接受單一值作為鍵。

有很多可能的解決方案

  1. 實作一個新的(或使用第三方)類似 map 的資料結構,它更靈活且允許多個鍵。
  2. 使用巢狀 map:cache.set(min) 將會是一個儲存配對 (max, result)Map。因此,我們可以取得 result 作為 cache.get(min).get(max)
  3. 將兩個值合併成一個值。在我們的特定案例中,我們可以使用字串 "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 作為參數清單。

callapply 之間唯一的語法差異是 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.callarguments 的上下文中執行它。

為什麼它有效?

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

幾乎「原封不動」地取自規範

  1. glue 為第一個引數,或者如果沒有引數,則為逗號 ","
  2. result 為空字串。
  3. this[0] 附加到 result
  4. 附加 gluethis[1]
  5. 附加 gluethis[2]
  6. …這樣做,直到 this.length 個項目被黏合。
  7. 傳回 result

因此,從技術上來說,它取用 this 並將 this[0]this[1] 等連接在一起。它故意以允許任何類陣列 this 的方式撰寫(這不是巧合,許多方法都遵循此慣例)。這就是它也能與 this=arguments 一起運作的原因。

裝飾器和函式屬性

通常可以安全地用裝飾過的函式或方法取代函式或方法,除了小事一樁。如果原始函式有屬性,例如 func.calledCount 或其他,那麼裝飾過的函式將不會提供它們。因為那是一個包裝器。所以如果有人使用它們,就需要小心。

例如,在上面的範例中,如果 slow 函式有任何屬性,那麼 cachingDecorator(slow) 就是沒有它們的包裝器。

有些裝飾器可能會提供自己的屬性。例如,裝飾器可以計算函式被呼叫的次數以及花費多少時間,並透過包裝器屬性公開這些資訊。

有一種方法可以建立保留函式屬性存取權限的裝飾器,但這需要使用特殊的 Proxy 物件來包裝函式。我們將在文章 Proxy 和 Reflect 中稍後討論它。

摘要

裝飾器是函式周圍的包裝器,用於改變其行為。主要工作仍然由函式執行。

裝飾器可以視為可以新增到函式的「功能」或「面向」。我們可以新增一個或多個。而且所有這些都不會改變其程式碼!

要實作 cachingDecorator,我們研究了方法

一般的呼叫轉發通常使用 apply 來完成

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

當我們從物件中取得方法並在另一個物件的背景下呼叫它時,我們也看到了方法借用的範例。將陣列方法取出並套用於參數是很常見的作法。另一種作法是使用實際上為陣列的 rest 參數物件。

在野外有許多裝飾器。透過解決本章節的任務,檢查你對它們的掌握程度。

任務

重要性:5

建立一個裝飾器 spy(func),它應該回傳一個包裝器,將所有對函式的呼叫儲存在其 calls 屬性中。

每個呼叫都儲存為一個參數陣列。

例如

function work(a, b) {
  alert( a + b ); // work is an arbitrary function or method
}

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"
}

附註:此裝飾器有時對單元測試很有用。其進階形式為 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"); // shows "test" after 1000ms
f1500("test"); // shows "test" after 1500ms

換句話說,delay(f, ms) 會回傳一個延遲 msf 變體。

在上述程式碼中,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

請注意這裡是如何使用箭頭函式的。我們知道箭頭函式沒有自己的 thisarguments,所以 f.apply(this, arguments) 從包裝器取得 thisarguments

如果我們傳遞一個常規函式,setTimeout 會在沒有參數和 this=window 的情況下呼叫它(假設我們在瀏覽器中)。

我們仍然可以使用中間變數傳遞正確的 this,但那會有點麻煩

function delay(f, ms) {

  return function(...args) {
    let savedThis = this; // store this into an intermediate variable
    setTimeout(function() {
      f.apply(savedThis, args); // use it here
    }, ms);
  };

}

在沙盒中開啟包含測試的解答。

重要性:5

debounce(f, ms) 裝飾器的結果是一個包裝器,它會暫停對 f 的呼叫,直到有 ms 毫秒的非活動時間(沒有呼叫,「冷卻時間」),然後使用最新的參數呼叫 f 一次。

換句話說,debounce 就像一位秘書,它會接聽「電話」,並等到有 ms 毫秒的安靜時間。然後它才會將最新的呼叫資訊轉交給「老闆」(呼叫實際的 f)。

例如,我們有一個函式 f,並將它替換為 f = debounce(f, 1000)

然後,如果包裝函式在 0ms、200ms 和 500ms 時被呼叫,然後沒有呼叫,則實際的 f 只會在 1500ms 時被呼叫一次。也就是說:在最後一次呼叫後的 1000ms 冷卻時間過後。

…而且它會取得最後一次呼叫的參數,其他呼叫都會被忽略。

這是它的程式碼(使用 Lodash 函式庫 中的 debounce 裝飾器)

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

f("a");
setTimeout( () => f("b"), 200);
setTimeout( () => f("c"), 500);
// debounced function waits 1000ms after the last call and then runs: alert("c")

現在舉一個實際的例子。假設使用者輸入了一些東西,我們希望在輸入完成時向伺服器發送請求。

沒有必要在輸入每個字元時都發送請求。我們希望先等待,然後再處理整個結果。

在網頁瀏覽器中,我們可以設定一個事件處理常式,也就是在輸入欄位每次變更時呼叫的函式。通常,事件處理常式會被呼叫很多次,每次輸入按鍵都會呼叫。但是,如果我們以 1000 毫秒「延遲」它,那麼它將只會在最後一次輸入後的 1000 毫秒後呼叫一次。

在此動態範例中,處理常式會將結果放入下方的方框中,請試試看

看到了嗎?第二個輸入呼叫延遲函式,因此它的內容會在最後一次輸入後的 1000 毫秒後處理。

因此,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 在「冷卻」期間後執行函式一次。適合處理最終結果。
  • throttle 執行它的頻率不會高於指定的 ms 時間。適合不應該太頻繁的定期更新。

換句話說,throttle 就像一位秘書,他會接聽電話,但不會以每 ms 毫秒高於一次的頻率打擾老闆(呼叫實際的 f)。

讓我們檢查實際應用,以更好地瞭解該需求以及它的來源。

例如,我們想要追蹤滑鼠移動。

在瀏覽器中,我們可以設定一個函式,讓它在每次滑鼠移動時執行,並在滑鼠移動時取得指標位置。在滑鼠積極使用期間,此函式通常會非常頻繁地執行,每秒可能執行約 100 次(每 10 毫秒)。我們希望在指標移動時更新網頁上的某些資訊。

…但更新函式 update() 太過耗費資源,無法在每次微小移動時執行。而且每 100 毫秒更新一次就已足夠。

因此,我們會將其包裝在裝飾器中:使用 throttle(update, 100) 作為函式,在每次滑鼠移動時執行,取代原本的 update()。裝飾器會經常被呼叫,但最多每 100 毫秒才會將呼叫轉發給 update()

視覺上,它會像這樣

  1. 在第一次滑鼠移動時,裝飾變體會立即將呼叫傳遞給 update。這很重要,因為使用者會立即看到我們對其移動的反應。
  2. 然後,當滑鼠繼續移動時,在 100 毫秒 內不會發生任何事。裝飾變體會忽略呼叫。
  3. 100 毫秒 結束時,會再發生一次 update,並使用最後的座標。
  4. 最後,滑鼠會停在某處。裝飾變體會等到 100 毫秒 經過後,再使用最後的座標執行 update。因此,相當重要的是,最終的滑鼠座標會被處理。

程式碼範例

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

// f1000 passes calls to f at maximum once per 1000 ms
let f1000 = throttle(f, 1000);

f1000(1); // shows 1
f1000(2); // (throttling, 1000ms not out yet)
f1000(3); // (throttling, 1000ms not out yet)

// when 1000 ms time out...
// ...outputs 3, intermediate value 2 was ignored

附註:傳遞給 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 並設定冷卻狀態(isThrottled = true)。
  2. 在此狀態下,所有呼叫都會記錄在 savedArgs/savedThis 中。請注意,內容和參數都同樣重要,且應記錄下來。我們需要同時使用它們來重現呼叫。
  3. ms 毫秒經過後,setTimeout 會觸發。冷卻狀態會被移除(isThrottled = false),如果我們已忽略呼叫,wrapper 會使用最後記錄的參數和內容執行。

第 3 步驟不會執行 func,而是執行 wrapper,因為我們不僅需要執行 func,還需要再次進入冷卻狀態並設定計時器來重設它。

在沙盒中開啟包含測試的解答。

教學課程地圖

留言

留言前請先閱讀…
  • 如果您有改善建議 - 請 提交 GitHub 議題 或提交拉取請求,而非留言。
  • 如果您無法理解文章中的某個部分 – 請詳細說明。
  • 若要插入少數幾個字元的程式碼,請使用 <code> 標籤,若要插入多行程式碼,請使用 <pre> 標籤將其包覆,若要插入超過 10 行的程式碼,請使用沙盒 (plnkrjsbincodepen…)