如何写出好代码之可读性

2022-11-21  本文已影响0人  蛰伏已久_d38b

可读性是好代码的一个重要指标,只有代码能让人读懂,才有可维护性,所以可读性是可维护性的基础。

如今大部分程序员都知道可读性很重要,可是如何提高可读性,有哪些具体可操作性的方法呢,本文从命名、短小精悍、清晰的结构、不玩魔术四个大的方面进行总结,由于笔者本人是前端开发,所以示例大部分用js,不过道理基本都是相通的,才疏学浅,欢迎指正。

一、 命名

提升代码可读性的最快速最有效的方法就是命名,别的开发技能需要大量的学习才行,而命名是我们很快可以掌握的,
关键是要有重视命名的意识。

想要快速提高编程水平,从每一个命名开始!

见名知意

名称要能反应代码的意图,看到名称就能知道作者想要表达什么。
比如CR中经常可以看到的:

let arr = []  //arr是什么? appList?
let obj = {}  //obj代表什么? appDetail? 
let str = ''

//e是什么,结构是怎么样的,必须上下搜索handle才能明白
//handle({appName, appStatus})?
function handle(e){
    if(e==0){
        
    }
}

//data是什么,状态status?
function getColor(data){
    
}

//求求你饶了我吧,flag是啥? isAdmin、canOperate?、shouldCreate
function create(flag){
    if(flag){
        
    }else{
        
    }
}

不好的命名带来理解上的困难,有时候必须要看完整个函数,才能猜测出大致的含义,有的要去结合上下文,
看看别处传来的参数是什么含义,而如果传入参数的地方,命名也不良好,就要继续往上查找了,经过这么一串查找,
有时候都快忘记为什么要去看最初那个函数了...

常见的命名方式

平时使用使用最多的命名大致分为这样几种:资源、状态、 事件、动作、条件

去除无意义的词

像数据类型这样的词没有太多的意义,通常可以去除或者换成更友好的词

//bad
let appArr = []
//good
let appList = []


//bad,肯定是函数,无需多言
function deleteFun(){
    
}
//good
function deleteResource(){
    
}
//或者有时想要表达事件处理函数,又容易和其他重名
//可以添加Handle代替Fun,Handle一般代指事件的处理函数,调用方一般为事件
function deleteResourceHandle(){

}

好名字可以念出来

对比下下面的代码

function delProdFun(){
    
}

function deleteProductHandle(){
    
}

当和同事沟通时,"哎,小李,你的那个d-e-l-p-r-o-d-f-u-n(逐个单词念出来)函数好像有点问题哎~"

代码是要和别人沟通的,必须能够通过人的语言讲出来,否则写的时候自己没感觉,一到多人沟通的时候可能要闹笑话了。

如无必要,尽量不用简写。

好名字不怕长

有时候会担心名字太长会不会不太好,是不是太长了写的就慢,其实不是,目前编辑器都有提示功能,
写出几个字符就会有推荐名称可以选择,不会增加编码负担。

长的名字通常具有较好的表现力,能把要做的事通过命名表现出来,所以好的命名就是注释、文档。

//同步状态到集群中
function syncStatus(){
    
}

function syncStatusToCluster(){
    
}

较短的名称一般很容易和别人重复,而较长的名字一般都有其特定场景,不容易和其他业务重复,对于全局搜索也比较友好。

拒绝误导

更有甚者,有时候命名和要表达的意思可能是反的,这种误导性的命名,可以当做bug处理了。
特别是在一些Boolean值应用时容易出现。

export function dealPathWithParams(url, obj,isEnvToProject) {
  if (!isEnvToProject) url = isEnv(url);
}

不过幸好,一般都不会犯这样的错误,但是下面这个错误非常场景,比如在get命名的函数中会做很多set的工作。

function getDetail(){
    requrst(url,params).then(_=>{
        setData()
    })
}

比如在获取cache或者localStorage时做一些set操作,命名和要做的事情不一致,或者名称只说了一件事,
函数内部干了多件事,这也同时违反了单一职责原则。

function getCache(key){
    if(!cacheData[key]){
        setCache(key, '')
    }else{
        clearCache(key)
        return cacheData[key]
    }
}

统一表达

一个团队沟通要想高效,必须使用相同的语言,一致的表达方式,减少不必要的学习及认知成本,写代码也是一样。
特别是在涉及一些业务问题时,同一个业务,如果使用不同的词语来表达,难免增加不必要的沟通理解成本。

制定一个领域词汇表

可以将业务领域内的相关词汇抽取出来形成一个词汇表,一般为名词,代指业务中涉及的资源和概念。

一来后续新人进入可以通过这些名词来了解业务的大概面貌, 二来统一了大家在业务方面的认知,减少沟通成本,
如什么是Commodity和product,接入环境到底用Environment还是Project,
机器是用Host还是Machine? 如果没有明确下来,读写代码时可能就很迷惑。

变量名 含义 备注
P3M 百度P3M商品
Commodity 商品组
Environment 环境 接入环境、版本
App 容器应用
AppVersion 容器应用版本
NativeApp 裸机应用
ResourcePool 资源池
Cluster 集群
Host 机器
Delivery 交付中心

除了业务领域统一用词外,还有一些通用的字段可以规范下来,比如创建时间是用create_time还是create_at,
创建人是create_by还是creator,虽然表达含义并无多大区别,但是经常变化也会让人感觉不够规范专业。

统一命名格式

一个团队必须统一命名格式,不同格式的命名表达不同的含义,通过命名就可以指定变量是做什么用的,
而且也让外人看来,更加的专业,增强信任感。试想一下,假如我们对外提供了一个接口,有的返回的变量格式是下划线,
有的是大驼峰,别人又怎么能相信功能是靠谱的呢?

一般常见的命名规范如下:

有了规范,在CR时就可以针对问题进行评论,否则有时候会感觉,这样也行那样也行,最后造成代码的混乱。

最后提醒一下,规范并不是为了让人记忆的,最好是搭配ESLint这种校验工具,我们的目的是产出规范的代码,
而不是让每个人都背会规范,增加新人融入成本。

二、短小精悍

除了命名之外,第二个最容易掌握的技巧就是把代码弄短。

虽然短小不是目的,但却是提高代码可读性的一个关键手段,短小的代码有以下好处:

写文章还是写诗

比如我要做一道中原名吃蒸面条,我们来看看两种表达方式有什么不同

文章的写法:

《蒸面条》
我去菜市场买了4块钱的面条,然后又买了一袋豆芽,还买了新鲜的五花肉,
回到家里,把豆芽洗干净,五花肉切片,拍了几颗大蒜,又找了两根葱切段,还切了几篇姜,然后起锅烧油,
加入五花肉炒出油,然后加入葱姜蒜爆香,翻炒一会再加入豆芽,继续大火翻炒,最后加入水,水开放上蒸笼,
把面条放上去,蒸个20来分钟,关火焖一会。

用代码写的文章

function zhengmiantiao(personCount){
    goToMarketByBike()
  if(personCount < 3){
      buyNoodles('500g')
  }else{
    buyNoodles('1000g')
  }
  bugDouya('200g')
  let meatPrice = getPrice('meat')
  if(meatPrice > 18){
      buyMeat('500g')
  }else{
    buyMeat('1000g')
  }
  washDouya()
  cutMeat()
  paiSuan()
  qieCong()
  qieJiang()
  fire()
  drainOil()
  if(temperature > 300){
      putMeat()
      putDouya()
      putWater()
  }
  sleep('5min')
  putNoodles()
  sleep('20min')
}

接下来采用诗的写法:

《蒸面条》
买菜,买面条、买豆芽,买五花
配菜,洗豆芽,切五花,拍蒜、葱切段、姜切片
炒菜,起锅烧油,炒五花,加调料,加豆芽,加水烧开
焖面,放蒸笼,铺面条,蒸20分钟,焖5分钟
function zhengmiantiao(person){
  buyFoods(person)
  prepare()
  friedVegetable()
  braisedNoodles()
}
function buyFoods(person){
  goToMarketByBike()
  if(personCount < 3){
    buyNoodles('500g')
  }else{
    buyNoodles('1000g')
  }
  bugDouya('200g')
  let meatPrice = getPrice('meat')
  if(meatPrice > 18){
    buyMeat('500g')
  }else{
    buyMeat('1000g')
  }
}

function prepare(){
  washDouya()
  cutMeat()
  paiSuan()
  qieCong()
  qieJiang()
}

function friedVegetable(){
  fire()
  drainOil()
  if(temperature > 300){
    putMeat()
    putDouya()
    putWater()
  }
  sleep('5min')
}

function braisedNoodles(){
  putNoodles()
  sleep('20min')
}

可以看到,采用诗一样的写法,更容易让人看懂整个流程,如果需要去了解某个细节,再进入某个细节函数进行查看即可,
整体可读性得到大大的提升。

拆分并不是简单的把一个大函数实现硬性拆解到几个小函数,而是真正的去分解一个业务流程,各个子函数之间互相解耦,
并不互相干涉。 以上面蒸面条为例,假如我们发现菜糊了,那我们就去炒菜函数friedVegetable检查,是否温度不合适,
如果发现肉块太大了,那就去优化配菜函数prepare,在试吃人员提出bug之后,我们无需浏览整个蒸面条的细节,
而是可以直接定位到bug可能存在的地方,只阅读那块代码即可,通过这种方式,降低了认知负担,提高了可读性。

用结构化的思维去思考,用结构化的形式去编程。

仅在最底层的函数中实现细节,上层函数更加关注流程和结构。

臃肿的文件

当接手一个几千行的代码时,想必都会从由衷的说一句'oh shit',不管你有多少理由写出这样的代码,
一定在某种程度上缺乏深度思考和代码设计,很少遇到一个优秀的开源库,里面出现上千行的代码,一般都100-300行之间,
他们的复杂度肯定要比我们平时的业务复杂的多,他们都没有超过千行,我们有什么理由呢?

超大的类

一般我们前端在写业务时很少遇到写类的情况,但是如果是要写一些底层库,通常还是避免不了的,针对超大的类,也有很多方法,
减少类文件的大小。比如我们常用的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();
  }
  
}

超大的页面文件

现在的前端组件化开发已经非常成熟了,Vue和React等框架为我们实现组件化提供了很好的支持,我们可以很方便的进行组件开发。

页面就是由一个一个的组件构成,通过将一个大的页面拆分成一个一个小的组件,一方面符合分治的思想,将大任务拆分为小任务,
然后逐个去实现,一个大的功能也许很复杂,但是具体到一个个的小组件上,都是一个可以实现的小任务,自上而下拆分,自下而上实现。

比如在实现一个复杂功能的时候,我一般习惯上来就按照功能拆分成不同的组件,然后再一个一个去实现。

以一个简单的列表页,上来可能就先写几个空的组件文件,并不去真正实现,也许只是简单的返回一个div,
先把整体的页面架子搭好,然后再去依次实现,其他更复杂的功能也是同样的道理。

<template>
  <div>
    <TableHeader>
      <OperateButtons />
      <SearchForm />
    </TableHeader>
    <CommonTable />
  </div>
</template>

将大页面拆分成小组件去实现,还有个好处就是可以多人并行开发,如果不这么拆分,就算有10个人,也只能一个人写,其他人看。
毕竟我们没法10个人在一个文件里面去写代码,那样造成的冲突不敢想象。

组件化拆分带来的另一个好处就是复用,通过抽取小的公共的组件,能够大大提升后续功能的开发速度,提升研发效率及质量。

一般一个页面文件,不建议超过300行,dom结构不建议超过100行,否则就带来很大的理解负担。

小文件一定比大文件更易读,如果不是,那一定是拆分人的错。

总结

三、清晰的结构

使用卫语句

卫语句也就是提前return,防止if嵌套层次太深。

function install() {
  if (hasCluster()) {
    if (hasHost) {
      installApp()
    } else {
      showToast("没有机器")
      return
    }
  } else {
    showToast("没有集群")
    return;
  }
}

使用卫语句改写后如下,改写的关键就是翻转if条件,让其尽快return

function install() {
  if (!hasCluster()) {
    showToast("没有集群")
    return;
  }

  if (!hasHost) {
    showToast("没有机器")
    return

  }
  installApp()
}

switch代替多个if else

function commandHandle(command){
    if(command === 'install'){
        install()
    }else if(command === 'start'){
        start()
    }else if(command === 'restart'){
      restart()
    }else{
        showToast('未知命令')
    }
}

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()
}

跳出链式调用

这里的链式调用是指在一个函数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件事,获取数据、格式化数据、给表单赋值,对整体流程有了明确的认识,
后面有需要可以再分别进入具体函数进行查看。

使用管道代替循环

使用管道操作可以大大简化代码的写法,去除一些无用的代码干扰,只关注需要关注的数据。

比如从一个应用列表找出运行中的应用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数据结构是什么,也就是将焦点聚焦到业务需求,
而不是关注点被噪音代码分散。

同一个层次对话

一个函数内部的实现,应该在做同一个层次的事情。

比如在一个战略会议上,别人发言都是谈方向谈策略,你不能发言说自己今天的工作安排是什么,这样的细节和抽象混合在一起,
显然是很不合适的。

比如我们上面提到的蒸面条函数,整个函数想要表达蒸面条的一个具体流程,就不要把怎么炒菜的过程写在整个函数中,
炒菜的细节和买菜、配菜、焖面,不在一个抽象层次,显得很混乱。

function zhengmiantiao(person) {
  buyFoods(person) //买菜
  prepare()        //配菜
  //大谈炒菜的细节
  fire()
  drainOil()
  if (temperature > 300) {
    putMeat()
    putDouya()
    putWater()
  }
  sleep('5min')
  //炒菜结束
  braisedNoodles() //焖面
}

明确的数据结构

使用js编程遇到的一个很大问题就是,不知道参数的数据结构,导致进行函数调用时,需要阅读完整的上下文,
以了解到底参数是个什么结构,特别是在参数是对象时,这个问题更加明显。

在不使用ts的情况下,我们可以通过注释和对象解构来表明对象的具体结构。

function addNews(news){
    //不知道news的结构
}

可以通过js的对象结构来表明参数结构,这样别人调用这个函数,就知道传入什么结构数据了。

function addNews({title, content, keywords}){
    //不知道news的结构
}

四、不要玩魔术

所谓玩魔术,就是超出预期,比如莫名其妙有个值可以使用,或者某个值突然变化了,抑或突然之间产生了某种副作用。

总之不在显式的预期范围内的,都有点像玩魔术,写代码应该尽量减少这种魔术,这不是惊喜,通常是惊吓。

数据来源在预期之内

以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来解决这些问题。

这种魔术的数据来源,节省了一次引入的工作,可是这个节省的工作缺导致可读性大大降低,得不偿失。

程序员最怕的是不确定性,让一切尽在掌握之中才是最佳做法。

依赖全局数据

注意这里说的是全局数据,包括全局变量以及其他一些全局共享的数据,如vuex、redux,全局数据也是一种魔术魔术,
他很神秘,不知从何而来,也不知要往那里去,神出鬼没。

使用全局数据进行首次开发的人很爽,通过全局共享,降低了各模块之间传参的复杂度,但是对于后续维护的人来说,
很不友好,不明白全局数据首次在什么时机设置,对使用的时序有限制,比如我在首次设置之前调用就为空,可能会出错,
其次不知道有什么人会对其进行修改,也不知道我对全局数据的修改对其他人有什么影响,多个模块通过全局数据紧紧耦合在一起。

如无必要,尽量减少全局数据

前几年曾经看到过这样一些react代码,将各个组件的内部数据全部放到redux,包括不需要共享的内部state,
现在看来这是一种糟糕的设计。

全局数据只存放多模块组件共享的最小量数据,最大限度减低全局变量带来的耦合和阅读故障。

将全局数据改为参数

比如在一些utils函数中可能也用到了全局数据,按理说utils是不应该耦合全局数据的,希望utils是一些简单的纯函数,
可以在业务代码中调用utils中的函数,然后将全局数据传递过来。

比如utils提供了一个diff方法,将传递过来的数据和全局数据中的project进行比对

//utils中的一个函数
function diff(source) {
  //依赖全局数据store
  let target = store.state.project
  //对比source和target
}

我们可以将全局数据变为一个参数,在调用方传递过来

function diff(source, target){
  //对比source和target
}

//调用方
diff(source, store.state.project)

通过这样的改写,将业务逻辑集中在业务代码中,这样utils不和业务耦合,假如后续要和其他数据进行diff,
只需要传参即可,不像之前,只能和全局数据中的某个属性进行对比,通过简单的改写增加了可复用性和可测试性。

纯函数

纯函数是最能预测的函数,它不玩魔术,就像数学中的函数一样,只要给定同样的输入,必然给出同样的输出,纯函数是可信赖的。
不管什么时候调用,只要输入不变,输出也永远不会变。

纯函数是所有函数式编程语言中使用的概念,这是一个非常重要的概念,我们应该尽可能的使用纯函数,
纯函数是可读性最好的函数。

满足以下条件的函数是纯函数。

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. 纯函数没有副作用

副作用包含很多,比如

比如以下这个就不是纯函数,因为它修改了参数

function addItem(arr, item){
    arr.push(item)
}

这样就是纯函数,或者深度克隆arr再push也可以

function addItem(arr, item){
    return [...arr, item]
}

在实际编程中,可以将一些函数中的纯函数部分提取出来,尽量增加可预测的部分,减少不可预测的部分

总结

前端同学欢迎添加vx好友交流:_hit757_

后续会继续总结如何提高复用性如何解耦,欢迎专注~~

上一篇下一篇

猜你喜欢

热点阅读