原文链接:https://javascript.info/call-apply-decorators,translate with ❤️ by zhangbao.

JavaScript 在处理函数上异常灵活。函数可以作为值传递,作为对象使用,现在讲怎样在函数之间转发和修饰它们。

透明缓存

有一个函数 slow(x) 执行起来非常消耗 CPU,但结果是稳定的。也就是说,传递相同的 x 值,总是得到同一个结果。

如果函数经常调用,我们可能就想缓存不同 x 值下的结果了,以避免额外的重新计算消耗。

不是把记忆功能放在 slow 本身,我们在 slow 函数外部,来一个包裹。我们将看到,这样做有许多好处。

这是代码,和一些解释:

  1. function slow(x) {
  2. // 这里很一个很耗费 CPU 资源的计算过程
  3. alert(`Called with ${x}`);
  4. return x;
  5. }
  6. function cachingDecorator(func) {
  7. let cache = new Map();
  8. return function(x) {
  9. if (cache.has(x)) { // 如果结果已经在缓存(Map)里了
  10. return cache.get(x); // 就直接返回
  11. }
  12. let result = func(x); // 否则执行 func,得到结果
  13. cache.set(x, result); // 缓存(记忆)结果
  14. return result;
  15. };
  16. }
  17. slow = cachingDecorator(slow);
  18. alert( slow(1) ); // slow(1) 被缓存了
  19. alert( "Again: " + slow(1) ); // 返回相同结果(缓存的)
  20. alert( slow(2) ); // slow(2) 被缓存了
  21. alert( "Again: " + slow(2) ); // 返回相同结果(缓存的)

在上面的代码里,cachingDecorator 是一个修饰器:一个特殊函数,拿到到另一个函数,包装它,并借此并修改它的行为。

我们可以使用 cachingDecorator 缓存、调用任意函数,它会返回缓存之后的包装函数。这很好,因为我们有许多函数要这么做,都可以使用这个 cachingDecorator 函数。

将缓存逻辑与和函数业务逻辑分离,我们能让代码更简单。

现在,我们进一步看作用详情。

cachingDecorator(func) 的结果是一个“包装器”:function(x) “包装”了 func(x) 调用,包含了缓存逻辑:

装饰和转发,call/apply - 图1

我们能看到,包装器返回的结果与直接调用 func(x) 返回的结果是一样的。对外部代码来说,还是 slow 函数,没有变。它只是在它的行为中添加了一个缓存功能。

做个总结:我们之所以使用分离的 cachingDecorator 函数而不是直接修改 slow 代码的好处:

  • cachingDecorator 可重用。直接使用另一个函数调用它,就可以得到另一个包装函数了。

  • 缓存逻辑是分离的·,slow 函数本身的复杂度没有增加(如果本身有的话)。

  • 我们可以结合使用多个修饰器(其他修饰符也遵循这个规则)。

使用“func.call”改变上下文

上面提到的缓存修饰器并不适应于对象方法。

例如,下面代码里的 worker.slow() 在装入修饰器调用之后就停止了:

  1. // 我们为 worker.slow 方法做一个缓存
  2. let worker = {
  3. someMethod() {
  4. return 1;
  5. },
  6. slow(x) {
  7. // 实际上,这是一个很耗费 CPU 的操作
  8. alert("Called with " + x);
  9. return x * this.someMethod(); // (*)
  10. }
  11. };
  12. // 跟之前一样的代码
  13. function cachingDecorator(func) {
  14. let cache = new Map();
  15. return function(x) {
  16. if (cache.has(x)) {
  17. return cache.get(x);
  18. }
  19. let result = func(x); // (**)
  20. cache.set(x, result);
  21. return result;
  22. };
  23. }
  24. alert( worker.slow(1) ); // 原始方法正常工作
  25. worker.slow = cachingDecorator(worker.slow); // 现在缓存
  26. alert( worker.slow(2) ); // Uncaught TypeError: this.someMethod is not a function(…)

错误发生在 (*) 处,它尝试访问 this.someMethod 但是失败了,你能知道为什么吗?

原因是我们在 func(x) 里 (**) 处真实封装调用的是原始函数。因此,当我们调用是,内部的 this=window
(严格模式下是 undefined)。

如果我们试着跑下面的代码,我们会观察到类似的症状:

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

所以,包装器将调用传递给原始方法,但是没有提供上下文。因此,导致了错误。

我们来修复下。

有一个特殊的内置方法 func.call(context, …args) 允许在调用函数时,指明函数执行时的上下文(即 this)。

语法是:

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

call 的第一个参数指定了 this 取值,从第二个开始就是调用函数时的参数列表了。

简单地说,这两个调用几乎是一样的:

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

两个调用方式都是使用参数 1,2 和 3 来调用函数 func。唯一不同的是 func.call 在调用的同时,指定了 this 到 obj。

作为例子,下面代码里,我们调用 sayHi 在两个不同的上下文环境中:sayHi.call(user) 使用 user 作为上下文环境执行 sayHi,下一行设置的上下文环境则是 admin:

  1. function sayHi() {
  2. alert(this.name);
  3. }
  4. let user = { name: "John" };
  5. let admin = { name: "Admin" };
  6. // 使用 call 去传递不同的对象作为“this”值
  7. sayHi.call( user ); // this = John
  8. sayHi.call( admin ); // this = Admin

下面例子中,我们使用 call 调用 say 时提供了上下文环境和短语:

  1. function say(phrase) {
  2. alert(this.name + ': ' + phrase);
  3. }
  4. let user = { name: "John" };
  5. // user 成为 this, "Hello" 变为第一个参数
  6. say.call( user, "Hello" ); // John: Hello

在我们的例子中,我们可以使用包装器中的 call 将上下文传递给原始函数:

  1. let worker = {
  2. someMethod() {
  3. return 1;
  4. },
  5. slow(x) {
  6. alert("Called with " + x);
  7. return x * this.someMethod(); // (*)
  8. }
  9. };
  10. function cachingDecorator(func) {
  11. let cache = new Map();
  12. return function(x) {
  13. if (cache.has(x)) {
  14. return cache.get(x);
  15. }
  16. let result = func.call(this, x); // 将"this"给当前的执行主语
  17. cache.set(x, result);
  18. return result;
  19. };
  20. }
  21. worker.slow = cachingDecorator(worker.slow); // 缓存
  22. alert( worker.slow(2) ); // 因为使用了调用主语作为上下文环境,所以结果正常输出
  23. alert( worker.slow(2) ); // 正常输出,但输出的是缓存的结果

现在一切都 OK 了。

为了让大家明白,让我们更深入地了解 this 是如何传递的:

  1. 在 worker.slow 在经过修饰之后,成为了包装函数 function (x) {…}。

  2. 所以当worker.slow)(2) 被执行时,包装器会得到 2 作为参数,而 this=worker(点之前的对象)。

  3. 在包装器内部,假设结果并没有缓存,func.call(this, x) 传递了当前的 this(=worker)和当前参数(=2)向原始函数。

使用“func.apply”调用多参数

现在我们让 cachingDecorator 更加通用,到目前为止,它只使用单参数函数。

怎样去缓存多参数 worker.slow 方法调用?

  1. let worker = {
  2. slow(min, max) {
  3. return min + max; // 假设这个操作很耗费资源
  4. }
  5. };
  6. // 会缓存结果
  7. worker.slow = cachingDecorator(worker.slow);

我们有两个任何需要解决。

我们怎么使用 min 和 max 两个参数作为 key 缓存在 map 里。在之前,对于一个参数 x 的情况,我们调用 cache.set(x, result) 保存结果,并且使用 cache.get(x) 得到结果。 但是现在我们需要记住一个参数组合的结果(min,max),原生 Map 只支持以单个值作为键。

有很多解决办法:

  1. 实现一个新的(或使用第三方)类似 map 的数据结构,它更加通用,并且允许一次设置多键。

  2. 使用嵌套映射:cache.set(min) 将是存储这一对 (max, result) 的 Map,所以我们可以通过 cache.get(min).get(max) 得到 result。

  3. 把两个值合并成一个。在我们的特殊情况下,我们可以使用字符串 “min,max” 作为 Map 键。为了获得灵活性,我们可以为修饰器提供一个哈希函数,它知道如何从多个值中获得一个值。

对于许多实际应用,第三种变体已经足够好了,所以我们将坚持下去。

要解决的第二个任务是如何将许多参数传递给 func。目前,包装器 function(x) 只接收一个参数,并传递给 func.call(this, x)。

在这里我们可以使用另一个内置的方法 func.apply

语法是:

  1. func.apply(context, args)

它用指定的上下文环境 context(即 this)和使用类数组对象 args 作为参数序列执行函数 func。

例如,这两个 call 几乎是一样的:

  1. func(1, 2, 3);
  2. func.apply(context, [1, 2, 3])

执行 func 时,传递了参数 1,2,3,而且设置了 this=context。

例如,这里的 say 用 this=user 调用,并将 messageData 作为参数列表专递:

  1. function say(time, phrase) {
  2. alert(`[${time}] ${this.name}: ${phrase}`);
  3. }
  4. let user = { name: "John" };
  5. let messageData = ['10:00', 'Hello']; // become time and phrase
  6. // user 成为了 this, messageData 是传递过去的参数列表 (time, phrase)
  7. say.apply(user, messageData); // [10:00] John: Hello (this=user)

call 和 apply 之间的唯一不同点是,call 接收的是用逗号分隔的参数列表,而 apply 接收的是一个类数组对象作为调用参数。

我们已经知道扩展运算符 … 从 剩余参数和扩展运算符 一章里,可以传递一个数组(或者任何可迭代对象)作为参数列表。

所以如果我们用 call 来调用,我们可以达到几乎 apply 一样的效果。

这两个调用几乎是等价的:

  1. let args = [1, 2, 3];
  2. func.call(context, ...args); // 将数组使用扩展运算符展开成参数列表,就可以用 call 了
  3. func.apply(context, args); // 与使用 apply 方法的效果相同

如果我们更仔细地观察,call 和 apply 的使用之间有一个细微的差别。

  • 扩展运算符 … 允许将可迭代对象 args 展开为调用 call 时的参数列表。

  • apply 仅接受类数组 args。

所以,这些调用是互补的。我们期望的是一个可迭代的,call OK,我们期望一个类数组的,apply OK。

如果 args 是可迭代的和类数组的,就像一个真正的数组一样,那么我们在技术上可以使用它们中的任何一个,但是 apply 可能会更快,因为它是一个单独的操作。大多数的 JavaScript 引擎内部优化比 call + 扩展运算符 的效率要高。

apply 最重要的用途之一是将调用传递给另一个函数,如下:

  1. let wrapper = function() {
  2. return anotherFunction.apply(this, arguments);
  3. };

这称为调用转发。wrapper 会传递所有接受到的东西:当前上下文环境 this 和 参数到 anotherFunction,并且返回函数执行结果。

当外部代码调用这样的包装器时,它难以区分与原始函数调用的区别。

现在让我们把它们都放到更强大的 cachingDecorator 中:

  1. let worker = {
  2. slow(min, max) {
  3. alert(`Called with ${min},${max}`);
  4. return min + max;
  5. }
  6. };
  7. function cachingDecorator(func, hash) {
  8. let cache = new Map();
  9. return function() {
  10. let key = hash(arguments); // (*)
  11. if (cache.has(key)) {
  12. return cache.get(key);
  13. }
  14. let result = func.apply(this, arguments); // (**)
  15. cache.set(key, result);
  16. return result;
  17. };
  18. }
  19. function hash(args) {
  20. return args[0] + ',' + args[1];
  21. }
  22. worker.slow = cachingDecorator(worker.slow, hash);
  23. alert( worker.slow(3, 5) ); // 正常工作
  24. alert( "Again " + worker.slow(3, 5) ); // 返回同样的结果(缓存的)

现在包装器使用任意数量的参数进行操作了:

这里有两点改变的地方:

  • 在 (*) 处,它调用 hash 将 arguments 创建成单个键的形式。在这里,我们使用一个简单的“连接”函数,将参数 (3, 5) 转换为键 “3,5”,更复杂的情况可能需要其他的哈希函数。

  • 然后,使用func.apply 在 (**) 处,来传递包装器得到的上下文和所有参数(不管有多少)到原始函数。

方法借用

现在让我们在哈希函数中做一个小小的改进:

  1. function hash(args) {
  2. return args[0] + ',' + args[1];
  3. }

到目前为止,它只适用于两个参数。如果它能粘上任意数量的参数,那就更好了。

自然的解决方案是使用 arr.join 方法:

  1. function hash(args) {
  2. return args.join();
  3. }

…不幸的是,这是行不通的。因为我们调用 hash(arguments) 和 arguments 对象是可迭代的类数组,它不是真正的数组。

因此,调用 join 将失败,如下所示:

  1. function hash() {
  2. alert( arguments.join() ); // Error: arguments.join is not a function
  3. }
  4. hash(1, 2);

尽管如此,还是有一种简单的方法来使用数组连接:

  1. function hash() {
  2. alert( [].join.call(arguments) ); // 1,2
  3. }
  4. hash(1, 2);

这个技巧叫做方法借用

我们从一个常规数组获取(借用)联接方法 [].join,并且使用 [].join.call 在指定环境上下文 arguments 中运行它。

是怎么起作用的呢?

这是因为本地方法 arr.join(glue) 的内部算法非常简单。

从规范中获得的几乎是“原样”:

  1. join 方法接受一个可选参数,表示链接元素时,中间使用的连接符。不提供时,默认是用 “,”,

  2. 让 result 是一个空字符串,

  3. 将 this[0] 的值添加到 result,

  4. 添加 glue 和 this[1],

  5. 添加 glue 和 this[2],

  6. …一直到 this.length 个元素都连接了,

  7. 返回 result。

所以,从技术上讲,它使用 this、将 this[0]、this[1]……一起连接起来。它是有意地以一种允许任何数组的方式编写的(不是一个巧合,许多方法都遵循这个实践)。这就是为什么它也适用于 this=arguments 的情况。

总结

装饰器是一个改变函数行为的包装器,主要的工作仍然是由这个被包装的函数完成。

一般来说,用一个修饰过的函数或方法替换一个函数是安全的,除了一个小的东西。如果原始函数在上面有属性,比如 func.calledCount 或其他什么,那么装饰的就不会提供给他们,因为这是一个包装器。因此,如果有人使用它们,就需要小心。一些装饰者提供他们自己的属性。

装饰器可以被看作是可以添加到函数中的“特性”或“方面”,我们可以添加一个或添加多个。所有这些都不改变函数本身的代码!

为了实现 cachingDecorator,我们学习了方法:

一般的调用转发通常是通过 apply 完成的:

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

我们还看到了一个方法借用的例子。当我们从一个对象中取出一个方法并在另一个对象的上下文中调用它。使用数组方法并将它们应用到参数中是很常见的,另一种方法是使用剩余参数对象,它是一个真实的数组对象了。

在野外有许多装饰者。通过解决这一章的任务来检查你是如何得到它们的。

(完)