🚃 JavaScript 装饰器模式和转发
JavaScript 在处理函数时提供了非凡的灵活性。除了前面提到的 被传递,用作对象,还可以 转发调用 和 装饰 函数。
# 引子:透明缓存
🌰 例子 / 有一个 CPU 重负载的函数 slow(x)
,但它的结果是稳定的。换句话说,对于相同的 x
,它总是返回相同的结果。如果经常调用该函数,可能希望将 结果缓存 下来,以避免在重新计算上花费额外的时间。
但是不是将这个功能添加到 slow()
中,而是创建一个 包装器函数(Wrapper),该函数增加了缓存功能。
function slow(x) {
// ...
// ... 可能会有重负载的 CPU 密集型工作
// ,,,
console.log(`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(x)
cache.set(x, result) // 然后将 x 添加到缓存
return result
}
}
slow = cachingDecorator(slow)
console.log(slow(1)) // slow(1) 被缓存下来并返回结果
console.log('again' + slow(1)) // 返回缓存中的结果
console.log(slow(2)) // slow(2) 被缓存下来并返回结果
console.log('again' + slow(2)) // 返回缓存中的结果
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
cachingDecorator
是一个 装饰器(decorator):一个特殊的函数,它接受另一个函数并改变它的行为。思想:可以为任何一个函数调用
cachingDecorator
,它将返回缓存包装器。通过将缓存与主函数代码分开,使主函数代码变得更简单。
cachingDecorator(func)
的结果是一个 “包装器”:function(x)
将func(x)
的调用 “包装” 到缓存逻辑中:从外部代码来看,包装的
slow
函数执行的仍然是与之前相同的操作。它只是在其行为上添加了缓存功能。
使用 cachingDecorator
并不改变原来函数的代码的好处:
cachingDecorator
是可重用的。- 缓存逻辑是独立的,它没有增加
slow
本身的复杂性。 - 如果需要,可以组合多个装饰器(其他装饰器将遵循同样的逻辑)。
🌰 例子 / 上面的 缓存装饰器 不适用于 对象的方法:
let worker = {
someMethod() {
return 1;
},
slow(x) {
// ...
// ... CPU 过载任务
// ...
console.log("Called with " + x);
return x * this.someMethod(); // (*)
}
};
// function cachingDecorator(func) ...
console.log(work.slow(1)) // 可用
work.slow = cachingDecorator(worker.slow)
console.log(work.slow(2)) // 不可用
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
因为装饰器试图访问
this.someMethod
时 ,这里的this
为undefined
。包装器将调用传递给原始方法,但没有上下文this
。因此,发生了错误。
# func.call()
可以使用内建函数方法 func.call(content, ...args)
解决这个方法,它允许调用一个 显式设置 this
的函数。
语法为: func.call(context, arg1, arg2, ...)
。
🌰 例子 / call
的简单应用:
func(1, 2, 3);
func.call(obj, 1, 2, 3)
2
这两种方法都是调用
func
函数,并且传入参数为1, 2, 3
。唯一的区别是func.call
还会将this
设置为obj
。
🌰 例子:
function sayHi() {
console.log(this.name);
}
let user = { name: "John" };
let admin = { name: "Admin" };
// 使用 call 将不同的对象传递为 "this"
sayHi.call(user); // John
sayHi.call(admin); // Admin
2
3
4
5
6
7
8
9
10
有参数的指定上下文调用:
function say(phrase) { console.log(this.name + ': ' + phrase) } let user = {name: 'Simon'} say.call(user, 'hello')
1
2
3
4
5
6
🌰 例子 / 修改先前的 缓存装饰器 例子:
let worker = {
someMethod() {
return 1;
},
slow(x) {
// ...
// ... CPU 过载任务
// ...
console.log("Called with " + x);
return x * this.someMethod(); // (*)
}
};
function cachingDecorator(func) {
let cache = new Map()
return function(x) {
if(cache.has(x)) {
if(cache.has(x)) return cache.get(x)
}
let result = func.call(this, x)
cache.set(x, result)
return result
}
}
worker.slow = cachingDecorator(worker.slow)
console.log(worker.slow(2))
console.log(worker.slow(2)) // 调用缓存
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
在此之中
this
的传递过程:
- 在经过装饰之后,
worker.slow
现在是包装器function (x) { ... }
。- 因此,当
worker.slow(2)
执行时,包装器将2
作为参数,并且this=worker
(它是点符号.
之前的对象)。- 在包装器内部,假设结果尚未缓存,
func.call(this, x)
将当前的this
(=worker
)和当前的参数(=2
)传递给原始方法。
# 传递多个参数
如果要将 缓存装饰器 写得更加通用,就要使它可以接收多个参数的函数。
例如,现在的 woker.slow
变成了多个参数的函数:
let worker = {
slow(min, max) {
// ...
return min + max
}
}
worker.slow = cachingDecorator(woker.slow)
2
3
4
5
6
7
8
此处
cachingDecorator
应该要记住相同参数的调用。在之前,对于单个参数
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
时,传入的不再是一个 x
,如果是多个参数时,可以使用 Rest 语法的 ...
收集多个参数。所以写为 func.call(this, ...arguments)
, 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)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
现在这个包装器可以处理任意数量的参数了(尽管哈希函数还需要被进行调整以允许任意数量的参数)。
两个关键点:
(*)
处:调用hash
来从arguments
创建一个单独的键。这里的 哈希函数 使用一个 连接符将两个参数连接在一起作为 Mapo 的键。(**)
: 使用func.call(this, ...arguments)
将包装器获取的上下文和所有参数传递给原始函数。
# func.apply()
可以使用 func.apply(this, arguments)
代替 func.call(this, ...arguments)
。
使用语法: func.apply(context, args)
。
context
: 可以设置上下文对象。args
: 类数组对象作为参数列表。
call
和 apply
唯一的区别是: call
期望一个参数列表,而 apply
期望一个包含这些参数的类数组对象。 其他方面,两个方法的调用都几乎是等效的。
func.call(context, ...args);
func.apply(context, args);
2
关于
args
的微小区别:
- Spread 语法
...
允许将 可迭代对象args
作为列表传递给call
。apply
只接受 类数组args
。
对于即可迭代又是类数组的对象,例如一个真正的数组,使用 call
或 apply
均可,但是 apply
可能会更快,因为大多数 JavaScript 引擎在内部对其进行了优化。
这种 将所有参数连同上下文 传递给另一个函数被称为 「呼叫转移」:
let wrapper = function() {
return func.apply(this, arguments);
};
2
3
# 方法借用
使用 func.apply
解决上面 哈希函数 可以接收任意数量的 args
:
不可以直接使用
arguments.join()
,因为arguments
只是类数组对象而不是真正的数组,不能使用数组的方法。
function hash() {
[].join.call(arguments)
}
2
3
这种用法为 方法借用。从常规数组 []
中借用 join
方法,使用 [].join.call(arguments)
在 arguments
的上下文运行它。
# 装饰器和函数属性
通常,用装饰的函数替换一个函数或一个方法是安全的。
但是如果原始函数有属性,例如 func.calledCount
或其他,则装饰后的函数将不再提供这些属性。因为这是装饰器。
一些包装器可能会提供自己的属性。例如,装饰器会计算一个函数被调用了多少次以及花费了多少时间,并通过包装器属性暴露这些信息。
存在一种创建装饰器的方法,该装饰器可保留对函数属性的访问权限,但这需要使用特殊的 Proxy
对象来包装函数。
# 总结
装饰器 是一个围绕改变函数行为的包装器。主要工作仍由该函数来完成。(可以看作是添加函数额外功能的 切面,可以添加一个或者多个而不需要更改函数代码)
实现函数 装饰器 使用两种方法:
func.call(context, arg1, arg2 ...)
:使用给定的上下文 对象 和 参数 调用func
.func.apply(context, args)
:调用func
将context
上下文对象作为this
和 类数组 的args
传递给参数列表。
通常 呼叫转移 使用
apply
完成:let wrapper = function() { return originalFunc.apply(this, arguments) }
1
2
3方法借用: 从一个对象中获取一个方法,在另一个对象的上下文中「调用」它。采用数组方法并将它们应用于
arguments
或者使用 Rest 参数对象。
# 实例
# 间谍
创建一个装饰器
spy(func)
,它应该返回一个包装器,该包装器将 所有对函数的调用 保存在其calls
属性中。每个调用都保存在一个 参数数组。
function work(a, b) {
console.log( a + b ); // work 是一个任意的函数或方法
}
work = spy(work);
work(1, 2); // 3
work(4, 5); // 9
for (let args of work.calls) {
console.log( 'call:' + args.join() ); // "call:1,2", "call:4,5"
}
2
3
4
5
6
7
8
9
10
11
12
点击查看
function spy(func) {
function wrapper(...args) {
wrapper.calls.push(args)
return func.apply(this, args)
}
wrapper.calls = []
return wrapper
}
2
3
4
5
6
7
8
9
# 延时
创建一个装饰器
delay(f, ms)
,该装饰器将f
的每次调用延时ms
毫秒。
function f(x) {
conosle.log(x);
}
// create wrappers
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);
f1000("test"); // 在 1000ms 后显示 "test"
f1500("test"); // 在 1500ms 后显示 "test"
2
3
4
5
6
7
8
9
10
点击查看
function delay(f, ms) {
return function() {
setTimeout(()=> func.apply(this, arguments), ms)
}
}
2
3
4
5
由于 箭头函数 没有自己的
this
和arguments
,所以f.apply(this, arguments)
从 当前包装器 中获取this
和arguments
。
如果不使用箭头函数,需要通过中间变量传递正确的 this
。
function delay(f, ms) {
return function(...args) {
let savedThis = this
setTimeout(function(){
f.apply(savedThis, args)
}, ms)
}
}
2
3
4
5
6
7
8
# 防抖
防抖处理可以看作装饰器
debounce(f, ms)
的结果是一个 包装起,该包装器暂停对f
的调用,直到经过ms
毫秒的「冷却期」,然后使用最新的参数调用f
一次。例如,一个函数
f
替换为f = debounce(f, 1000)
。如果包装器函数 分别在 0 ms, 200 ms, 500 ms 时被调用了,之后没有其他调用,那么实际的f
只会在 1500 ms 时被调用一次。简单的说,就是需要等待 最后一次调用时 经过 1000 ms 后 的冷却期。实际应用:如果用户输入一些内容,在用户输入完成后才向服务器发送请求,而没有必要为每一个字符的输入都发送请求。要等待一段时间,才处理整个输入结果。一般 输入框 的发生输入变化时会调用一个事件处理函数,如果监听每一次变化则整个事件处理函数会被频繁调用,使用 防抖
debounce
延时 1000 ms 处理,只会在最后一次输入后的 1000 ms 后被调用一次事件处理函数。
手写一个 debounce
防抖装饰器:
点击查看
function debounce(func, delay) {
let timeout;
return function() {
clearTimeout(timeout)
setTimeout(()=> func.apply(this, arguments), delay)
}
}
2
3
4
5
6
7
# 节流
节流装饰器
throttle(f, ms)
返回一个包装器,当被多次调用时,在每 ms 最多将调用传递给f
一次。与防抖装饰器区分:
debouce
会在 「冷却期」之后运行函数一次。适合处理最终结果。throttle
运行函数的频率 不会大于 所给定的时间ms
。适用于 不应该经常进行的定时更新。- 防抖是接听电话的秘书,一直等到 ms 毫秒之后才会把信息传达给老板。而节流是接听电话的秘书,打扰老板的频率不能超过每 ms 一次。
实际应用:追踪鼠标指针的移动,以更新网页上的某些信息。但是没有必要将每一个微小的移动都绑定事件更新,高于每 100 ms 的更新频次没有意义。所以将其包装到 节流装饰器,使用
throttle(update, 1000)
作为每次鼠标移动时运行的函数,而不是原始的update()
。装饰器会被频繁调用,但是最多每 100 ms 将调用装发给update()
一次。
点击查看
function throttle(func, ms) {
let isThrottled = false,
savedArgs,
savedThis;
function wrapper() {
if(isThrottled) {
savedArgs = arguments
savedThis = this
return
}
isThrottled = true
func.apply(this, arguments)
setTimeout(function(){
isThrottled = false
if(savedArgs) {
wrapper.apply(savedThis, savedArgs)
savedArgs = savedThis = null
}
}, ms)
}
return wrapper
}
function f(a){
console.log(a)
}
let f1000 = throttle(f, 1000)
f1000(1)
f1000(2)
f1000(3)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
调用 throttle(func, ms)
返回 wrapper
。
- 第一次调用时,
wrapper
值运行func
并设置冷却状态(isThrottled = true
)。 - 在这种状态下,所有调用都记忆在
savedArgs/savedThis
中。请注意,上下文和参数(arguments)同等重要,应该被记下来。 - 经过
ms
毫秒后,触发setTimeout
中的回调函数。冷却状态移除(isThrottled = false
),如果忽略了调用,即将使用最后记忆的参数和上下文执行wrapper
(因为不仅仅需要执行func
,还要进入 冷却状态并且设置 timeout 重制它)。
所以在例子中, 只执行了 第一次 和 最后一次。