2023年7月30日

装饰器模式和转发,call/apply

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 本身的复杂性(如果有的话)。
  • 如果需要,我们可以组合多个装饰器(其他装饰器将遵循同样的逻辑)。

使用 “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); // 现在对其进行缓存

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,参数是 123。唯一的区别是 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); // 现在 "this" 被正确地传递了
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // 现在对其进行缓存

alert( worker.slow(2) ); // 工作正常
alert( worker.slow(2) ); // 工作正常,没有调用原始函数(使用的缓存)

现在一切都正常工作了。

为了让大家理解地更清晰一些,让我们更深入地看看 this 是如何被传递的:

  1. 在经过装饰之后,worker.slow 现在是包装器 function (x) { ... }
  2. 因此,当 worker.slow(2) 执行时,包装器将 2 作为参数,并且 this=worker(它是点符号 . 之前的对象)。
  3. 在包装器内部,假设结果尚未缓存,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)。

这儿有许多解决方案可以实现:

  1. 实现一个新的(或使用第三方的)类似 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)

现在这个包装器可以处理任意数量的参数了(尽管哈希函数还需要被进行调整以允许任意数量的参数。一种有趣的处理方法将在下面讲到)。

这里有两个变化:

  • (*) 行中它调用 hash 来从 arguments 创建一个单独的键。这里我们使用一个简单的“连接”函数,将参数 (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)。

callapply 之间唯一的语法区别是,call 期望一个参数列表,而 apply 期望一个包含这些参数的类数组对象。

因此,这两个调用几乎是等效的:

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

它们使用给定的上下文和参数执行相同的 func 调用。

只有一个关于 args 的细微的差别:

  • Spread 语法 ... 允许将 可迭代对象 args 作为列表传递给 call
  • apply 只接受 类数组 args

……对于即可迭代又是类数组的对象,例如一个真正的数组,我们使用 callapply 均可,但是 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.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] ……等 join 在一起。它的编写方式是故意允许任何类数组的 this 的(不是巧合,很多方法都遵循这种做法)。这就是为什么它也可以和 this=arguments 一起使用。

装饰器和函数属性

通常,用装饰的函数替换一个函数或一个方法是安全的,除了一件小东西。如果原始函数有属性,例如 func.calledCount 或其他,则装饰后的函数将不再提供这些属性。因为这是装饰器。因此,如果有人使用它们,那么就需要小心。

例如,在上面的示例中,如果 slow 函数具有任何属性,而 cachingDecorator(slow) 则是一个没有这些属性的包装器。

一些包装器可能会提供自己的属性。例如,装饰器会计算一个函数被调用了多少次以及花费了多少时间,并通过包装器属性公开(expose)这些信息。

存在一种创建装饰器的方法,该装饰器可保留对函数属性的访问权限,但这需要使用特殊的 Proxy 对象来包装函数。我们将在后面的 Proxy 和 Reflect 中学习它。

总结

装饰器 是一个围绕改变函数行为的包装器。主要工作仍由该函数来完成。

装饰器可以被看作是可以添加到函数的 “features” 或 “aspects”。我们可以添加一个或添加多个。而这一切都无需更改其代码!

为了实现 cachingDecorator,我们研究了以下方法:

通用的 调用传递(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

注意这里是如何使用箭头函数的。我们知道,箭头函数没有自己的 thisarguments,所以 f.apply(this, arguments) 从包装器中获取 thisarguments

如果我们传递一个常规函数,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() 一次。

在视觉上,它看起来像这样:

  1. 对于第一个鼠标移动,装饰的变体立即将调用传递给 update。这很重要,用户会立即看到我们对其动作的反应。
  2. 然后,随着鼠标移动,直到 100ms 没有任何反应。装饰的变体忽略了调用。
  3. 100ms 结束时 —— 最后一个坐标又发生了一次 update
  4. 然后,最后,鼠标停在某处。装饰的变体会等到 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

  1. 在第一次调用期间,wrapper 只运行 func 并设置冷却状态(isThrottled = true)。
  2. 在冷却状态下,所有调用都被保存在 savedArgs/savedThis 中。请注意,上下文(this)和参数(arguments)都很重要,应该被保存下来。我们需要它们来重现调用。
  3. 经过 ms 毫秒后,setTimeout中的函数被触发。冷却状态被移除(isThrottled = false),如果存在被忽略的调用,将使用最后一次调用保存的参数和上下文运行 wrapper

第 3 步运行的不是 func,而是 wrapper,因为我们不仅需要执行 func,还需要再次进入冷却状态并设置 setTimeout 以重置节流。

使用沙箱的测试功能打开解决方案。

教程路线图