不要使用函数作为回调除非它们被设计为回调(附实例)

103 阅读6分钟

这里有一个老模式,似乎正在卷土重来:

// Convert some numbers into human-readable strings:
import { toReadableNumber } from 'some-library';
const readableNumbers = someNumbers.map(toReadableNumber);

其中toReadableNumber 的实现是这样的:

export function toReadableNumber(num) {
  // Return num as string in a human readable form.
  // Eg 10000000 might become '10,000,000'
}

一切都很好,直到some-library 被更新,然后一切都坏了。但这不是some-library 的错--他们从未将toReadableNumber 设计成对array.map 的回调。

问题就在这里:

// We think of:
const readableNumbers = someNumbers.map(toReadableNumber);
// …as being like:
const readableNumbers = someNumbers.map((n) => toReadableNumber(n));
// …but it's more like:
const readableNumbers = someNumbers.map((item, index, arr) =>
  toReadableNumber(item, index, arr),
);

我们也在向toReadableNumber 传递数组中项目的索引,以及数组本身。这在一开始很正常,因为toReadableNumber 只有一个参数,但在新版本中:

export function toReadableNumber(num, base = 10) {
  // Return num as string in a human readable form.
  // In base 10 by default, but this can be changed.
}

toReadableNumber 的开发者认为他们在做一个向后兼容的改变。他们增加了一个新的参数,并给它一个默认值。然而,他们没有想到,有些代码已经在调用有三个参数的函数了。

toReadableNumber 并不是设计成对 的回调,所以安全的做法是创建你自己的函数,设计array.map与 。array.map

const readableNumbers = someNumbers.map((n) => toReadableNumber(n));

就这样了!toReadableNumber 的开发者现在可以在不破坏我们代码的情况下增加参数。

同样的问题,但在网络平台函数方面

这是我最近看到的一个例子:

// A promise for the next frame:
const nextFrame = () => new Promise(requestAnimationFrame);

但这相当于:

const nextFrame = () =>
  new Promise((resolve, reject) => requestAnimationFrame(resolve, reject));

这在今天是可行的,因为requestAnimationFrame 只对第一个参数做一些事情,但这可能不会永远是真的。可能会增加一个额外的参数,而上述代码可能会在任何浏览器的更新requestAnimationFrame

这种模式出错的最好例子可能是:

const parsedInts = ['-10', '0', '10', '20', '30'].map(parseInt);

如果有人在技术面试中问你这个问题的结果,我建议你揉揉眼睛走出去。但无论如何,答案是[-10, NaN, 2, 6, 12] ,因为parseInt 有第二个参数。

选项对象也会有同样的问题

Chrome 90将允许你使用一个AbortSignal 来移除事件监听器,这意味着一个单一的AbortSignal 可以用来移除事件监听器,取消获取,以及其他任何支持信号的东西:

const controller = new AbortController();
const { signal } = controller;

el.addEventListener('mousemove', callback, { signal });
el.addEventListener('pointermove', callback, { signal });
el.addEventListener('touchmove', callback, { signal });

// Later, remove all three listeners:
controller.abort();

然而,我看到了一个例子,其中没有:

const controller = new AbortController();
const { signal } = controller;
el.addEventListener(name, callback, { signal });

...他们这样做:

const controller = new AbortController();
el.addEventListener(name, callback, controller);

和回调的例子一样,这在今天是可行的,但在将来可能会失效。

AbortController 并不是被设计成addEventListener 的一个选项对象。它现在是有效的,因为唯一的东西 AbortControlleraddEventListener 选项的共同点是signal 属性。

如果,比如说在将来,AbortController 得到一个controller.capture(otherController) 方法,你的监听器的行为就会改变,因为addEventListener 会在capture 属性中看到一个真实的值,而captureaddEventListener 的一个有效选项。

和回调的例子一样,最好是创建一个旨在成为addEventListener 选项的对象:

const controller = new AbortController();
const options = { signal: controller.signal };
el.addEventListener(name, callback, options);
// Although I find this pattern easier when multiple things
// get the same signal:
const { signal } = controller;
el.addEventListener(name, callback, { signal });

就这样吧!注意函数被用作回调,对象被用作选项,除非它们是为这些目的而设计的。不幸的是,我不知道有什么提示规则能抓住它(编辑:看起来有一条规则能抓住一些情况,谢谢James Ross!)。

TypeScript并不能解决这个问题

编辑:当我第一次发布这篇文章时,它在结尾处有一个小注解,显示TypeScript并不能防止这个问题,但还是有朋友在Twitter上告诉我 "只要使用TypeScript",所以让我们更详细地看一下。

TypeScript不喜欢这样

function oneArg(arg1: string) {
  console.log(arg1);
}

oneArg('hello', 'world');
//              ^^^^^^^
// Expected 1 arguments, but got 2.

但它对这个却很好

function twoArgCallback(cb: (arg1: string, arg2: string) => void) {
  cb('hello', 'world');
}

twoArgCallback(oneArg);

...尽管结果是一样的:

因此,TypeScript对这个没有意见

function toReadableNumber(num): string {
  // Return num as string in a human readable form.
  // Eg 10000000 might become '10,000,000'
  return '';
}

const readableNumbers = [1, 2, 3].map(toReadableNumber);

如果toReadableNumber ,增加了第二个字符串参数,TypeScript会抱怨,但这并不是例子中发生的事情。一个额外的数字参数被添加,而这符合类型约束

requestAnimationFrame 这个例子中,事情变得更糟,因为这是在浏览器的新版本部署后出的问题,而不是在你的项目的新版本部署后出的问题。此外,TypeScript的DOM类型往往落后于浏览器发货的时间。

我是TypeScript的粉丝,这个博客也是用TypeScript建立的,但是它并没有解决这个问题,也许也不应该解决。

大多数其他类型的语言在这里的行为与TypeScript不同,不允许以这种方式铸造回调,但TypeScript在这里的行为是故意的,否则它就会拒绝以下情况。

const numbers = [1, 2, 3];
const doubledNumbers = numbers.map((n) => n * 2);

...因为给map 的回调被投到一个有更多参数的回调。以上是JavaScript中极为常见的模式,而且完全安全,所以TypeScript为其破例是可以理解的。

问题是 "这个函数是否打算成为map 的回调?",而在JavaScript的世界里,这并不是真的可以用类型解决的。相反,我想知道新的JS和网络函数如果被调用时有太多的参数,是否应该抛出。这将 "保留 "额外的参数供将来使用。我们不能在现有的函数中这样做,因为这将破坏向后的兼容性,但我们可以在现有的函数中显示控制台警告,我们可能想在以后添加参数。我提出了这个想法,但人们似乎对它不太感兴趣😀。

当涉及到选项对象时,事情就有点棘手了:

interface Options {
  reverse?: boolean;
}

function whatever({ reverse = false }: Options = {}) {
  console.log(reverse);
}

你可以说,如果传递给whatever 的对象有除reverse 以外的其他属性,API应该警告/失败。但是在这个例子中:

whatever({ reverse: true });

......我们传递的对象有toString,constructor,valueOf,hasOwnProperty 等属性,因为上面的对象是Object 的一个实例。要求这些属性是 "自己的 "属性似乎太有限制性了(目前在运行时不是这样的),但也许我们可以为Object 自带的属性增加一些津贴。