2022 年 6 月 8 日

装饰器和转发,call/apply

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

透明缓存

假设我们有一个函数 slow(x),它非常耗费 CPU,但其结果是稳定的。换句话说,对于相同的 x,它始终返回相同的结果。

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

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

以下是代码,后面有解释

function slow(x) {
  // there can be a heavy CPU-intensive job here
  alert(`Called with ${x}`);
  return x;
}

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

  return function(x) {
    if (cache.has(x)) {    // if there's such key in cache
      return cache.get(x); // read the result from it
    }

    let result = func(x);  // otherwise call func

    cache.set(x, result);  // and cache (remember) the result
    return result;
  };
}

slow = cachingDecorator(slow);

alert( slow(1) ); // slow(1) is cached and the result returned
alert( "Again: " + slow(1) ); // slow(1) result returned from cache

alert( slow(2) ); // slow(2) is cached and the result returned
alert( "Again: " + slow(2) ); // slow(2) result returned from cache

在上面的代码中,cachingDecorator 是一个装饰器:一个特殊的函数,它接受另一个函数并改变其行为。

我们的想法是,我们可以对任何函数调用 cachingDecorator,它将返回缓存包装器。这很好,因为我们可以拥有许多可以使用此类功能的函数,而我们只需要将 cachingDecorator 应用到它们即可。

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

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

从外部代码来看,包装后的 slow 函数仍然执行相同操作。它只是在其行为中添加了缓存方面的内容。

总之,使用单独的 cachingDecorator 而不是更改 slow 本身的代码有很多好处

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

对上下文使用“func.call”

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

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

// we'll make worker.slow caching
let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    // scary CPU-heavy task here
    alert("Called with " + x);
    return x * this.someMethod(); // (*)
  }
};

// same code as before
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) ); // the original method works

worker.slow = cachingDecorator(worker.slow); // now make it caching

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)

它们都使用参数 123 调用 func。唯一的区别是 func.call 还将 this 设置为 obj

例如,在下面的代码中,我们在不同对象的上下文中调用 sayHisayHi.call(user) 运行 sayHi,提供 this=user,下一行设置 this=admin

function sayHi() {
  alert(this.name);
}

let user = { name: "John" };
let admin = { name: "Admin" };

// use call to pass different objects as "this"
sayHi.call( user ); // John
sayHi.call( admin ); // 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" is passed correctly now
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // now make it caching

alert( worker.slow(2) ); // works
alert( worker.slow(2) ); // works, doesn't call the original (cached)

现在一切都很好。

为了让这一切都清楚,让我们更深入地了解 this 是如何传递的

  1. 在装饰 worker.slow 之后,现在是包装器 function (x) { ... }
  2. 因此,当执行 worker.slow(2) 时,包装器将获得 2 作为参数,并且 this=worker(它是点之前的对象)。
  3. 在包装器内部,假设结果尚未缓存,func.call(this, x) 将当前 this=worker)和当前参数(=2)传递给原始方法。

进行多参数

现在让我们让 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);

以前,对于单个参数 x,我们只需 cache.set(x, result) 来保存结果,并 cache.get(x) 来检索它。但现在我们需要记住参数组合 (min,max) 的结果。本机 Map 只将单个值作为键。

有很多可能的解决方案

  1. 实现一个新的(或使用第三方)类似映射的数据结构,它更通用并允许多键。
  2. 使用嵌套映射:cache.set(min) 将是存储对 (max, result)Map。因此,我们可以将 result 获取为 cache.get(min).get(max)
  3. 将两个值合并为一个值。在我们的特定案例中,我们可以将字符串 "min,max" 作为 Map 键。为了灵活性,我们可以允许为装饰器提供一个 哈希函数,该函数知道如何从多个值中生成一个值。

对于许多实际应用,第 3 种变体已经足够好,所以我们将坚持使用它。

此外,我们需要传递的不只是 x,还有 func.call 中的所有参数。让我们回想一下,在 function() 中,我们可以将它的参数伪数组获取为 arguments,因此 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)

现在它适用于任何数量的参数(尽管哈希函数也需要进行调整以允许任何数量的参数。下面将介绍处理此问题的有趣方法)。

有两个变化

  • 在行 (*) 中,它调用 hasharguments 创建一个键。这里我们使用一个简单的“连接”函数,将参数 (3, 5) 转换为键 "3,5"。更复杂的情况可能需要其他哈希函数。
  • 然后 (**) 使用 func.call(this, ...arguments) 将上下文和包装器获取的所有参数(不仅仅是第一个参数)传递给原始函数。

func.apply

我们可以使用 func.apply(this, arguments) 代替 func.call(this, ...arguments)

内置方法 func.apply 的语法是

func.apply(context, args)

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

callapply 之间唯一的语法区别是,call 期望一个参数列表,而 apply 接受一个包含参数的类数组对象。

因此,这两个调用几乎等效

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

它们使用给定的上下文和参数执行相同的 func 调用。

关于 args 只有一个细微差别

  • 展开语法 ... 允许将 可迭代 args 作为列表传递给 call
  • apply 仅接受 类数组 args

…对于既可迭代又类数组的对象(例如真实数组),我们可以使用任何一个,但 apply 可能更快,因为大多数 JavaScript 引擎在内部对其进行了更好的优化。

将所有参数连同上下文一起传递给另一个函数称为调用转发

这是其最简单的形式

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);

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

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 或其他任何属性,那么装饰过的函数将不提供这些属性。因为那是一个包装器。所以如果使用它们,需要小心。

例如,在上面的示例中,如果 slow 函数具有任何属性,那么 cachingDecorator(slow) 就是一个没有这些属性的包装器。

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

有一种方法可以创建保留对函数属性的访问权限的装饰器,但这需要使用一个特殊的 Proxy 对象来包装一个函数。我们将在文章 Proxy 和 Reflect 的后面部分讨论它。

总结

装饰器 是一个包装器,它围绕一个函数来改变其行为。主要工作仍然由函数执行。

装饰器可以看作是添加到函数的“特性”或“方面”。我们可以添加一个或多个。所有这些都不需要更改其代码!

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

通用的调用转发通常使用 apply 来完成

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

当我们从一个对象中获取一个方法并在另一个对象的上下文中 call 它时,我们还看到了方法借用的一个示例。获取数组方法并将其应用于 arguments 是很常见的。另一种方法是使用一个真正的数组的 rest 参数对象。

在现实世界中有很多装饰器。通过解决本章的任务来检查你对它们的掌握程度。

任务

重要性:5

创建一个装饰器 spy(func),它应该返回一个包装器,该包装器将函数的所有调用保存在其 calls 属性中。

每个调用都作为参数数组保存。

例如

function work(a, b) {
  alert( a + b ); // work is an arbitrary function or method
}

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"); // shows "test" after 1000ms
f1500("test"); // shows "test" after 1500ms

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

在上面的代码中,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

请注意这里如何使用箭头函数。众所周知,箭头函数没有自己的 thisarguments,因此 f.apply(this, arguments) 从包装器中获取 thisarguments

如果我们传递一个常规函数,setTimeout 将在没有参数和 this=window 的情况下调用它(假设我们在浏览器中)。

我们仍然可以通过使用中间变量来传递正确的 this,但这有点麻烦

function delay(f, ms) {

  return function(...args) {
    let savedThis = this; // store this into an intermediate variable
    setTimeout(function() {
      f.apply(savedThis, args); // use it here
    }, ms);
  };

}

在沙盒中打开带有测试的解决方案。

重要性:5

debounce(f, ms) 装饰器的结果是一个包装器,它会挂起对 f 的调用,直到有 ms 毫秒的不活动(没有调用,“冷却期”),然后使用最新的参数调用 f 一次。

换句话说,debounce 就像一个秘书,它接受“电话”,并等待 ms 毫秒的安静时间。然后才将最新的通话信息转给“老板”(调用实际的 f)。

例如,我们有一个函数 f,并用 f = debounce(f, 1000) 替换它。

然后,如果在 0ms、200ms 和 500ms 时调用包装函数,然后没有调用,那么实际的 f 将只在 1500ms 时调用一次。也就是说:在最后一次调用后的 1000ms 冷却期之后。

…它将获取最后一次调用的参数,其他调用将被忽略。

以下是它的代码(使用了 Lodash 库 中的 debounce 装饰器)

let f = _.debounce(alert, 1000);

f("a");
setTimeout( () => f("b"), 200);
setTimeout( () => f("c"), 500);
// debounced function waits 1000ms after the last call and then runs: alert("c")

现在举一个实际的例子。假设用户输入了一些内容,我们希望在输入完成后向服务器发送请求。

没有必要为输入的每个字符发送请求。相反,我们希望等待,然后处理整个结果。

在网络浏览器中,我们可以设置事件处理程序,即在输入字段的每次更改时调用的函数。通常,事件处理程序会被非常频繁地调用,对于每个键入的键。但如果我们用 1000 毫秒对它进行“防抖”,那么它只会在最后一次输入后 1000 毫秒后调用一次。

在此实时示例中,处理程序将结果放入下面的一个框中,请尝试一下

看到了吗?第二个输入调用了防抖函数,因此其内容在最后一次输入后 1000 毫秒后才被处理。

因此,debounce 是处理一系列事件的好方法:无论是按键序列、鼠标移动还是其他内容。

它在最后一次调用后等待指定时间,然后运行其函数,该函数可以处理结果。

任务是实现 debounce 装饰器。

提示:如果你仔细想想,那只有几行 :)

在沙盒中打开测试。

function debounce(func, ms) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, arguments), ms);
  };
}

debounce 的调用返回一个包装器。当调用时,它在给定的 ms 后计划原始函数调用,并取消之前的此类超时。

在沙盒中打开带有测试的解决方案。

重要性:5

创建一个“节流”装饰器 throttle(f, ms),它返回一个包装器。

当它被多次调用时,它最多每 ms 毫秒将调用传递给 f 一次。

与防抖装饰器相比,行为完全不同

  • debounce 在“冷却”周期后运行函数一次。适用于处理最终结果。
  • throttle 运行它的频率不高于给定的 ms 时间。适用于不应该太频繁的定期更新。

换句话说,throttle 就像一位秘书,他接听电话,但每 ms 毫秒不会打扰老板(调用实际的 f)超过一次。

让我们检查一下实际应用,以更好地理解该要求及其来源。

例如,我们希望跟踪鼠标移动。

在浏览器中,我们可以设置一个函数在每次鼠标移动时运行,并在鼠标移动时获取指针位置。在鼠标处于活动使用状态期间,此函数通常运行得非常频繁,每秒可能约为 100 次(每 10 毫秒)。当指针移动时,我们希望更新网页上的某些信息。

…但更新函数 update() 太重,无法在每次微小移动时执行。每 100 毫秒更新一次也没有什么意义。

所以我们将其包装到装饰器中:使用 throttle(update, 100) 作为在每次鼠标移动时运行的函数,而不是原始的 update()。该装饰器将经常被调用,但最多每 100 毫秒将调用转发到 update() 一次。

从视觉上看,它将如下所示

  1. 对于第一次鼠标移动,装饰后的变体立即将调用传递给 update。这一点很重要,用户会立即看到我们对他们移动的反应。
  2. 然后随着鼠标移动,直到 100 毫秒,什么都不会发生。装饰后的变体将忽略调用。
  3. 100 毫秒 结束时——使用最后坐标再进行一次 update
  4. 然后,最后,鼠标在某个地方停止。装饰后的变体将等待直到 100 毫秒 到期,然后使用最后坐标运行 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

P.S. 传递给 f1000 的参数和上下文 this 应传递给原始 f

在沙盒中打开测试。

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

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

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

    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,而且还需要再次进入冷却状态并设置超时以重置它。

在沙盒中打开带有测试的解决方案。

教程地图

评论

在评论之前阅读此内容…
  • 如果您有改进建议 - 请提交 GitHub 问题或提交拉取请求,而不是发表评论。
  • 如果您无法理解文章中的某些内容 – 请详细说明。
  • 要插入一些代码,请使用 <code> 标记,对于多行代码 – 将其包装在 <pre> 标记中,对于 10 行以上的代码 – 使用沙盒 (plnkrjsbincodepen…)