此文,基于《重构-改善既有代码的设计》第2版,的学习之后的一些想法。
什么是重构?
重构是在不改变软件可视范围内的对代码的调整,主要提高代码可读性,降低修改成本。
在这本书中,任何一个重构方法的介绍,作者总在强调一件事情,重构代码是,请注意测试,稳定是重构的基本原则。
什么时候重构?
在代码逻辑不断增加的时候,也许有人想起重构代码,但是,每一次有这个念头,总会被无数个理由击败。运行的好好的,不要动他了,又不是不能用;重构会浪费很多的时间;排期紧;又不是我写的;代码太长了,太难理解,万一搞坏了怎么办;
所以,一个很重要的问题?什么时候重构呢?
这里我将其分为两类
一:立刻重构
需要立刻重构的代码,具备一个特性,那就是影响不大,且能随手解决。
1.添加新功能时
当你添加新功能时,发现又一些代码可以被提炼复用、或者同一模块有一些可以优化的地方,可以随手优化一下;
2.修复bug时
修复bug时,可以随手做一些优化,但不建议做太多的优化,毕竟解决bug时最紧急的事情;
3.需要理解代码,梳理逻辑时
这是一个很重要的时机,理解代码或数据逻辑时,当你发现代码的可读性很差,需要的成本很高时,这部分代码就已经到了需要重构的时候了;
二:排期重构
排期重构,就需要我们专门有一段时间去进行代码重构,可能是2天~2周。
1.小型重构-有计划的重构
代码到了需要专门需要拿出一段时间去重构的时候,问题就已经很严重了,但是,此时,建议,每一次专门的重构,时间在1~2周内,长时间段的重构,会让开发变得极端;
同时,建议,专门的重构,可以根据模块进行重构,或者根据某一类问题进行重构
比如,在同一此计划中,只针对于鉴权模块/组织架构模块重构;或者,此次重构,只重构if/else类型的代码;
重构的集中,能够让重构的效率更高;
2.大型重构-比如更新某个依赖库
在一个较为庞大的项目中,修改依赖库,会触发比较多的改动,需要专门进行重构,并安排足够的测试;
3.code review时
这个一个非常好的时机,如果是团队每周代码review一次,那重构的时间也就一天或者半天,如果,一月一次review,可能就要有一个重构时间了;
什么时候需要重构
红色:我有自己的想法
绿色:不需要解释的异味
- 神秘命名
- 重复代码
- 过长函数
- 过长参数列表
- 全局数据
- 变量放全局,各处使用随时赋值,永远不知道是哪个逻辑的bug
- 可变数据
- 在不同的地方,修改更新了同一个变量的数据,导致数据混乱!
- 优化:
1.封装变量
2.封装变量为类,即以查询取代派生变量
- 发散式变化
- 例子:在一个类中,加入一个新的数据库,需要修改3个函数、修改这个引用,我需要改动5个函数
这样,将数据关心的数据挪移到同一个类中进行统一管理输出.
在调用方,只关心一个调用,不关心数据的来源
- 霰弹式修改
- 修改某个变化,就要在多个类中进行各种细微的修改,此时,就可以将这些相关的类挪移到同一个类中,进行管理
- 依恋情结
- 一个类中的的函数与其他函数或类的数据交换特别多,那么如此严重的数据依赖,为什么不考虑转移函数到其他的相关类中呢
- 数据泥团
- 同样的数据结构出现在多个地方,可以抽离出来作为类处理
- 基本类型偏执
- 过度使用基本类操作,如果有大量关于某一类的运算或者断言,可以进行封装结构或者多态方式替代
- 重复的switch
- 重复的switch可以被提炼,并不代表这所有的switch都必须使用多态来处理
- 循环语句
- 用管道符更优雅的处理循环,比如filter和map
- 但是在某些情况下,原生的写法比管道符的效率更高,所以,管道符,视情况而定
- 例子:
- 冗余的元素
- 随着代码量的增加,一些函数或者数据会被增加很多冗余的元素,使用类或者内联函数,清晰提取相关数据元素,进行统一处理
- 夸夸其谈的通用性
- 缩小类的使用范围,做不必要的通用性缩减,抽离的超类,没有那么大的责任,就可以放到小的模块中
- 临时字段
- 创建的临时字段,有时候,你的意思就是临时作用,然而,你不知道它的临时结束日期是什么时候,所以,可以使用搬移函数将相关函数或字段放入新的类中,或者引入特例,创建你一个替代对象,避免写条件式代码
过长的消息链
- 不好解释,直接上代码
let notice = require("./notice"); let people = notice.info.sendMessage.list;
- 不好解释,直接上代码
中间人
- 过度的隐藏内部结构
- 内幕交易
- 类内的数据交换异常频繁,可以考虑将数据单独作为类处理
- 过大的类
- 一个类中拥有过多的函数,承担过重的逻辑
- 异曲同工的类
- 拥有差不多功能的类,可以被合并
- 纯数据类
- 在纯数据类中创建查看/修改函数
- 被拒绝的遗赠
- 当子类不需要使用超类的函数或数据,可以使用委托代替子类
- 注释
- 注释是好的,但是,当你的代码需要过多的注释才能让人看懂的话,是不是可以考虑语义化命名函数、拆分函数等等操作
重构的方法
重构的方法,书中讲解了很多,这里,会解释大部分重构方法,当然,一些显而易见的就不再赘述了;
1.简单的重构方法
2.封装变量
在重构过程中,尤其是重构函数时,入参的排列顺序及注释会产生很大的误解,所以为了重构的方便性,以及管理入参,可以考虑使用封装变量的方法
或者,当可变数据超出当前作用域的时候,就可以将其封装
//旧代码
function test(date){
let {
name,age,sex,remark} = date;
///...业务逻辑
return getUser(name,age,sex);
}
//封装变量
function test(date){
let {
name,age,sex,remark} = date;
///...业务逻辑
let filter = {
name,age,sex};
return getUser(filter);
}
3.拆分函数
拆分函数,顾名思义,庞大的逻辑,不要想了,拆吧!!!!想想一个函数500行,可怕啊😨
可以细分功能,拆成不同的函数
4.函数组合成类
函数组合成类:首先,将共同类的函数组合到同一个类中,便于后期使用调用,相关模块内容;其次可以以少量的初始化数据,进行初始化类,同时,类内函数互相调用,可以较少入参的传输;
5.封装数据
类似于TS中的数据类型声明;
通俗说法就是,将变量对象或需要处理的数据,或不想改变原有值的对象数据,将其封装在一个类中,暴露各种获取方法,产生逻辑所需要的各种数据!
多用于数据转化、深拷贝、格式化数据等等...
同时缺点也明显,在复制巨大数据结构时,性能消耗大,按需使用!
//旧代码 user.js
async function userInfo(userId){
let ageList = [
{
age:1,money:1000,remark:"1"},
{
age:2,money:2000,remark:"2"},
{
age:3,money:2000,remark:"3"}
];
let user = await User.getInfo(userId);
return ageList.find(item=>{
if(item.age === user.age){
item.sex = user.sex;
item.name = user.name;
return item;
}
});
}
//重构后
//class类
class User {
constructor(ageInfo, user) {
let {
age, money, remark} = ageInfo;
let {
sex, name} = user;
this.age = age;
this.money = money;
this.remark = remark;
if (age === user.age) {
this.sex = sex;
this.name = name;
}
}
}
module.exports = User;
//user.js
let User = require("./user.class");
async function userInfo(userId) {
let ageList = [
{
age: 1, money: 1000, remark: "1"},
{
age: 2, money: 2000, remark: "2"},
{
age: 3, money: 2000, remark: "3"}
];
let user = await User.getInfo(userId);
if (item.age === user.age) {
return new User(item,user);
}
});
}
6.提炼类
顾名思义,为了不让一个文件承担很多责任,可以提炼一些类或函数.如果觉得某些方法是很多地方都用的到,就提炼一个超类.
7.隐藏委托关系/移除中间人
简而言之,就是常见一个类,将客户端的链式调用,放到一个函数中,可以端只需要带哦用一个类就可以获取到想要的值,而不用关心数据之间的链式调用
例如:客户端需要知道某人的经理是谁
后端:
Class Person
constrctor(name){
this._name = name
}
get name(){
return this.name;}
get department(){
return this._department;}
set department(arg){
this._department = args;}
Class Deparment
get manager(){
return this._manager;}
set manager(arg){
this._manager = args;}
客户端调用:
manager = aPerson.department.manager;
优化后:
Class Person
get manager(){
return this._department.manager;}
客户端调用
manager = aPerson.manager;
移除中间人:这种操作是按代码情况而定,当不需要隐藏委托关系时,就可以去除中间人。
为什么要移除中间人,当隐藏的委托关系过多时,就完全变成了一个数据中转站,这并不是我们想要的操作,我们要的是轻量级,这样沉重的操作就需要被优化调!
8.替换算法
将一些复杂的算法,拆分为多了小的算法,最后组合长想要的数据,算法也是需要根据时代去变化的,比如,es6中的includs方法就很好的代替了es5中的find方法。
9.以对象代替基本类型
在重构一中,也称为以类取代类型码
当一份数据不局限于展示时,就可以为其创建一个类,尽管初期很繁琐,的却,但是,当业务越来越复杂,这个类的好处就越明显!
给这个数据一个取值函数,这是基础的!
10. 搬移特性
为什么说搬移特性,应为这里包很很多搬移,比如:搬移字段、搬移语句、搬移函数等等。
有些人,在开发过程中可能想到什么就写什么,那么我们就可以在逻辑完成之后,对语句进行调整;
主要的宗旨就在于,将相关的代码放到一起,便于理解和处理。如果搬移之后,你发现还可以提炼函数、提炼类,这都是可以优化的;
11.拆分循环
很简单的理解,尽量拆分循环中的操作,让一个循环只做一件事(数据计算),或者少数的事情,这样会便于后期修改和理解,因为,你知道每一个循环是为了做什么,而不是一大堆计算换入同一个循环中,你该懂的知识其中一个计算,却要理解所有计算,你的操作会不会影响到其他操作!
重构提醒:不要担心拆分循环会造成效率低下,我们有更多的方法可以提升效率。
//旧代码
function loop() {
let array = [
{
key: 1, sex: 1},
{
key: 2, sex: 1},
{
key: 3, sex: 1},
{
key: 4, sex: 1},
];
let num = 0;
for (let i = 0; i < array.length; i++) {
num += array[i].key;
if (array[i].sex === 1) {
array[i].sexName = "男";
}
}
}
//重构后
function loop() {
let array = [
{
key: 1, sex: 1},
{
key: 2, sex: 1},
{
key: 3, sex: 1},
{
key: 4, sex: 1},
];
let num = 0;
for (let i = 0; i < array.length; i++) {
num += array[i].key;
}
for (let j = 0; j < array.length; j++) {
if (array[j].sex === 1) {
array[j].sexName = "男";
}
}
}
12.以管道代替循环
在操作系统和代码进步的时代,各种管道符可以让代码的可读性更强。
示例:
在函数中筛选出所有的印度的办公室,并返回办公室所在城市信息和联系电话
原代码
function acquireData(input) {
const lines = input.splice("\n");
let firstLine = true;
const result = [];
for (const line of lines) {
if (firstLine) {
firstLine = false;
continue;
}
if (line.trim() === "") continue;
const record = line.splice(",");
if (record[1].trim() === "India") {
result.push({
city: record[0].trim(), phone: record[2].trim()})
}
}
return result;
}
管道符优化后
function acquireData(input) {
const lines = input.splice("\n");
return lines.slice(1)
.filter(line => line.trim() !== "")
.map(line => line.splice(","))
.filter(fields => fields[1].trim() === "India")
.map(fields => ({
city: fields[0].trim(), phone: fields[2].trim()}));
}
13.分解条件表达式
比较沉重的业务功能,可能拥有较为复杂的条件逻辑,在大型功能中,冗长的函数让人头大,且毫无修改心思,谁知道会不会触发罗七八糟的逻辑,而且,为了一个小修改,就要通读冗杂的逻辑,而你其实,只需要其中一个小改动。
这是提取函数的一个具体分之,将一段的冗长的代码分解为多个小函数,配合语义化函数,这样,修改的时候,你只需要去查看那几个逻辑即可。
14.合并条件表达式
这是和13对立的方法,但是,不可否认,这是一个好的方法,有些人在条件逻辑中,条件不同,但输出完全一致,而事实上,我们可以把他们合并起来,让代码更优雅
示例:
//源逻辑
let test = {
"name": "lock",
age: null
}
if (test.name) {
if (test.age) {
console.log("test", test);
return true;
}
}
return false;
//优化后,合并条件表达式
let test = {
"name": "lock",
age: null
}
if (test.name && test.age) {
console.log("test", test);
return true;
}
return false;
15.以卫语句取代嵌套条件表达式
这个方法的精髓就在于,在if/else语句中,给某一分之以特别重视,即,当出现此条件是,立刻返回/退出当前函数。
//旧代码
function ifCode() {
let age = 1;
let result = false;
if (age === 1) {
result = true;
} else {
result = false;
}
return result;
}
//重构代码
function ifCode() {
let age = 1;
let result = false;
if (age === 1) {
result = true;
return result;
}
return result;
}
16.以多态取代条件表达式
在复杂逻辑处理时,我们经常会在一个条件内,处理很长的函数,同时,此方法,拥有多个不同的条件,且处理方案均不相同,此时,多态便尤为重要!
这是一个简单的例子
//源逻辑
function caseInfo(name){
let discountCaseId;
if(name === "one"){
discountCaseId = 1;
}else if(name === "two") {
discountCaseId = 7;
}
return discountCaseId;
}
//重构后代码逻辑
function caseMap(name){
const caseMap = {
one: 1, two: 7};
const discountCaseId = caseMap[name];
return discountCaseId;
}
当然,用于处理条件逻辑是这样的
//源逻辑
async revenueDaily(data) {
let {
store_id, tenancy_id, report_date, upload_type, list} = data;
let token = await Order.getToken();
if(upload_type === "2"){
//...
return NCC.post(ROUTE.SALE_ORDER, saleData, token);
}else if(upload_type === "3"){
//...
return NCC.post(ROUTE.RECEIVABLE, receivableData, token);
}else if(upload_type === "4"){
//...
return Order.receivableForAllStores(data, token);
}
}
//重构后的逻辑
async revenueDaily(data) {
let {
store_id, tenancy_id, report_date, upload_type, list} = data;
let token = await Order.getToken();
return uploadFunction[upload_type](token, {
report_date, store_id, tenancy_id, list});
}
let uploadFunction = {
"2": async (token, data) => {
//...
return NCC.post(ROUTE.SALE_ORDER, saleData, token);
},
"3": async (token, data) => {
//...
return NCC.post(ROUTE.RECEIVABLE, receivableData, token);
},
"4": async (token, data) => {
//...
return Order.receivableForAllStores(data, token);
}
};
17.引入特值
这种方法适用于,代码中某个特殊的值或情况经常出现,切处理逻辑相同,即可采用引入特值方法,本质上,他和萃取函数相同,不过可以理解为对极限情况或固定情况的处理;
常见的特值处理有,判断字符是否为不存在、判断邮箱格式是否正确。
18.将查询函数和修改函数分离
任何有返回值的函数,都不应该看得到副作用!
将无论怎么查询,返回值都是一样的结果存储在某个缓存中,这样可以为后续的查询大大减少时间;
如果是一个既有返回值又有副作用的函数,可以试着将查询从修改中分离出来,变成可复用的查询;
19.移除标记类型
一些标记类型的函数取值,让人不得不去扒拉代码,尤其是,你的函数参数中有一个布尔值时,你讲不知道这个true/false是想执行什么逻辑,那么此时,我们就可以将其需要执行的函数拆分为两个函数,并在函数调用时,将拆分函数作为参数写入源函数中,这样,你就明确的知道,这个值时做什么的!
20.以查询取代参数
此优化方案,有利有弊。
利为:简化了调用方的操作,调用方用了更少的操作、更少的参数完成了功能;
弊为:可能会给函数造成不必要的依赖关系,增加开发者维护成本,此弊端,其实可以用小颗粒度的功能函数来弥补,同时查询函数的结果要具备确定性,即:无论什么时候,我传入固定的值,返回值是相同的。
21.以工厂函数取代构造函数
简而言之,此方法就是,创建一个工厂函数,用于操作new类动作,用来简化逻辑理解。
22.移动代码
包括:函数上移、字段上移、构造函数本体上移至超类
以及反向操作
函数下移、字段下移、构造函数本体上移至子类
相关介绍可以查看另一篇详细说明
《重构2》第十二章-继承
23.以委托取代子类/以委托取代超类
以委托取代子类:
在需要出处理多个平行逻辑判断的时候,为了清楚的展示逻辑,可以创建一个中间类,在中间类里,进行工厂函数构建,来处理多个平行处理逻辑,同时,如果有些函数如果比较基础,也可以挪移至超类中,进行swich/case操作;在调用方使用时,只需要使用中间类就可以判断各种逻辑执行的出口,此时中间类相当于一个水龙头的不同的出水口;
以委托取代超类:
主要用于优化子类继承超类的list,为了避免子类的修改操作影响到超类的所有数据,因此,将部分属性作为派生子类创建,然后在派生子类中修改子类的数据,而在超类中,如果想获取/修改当前属性,只需要将派生子类在超类中获取为一个属性,通过属性转发各派生子类属性的获取/修改即可。
🤏一点想法
重构,不是无法进行,也不是可以不进行。所谓重构,无外乎一些好的代码习惯,加上一些功能模块完成是的代码优化。好的代码习惯,其实已经在开发中就在执行优化重构了。
在重构时,我们提取的优秀的功能点,函数,都是让人眼前一亮,心旷神怡的。既然优秀,为什么不封装起来呢?
小步快走,重构并不难!但是别步子太大,容易摔!
⚠️些许告诫
1.重构,一定要进行代码检测/单元测试,越庞大的项目,越需要严谨的单元测试,这样,才不至于,你的重构造成项目雪崩!
2.重构,不是一撮而就,小步快走,谨慎前行。
3.杜绝拷贝,在开发中,我们已经有了代码自动补全,如果你在开发一个功能,需要重复的逻辑,是不是可以考虑提炼函数?如果你是借鉴其他项目的操作,自己按着别人的逻辑敲一次,理解别人的思路,也是让自己对这个功能有更深的理解。
补充
重构的方法有很多,这里只说了一些我觉得重要且常用的,如果想看全部的重构方法,可以查看我的读书笔记
代码坏味道