在编程中,大部分时间都花在阅读代码上,不仅要理解自己的代码,还需要理解其他人的代码。因此,写出易读的代码能够显著提高编程效率。
在非核心领域,即使为了效率,也不应牺牲代码可读性,因为可读性始终是至关重要的。
可读性是好代码的一个重要指标,只有代码能让人读懂,才有可维护性,所以可读性是可维护性的基础。良好可读的代码通常反映了更健壮的代码结构,因为程序员更愿意调整这部分代码,而且也更容易进行修改。
一、见名知意
命名是开发过程中至关重要的技能,有一个易于理解的名字可以承载很信息,某种程度上是一种更好的注释,一个糟糕的命名,可能会引起别人的误解,对开发效率和项目质量影响很大;相反,遵循一套严格的命名规范,无论是对自己还是接手项目的人,都会大大降低代码的维护成本。命名规范涵盖的面比较广,一般包括变量或常量名、函数或类名、文件或工程目录名、工程名以及空间名等。
把信息装到名字里,从字面含义可以关联其代码中的用途。名字应该尽量精确、专业、不要有多余。不会误解的名字,阅读你代码的人应该理解你的本意,并且不会有其他的理解。
要仔细审视名字,"这个名字会被别人误解成其他含义吗"?
1.1 避免空泛的名字
在需要实际意义表现时,避免使用tmp
、retval
、foo
这样的词
不好的方式
ts
let arr = [] // arr 是存储什么的?
let obj = {} // obj 代表什么资源?
let str = '' // str 有什么用处
看到这样的命名,完全不知道要表达什么,比如 arr,看起来是个数组,可是这个数组究竟存放什么内容数据呢?不知道,必须继续往下看。
好的方式
修改后如下,不需要看后续代码,即可明白变量意图。
ts
let newsList = [] // 新闻列表
let newsDetail = {} // 新闻详情
let newsAuthor = '' // 新闻作者
PS: 某些情况使用空泛的名字也有好处,比如说在交换两个变量的时候使用tmp
,在循环迭代器中使用i
、j
、iter
,但是在嵌套的循环中,加上有意义的前缀使之更能相互区分
1.2 用具体的名字代替抽象的名字
要明确的指出这是什么,干什么的,而不是给一个模糊的、抽象的描述
不好的方式
ts
let thisState = false // 什么的状态?
let deleteFun = () => {} // 删除函数?是删除什么的?
let newsData = [] // 新闻的什么数据?
let newsInfo = [] // 新闻的什么信息?
let appFun = {} // ?
好的方式
修改后如下,可以一眼知道是干什么的。
ts
let orderStatus = false // 订单状态 let deleteNews = () => {} // 删除新闻 let newsLabels = [] // 新闻的标签列表
let newsComments = [] // 新闻的评论列表
let fetchMethods = {} // 请求相关的方法
1.3 使用前缀或后缀来给名字附带更多信息
在为变量或函数起名时,需要考虑其他人可能如何解读这个名字,以及是否可能产生对原本意图的误解。
-
对于布尔类型的命名,可以考虑在命名中加入 is、can、should、has 等前缀,以明确表示与布尔值相关的含义。
-
对于函数的命名,应该是动词开头 + 恰当的名词,参数同样应该有具体的含义,用以说明其背后的意图以及参数的意图,函数的名字则应该说明他们做了什么。 常用的动词前缀如下表所示:
动词 含义 说明 can 判断是否可执行某个动作 函数返回一个布尔值。true:可执行;false:不可执行 has 判断是否含有某个值 函数返回一个布尔值。true:含有此值;false:不含有此值 get 获取某个值 函数返回一个非布尔值 set 设置某个值 无返回值、返回是否设置成功或者返回链式对象 load/query 加载某些数据 无返回值或者返回是否加载完成的结果 save/update 保存或修改某些数据 无返回值或者返回是否操作成功的结果 -
当要定一个值的上限或者下限时,max 和 min 是很好的前缀
-
对于包含的范围,使用 first 和 last 是很好的选择
-
对于包含/排除的范围,begin 和 end 是常用的选择
-
很多单词在用来编程时是多义性的,例如 filter、length 和 limit,他们最好在确定的场景下被使用,而不是用于确定某个场景
不好的方式
ts
let getUser = () => {} // 获取用户什么?通过什么获取?
let cardTabs = () => {} // 卡片列表,注意这是一个函数,通过什么手段获取?格式化化后的?去重?不知道里边干了啥
let checkEmail = () => {} // 检查邮箱什么?格式合法?有效邮箱?邮箱种类?
let getColor = (data) => {} // data是什么,有什么业务含义?到底根据什么来获取颜色?
let create = (flag) => {} // 创建啥?flag是啥? 什么情况下是true?什么情况下是false?
let _newsList = [] // ???这是什么新闻列表
let redirected = false // 是否跳转过
let admin = false // 是否为管理员?
好的方式
ts
let getUserInfoById = () => {} // 通过 id 获取用户信息 let queryCardList = () => {} let validateEmailFormat = [] // 验证邮箱格式是否合法
let getColor = (installStatus) => {} // 根据安装状态获取对应的颜色
let createArticle = (isAdmin) => {}
let deduplicatedNewsList = [] // 去重的新闻列表
let hasRedirected = false
let isAdmin = false
1.4 为名字附带更多的信息
不好的方式
以下函数接收的参数类型为典型的带单位变量,此类函数的使用者通常需要知道参数的单位
ts
function start(delay: number) {}
function createCache(size: number) {}
function throttleDownload(limit: number) {}
function rotate(angle: number) {}
好的方式
修改后如下,带单位的值最好附带上单位,一目了然
ts
function start(delay_secs: number) {}
function createCache(size_mb: number) {}
function throttleDownload(max_kbps: number) {}
function rotate(degree_cw: number) {}
其他附带额外信息不仅限于单位,比如:
情形 | 变量名 | 更好的名字 |
---|---|---|
一个"纯文本"格式的代码,需要加密后才能进一步使用 | password | plaintext_password |
一条用户提供的注释,需要转义之后才能用于显示 | comment | unescaped_comment |
已转化为UTF-8格式的html字节 | html | html_utf8 |
以"url方式编码"的输入数据 | data | data_urlenc |
1.5 找到更有表现力的词
-
get
根据情境,用FetchPage()
或者DownloadPage()
代替getPage()
-
size()
在树中应该用height()
表示高度,nodes()
表示节点数,用memoryBytes()
表示内存中所占的空间 -
其他示例如下表:
单词 更多选择 send deliver、dispatch、 announce、 distribute、route find search、extract、locate、recover start launch、create、begin、open make create、set up、build、generate、compose、add、new
1.6 禁止直接使用字面量
- 常量使用,尽可能不在逻辑中直接使用字面量如:3,'abc' 等,应该用语义化常量定义
- 枚举类型使用,多状态字面量,应该使用枚举类型,尽可能使用常量枚举,次选普通枚举
不好的方式
修改前,代码中的字面量通常对开发者来说不友好,需要二次确认诸如 30、'12'、'000000' 等所代表的含义
ts
if (age > 30) {} // 30
if (code === '000000') {} // '000000'
switch (styleCode: string) { // '12' '13' '14'
case '12':
break;
case '13':
break;
case '14':
break;
default:
break;
}
好的方式
修改后如下,可以一眼知道是什么。
ts
// 使用常量
const MAX_AGE = 30;
if (age > MAX_AGE) {}
// 使用常量枚举,增加代码可读性
const enum RESPONSE_CODES {
SUCCESS = '000000',
// ...
}
if (code === RESPONSE_CODES.SUCCESS) {}
// 常量枚举,既可以定义类型,又可以语义化变量
const enum STYLE_CODE {
SINGLE_IMAGE = '12',
TEXT_AND_IMAGE = '13',
ERROR = '14',
}
switch (styleCode: STYLE_CODE) {
case STYLE_CODE.SINGLE_IMAGE:
break;
case STYLE_CODE.TEXT_AND_IMAGE:
break;
case STYLE_CODE.ERROR:
break;
default:
break;
}
注意:
枚举和常量枚举(const枚举):使用枚举可以清晰地表达意图或创建一组有区别的用例
关于常量枚举:
有时定义枚举可能只是为了让程序可读性更好,而不需要编译后的代码,即不需要编译成对象。typescript 中考虑到这种情况,所以加入了 const enum (完全嵌入的枚举)
TS 中常量枚举会在编译阶段被删除,枚举成员只能是常量成员不能包含计算成员,如果包含了计算成员,则会在编译阶段报错,枚举成员在使用的地方会被内联进来,避免额外的性能开销。
普通枚举则枚举更加灵活,使用性多样,注意区分两者的使用场景
1.7 合理的名字长度
不好的方式
ts
const user = {
userName: 'Jone',
userSurname: 'Doe',
userAge: '28'
}
要明确的指出这是什么,干什么的,而不是给一个模糊的、抽象的描述
好的方式
修改后如下,可以一眼知道是干什么的。
ts
const user = {
name: 'Jone',
surname: 'Doe',
age: '28'
}
- 在小作用域中使用短的名字,相反在大作用域中使用长名字
- 首字母缩略词和单词缩写应该是大家普遍接受和理解的,例如用doc代替document、str代替string
- 丢掉没用的词,
ConvertToString
可以直接写成toString
,这样也没有丢失任何信息
1.8 使用行业/团队范式命名
一个团队必须统一命名格式,不同格式的命名表达不同的含义,通过命名就可以指定变量是做什么用的, 而且也让外人看来,更加的专业,增强信任感。试想一下,假如我们对外提供了一个接口,有的返回的变量格式是下划线, 有的是大驼峰,难免让人吐槽。
- 变量:小驼峰,如
newsDetail
- 函数:小驼峰,如
getNewsDetail
- 类名:大驼峰,如
EventBus
- 类的私有属性和方法名:应该以下划线开头,如
var _name = name;
- 常量:大写 + 下划线分割,如
NEWS_STATUS
- 目录:小写 + 中划线分割,如
file-name
- 文件:小写 + 中划线分割,如
event-bus.js
,团队内部统一即可 - 组件:大驼峰,如
TnTable.vue
,如果在目录下的index
文件,则用小写,如index.vue
class
:类名一般应为小写 + 中划线,参考html
元素的规范,html
中一般小写 + 中划线
有了规范,在 CR
时就可以针对问题进行评论,否则有时候会感觉,这样也行那样也行,最后造成代码的混乱。
1.9 其他错误用例
以下列举的不规范的命名方式,在任何情况下,你都不应该考虑使用它们:
- 单词拼写错误 提交表单中,把
Form
写成了From
,如submitFrom
- 中英文混用
let chanpinList;
这个变量名混用中英文,很不容易理解。除非是一些被创造出来但已经被广泛接受了的名词,如淘宝-taobao
,微博-weibo
,其他的情况都建议用英文; - 中文词汇缩写 比如表达服务市场时,直接使用
fwsc
,对于第一次接触的人完全不理解含义 - 以1-9或a-z命名 比如页面上有几个按钮,直接命名成 btn1,btn2,btn3,...或者 btnA,btnB,btnC,...,这样看似简单,实际上从这些命名里面读取不到任何信息,时间久了就加无法与业务对应
- 混用命名格式 比如表示评论列表,有地方叫 comments,另一个地方叫 comment-list,还有的地方叫 commentList,几种规范混在一起,就感觉很不规范
- 单复数不分 比如有两个操作,一个是下载全部订单数据,一个是下载当前订单数据,结果分别命名为 downloadOrderData 与 downloadOrder,如果没有单复数,是不能很好地表达出业务含义的
- 正反义词错用 比如有两个操作,一个是显示弹窗,一个是关闭弹窗,结果分别命名为 showEditDialog 与 closeEditDialog。show 和 close ,一个是显示,一个是关闭,显然不是一组正反义词
- 容易被过滤的单词 ad、banner、gg、guanggao 等有机会和广告挂勾的字面不建议直接用来做ClassName,因为有些浏览器插件(Chrome的广告拦截插件等)会直接过滤这些类名
二、恰如其分的注释
关键思想:注释的目的是尽量帮助读者了解得和作者一样多
2.1 优先考虑命名而不是注释
注释固然很重要,但最好的文档其实就是代码本身。优先考虑使用有意义的类型名和变量名,不要为了注释而注释,某种程度上,因为需要注释常常因为它不是很好读,这个时候应该先考虑你的函数名和变量名是不是应该改改。
不好的方式
ts
// 删除表格中指定 id 的订单数据
delete(id)
好的方式
不要给不好的名字加注释 --- 应该把名字改好 ,好 代码 > 坏代码 + 好注释
ts
deleteOrderItemById(id)
2.2 声明高层次的意图而非细节
不要描述显而易见的现象,永远不要用自然语言翻译代码,而应当解释代码为什么要这么做,或者是为了让代码文档化。比如 为接口提供功能说明,为复杂的实现提供逻辑说明,以阐述为什么是这样而不是那样,标注代码中的缺陷,解释读者意料之外的行为等。
不好的方式
- 对代码的翻译,是没有价值的注释
- 不要为了注释而注释,没有提供比代码本身更多信息的注释要么删除,要么改进
- 不要为了那些从代码本身就能快速推断的事实写注释
ts
// 这是 Account类 的定义
class Account {
// 给 profile 赋予新的值
setProfile(profile);
}
ts
class Orders {
_orders = [];
constructor() {}
findIndexById(id) {}
// 先按 id 查找订单,如果存在则进行删除操作,不存在打印错误日志
deleteById(id) {
const index = this.findIndexById(id);
if (index > -1) {
this._orders.splice(index, 1);
return;
}
console.log('订单不存在');
}
}
ts
const REVIEW_VERSION = '3051600';
function isReviewVersion() {
// 版本信息为 jsbridge 获取,在 vuex 全局存储。需要把 col01 按照,分割,取第一项
const currentVersion = this.ayhMineUserGuideAppVersion?.col01?.split(',')?.[0];
return currentVersion === REVIEW_VERSION;
}
好的方式
给常量加注释,记下决定这个常量值时的想法
ts
// 权衡图片大小/质量,图片质量设置的最佳值为 0.72
const IMAGE_QUALITY = 0.72;
说明背后为什么是它,而不是其他写法
ts
/**
* User 类代表应用中的用户实体,包含用户名、邮箱和密码属性,
* 这里使用字符串数组来存储用户的权限列表,而不是直接内建权限枚举,
* 是因为这样可以支持自定义权限扩展且更灵活地处理不同角色的用户权限。
*/
class User {
constructor(
public nickname: string,
public email: string,
private password: string,
public permissions: string[]
) {}
// ...
}
记录你写代码时重要的想法
ts
/**
* User 类的设计基于单一职责原则,将用户认证与用户数据存储分开处理。
* 将密码哈希处理放在构造函数内是因为在实例化时即完成安全处理,
* 避免了原始密码在内存中的长时间存在。
*/
class User {
// ...
}
记录对代码有价值的见解,例如:解释代码没法修复的缺陷、代码不整洁的原因
ts
/**
* 在这个例子中,我们使用了一个可选属性 `address` 的接口。
* 尽管在许多情况下地址对于用户对象来说是必需的,但由于历史遗留数据或特定API端点的限制,
* 我们在这里将其设置为可选以兼容那些可能缺失地址信息的情况。
*/
interface UserProps {
id: number;
name: string;
address?: {
street: string;
city: string;
country: string;
};
}
2.3 公布可能的陷阱
难免在实现中引入 hack 代码或考虑但未处理的边界场景,此时应为后来者显示标注,以便后续回溯和修复。在大块长函数前,总结其用途和用法。当你写代码有以下想法时:"这段代码有什么出人意料的地方吗?","这个方法会不会被误用?"等,基本上说就是你需要未雨绸缪,预料到别人使用你代码时可能遇到的问题。如:
ts
// 因为调用外部邮件服务器发送邮件,所以耗时较长,请使用异步方法调用以防止 UI卡死。
function sendEmail(to: string, subject: string, body: string) {}
2.4 为代码中的瑕疵写注释
比如有如下几种标记:
标记 | 通常代表的含义 |
---|---|
TODO: | 我还没有处理的事情 |
FIXME: | 已知的无法运行的代码 |
HACK: | 对一个问题不得不采用的比较粗糙的解决方案 |
XXX: | 危险!这里有重要的问题 |
好的方式
ts
export class ABManager {
// TODO: 为了数据安全性,后期改为自定义加密缓冲区
cache = window.localStorage;
// ...
}
PS: 使用标记时,需要在标记之后紧跟一个英文空格,这是标准的写法,也是通常可以被编辑器插件识别的写法
2.5 为使用技巧的代码添加注释
通常来说每个开发者都有一些自己的代码技巧、最佳实践或者掌握了一些社区通用的技巧,这些技巧有时候并不会被其他开发者熟知,因此当其他开发者看到时往往会产生疑惑,此时应该尽可能的为一些看起来不符合正常逻辑但却非常有用的代码添加注释。
好的方式
ts
function clear() {
// ...
// 强制让 vector 放弃其内存(查找"STL交换技巧")
this.vector().swap(data);
}
2.6 站在读者的角度,培养注释意识
当写代码时要时刻问自己,凡是当自己觉得其他人有可能对自己代码产生疑问、误解的地方,通常需要添加说明性、总结性的注释
- 这段代码其他人可以看懂吗?
- 这是一段定制化逻辑吗?
- 这种处理逻辑是否属于奇技淫巧?
- 自己可以在一段时间之后仍然记得这段代码逻辑吗?
三、整洁的代码格式
3.1 把控制流变得易读
把条件、循环、以及其他对控制流的改变做的越"自然"越好。让读者不用停下来重读代码。
3.1.1 条件语句中比较参数的顺序
- 有以下指导原则:
比较的左侧 | 比较的右侧 |
---|---|
"被问询的"表达式,它的值更倾向于不断变化 | 用来做比较的表达式,它的值更倾向于常量 |
这是和日常语言习惯是一致的,我们会很自然的说:"如果你的年收入至少是10万"
3.2.2 if/else 语句块的顺序
- 先处理掉简单的情况。这种方式可能还会让if和else在屏幕内都可见
- 先处理有趣的或者是可疑的情况
3.2.3 三目运算符
- 不要为了减少代码行数而使用三目运算符,它只适用于从两个简单的值中作出选择的情况
不好的方式
带有复杂逻辑的三目运算符反而增加了代码的阅读时间
ts
return age >= 6 ? (gender !== undefined ? gender : '未知') : '儿童';
ts
return exponent >= 0 ? mantissa * (1 << exponent) : mantissa / (1 << -exponent);
好的方式
ts
time_str += (hour >= 12) ? 'pm' : 'am';
3.2.4 从函数提前返回
提前返回(early returns)能
- 让代码的视觉效果更加流畅。如果选择其他替代方法,使用 if 语句,就需要额外缩进,而使用提前返回则并不需要,代码的可读性也更强。
- 避免深层次的嵌套严重影响代码的可读性,嵌套一开始是很简单的,但是后来的改动会加深嵌套
- 使用提前返回,可以先排除无效情况(保镖模式),再空出一行,便可专注于函数的"真正"主体。
- 如果不使用提前返回,那就只有一个出口点,意味着人们必须一直在脑海中搜索全部代码,直到函数运行结束。
好的方式
ts
function contains(str: string, substr: string): boolean {
if (str == null || substr == null) return false;
if (substr.equals('')) return true;
// ...
}
3.2 拆分超长表达式
把超长表达式拆分成更容易理解的小块
3.2.1 解释变量
引入一个额外的变量,使之成为一个小一点的子表达式
不好的方式
ts
if (line.split(':')[0].strip() === 'root') {}
好的方式
ts
const username = line.split(':')[0].strip();
if (username === 'root') {}
3.2.2 总结 变量
用一个很短的名字来代替一大块代码,会更容易管理和思考
不好的方式
ts
if (request.user.id == document.owner_id) {
// user can edit this document...
}
if (request.user.id != document.owner_id) {
// document is read-only...
}
好的方式
ts
// user_owns_document 为总结变量,它使得表达更加清楚
const user_owns_document = request.user.id === document.owner_id;
if (user_owns_document) {
// user can edit this document...
}
if (!user_owns_document) {
// document is read-only...
}
3.2.3 使用摩根定理
分别进行取反、转换与/或,反向操作是提取出"反因子"
- not (a or b or c) ⇔ (not a) and (not b) and (not c)
- not (a and b and c) ⇔ (not a) or (not b) or (not c)
不好的方式
ts
if (!(file_exists && !is_protected)) throw new Error('Sorry, could not read file.');
好的方式
ts
if (!file_exists || is_protected) throw new Error('Sorry, could not read file.');
3.2.4 不要滥用短路逻辑
短路操作虽然可以很智能的运用在某些场景,使之成为条件控制的效果,但是影响代码的理解
不好的方式
ts
(!(bucket = findBucket(key))) && console.log('打印 bucket');
好的方式
ts
// 以下方式更易懂
const bucket = FindBucket(key);
if (bucket !== NULL) console.log('打印 bucket');
ts
// 但短路操作在很多情况下也能达到简洁的目的
const username = userInfo?.name ?? '未知';
3.2.5 使用可选链操作符和双问号操作符
不好的方式
ts
if (userInfoList && userInfoList[0] && userInfoList[0].name) {}
let orderNo = '';
if (order && order.no !== undefined) orderNo = order.no;
if (user && user.run) user.run('1hours');
好的方式
ts
if (userInfoList?.[0]?.name) {}
const order = order?.no ?? '';
user?.run?.('1hours');
3.2.6 拆分巨大的语句
- 复杂的逻辑会产生复杂的表达式,表达式复杂会增加代码的阅读难度,解决它需要转换思维,用更优雅的方式
- 巨大的语句的拆分需要找到重复的部分,进行简化
- 有时需要把问题"反向"或者考虑目标的对立面
3.3 抽取不相关的子问题
理解某个函数或者代码块高层次的目标,对于每行代码确定它是否为目标而工作,如果有很多代码行在解决 不相关的子问题,将它抽取到独立的函数中。
3.4 代码 应当一次只做一件事情
相当于一个函数应该只做一件事情,但是一个函数也可以用空白行区分不同的事情,来达到逻辑上的清晰
- 将所有任务列出来,其中一些任务可以很容易地编程单独的函数(或类)
- 难点在于准确描述列出的所有的小任务
- 分开解决任务使代码变得更小
假设有以下需求:
给一个"location_info"对象,里面有4个字段,LocalityName
、SubAdministrativeAreaName
、AdministrativeAreaName
、CountryName
分别对应城市、大城市、州和国家名。给 4 个字段的值,生成一个 Geo 的 Display。取值逻辑分两部分,第一部分从前面 3 个字段里取值,优先取靠前的,比如 LocalityName 有值就不取 SubAdministrativeAreaName。如果 3 个都没有值就用默认的 "Middle-ofNowhere"。第二部分取CountryName,如果为空则取 "Planet Earth"。
不好的方式
ts
function getDisplay(locationInfo) {
let place = locationInfo.getLocalityName() || '';
if (!place.length) {
place = locationInfo.getSubAdministrativeAreaName();
}
if (!place.length) {
place = locationInfo.getAdministrativeAreaName();
}
if (!place.length) {
place = 'Middle-of-Nowhere';
}
const getCountryName = locationInfo.getCountryName() || '';
if (getCountryName.length) place += ', ' + getCountryName;
else place += ', Plane Earth';
return place;
}
好的方式
ts
class GeoDisplay {
static readonly DEFAULT_FIRST_PART: string = 'Middle-of-Nowhere';
static readonly DEFAULT_SECOND_PART: string = 'Plane Earth';
constructor() {}
getDisplay(locationInfo) {
return this.getFirstPart(locationInfo) + ', ' + this.getSecondPart(locationInfo);
}
private getFirstPart(locationInfo) {
return [
locationInfo.getLocalityName() || '',
locationInfo.getSubAdministrativeAreaName() || '',
locationInfo.getAdministrativeAreaName() || '',
GeoDisplay.DEFAULT_FIRST_PART,
].find(place => place.length);
}
private getSecondPart(locationInfo) {
const getCountryName = locationInfo.getCountryName() || '';
return getCountryName.length ? getCountryName : GeoDisplay.DEFAULT_SECOND_PART;
}
}
这样做的好处就是把每一个字段的取值都独立开了,方便增加新的需求,而且代码层次比较清晰,如果我只看getDisplay 的话,直接就能知道前面一部分拼上逗号再拼后面一部分。每一个字段的取值逻辑如果有变化也方便修改。
我们的一些接口的塞值逻辑,就是平铺把所有字段的 set 判断逻辑都写在一个函数里,这样阅读的时候就总有一种被打断的感觉。
3.5 把想法变成代码
开发功能前,可以先把想法用语言描述一遍,步骤如下
- 用自然语言描述程序
- 用这个描述来帮助你写出更自然的代码
如果你不能把问题说明白或者用词语来设计,估计是缺少了什么东西或者什么东西缺少定义