重构的原则
重构的定义
- (名词形式)对软件内部结构的一种调整,目的是在不改变软件可察行为的前提下,提高可理解性,降低修改成本。
- (动词形式)使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
软件开发的两顶帽子
- 添加新功能时,不应该修改既有代码,只管添加新功能并通过测试。(开放封闭原则)
- 重构时不再添加新功能,只管改进程序结构,并通过已有测试。
为何重构
- 重构改进软件设计(Design)消除重复代码,我就可以确定所有事物和行为在代码中只表述一次。
- 重构使软件更容易理解(Maintain)好让以后接手的人看得懂
- 重构帮助找到BUG(Debug)顺着计算机的稍微走一遍,大部分bug就能解决
- 重构提高编程速度(Efficiency)添加新的功能时候顾虑少一点,思路清晰
何时重构
- 事不过三,三则重构
- 预备性重构:添加新的功能时更加容易,让修改多处的代码变成修改一处
- 帮助理解的重构:使代码更容易懂,让代码做到一目了然
- 捡垃圾式重构:复审代码时感觉不好 如果有时间就改了
- 有计划的重构:一般都是有了重大问题,作出规划,逐步替代旧代码
重构的目标
- 难以阅读的程序,难以修改 ==> 容易阅读
- 逻辑重复的程序,难以修改 ==> 所有逻辑都只在唯一地点指定
- 添加新行为时需要修改已有代码的程序,难以修改 ==> 新的改动不会危及现有行为
- 带复杂条件逻辑的程序,难以修改 ==> 尽可能简单表达条件逻辑
代码的坏味道
代码在不断地更新迭代中保持清晰易读的重点,在于能敏锐地察觉到代码的坏味道!
1. 神秘命名
命名这东西刚开始学编程的时候就很是个问题,abcd,xxx1234,拼音缩写等等,什么妖魔鬼怪都有,看别人的代码看到这些东西真的会头大。有时候还会看到把后端返回的数据不管是什么数据类型,都用xxData存储,最后代码里一会把它当对象用,一会把它当数组用。一个好名字能清晰表明自己的功能和用法。数组尽量用xxList,对象尽量用xxObj等方式命名。
如果你想不出什么好名字,有可能背后还隐藏着更深的设计问题。
2. 重复代码
如果要修改重复代码,必须找出所有相关的副本来修改,想想就很累,还很容易出错,需设法提炼成函数。
3. 过长函数
函数越长,越难理解。给小函数良好的命名,阅读代码的人就可以通过名字了解函数的作用,根本不必去看其中写了什么。妙啊!
每当感觉方法的某个地方需要注释来加以说明,可以把这部分代码放入一个独立的方法中,“将意图和实现分离”,并以意图(而不是实现手法)来命名方法。
条件表达式和循环常常也是提炼的信号。
4. 过长参数列表
不用参数就只能选择全局数据,这肯定是不可取的。
改善的几点方法:
- 如果可以向某个参数发起查询获得另一个参数的值,就用以查询取代参数。
- 如果正在从现有的数据结构中抽取很多数据项,就保持对象完整。
- 如果几个参数总是同时出现,就用引入参数对象。
- 如果某个参数被用作区分函数行为的标记,可以使用移除标记参数。
5. 全局数据
全局数据的问题在于:从代码库任何一个角落都可以修改它。
把全局数量用一个函数包装起来,并控制对其的访问,最好搬移到一个类或者模块中,控制其作用域。
6. 依恋情结
函数和另一个模块中的函数或者数据交流频繁,远多于自己所处模块内部交流。最好将此函数移动到那个模块中。比如父子组件的方法,应该放到父还是子。
7. 数据泥团 / 基本类型偏执
如果你有一组应该总是被放在一起的数据,删掉其中一项,其他数据也没有意义,那就应该为它们产生一个新的对象。比如表示数值与币种、起始值与结束值的字段。(例子-拆分函数中priceData)
8. 重复switch
每当想要增加一个选择分支,必须找到所有的switch,并逐一更新。可以使用多态来解决。(例子1)
9. 过长的消息链
一个对象请求另一个对象,然后再请求另一个对象。。。代码与查找过程中的导航结构紧密耦合,一旦对象之间的关系发生任何变化,代码就不得不发生改变。
10. 过大的类
类/函数的设计应当遵循单一职责原则。
11. 被拒绝的遗赠
在JS中较多的情形是由于需求的修改,父组件的传参或者方法在子组件中已经不再需要,这种情况要及时删除已经弃用的props传参或方法。
12. 过长的注释
注释不是用来补救劣质代码的,事实上如果我们去除了代码中的所有坏味道,当劣质代码都被移除的时候,注释已经变得多余,因为代码已经讲清楚了一切。
重构思路
SOLID 原则
OCP:削水果 - 苹果和西瓜
DIP:示例见imageDraw.vue
提炼函数
何时应该把代码放进独立的函数?
有人说代码长度考虑控制在一屏以内、有人说重复性代码应提炼成函数。最合理的解释:“将意图和实现分离”。
示例代码见下方【函数组合成变换】
提炼函数的反向操作是内联函数。如果内联函数同样清晰易读,就不必提炼。
改变函数声明
函数是软件系统的关节,这些关节的最重要元素是函数名(代表意图)。
改进函数名方式:注意描述函数用途,再翻译成为函数名。
封装变量
数据的可访问范围过大,重新组织数据的难度就会增加,可以转化为重新组织函数,把广泛使用的数据用函数的形式封装起来访问。好处是提供了一个清晰的观测点,后续添加修改数据逻辑清晰。
不过,对于数据来说,不可变性更重要,不可变性是强大的代码防腐剂。
引入解释性变量或函数
用变量给表达式提供有意义的名字。
const area = width * height
// bad
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
charge = quantity * _winterRate + _winterServiceCharge
} else {
charge = quantity * _summerRate
}
// good
if (notSummer(date)) {
charge = winterCharge(quantity)
} else {
charge = summerCharge(quantity)
}
拆分函数
// bad
function handle(arr) {
//数组去重
let _arr=[],_arrIds=[];
for(let i=0;i<arr.length;i++){
if(_arrIds.indexOf(arr[i].id)===-1){
_arrIds.push(arr[i].id);
_arr.push(arr[i]);
}
}
//遍历替换
_arr.map(item=>{
for(let key in item){
if(item[key]===''){
item[key]='--';
}
}
});
return _arr;
}
// good
function handle(arr) {
let filterArr = filterRepeatById(arr)
return replaceEachItem(filterArr)
}
函数参数化(令函数携带参数)
如果发现两个函数逻辑非常相似,只有一些字面量值不同,可以将其合并成一个函数,以函数的参数形式传入不同的值,从而消除重复。
/* 锁定 */
lock() {
let checkedIds = this.checkedIds;
if (checkedIds.length == 0) {
this.$Message.warning("请选择要锁定的内容");
return;
}
lock({ id: checkedIds })
.then(res => {
this.refreshList();
})
.catch(fail => {
this.$Message.warning("发生错误" + fail);
});
},
/* 解锁 */
unlock() {
let checkedIds = this.checkedIds;
if (checkedIds.length == 0) {
this.$Message.warning("请选择要解锁的内容");
return;
}
unlock({ id: checkedIds })
.then(res => {
this.refreshList();
})
.catch(fail => {
this.$Message.warning("发生错误" + fail);
});
},
// good
const LOCK_TYPE_OBJ = {
lock: {
requestMethod: lock,
desc: "锁定"
},
unlock: {
requestMethod: unlock,
desc: "解锁"
}
};
handleChangeLockStatus(type: 'lock' | 'unlock'):void {
const { requestMethod, desc } = LOCK_TYPE_OBJ[type];
consttipStr = `请选择要${desc}的内容`;
if (this.checkedIds.length == 0) {
this.$Message.warning(tipStr);
return;
}
requestMethod ({ id: this.checkedIds })
.then(res => {
this.refreshList();
})
.catch(fail => {
this.$Message.warning("发生错误" + fail);
});
}
以查询取代派生变量
强烈建议把可变数据的作用域范围限制在最小范围,这里的派生变量是指基于源数据计算出来的派生可变变量,我们尽量避免的他的原因是,如果源数据发生变化,那么直接使用这个之前计算出的派生变量将发生错误。
所以对应可变源数据采用对象计算风格,也就是每次使用调用他的计算函数得到;
<div :style="{ color: textColor }">测试</div>
...
computed: {
textColor() {
return this.theme === 'dark' ? '#fff' : '#495060';
},
},
如果源数据不可变,那么使用将计算结果封装在数据结构中可以避免重复计算将是不错的选择。
函数组合成变换
把所有计算派生数据的逻辑放到一块,这样可以始终可以在固定的地方找到和更新这些逻辑,避免重复
handleSaveChannel() {
this.$refs['channelform'].validate((valid) => {
if (!valid) {
this.$Message.warning('请填写正确的信息');
return;
}
const { liveAddr } = this.formItem;
let taskList = this.calcTaskList(this.channelInfos, liveAddr);
this.saveTask(taskList);
});
},
saveTask(taskList) {
const { channelId, channelName } = this.formItem;
createTask(this.orgId, this.userId, channelId, channelName, taskList)
.then(this.saveCallBack)
.catch((fail) => {
this.$Message.warning(fail.data.message || '保存失败');
});
},
一些简化条件逻辑的情况:
以卫语句取代嵌套条件表达式 (Replace Nested Conditional with Guard Clauses)
卫语句的精髓:给某一条分支以特别的重视。
if-then-else代码结构传递的消息是:各个分支有同样的重要性。
卫语句告诉阅读者:“这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。”(可能违背单一出口原则,保持代码清晰才是关键)
// bad
let getPayAmount = () => {
let result
if (_isDead) result = deadAmount()
else {
if (_isSeparated) result = separatedAmount()
else {
if (_isRetired) result = retiredAmount()
else result = normalPayAmount()
}
}
return result
}
// good
let payAmount = () => {
if (_isDead) return deadAmount()
if (_isSeparated) return separatedAmount()
if (_isRetired) return retiredAmount()
return normalPayAmount()
}
例子:只有还在公司上班 的员工才需要支付工资,所以这个函数需要检查两种“员工已经不在公司上班”的情况。
使用逻辑或、逻辑与 (这是一个非常简单的,是第一眼就能看出来应该如何重构,但是在有些代码中依然出现过,这就是因为没有重视重构的问题:任何时间都可以是重构的最佳时机。)
分解条件表达式
// bad
if (!aDate.isBefore (plan, summerStart) && !aDate.isAfter (plan. summerEnd)) {
charge = quantity * plan. summerRate;
} else {
charge = quantity * plan. regularRate + plan. regularServiceCharge;
}
// good
if (summer()) {
charge = summerCharge();
} else {
charge = regularCharge();
}
合并条件表达式
条件不同,但最终行为一致的条件检查。
// bad
if (anEmployee. seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;
// good
if (isNotEligibleForDisability()) return 0;
function isNotEligibleForDisability() {
return ((anEmployee.seniority < 2)
|| (anEmployee.monthsDisabled>12)
|| (anEmployee.isPartTime));
}
- 原代码:这里有一些各自独立的条件检查,他们只是恰好同时发生。
- 合并后:只有一次条件检查,只不过有多个并列条件需要检查。
总结,上述讲的这些重构方法,是一些根据不同场景下可能适用的方法。代码在不断地更新迭代中,保持清晰易读 的关键在于能敏锐地察觉到代码的坏味道!然后再结合这些思路和原则进行重构!