可读性友好的JavaScript:两个专家的故事
每个人都想成为专家,但什么才是专家呢?这些年来,我见过两种被称为“专家”的人。专家一是指对语言中的每一个工具都了如指掌的人,而且无论是否有帮助,都一定要用好每一点。专家二也知道每一个语法,但他们对采用什么来解决问题比较挑剔,会考虑很多因素,包括与代码有关的和无关的。
你能猜猜我们想让哪位专家加入我们的团队吗?如果你说是专家二,那你猜对了。他们是专注于编写可读性好的 JavaScript 代码的开发人员,其他人可以理解和维护。他们能把复杂的事情简单化。但“可读性”很少是确定的--事实上,它在很大程度上是基于主观感受。那么,专家们在编写可读性代码时应该以什么为目标?是否有明确的正确和错误的选择?有,这视情况而定。
显而易见的选择
为了提高开发者的体验,TC39 近年来在 ECMAScript 中加入了很多新功能,包括很多从其他语言中借鉴的成熟模式。ES2019 年新增的一个功能是 Array.prototype.flat()
它接受一个表示深度或 Infinity
参数,并将一个数组扁平化。如果没有给出参数,深度默认为 1。
在增加这个功能之前,我们需要用下面的语法将一个数组扁平化为单层。
let arr = [1, 2, [3, 4]]
;[].concat.apply([], arr)
// [1, 2, 3, 4]
当我们添加了 flat()
后,同样的功能可以用一个单一的、描述性的函数来表达。
arr.flat()
// [1, 2, 3, 4]
第二行代码是否更具可读性?答案是肯定的。事实上,两位专家都会同意。
并不是每个开发人员都会知道 flat()
的存在。但他们不需要,因为 flat()
是一个描述性动词,它可以传达正在发生的意义。它比 concat.apply()
要直观得多。
对于新语法是否比旧语法好这个问题,这是少有的有明确答案的情况。两位专家,每个人都熟悉这两种语法,都会选择第二种。他们会选择更短、更清晰、更容易维护的一行代码。
但选择和权衡并不总是那么具有决定性的。
状况检查
JavaScript 的神奇之处在于它的用途非常广泛。它在网络上的广泛使用是有原因的。至于你认为这是好事还是坏事,那就另当别论了。
但是,随着这种多功能性的出现,也带来了选择的矛盾。你可以用很多不同的方式来编写同样的代码。你如何确定哪种方式是“正确的”?除非你了解可用的选项和它们的局限性,否则你甚至无法开始做决定。
让我们以函数式编程的 map()
为例。我将通过各种迭代来讲解,这些迭代都会产生相同的结果。
这是我们 map()
例子的最简洁版本。它使用了最少的字符,只有一行代码。这是我们的基线。
const arr = [1, 2, 3]
let multipliedByTwo = arr.map(el => el * 2)
// multipliedByTwo is [2, 4, 6]
接下来这个例子只增加了两个字符:括号。有什么损失吗?又得到了什么呢?一个有多个参数的函数总是需要使用括号,这有什么不同吗?我认为是的。在这里加入它们并没有什么坏处,而且当你不可避免地写一个有多个参数的函数时,它提高了一致性。事实上,当我写这个的时候,Prettier 执行了这个约束,它不希望我创建一个没有括号的箭头函数。
let multipliedByTwo = arr.map((el) => el * 2)
让我们再进一步。我们添加了大括号和回车。现在,这开始看起来更像一个传统的函数定义。如果有一个和函数逻辑一样长的关键字,可能会显得有些矫枉过正。然而,如果函数超过一行,这个额外的语法又是必须的。我们是否假定我们不会有任何其他超过一行的函数?这似乎很值得怀疑。
let multipliedByTwo = arr.map((el) => {
return el * 2
})
接下来我们不使用箭头函数。我们使用与上面相同的语法,但我们换成了 function
关键字。这很有意思,因为任何情况下这种语法都能用;任何数量的参数或行数都不会导致什么问题,所以这更具有一致性。它比我们最初的定义更啰嗦,但这是一件坏事吗?这对一个新的程序员,或者一个精通 JavaScript 以外语言的人来说,会有怎样的冲击?相比之下,一个精通 JavaScript 的人是否会因为这个语法而感到沮丧?
let multipliedByTwo = arr.map(function (el) {
return el * 2
})
最后我们到了最后一个选项:只传递函数。而 timesTwo
可以使用我们喜欢的任何语法来写。同样,没有任何情况传递函数名会造成问题。但是退一步想一想,这是否会让人感到困惑。如果你是这个代码库的新手,是否清楚 timesTwo
是一个函数而不是一个对象?当然,map()
是为了给你一个提示,但错过这个细节也不是没有道理的。timesTwo
被声明和初始化的位置呢?它容易找到吗?是否清楚它在做什么,以及它是如何影响这个结果的?这些都是重要的考虑因素。
const timesTwo = (el) => el * 2
let multipliedByTwo = arr.map(timesTwo)
正如你所看到的,这里没有明确的答案。但为你的代码库做出正确的选择意味着了解所有的选项及其局限性。并且知道一致性需要括号、大括号和 return
关键字。
在编写代码时,你必须问自己一些问题。性能的问题通常是最常见的。但是当你在看功能相同的代码时,你的判断应该基于人--人如何消费代码。
新的并不总是更好
到目前为止,我们已经找到了一个明确的例子,说明两位专家都会采用最新的语法,即使它并不为人所知。我们还看了一个例子,它提出了很多问题,但没有那么多答案。
现在是时候深入研究我以前写过的代码了......但被删除了。这是让我第一次成为专家的代码,使用了一个鲜为人知的语法来解决问题,但对我的同事来说它破坏了我们代码库的可维护性。
解构赋值可以让你从对象(或数组)中解开值。它通常看起来像这样。
const { node } = exampleObject
它在一行中初始化一个变量并给它赋值。但这并不是必须的。
let node
;({ node } = exampleObject)
最后一行代码使用解构给一个变量赋值,但变量声明发生在它之前的一行。这并不是一件稀奇古怪的事情,但很多人并不知道你可以这样做。
但仔细看看这段代码。它为那些不使用分号结束行的代码强行加上了一个尴尬的分号。它将命令用括号包裹起来,并加上大括号;完全不清楚这是在做什么。它不容易阅读,而且,作为专家,它不应该出现在我写的代码中。
let node
node = exampleObject.node
这个代码解决了这个问题。它很好用,很清楚它的作用,我的同事们不用查就能明白。对于解构语法,我可以做并不代表我应该做。
代码不是一切
正如我们所看到的那样,专家二的解决方案很少能单凭代码就能明显地看出;但每个专家会写哪些代码,还是有明显的区别。这是因为代码是给机器看的,而人类要解释它。所以还有一些非代码因素需要考虑!
你为一个 JavaScript 开发团队所做的语法选择,与你为一个不沉浸于细枝末节的多语言团队应该做的选择是不同的。
让我们以扩展运算符(...
) 与 concat()
为例。
扩展运算符是几年前添加到 ECMAScript 中的,它得到了广泛的应用。它是一种实用的语法,它可以做很多不同的事情。其中之一就是连接数组。
const arr1 = [1, 2, 3]
const arr2 = [9, 11, 13]
const nums = [...arr1, ...arr2]
虽然扩展运算符很强大,但它并不是一个很直观的符号。所以除非你已经知道它的作用,否则它并没有极大的帮助。虽然两位专家可能会安全地假设一个 JavaScript 专家团队熟悉这种语法,但专家二可能会质疑一个多语言程序员团队是否如此。相反,专家二可能会选择 concat()
方法来代替,因为它是一个描述性动词,你可以从代码的上下文中理解。
这段代码给我们提供了和上面扩展运算符例子一样的数字结果。
const arr1 = [1, 2, 3]
const arr2 = [9, 11, 13]
const nums = arr1.concat(arr2)
而这只是人为因素影响代码选择的一个例子。例如,一个由很多不同团队接触的代码库,可能必须持有更严格的标准,不一定能跟上最新最强的语法。然后,你站在源代码以外的视角,考虑你的工具链中的其他因素,这些因素会让在这些代码上工作的人感到更轻松或者更困难。有一些代码,可以以一种敌视测试的方式进行结构化。有一些代码,让你在未来的扩展或功能添加时陷入困境。有的代码性能较差,不能处理不同的浏览器。所有这些都会成为专家二提出建议的因素。
专家二还考虑了命名的影响。但说实话,即使是他们也不能在大多数时候把这一点做对。
结语
专家并不是通过使用每一个规范来证明自己;他们是通过对规范的充分了解来证明自己,从而明智地使用恰当的语法并做出合理的决定。这就是专家如何成为倍增器--这也是他们会造就新的专家的原因。
那么,这对我们这些自认为是专家或有志于成为专家的人来说意味着什么呢?这意味着编写代码需要问自己很多问题。它意味着要以一种真实的方式考虑你的开发者受众。你能写出的最好的代码是完成一些复杂的业务,但本质上是那些检查你的代码库的人所能理解的代码。
不,这并不容易。而且往往没有一个明确的答案。但这是你在写每个函数时都应该考虑的问题。
英文原文:https://alistapart.com/article/human-readable-javascript