🚃 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 重制它)。
所以在例子中, 只执行了 第一次 和 最后一次。

