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
,参数是 1
、2
和 3
。唯一的区别是 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" };
// 使用 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
是如何被传递的:
- 在经过装饰之后,
worker.slow
现在是包装器function (x) { ... }
。 - 因此,当
worker.slow(2)
执行时,包装器将2
作为参数,并且this=worker
(它是点符号.
之前的对象)。 - 在包装器内部,假设结果尚未缓存,
func.call(this, x)
将当前的this
(=worker
)和当前的参数(=2
)传递给原始方法。
使用 “func.apply” 来传递多参数
现在让我们把 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)。
这儿有许多解决方案可以实现:
- 实现一个新的(或使用第三方的)类似 map 的更通用并且允许多个键的数据结构。
- 使用嵌套 map:
cache.set(min)
将是一个存储(键值)对(max, result)
的Map
。所以我们可以使用cache.get(min).get(max)
来获取result
。 - 将两个值合并为一个。为了灵活性,我们可以允许为装饰者提供一个“哈希函数”,该函数知道如何将多个值合并为一个值。
对于许多实际应用,第三种方式就足够了,所以我们就用这个吧。
当然,我们需要将 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(this, arguments)
代替 func.call(this, ...arguments)
。
内建方法 func.apply 的语法是:
func.apply(context, args)
它运行 func
设置 this=context
,并使用类数组对象 args
作为参数列表(arguments)。
call
和 apply
之间唯一的语法区别是,call
期望一个参数列表,而 apply
期望一个包含这些参数的类数组对象。
因此,这两个调用几乎是等效的:
func.call(context, ...args); // 使用 spread 语法将数组作为列表传递
func.apply(context, args); // 与使用 call 相同
这里只有很小的区别:
- Spread 语法
...
允许将 可迭代对象args
作为列表传递给call
。 apply
仅接受 类数组对象args
。
因此,这些调用可以相互补充。当我们期望可迭代对象时,使用 call
,当我们期望类数组对象时,使用 apply
。
对于即可迭代又是类数组的对象,例如一个真正的数组,从技术上讲我们使用 call
或 apply
都行,但是 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.call
在 arguments
的上下文中运行它。
它为什么有效?
那是因为原生方法 arr.join(glue)
的内部算法非常简单。
从规范中几乎“按原样”解释如下:
- 让
glue
成为第一个参数,如果没有参数,则使用逗号","
。 - 让
result
为空字符串。 - 将
this[0]
附加到result
。 - 附加
glue
和this[1]
。 - 附加
glue
和this[2]
。 - ……以此类推,直到
this.length
项目被粘在一起。 - 返回
result
。
因此,从技术上讲,它需要 this
并将 this[0]
,this[1]
……等 join 在一起。它的编写方式是故意允许任何类数组的 this
的(不是巧合,很多方法都遵循这种做法)。这就是为什么它也可以和 this=arguments
一起使用。
装饰者和函数属性
通常,用装饰的函数替换一个函数或一个方法是安全的,除了一件小东西。如果原始函数有属性,例如 func.calledCount
或其他,则装饰后的函数将不再提供这些属性。因为这是装饰者。因此,如果有人使用它们,那么就需要小心。
例如,在上面的示例中,如果 slow
函数具有任何属性,而 cachingDecorator(slow)
则是一个没有这些属性的包装器。
一些包装器可能会提供自己的属性。例如,装饰者会计算一个函数被调用了多少次以及花费了多少时间,并通过包装器属性公开(expose)这些信息。
存在一种创建装饰者的方法,该装饰者可保留对函数属性的访问权限,但这需要使用特殊的 Proxy
对象来包装函数。我们将在后面的 Proxy 和 Reflect 中学习它。
总结
装饰者 是一个围绕改变函数行为的包装器。主要工作仍由该函数来完成。
装饰者可以被看作是可以添加到函数的 “features” 或 “aspects”。我们可以添加一个或添加多个。而这一切都无需更改其代码!
为了实现 cachingDecorator
,我们研究了以下方法:
- func.call(context, arg1, arg2…) —— 用给定的上下文和参数调用
func
。 - func.apply(context, args) —— 调用
func
将context
作为this
和类数组的args
传递给参数列表。
通用的 呼叫转移(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
注意这里是如何使用箭头函数的。我们知道,箭头函数没有自己的 this
和 arguments
,所以 f.apply(this, arguments)
从包装器中获取 this
和 arguments
。
如果我们传递一个常规函数,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)
装饰者的结果应该是一个包装器,该包装器最多允许每隔 ms
毫秒将调用传递给 f
一次。
换句话说,当我们调用 “debounced” 函数时,它保证之后所有在距离上一次调用的时间间隔少于 ms
毫秒的调用都会被忽略。
例如:
let f = debounce(alert, 1000);
f(1); // 立即执行
f(2); // 被忽略
setTimeout( () => f(3), 100); // 被忽略(只过去了 100 ms)
setTimeout( () => f(4), 1100); // 运行
setTimeout( () => f(5), 1500); // 被忽略(距上一次运行不超过 1000 ms)
在实际中,对于那些用于检索/更新某些内容的函数而言,当我们知道在短时间内不会有什么新内容的时候时,debounce
就显得很有用,因此最好不要浪费资源。
解决方案
function debounce(f, ms) {
let isCooldown = false;
return function() {
if (isCooldown) return;
f.apply(this, arguments);
isCooldown = true;
setTimeout(() => isCooldown = false, ms);
};
}
对 debounce
的调用返回一个包装器。这儿可能会有两种状态:
isCooldown = false
—— 准备好执行。isCooldown = true
—— 等待时间结束。
在第一次调用时,isCooldown
是 false
,因此调用继续进行,状态变为 true
。
当 isCooldown
为 true
时,所有其他调用都被忽略。
然后 setTimeout
在给定的延时结束后,将其恢复为 false
。
节流装饰者
重要程度: 5
创建一个“节流”装饰者 throttle(f, ms)
—— 返回一个包装器,最多每隔 1ms
将调用传递给 f
一次。那些属于“冷却”期的调用将被忽略。
与 debounce
的区别 —— 如果被忽略的调用是冷却期间的最后一次,那么它会在延时结束时执行。
让我们看看现实生活中的应用程序,以便更好地理解这个需求,并了解它的来源。
例如,我们想要跟踪鼠标移动。
在浏览器中,我们可以设置一个函数,使其在每次鼠标移动时运行,并获取鼠标移动时的指针位置。在使用鼠标的过程中,此函数通常会执行地非常频繁,大概每秒 100 次(每 10 毫秒)。
当鼠标指针移动时,我们想要更新网页上的某些信息。
……但是更新函数 update()
太重了,无法在每个微小移动上都执行。高于每 100ms 更新一次的更新频次也没有意义。
因此,我们将其包装到装饰者中:使用 throttle(update, 100)
作为在每次鼠标移动时运行的函数,而不是原始的 update()
。装饰者会被频繁地调用,但是最多每 100ms 将调用转发给 update()
一次。
在视觉上,它看起来像这样:
- 对于第一个鼠标移动,装饰的变体立即将调用传递给
update
。这很重要,用户会立即看到我们对其动作的反应。 - 然后,随着鼠标移动,直到
100ms
没有任何反应。装饰的变体忽略了调用。 - 在
100ms
结束时 —— 最后一个坐标又发生了一次update
。 - 然后,最后,鼠标停在某处。装饰的变体会等到
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(f, ms) {
let isThrottled = false,
savedArgs,
savedThis;
function wrapper() {
if (isThrottled) { // (2)
savedArgs = arguments;
savedThis = this;
return;
}
f.apply(this, arguments); // (1)
isThrottled = true;
setTimeout(function() {
isThrottled = false; // (3)
if (savedArgs) {
wrapper.apply(savedThis, savedArgs);
savedArgs = savedThis = null;
}
}, ms);
}
return wrapper;
}
调用 throttle(f, ms)
返回 wrapper
。
- 在第一次调用期间,
wrapper
只运行f
并设置冷却状态(isThrottled = true
)。 - 在这种状态下,所有调用都记忆在
savedArgs/savedThis
中。请注意,上下文和参数(arguments)同等重要,应该被记下来。我们同时需要他们以重现调用。 - ……然后经过
ms
毫秒后,触发setTimeout
。冷却状态被移除(isThrottled = false
),如果我们忽略了调用,则将使用最后记忆的参数和上下文执行wrapper
。
第 3 步运行的不是 func
,而是 wrapper
,因为我们不仅需要执行 func
,还需要再次进入冷却状态并设置 timeout 以重置它。