原文地址:Refactoring optional chaining into a large codebase: lessons learned 作者:Lea Verou
如今,可选链操作符已经被支持了。我决定用其来重构Mavo(当然了,还需要提供一个转译版本来适配不支持该特性的浏览器)。我等这一刻已经很久了,这是我认为自箭头函数和模板字符串以来最重要的一个语法改进,甚至比async/await
还要重要。因为属性访问操作遍地都是,可选链操作符能够改进大量的代码。
首先,让我们来了解一下什么是可选链操作符。
我们知道,若不依次检查foo
、foo.bar
、foo.bar.baz
是否存在就直接读取使用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
或者采用另一个新的操作符——空值合并操作符:
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
添加其它属性的访问,就需要进行同样的检查,而不是仅仅使用已经存在的条件即可。
兴许有人会想像以下这样转换:
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,这可能是通过这种方式进行重构时引入的最常见问题。
请小心转换如下代码:
if (foo && bar && foo.prop1 === bar.prop2) { /* ... */ }
改为:
if (foo?.prop1 === bar?.prop2) { /* ... */ }
在第一种情况下,只有当foo
和bar
都为真的情况下,整个判断条件才有可能为真。然而在第二种情况下,如果当foo
和bar
都为空值,整个判断条件也将会是真,因为两边都返回了undefined
。第二个值不使用可选链操作符,也可能出现该BUG,只要返回undefined
就有意外相等的可能性。
还有一件需要注意的事情就是可选链操作符的优先级高于&&
,而相等/不等操作符的优先级低于?.
而高于&&
。当通过使用来?.
替换&&
时,若还涉及到相等检查,这点就变得十分重要。例如:
if (foo && foo.bar === baz) { /* ... */ }
请问这里的baz
在和什么进行比较,foo.bar
还是 foo && foo.bar
?由于&&
的优先级低于===
,因此其等价于:
if (foo && (foo.bar === baz)) { /* ... */ }
如果这里foo
为假,则整个条件判断内的语句将不会被执行。当我们通过可选链操作符进行重构后,就成为(foo && foo.bar
)和baz
进行比较。当baz
为undefined时,就能看到不同语义下会得出不同的结果。例如:
if (foo?.bar === baz) { /* ... */ }
此时,若foo
为空值时,可选链操作符语句将返回undefined
,即整个判断条件为真,这基本上就和上边例子的结果一致。在其它大多数情况,这个场景也不会有太多不同。然而,当使用不等运算时,这就会变得十分糟糕。例如:
if (foo && foo.bar !== baz) { /* ... */ }
改为:
if (foo?.bar !== baz) { /* ... */ }
此时,当foo
为空值、baz
不为undefined
时,整个判断条件就为真,和重构之前的表现结果并不一致。这种差异在边界情况下都不容易被察觉到,更别说一般情况了。
当我们仔细考虑了很多种情况时,也会很容易忘记返回语句。譬如,我们不能进行如下的替换:
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
为空值时,才会返回undefined
。
重构后,Mavo的资源大小轻便了2KB并减少了37行代码。然而,转译版本多了79行代码并加重了9KB大小。
这里是可供参考的相关提交记录。在此次提交中,我尽可能只引入了跟可选链操作符相关的代码。因此,显示的diff部分就可当做可选链操作符的示例。其中,有104行添加项和141行删除项,大约有100个可选链操作符的实践示例。
希望对大家有所帮助。