🙋🏻♀️ 编者按:本文作者是蚂蚁集团前端工程师梧阙,介绍了代码质量标准和设计原则,并从编码和设计的角度列举了一些常见的坏味道的代码,最后对坏味道的代码进行了重构,欢迎一起来探讨~
我们为什么需要高质量的代码?
- 你是否有过为了修改一处简单的功能,看了半天源代码而无从下手的时候?
- 你是否遇到过前端未正确处理异常,导致页面白屏的时候?
- 你是否遇到需要重构一大堆代码,却发现没有任何测试用例,只能靠人肉回归的时候?
这三个常见的场景分别对应代码质量衡量指标中的可维护性、鲁棒性和可测试性。因此代码质量并不是只是与维护者的心情息息相关,与软件的交付质量也是密不可分。
知名程序员如何看待高质量的代码
Bjarne Stroustrup
- C++语言发明者,《C++程序设计语言》作者
我喜欢优雅和高效的代码,代码逻辑应当直截了当,叫缺陷难以隐藏;尽量减少依赖关系,使之便于维护;依据某种分层战略完善错误处理代码;性能调至最优,省得引诱别人做没规矩的优化搞出一堆混乱来。整洁的代码只做好一件事。
Grady Booch
- UML 创始人,《面向对象分析与设计》作者
整洁的代码简单直接。整洁的代码如同优美的散文。整洁的代码从不隐藏设计者的意图,充满了干净利落的抽象和直截了当的控制语句。
Ward Cunningham
- Wiki 发明者,极限编程创始人之一
如果每个例程都让你感到深合己意,那就是整洁代码。如果代码让编程语言看上去像是专为解决那个问题而存在,就可以称之为漂亮的代码。
代码质量指标
➤ 可维护性
系统的可维护性是衡量一个系统的可修复(恢复)性和可改进性的难易程度。所谓可修复性是指在系统发生故障后能够排除(或抑制)故障予以修复,并返回到原来正常运行状态的可能性。而可改进性则是系统具有接受对现有功能的改进,增加新功能的可能性。
可读性
罗伯特·根宁公式
- 句子的形成。句子越单纯,其可读性越大。
- 迷雾系数 (Fog index)。这是指词汇抽象和艰奥难懂的程度。因此,迷雾系数越大,其可读性越小。
- 人情味成分。新闻中含人情味成分越多,其可读性越大。
新闻可读性的研究是随着西方报业竞争兴起的,其目的在于改进新闻写作,以求扩大发行量。除去第三点人情味成分,前两点对于改进代码的可读性也有着普适的借鉴意义。
名副其实
如果我们的命名需要使用注释来补充,那就不算是名副其实,例如下面这个例子:
let d; // 消逝的时间,以日计
我们应该指明了计量对象和计量单位的名称:
let elapsedTimeInDays; let daysSinceCreation;
避免误导
- 保证命名的类型准确性,例如是一个哈希表就不要使用 List 结尾,数组变量就不要使用单数形式的命名。
- 避免过于相似的命名: 例如 ControllerForEfficientHandlingOfStrings 和 ControllerForEfficientStorageOfStrings
- 避免无意义的区分: 例如 getActiveAccount() 和 getActiveAccountInfo()
- 保持语义一致: 例如多个类中都有 add 方法,该方法传入两个集合参数而获得一个新的集合。而现在新增加了一个类,有个函数是将单个参数放入集合中,用 add 来命名语义就不一致了,应当使用 append 之类的词来命名。
// 不要使用错误的类型命名 const accountList = {}; // 数组变量就不要使用单数形式的命名 const account = [];
可扩展性与可复用性
可扩展性在软件工程领域是指:设计良好的代码允许更多的功能在必要时可以被插入到适当的位置中。这样做的目的的是为了应对未来可能需要进行的修改,而造成代码被过度工程化地开发。
为了实现这一目标,灵活的设计和封装的细粒度必不可少。具体可以查看下文中的单一职责原则与开发-封闭原则一节。
➤ 鲁棒性(健壮性)
鲁棒是Robust的音译,也就是健壮和强壮的意思。它也是在异常和危险情况下系统生存的能力。比如说,计算机软件在输入错误、磁盘故障、网络过载或有意攻击情况下,能否不死机、不崩溃,就是该软件的鲁棒性。所谓“鲁棒性”,也是指控制系统在一定(结构,大小)的参数摄动下,维持其它某些性能的特性。根据对性能的不同定义,可分为稳定鲁棒性和性能鲁棒性。
稳定
程序的稳定性与边界条件和异常处理息息相关。对于边界条件的处理可以查看下文中可测试性与完整性一节。本节着重讲述一下防御性编程和异常处理的部分。
防御性编程
防御性编程,是防御式设计的一种具体体现,它是为了保证对程序的不可预见的使用不会造成程序功能上的损坏。
防御性编程的几大原则:
- 使用好的编码风格和合理的设计
- 不要仓促的编写代码:欲速则不达,想清楚你要输入的是什么,在写每一行时都三思而后行。可能会出现什么样的错误?你是否已经考虑了所有可能出现的逻辑分支?
- 不要相信任何人: 包括用户的输入和后端的返回值。一个残酷的事实是,他们不会永远给到前端程序正确的输入。
- 编码的目标要清晰,而不是简洁:不要为了炫技而使用一些可读性低的简洁写法,如何平衡简洁和可读性是一个程序员必修的课题。
举个具体的例子,例如后端返回值预期返回一个对象数组,但是返回了一个非空的空对象,这时我们的短路运算符其实并不能起到类型保护的作用:
// {} 非空,但是不存在 sort 方法 (result?.response?.data || []).sort
对于这样的数据, 我们可以使用 lodash 的 toArray 方法做一次类型转换来保证类型安全:
// toArray 转换后一定会存在 sort 方法 toArray(result?.response?.data).sort
异常处理
前端的异常处理主要需要着眼于异常发生时,尽可能的保证页面功能模块的可用,将异常的影响范围尽可能的缩小。
- 对于所有可能出现异常的代码,加上 try-catch 语句: 未被捕获的异常,自异常的那一行后续的代码都不会继续执行,影响面是不可控的。
- 加上 ErrorBoundary: ErrorBoundary 组件使得页面白屏时能够展示降级 (fallback) 组件,不管怎么说用户对于一个可交互的异常页的心理预期, 是远胜于一个空白页的。
- 对于视图中使用到的变量, 需要保证取值不会导致异常: 例如对一个字符串变量执行
s.length
操作,当 s 变量不存在 length 属性时,就会导致视图渲染异常。我们可以选择使用可选链来保证安全性s?.length
性能
通用优化
对于性能优化,既有一些行业通用的方法论,例如空间换时间、并行、减少计算量等。例如一些查找操作可以用哈希表来做时间复杂度的优化:
// 双层循环 newFields.forEach((field, index) => { const matchCol = newCols.find( (col) => col.search !== false && col.dataIndex === field.dataIndex, ); }); // 修改为单层循环, 查找的时间复杂度降低为 O(1) const colMap = newCols.reduce((a, c) => ({ if (col.search !== false) { a[c.dataIndex] = c; } return a; }), {}); newFields.forEach((field, index) => { const matchCol = colMap[field.dataIndex]; });
编程语言写法优化
同时也有一些编程语言写法上的性能优化, 对于 JavaScript 而言,我们可以使用一些在线 jsperf 网站测试不同写法的性能区别, 例如:Date.now()
与 +new Date()
两种不同的产生当前时间戳的写法,在笔者的 MacBook Pro (i7) 上使用 Chrome 101 版本执行效率大致有 56% 的差距。
前端优化
对于前端而言, 另一个主要的性能优化场景就是尽可能的减少组件的重新渲染,尤其是存在高昂计算成本的组件。以 React 为例,举一个最为常见的性能优化案例:
import { useState } from "react" export default function App() { let [color, setColor] = useState("red") return ( <div> <input value={color} onChange={e => setColor(e.target.value)} /> <p style={{ color }}>Hello, world!</p> <ExpensiveTree /> </div> ) } function ExpensiveTree() { let now = performance.now() while (performance.now() - now < 100) { // Artificial delay -- do nothing for 100ms } return <p>I am a very slow component tree.</p> }
- 在线示例
每次 color 改变都会导致重新渲染,白白浪费 100 ms 的渲染时间。因此我们需要缩小 color 这个状态变动导致 render 的影响范围:
export default function App() { return ( <> <Form /> <ExpensiveTree /> </> ) } function Form() { let [color, setColor] = useState("red") return ( <> <input value={color} onChange={e => setColor(e.target.value)} /> <p style={{ color }}>Hello, world!</p> </> ) }
- 在线示例
这样一来,color 的变化就不会导致和的重新渲染了。React 团队为了让我们将组件粒度拆的尽可能的细,不惜以性能为代价,真是用心良苦(雾)
下面我们再对上面的场景进行扩展,在顶层组件和子组件中都使用了状态 color ,但仍然不关心它:
import { useState } from "react" export default function App() { let [color, setColor] = useState("red") return ( <div style={{ color }}> <input value={color} onChange={e => setColor(e.target.value)} /> <ExpensiveTree /> <p style={{ color }}>Hello, world!</p> </div> ) }
这时我们可以将作为一个插槽传入包装后的顶层组件, color 的变化就不会导致的重新渲染了。
import { useState } from "react" export default function App() { return <ColorContainer expensiveTreeNode={<ExpensiveTree />}></ColorContainer> } function ColorContainer({ expensiveTreeNode }) { let [color, setColor] = useState("red") return ( <div style={{ color }}> <input value={color} onChange={e => setColor(e.target.value)} /> {expensiveTreeNode} <p style={{ color }}>Hello, world!</p> </div> ) }
➤ 可测试性与完整性
测试金字塔与单元测试
对于软件测试而言,存在着单元测试、集成测试、组件测试、E2E测试和探索性测试,越往后编写测试用例的成本就越高,因此用例数量也会随之减少。对于大多数的场景我们只要写好单元测试就可以满足我们的需求了。
集团的开发规约中描述了好的单测的特征, 被称为 AIR 原则
- A:Automatic(自动化):单元测试应该是全自动执行的,并且非交互式的。
- I:Independent(独立性):为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
- R:Repeatable(可重复):单元测试通常会被放到持续集成中,每次有代码 check in 时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
其中的 A 和 R 可以由我们的测试框架来保证,而独立性则需要我们自己保证。例如我们应当在测试套件的beforeEach 生命周期里做变量的初始化, 从而保证测试的独立性:
describe('province', function() { let asia; beforeEach(function() { asia = new Province(sampleProvinceData()); }); it('shortfall', function() { expect(asia.shortfall).equal(5); }); it('profit', function() { expect(asia.profit).equal(230); }); });
另外对于 React 组件的单元测试,读者可以翻阅 Bigfish 文档中的 React 单元测试入门 一文, 本文就不展开赘述。
完整性
好的测试用例是应当具备完整性的,包括功能测试、边界测试和反向(负面)测试的完整性。
功能测试
检验对于正确的输入以及极端情况的输入,程序运行的结果是否符合预期。
- 例如对于传入数字的场景传入一个超过32位的大数。
边界测试
检验是否对边界条件进行处理。下面是几种常见的边界条件:
- 递归的终止条件
- 循环的开始与终止条件
- 给定数字区间, 输入区间边缘的值
反向(负面)测试
检验对于错误的输入, 程序运行的结果是否符合预期。
- 要求非空的输入,传入一个
null
值 - 要求是数组的输入, 传入一个空对象
{}
设计原则
➤ SOLID
SOLID 是面向对象设计五个最为重要原则的首字母简写,是数十年软件工程经验来之不易的成果。
单一职责原则
The Single Responsibility Principle, 简称 SRP。这一设计原则的主要思想是一个模块只负责一个职责。
例如下面这个调制解调器的类就违反了 SRP:
class Modem { dial: (pno: string) => {} hangup: () => {} send: (s: string) => {} recv: () => {} }
它既负责了连接管理又负责了数据通信, 出于提高代码内聚性(模块组成元素之间的功能相关性),降低耦合性的考量,我们应该将其拆分为两个类, 如果真的有必要我们再将其组合成一个超类:
class Connection { dial: (pno: string) => {} hangup: () => {} } class DataChannel { send: (s: string) => {} recv: () => {} } // 如果真的有必要我们再将其组合成一个超类 class Modem extends Connection, DataChannel {}
开放-封闭原则
The Open-Closed Principle, 简称 OCP。这一设计原则的主要思想是拓展软件实体时(类、模块、函数等),不应当修改原本正常运行的代码。即对于拓展是开放的,对于更改是封闭的。
例如下面这个绘制所有形状的函数就违反了 OCP:
enum ShapeType { circle, square } interface ICircle { type: ShapeType; radius: number; } interface ISquare { type: ShapeType; size: number; } type IShape = ICircle | ISquare; function drawAllShapes(list: IShape[]) { for (const item of list) { switch (item.type) { case ShapeType.circle: return drawCircle(item); break; case ShapeType.square: drawSquare(item); break; } } }
每当我们需要新增一个形状的时候,我们都需要修改一次 drawAllShapes 函数,显然违反了"对于更改是封闭的"这一原则。
我们可以改变思路,将绘制函数内聚在各个形状之中,顺便为绘制所有形状的函数添加 hooks 以应对后续的拓展:
class Circle extends ICircle { draw() {} } class Square extends ISquare { draw() {} } type Shape = Circle | Square; interface IHook { beforeDraw: (list: Shape[]) => Shape[] afterDraw: (list: Shape[]) => Shape[] } function drawAllShapes(list: Shape[], hook: IHook) { // 添加 hooks list = hook?.beforeDraw?.(list); for (const item of list) { item.draw(); } list = hook?.afterDraw?.(list); }
里氏替换原则
The Liskov Substitution Principle, 简称 LSP。这一设计原则的主要思想是子类必须能够替换掉他的基类。
例如下面这个 Square 和 Rectangle 类的例子就违反了 LSP:
class Rectangle { private _w: number = 0; private _h: number = 0; setWidth(width: number) { this._w = width; } setHeight(height: number) { this._h = height; } getWidth() { return this._w; } getHeight() { return this._h; } } class Square extends Rectangle { setWidth(width: number): void { super.setWidth(width); super.setHeight(width); } setHeight(height: number): void { super.setWidth(height); super.setHeight(height); } }
Square 作为子类并不能替换掉基类 Rectangle, Square 在修改高度的时候会同时修改宽度以保持正方形的特性, 对于下面这个函数,传入一个矩形和正方形会导致不一样的运行结果:
function printWidth(shape: Rectangle) { shape.setHeight(32); console.log(shape.getWidth()); } printWidth(new Square()); // 32 printWidth(new Rectangle()); // 0
依赖倒置原则
The Dependency Inversion Principle, 简称 DIP。这一设计原则的主要思想是高层模块不应当依赖于低层模块,两者都应该依赖于抽象。
例如下面这个 Button 类就违反了 DIP:
class Button { private lamp: Lamp = new Lamp(); private status = false; poll() { this.status = !this.status; const method = this.status ? 'turnOn' : 'turnOff'; this.lamp[method](); } }
lamp 耦合在了 Button 中,这意味着 Lamp 类改变时,Button 类会受到影响。此外,想用 Button 类来控制一个 Motor 对象是不可能的。因此我们需要将 Lamp 类 抽象成一个 SwitchableDevice 类, 然后将 device 实例在实例化时动态注入:
class Button { private device: SwitchableDevice; private status = false; constructor(device: SwitchableDevice) { this.device = device; } poll() { this.status = !this.status; const method = this.status ? 'turnOn' : 'turnOff'; this.device[method](); } } const lampButton = new Button(new Lamp()); const motorButton = new Button(new Motor());
依赖倒置在后端生态中十分常见,接触过 Java 的同学应该都会听过依赖注入(Dependency Injection,简称DI)和控制反转(Inversion of Control, 简称 IoC)的概念,IoC 就是 DIP 在工程应用中的叫法,DI 就是具体实现 DIP 的方式。
接口隔离原则
The Interface Segregation Principle, 简称 ISP。 这一设计原则的主要思想是如果类的接口不是内聚的,就应该被拆解为多个。类似于接口(架构设计)层面的 SRP。
例如下面这个 IOrder 接口就违反了 ISP:
interface IOrder { // 申请 apply: () => void; // 审核 approve: () => void; // 结束 end: () => void; // 切换供应商 changeSupplier: () => void; // 切换门店 changeShop: () => void; }
上面的 apply、approve、end 还算是订单通用的接口,而 changeSupplier 显然是生产订单才会用到的接口,changeShop 销售订单才会用到。因此我们应该将其拆解开, 需要使用时可以利用多继承将它们组合到一起:
interface IOrder { // 申请 apply: () => void; // 审核 approve: () => void; // 结束 end: () => void; } interface IProductOrder extends IOrder { // 切换供应商 changeSupplier: () => void; } interface ISaleOrder extends IOrder { // 切换门店 changeShop: () => void; } // 产销订单 interface IProductSaleOrder extends IProductOrder, ISaleOrder { bindSupplierWithShop: () => void; }
➤ KISS
KISS 是“Keep it Simple and Stupid”的首字母简写,这个词最初被美国海军使用,后来被广泛应用于其他领域,包括软件工程。这一原则的主要思想是简洁而清晰的设计往往会带来更高的可维护性和可测试性。
坏味道的代码
讲完了代码质量指标和设计原则,下面从编码和设计的角度列举了一些常见的坏味道的代码:
➤ 编码
神秘命名
There are only two hard things in Computer Science: cache invalidation and naming things. -- Phil Karlton
命名作为计算机科学的两大难题之一,一直困扰着众多的程序员们。为了赶进度有些人可能会选择使用 a1, a2 这样随意的命名,这样的变量不加上注释对于维护者无疑是一场灾难,而一个好的命名显然无需注释就能让阅读者知道它的意义。而且如果你想不出一个好名字,背后往往潜藏着更深的设计问题。为一个恼人的名字所付出的纠结,常常能推动我们对代码进行精简。
重复代码与数据泥团
这样的坏味道在项目中是非常常见的,程序员们对于自己复制粘贴的行为总是可以找出各式各样的理由来推脱。但是重复代码是很难维护的,一旦有重复代码存在,阅读这些代码就得加倍仔细,留意其间细微的差异,这会给代码修改造成很大的困扰。你常常可以在很多地方看到相同的几项数据:两个类中相同的字段、许多函数签名中相同的参数, 这些数据项被形象的称为数据泥团。不同于大段的重复代码,人们总是倾向于保留数据泥团, 但是这些总是绑在一起出现的数据真应该拥有属于它们自己的对象。
过长函数/参数列表/类
早在编程的洪荒年代,程序员们就已认识到: 代码越长就越难理解。另外不只是可读性有所欠缺,代码的可复用性、可扩展性和可测试性都会随着长度的增加而降低。
Dead Code
注释掉与不再使用的代码会随着时间推移越来越与系统无关,没有人知道他有多旧,也没有人知道它有没有意义。没有人会删除它,因为大家都假设别人需要它或是有进一步的计划。因此对于自己导致的 Dead Code 应当及时删除,不用担心,源代码控制系统会记得它。
➤ 设计
发散式变化与霰弹式修改
新加一个功能必须修改很多个分散在各个文件中的代码才能完成,这样的坏味道被形象的称之为霰弹式修改。如果需要修改的代码散布四处,不但很难找到它们,也很容易错过某个重要的修改。新加一个功能必须修改另一个或几个相关模块的代码才能完成,这样的坏味道被称为发散式变化。两者都是非常不合理的设计,这样的设计显然违反了我们下文将会提到的开放-封闭原则。
夸夸其谈通用性
有人说“哦,我想我们总有一天需要做这件事”,并因此企图用各式各样的钩子和特殊情况来处理一些非必要的事情。但是这么做的后果往往是导致系统更难理解和维护。
内幕交易
模块间的数据交换逻辑可能散落在各自不同文件的模块中,就像私底下进行的内幕交易。对于这些不可避免的数据交换我们应当将它们放在一处共同的地方,减少它们私下的交流。
依恋情结
一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流,这就是依恋情结的典型情况。这时我们就应当将函数移动到它的依恋模块中。
过度使用委托
如果某个类的接口有一半的函数都委托给其他类,这样就属于过度运用委托。这时应该使用移除中间人,直接和真正负责的对象打交道。
重构坏味道的代码
➤ 拆分与移动
提炼函数
function printOwing(invoice) { let outstanding = 0; console.log("***********************"); console.log("**** Customer Owes ****"); console.log("***********************"); // calculate outstanding for (const o of invoice.orders) { outstanding += o.amount; } // record due date const today = Clock.today; invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30); //print details console.log(`name: ${invoice.customer}`); console.log(`amount: ${outstanding}`); console.log(`due: ${invoice.dueDate.toLocaleDateString()}`); }
我们应该将代码逻辑根据相关性拆分为多个部分,使得每个部分尽可能的独立:
function printBanner() { console.log("***********************"); console.log("**** Customer Owes ****"); console.log("***********************"); } function printOwing(invoice) { let outstanding = 0; printBanner(); // calculate outstanding for (const o of invoice.orders) { outstanding += o.amount; } // record due date const today = Clock.today; invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30); printDetails(); function printDetails() { console.log(`name: ${invoice.customer}`); console.log(`amount: ${outstanding}`); console.log(`due: ${invoice.dueDate.toLocaleDateString()}`); } }
提炼变量
例如下面这一长串的计算表达式可读性就十分差劲,维护者需要花上不少精力来识别出不同计算单元对应的究竟是什么:
function calc(order) { return order.quantity * order.itemPrice - Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 + Math.min(order.quantity * order.itemPrice * 0.1, 100); }
我们应当将每个计算单元提炼成一个有意义的变量,对于可重用的计算逻辑可以将它提炼成函数:
function getQuantityDiscount(order) { return Math.max(0, order.quantity - 500) * order.itemPrice * 0.05; } function calc(order) { const basePrice = order.quantity * order.itemPrice; // 如果你觉得计算逻辑可能会被重用,可以将它封装成一个函数 const quantityDiscount = getQuantityDiscount(order); const shipping = Math.min(basePrice * 0.1, 100); return basePrice - quantityDiscount + shipping; }
提炼类
如同数据库第一范式一样,我们也需要尽量保证类的成员变量不可再分。否则随着责任不断增加,这个类会变得过分复杂。
class Person { get officeAreaCode() {return this._officeAreaCode;} get officeNumber() {return this._officeNumber;} }
区号和号码都应该归属于电话实体,我们应当提炼出一个新的类:
class Person { get officeAreaCode() {return this._telephoneNumber.areaCode;} get officeNumber() {return this._telephoneNumber.number;} } class TelephoneNumber { get areaCode() {return this._areaCode;} get number() {return this._number;} }
拆分循环
将多件事情放在一个循环当中处理的确有性能上的优势,但是对于保证代码的可维护性和可测试性不太有利。后文我们也会提到单一职责原则是提高内聚性,降低耦合性的首要原则:
let averageAge = 0; let totalSalary = 0; for (const p of people) { averageAge += p.age; totalSalary += p.salary; } averageAge = averageAge / people.length;
将处理逻辑拆分开, 更方便我们的封装与组合:
let totalSalary = 0; for (const p of people) { totalSalary += p.salary; } let averageAge = 0; for (const p of people) { averageAge += p.age; } averageAge = averageAge / people.length;
搬移字段
发现我们发现每当调用某个函数时,除了传入一个记录参数,还总是需要同时传入另一条记录的某个字段一起作为参数时,那就说明很可能有字段放错了位置, 例如:
class Customer { get plan() { return this._plan; } get discountRate() { return this._discountRate; } }
我们应该将 discountRate 属性移动到 plan 实体上:
class Customer { get plan() { return this._plan; } get discountRate() { return this._plan._discountRate; } }
➤ 封装与组合
封装变量
对于所有可变的数据,只要它的作用域超出单个函数,我们就应该将其封装起来,只允许通过函数访问。数据的作用域越大,封装就越重要。例如:
let defaultOwner = {firstName: "Martin", lastName: "Fowler"};
只暴露 set 和 get 方法,使得修改和查询的来源变得更为清晰:
let defaultOwnerData = {firstName: "Martin", lastName: "Fowler"}; export function defaultOwner() { return defaultOwnerData; } export function setDefaultOwner(arg) { defaultOwnerData = arg; }
或者我们可以使用类的形式来暴露可变数据:
class DefaultOwner { constructor() { this._firstName = "Martin"; this._lastName = "Fowler"; } get firstName() { return this._firstName; } get lastName() { return this._lastName; } set firstName(name: string) { this._firstName = name; } set lastName(name: string) { this._lastName = name; } }
另外对于集合类型的变量我们应该注意: 不要只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知。
class Person { get courses() {return this._courses;} set courses(aList) {this._courses = aList;} }
将增删操作封装为成员方法:
class Person { get courses() {return this._courses.slice();} addCourse(aCourse) { ... } removeCourse(aCourse) { ... } }
引入参数对象
引入参数对象不仅可以减少数据泥团,对于过长的参数列表也能够提高可读性。以笔者自身经验而言,当一个函数存在三个以上的参数时,参数对象是必不可少的。
function amountInvoiced(startDate, endDate) {...} function amountReceived(startDate, endDate) {...} function amountOverdue(startDate, endDate) {...}
将数据泥团组合成参数对象:
function amountInvoiced(aDateRange) {...} function amountReceived(aDateRange) {...} function amountOverdue(aDateRange) {...}
函数组合成类
如果发现一组函数形影不离地操作同一块数据(通常是将这块数据作为参数传递给函数),就是时候组建一个类了。类能明确地给这些函数提供一个共用的环境,在对象内部调用这些函数可以少传许多参数,从而简化函数调用,并且这样一个对象也可以更方便地传递给系统的其他部分。
function base(aReading) {...} function taxableCharge(aReading) {...} function calculateBaseCharge(aReading) {...}
将一组函数组合成类:
class Reading { base() {...} taxableCharge() {...} calculateBaseCharge() {...} }
查询取代临时变量
临时变量的一个作用是保存某段代码的返回值,以便在函数的后面部分使用它。对于一个类而言,在其他成员方法中使用同一个临时变量的概率是很大的,大多数时候我们可以将临时变量提炼为函数。
class Order { calcPrice() { const basePrice = this._quantity * this._itemPrice; if (basePrice > 1000) return basePrice * 0.95; else return basePrice * 0.98; } }
将可以被复用的部分提炼为查询函数:
class Order { get basePrice() { return this._quantity * this._itemPrice; } get isLargeOrder() { return this.basePrice > 1000; } calcPrice() { return this.basePrice * (this.isLargeOrder ? 0.95 : 0.98); } }
查询取代派生变量
计算往往能更清晰地表达数据的含义,而且也避免了“源数据修改时忘了更新派生变量”的错误。
class ProductionPlan { get production() { return this._production; } applyAdjustment(anAdjustment) { this._adjustments.push(anAdjustment); this._production += anAdjustment.amount; } }
用计算属性取代派生变量, 这样的写法对于使用过 vue.js 的同学应该十分熟悉:
class ProductionPlan { get production() { return this._adjustments.reduce((a, c) => a += c.amount, 0); } applyAdjustment(anAdjustment) { this._adjustments.push(anAdjustment); } }
➤ 简化条件逻辑
替换算法
为完成一个功能每个程序员都会有不同的算法或者说写法,通常来说越清晰的算法可维护性越高。当发现做一件事可以有更清晰的方式,你就就应该用比较清晰的方式取代复杂的方式。
function foundPerson(people) { for(let i = 0; i < people.length; i++) { if (people[i] === "Don") { return "Don"; } if (people[i] === "John") { return "John"; } if (people[i] === "Kent") { return "Kent"; } } return ""; }
我们其实并不需要这些条件语句:
function foundPerson(people) { const candidates = ["Don", "John", "Kent"]; return people.find(p => candidates.includes(p)) || ''; }
拆分条件表达式
这条其实是提炼函数的一个特例,但是由于强调这一点有很大的价值,因此单独拆出来讲:
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) { charge = quantity * plan.summerRate; } else { charge = quantity * plan.regularRate + plan.regularServiceCharge; }
对于复杂的条件语句我们应当将条件判断与条件分支抽成三个函数:
if (summer()) { charge = summerCharge(); } else { charge = regularCharge(); }
合并条件表达式
当检查条件各不相同,最终行为却一致时,应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。从而使条件检查的用意更清晰:
if (anEmployee.seniority < 2) return 0; if (anEmployee.monthsDisabled > 12) return 0; if (anEmployee.isPartTime) return 0;
将返回值一致的条件合并:
if (isNotEligibleForDisability()) return 0; function isNotEligibleForDisability() { return ((anEmployee.seniority < 2) || (anEmployee.monthsDisabled > 12) || (anEmployee.isPartTime)); }
卫语句取代嵌套条件表达式
如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句”(guard clauses)。
function getPayAmount() { let result; if (isDead) result = deadAmount(); else { if (isSeparated) result = separatedAmount(); else { if (isRetired) result = retiredAmount(); else result = normalPayAmount(); } } return result; }
使用卫语句可以大大减少嵌套语句的数量,增强可读性:
function getPayAmount() { if (isDead) return deadAmount(); if (isSeparated) return separatedAmount(); if (isRetired) return retiredAmount(); return normalPayAmount(); }
多态取代条件表达式
典型的场景是存在好几个函数都有基于类型代码的 switch 语句。我们可以针对 switch 语句中的每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。
switch (bird.type) { case 'EuropeanSwallow': return "average"; case 'AfricanSwallow': return (bird.numberOfCoconuts > 2) ? "tired" : "average"; case 'NorwegianBlueParrot': return (bird.voltage > 100) ? "scorched" : "beautiful"; default: return "unknown"; }
利用多态代替 switch 语句:
class EuropeanSwallow { get plumage() { return "average"; } } class AfricanSwallow { get plumage() { return (this.numberOfCoconuts > 2) ? "tired" : "average"; } } class NorwegianBlueParrot { get plumage() { return (this.voltage > 100) ? "scorched" : "beautiful"; } }
总结
必需
- 不要相信任何人: 包括用户的输入和后端的返回值
- 对于视图中使用到的变量, 必须保证取值不会导致异常
- 对于核心函数进行单元测试, 必须包括边界测试和反向测试
- 使用有意义且统一风格的命名: 名副其实,避免误导
- 避免出现重复代码
- 避免出现 Dead Code
- 避免过长的参数列表/函数/类
建议
- 符合单一职责原则
- 符合开放-封闭原则
- 避免内幕交易
- 避免依恋情结
- 避免夸夸其谈通用性
- 对于 export 出的对象字面量使用封装变量
推荐阅读
- 《代码整洁之道》 https://book.douban.com/subject/4199741/
- 《重构(第 2 版)》https://book.douban.com/subject/30468597/
- 《编写可读代码的艺术》 https://book.douban.com/subject/10797189/
- 《React 性能优化 | 包括原理、技巧、Demo、工具使用》 https://juejin.cn/post/6935584878071119885
- 《敏捷软件开发 : 原则、模式与实践》 https://book.douban.com/subject/1140457/
- 《阿里巴巴 Java 开发手册》 https://kangroo.gitee.io/ajcg/#/naming-style
- 《naming-cheatsheet》 https://github.com/kettanaito/naming-cheatsheet