不要随便把函数当回调来用!

原文链接: Don’t use functions as callbacks unless they’re designed for it – JakeArchibald.com
作者: Jake Archibald
译者:大白

这是一个看起来正在卷土重来的老模式:

// 将一些数字转换成人类易读的字符串
import { toReadableNumber } from 'some-library';
const readableNumbers = someNumbers.map(toReadableNumber);
复制代码

其中 toReadableNumber 的实现如下:

// some-library
export function toReadableNumber(num) {
  // 将 num 转为可读的字符串返回
  // 例如 10000000 可能会变成 '10,000,000'
}
复制代码

some-library这个外部库更新之前,一切相安无事,但在它更新之后以上代码就出问题了。但这并不是 some-library 的错,因为这个库从来没有想过让 toReadableNumber 作为 array.map 的回调来使用。

问题在这里:

// 我们认为以下代码
const readableNumbers = someNumbers.map(toReadableNumber);
// 相当于:
const readableNumbers = someNumbers.map((n) => toReadableNumber(n));
// 但它实际上却是这样的:
const readableNumbers = someNumbers.map((item, index, arr) =>
  toReadableNumber(item, index, arr),
);
复制代码

我们同时还给 toReadableNumber传递了索引和数组本身。这一开始没问题,因为 toReadableNumber 当时只有一个形参,但在新版本中它又加了一个:

export function toReadableNumber(num, base = 10) {
  // 将 num 转为可读的字符串返回
  // 默认以 10 作为基数,但基数可以改变
}
复制代码

toReadableNumber 的开发者觉得他们做了一个向后兼容的变更。他们加了一个新形参,并且为它赋了一个初始值。但他们没想到有些代码已经在使用三个实参来调用这个函数了。

toReadableNumber 并不是设计用来给 array.map当回调的,所以保险的做法自己写一个函数传给 array.map

// 在我们写的这个箭头函数中,明确了只传 num
const readableNumbers = someNumbers.map((n) => toReadableNumber(n));
复制代码

这样就可以了! toReadableNumber 的开发者现在可以添加其他的形参而不会搞砸我们的代码了。

Web 平台函数也存在同样的问题

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

// 创建一个表示下一帧的Promise对象:
const nextFrame = () => new Promise(requestAnimationFrame);
复制代码

但这其实等同于:

const nextFrame = () =>
  new Promise((resolve, reject) => requestAnimationFrame(resolve, reject));
复制代码

这段代码现在是工作的,因为 requestAnimationFrame 目前只接收一个参数。但未来是不是只接收一个参数是不一定的。这个函数将来可能会有一个额外的形参,那么上面这段代码就有可能在浏览器发布新版 requestAnimationFrame 的时候崩溃。(译者注:比如说,假设第二个参数在执行中会被调用,相当于调用了 reject。)

下面这个例子最明显地说明了这种模式的问题:

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

如果有任何人在技术面试的时候问你上面代码的运行结果,那你就白他一眼,结束面试。但无论如何,答案是 [-10, NaN, 2, 6, 12],因为 parseInt 是有第二个形参的。(译者注:Number.parseInt(string,[ radix]), 而我们每次传入的基数(radix) 为 0、1、2、3、4。)

选项对象也可能有同样的问题

Chrome 90 将支持使用 AbortSignal 来移除事件监听器,这意味着 AbortSignal 可以被用来移除事件监听器、取消请求,以及任何支持信号 (signal) 的东西。

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

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

// 之后某个时刻,移除所有的三个监听器:
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);

// 不过,下面这个模式在需要重用signal时更方便:
const { signal } = controller;
el.addEventListener(name, callback, { signal });
复制代码

这样就行了! 小心被用作回调的函数和被用作选项的对象,除非它们是为此而生的。不幸的是,我没有发现能够找出这种模式的检查(linting)规则。(更新:看起来有个规则能在部分场合下找出这种模式,感谢 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);
复制代码

但两种写法的结果是相同的,都是给oneArg传了两个参数。

所以 TypeScript 能接受下面的代码

function toReadableNumber(num): string {
  // 将 num 转为可读的字符串返回
  // 例如 10000000 可能会变成 '10,000,000'
  return '';
}

const readableNumbers = [1, 2, 3].map(toReadableNumber);
复制代码

如果 toReadableNumber 被修改为增加了第二个字符串类型的形参,TypeScript 就会报错,但这并不是这个例子中发生的事。我们额外添加了一个的number类型的形参,而这满足了类型约束

如果是类似前面 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 以及 Web 函数应该在他们被使用了过多的实参调用时报错。这将为未来的使用 “预留” 一些额外的形参位置。但不能为现有的函数添加这一行为,因为这将破坏向后兼容性,不过可以为将来可能增加形参的现有函数添加控制台警告。 我提出了这个想法 ,但大家对于这个主意并不是特别感兴趣 ?。

当面对选项对象的时候,就更棘手了:

interface Options {
  reverse?: boolean;
}

function whatever({ reverse = false }: Options = {}) {
  console.log(reverse);
}
复制代码

你可以说接口应该在传给 whatever 的对象有 reverse 以外的属性时警告或者失败。但在这个例子中:

whatever({ reverse: true });
复制代码

我们传入了一个有 toStringconstructorvalueOfhasOwnProperty 等属性的对象,因为这个对象是 Object 的一个实例。 看起来要求这些属性都是“自有属性”过于严格了(这并不是对象在运行时的工作方式),但是也许我们可以对 Object自带的属性网开一面。

感谢我的播客伴侣 Surma 的校对和建议,以及 Ryan Cavanaugh 对我 TypeScript 内容的指正。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享