原文链接: 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
的选项对象的。上面的方法现在能够正常工作只不过是因为 AbortController
和 addEventListener
选项 只有 signal
属性相同。
如果,比如说之后,AbortController
又加了一个 controller.capture(otherController)
方法,那么你的监听器的行为就会改变。因为 addEventListener
会把 capture
属性理解为一个真值, 并且 capture
是 addEventListener
的一个有效选项。
和之前的回调例子一样,这里最好创建一个专门用作 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 });
复制代码
我们传入了一个有 toString
、 constructor
、 valueOf
、hasOwnProperty
等属性的对象,因为这个对象是 Object
的一个实例。 看起来要求这些属性都是“自有属性”过于严格了(这并不是对象在运行时的工作方式),但是也许我们可以对 Object
自带的属性网开一面。
感谢我的播客伴侣 Surma 的校对和建议,以及 Ryan Cavanaugh 对我 TypeScript 内容的指正。