出其不意
1920年,William Strunk Jr的《英文写作指南》出版了,这本书给英语的风格定下了一个规范,而且已经沿用至今。代码其实也可以使用相似的方法加以改进。
本文接下来的部分是一些指导方针,不是一成不变的法律。如果能够清晰解释代码含义,当然有很多的理由不这样做,但是,请保持警惕和自觉。他们能经过时间的检验也是有理由的:因为他们通常都是对的。偏离指南应该有好的理由,并不能简单因为突发奇想或者个人偏好就那么做。
基本上写作的基本准则的每一部分都能应用在代码上:
- 让段落成为文章的基本结构:每一段对应一个主题。
- 去掉无用的单词。 .
- 使用主动语态。
- 避免一连串松散的句子。
- 将相关的词语放在一起。
- 陈述句用主动语态。
- 平行的概念用平行的结构。
这些都可以用在我们的代码风格上。
- 让函数成为代码的基本单元。每个函数做一件事。
- 去掉无用的代码
- 使用主动语态
- 避免一连串松散结构的代码
- 把相关的代码放在一起。
- 表达式和陈述语句中使用主动语态。
- 用并行的代码表达并行的概念。
1、让函数成为代码的基本单元。每个函数做一件事。
软件开发的本质就是写作。我们把模块、函数、数据结构组合在一起,就有了一个软件程序。
理解如何编写函数并如何构建它们,是软件开发者的基本技能。
模块是一个或多个函数或数据结构的简单集合,数据结构是我们如何表示程序的状态,但在没有应用函数,数据结构自身不会发生什么有趣的事情。
JavaScript有三种类型的函数:
- 交流型函数:执行I/O的函数
- 功能型函数:一系列指令的合集
- 映射型函数:给一些输入,返回相应的输出
所有有用的程序都需要I / O,并且许多程序遵循一些程序顺序,但大多数函数应该像映射函数:给定一些输入,该函数将返回一些相应的输出。
一个函数做一件事:如果你的函数是I/O敏感,那么就不要把I/O和映射(计算)混杂在一起。如果你的函数是为了映射,那么就不要加入I/O。功能性的函数就违背了这条准则。功能性的函数还违背了另一条准则:避免把松散的句子写在一起。
理想的函数应该是一个简单的,确定的,纯粹功能函数。
- 给定相同的输入,返回相同的输出
- 没有副作用
参见《什么是纯粹的函数》
2. 去掉无用代码
刚健的文字是简练的。一句话应该不包含无用的词语,一段话没有无用的句子,正如作画不应该有多余的线条,一个机器没有多余的零件。这就要求作者尽量用短句子,避免罗列所有细节,在大纲里就列出主题,而不是什么都说。William Strunk,Jr.,《英文写作指南》
简练的代码在软件中也很重要,这是因为更多的代码让bug有了藏匿的空间。更少的代码=更少的含有bug的空间=更少bug。
简练的代码更清晰,是因为它有更高的信噪比:读者可以减少对的语法理解更多的了解它的意义。更少的代码=更少的语法噪音=更多信息的传递。
借用《英文写作指南》的一个词:简练的代码更有力。
function secret (message) { return function () { return message; } };
上面一段代码可以简化为:
const secret = msg => () => msg;
对于熟悉箭头函数(ES 2015年加入的新特性)的人来说,这段代码可读性增强了。它去掉了多余的语法:括号,function关键词,以及return返回值语句。
第一个版本包含了不必要的语法。对于熟悉箭头语法的人来说,括号,function关键词,和return语句都没有任何意义。它们存在只是因为还有很多人对ES6的新特性不熟悉。
ES6从2015年就是语言标准了。你应该熟悉它了。
去掉无用的变量
有时候我们倾向给一些实际不需要命名的变量命名。原因是人脑在可用的容量内只能存储有限的资源,并且每个变量都必须作为离散量子存储,占据了我们可用的不多的记忆空间。
因为这个原因,有经验的开发者都倾向减少不需要的变量命名。
比如,在大多数情况下,你应该去掉变量,只给创建一个返回值的变量。函数名应该能够提供足够多的信息以显示它的返回值。看下面的例子:
const getFullName = ({firstName, lastName}) => { const fullName = firstName + ' ' + lastName; return fullName; };
以及:
const getFullName = ({firstName, lastName}) => ( firstName + ' ' + lastName );
开发者常常用来减少变量的另一个做法是:利用函数组合以及Point-free 的风格。
Point-free 风格是指:定义函数时无需引用对其操作的参数。常用的point-free风格方式主要是curry和函数组合。
看一个使用curry的例子:
const add2 = a => b => a + b; // Now we can define a point-free inc() // that adds 1 to any number. const inc = add2(1); inc(3); // 4
现在看一下inc()函数。注意它并没有是有function关键词,或者=>语法。没有参数列表,因为这个函数内部并没有使用参数列表。相反的,它返回的是如何处理参数的一个函数。
下面我们看一下使用函数组合的例子。函数组合是把一个函数结果应用到另一个函数的处理流程。你可能没有意识到,你其实一直都在用函数组合。当你调用.map()
或者promise.then()
函数的时候,你就在使用它了。例如,它的大部分时候的基本形态,其实都像这样:f(g(x)).在代数中,这样的组合被写成:f ° g, 被称作“g后f”或者“f组合g”。
当你把两个函数组合在一起时,你就去掉了需要存储的中间返回值的变量。我们看一下下面这个可以更简单的代码:
const g = n => n + 1; const f = n => n * 2; // With points: const incThenDoublePoints = n => { const incremented = g(n); return f(incremented); }; incThenDoublePoints(20); // 42 // compose2 - Take two functions and return their composition const compose2 = (f, g) => x => f(g(x)); // Point-free: const incThenDoublePointFree = compose2(f, g); incThenDoublePointFree(20); // 42
使用仿函数也能实现类似的效果。使用仿函数也能实现类似的效果。下面这段代码就是使用仿函数的一个例子:
const compose2 = (f, g) => x => [x].map(g).map(f).pop(); const incThenDoublePointFree = compose2(f, g); incThenDoublePointFree(20); // 42
其实在你使用promise链时,基本上就是在用这个方法了。
实际上, 每个编程序库都至少有两个版本的实用方法:?compose ()
?把函数从右向左组合,pipe()
函数将函数从左向右组合。
Lodash把这两个函数称作compose()
和flow()
。当我在Lodash里使用它们时,一般都这样引入:
import pipe from 'lodash/fp/flow'; pipe(g, f)(20); // 42
然而,下面的代码更少,而且完成的了同样的事情
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x); pipe(g, f)(20); // 42
如果函数组合对你来说像外星人一样深不可测,而且你也不确定如何使用,那么请认真回顾一下前面的话:
软件开发的本质是写作。我们把模块、函数、数据结构组合在一起,就构成了软件程序。
由此你就可以得出结论:理解函数的工具意义和对象组合,就像是一个家庭手工劳动者要能理解如何使用钻子和钉子枪一样的基本技能。
当你用指令集和中间变量把不同函数组合在一起时,其实就像是用胶布和疯狂的胶水随意的把东西沾在一起。
请记住:
- 如果能用更少的代码表达相同的意思,且不改变或混淆代码含义,那就应该这样做。
- 如果可以使用更少变量达到相同目的,也不会改变或混淆原意,那也应该这样做。
3.使用主动语态
主动语态比被动语态更加直接、有力。 — William Strunk,Jr. 《英文写作指南》
命名越直接越好。
myFunction.wasCalled()
优于myFunction.hasBeenCalled()
。createUser()
?优于User.create()
。notify()
优于Notifier.doNotification()
。
命名断言或者布尔变量时尽量使用是或否的问题形式:
isActive(user)
优于getActiveStatus(user)。
isFirstRun = false;
优于firstRun = false;
。
命名函数使用动词形式
increment()
优于plusOne()
。unzip()
优于filesFromZip()
。filter(fn, array)
优于matchingItemsFromArray(fn,array)
。
事件处理
事件处理函数和生命周期的函数是个例外,要避免使用动词形式,因为他们通常是为了说明这时该做什么而不是他们作为主语自身要做了什么。功能应该和命名一致。
element.onClick(handleClick)
优于element.click(handleClick)
。component.onDragStart(handleDragStart)
优于component.startDrag(handleDragStart)。
这个例子里两种命名方法的第二种,看上去更像是我们尝试触发一件事,而不是对这个事件作出响应。
生命周期函数
假设有一个组件,有这样一个生命周期函数,在它更新之前要调用一个事件处理的函数,有以下几种命名方式:
componentWillBeUpdated(doSomething)
componentWillUpdate(doSomething)
componentWillUpdate(doSomething)
第一种命名使用被动语态。这种方式有点绕口,不如其他方式直观。
第二种方式稍好,但是给人的意思是这个生命周期方法要调用一个函数。componentWillUpdate(handler)
读起来就像是这个组件要更新一个事件处理程序,这就偏离了本意。我们的原意是:”在组件更新前,调用事件处理”beforeComponentUpdate()
这样命名更为恰当清晰。
还能更精简。既然这些都是方法,那么主语(也就是组件本身)其实已经确定了。调用这个方法时再带有主语就重复了。想象一下看到这段代码时,你会看到component.componentWillUpdate()
。这就像是在说“吉米,吉米中午要吃牛排”。你其实不需要听到重复的名字。
component.beforeUpdate(doSomething)
优于component.beforeComponentUpdate(doSomething)
Functional mixins?是把属性和方法添加到Object对象上的一种方法。函数一个接一个的组合添加在一起,就像是管道流一样,或者像组装线一样。每个functional mixin的函数都有一个instance
作为输入,把一些额外的东西附加上去,然后再传递给下一个函数,就像组装流水线一样。
我倾向用形容词命名mixin 函数。你也可以使用“ing”或者“able”之类的后缀来表示形容词的含义。例如:
const duck = composeMixins(flying,quacking);
const box = composeMixins(iterable,mappable);
4、避免一连串松散的语句
一连串的句子很快就会无聊冗长了。William Strunk,Jr.,《英文写作指南》
开发者其实常常讲一连串的事件连接成一整个处理过程:一系列松散的语句本来就为了一个接一个而设计存在的。但过度使用这样的流程会导致代码像意大利面一样错综复杂。
这种序列常常被重复,尽管会有些许的不同,有时还会出乎意料的偏离正规。例如,一个用户界面可能会和另外的用户界面共享了同样的组件代码。这样的代价就是代码可能被分到不同的生命周期里并且一个组件可能由多个不同的代码块进行管理。
参考下面这个例子:
const drawUserProfile = ({ userId }) => { const userData = loadUserData(userId); const dataToDisplay = calculateDisplayData(userData); renderProfileData(dataToDisplay); };
这段代码做了三件事:加载数据,计算相关状态,然后渲染内容。
在现代的前端应用框架中,这三件事是相互分离的。通过分离,每件事都可以得到比较好的组合或者扩展。
例如,我们可以完全替换渲染器,而不用影响其他部分;例如,React有丰富的自定义渲染器:适用于原生iOS和Android应用程序的ReactNative,WebVR的AFrame,用于服务器端渲染的ReactDOM / Server 等等。
另一个问题是你无法简单的计算要显示的数据并且如果没有第一次加载数据就无法生成显示页面。假如你已经加载了数据呢?那么你的计算逻辑就在接下来的调用中变的多余了。
分离也使得各个部件独立可测。我喜欢给自己的应用加很多单元测试,并且把测试结果显示出来,这样我有任何改动的时候都能看到。但是,如果我要尝试测试加载数据并渲染的功能,那我就不能只用一些假数据测试渲染部分。正在保存……
我无法通过单元测试立刻获得结果。函数分离却可以让我们能够进行独立的测试。
这个例子就已经说明,分离函数可以让我们能够参与到应用的不同生命周期中去。可以在应用加载组件后,触发数据的加载功能。计算和渲染可以在视图发生变化的时候进行。
这样的结果就是更清楚地描述了软件的责任:可以重用组件相同的结构以及生命周期的回调函数,性能也更好;在后面工作流程中,我们也节省了不必要的劳动。
5.把相关的代码放在一起。
很多框架或者样板程序都预设了一种程序的组织方法,那就是按照文件类型划分。如果你做一个小的计算器或者To Do的应用,这样做没问题;但是如果是大型项目,更好的办法是按功能对文件进行分组。
下面以一个To Do 应用为例,有两种文件组织结构。
按照文件类型分类
. ├── components │ ├── todos │ └── user ├── reducers │ ├── todos │ └── user └── tests ├── todos └── user
按照文件功能分类
. ├── todos │ ├── component │ ├── reducer │ └── test └── user ├── component ├── reducer └── test
按照功能组织文件,可以有效避免在文件夹视图中不断的上下滚动,直接去到功能文件夹就可以找到要编辑的文件了。
把文件按照功能进行组织。
6.陈述句和表达式使用主动语态。
“做出明确的断言。避免无聊、不出彩、犹豫、不可置否的语气。使用“not”时应该表达否定或者对立面的意思,而不要用来作为逃避的手段。”William Strunk,Jr., 《英文写作指南》。
isFlying
优于isNotFlying
。late
优于notOnTime
。
If语句
if (err) return reject(err); // do something...
比下面这种方式更好:
if (!err) { // ... do something } else { return reject(err); }
三元表达式
{ [Symbol.iterator]: iterator ? iterator : defaultIterator }
比下面的形式更好:
{ [Symbol.iterator]: (!iterator) ? defaultIterator : iterator }
尽量选择语气强烈的否定句
有时候我们只关系一个变量是否缺失,因此使用主动语法会让我们被迫加上一个!
。在这些情况下,不如使用语气强烈的否定句式。“not”这个词和!
的语气相对较弱。
if (missingValue)
优于if (!hasValue)
。if (anonymous)
优于if (!user)
。if (isEmpty(thing))
优于if (notDefined(thing))
。
函数调用时避免使用null和undefined参数类型
不要使用undefined
或者null
的参数作为函数的可选参数。尽量使用可选的Object
做参数。尽量使用可选的Object做参数。
const createEvent = ({ title = 'Untitled', description = '', timeStamp = Date.now() }) => // ... // later... const birthdayParty = createEvent({ title = 'Birthday Party', timeStamp = birthDay });
优于
const createEvent( title = 'Untitled', description = '', timeStamp = Date.now() ); // later... const birthdayParty = createEvent( 'Birthday Party', undefined, // This was avoidable birthDay );
6、使用平行结构
平行结构需要尽可能相似的结构表达语义。格式上的形似使得读者能够理解不同语句的意义也是相似的。- William Strunk,Jr., 《英文写作指南》
实际应用中,还有一些额外的问题没有解决。我们可能会重复的做同一件事情。这样的情况出现时,就有了抽象的空间。把相同的部分找出来,并抽象成可以在不同地方同时使用的公共部分。这其实就是很多框架或者功能库做的事情。
以UI控件为例来说。十几年以前,使用jQuery写出把组件、逻辑应用、网络I/O混杂在一起的代码还还很常见。然后人们开始意识到,我们可以在web应用里也使用MVC框架,于是人们逐渐开始把模型从UI更新的逻辑中分离出来。
最终的结构是:web应用使用了组件化模型的方法,这让我们可以用JSX或者HTML模板来构建我们的UI组件。
这就让我们能够通过相同的方式去控制不同组件的更新,而无需对每一个组件的更新写重复的代码。
熟悉组件化的人可以轻易的看到每个组件的工作原理:有一些代码是表示UI元素的声明性标记,也有一些用于事件处理程序和用在生命周期上的回调函数,这些回调函数在需要的时候会被执行。
当我们为相似的问题找到一种模式后,任何熟悉这个模式的人都能很快的理解这样的代码。
结论:代码要简洁,但不是简单化。
刚健的文字是简练的。一句话应该不包含无用的词语,一段话没有无用的句子,正如作画不应该有多余的线条,一个机器没有多余的零件。这就要求作者尽量用短句子,避免罗列所有细节,在大纲里就列出主题,而不是什么都说。-William Strunk,Jr.,《英文写作指南》
ES6在2015年是标准化的,但在2017年,许多开发人员避免了简洁的箭头功能,隐式回报,休息和传播操作等的功能。人们以编写更容易阅读的代码为借口,但只是因为人们更熟悉旧的模式而已。这是个巨大的错误。熟悉来自于实践,熟悉ES6中的简洁功能明显优于ES5的原因显而易见:相比厚重的语法功能的代码,这样的代码更简洁。
代码应该简洁,而不是简单化。
简洁的代码就是:
- 更少的bug
- 更加便于调试
bug通常是这样的:
- 修理起来耗时耗力
- 可能引入更多的bug
- 打乱正常的工作流程
所以简洁的代码应该要:
- 易写
- 易读
- 易维护
让开发者学会并使用新技术比如curry其实是值得的。这样做也是在让读者们熟悉新知识。如果我们还是依然用原来的做法,这也是对阅读代码人的不尊重,就好像在用成年人在和婴儿讲话时使用孩子的口吻一样。
我们可以假设读者不理解这段代码的实现,但请不要假设阅读代码的人都很笨,或者假设他们连这门语言都不懂。
代码应该简洁,而但不要掉价。掉价才是一种浪费和侮辱。要在实践中练习,投入精力去熟悉、学习一种新的编程语法、一种更有活力的风格。
代码应该简洁,而非简单化。