02-如何提升代码可读性
从过去面向计算机硬件编程的语言(汇编),到现在更符合人类思考和抽象习惯的高级语言(C++、Java、Python、JavaScript、PHP、Ruby等),编程已经变成了人类逻辑到机器逻辑的映射,程序的主要受众不再是机器,而是人本身,在大多数时候代码的可读性比代码的性能更加重要。
可读性是代码易维护的前提条件,一段读不懂的代码是无法维护的,一段不易读的代码维护效率是低下的,提升代码可读性是每个程序员必须要修炼的第一层编码内功。
提升代码可读性是有很多行之有效的方法的,包括使用清晰易懂的命名、添加必要的注释、通过抽象和封装写出更加短小的函数和组件、保持清晰的代码结构、使用简洁且复用表现力的代码、明确的指明数据来源等,只要在编程中适当训练,我们是可以快速提升自己的代码可读性的。
下面详细介绍下提升代码可读性的可执行方法。
一、 好的命名是成功的一半
提升代码可读性的最快速最有效的方法就是命名,别的技能大都需要大量的学习才行,而命名是我们很快可以掌握的,只要转变了认知,从今天开始认识到命名的重要性,便能迅速提高你的代码可读性。在很多书籍中,也都提到了命名的重要性,可能很多同学已经比较熟悉了,但是由于命名对可读性提升非常大,所以在这里仍然赘述一下,熟悉的可以跳过本小节。
想要快速提高编程水平,从重视每一个命名开始!
1.1 见名知意
好的命名要能反应代码的意图,具有自解释性,看到名称就能知道作者想要表达什么业务含义。
在CodeReview中经常可以看到这样的变量命名:
let arr = [] //arr是存储什么的?
let obj = {} //obj代表什么资源?
let str = '' //str有什么用处
每次看到这样的命名,完全不知道要表达什么,比如arr,看起来是个数组,可是这个数组究竟存放什么内容数据呢?不知道,必须继续往下看。
修改后如下,不需要看后续代码,即可明白变量意图。
let newsList = [] //新闻列表
let newsDetail = {} //新闻详情
let newsAuthor = '' //新闻作者
在过往的CodeReview中,还会频繁遇到如下函数参数命名:
//某个事件的处理函数
function handle(e){
//e是什么,结构是怎么样的,必须去查看调用处的代码,才只能e的含义
if(e === 0){
}
}
function getColor(data){
//data是什么,有什么业务含义?到底根据什么来获取颜色?
}
function create(flag){
//flag是啥? 什么情况下是true?什么情况下是false?
if(flag){
}else{
}
}
在上述示例中,你无法一眼看出参数代表的业务含义,不明白参数也就无法确定这个函数内部的逻辑到底是否合理。这时候为了搞懂这个函数的意义,必须要去查看函数在哪里调用,传递的参数代表什么,而如果调用方命名也不合理,就要继续查看调用方的调用方,为了弄明白一个函数的含义,你甚至要查找3、4个函数,这明显违背了最小知识原则,大大加深了代码阅读障碍,很可能的情况是,在你进入到第4个函数时,你忘记了你要干嘛。
假如将上述getColor函数参数简单修改下:
function getColor(installStatus){
switch (installStatus){
case 'success':
return 'green'
case 'fail':
return 'red'
default:{
return 'black'
}
}
}
现在试着说下这个函数的功能:这应该是一个获取颜色函数,参数是安装状态,状态成功返回绿色,失败返回红色,其他情况返回黑色,不需要去阅读任何上下文,就能明白这个函数的作用。
同样的将上述create方法的参数flag改为isAdmin,是不是瞬间清晰了,这是一个创建方法,根据是否是超级管理员执行不同的逻辑,非常地清晰。
function create(isAdmin){
if(isAdmin){
}else{
}
}
良好的命名让我们可以见名知意,不需要去了解上下文,不需要看任何注释,就能理解其要表达的业务含义。
1.1.1 推荐的命名方式
命名通常是为了表达这几种业务含义:某种资源、某个状态、某个事件、某个动作、某种条件
- 资源命名
一般为"资源名称 + 资源类型":如 newsList(新闻列表)、newsDetail(新闻详情),而如果只是单纯的用list或者detail命名,通用性太强,不知道想要表达什么资源的列表/详情,特别是一个页面有多种资源时。
- 状态命名
最好也加上资源名称,如appInstallStatus(应用安装状态)、dialogVisible(弹窗显示状态)、tableLoading(表格加载状态)。
- 事件处理函数命名
一般为动宾短语,如deleteNews、createNews,或者deleteNewsHandler、createNewsHandler,尽量不要叫 deleteData这样的通用性的词,存在多种资源时不知道Data代指何物。
- 动作命名
一般为动宾短语,也可以是个句子,关键要把做的事情表达清楚,如getNewsDetail、updateNewsStatus。
- 条件命名
条件一般为布尔值,布尔值一般分为这几种:是什么(is**)、可以吗(can**)、应该吗(should**)、有吗(has**)
- 是什么: isAdmin、isAdd、isEmpty
- 可以吗:canDelete、canSave
- 应该吗:shouldUseCache
- 有吗:hasDeletePermission
- 进行时/过去式:enabled/disabled、destroying/destroyed、loading、visible
1.2 去除无意义的词
在命名中有时会有一些无意义的废话,比如命名中包含数据类型,通常可以去除或者换成更友好的词。
//bad,看后面赋值知道是数组,但是更想知道业务含义
let newsArr = []
//good
let newsList = []
//bad,这肯定是函数,无需多言,Fun毫无意义
function deleteFun(){
}
//good
function deleteResource(){
}
function deleteResourceHandler(){
}
1.3 名称要有明显区分
好名字应该有分区度,避免歧义,比如一下几个变量:
//bad
let news = {}
let newsData = []
let newsInfo = []
let newsDetail = {}
你能弄清楚news、newsData、newsInfo、newsDetail到底有什么区别呢?看起来好像是一个意思。
把业务相关词汇加上去就清楚多了:
//good
let newsSummary = {} //新闻概要
let newsLabels = [] //新闻标签
let newsComments = [] //新闻评论
let newsDetail = {} //新闻详情
1.4 好名字可以念出来
现在试着给同事介绍下以下两个函数:
function delProdFun(){
}
function deleteProductHandler(){
}
当要和同事讨论delProdFun函数时:"李哥,你写的那个d-e-l-p-r-o-d-f-u-n(逐个单词念出来)函数好像有点问题哎~"
代码是要和别人沟通的,必须能够通过人的语言讲出来,否则写的时候自己没感觉,一到多人沟通的时候可能要闹笑话了。
如无必要,尽量不用简写,除非是一些约定俗成的简写,如IP、HTTP、ID、No等。
1.5 好名字不怕长
有时候会担心名字太长会不会不太好,是不是太长了写的就慢,其实不是,目前编辑器都有提示功能, 写出几个字符就会有推荐名称可以选择,不会增加编码负担。
而长的名字通常具有较好的表现力,能把要做的事通过命名表现出来,相当于通过命名写好了注释和文档。
function getUser(id){
}
//看到函数名就知道怎么传参
function getUserById(id){
}
//函数名较短,需要注释来说明函数用途
//同步函数,把命名空间同步到其他集群
function sync(){
}
function syncNamespaceToOtherCluster(){
//无需注释,见名知意
}
较短的名称一般很容易和别人重复,而较长的名字一般都有其特定场景,不容易和其他业务重复,对于全局搜索也比较友好。
1.6 拒绝误导
更有甚者,有时候命名和要表达的意思可能是反的,这种误导性的命名,可以当做bug处理了。
特别是在一些Boolean值应用时很容易出现,千万不要笑,这种表达相反含义的命名,确实发生过。
function deleteUser(hasPermission) {
if (hasPermission) {
//hasPermission 表达的是无权限
}
}
不过幸好,一般都不会犯这样的错误,但是下面这个错误非常场景,比如在get命名的函数中会做很多set的工作。
function getDetail(){
requrst(url,params).then(_=>{
setData()
})
}
当你在别处看到getDetail()函数调用时,你根本想不到这里居然修改了其他代码。
getDetail() //看着是get操作,没想到居然修改了某些数据
再比如在获取cache或者localStorage时做一些set操作,命名和要做的事情不一致,或者名称只说了一件事, 函数内部干了多件事,这也同时违反了单一职责原则。
function getCache(key){
if(!cacheData[key]){
setCache(key, '') //没想到get操作中有set操作
}else{
clearCache(key) //没想到get操作中有清空操作
return cacheData[key]
}
}
1.7 统一表达
一个团队沟通要想高效,必须使用相同的语言,一致的表达方式,减少不必要的学习及认知成本,写代码也是一样。 特别是在涉及一些业务问题时,同一个业务,如果使用不同的词语来表达,难免增加不必要的沟通理解成本。
1.7.1 制定一个领域词汇表
可以将业务领域内的相关词汇抽取出来形成一个词汇表,一般为名词,代指业务中涉及的资源和概念。 一来后续新人进入可以通过这些名词来了解业务的大概面貌, 二来统一了大家在业务方面的认知,减少沟通成本。
比如user、member、customer都可能用来表示用户,那么什么时候用哪个词怎么界定呢?如果没有规范随便命名,可能会导致理解上的偏差,你以为是客户,实际别人要表示的是注册用户,无形中增加了很多理解上的障碍。
领域词汇表示例:
变量名 | 含义 | 备注 |
---|---|---|
USER | 普通注册用户 | |
CUSTOMER | 客户 | 签订过合同的用户 |
SKU | 最小存货单位 | |
COMMODITY | 商品 | |
PRODUCT | 产品 | |
CONTRACT | 合同 | |
ORDER | 订单 |
领域词汇表的作用是用来统一认知,在变量命名时根据领域词汇表中的名称来命名,让代码更加可读。
除了业务领域统一用词外,还有一些通用的字段也可以规范下来,比如创建时间到底是用created_time还是created_at, 创建人是created_by还是creator,虽然表达含义并无多大区别,但是如果多个地方随机变化使用,也会让人感觉不够规范专业。
1.7.2 统一命名格式
一个团队必须统一命名格式,不同格式的命名表达不同的含义,通过命名就可以指定变量是做什么用的, 而且也让外人看来,更加的专业,增强信任感。试想一下,假如我们对外提供了一个接口,有的返回的变量格式是下划线, 有的是大驼峰,别人又怎么能相信功能是靠谱的呢?
一般常见的命名规范如下:
- 变量:小驼峰,如newsDetail
- 函数:小驼峰,如getNewsDetail
- 类名:大驼峰,如EventBus
- 常量:大写+下划线分割,如NEWS_STATUS
- 目录:小写+中划线分割,如file-name
- 文件:小写+中划线分割,如 event-bus.js,团队内部统一即可
- 组件:大驼峰,如TnTable.vue,如果在目录下的index文件,则用小写,如index.vue
- class:类名一般应为小写+中划线,参考html元素的规范,html中一般小写+中划线
有了规范,在CR时就可以针对问题进行评论,否则有时候会感觉,这样也行那样也行,最后造成代码的混乱。
最后提醒一下,规范并不是为了让人记忆的,最好是搭配ESLint这种校验工具,我们的目的是产出规范的代码, 而不是让每个人都背会规范,增加新人融入成本。
二、小而美
除了命名之外,第二个最容易掌握的技巧就是把代码弄短。
虽然短小不是目的,但却是提高代码可读性的一个关键手段,短小的代码有以下好处:
- 短小的东西天然的更容易理解,相比动辄几百上千行的函数或者文件,几行或者10-20行的代码需要更小的认知负担
- 短小的代码更有可能做到单一职责,也更有可能被复用
- 要实现短小的代码,就逼迫你去思考代码的结构,而不是稀里糊涂地杂糅一堆代码
每次接手一个几千行的Vue组件时,都会由衷的感慨一句"oh shit",不管你有多少理由写出这样的代码,一定在某种程度上是缺乏深度思考和代码设计的,很少遇到一个优秀的开源库,里面出现上千行的代码,一般都100-300行之间,他们的复杂度通常都要比我们平时写的业务复杂的多,他们都没有超过千行,我们又有什么理由呢?不要为自己思想上的懒惰找借口,出来混,迟早要还的。
我们可以通过拆分函数、组件化、模块化等方式来实现小而美的代码。
2.1 拆分函数
假设我们有一个名为 processUserData 的长函数,它接受一些用户数据,对其进行处理并返回结果。该函数的代码可能类似于下面这样:
function processUserData(userData) {
let processedData = {};
let firstName = userData.firstName;
let lastName = userData.lastName;
let fullName = firstName + ' ' + lastName;
processedData.name = fullName;
let age = userData.age;
let birthYear = new Date().getFullYear() - age;
processedData.birthYear = birthYear;
let email = userData.email;
let isEmailValid = validateEmail(email);
processedData.isEmailValid = isEmailValid;
let password = userData.password;
let isPasswordValid = validatePassword(password);
processedData.isPasswordValid = isPasswordValid;
return processedData;
}
function validateEmail(email) {
// validate email logic
return true;
}
function validatePassword(password) {
// validate password logic
return true;
}
现在试着阅读上面的代码,逻辑不是很复杂,应该都可以读懂,但是存在一个问题,基本上必须要把processUserData函数的全部实现读完(14行左右),才能知道整个函数的作用。
我们可以将该函数拆分为以下几个小函数:
- getFullName :从用户数据中获取完整姓名
- calculateBirthYear :从用户数据中获取年龄并计算出出生年份
- validateEmail :验证用户数据中的电子邮件地址
- validatePassword :验证用户数据中的密码
- processUserData :调用上述四个函数,对用户数据进行处理并返回结果
拆分后的代码可能如下所示:
function getFullName(userData) {
let firstName = userData.firstName;
let lastName = userData.lastName;
return firstName + ' ' + lastName;
}
function calculateBirthYear(userData) {
let age = userData.age;
return new Date().getFullYear() - age;
}
function validateEmail(email) {
// validate email logic
return true;
}
function validatePassword(password) {
// validate password logic
return true;
}
function processUserData(userData) {
let processedData = {};
processedData.name = getFullName(userData);
processedData.birthYear = calculateBirthYear(userData);
processedData.isEmailValid = validateEmail(userData.email);
processedData.isPasswordValid = validatePassword(userData.password);
return processedData;
}
现在再试着阅读processUserData,只需要读5行就能大致明白该函数的作用。假如如果现在有个bug,processedData.birthYear计算不正确,那么你会很快定位到,应该只需要去查看calculateBirthYear的实现即可。
通过将长函数拆分成多个小函数,可以让你更快的了解到整个函数的全貌,而不会陷入到实现的细节中去,除非你关心某块细节的实现,才需要进入相应的小函数中去查看。
拆分函数并不能将代码长度降低,有时候可能会让代码更长,但是却能让你更加快速的了解函数意图。
拆分成小函数后,每个小函数基本上就做一件事,这样就更容易实现复用,比如上述拆分出来的calculateBirthYear函数可能其他地方也需要使用,这样也提高了代码的复用性。
2.2 组件化拆分页面
我们知道Class类是封装了数据和方法,而组件Component则是封装了UI和逻辑。将一个复杂页面中紧密结合的UI和逻辑抽取出来,封装为一个组件,然后通过多个组件的组合构成一个功能更强大的组件,最终聚合为一个页面。
在现代web开发中,我们可以认为一切皆组件,页面是个顶层组件,由一个功能模块组件构成,功能模块组件再由一些更细小的UI组件构成。通过这种组件化的开发,很容易看清整个功能模块的结构组成,要修改某块代码,只需要进入相应的组件即可,而不必在一个超大的文件中翻找。
现在的前端组件化开发已经非常成熟了,Vue和React等框架为我们实现组件化提供了很好的支持,我们可以很方便的进行组件开发,我们没有理由再写出那种一个页面几千上万行的代码,每次看到一个文件几千行就会忍不住吐槽,修改起来总要上下来回滚动,搜索想要查找的逻辑,很难看清楚页面的整体设计思路,可读性很差。
通过组件拆分这种方式开发,也非常符合分治的思想,将大任务拆分为小任务, 然后逐个去实现,一个大的功能也许很复杂,但是具体到一个个的小组件上,都是一个可以实现的小任务,自上而下拆分,自下而上再去实现,就和我们拆分工作是一个逻辑。
在实现一个复杂功能的时候,我一般习惯上来就按照功能拆分成不同的组件,然后再一个一个去实现。
以一个公司的官网开发为例,可以根据网站的布局和功能将页面划分为几个组件:
- Header:头部组件,展示公司标志、导航菜单和搜索栏
- Banner:横幅组件,展示产品或服务的幻灯片图片或视频。
- AboutUs:关于我们组件,展示公司的历史、使命和团队成员。
- Services:服务组件,展示公司的服务或产品,以及它们的特点
- ContactUs:联系我们组件,展示公司的各个部门联系方式
- Footer:底部组件,展示友情链接、备案信息等
<template>
<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
</main>
<Footer/>
</div>
</template>
<script>
import Header from './components/Header.vue';
import Banner from './components/Banner.vue';
import AboutUs from './components/AboutUs.vue';
import Services from './components/Services.vue';
import ContactUs from './components/ContactUs.vue';
import Footer from './components/Footer.vue';
export default {
name: 'App',
components: {
Header,
Banner,
AboutUs,
Services,
ContactUs,
Footer
}
};
</script>
在开发时首先写几个空的组件文件,并不去真正实现,可以只是简单的返回一个div, 先把整体的页面架子搭好,然后再去依次实现,自上而下设计,自下而上实现,化整为零,化繁为简。
<!--./components/Header.vue-->
<template>
<div>
这是Header组件,暂时还未实现
</div>
</template>
<script>
export default {
name: 'Header',
};
</script>
这个例子或许很简单,官网嘛,逻辑并不会很复杂,好像不做组件化设计也并不能怎样,但是如果是实现一个低代码的大屏设计器呢,不组件化开发根本不可能,我之前工作中就通过这种方式,完成了一个功能复杂的低代码大屏的开发,一个低代码设计器大致可以分为顶部ToolBar,主题内容左侧是物料选择MaterialSelect、绘图区域DrawingArea以及属性编辑区域PropertyEditing,每个组件再去细分,然后逐个去实现。
<template>
<div>
<!--顶部工具栏-->
<ToolBar/>
<main>
<!--物料选择-->
<MaterialSelect />
<!--绘图区域-->
<DrawingArea/>
<!--属性编辑-->
<PropertyEditing/>
</main>
</div>
</template>
组件化开发的代码不仅可读性强,而且还有个附带的好处,就是可以多人并行开发,如果不这么拆分,就算有10个人,也只能一个人写,其他人看着。 毕竟我们没法10个人在一个文件里面去写代码,那样造成的冲突不敢想象。
组件化拆分带来的另一个好处就是复用,通过抽取小的公共的组件,能够大大提升后续功能的开发速度,提升研发效率及质量。
根据我的经验,一般一个Vue组件文件,不建议超过300行,其中Template模板部分不建议超过100行,否则就带来很大的阅读负担。
相信我,小文件一定比大文件更易读,如果不是,那一定是拆分人的错,通常出现这种问题,都是没有做到组件内部高内聚,组件和组件之间低耦合,通常是组件划分不合理,导致耦合严重,比如多个子组件之间互相调用,才让人觉得还不如一个大文件更可读。
2.3 臃肿的utils
在开发中经常会遇到一个超大的utils文件,各种工具函数没有地方放置了都一股脑的丢进utils.js中,导致utils文件动辄几千甚至上万行,如果来了一个新人,看到这个utils,很可能不知道究竟有哪些方法可以用,当他需要一个工具函数时,干脆再写一个丢进这个文件中,导致整个文件的可读性越来越差。
我们应该进行对utils进行合理的模块化拆分,通过结构化的思考,将utils工具函数进行分类,比如可以分为cookie操作、date处理、url处理、event事件处理等,将原来的一个utils.js拆分为多个小的功能更聚合的文件,如果来了一个新人,想查找日期处理有哪些方法,很容易就定位到date.js中,由于这个文件很小,很快就能获取到自己想要的信息,也就是我们的项目可读性增强了。
当然了,不只是utils文件,其他别的文件也是一样的道理,通过模块化拆分,将大文件拆成多个小文件,和上面说到的组件化是一个意思。
2.4 超大的类
一般我们前端在写业务时很少遇到写类的情况,但是如果是要写一些底层库,通常还是避免不了的,针对超大的类,也有很多方法减少类文件的大小。比如我们常用的Vue,本身就是一个类,如果把一个Vue类的所有属性和方法集中在一个文件,可读性肯定是非常差的。
以Vue类的实现为例:
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'
//Vue类的构造函数
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
//在不同的文件中,给Vue类增加相应的方法
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue
主文件中,仅仅定义一个构造函数 function Vue(), 并没有为其添加任何属性和方法。通过将Vue类的功能进行结构化的拆分,分成几个不同的文件去为Vue类增加方法。
以state.js为例,在stateMixin方法中,为Vue增加原型方法,这样就通过多个文件,共同创建一个超大型的类。
export function stateMixin(Vue: Class<Component>) {
Vue.prototype.$set = set
Vue.prototype.$delete = del
Vue.prototype.$watch = function () {
//...
}
}
除了这种方式来实现一个超大的类外,还可以将一个大类中的不同模块拆分成不同的类,然后在这个大类中进行组合。 比如一个组件类Component,可能包含数据管理、渲染管理、事件管理,那么我们就可以把数据管理Store和事件管理Event单独抽出去实现, 在组件类中组合Store和Event两个类,共同完成一个复杂的任务,这样组件类Component的文件长度就大大降低了。
import Store from './store.js'
import Event from './Event.js'
class Component {
constructor({el, template, data, computed, mounted, updated, methods}) {
this.store = new Store({data, computed});
this.event = new Event();
}
}
三、清晰的结构
清晰的代码结构也能让代码更可读,下面我们介绍几种常见让代码结构更清晰的方法。
3.1 使用卫语句
卫语句也就是提前return,防止if嵌套层次太深。
function getScoreLabel(score) {
if (socre > 60) {
if (score > 80) {
return 'A'
} else {
return 'B'
}
} else {
return 'C';
}
}
使用卫语句改写后如下,改写的关键就是翻转if条件,让其尽快return。
function getScoreLabel() {
if (socre <= 60) {
return 'C';
}
if (socre <= 80) {
return 'B';
}
return 'A'
}
可以看出,相对原有写法,使用卫语句逻辑更加清晰。
3.2 switch代替多个if else
function commandHandle(command){
if(command === 'install'){
install()
}else if(command === 'start'){
start()
}else if(command === 'restart'){
restart()
}else{
showToast('未知命令')
}
}
switch比较适合这种判断条件比较单一的情况,如判断是否等于某个字符串或者数字,如果if条件存在3个及以上,使用switch相对来说结构更加清晰。
function commandHandle(command){
switch (command){
case 'install':
install();
break;
case 'start':
start();
break;
case 'restart':
restart();
break
default:{
showToast('未知命令')
}
}
}
当然如果事情如此简单,我们还可以进行更进一步的优化,通过一个map来指定每个命令对应的动作。
function commandHandle(command) {
let commandHandleMap = {
install,
start,
restart,
default: () => showToast('未知命令')
}
let handle = commandHandleMap[command] || commandHandleMap['default']
handle()
}
3.3 跳出链式调用
这里的链式调用是指在一个函数A中调用函数B,函数B中调用函数C,如果要弄明白整个流程, 则需要沿着链条一步一步往下去查看,有些情况可以将链式调用改为顺序结构。
以平时最常见的一个组件初始化为例,在下面这个例子中,首先在组件挂载时调用init,init中调用获取数据getData, 获取数据后调用格式化数据formatData,格式化数据之后再调用setFormData给表单赋初始值。
<script>
export default {
mounted(){
this.init()
},
methods:{
init(){
this.getData()
},
getData(){
request(url).then(res =>{
this.formatData(res)
})
},
formatData(res){
let initData = res.map(item => item)
this.setFormData(initData)
},
setFormData(initData){
this.formData = initData
}
}
}
</script>
如果想要弄明白init中到底发生了什么,必须需要沿着链条一个一个去阅读相关实现,伴随着在函数中跳来跳去,增加了很大的认知负担。
我们只需在函数中返回数据,就可以将链式调用改为顺序结构。
<script>
export default {
mounted() {
this.init()
},
methods: {
async init() {
let data = await this.getData()
let formData = this.formatData(data)
this.setFormData(formData)
},
getData() {
return request(url).then(res => {
return res
})
},
formatData(res) {
return res.map(item => item)
},
setFormData(initData) {
this.formData = initData
}
}
}
</script>
改写之后可以清楚看到init中发生了3件事,获取数据、格式化数据、给表单赋值,对整体流程有了明确的认识,后面有需要可以再分别进入具体函数进行查看。
3.4 使用管道代替循环
使用管道操作可以大大简化代码的写法,去除一些无用的代码干扰,只关注需要关注的数据。
比如从一个应用列表找出运行中的应用id,使用for循环写法如下:
let appList = [
{
id: 1,
name: '应用1',
status: 'running'
},
{
id: 2,
name: '应用2',
status: 'destroyed'
}
]
function getRunningAppId(){
let ids = [];
for(let i = 0; i < appList.length; i++){
if(appList[i].status === 'running'){
ids.push(appList[i].id)
}
}
return ids
}
使用管道改写后
let appList = [
{
id: 1,
name: '应用1',
status: 'running'
},
{
id: 2,
name: '应用2',
status: 'destroyed'
}
]
function getRunningAppId(){
return appList.filter(app => items.status =='running').map(app => app.id)
}
管道操作将关注点聚焦到过滤条件是什么,想要的map数据结构是什么,也就是将焦点聚焦到业务需求,而不是关注点被噪音代码分散。
3.5 同一个层次对话
一个函数内部的实现,应该在做同一个层次的事情。
比如在一个战略会议上,别人发言都是谈方向谈策略,你不能发言说自己明天的工作安排是什么,这样的细节和抽象混合在一起,显然是不在一个层面对话,也是非常不合适的。
假设有个执行命令的函数,支持的命令有安装install、卸载destroy和删除delete,现在看看这块代码有什么问题:
function executeCommand(command, params){
switch (command){
case 'install':
installApp(params);
break;
case 'destroy':
destroyApp(params);
break;
case 'delete':
if(params.id){
showToast("id不能为空")
}
request.delete('/api/v1/user').then(res=>{
showToast("删除成功")
})
default:{
}
}
}
function installApp(params){
//安装代码
}
function destroyApp(params){
//卸载代码
}
可以看出来,在executeCommand函数中,install和destroy的抽象层级较高,delete抽象层级较低,在面向细节编程,很明显delete命令的处理和其他两个不在一个层级上,导致整段代码看起来怪怪的,可读性不是很好。
function executeCommand(command, params){
switch (command){
case 'install':
installApp(params);
break;
case 'destroy':
destroyApp(params);
break;
case 'delete':
deleteApp(params);
break
default:{
}
}
}
function installApp(params){
//安装代码
}
function destroyApp(params){
//卸载代码
}
function deleteApp(params){
if(params.id){
showToast("id不能为空")
}
request.delete('/api/v1/user').then(res=>{
showToast("删除成功")
})
}
改写也非常简单,只需要把delete相关操作抽取出来封装成一个函数即可,现在对比下两个executeCommand方法,看看哪个可读性更好。
3.6 明确的参数结构
使用js编程遇到的一个很大问题就是,当参数是个对象时,不知道参数的数据结构,导致进行函数调用时,需要阅读完整的上下文, 才能了解到参数到底是个什么结构,在不使用typeScript的情况下,我们可以通过注释和对象解构来表明对象的具体结构。
//反例
function addNews(news){
//不知道news的结构
}
可以通过js的对象解构来表明参数结构,这样别人调用这个函数,就知道传入什么结构数据了,代码可读性比单纯写一个对象变量更加好。
function addNews({title, content, keywords}){
//现在知道了添加news时对象可以包含title、content、keywords三个属性
}
四、简洁有力的表达
简洁的代码更有表现力,啰里啰嗦的代码表达容易制造更多理解上的障碍。简洁的代码就像是干净的桌面上放了一颗明亮的宝石,啰嗦的代码就行是混乱的桌面上藏了一颗宝石,必须拨开干扰才能发现你想要的东西。
4.1 简化Bool值返回
有时候会在满足不同条件时返回不同Bool值,这时可以把条件直接作为返回值,以简化代码。
//反例
function pass(score){
if(score >= 60){
return true
}else{
return false
}
}
改写成下面的代码是否更易懂。
//正例
function pass(score){
return score >= 60
}
再举一个我CodeReview中遇到的例子,需求是判断文件是否能预览,只支持pdf和docx格式的文件预览。
//反例
function canPreview(fileName){
let flag = false;
if(fileName.endsWith('.pdf') || fileName.endsWith('.docx')){
flag = true;
}else{
flag = false;
}
return flag;
}
上面这个实现就太啰嗦了,完全可以一行代码实现,而且还更加易读。
//正例
function canPreview(fileName){
return fileName.endsWith('.pdf') || fileName.endsWith('.docx')
}
4.2 短路求值
当使用逻辑与运算符 && 时,如果第一个操作数为false,则不会对第二个操作数进行求值,直接返回false,否则返回第二个操作数的值,利用这个特点我们可以通过短路求值简化代码写法。
//改写前
if(condition){
doSomething()
}
//改写后
condition && doSomething()
4.3 三元运算赋值
通过三元运算符,可以简化一些条件赋值操作。
let num = 6;
let isEven = false;
if(num % 2 === 0){
isEven = true;
}
通过三元运算改写:
let num = 6;
let isEven = num % 2 === 0 ? true : false;
需要注意的是,三元运算符虽然可以简化代码,但如果嵌套过多或者条件过于复杂,会影响代码的可读性和维护性。因此,在实际开发中需要根据具体情况选择使用。
4.4 语义化的表达
在判断字符串中是否包含某个字符串时,indexOf和includes相比,明显includes的语义性更强,这也是为什么js在es6中增加includes的原因。
if(fileName.indexOf('pdf') !== -1){
}
if(fileName.includes('pdf')){
}
在判断某个变量是否等于多个值中的一个时,也可以用includes改写,不仅简洁,后续增加新的值时修改也更简单。
if(status === 'running' || status === 'stop'){
}
//更加简洁语义化的表达
if(['running','stop'].includes(status)){
}
语义化的表达还有很多例子,我们平时需要多注意下同一个功能不同写法之间的区别。
我们再举一个例子,现在有个对象obj={a:1, b:2, c:3},我们想获取一个新的对象,只包含obj的a、b属性。
const obj = {
a: 1,
b: 2,
c: 3
}
function getOtherPropties(obj){
let result = {};
Object.keys(obj).forEach(key =>{
if(key !== 'c'){
result[key] = obj[key];
}
})
return result;
}
getOtherPropties(obj)
其实在常见的前端库lodash中,已经封装了相应的方法,只需要一行代码就可以实现,而且语义化也更强。
import {omit} from 'lodash'
const obj = {
a: 1,
b: 2,
c: 3
}
console.log(omit(obj, ['c']))
适当使用第三方库,也有利于简化我们的代码。
4.5 箭头函数替代普通函数
箭头函数相对普通函数代码更加简洁,特别是箭头函数可以直接返回而不用return语句,可以极大简化代码,特别是在一些创建回调函数场景,用处很大。
let products = [
{
id: 1,
name: '产品1',
price: 25
},
{
id: 2,
name: '产品2',
price: 50
}
]
//使用普通函数
let productIds = products.map(function (product){
return product.id
})
//箭头函数
let productIds2 = products.map(product => product.id)
对比两个写法,箭头函数明显更加直观,箭头函数可以让关注点聚焦在要表达的业务含义上,不会受到无关语法代码干扰。
如果用箭头函数返回一个对象,也非常方便,只需要将{}用()扩起来即可,比如根据一个对象数组生成下拉选择的options:
let products = [
{
id: 1,
name: '产品1',
price: 25
},
{
id: 2,
name: '产品2',
price: 50
}
]
//使用普通函数
let productOptions = products.map(function (product){
let option = {};
option.value = product.id;
option.label = product.name;
return option;
})
//箭头函数
let productOptions = products.map(product => ({
value: product.id,
label: product.name
}))
五、不要玩魔术
所谓玩魔术,就是在预期之外发生了一些变化,比如莫名其妙有个值可以使用,或者某个值突然变化了,抑或突然之间产生了某种副作用。
总之不在显式的预期范围内的,都有点像玩魔术,写代码应该尽量减少这种魔术,这不是惊喜,通常是惊吓。
5.1 数据来源在预期之内
以react为例,目前引入了hooks来进行逻辑的复用,相较于之前的高阶组件,hooks的调用更加显式。
通常高阶组件会向被包裹的组件传递属性, 比如下面这个,会向组件传递一个data属性,如果一个组件被多个高阶组件包裹, 则会传递更多属性。
function hoc(WrappedComponent) {
// ...并返回另一个组件...
return class extends React.Component {
constructor(props) {
super(props);
this.state = {
data:[]
};
}
//...省略业务代码
render() {
//高阶组件会向组件传递属性
return <WrappedComponent data={this.state.data} {...this.props} />;
}
};
}
在组件内部可以通过this.props来进行获取,那么当你只看这个组件时,这个props下的data属性从何而来? 要想搞明白这个魔术数据,可能你要沿着包裹一层一层往外查看,增加了阅读障碍。
class ChildComponent extends React.Component {
constructor(props) {
super(props);
console.log(this.props.data)
}
}
而采用hooks这没有这方面的疑问,所有的数据来源都是清晰明确的,可以直接看出data是从useData来的。
function ChildComponent(){
let data = useData()
}
vue中的mixin也是有点像玩魔术,引入mixin后,组件内部多了很多可以访问的属性和方法, 但是这个属性和方法并没有在当前组件定义,如果没有仔细看mixin,甚至觉得这是写代码人的bug, 而且如果引入多个mixin,就更不清楚这些变量和方法从何而来,这简直是在玩魔术, 所以目前vue改用了组合式Api来解决这些问题。
这种魔术的数据来源,节省了一次引入的工作,可是这个节省的工作缺导致可读性大大降低,得不偿失。
程序员最怕的是不确定性,让一切尽在掌握之中才是最佳做法,不要在背后偷偷做事情。
5.2 减少依赖全局数据
注意这里说的是全局数据,包括全局变量以及其他一些全局共享的数据,如vuex、redux,全局数据也是一种魔术,他很神秘,不知从何而来,也不知要往那里去,神出鬼没。
使用全局数据进行首次开发的人很爽,通过全局共享,降低了各模块之间传参的复杂度,但是对于后续维护的人来说, 很不友好,不明白全局数据首次在什么时机设置,对使用的时序有没有限制,比如我在首次设置之前调用就为空,可能会出错, 其次不知道有什么人会对其进行修改,也不知道我对全局数据的修改对其他人有什么影响,多个模块通过全局数据紧紧耦合在一起。
如无必要,尽量减少全局数据
前几年曾经看到过这样一些react代码,将各个组件的内部数据全部放到redux中,包括不需要共享的内部state,现在看来这是一种糟糕的设计。
全局数据只存放多模块组件共享的最小量数据,最大限度减低全局变量带来的耦合和阅读故障,如果某个值不需要多组件共享,就不要放入全局。
将全局数据改为参数
比如在一些utils函数中可能也用到了全局数据,按理说utils是不应该耦合全局数据的,希望utils是一些简单的纯函数,可以在业务代码中调用utils中的函数,然后将全局数据传递过来。
比如我曾经见到过这样的写法,utils提供了一个diff方法,将传递过来的source数据和全局数据中的某个值进行比对:
//utils中的一个函数
function diff(source) {
//依赖全局数据store
let target = store.state.targetData
//对比source和target
}
现在在某个页面的代码中,有人调用了diff函数,试问,如果别人看到这段代码,知道diff操作究竟在和谁比较吗?
diff(sourceData)
我们可以将全局数据变为一个参数,在调用方传递过来
function diff(source, target){
//对比source和target
}
//调用方
diff(source, store.state.project)
通过这样的改写,再看到diff函数,你至少能知道究竟是谁和谁进行diff,将业务逻辑集中在业务代码中,这样utils不和业务耦合,假如后续要和其他数据进行diff,只需要传参即可,不像之前,只能和全局数据中的某个属性进行对比,通过简单的改写不仅增加了代码的可读性,还增加了可复用性和可测试性。
5.3 尽可能使用纯函数
纯函数是所有函数式编程语言中使用的概念。
所谓纯函数就是指在相同的输入条件下,总是返回相同的输出结果,并且不会对外部环境产生任何副作用的函数。纯函数不会修改传入的参数,也不会改变全局变量或者调用其他不纯的函数。它们只依赖于输入参数,而不依赖于外部的状态或者数据。
纯函数有以下特点。
1. 输入不变输出不变
比如一个加法函数,无论什么时候调用,无论调用多少次,只要输入确定了,你总能轻易的预料到输出。
function add(a,b){
return a + b
}
类似于数学中的函数 f(x) = 2*x + 1,x给定了f(x)也是确定的。
2. 不依赖外部,只依赖参数
纯函数的输入只来自参数,而不会使用全局变量或者外部变量
下面的函数就不是纯函数,依赖了外部变量
let sum = 0
function add(a,b){
return sum +a + b
}
3. 纯函数没有副作用
副作用包含很多,比如
- 修改参数
- 修改全局量的值或者外部变量
- 进行网络请求
- 进行dom的操作
- 进行定时器的调用
比如以下这个就不是纯函数,因为它修改了参数
function addItem(arr, item){
arr.push(item)
}
这样就是纯函数,或者深度克隆arr再push也可以
function addItem(arr, item){
return [...arr, item]
}
在实际编程中,肯定不能做到100%全是纯函数,但是我们应该加大纯函数的占比,尽量编写纯函数。
纯函数是最让人放心的函数,它不玩魔术,就像数学中的函数一样,只要给定同样的输入,必然给出同样的输出,纯函数是可信赖的,由于它的这些特性,让代码的可读性更好。
总结
提升代码可读性是我们编程的基本素养,希望大家在编码时,心中时刻想着要是别人来看这块代码能看懂吗?心中有了这根弦,再结合我们上面讲的具体方法,相信短期内就会有很大的提高。