首先,什么是可选链(optional-chaining)
,这是在ES2020
中引入的新特性,在typescript v3.7
中也已经支持。
要知道,你不能只在代码中写foo.bar.baz()
,而不检查foo
是否存在,然后检查foo.bar
是否存在,再检查foo.bar.baz
是否存在,否则你可能会收到一个异常。所以你的代码必须类似于:
if (foo && foo.bar && foo.bar.baz) {
foo.bar.baz();
}
或者:
foo && foo.bar && foo.bar.baz && foo.bar.baz();
但如果有了可选链,你就可以直接这样做:
foo?.bar?.baz?.()
它支持正常的属性访问、括号(foo?.[bar]
),甚至是函数调用(foo?.()
);
可选链还可以简化其他很多代码,同时也有一些需要注意的地方。
假设你决定要重构你的代码,要找什么呢?
当然明显的有foo && foo.bar
,转为foo?.bar
。
还有上文所述if
语句中的代码。
另外还有一些情况:
foo ? foo.bar : defaultValue
重构后:
foo?.bar || defaultValue
或者用ES2020中另一个新的运算符,空值合并运算符(Nullish Coalescing Operator):
foo?.bar ?? defaultValue
if (foo.length > 3) {
foo[2]
}
重构后:
foo?.[2]
请注意,这并不能替代真正的数组检查,比如Array.isArray(foo)
。
let match = "#C0FFEE".match(/#([A-Z]+)/i);
let hex = match && match[1];
或者
let hex = ("#C0FFEE".match(/#([A-Z]+)/i) || [,])[1];
重构后:
let hex = "#C0FFEE".match(/#([A-Z]+)/i)?.[1];
if (element.prepend) element.prepend(otherElement);
重构后:
element.prepend?.(otherElement);
虽然你可能很想重构这样一段代码:
if (foo) {
something(foo.bar);
somethingElse(foo.baz);
andOneLastThing(foo.yolo);
}
转为:
something(foo?.bar);
somethingElse(foo?.baz);
andOneLastThing(foo?.yolo);
不要这样做。因为这样会让JS在运行时检查foo
三次而不是一次。你可能觉得这在性能上并不那么重要,但对于阅读代码的人来说,也会存在同样的重复:他们必须在心理上处理三次对foo
的检查,而不是一次。如果他们要在另一个语句上使用foo
的属性,他们需要另外添加一个检查,而不是使用已有的条件。
你可能会将下面的代码:
if (foo && foo.bar) {
foo.bar.baz = someValue;
}
重构为:
foo?.bar?.baz = someValue;
然而,这样做会出错。比如,将下面的代码:
if (this.bar && this.bar.edit) {
this.bar.edit.textContent = this._("edit");
}
重构为:
if (this.bar?.edit) {
this.bar.edit.textContent = this._("edit");
}
目前为止一切都还好,直到代码进一步重构成这样:
this.bar?.edit?.textContent = this._("edit");
这会抛出这样的异常:Uncaught SyntaxError: Invalid left-hand side in assignment
。所以你仍然需要条件,你可以使用ESLint
来防止这样的错误在运行代码后产生。
?.
放错位置,或者忘记?.
值得注意的是,当你重构一个带有可选链的长链时,你往往需要在第一个链之后插入多个 ?.
,否则一旦可选链返回undefined
,你的代码就会报错。
或者,有时是因为你把?.
放错了位置。
比如下面这段代码:
this.children[index]? this.children[index].element : this.marker
重构为:
this.children?.[index].element ?? this.marker
那么你会得到这样一个报错:TypeError: Cannot read property 'element' of undefined.
这样因为忘记了一个?.
导致的:
this.children?.[index]?.element ?? this.marker
这样是可行的,不过回顾下重构前的代码,有一个?.
是多余的:
this.children[index]?.element ?? this.marker
请注意,当用可选链代替数组长度检查时,可能会对性能造成影响,因为数组的越界访问会影响V8执行代码的效率(因为它也要检查像原型链的这种属性,而不仅仅是判断数组中有没有指定的索引)。
当你大肆进行重构到了一定程度后,可能很容易在一些地方只引入可选链,而实际上它将改变你代码的作用,引发一些难以察觉的bug。
最常见的情况可能是将foo && foo.bar
替换为foo?.bar
。虽然在大多数情况下,这两个函数的工作原理是相同的,但并不是每一种情况都是如此。当foo
为null
时,前者会返回null
,而后者返回undefined
。这可能会导致在精准区分的情况下出现bug,这也是这种情况下重构最常见的引起的bug。
比如将下面这段代码:
if (foo && bar && foo.prop1 === bar.prop2) { /* ... */ }
转为:
if (foo?.prop1 === bar?.prop2) { /* ... */ }
在第一种情况下,只有foo和bar都为真时条件才为真。但在第二种情况下,如果foo
和bar
都为undefined
,那么条件也会为真。
需要注意的是,可选链的优先级高于&&
。尤其当你重构一个包含&&
和平等运算符的表达式时,需要额外注意,因为平等运算符相对于?.
和&&
而言,其优先级低于前者,高于后者。
if (foo && foo.bar === baz) { /* ... */ }
这里baz
在和谁比较? foo.bar
还是foo && foo.bar
?因为&&
的优先级比===
低,所以是这样:
if (foo && (foo.bar === baz)) { /* ... */ }
在上面的代码中,如果foo
是假值,条件就永远不能被执行。然而,一旦我们将其重构为可选链的形式,就会变成比较foo && foo.bar
与baz
:
if (foo?.bar === baz) { /* ... */ }
一个明显的问题是,当 baz
为undefined
时,我们可以在foo
为nullish
的时候进入条件语句,这和我们的初衷相违和。
另一种更糟糕的情况是出现不等式操作符时,因为它和平等操作符具有相同的优先级。比如:
if (foo && foo.bar !== baz) { /* ... */ }
转为:
if (foo?.bar !== baz) { /* ... */ }
现在,当baz
不是undefined
,只要foo
为nullish
时就会进入条件语句!
在重构返回语句时,你要当心这样的替换:
if (foo && foo.bar) {
return foo.bar();
}
转为:
return foo?.bar?.();
在第一种情况下,代码会有条件地返回,但在第二种情况下,代码总是会返回。如果条件是函数中的最后一条语句,这不会有任何问题,但如果不是,就会改变控制流程。
来看看这段代码:
/**
* Get the current value of a CSS property on an element
*/
getStyle: (element, property) => {
if (element) {
var value = getComputedStyle(element).getPropertyValue(property);
if (value) {
return value.trim();
}
}
},
发现这段代码的错误了吗?如果value
是一个空字符串(根据上下文,它很可能是),函数将返回undefined
,因为空字符串是个假值。我们使用可选链来解决这个问题:
if (element) {
var value = getComputedStyle(element).getPropertyValue(property);
return value?.trim();
}
现在,如果value
是空字符串,它就会支持返回一个空字符串,而且只有当value
是nullish
时,它才会返回undefined
。
注:这里记录着更多的重构实例
原文-refactoring-optional-chaining-into-a-large-codebase-lessons-learned