装饰和转发,call/apply

JavaScript在处理函数时提供了非凡的灵活性。它们可以被传递,用作对象,现在我们将看到如何在它们之间转发装饰它们。

透明缓存

假设我们有一个函数 slow(x) ,它是 CPU 重负载的,但它的结果是稳定的。换句话说,对于相同的 x,它总是返回相同的结果。

如果经常调用该函数,我们可能希望缓存(记住)不同 x 的结果,以避免在重新计算上花费额外的时间。

但是我们不是将这个功能添加到 slow() 中,而是创建一个包装器。正如我们将要看到的,这样做有很多好处。

下面是代码和解释:

function slow(x) {
  // 这里可能会有重负载的CPU密集型工作
  alert(`Called with ${x}`);
  return x;
}

function cachingDecorator(func) {
  let cache = new Map();

  return function(x) {
    if (cache.has(x)) { // 如果结果在 map 里
      return cache.get(x); // 返回它
    }

    let result = func(x); // 否则就调用函数

    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 是一个装饰器:一个特殊的函数,它接受另一个函数并改变它的行为。

我们的想法是,我们可以为任何函数调用 cachingDecorator,它将返回缓存包装器。这很好,因为我们有很多函数可以使用这样的特性,而我们需要做的就是将 cachingDecorator 应用于它们。

通过将缓存与主函数代码分开,我们还可以使主函数代码变得更简单。

现在让我们详细了解它的工作原理吧。

cachingDecorator(func) 的结果是一个“包装器”:function(x)func(x) 的调用 “包装” 到缓存逻辑中:

正如我们所看到的,包装器返回 func(x) “的结果”。从外部代码中,包装的 slow 函数仍然是一样的。它只是在其函数体中添加了一个缓存。

总而言之,使用单独的 cachingDecorator 而不是改变 slow 本身的代码有几个好处:

  • cachingDecorator 是可重用的。我们可以将它应用于另一个函数。
  • 缓存逻辑是独立的,它没有增加 slow 本身的复杂性(如果有的话)。
  • 如果需要,我们可以组合多个装饰器(其他装饰器将遵循同样的逻辑)。

使用 “func.call” 作为上下文

上面提到的缓存装饰器不适合使用对象方法。

例如,在下面的代码中,worker.slow() 装饰后停止工作:

// 我们将让 work 缓存一个 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) ); // 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)

他们都调用的是 func,参数是 123。唯一的区别是 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 ); // this = John
sayHi.call( admin ); // this = 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" 现在被正确的传递了
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // 现在让他缓存起来

alert( worker.slow(2) ); // 生效了
alert( worker.slow(2) ); // 生效了, 不会调用原始的函数了。被缓存起来了

现在一切都很好。

为了清楚地说明,让我们更深入地了解 this 是如何传递的:

  1. 在经过装饰之后,worker.slow 现在是包装器 function (x) { ... }
  2. 因此,当执行 worker.slow(2) 时,包装器将 2 作为参数并且 this=worker(它是点之前的对象)。
  3. 在包装器内部,假设结果尚未缓存,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
  }
};

// should remember same-argument calls
worker.slow = cachingDecorator(worker.slow);

我们这里有两个要解决的任务。

首先是如何在 cache map 中使用参数 minmax 作为键。以前,对于单个参数 x,我们可以只使用 cache.set(x, result) 来保存结果,并使用 cache.get(x) 来检索它。但是现在我们需要记住参数组合 * (min,max) 的结果。原生 Map 仅将单个值作为键。

有许多解决方案可以实现:

  1. 实现一个新的(或使用第三方)类似 map 的数据结构,它更通用并允许多键。
  2. 使用嵌套映射:cache.set(min) 将是一个存储对 (max, result)Map。所以我们可以将 result 改为 cache.get(min).get(max)
  3. 将两个值合并为一个。在我们的特定情况下,我们可以使用字符串 “min,max” 作为 Map 键。为了灵活性,我们可以允许为装饰器提供散列函数,它知道如何从多个中创建一个值。

对于许多实际应用,第三种方式已经足够好,所以我们就用这个吧。

要解决的第二个任务是如何将许多参数传递给 func。目前,包装器 function(x) 假设一个参数,func.call(this, x) 传递它。

在这里我们可以使用另一种内置方法 func.apply.

语法如下:

func.apply(context, args)

它运行 func 设置 this=context 并使用类似数组的对象 args 作为参数列表。

例如,这两个调用几乎相同:

func(1, 2, 3);
func.apply(context, [1, 2, 3])

两个都运行 func 给定的参数是 1,2,3。但是 apply 也设置了 this = context

例如,这里 saythis=usermessageData 作为参数列表调用:

function say(time, phrase) {
  alert(`[${time}] ${this.name}: ${phrase}`);
}

let user = { name: "John" };

let messageData = ['10:00', 'Hello']; // 成为时间和短语

// user 成为 this,messageData 作为参数列表传递 (time, phrase)
say.apply(user, messageData); // [10:00] John: Hello (this=user)

callapply 之间唯一的语法区别是 call 接受一个参数列表,而 apply 则接受带有一个类似数组的对象。

我们已经知道了 Rest 参数与 Spread 操作符 一章中的扩展运算符 ...,它可以将数组(或任何可迭代的)作为参数列表传递。因此,如果我们将它与 call 一起使用,就可以实现与 apply 几乎相同的功能。

这两个调用结果几乎相同:

let args = [1, 2, 3];

func.call(context, ...args); // 使用 spread 运算符将数组作为参数列表传递
func.apply(context, args);   // 与使用 apply 相同

如果我们仔细观察,那么 callapply 的使用会有一些细微的差别。

  • 扩展运算符 ... 允许将 可迭代的 参数列表 作为列表传递给 call
  • apply 只接受 类似数组一样的 参数列表

所以,这些调用方式相互补充。我们期望有一个可迭代的 call 实现,我们也期望有一个类似数组,apply 的实现。

如果 参数列表 既可迭代又像数组一样,就像真正的数组一样,那么我们在技术上可以使用它们中的任何一个,但是 apply 可能会更快,因为它只是一个操作。大多数 JavaScript 引擎内部优化比一对 call + spread 更好。

apply 最重要的用途之一是将调用传递给另一个函数,如下所示:

let wrapper = function() {
  return anotherFunction.apply(this, arguments);
};

这叫做 呼叫转移wrapper 传递它获得的所有内容:上下文 thisanotherFunction 的参数并返回其结果。

当外部代码调用这样的 wrapper 时,它与原始函数的调用无法区分。

现在让我们把它全部加入到更强大的 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.apply(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.apply 传递上下文和包装器获得的所有参数(无论多少)到原始函数。

借用一种方法

现在让我们在散列函数中做一个小改进:

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() ); // 报错:arguments.join 不是函数
}

hash(1, 2);

不过,有一种简单的方法可以使用数组的 join 方法:

function hash() {
  alert( [].join.call(arguments) ); // 1,2
}

hash(1, 2);

这个技巧被称为 方法借用

我们从常规数组 [].join 中获取(借用)连接方法。并使用 [].join.callarguments 的上下文中运行它。

它为什么有效?

那是因为本地方法 arr.join(glue) 的内部算法非常简单。

从规范中得出几乎“原样”:

  1. glue 成为第一个参数,如果没有参数,则使用逗号 ","
  2. result 为空字符串。
  3. this[0] 附加到 result
  4. 附加 gluethis[1]
  5. 附加 gluethis[2]
  6. …直到 this.length 项目粘在一起。
  7. 返回 result

因此,从技术上讲,它需要 this 并将 this[0]this[1] …… 等加在一起。它的编写方式是允许任何类似数组的 this(不是巧合,许多方法遵循这种做法)。这就是为什么它也适用于 this=arguments

总结

装饰器是一个改变函数行为的包装器。主要工作仍由该函数来完成。

除了一件小事,使用装饰器来替代函数或方法通常都是安全的。如果原始函数具有属性,例如 func.calledCount 或者其他什么,则装饰的函数将不提供它们。因为那是一个包装器。因此,如果使用它们,需要小心。一些装饰器提供它们自己的属性。

装饰器可以被视为可以添加到函数中的“特征”或“方面”。我们可以添加一个或添加许多。而这一切都没有改变它的代码!

为了实现 cachingDecorator,我们研究了方法:

通用 呼叫转移 通常使用 apply 完成:

let wrapper = function() {
  return original.apply(this, arguments);
}

当我们从一个对象中获取一个方法并在另一个对象的上下文中“调用”它时,我们也看到了一个 方法借用 的例子。采用数组方法并将它们应用于参数是很常见的。另一种方法是使用静态参数对象,它是一个真正的数组。

在 js 领域里有很多装饰器的使用方法 。快通过解决本章的任务来检查你掌握它们的程度吧。

任务

重要程度: 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"
}

附:该装饰器有时用于单元测试,它的高级形式是 Sinon.JS 库中的 sinon.spy

打开带有测试的沙箱。

在这里,我们可以使用 calls.push(args) 来存储日志中的所有参数,并使用 f.apply(this, args) 来转发调用。

使用沙箱的测试功能打开解决方案。

重要程度: 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"); // 在 1000 ms 后展示 "test"
f1500("test"); // 在 1500 ms 后展示 "test"

换句话说,delay(f, ms) 返回的是延迟 ms 后的 f 的变体。

在上面的代码中,f 是单个参数的函数,但是你的解决方案应该传递所有参数和上下文 this

打开带有测试的沙箱。

解决方案:

function delay(f, ms) {

  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };

}

注意这里是如何使用箭头函数的。我们知道,箭头函数没有自己的 thisarguments,所以 f.apply(this, arguments)从包装器中获取 thisarguments

如果我们传递一个常规函数,setTimeout 将调用它且不带参数和 this=window(在浏览器中),所以我们需要编写更多代码来从包装器传递它们:

function delay(f, ms) {

  // added variables to pass this and arguments from the wrapper inside setTimeout
  return function(...args) {
    let savedThis = this;
    setTimeout(function() {
      f.apply(savedThis, args);
    }, ms);
  };

}

使用沙箱的测试功能打开解决方案。

重要程度: 5

debounce(f, ms)装饰器的结果应该是一个包装器,它每隔几毫秒调用一次 f

换句话说,当我们调用 “debounced” 函数时,它保证将忽略最接近的 “ms” 内发生的情况。

例如:

let f = debounce(alert, 1000);

f(1); // 立即执行
f(2); // 忽略

setTimeout( () => f(3), 100); // 忽略 (只过去了12 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 是假的,所以调用继续进行,状态变为 true

isCooldown 为真时,所有其他调用都被忽略。

然后 setTimeout 在给定的延迟后将其恢复为 false

使用沙箱的测试功能打开解决方案。

重要程度: 5

创建一个“节流”装饰器 throttle(f, ms) —— 返回一个包装器,每隔 “ms” 毫秒将调用最多传递给 f 一次。那些属于“冷却”时期的调用被忽略了。

debounce 的区别 —— 如果被忽略的调用是冷却期间的最后一次,那么它会在延迟结束时执行。

让我们检查一下真实应用程序,以便更好地理解这个需求,并了解它的来源。

例如,我们想要跟踪鼠标移动。

在浏览器中,我们可以设置一个函数,鼠标的每次微小的运动都执行,并在移动时获取指针位置。在活动鼠标使用期间,此功能通常非常频繁地运行,可以是每秒 100 次(每 10 毫秒)。

跟踪功能应更新网页上的一些信息。

更新函数 update() 太重了,无法在每次微小动作上执行。每 100 毫秒更频繁地制作一次也没有任何意义。

因此我们将 throttle(update, 100) 指定为在每次鼠标移动时运行的函数,而不是原始的 update()。装饰器将经常调用,但 update() 最多每 100ms 调用一次。

在视觉上,它看起来像这样:

  1. 对于第一个鼠标移动,装饰变体将调用传递给 update。这很重要,用户会立即看到我们对他行动的反应。
  2. 然后当鼠标移动时,直到 “100ms” 没有任何反应。装饰的变体忽略了调用。
  3. 100ms 结束时 – 最后一个坐标发生了一次 update
  4. 然后,最后,鼠标停在某处。装饰的变体等到 100ms到期,然后用最后一个坐标运行 update。因此,也许最重要的是处理最终的鼠标坐标。

一个代码示例:

function f(a) {
  console.log(a)
};

// f1000 passes calls to f at maximum once per 1000 ms
let f1000 = throttle(f, 1000);

f1000(1); // shows 1
f1000(2); // (throttling, 1000ms not out yet)
f1000(3); // (throttling, 1000ms not out yet)

// when 1000 ms time out...
// ...outputs 3, intermediate value 2 was ignored

附:参数和传递给 f1000 的上下文 this 应该传递给原始的 f

打开带有测试的沙箱。

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

    if (isThrottled) { // (2)
      savedArgs = arguments;
      savedThis = this;
      return;
    }

    func.apply(this, arguments); // (1)

    isThrottled = true;

    setTimeout(function() {
      isThrottled = false; // (3)
      if (savedArgs) {
        wrapper.apply(savedThis, savedArgs);
        savedArgs = savedThis = null;
      }
    }, ms);
  }

  return wrapper;
}

调用 throttle(func, ms) 返回 wrapper

  1. 在第一次调用期间,wrapper 只运行 func 并设置冷却状态 (isThrottled = true)。
  2. 在这种状态下,所有调用都记忆在 savedArgs/savedThis 中。请注意,上下文和参数都同样重要,应该记住。我们需要他们同时重现这个调用。
  3. …然后在 ms 毫秒过后,setTimeout 触发。冷却状态被删除 (isThrottled = false)。如果我们忽略了调用,则使用最后记忆的参数和上下文执行 wrapper

第3步不是 func,而是 wrapper,因为我们不仅需要执行 func,而是再次进入冷却状态并设置超时以重置它。

使用沙箱的测试功能打开解决方案。

教程路线图

评论

在评论之前先阅读本内容…
  • 欢迎你在文章下添加补充内容、提出你的问题或回答提出的问题。
  • 使用 <code> 标签插入几行代码,对于多行代码 — 可以使用 <pre>,对于超过十行的代码 — 建议使用沙箱(plnkrJSBincodepen 等)。
  • 如果你无法理解文章中的内容 — 请详细说明。