从3 个方面增加代码可读性和可维护性

5,704 阅读14分钟

故事

领导💬:“原来项目有个需求变动,需要你去改一下,没有改很多,这个应该很快吧。”

二盛💬:“好👌,我先看一下。”

内心忐忑的二盛打开了那个古老的项目,不看不知道,一看吓一跳,项目的代码大概是这样的:

一文千行洋洋洒洒
每行代码密密麻麻
一气呵成想哪写哪
条理不清一团乱麻

重复代码整齐好看
宁写多次绝不封装
全局变量满天飞翔
想用就用从天而降

变量命名全按顺序
a1 , a2 , a3 , a4
只用for+if语句
实现一切无所畏惧

这里写死那里写死
复用全是直接复制
惜墨如金不写注释
看不懂是别人的事

面对着这些杂乱无章随心所欲的代码,二盛哇的一声哭了出来😭。

成年人的世界没有容易二字,二盛擦干泪,开始小心翼翼地改代码。

最后90%的时间花在了阅读代码,10%的时间花在修改上,如同完成一个多米诺骨牌的项目,成就感油然而生。

于是二盛在工作汇报中自豪地写下:今天改一个XXX(小功能),备注:原来的代码不好读懂。

当领导看到工作报告时的内心活动:“一个小小的功能,改了那么久?原来的代码是一个新手写的,居然还说不好读懂,看来这个员工能力不行啊。”

前言

  • 文章目的:帮助大家写出可读性和可维护性高的代码😁
  • 适合人员:初级人员,以及想让队友好好写代码的朋友们
  • 阅读时长:因人而异,总共4000+字,看不完点个收藏👍⭐

3 个方面

准备数据

// 鼠笼🐀🐀🐀
const mouseList = [
    { id: 'm01', name: '小白鼠', type: '0' },
    { id: 'm02', name: '小黑鼠', type: '4' },
    { id: 'm03', name: '小红鼠', type: '5' },
    { id: 'm04', name: '小橙鼠', type: '3' },
    { id: 'm05', name: '小黄鼠', type: '1' },
    { id: 'm06', name: '小青鼠', type: '1' },
    { id: 'm07', name: '小蓝鼠', type: '2' },
    { id: 'm08', name: '小绿鼠', type: '2', cap:{} },
    { id: 'm09', name: '小紫鼠', type: '5' },
]
// 类型对照表
const typeMap = {
    '0': '家鼠',
    '1': '田鼠',
    '2': '竹鼠',
    '3': '松鼠',
    '4': '米老鼠',
    '5': '快乐番薯'
}

第1方面:表明意图

明确告诉读代码的人你在干什么

下面是例子

📄需求

你去👉委婉的告诉小绿鼠,他老婆出轨了。

✍实现

var flag = false
for (var i = 0; i < mouseList.length; i++) {
    if (mouseList[i].name == '小绿鼠') {
        mouseList[i].cap.color = 'green'
        flag = true
        break
    }
}
if (flag) {
    console.log('我已经告诉他了')
} else {
    console.warn('没有找到小绿鼠')
}

🔍分析

现在我们来分析以上代码做的事情:

  1. 定义一个变量flag, 默认值为false
  2. mouseList进行遍历
  3. 如果数组元素的name小绿鼠
  4. 给该数组元素的cap属性的color属性赋值
  5. 把变量flag重新赋值true
  6. 终止循环
  7. 通过flag判断是否找到,给出提示

可以看到,我们需要看完一整段代码之后,才能知道代码是在做什么,因为第一眼看到的是for+if,我们只能由此得知要进行遍历+判断,而无法得知更明确的意图。

🛠优化

下面这3种方法可以让代码的意图更加明确:

  1. 直接写注释

在两小段代码开通分别添加注释:

  • 在所有老鼠中找到一只名字叫小绿鼠的老鼠,帮他把帽子染成绿色
  • 事情做完了给出提示

写注释简单粗暴,可是十分有效。然而无论是作者写注释,还是读者读注释,都需要耗费时间,因此如果是简单的功能,那么注释是没有必要的,好钢用在刀刃上,注释也应该写在关键之处。

  1. 命名里面给讯息

注释可以省略不写,但是命名一般跑不掉,正所谓命名不规范,队友两行泪,瞎起名伤害的不仅仅是队友,还有将来看代码的自己。

原代码的写法是立一个flag

var flag = false

现在我们把它改成这样

let isFound = false

这样写有3个好处:

  1. ES6let而非const,说明我将来要对这个变量重新赋值,而var只是单纯声明
  2. is开头说明变量是Boolean类型,如果在下文中更一群杂七杂八的变量混在一起,也能一眼认出个大概
  3. isFound的意思是是否找到,这个found一出来,读者马上就知道作者找东西的意图

found 是 find 的过去分词

所以,单看let isFound = false,不看下面的代码,我们就可以推测出作者是在寻找目标,isFound是作为是否找到目标的标识,如果找到目标以后,一定会有isFound = true的代码出现

  1. 使用函数

这里使用函数的意思是,把非必要内容封装进函数中,只留下主要信息,通过主要信息来凸显意图。

封装之前我们先把代码逻辑再拆分细一些, 把寻找目标对目标进行操作分成两步,下面把寻找目标封装成函数,首先提取要素:

  • 范围 (在哪里找)
  • 目标描述 (找啥样的)
  • 数量 (找几个)
  • 结果 (找到没)

以此为来封装函数

/**
 * 数组里面找元素
 * @param {array} array 范围
 * @param {function} callback 目标描述
 * @param {number} count 数量
 * @return {array} 结果
 */
function arrayFindItem(array, callback, count) {
    const result = []
    let _count = 0
    for (let i = 0; i < array.length; i++) {
        if (callback(array[i])) {
            _count++
            result.push(array[i])
            if (_count === count) {
                return result
            }
        }
    }
    return result
}

接着使用它

const result = arrayFindItem(mouseList, function(mouse) {
    return mouse.name === '小绿鼠'
}, 1)

这样一来,代码里面剩下部分的信息就很明确了:

  • 行为: arrayFindItem 在数组中找元素
  • 在哪里找:mouseList
  • 找啥样的:function(mouse) { return mouse.name === '小绿鼠' }
  • 找几个: 1
  • 找到没: result

虽然清晰了不少,但前提是需要把arrayFindItem的参数和返回值了解清楚,而就本例而言,有更好的解决方法: Array.prototype.find

const result = mouseList.find(function(mouse) {
    return mouse.name === '小绿鼠'
})

由于是es6规范里的数组方法,所以大家对它的行为已经非常了解,不需要额外的阅读成本。


第2方面:代码拆分

事情要一件一件地做,代码要一块一块地写。

📄需求

  1. 把笼子里面生的鼠,并按照下面的做法烹饪一下:
    • 竹鼠 -> 油炸
    • 家鼠和田鼠 -> 水煮
    • 番薯 -> 碳烤🔥
  2. 做成晚餐,我晚上要吃🍽

✍实现

新手可能写出来的代码

var dinnerList = []
for (var i = 0; i < mouseList.length; i++) {
    if (mouseList[i].isRaw != true) {
        if (mouseList[i].type === '2') {
            // 宽油竹鼠
            mouseList[i].recipe = '油炸配方'
            mouseList[i].newName = '油炸' + mouseList[i].name
            // ...被省略的油炸的其他操作
            mouseList[i].isRaw = true
            dinnerList.push(mouseList[i])
        } else if (mouseList[i].type === '0' || mouseList[i].type === '1') {
            // 水煮家鼠 + 田鼠
            mouseList[i].recipe = '水煮配方'
            mouseList[i].newName = '水煮' + mouseList[i].name
            // ...被省略的水煮的其他操作
            mouseList[i].isRaw = true
            dinnerList.push(mouseList[i])
        } else if (mouseList[i].type === '5') {
            // 烤番薯
            mouseList[i].recipe = '碳烤配方'
            mouseList[i].newName = '碳烤' + mouseList[i].name
            // ...被省略的碳烤的其他操作
            mouseList[i].isRaw = true
            dinnerList.push(mouseList[i])
        }
    }
}
console.log(dinnerList)

🔍分析

  1. 定义数组dinnerList
  2. 遍历mouseList
  3. 找出所有属性isRawtrue的数组元素
  4. 在3的基础上,根据属性 type的不同,进行不同的操作
    • type2 ==> 油炸操作
    • type01 ==> 水煮操作
    • type5 ==> 碳烤操作

很明显,把烹饪过程直接写在for循环里面会造成循环过长,不利于阅读,所以应该将其拆分出来。

🛠优化

那么我们先进行第一步,将烹饪方法拆分出来

/* ****** 这里是烹饪的方法们 ****** */

function fry(mouse) {
    mouse.recipe = '油炸配方'
    mouse.newName = '油炸' + mouse.name
    // ...被省略的油炸的其他操作
    mouse.isRaw = true
}
function boil(mouse) {
    mouse.recipe = '水煮配方'
    mouse.newName = '水煮' + mouse.name
    // ...被省略的水煮的其他操作
    mouse.isRaw = true
}
function roast(mouse) {
    mouse.recipe = '碳烤配方'
    mouse.newName = '碳烤' + mouse.name
    // ...被省略的碳烤的其他操作
    mouse.isRaw = true
}

这样我们就有3个烹饪方法了,把它们放一起给上注释,既清晰又方便维护。

假设现在的需求是修改某一种烹饪方法,我们只需要找到方法,并修改方法内部的实现就搞定了,甚至不用去管该方法在哪里被调用。

还没完,接着把代码补充完整

var dinnerList = []
for (var i = 0; i < mouseList.length; i++) {
    if (mouseList[i].isRaw != true) {
        if (mouseList[i].type === '2') {
            fry(mouseList[i]) // 宽油竹鼠
            dinnerList.push(mouseList[i])
        } else if (mouseList[i].type === '0' || mouseList[i].type === '1') {
            boil(mouseList[i]) // 水煮家鼠 + 田鼠
            dinnerList.push(mouseList[i])
        } else if (mouseList[i].type === '5') {
            roast(mouseList[i]) // 烤番薯
            dinnerList.push(mouseList[i])
        }
    }
}
console.log(dinnerList)

这段循环中出现了if... else if... else if...,并且判断的对象都是type,这就证明了里面有可以拆分出来的逻辑。

回顾一下我们最开始的需求

  • 竹鼠 -> 宽油竹鼠
  • 家鼠和田鼠 -> 水煮
  • 番薯 -> 碳烤

让一段代码的逻辑贴近需求,那么无论是代码可读性还是应对需求变更的能力,都会上升一个层次。

接下来就是把需求转换成代码: 左边用类型代替,右边用函数代替,初步版:

条件 操作
type2 油炸操作
type01 水煮操作
type5 碳烤操作

接着用代码符号代替:

type cookFn
2 fry()
0 || 1 boil()
5 roast()

到这一步会发现这种对映关系就是key => valuekey是老鼠的type, value是烹饪的方法cookFn, 所以我们理所应当用对象来存储对应关系

// 老鼠烹饪方法映射表
const mouseCookFnMap = {
    // type: cookFn 
    '0': boil,
    '1': boil,
    '2': fry,
    '5': roast
}

完美!你如果有强迫症的话,也可以把所有的类型都补充完整,像这样

// 老鼠烹饪方法映射表(强迫症版)
const mouseCookFnMapIllVer = {
    // type: cookFn 
    '0': boil,
    '1': boil,
    '2': fry,
    '3': undefined, // 未指定烹饪方法
    '4': undefined,
    '5': roast
}

写好了就马上用一下

var dinnerList = []
for (var i = 0; i < mouseList.length; i++) {
    if (mouseList[i].isRaw != true) {
        var cookFn = mouseCookFnMap[mouseList[i].type]
        if (cookFn) { // cookFn !== undefined
            cookFn(mouseList[i])
            dinnerList.push(mouseList[i])
        }
    }
}
console.log(dinnerList)

这时候,你收到一个需求变动:“宽油竹鼠太费油了,给我改成碳烤。”

只需要把'2': fry,改成'2': roast,就🆗了。

并且,使用映射关系表可以轻松应对某些需求更为复杂的场景。

比如说宽油竹鼠,实际上并非油炸就能完成的,竹鼠的皮肉比较厚实,还需要长时间的焖煮,所以对于竹鼠,需要先油炸焖煮

此时,映射表的value就不单单是一个方法了,应该是多个方法并且是有序的,显然可以用数组来存储:

// 加一个`焖煮方法`
function braise(mouse) { 
    // ... 省略的焖煮方法具体实现 
}

// 老鼠烹饪方法映射表加强版
const mouseCookFnMapPlus = {
    // type: cookFnArray
    '0': [boil],
    '1': [boil],
    '2': [fry, braise],
    '5': [roast]
}

使用的时候将直接调用方法

cookFn(mouseList[i])

改成遍历数组依次调用

cookFnArray.forEach(cookFn => cookFn(mouseList(i)))

这里实在不想写for循环了,用了forEach,下文会劝你们不要尽量写for循环


第3方面:去除冗余

这一方面主要是从语法层面上,来探讨如何去掉代码中的冗余,具体做法是找到代码中与主题无关或重复的部分(主要是变量),尝试去除它们。

这里就不加新的需求了,直接把上面的例子拿过来用

箭头函数➡

ES6箭头函数的优点有两个:

  1. 改变函数内this指向

过去为了将函数内部的this指向到外层作用域,主要方法是

var that = this
// or
var self = this

讲真的,看到that我头都大了😰,每个函数开始前都定义一个that不累吗?

而箭头函数中的this就是指向到外层的,彻底去除了上面这种冗余的代码!💯

能写=>的时候,就不要写function。与其说用箭头函数是为了将this指向到外层,不如说function关键字是为了将this指向到本层才会去用。

  1. 简化写法

先感受一下

mouseList.find(function(mouse) {
    return mouse.name === '小绿鼠'
})
// 箭头函数写法
mouseList.find(mouse => mouse.name === '小绿鼠')

少写很多字有没有,附上写法对比

写法 function (参数) => { 自动return 函数体 }
原写法 function (参数) { 函数体 }
箭头1 (参数) => { 函数体 }
箭头2 (参数) => // 有 单行代码

箭头函数太棒了!写者能少写,看者能少看。具体使用看文档,我们接着往下看

循环

首先看这个for循环,它又长又宽

for (var i = 0; i < mouseList.length; i++) {
    if (mouseList[i].name == '小绿鼠') {
        mouseList[i].cap.color = 'green'
    }
}

很明显此处的变量i毫无意义,那么如何去除i呢?🤔

第一种是使用ES6for..of

for (const mouse of mouseList) {
    if (mouse.name == '小绿鼠') {
        mouse.cap.color = 'green'
    }
}

不过使用for...of,即使想要下标 i 它也给不了,推荐一般情况下,能用 forEach 的时候都用 Array.prototype.forEach()

mouseList.forEach((mouse) => {
    if (mouse.name == '小绿鼠') {
        mouse.cap.color = 'green'
    }
})

说明:for...of可以遍历所有部署了iterator(迭代器)的数据,而forEach仅仅是数组原型上的方法。但是你如果铁了心要用forEach,可以利用展开运算符(...)来把可迭代对象转换成数组:[...iterableValue].forEach()

forEach虽然好用,但是千万别只用forEach用到死,数组还有那么多好用的方法,它们封装得更完整也更具语义化,对数组方法不熟悉的话可以多看几遍文档

知道有写着方法,一直想不起来去用怎么办?

在写循环之前,先想想自己最后想要什么,有了明确的目标之后再下手

目标 手段 返回值
找一项 find 数组元素 (没找到是undefined)
找一项的下标 findIndex number(没找到是-1)
找多个(过滤) filter array
复制全部并改造 map array
有部分是? some boolean
全都是? every boolean
... ... ...

解构(析构)

变量的解构赋值(destructuring)

变量的解构有很多种,都差不多,这里只介绍最常用的一种,对象解构

例子

假设我们要取出小白鼠的几个属性,不用解构赋值是这样的

const whiteMouse = { id: 'm01', name: '小白鼠', type: '0' }
const id = whiteMouse.id
const name = whiteMouse.name
const type = whiteMouse.type

用了解构赋值是这样的

const whiteMouse = { id: 'm01', name: '小白鼠', type: '0' }
const { id, name, type } = whiteMouse

优势很明显了,去掉了很多冗余的代码。

解构可以用在很多地方,只要是取对象的某个属性赋值给一个变量,就可以用解构,下面是小绿鼠的例子的加强版

const greenMouse = mouseList.find(mouse => mouse.name === '小绿鼠')
if (greenMouse) {
    greenMouse.cap.color = 'green'
    greenMouse.cap.size = 'big'
    greenMouse.cap.brightness = 'high'
}

由于担心太过委婉以致于小绿鼠没有发觉,我们增大了帽子🎩尺寸并且让帽子变得更加耀眼。

在遇到这种一个对象属性在后文中被多次使用的情况,最好用一个变量来存一下,避免多个 对象.属性.属性... 的写法让代码臃肿不堪,影响阅读。

const greenMouse = mouseList.find(({ name }) => name === '小绿鼠')
if (greenMouse) {
    const { cap } = greenMouse
    cap.color = 'green'
    cap.size = 'big'
    cap.brightness = 'high'
}

好理解也好用,不过细心的话会发现这里还有另一个地方也用了解构,就是.find()的回调函数的参数部分。

这样写可以减少一个自定义的变量mouse,它也是与主题无关的,而且定义出来只用一次,也算一种冗余。

解释一下这个解构

(mouse) => mouse.name === '小绿鼠'

.find()传入的回调函数(mouse) => mouse.name === '小绿鼠', 它的第一个参数mouse,是mouseList中的元素,也就是

   { id: 'm01', name: '小白鼠', type: '0' } // 第一次回调运行时`mouse`的值
   { id: 'm02', name: '小黑鼠', type: '4' } // 第二次回调运行时`mouse`的值
   // ...

既然mouse是一个对象,我们只需要它的name属性,那就({ name })只不过是把

const mouse = { id: 'm01', name: '小白鼠', type: '0' }

改成了

const { name } = { id: 'm01', name: '小白鼠', type: '0' }

解构真的很常用,请求接口回来的时候,就经常会这么写

async function getData() {
    const { code, msg, data } = await requestFn()
    // ...
}

所以这里说一句,请求接口后把回调函数.then()收起来吧😂,你看这async + await,它不清晰吗,可读性不高吗?

最后

周末两天的时间全花在这篇文章上面了,希望对各位朋友有所帮助。如果文中有什么错误,或者有什么建议,欢迎指出,谢谢大家😘。