专栏首页腾讯IVWEB团队的专栏【译】通过可选链操作符重构大型代码库的经验教训

【译】通过可选链操作符重构大型代码库的经验教训

原文地址:Refactoring optional chaining into a large codebase: lessons learned 作者:Lea Verou

如今,可选链操作符已经被支持了。我决定用其来重构Mavo(当然了,还需要提供一个转译版本来适配不支持该特性的浏览器)。我等这一刻已经很久了,这是我认为自箭头函数和模板字符串以来最重要的一个语法改进,甚至比async/await还要重要。因为属性访问操作遍地都是,可选链操作符能够改进大量的代码。

首先,让我们来了解一下什么是可选链操作符。

我们知道,若不依次检查foofoo.barfoo.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

如果像我一样对项目进行如此重构,就很容易在某个点引入可选链操作符后,不经意间改变了代码功能并引入了难以察觉的BUG。

null vs undefined

通常来说,将foo && foo.bar替换为foo?.bar可能是最常见的场景。大多数情况下,这种方式的结果是一致的,但并不代表所有的情况。当foonull时,前者返回null,而后者返回undefined。在需要进行区分的场景下,就会引入BUG,这可能是通过这种方式进行重构时引入的最常见问题。

等值判断

请小心转换如下代码:

if (foo && bar && foo.prop1 === bar.prop2) { /* ... */ }

改为:

if (foo?.prop1 === bar?.prop2) { /* ... */ }

在第一种情况下,只有当foobar都为真的情况下,整个判断条件才有可能为真。然而在第二种情况下,如果当foobar都为空值,整个判断条件也将会是真,因为两边都返回了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?.();

在第一种情况下,返回值是有条件的;而在第二种情况下,任何时候都会有返回值。如果该逻辑是函数中的最后一段语句,将不会引入任何问题。若不是,则将改变代码执行的流程。

有时也能解决BUG

来看一看我在重构中遇到的这段代码:

/**
 * 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个可选链操作符的实践示例。

希望对大家有所帮助。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • HTTP概述

    <img style="width: 600px;" src="http://7tszky.com1.z0.glb.clouddn.com/FgB7-X3Scd...

    腾讯IVWEB团队
  • 【译】HTTP/2:更短的页面加载时间更好的搜索引擎排名

    HTTP/2:如今,Google、Youtube、Facebook等很多大型网站都已经使用了 HTTP/2,很多人都知道 HTTP/2,也就不足为奇了。

    腾讯IVWEB团队
  • HTTP1.1与前端性能

    HTTP协议是前端性能乃至安全中一个非常重要的话题,最近在看《web性能权威指南(High Performance Browser Networking)》,把...

    腾讯IVWEB团队
  • 在js中关于同名变量和函数的地位争夺问题

    其实,在浏览器解析js代码的过程中,会有一个预编译的过程,遇到function 函数定义的部分,会先将该部分的代码提前,所以我们在第一个console.log(...

    Theone67
  • JS 变量提升

    问到 JS 一些细节问题的时候发挥比较糟糕,有些是知道反应得太慢,有些是压根没接触过,还是积累的太少了。这篇的 JS 变量提升问题就是从没有接触过的,网上一搜一...

    Alan Zhang
  • R中的管道操作符%>%

    管道是一种强大的工具,可以清楚地表示由多个操作组成的一个操作序列。管道%>% 来自于magrittr 包。因为tidyverse 中的包会自动加载%>%,所以一...

    生信编程日常
  • VC和GCC成员函数指针实现的研究(三)

    因为是兼容虚继承和非虚继承的,所以赋值的部分的汇编是一样的。这里就不贴了。关键在于执行期它是怎么找到虚基类的。请往下看:

    owent
  • python expect

    py3study
  • VC和GCC成员函数指针实现的研究(二)

    调用的时候主要看(c.*vptr2)()的代码。因为(c.vptr1)()生成的和单继承一样。而由于它们最终都转向vcall,所以vptr2的时候调整了虚表指针...

    owent
  • 你所不知道的“foo”和“bar”

    “foo” 和“foobar”等单词经常会作为示例名称出现在各种程序和技术文档中。据统计,在各种计算机和通信技术文档中,大约有百分之七的文档出现了这些词语。...

    Jean

扫码关注云+社区

领取腾讯云代金券