原作者:Jake Archibald
原文链接:https://jakearchibald.com/2021/function-callback-risks/
这似乎是一个正在卷土重来的旧模式:
1 | // 把数字转化成千分位分隔的字符串。 |
1 |
|
这样看起来一切正常,但是如果some-library
做了升级,结果可能就不一样了。因为toReadableNumber
一开始可能就不是被设计用来作为array.map
的参数的。
问题出在这里:
1 | // 我们这样写: |
可以看到,除了item
参数,还多余的传递了索引和数组本身给toReadableNumber
。当toReadableNumber
只有一个参数的时候这没什么问题,但是当发生如下修改时:
1 | export function toReadableNumber(num, base = 10) { |
开发人员已经尽力适配了老代码。虽然为toReadableNumber
增加了一个参数,但是指定了默认值,这样做本身是没有任何问题的。但谁曾想在某些代码中toReadableNumber
已经被传了三个参数!
以上,由于toReadableNumber
不是专门设计作为array.map
的回调,所以更安全靠谱的做法是写一个适用的函数,然后单独调用toReadableNumber
:
1 | const readableNumbers = someNumbers.map((n) => toReadableNumber(n)); |
这样做的好处是当toReadableNumber
再增加参数,也不会造成代码错误。
Web 平台提供的函数也会有相同的问题
最近看到这样一段代码:
1 | // A promise for the next frame: |
等效于:
1 | const nextFrame = () => |
之所以现在没有问题,是因为requestAnimationFrame
只接受一个参数。如果将来requestAnimationFrame
增加了额外的参数,所有进行了这项升级的浏览器在运行上述代码的时候都可能崩溃。
这个例子很好地反应了这种模式是如何出错的:
1 | const parsedInts = ['-10', '0', '10', '20', '30'].map(parseInt); |
如果面试中遇到这样的问题,建议你翻翻白眼直接离场。但我还是说一下吧,答案是[-10, NaN, 2, 6, 12]
,因为 parseInt 有第二个参数。
对象参数也有相同的问题
Chrome 90 将允许你使用一个AbortSignal
来删除一个事件监听器,这意味着一个单独的AbortSignal
可以用来删除事件监听器、取消请求、以及做其他任何支持信号的事情。
1 | const controller = new AbortController(); |
我们期望的写法是这样的:
1 | const controller = new AbortController(); |
但是有人这样写:
1 | const controller = new AbortController(); |
和前面的例子一样,现在这样写没问题,不代表以后不会出问题。
1 |
|
综上所述,小心作为回调使用的函数,以及作为选项使用的对象,除非它们就是专为此设计的。我不知道有哪一条 linting 规则可以捕捉到它。(注:好像这个规则可以捕获一些情况,感谢 James Ross !)
TypeScript 并不能解决这个问题
注:当我第一次发布文章的时候,我就说了 TypeScript 不能防止这些问题,但是还是有些人在 Twitter 上告诉我说,”用 TypeScript 就行了“,所以我们就好好唠唠这个事情。
TypeScript 这样写会报错:
1 | function oneArg(arg1: string) { |
但是这么写就不报错:
1 | function twoArgCallback(cb: (arg1: string, arg2: string) => void) { |
。。。虽然结果是一样的(译者注:都是 oneArg
方法接收了两个参数)。
因此 TypeScript 这么写也不报错:
1 | function toReadableNumber(num: number): string { |
如果 toReadableNumber
添加一个 string
类型的参数,TypeScript 会报错,但是这个检查对我们这个例子来说没有用。我们新增的参数类型是 _number
_, 它符合类型约束。
对于requestAnimationFrame
来说情况会更加糟糕,只要使用新版本浏览器的时候就会出错,和项目版本无关。此外,TypeScript DOM 类型往往落后于浏览器几个月。
尽管我是 TypeScript 的粉丝,此博客是使用 TypeScript 构建的,但它不能解决此问题,而且可能不应该解决。
从这一点来说,大部分其他类型语言表现得和 TypeScript 都不相同,并且不允许以这种方式进行回调。但是 TypeScript 是有意为之,否则下面的代码将无法运行,因为被传入的回调函数被传入了多余的参数。
1 | const numbers = [1, 2, 3]; |
在 JavaScript 中这是非常常见的做法,并且非常安全。所以 TypeScript 这样做也是情理之中。
问题是“这个函数是不是用来做 map
的回调的”,对于 JavaScript 来说,类型并不能真正解决问题。相反,我好奇 JS 是不是应该在传入多余参数的时候抛出错误,确实,这将’保留’额外的参数位置以便之后的升级。但在现有功能上直接改造是不现实的,这样会造成兼容性的问题,但是我们可以现在增加控制台的警告。我提出过这样的想法,但是并没有多少人感兴趣。
另外当涉及选项对象时,事情会变得有些棘手:
1 | interface Options { |
按照上面的说法,如果传递给whatever
的对象具有reverse
之外的属性,则 API 应该发出警告。我们看下面的示例:
1 | whatever({ reverse: true }); |
我们传入的是一个 Object
的实例,它天然就有额外的属性,像toString
,constructor
,valueOf
,hasOwnProperty
等。所以要求属性都是“自有”属性(这不是它在运行时的工作方式)似乎过于严格,所以对于 Object
自身的属性或许应该考虑放宽一些限制。