02-如何提升代码可读性

angrybird2332022-02-05 22:04:43

从过去面向计算机硬件编程的语言(汇编),到现在更符合人类思考和抽象习惯的高级语言(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行左右),才能知道整个函数的作用。

我们可以将该函数拆分为以下几个小函数:

  1. getFullName :从用户数据中获取完整姓名
  2. calculateBirthYear :从用户数据中获取年龄并计算出出生年份
  3. validateEmail :验证用户数据中的电子邮件地址
  4. validatePassword :验证用户数据中的密码
  5. 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%全是纯函数,但是我们应该加大纯函数的占比,尽量编写纯函数。

纯函数是最让人放心的函数,它不玩魔术,就像数学中的函数一样,只要给定同样的输入,必然给出同样的输出,纯函数是可信赖的,由于它的这些特性,让代码的可读性更好。

总结

提升代码可读性是我们编程的基本素养,希望大家在编码时,心中时刻想着要是别人来看这块代码能看懂吗?心中有了这根弦,再结合我们上面讲的具体方法,相信短期内就会有很大的提高。

最后更新时间 9/18/2024, 8:13:52 AM