目录

🚃 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)) // 返回缓存中的结果
1
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) 的调用 “包装” 到缓存逻辑中:

image-20220511140925295

从外部代码来看,包装的 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)) // 不可用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

因为装饰器试图访问 this.someMethod 时 ,这里的 thisundefined 。包装器将调用传递给原始方法,但没有上下文 this 。因此,发生了错误。

# func.call()

可以使用内建函数方法 func.call(content, ...args) 解决这个方法,它允许调用一个 显式设置 this 的函数。

语法为: func.call(context, arg1, arg2, ...)

🌰 例子 / call 的简单应用:

func(1, 2, 3);
func.call(obj, 1, 2, 3)
1
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
1
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)) // 调用缓存
1
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) 
1
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)
1
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 : 类数组对象作为参数列表。

callapply 唯一的区别是: call 期望一个参数列表,而 apply 期望一个包含这些参数的类数组对象。 其他方面,两个方法的调用都几乎是等效的。

func.call(context, ...args);
func.apply(context, args);
1
2

关于 args 的微小区别:

  • Spread 语法 ... 允许将 可迭代对象 args 作为列表传递给 call
  • apply 只接受 类数组 args

对于即可迭代又是类数组的对象,例如一个真正的数组,使用 callapply 均可,但是 apply 可能会更快,因为大多数 JavaScript 引擎在内部对其进行了优化。

这种 将所有参数连同上下文 传递给另一个函数被称为 「呼叫转移」:

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

# 方法借用

使用 func.apply 解决上面 哈希函数 可以接收任意数量的 args

不可以直接使用 arguments.join() ,因为 arguments 只是类数组对象而不是真正的数组,不能使用数组的方法。

function hash() {
  [].join.call(arguments)
}
1
2
3

这种用法为 方法借用。从常规数组 [] 中借用 join 方法,使用 [].join.call(arguments)arguments 的上下文运行它。

# 装饰器和函数属性

通常,用装饰的函数替换一个函数或一个方法是安全的。

但是如果原始函数有属性,例如 func.calledCount 或其他,则装饰后的函数将不再提供这些属性。因为这是装饰器。

一些包装器可能会提供自己的属性。例如,装饰器会计算一个函数被调用了多少次以及花费了多少时间,并通过包装器属性暴露这些信息。

存在一种创建装饰器的方法,该装饰器可保留对函数属性的访问权限,但这需要使用特殊的 Proxy 对象来包装函数。

# 总结

  • 装饰器 是一个围绕改变函数行为的包装器。主要工作仍由该函数来完成。(可以看作是添加函数额外功能的 切面,可以添加一个或者多个而不需要更改函数代码)

  • 实现函数 装饰器 使用两种方法:

    • func.call(context, arg1, arg2 ...) :使用给定的上下文 对象参数 调用 func .
    • func.apply(context, args) :调用 funccontext 上下文对象作为 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"
}
1
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
}
1
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"
1
2
3
4
5
6
7
8
9
10
点击查看
function delay(f, ms) {
  return function() {
    setTimeout(()=> func.apply(this, arguments), ms)
  }
}
1
2
3
4
5

由于 箭头函数 没有自己的 thisarguments ,所以 f.apply(this, arguments)当前包装器 中获取 thisarguments

如果不使用箭头函数,需要通过中间变量传递正确的 this

function delay(f, ms) {
  return function(...args) {
    let savedThis = this
    setTimeout(function(){
      f.apply(savedThis, args)
    }, ms)
  }
}
1
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)
  }
}
1
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)
1
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 重制它)。

所以在例子中, 只执行了 第一次 和 最后一次。

📢 上次更新: 2022/09/02, 10:18:16