编写可读的代码,对于以代码谋生的程序员而言,是一件极为重要的事。从某种角度来说,代码最重要的功能是能够被阅读,其次才是能够被正确执行。一段无法正确执行的代码,也许会使项目延期几天,但它造成的危害只是暂时和轻微的,毕竟这种代码无法通过测试并影响最终的产品;但是,一段能够正确执行,但缺乏条理、难以阅读的代码,它造成的危害却是深远和广泛的:这种代码会提高产品后续迭代和维护的成本,影响产品的稳定,破坏团队的团结(雾),除非我们花费数倍于编写这段代码的时间和精力,来消除它对项目造成的负面影响。
在最近的工作和业余生活中,我对「如何写出可读的代码」这个问题颇有一些具体的体会,不妨记录下来吧。
JavaScript 是动态和弱类型的语言,使用起来比较「轻松随意」,在 IE6 时代,轻松随意的习惯确实不是什么大问题,反而能节省时间,提高出活儿的速度。但是,随着当下前端技术的快速发展,前端项目规模的不断膨胀,以往那种轻松随意的编码习惯,已经成为项目推进的一大阻力。
这篇文章讨论的是 ES6/7 代码,不仅因为 ES6/7 已经在大部分场合替代了 JavaScript,还因为 ES6/7 中的很多特性也能帮助我们改善代码的可读性。
变量命名
变量命名是编写可读代码的基础。只有变量被赋予了一个合适的名字,才能表达出它在环境中的意义。
命名必须传递足够的信息,形如 getData 这样的函数命名就没能提供足够的信息,读者也完全无法猜测这个函数会做出些什么事情。而 fetchUserInfoAsync 也许就好很多,读者至少会猜测出,这个函数大约会远程地获取用户信息;而且因为它有一个 Async 后缀,读者甚至能猜出这个函数会返回一个 Promise 对象。
命名的基础
通常,我们使用名词来命名对象,使用动词来命名函数。比如:
monkey.eat(banana); // the money eats a banana const apple = pick(tree); // pick an apple from the tree
这两句代码与自然语言(右侧的注释)很接近,即使完全不了解编程的人也能看懂大概。
有时候,我们需要表示某种集合概念,比如数组或哈希对象。这时可以通过名词的复数形式来表示,比如用 bananas 表示一个数组,这个数组的每一项都是一个 banana。如果需要特别强调这种集合的形式,也可以加上 List 或 Map 后缀来显式表示出来,比如用 bananaList 表示数组。
有些单词的复数形式和单数形式相同,有些不可数的单词没有复数形式(比如 data,information),这时我也会使用 List 等后缀来表示集合概念。
命名的上下文
变量都是处在上下文(作用域)之内,变量的命名应与上下文相契合,同一个变量,在不同的上下文中,命名可以不同。举个例子,假设我们的程序需要管理一个动物园,程序的代码里有一个名为 feedAnimals 的函数来喂食动物园中的所有动物:
function feedAnimals(food, animals) { // ... // 上下文中有 bananas, peaches, monkey 变量 const banana = bananas.pop(); if (banana) { monkey.eat(banana); } else { const peach = peaches.pop(); monkey.eat(peach); } // ... }
负责喂食动物的函数 feedAnimals 函数的主要逻辑就是:用各种食物把动物园里的各种动物喂饱。也许,每种动物能接受的食物种类不同,也许,我们需要根据各种食物的库存来决定每种动物最终分到的食物,总之在这个上下文中,我们需要关心食物的种类,所以传给 money.eat 方法的实参对象命名为 banana 或者 peach,代码很清楚地表达出了它的关键逻辑:「猴子要么吃香蕉,要么吃桃子(如果没有香蕉了)」。我们肯定不会这样写:
// 我们不会这样写 const food = bananas.pop(); if(food) { monkey.eat(food); } else { const food = peaches.pop(); monkey.eat(food); }
Monkey#eat 方法内部就不一样了,这个方法很可能是下面这样的(假设 eat 是 Monkey 的基类 Animal 的方法):
class Animal{ // ... eat(food) { this.hunger -= food.energy; } // ... } class Monkey extends Animal{ // ... }
如代码所示,「吃」这个方法的核心逻辑就是根据食物的能量来减少动物(猴子)自身的饥饿度,至于究竟是吃了桃子还是香蕉,我们不关心,所以在这个方法的上下文中,我们直接将表示食物的函数形参命名为 food。
想象一下,假设我们正在编写某个函数,即将写一段公用逻辑,我们会选择去写一个新的功能函数来执行这段公用逻辑。在编写这个新的功能函数过程中,往往会受到之前那个函数的影响,变量的命名也是按照其在之前那个函数中的意义来的。虽然写的时候不感觉有什么阻碍,但是读者阅读的单元是函数(他并不了解之前哪个函数),会被深深地困扰。
严格遵循一种命名规范的收益
如果你能够时刻按照某种严格的规则来命名变量和函数,还能带来一个潜在的好处,那就是你再也不用记住哪些之前命名过(甚至其他人命名过)的变量或函数了。特定上下文中的特定含义只有一种命名方式,也就是说,只有一个名字。比如,「获取用户信息」这个概念,就叫作 fetchUserInfomation,不管是在早晨还是傍晚,不管你是在公司还是家中,你都会将它命名为 fetchUserInfomation 而不是 getUserData。那么当你再次需要使用这个变量时,你根本不用翻阅之前的代码或依赖 IDE 的代码提示功能,你只需要再命名一下「获取用户信息」这个概念,就可以得到 fetchUserInfomation 了,是不是很酷?
分支结构
分支是代码里最常见的结构,一段结构清晰的代码单元应当是像二叉树一样,呈现下面的结构。
if (condition1) { if (condition2) { ... } else { ... } } else { if (condition3) { ... } else { ... } }
这种优美的结构能够帮助我们在大脑中迅速绘制一张图,便于我们在脑海中模拟代码的执行。但是,我们大多数人都不会遵循上面这样的结构来写分支代码。以下是一些常见的,在我看来可读性比较差的分支语句的写法:
不好的做法:在分支中 return
function foo() { if (condition) { // 分支1的逻辑 return; } // 分支2的逻辑 }
这种分支代码很常见,而且往往分支 2 的逻辑是先写的,也是函数的主要逻辑,分支 1 是后来对函数进行修补的过程中产生的。这种分支代码有一个很致命的问题,那就是,如果读者没有注意到分支1中的 return(我敢保证,在使用 IDE 把代码折叠起来后,没人能第一时间注意到这个 return),就不会意识到后面一段代码(分支 2)是有可能不会执行的。我的建议是,把分支 2 放到一个 else 语句块中,代码就会清晰可读很多:
function foo() { if (condition) { // 分支 1 的逻辑 } else { // 分支 2 的逻辑 } }
如果某个分支是空的,我也倾向于留下一个空行,这个空行明确地告诉代码的读者,如果走到这个 else,我什么都不会做。如果你不告诉读者,读者就会产生怀疑,并尝试自己去弄明白。
不好的做法:多个条件复合
if (condition1 && condition2 && condition3) { // 分支1:做一些事情 } else { // 分支2:其他的事情 }
这种代码也很常见:在若干条件同时满足(或有任一满足)的时候做一些主要的事情(分支1,也就是函数的主逻辑),否则就做一些次要的事情(分支2,比如抛异常,输出日志等)。虽然写代码的人知道什么是主要的事情,什么是次要的事情,但是代码的读者并不知道。读者遇到这种代码,就会产生困惑:分支2到底对应了什么条件?
在上面这段代码中,三种条件只要任意一个不成立就会执行到分支 2,但这其实本质上是多个分支:1)条件 1 不满足,2)条件 1 满足而条件 2 不满足,3)条件 1 和 2 都满足而条件 3 不满足。如果我们笼统地使用同一段代码来处理多个分支,那么就会增加阅读者阅读分支 2 时的负担(需要考虑多个情况)。更可怕的是,如果后面需要增加一些额外的逻辑(比如,在条件 1 成立且条件 2 不成立的时候多输出一条日志),整个 if-else 都可能需要重构。
对这种场景,我通常这样写:
if (condition1) { if (condition2) { // 分支1:做一些事情 } else { // 分支2:其他的事情 } } else { // 分支3:其他的事情 }
即使分支 2 和分支 3 是完全一样的,我也认为有必要将其分开。虽然多了几行代码,收益却是很客观的。
万事非绝对。对于一种情况,我不反对将多个条件复合起来,那就是当被复合的多个条件联系十分紧密的时候,比如 if(foo && foo.bar)。
不好的做法:使用分支改变环境
let foo = someValue; if (condition) { foo = doSomethingTofoo(foo); } // 继续使用 foo 做一些事情
这种风格的代码很容易出现在那些屡经修补的代码文件中,很可能一开始是没有这个 if 代码块的,后来发现了一个 bug,于是加上了这个 if 代码块,在某些条件下对 foo 做一些特殊的处理。如果你希望项目在迭代过程中,风险越积越高,那么这个习惯绝对算得上「最佳实践」了。
事实上,这样的「补丁」积累起来,很快就会摧毁代码的可读性和可维护性。怎么说呢?当我们在写下上面这段代码中的 if 分支以试图修复 bug 的时候,我们内心存在这样一个假设:我们是知道程序在执行到这一行时,foo 什么样子的;但事实是,我们根本不知道,因为在这一行之前,foo 很可能已经被另一个人所写的尝试修复另一个 bug 的另一个 if 分支所篡改了。所以,当代码出现问题的时候,我们应当完整地审视一段独立的功能代码(通常是一个函数),并且多花一点时间来修复他,比如:
const foo = condition ? doSomethingToFoo(someValue) : someValue;
我们看到,很多风险都是在项目快速迭代的过程中积累下来的。为了「快速」迭代,在添加功能代码的时候,我们有时候连函数这个最小单元的都不去了解,仅仅着眼于自己插入的那几行,希望在那几行中解决/hack掉所有问题,这是十分不可取的。
我认为,项目的迭代再快,其代码质量和可读性都应当有一个底线。这个底线是,当我们在修改代码的时候,应当完整了解当前修改的这个函数的逻辑,然后修改这个函数,以达到添加功能的目的。注意,这里的「修改一个函数」和「在函数某个位置添加几行代码」是不同的,在「修改一个函数」的时候,为了保证函数功能独立,逻辑清晰,不应该畏惧在这个函数的任意位置增删代码。
函数
函数只做一件事情
有时,我们会自作聪明地写出一些很「通用」的函数。比如,我们有可能写出下面这样一个获取用户信息的函数 fetchUserInfo:其逻辑是:
- 当传入的参数是用户ID(字符串)时,返回单个用户数据;
- 而传入的参数是用户ID的列表(数组)时,返回一个数组,其中的每一项是一个用户的数据。
async function fetchUserInfo(id) { const isSingle = typeof idList === 'string'; const idList = isSingle ? [id] : id; const result = await request.post('/api/userInfo', {idList}); return isSingle ? result[0] : result; }
// 可以这样调用 const userList = await fetchUserInfo(['1011', '1013']); // 也可以这样调用 const user = await fetchUserInfo('1017');
这个函数能够做两件事:1)获取多个用户的数据列表;2)获取单个用户的数据。在项目的其他地方调用 fetchUserInfo 函数时,也许我们确实能感到「方便」了一些。但是,代码的读者一定不会有相同的体会,当读者在某处读到 fetchUserInfo(['1011', '1013']) 这句调用的代码时,他就会立刻对 fetchUserInfo 产生「第一印象」:这个函数需要传入用户ID数组;当他读到另外一种调用形式时,他一定会怀疑自己之前是不是眼睛花了。读者并不了解背后的「潜规则」,除非规则是预先设计好并且及时地更新到文档中。总之,我们绝不该一时兴起就写出上面这种函数。
遵循一个函数只做一件事的原则,我们可以将上述功能拆成两个函数fetchMultipleUser 和 fetchSingleUser 来实现。在需要获取用户数据时,只需要选择调用其中的一个函数。
async function fetchMultipleUser(idList) { return await request.post('/api/users/', {idList}); }
async function fetchSingleUser(id) { return await fetchMultipleUser([id])[0]; }
上述改良不仅改善了代码的可读性,也改善了可维护性。举个例子,假设随着项目的迭代,获取单一用户信息的需求不再存在了。
如果是改良前,我们会删掉那些「传入单个用户ID来调用 fetchUserInfo」的代码,同时保留剩下的那些「传入多个用户ID调用 fetchUserInfo」的代码, 但是 fetchUserInfo 函数几乎一定不会被更改。这样,函数内部 isSingle 为 true 的分支,就留在了代码中,成了永远都不会执行的「脏代码」,谁愿意看到自己的项目中充斥着永远不会执行的代码呢?
对于改良后的代码,我们(也许借助IDE)能够轻松检测到 fetchSingleUser 已经不会被调用了,然后放心大胆地直接删掉这个函数。
那么,如何界定某个函数做的是不是一件事情?我的经验是这样:如果一个函数的参数仅仅包含输入数据(交给函数处理的数据),而没有混杂或暗含有指令(以某种约定的方式告诉函数该怎么处理数据),那么函数所做的应当就是一件事情。比如说,改良前的 fetchUserInfo 函数的参数是「多个用户的ID数组或单个用户的ID」,这个「或」字其实就暗含了某种指令。