变量命名
变量命名是编写可读代码的基础。只有变量被赋予了一个合适的名字,才能表达出它在环境中的意义。
命名必须传递足够的信息,形如 getData
这样的函数命名就没能提供足够的信息,读者也完全无法猜测这个函数会做出些什么事情。而 fetchUserInfoAsync
也许就好很多,读者至少会猜测出,这个函数大约会远程地获取用户信息;而且因为它有一个 Async
后缀,读者甚至能猜出这个函数会返回一个 Promise 对象。
命名的基础
通常,我们使用名词来命名对象,使用动词来命名函数。比如:
monkey.eat(banana); // the money eats a banana |
这两句代码与自然语言(右侧的注释)很接近,即使完全不了解编程的人也能看懂大概。
有时候,我们需要表示某种集合概念,比如数组或哈希对象。这时可以通过名词的复数形式来表示,比如用 bananas
表示一个数组,这个数组的每一项都是一个 banana
。如果需要特别强调这种集合的形式,也可以加上 List
或 Map
后缀来显式表示出来,比如用 bananaList
表示数组。
有些单词的复数形式和单数形式相同,有些不可数的单词没有复数形式(比如 data,information),这时我也会使用
List
等后缀来表示集合概念。
命名的上下文
变量都是处在上下文(作用域)之内,变量的命名应与上下文相契合,同一个变量,在不同的上下文中,命名可以不同。举个例子,假设我们的程序需要管理一个动物园,程序的代码里有一个名为 feedAnimals
的函数来喂食动物园中的所有动物:
function feedAnimals(food, animals) { |
负责喂食动物的函数 feedAnimals
函数的主要逻辑就是:用各种食物把动物园里的各种动物喂饱。也许,每种动物能接受的食物种类不同,也许,我们需要根据各种食物的库存来决定每种动物最终分到的食物,总之在这个上下文中,我们需要关心食物的种类,所以传给 money.eat
方法的实参对象命名为 banana
或者 peach
,代码很清楚地表达出了它的关键逻辑:「猴子要么吃香蕉,要么吃桃子(如果没有香蕉了)」。我们肯定不会这样写:
// 我们不会这样写 |
Monkey#eat
方法内部就不一样了,这个方法很可能是下面这样的(假设 eat
是 Monkey
的基类 Animal
的方法):
class Animal{ |
如代码所示,「吃」这个方法的核心逻辑就是根据食物的能量来减少动物(猴子)自身的饥饿度,至于究竟是吃了桃子还是香蕉,我们不关心,所以在这个方法的上下文中,我们直接将表示食物的函数形参命名为 food
。
想象一下,假设我们正在编写某个函数,即将写一段公用逻辑,我们会选择去写一个新的功能函数来执行这段公用逻辑。在编写这个新的功能函数过程中,往往会受到之前那个函数的影响,变量的命名也是按照其在之前那个函数中的意义来的。虽然写的时候不感觉有什么阻碍,但是读者阅读的单元是函数(他并不了解之前哪个函数),会被深深地困扰。
严格遵循一种命名规范的收益
如果你能够时刻按照某种严格的规则来命名变量和函数,还能带来一个潜在的好处,那就是你再也不用记住哪些之前命名过(甚至其他人命名过)的变量或函数了。特定上下文中的特定含义只有一种命名方式,也就是说,只有一个名字。比如,「获取用户信息」这个概念,就叫作 fetchUserInfomation
,不管是在早晨还是傍晚,不管你是在公司还是家中,你都会将它命名为 fetchUserInfomation
而不是 getUserData
。那么当你再次需要使用这个变量时,你根本不用翻阅之前的代码或依赖 IDE 的代码提示功能,你只需要再命名一下「获取用户信息」这个概念,就可以得到 fetchUserInfomation
了,是不是很酷?
分支结构
分支是代码里最常见的结构,一段结构清晰的代码单元应当是像二叉树一样,呈现下面的结构。
if (condition1) { |
这种优美的结构能够帮助我们在大脑中迅速绘制一张图,便于我们在脑海中模拟代码的执行。但是,我们大多数人都不会遵循上面这样的结构来写分支代码。以下是一些常见的,在我看来可读性比较差的分支语句的写法:
不好的做法:在分支中 return
function foo() { |
这种分支代码很常见,而且往往分支 2 的逻辑是先写的,也是函数的主要逻辑,分支 1 是后来对函数进行修补的过程中产生的。这种分支代码有一个很致命的问题,那就是,如果读者没有注意到分支1中的 return
(我敢保证,在使用 IDE 把代码折叠起来后,没人能第一时间注意到这个 return
),就不会意识到后面一段代码(分支 2)是有可能不会执行的。我的建议是,把分支 2 放到一个 else
语句块中,代码就会清晰可读很多:
function foo() { |
如果某个分支是空的,我也倾向于留下一个空行,这个空行明确地告诉代码的读者,如果走到这个
else
,我什么都不会做。如果你不告诉读者,读者就会产生怀疑,并尝试自己去弄明白。
不好的做法:多个条件复合
if (condition1 && condition2 && condition3) { |
这种代码也很常见:在若干条件同时满足(或有任一满足)的时候做一些主要的事情(分支1,也就是函数的主逻辑),否则就做一些次要的事情(分支2,比如抛异常,输出日志等)。虽然写代码的人知道什么是主要的事情,什么是次要的事情,但是代码的读者并不知道。读者遇到这种代码,就会产生困惑:分支2到底对应了什么条件?
在上面这段代码中,三种条件只要任意一个不成立就会执行到分支 2,但这其实本质上是多个分支:1)条件 1 不满足,2)条件 1 满足而条件 2 不满足,3)条件 1 和 2 都满足而条件 3 不满足。如果我们笼统地使用同一段代码来处理多个分支,那么就会增加阅读者阅读分支 2 时的负担(需要考虑多个情况)。更可怕的是,如果后面需要增加一些额外的逻辑(比如,在条件 1 成立且条件 2 不成立的时候多输出一条日志),整个 if-else
都可能需要重构。
对这种场景,我通常这样写:
if (condition1) { |
即使分支 2 和分支 3 是完全一样的,我也认为有必要将其分开。虽然多了几行代码,收益却是很客观的。
万事非绝对。对于一种情况,我不反对将多个条件复合起来,那就是当被复合的多个条件联系十分紧密的时候,比如
if(foo && foo.bar)
。
不好的做法:使用分支改变环境
let foo = someValue; |
这种风格的代码很容易出现在那些屡经修补的代码文件中,很可能一开始是没有这个 if
代码块的,后来发现了一个 bug,于是加上了这个 if
代码块,在某些条件下对 foo
做一些特殊的处理。如果你希望项目在迭代过程中,风险越积越高,那么这个习惯绝对算得上「最佳实践」了。
事实上,这样的「补丁」积累起来,很快就会摧毁代码的可读性和可维护性。怎么说呢?当我们在写下上面这段代码中的 if
分支以试图修复 bug 的时候,我们内心存在这样一个假设:我们是知道程序在执行到这一行时,foo
什么样子的;但事实是,我们根本不知道,因为在这一行之前,foo
很可能已经被另一个人所写的尝试修复另一个 bug 的另一个 if 分支所篡改了。所以,当代码出现问题的时候,我们应当完整地审视一段独立的功能代码(通常是一个函数),并且多花一点时间来修复他,比如:
const foo = condition ? doSomethingToFoo(someValue) : someValue; |
我们看到,很多风险都是在项目快速迭代的过程中积累下来的。为了「快速」迭代,在添加功能代码的时候,我们有时候连函数这个最小单元的都不去了解,仅仅着眼于自己插入的那几行,希望在那几行中解决/hack掉所有问题,这是十分不可取的。
我认为,项目的迭代再快,其代码质量和可读性都应当有一个底线。这个底线是,当我们在修改代码的时候,应当完整了解当前修改的这个函数的逻辑,然后修改这个函数,以达到添加功能的目的。注意,这里的「修改一个函数」和「在函数某个位置添加几行代码」是不同的,在「修改一个函数」的时候,为了保证函数功能独立,逻辑清晰,不应该畏惧在这个函数的任意位置增删代码。
函数
函数只做一件事情
有时,我们会自作聪明地写出一些很「通用」的函数。比如,我们有可能写出下面这样一个获取用户信息的函数 fetchUserInfo
:其逻辑是:
1) 当传入的参数是用户ID(字符串)时,返回单个用户数据;
2) 而传入的参数是用户ID的列表(数组)时,返回一个数组,其中的每一项是一个用户的数据。
async function fetchUserInfo(id) { |
这个函数能够做两件事:1)获取多个用户的数据列表;2)获取单个用户的数据。在项目的其他地方调用 fetchUserInfo
函数时,也许我们确实能感到「方便」了一些。但是,代码的读者一定不会有相同的体会,当读者在某处读到 fetchUserInfo(['1011', '1013'])
这句调用的代码时,他就会立刻对 fetchUserInfo
产生「第一印象」:这个函数需要传入用户ID数组;当他读到另外一种调用形式时,他一定会怀疑自己之前是不是眼睛花了。读者并不了解背后的「潜规则」,除非规则是预先设计好并且及时地更新到文档中。总之,我们绝不该一时兴起就写出上面这种函数。
遵循一个函数只做一件事的原则,我们可以将上述功能拆成两个函数fetchMultipleUser
和 fetchSingleUser
来实现。在需要获取用户数据时,只需要选择调用其中的一个函数。
async function fetchMultipleUser(idList) { |
上述改良不仅改善了代码的可读性,也改善了可维护性。举个例子,假设随着项目的迭代,获取单一用户信息的需求不再存在了。
-
如果是改良前,我们会删掉那些「传入单个用户ID来调用
fetchUserInfo
」的代码,同时保留剩下的那些「传入多个用户ID调用fetchUserInfo
」的代码, 但是fetchUserInfo
函数几乎一定不会被更改。这样,函数内部isSingle
为true
的分支,就留在了代码中,成了永远都不会执行的「脏代码」,谁愿意看到自己的项目中充斥着永远不会执行的代码呢? -
对于改良后的代码,我们(也许借助IDE)能够轻松检测到
fetchSingleUser
已经不会被调用了,然后放心大胆地直接删掉这个函数。
那么,如何界定某个函数做的是不是一件事情?我的经验是这样:如果一个函数的参数仅仅包含输入数据(交给函数处理的数据),而没有混杂或暗含有指令(以某种约定的方式告诉函数该怎么处理数据),那么函数所做的应当就是一件事情。比如说,改良前的 fetchUserInfo
函数的参数是「多个用户的ID数组或单个用户的ID」,这个「或」字其实就暗含了某种指令。
函数应适当地处理异常
有时候,我们会陷入一种很不好的习惯中,那就是,总是去尝试写出永远不会报错的函数。我们会给参数配上默认值,在很多地方使用 ||
或者 &&
来避免代码运行出错,仿佛如果你的函数报错会成为某种耻辱似的。而且,当我们尝试去修复一个运行时报错的函数时,我们往往倾向于在报错的那一行添加一些兼容逻辑来避免报错。
举个例子,假设我们需要编写一个获取用户详情的函数,它要返回一个完整的用户信息对象:不仅包含ID,名字等基本信息,也包含诸如「收藏的书籍」等通过额外接口返回的信息。这些额外的接口也许不太稳定:
async function getUserDetail(id) { |
假设 fetchUserFavorites
会时不时地返回 undefined
,那么读取其 books
属性自然就会报错。为了修复该问题,我们很可能会这样做:
const favorites = await fetchUserFavorits(id); |
这样做看似解决了问题:的确,getUserDetail
不会再报错了,但同时埋下了更深的隐患。
当 fetchUserFavorites
返回 undefined
时,程序已经处于一种异常状态了,我们没有任何理由放任程序继续运行下去。试想,如果后面的某个时刻(比如用户点击「我收藏的书」选项卡),程序试图遍历 user.favoriteBooks
属性(它被赋值成了undefined
),那时也会报错,而且那时排查起来会更加困难。
如何处理上述的情况呢?我认为,如果被我们依赖的 fetchUserFavorits
属于当前的项目,那么 getUserDetail
对此报错真的没什么责任,因为 fetchUserFavorits
就不应该返回 undefined
,我们应该去修复 fetchUserFavorits
,任务失败时显式地告知出来,或者直接抛出异常。同时,getUserDetail
稍作修改:
// 情况1:显式告知,此时应认为获取不到收藏数据不算致命的错误 |
那么如果 fetchUserFavorits
不在当前项目中,而是依赖的外部模块呢?我认为,这时你就该为选择了这样一个不可靠的模块负责,在 getUserDetail
中增加一些「擦屁股」代码,来避免你的项目的其他部分受到侵害。
const favorites = await fetchUserFavorits(id); |
控制函数的副作用
无副作用的函数,是不依赖上下文,也不改变上下文的函数。长久依赖,我们已经习惯了去写「有副作用的函数」,毕竟 JavaScript 需要通过副作用去操作环境的 API 完成任务。这就导致了,很多原本可以用纯粹的、无副作用的函数完成任务的场合,我们也会不自觉地采取有副作用的方式。
虽然看上去有点可笑,但我们有时候就是会写出下面这样的代码!
async function getUserDetail(id) { |
上面,addFavoritesToUser
函数就是一个「有副作用」的函数,它改变了 users
,给它新增了几个个字段。问题在于,仅仅阅读 getUserData
函数的代码完全无法知道,user 会发生怎样的改变。
一个无副作用的函数应该是这样的:
async function getUserDetail(id) { |
总结
伟大的文学作品都是建立在废纸堆上的,不断删改作品的过程有助于写作者培养良好的「语感」。当然,代码毕竟不是艺术品,程序员没有精力也不一定有必要像作家一样反复打磨自己的代码/作品。但是,如果我们能够在编写代码时稍稍多考虑一下实现的合理性,或者在添加新功能的时候稍稍回顾一下之前的实现,我们就能够培养出一些「代码语感」。这种「代码语感」会非常有助于我们写出高质量的可读的代码。
源地址:http://taobaofed.org/blog/2017/01/05/writing-readable-code/