编写自文档化JavaScript的15种方法
原文:15 Ways to Write Self-documenting JavaScript
在代码里发现不合适和没用的注释不是很滑稽吗?
这是个很容易犯的错误:你改动了代码,却忘了删除或者更新注释。不好的注释不会让你的代码崩溃,但是想想调试的时候回发生什么吧。你看着注释,注释在说一件事,代码却在说另一件事。你很可能最后要浪费时间搞明白它,最糟的情况下,它还会误导你!
但是写零注释的代码也不是个好选择。以我超过15年的编程经验来看,我从未见过完全不需要注释的代码库。
不过,有办法减少对注释的需要。我们可以利用特定的编码技术让代码更清晰,简单地说就是利用编程语言的特性。
这不仅让我们的代码更容易理解,而且从整体上改进了程序的设计!
这种代码通常叫做自文档化。现在让我来告诉你如何使用这个方法来写代码。尽管这些例子都是用JavaScript写的,你也可以将大部分的技术应用到其他语言。
技术概览
有些程序员把引入注释作为自文档化代码的一部分。在这篇文章中,我们只关注代码。注释很重要,但这是一个需要另外单独讨论的大话题。
我们可以把自文档化的代码技术分为三大类:
- 结构化,用代码的结构或目录说明意图
- 命名相关,比如函数或变量命名
- 语法相关,我们使用(或避免使用)语言的特性来澄清代码。
这些大多说起来简单。挑战在于知道什么时候使用什么技术。在展开每一种类型时我会展示一些实用的例子给你看。
结构化
首先让我们来看结构化这一类。结构上的改动涉及为了加强清晰性而移动代码位置。
把代码移到函数内
这跟“提取函数”重构是一样的——也就是把现有代码移动到新的函数内:我们把代码“提取”出来放进新的函数里。
例如,猜猜下面这行代码要做什么:
var width = (value - 0.5) * 16;
不是很明确;在这加个注释会非常有用。或者,我们可以提取一个函数,让它变得自文档化:
var width = emToPixels(value);
function emToPixels(ems) {
return (ems - 0.5) * 16;
}
唯一的改动就是我把计算移到了函数内。函数的名字也描述了它做的事情,因此代码也无需说明了。还有个额外的好处,我们现在有了一个可以在其他地方使用的有用的助手函数,所以这个方法也有助于减少重复。
把条件表达式换成函数
包含多个操作数的If语句通常难以理解,如果没有注释的话。我们可以采用跟上面类似的办法来说明:
if(!el.offsetWidth || !el.offsetHeight) {
}
上面的条件有什么意图?
function isVisible(el) {
return el.offsetWidth && el.offsetHeight;
}
if(!isVisible(el)) {
}
同样,我们把代码移到了函数内,代码立即变得好理解多了。
把表达式换成变量
用变量替换某些东西跟移动代码到函数内是类似的,但我们只是简单地用一个变量,而不是函数。
我们再来看下这个if语句的例子:
if(!el.offsetWidth || !el.offsetHeight) {
}
不用提取函数,我们也可以通过引入一个变量来说明:
var isVisible = el.offsetWidth && el.offsetHeight;
if(!isVisible) {
}
有时候这是个比提取函数更好的选择——比如,当你要说明的逻辑只针对特定的只在一个地方使用的算法。
这个方法最普遍的用途是在数学表达式上:
return a * b + (c / d);
我们可以拆分计算过程来说明代码:
var multiplier = a * b;
var divisor = c / d;
return multiplier + divisor;
因为我数学很糟,想象下如果上面的例子包含一些有意义的算法(该有多恐怖)。无论如何,这么做的意义就是,你可以把复杂的表达式放到变量里,这样就给很难看懂的代码附加了含义。
类和模块接口
类或模块的接口——也就是公有方法和属性——可以充当自身使用方法的文档。
我们来看个例子:
class Box {
setState(state) {
this.state = state;
}
getState() {
return this.state;
}
}
这个类可能还包括其他代码。为了说明公有接口如何充当文档,我有意简化了例子。
你能看出这个类怎么用吗?或许花点工夫可以做到,但它不是特别明显。
两个函数的名字都很合理:从它们的名字可以看出用途。但尽管如此,该如何使用它们并不是很明确。很可能你需要查看这个类的更多的代码或文档才能搞清楚。
如果我们把它改成这样会如何:
class Box {
open() {
this.state = 'open';
}
close() {
this.state = 'closed';
}
isOpen() {
return this.state === 'open';
}
}
更容易看出用法了,不是吗?注意,我们只改动了公有接口;内部表述跟this.state
是一样的。
现在你一眼就可以看出如何使用Box
类。这表明即便第一个版本有好的函数命名,整个package还是令人疑惑,而加上如此简单的决定,却可以产生重大影响。你需要经常思考全局。
代码分组
给不同部分的代码分组也是一种形式的文档化。
例如,你应该总是把变量定义在越靠近使用它的地方越好,并且把变量的使用分组在一起。
这可以用来指示代码不同部分之间的关系,以便将来任何人在修改的时候更容易找到同时需要修改的地方。
考虑下这个例子:
var foo = 1;
blah()
xyz();
bar(foo);
baz(1337);
quux(foo);
你能一眼看出foo
被调用了几次吗?对比下这个:
var foo = 1;
bar(foo);
quux(foo);
blah()
xyz();
baz(1337);
把foo
所有的调用都分组在一块,我们可以轻易地看出代码的哪些部分依赖于它。
使用纯函数
纯函数比依赖状态的函数更容易理解。
什么是纯函数?当用同样的参数调用一个函数的时候,它总是产生相同的输出,它很可能就是所谓的“纯”函数。这意味着函数不能有副作用或依赖于状态——比如时间、对象属性、Ajax等等。
这种类型的函数更容易理解,因为任何影响输出的值都被显式地传入。你不必到处查看某个东西的来源,或者什么会影响结果,因为这都是显而易见的。
这种类型的函数有助于编写更加自文档化的代码的另一个原因是,你可以信任它们的输出。无论如何,函数的返回只基于你给它的参数。它也不会
一个较好的反面例子是document.write()
。有经验的JS开发者知道不应该使用它,但是很多初学者在上面栽跟头了。有时候它工作得很好——但有时候在特定条件下,它会把整个页面清空。这就是副作用!
为了更好地概览什么是纯函数,可以看看这篇文章函数式编程:纯函数
目录和文件结构
当给文件和路径命名的时候,遵循项目的命名约定。如果项目没有明确的约定,那么就遵循选用语言的标准。
例如,如果你要添加新的UI相关的代码,你就要找到类似功能在项目里的位置。如果UI相关的代码放在src/ui/
,你应该也这么做。
这样会更容易地基于所知道的项目其他部分代码找到相关代码和表明意图。所有UI代码都在一块,毕竟它必须是UI相关的。
命名
关于计算机科学的两大难题有句名言:
计算机科学只有两大难题:缓存校验和命名。——Phil Karlton
因此让我们看看如何利用命名来让代码自文档化。
函数重命名
给函数命名通常不难,不过你可以遵守几条简单的规则:
- 避免使用含糊的词语,如“handle”或“manage”:
handleLinks()
,manageObjects()
。它们分别是做什么的? - 使用动作动词:
cutGrass()
,sendFile()
,这是些主动执行某些动作的函数 - 指明返回值:
getMagicBullet()
,readFile()
。你不能总是这么做,但在需要的地方就很有用。 - 强类型语言也可以利用类型签名来指明返回值。
变量重命名
关于变量,有两条好的经验法则:
- 指明数量单位:如果有数字类型的参数,你可以带上期望的数量单位。例如,使用
widthPx
而不是width
,这就指明变量值使用像素而不是其他单位。 - 不要使用缩写:
a
或b
是不可接受的名称,除非用作循环的计数器。
遵循既定的命名约定
在代码里尽量遵循相同的命名约定。例如,如果你有个特定类型的对象,就用一样的名字:
var element = getElement();
不要突然改叫node
var node = getElement();
如果你在代码库的其他地方遵守相同的约定,阅读代码的任何人可以根据它在其他地方的含义对它的含义做出安全的假设。
使用有意义的错误
Undefined不是对象!
这是所有人的最爱。我们不要跟着JavaScript的例子走,我们要确保代码抛出的任何错误都是有含义的。
什么能让错误消息有意义?
- 它应该描述问题是是什么
- 如果可能,它应该包含引起错误的任何变量值或数据
- 关键点:错误应该能帮助我们发现那里出问题了——因此可以作为文档来说明函数是怎样工作的。
语法
自文档化的代码中语法相关的方法跟具体语言相关了。例如,Ruby和Perl允许你使用各种语法技巧,而总的来说,这是应该避免的。
让我们来看看JavaScript里会发生什么。
不要使用语法技巧
不要使用奇怪的技巧。这行代码就很能迷惑人:
imTricky && doMagic();
它跟这个合理长相的代码是等价的:
if(imTricky) {
doMagic();
}
永远推荐后者。语法技巧不会给人和人带来任何帮助。
使用命名常量,避免魔法值
如果你在代码里有特殊的值——比如数字或字符串值——考虑用常量。尽管现在它看起来很明确,通常情况下一两个月再回头看,没人知道为什么这个特定的数字会出现在这。
const MEANING_OF_LIFE = 42;
(如果你不用ES6,你可以用个var
,同样可以运行得很好)
避免布尔标识
布尔标识会造成难以理解的代码。看看这个:
myThing.setData({ x: 1 }, true);
true
的含义是什么?你肯定不知道,除非你深入阅读setData()
的源码然后找到它。
相反,你可以添加另一个函数,或重命名现有函数:
myThing.mergeData({ x: 1 });
现在你可以立刻知道它在做什么。
使用语言特性
我们甚至可以利用语言的一些特性来更好地了解代码背后的意图。
JavaScript里一个好例子就是数组遍历方法:
var ids = [];
for(var i = 0; i < things.length; i++) {
ids.push(things[i].id);
}
以上代码把一列ID收集到新数组中。然而,为了清楚这一点,我们需要看完整个循环体。对比下map()
:
var ids = things.map(function(thing) {
return thing.id;
});
在这个例子中,我们马上知道它会产生一个新数组,因为这就是map()
的目的。这非常有利,尤其是你有更多复杂的循环逻辑的时候。MDN上有一个遍历函数列表。
JavaScript中另外一个例子是const
关键字。
通常,你声明一个应该永远不会改变的变量。一个常见的例子是用CommonJS加载模块的时候。
var async = require('async');
我们可以让从不改变的意图更加明确:
const async = require('async');
额外的好处是,如果有人不小心尝试改变它,我们会得到一个出错提示。
反模式
有了这么多方法,你可以做得很好了。然而,还有些事情你需要注意……
为了简短的函数而提取代码
有些人提倡使用超小函数,如果你把所有东西都提取出去,就达到目的了。然而这会导致代码让人难以理解。
例如,假设你在调试代码。你在看函数a()
。然后,你发现它用了b()
,而b
又用了c()
。诸如此类。
短函数确实不错,又很容易理解,不过如果你只在一个地方使用该函数,考虑“把表达式换成变量”这个方法。
不要勉强
通常,做这件事没有绝对正确的方式。因此,如果某些想法不是太妙,不要勉强。
结论
为了提高代码的可维护性,让你的代码自文档化有很长的路要走。每一个注释都是需要维护的额外负担,所以尽可能地去掉注释是比较好的做法。
然而,代码自文档化并不是要取代文档或注释。例如,由于代码的意图表达能力所限,你还是需要好的注释。对库来说API文档也非常重要,因为阅读源码不太可行,除非你的库非常小。