在处理函数时,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)
它们都使用参数 1
、2
和 3
调用 func
。唯一的区别是 func.call
还将 this
设置为 obj
。
例如,在下面的代码中,我们在不同对象的上下文中调用 sayHi
:sayHi.call(user)
运行 sayHi
,提供 this=user
,下一行设置 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
在我们的案例中,我们可以在包装器中使用 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" 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
是如何传递的
- 在装饰
worker.slow
之后,现在是包装器function (x) { ... }
。 - 因此,当执行
worker.slow(2)
时,包装器将获得2
作为参数,并且this=worker
(它是点之前的对象)。 - 在包装器内部,假设结果尚未缓存,
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
只将单个值作为键。
有很多可能的解决方案
- 实现一个新的(或使用第三方)类似映射的数据结构,它更通用并允许多键。
- 使用嵌套映射:
cache.set(min)
将是存储对(max, result)
的Map
。因此,我们可以将result
获取为cache.get(min).get(max)
。 - 将两个值合并为一个值。在我们的特定案例中,我们可以将字符串
"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)
将上下文和包装器获取的所有参数(不仅仅是第一个参数)传递给原始函数。
func.apply
我们可以使用 func.apply(this, arguments)
代替 func.call(this, ...arguments)
。
内置方法 func.apply 的语法是
func.apply(context, args)
它运行 func
,设置 this=context
,并使用类数组对象 args
作为参数列表。
call
和 apply
之间唯一的语法区别是,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.call
在 arguments
的上下文中运行它。
它为什么有效?
这是因为原生方法 arr.join(glue)
的内部算法非常简单。
几乎“原样”地从规范中摘录
- 令
glue
为第一个参数,如果没有参数,则为逗号","
。 - 令
result
为一个空字符串。 - 将
this[0]
追加到result
。 - 追加
glue
和this[1]
。 - 追加
glue
和this[2]
。 - …一直这样进行,直到
this.length
个项目被粘合在一起。 - 返回
result
。
因此,从技术上讲,它获取 this
并将 this[0]
、this[1]
等连接在一起。它有意地以一种允许任何类似数组的 this
(这不是巧合,许多方法都遵循这种做法)的方式编写。这就是它也能与 this=arguments
一起工作的原因。
装饰器和函数属性
通常可以安全地用装饰过的函数或方法替换一个函数或方法,除了一个小问题。如果原始函数具有属性,例如 func.calledCount
或其他任何属性,那么装饰过的函数将不提供这些属性。因为那是一个包装器。所以如果使用它们,需要小心。
例如,在上面的示例中,如果 slow
函数具有任何属性,那么 cachingDecorator(slow)
就是一个没有这些属性的包装器。
一些装饰器可能会提供自己的属性。例如,一个装饰器可以计算一个函数被调用了多少次以及花费了多少时间,并通过包装器属性公开这些信息。
有一种方法可以创建保留对函数属性的访问权限的装饰器,但这需要使用一个特殊的 Proxy
对象来包装一个函数。我们将在文章 Proxy 和 Reflect 的后面部分讨论它。
总结
装饰器 是一个包装器,它围绕一个函数来改变其行为。主要工作仍然由函数执行。
装饰器可以看作是添加到函数的“特性”或“方面”。我们可以添加一个或多个。所有这些都不需要更改其代码!
为了实现 cachingDecorator
,我们研究了方法
- func.call(context, arg1, arg2…) – 使用给定的上下文和参数调用
func
。 - func.apply(context, args) – 调用
func
,将context
作为this
传递,并将类似数组的args
传递到参数列表中。
通用的调用转发通常使用 apply
来完成
let wrapper = function() {
return original.apply(this, arguments);
};
当我们从一个对象中获取一个方法并在另一个对象的上下文中 call
它时,我们还看到了方法借用的一个示例。获取数组方法并将其应用于 arguments
是很常见的。另一种方法是使用一个真正的数组的 rest 参数对象。
在现实世界中有很多装饰器。通过解决本章的任务来检查你对它们的掌握程度。
评论
<code>
标记,对于多行代码 – 将其包装在<pre>
标记中,对于 10 行以上的代码 – 使用沙盒 (plnkr、jsbin、codepen…)