23日 七月 2020

装饰者模式和转发,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) ); // 一样的

alert( slow(2) ); // slow(2) 被缓存下来了
alert( "Again: " + 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); // 使用 spread 语法将数组作为列表传递
func.apply(context, args);   // 与使用 call 相同

这里只有很小的区别:

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

因此,当我们期望可迭代对象时,使用 call,当我们期望类数组对象时,使用 apply

对于即可迭代又是类数组的对象,例如一个真正的数组,我们使用 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

The result of debounce(f, ms) decorator is a wrapper that suspends calls to f until there’s ms milliseconds of inactivity (no calls, “cooldown period”), then invokes f once with the latest arguments.

For instance, we had a function f and replaced it with f = debounce(f, 1000).

Then if the wrapped function is called at 0ms, 200ms and 500ms, and then there are no calls, then the actual f will be only called once, at 1500ms. That is: after the cooldown period of 1000ms from the last call.

…And it will get the arguments of the very last call, other calls are ignored.

Here’s the code for it (uses the debounce decorator from the Lodash library:

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

Now a practical example. Let’s say, the user types something, and we’d like to send a request to the server when the input is finished.

There’s no point in sending the request for every character typed. Instead we’d like to wait, and then process the whole result.

In a web-browser, we can setup an event handler – a function that’s called on every change of an input field. Normally, an event handler is called very often, for every typed key. But if we debounce it by 1000ms, then it will be only called once, after 1000ms after the last input.

In this live example, the handler puts the result into a box below, try it:

See? The second input calls the debounced function, so its content is processed after 1000ms from the last input.

So, debounce is a great way to process a sequence of events: be it a sequence of key presses, mouse movements or something else.

It waits the given time after the last call, and then runs its function, that can process the result.

The task is to implement debounce decorator.

Hint: that’s just a few lines if you think about it :)

打开带有测试的沙箱。

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

A call to debounce returns a wrapper. When called, it schedules the original function call after given ms and cancels the previous such timeout.

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

重要程度: 5

创建一个“节流”装饰者 throttle(f, ms) —— 返回一个包装器。

当被多次调用时,它会在每 ms 毫秒最多将调用传递给 f 一次。

与去抖的不同是,它是个完全不同的装饰器:

  • debounce 会在“冷却(cooldown)”期后运行函数一次。适用于处理最终结果。
  • throttle 运行函数的频率不会大于所给定的时间 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;
    }

    func.apply(this, arguments); // (1)

    isThrottled = true;

    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 中。请注意,上下文和参数(arguments)同等重要,应该被记下来。我们同时需要他们以重现调用。
  3. ……然后经过 ms 毫秒后,触发 setTimeout。冷却状态被移除(isThrottled = false),如果我们忽略了调用,则将使用最后记忆的参数和上下文执行 wrapper

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

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

教程路线图

评论

在评论之前先阅读本内容…
  • 如果你发现教程有错误,或者有其他需要修改和提升的地方 — 请 提交一个 GitHub issue 或 pull request,而不是在这评论。
  • 如果你对教程的内容有不理解的地方 — 请详细说明。
  • 使用 <code> 标签插入只有几个词的代码,插入多行代码可以使用 <pre> 标签,对于超过 10 行的代码,建议你使用沙箱(plnkrJSBincodepen…)