重構 - 改善程式碼的各方面問題

守候i發表於2018-04-23
重構不是對以前程式碼的全盤否定,而是利用更好的方式,寫出更好,更有維護性程式碼。不斷的追求與學習,才有更多的進步。

1.前言

做前端開發有一段時間了,在這段時間裡面,對於自己的要求,不僅僅是專案能完成,功能正常使用這一層面上。還盡力的研究怎麼寫出優雅的程式碼,效能更好,維護性更強的程式碼,通俗一點就是重構。這篇文章算是我一個小記錄,在此分享一下。該文章主要針對介紹,例子也簡單,深入複雜的例子等以後有適合的例項再進行寫作分享。如果大家對怎麼寫出優雅的程式碼,可維護的程式碼,有自己的見解,或者有什麼重構的實力,歡迎指點評論。

關於重構,準備寫一個系列的文章,不定時更新,主要針對以下方案:邏輯混亂重構,分離職責重構,新增擴充套件性重構,簡化使用重構,程式碼複用重構。其中會穿插以下原則:單一職責原則,最少知識原則,開放-封閉原則。如果大家對重構有什麼好的想法,或者有什麼好的例項,歡迎留言評論,留下寶貴的建議。

2.什麼是重構

首先,重構不是重寫。重構大概的意思是在不影響專案的功能使用前提下,使用一系列的重構方式,改變專案的內部結構。提高專案內部的可讀性,可維護性。

無論是什麼專案,都有一個從簡單到複雜的一個迭代過程。在這個過程裡面,在不影響專案的使用情況下,需要不斷的對程式碼進行優化,保持或者增加程式碼的可讀性,可維護性。這樣一來,就可以避免在團隊協作開發上需要大量的溝通,交流。才能加入專案的開發中。

3.為什麼重構

衣服髒了就洗,破了就補,不合穿就扔。

隨著業務需求的不斷增加,變更,捨棄,專案的程式碼也難免會出現瑕疵,這就會影響程式碼的可讀性,可維護性,甚至影響專案的效能。而重構的目的,就是為了解決這些瑕疵,保證程式碼質量和效能。但是前提是不能影響專案的使用。

至於重構的原因,自己總結了一下,大概有以下幾點

  1. 函式邏輯結構混亂,或因為沒註釋原因,連原始碼寫作者都很難理清當中的邏輯。
  2. 函式無擴充套件性可言,遇到新的變化,不能靈活的處理。
  3. 因為物件強耦合或者業務邏輯的原因,導致業務邏輯的程式碼巨大,維護的時候排查困難。
  4. 重複程式碼太多,沒有複用性。
  5. 隨著技術的發展,程式碼可能也需要使用新特性進行修改。
  6. 隨著學習的深入,對於以前的程式碼,是否有著更好的一個解決方案。
  7. 因為程式碼的寫法,雖然功能正常使用,但是效能消耗較多,需要換方案進行優化

4.何時重構

在合適的時間,在合適的事情

在我的理解中,重構可以說是貫穿整一個專案的開發和維護週期,可以當作重構就是開發的一部分。通俗講,在開發的任何時候,只要看到程式碼有別扭,激發了強迫症,就可以考慮重構了。只是,重構之前先參考下面幾點。

  • 首先,重構是需要花時間去做的一件事。花的時間可能比之前的開發時間還要多。
  • 其次,重構是為了把程式碼優化,前提是不能影響專案的使用。
  • 最後,重構的難度大小不一,可能只是稍微改動,可能難度比之前開發還要難。

基於上面的幾點,需要大家去評估是否要進行重構。評估的指標,可以參考下面幾點

  • 數量: 需要重構的程式碼是否過多。
  • 質量: 可讀性,可維護性,程式碼邏輯複雜度,等問題,對程式碼的質量影響是否到了一個難以忍受的地步。
  • 時間: 是否有充裕的時間進行重構和測試。
  • 效果: 如果重構了程式碼,得到哪些改善,比如程式碼質量提高了,效能提升了,更好的支援後續功能等。

5.怎麼重構

選定目標,針對性出擊

怎麼重構,這個就是具體情況,具體分析了。如同“為什麼重構一樣”。發現程式碼有什麼問題就針對什麼情況進行改進。

重構也是寫程式碼,但是不止於寫,更在於整理和優化。如果說寫程式碼需要一個‘學習--瞭解-熟練’的過程,那麼重構就需要一個‘學習-感悟-突破-熟練’的過程。

針對重構的情況,下面簡單的用幾個例子進行說明

5-1.函式無擴充套件性

如下面一個例子,在我一個庫的其中一個 API

//檢測字串
//checkType('165226226326','mobile')
//result:false
let checkType=function(str, type) {
    switch (type) {
        case 'email':
            return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(str);
        case 'mobile':
            return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
        case 'tel':
            return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
        case 'number':
            return /^[0-9]$/.test(str);
        case 'english':
            return /^[a-zA-Z]+$/.test(str);
        case 'text':
            return /^\w+$/.test(str);
        case 'chinese':
            return /^[\u4E00-\u9FA5]+$/.test(str);
        case 'lower':
            return /^[a-z]+$/.test(str);
        case 'upper':
            return /^[A-Z]+$/.test(str);
        default:
            return true;
    }
}複製程式碼

這個 API 看著沒什麼毛病,能檢測常用的一些資料。但是有以下兩個問題。

1.但是如果想到新增其他規則的呢?就得在函式裡面增加 case 。新增一個規則就修改一次!這樣違反了開放-封閉原則(對擴充套件開放,對修改關閉)。而且這樣也會導致整個 API 變得臃腫,難維護。

2.還有一個問題就是,比如A頁面需要新增一個金額的校驗,B頁面需要一個日期的校驗,但是金額的校驗只在A頁面需要,日期的校驗只在B頁面需要。如果一直新增 case 。就是導致A頁面把只在B頁面需要的校驗規則也新增進去,造成不必要的開銷。B頁面也同理。

建議的方式是給這個 API 增加一個擴充套件的介面

let checkType=(function(){
    let rules={
        email(str){
            return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(str);
        },
        mobile(str){
            return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
        },
        tel(str){
            return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
        },
        number(str){
            return /^[0-9]$/.test(str);
        },
        english(str){
            return /^[a-zA-Z]+$/.test(str);
        },
        text(str){
            return /^\w+$/.test(str);
        },
        chinese(str){
            return /^[\u4E00-\u9FA5]+$/.test(str);
        },
        lower(str){
            return /^[a-z]+$/.test(str);
        },
        upper(str){
            return /^[A-Z]+$/.test(str);
        }
    };
    //暴露介面
    return {
        //校驗
        check(str, type){
            return rules[type]?rules[type](str):false;
        },
        //新增規則
        addRule(type,fn){
            rules[type]=fn;
        }
    }
})();

//呼叫方式
//使用mobile校驗規則
console.log(checkType.check('188170239','mobile'));
//新增金額校驗規則
checkType.addRule('money',function (str) {
    return /^[0-9]+(.[0-9]{2})?$/.test(str)
});
//使用金額校驗規則
console.log(checkType.check('18.36','money'));複製程式碼

上面的程式碼,是多了一些,但是理解起來也沒怎麼費勁,而且擴充性也有了。

上面這個改進其實是使用了策略模式(把一系列的演算法進行封裝,使演算法程式碼和邏輯程式碼可以相互獨立,並且不會影響演算法的使用)進行改進的。策略模式的概念理解起來有點繞,但是大家看著程式碼,應該不繞。

這裡展開講一點,在功能上來說,通過重構,給函式增加擴充套件性,這裡實現了。但是如果上面的 checkType是一個開源專案的 API,重構之前呼叫方式是:checkType('165226226326','phone') 。重構之後呼叫方式是: checkType.check('188170239','phone');或者 checkType.addRule() ;。如果開源專案的作者按照上面的方式重構,那麼之前使用了開源專案的 checkType 這個 API 的開發者,就可能悲劇了,因為只要開發者一更新這個專案版本,就有問題。因為上面的重構沒有做向下相容。

如果要向下相容,其實也不難。加一個判斷而已。

let checkType=(function(){
    let rules={
        email(str){
            return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(str);
        },
        mobile(str){
            return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
        },
        tel(str){
            return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
        },
        number(str){
            return /^[0-9]$/.test(str);
        },
        english(str){
            return /^[a-zA-Z]+$/.test(str);
        },
        text(str){
            return /^\w+$/.test(str);
        },
        chinese(str){
            return /^[\u4E00-\u9FA5]+$/.test(str);
        },
        lower(str){
            return /^[a-z]+$/.test(str);
        },
        upper(str){
            return /^[A-Z]+$/.test(str);
        }
    };
    //暴露介面
    return function (str,type){
        //如果type是函式,就擴充套件rules,否則就是驗證資料
        if(type.constructor===Function){
            rules[str]=type;
        }
        else{
            return rules[type]?rules[type](str):false;
        }
    }
})();

console.log(checkType('188170239','mobile'));

checkType('money',function (str) {
    return /^[0-9]+(.[0-9]{2})?$/.test(str)
});
//使用金額校驗規則
console.log(checkType('18.36','money'));複製程式碼

這樣執行能正常,也有擴充套件性性,但是對於程式碼潔癖的來說,這樣寫法不優雅。因為 checkType 違反了函式單一原則。一個函式負責過多的職責可能會導致以後不可估量的問題,使用方面也很讓人疑惑。

面對這樣的情況,就個人而言,瞭解的做法是:保留 checkType ,不做任何修改,在專案裡面增加一個新的 API ,比如 checkTypOfString ,把重構的程式碼寫到 checkTypOfString 裡面。通過各種方式引導開發者少舊 checkType ,多用 checkTypOfString 。之後的專案迭代裡面,合適的時候廢棄 checkType

5-2.函式違反單一原則

函式違反單一原則最大一個後果就是會導致邏輯混亂。如果一個函式承擔了太多的職責,不妨試下:函式單一原則 -- 一個函式只做一件事。

如下例子

//現有一批的錄入學生資訊,但是資料有重複,需要把資料進行去重。然後把為空的資訊,改成保密。
let students=[
    {
        id:1,
        name:'守候',
        sex:'男',
        age:'',
    },
    {
        id:2,
        name:'浪跡天涯',
        sex:'男',
        age:''
    },
    {
        id:1,
        name:'守候',
        sex:'',
        age:''
    },
    {
        id:3,
        name:'鴻雁',
        sex:'',
        age:'20'
    }
];

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;
}
console.log(handle(students))複製程式碼

重構 - 改善程式碼的各方面問題
執行結果沒有問題,但是大家想一下,如果以後,如果改了需求,比如,學生資訊不會再有重複的記錄,要求把去重的函式去掉。這樣一來,就是整個函式都要改了。還影響到下面的操作流程。相當於了改了需求,整個方法全跪。城門失火殃及池魚。

下面使用單一原則構造一下

let handle={
    removeRepeat(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]);
            }
        }
        return _arr;
    },
    setInfo(arr){
        arr.map(item=>{
            for(let key in item){
                if(item[key]===''){
                    item[key]='保密';
                }
            }
        });
        return arr;
    }
};
students=handle.removeRepeat(students);
students=handle.setInfo(students);
console.log(students);複製程式碼

重構 - 改善程式碼的各方面問題

結果一樣,但是需求改下,比如不需要去重,把程式碼註釋或者直接刪除就好。這樣相當於把函式的職責分離了,而且職責之前互不影響。中間去除那個步驟不會影響下一步。

//students=handle.removeRepeat(students);
students=handle.setInfo(students);
console.log(students);複製程式碼

5-3.函式寫法優化

這種情況就是,對於以前的函式,在不影響使用的情況下,現在有著更好的實現方式。就使用更好的解決方案,替換以前的解決方案。

比如下面的需求,需求是群裡一個朋友發出來的,後來引發的一些討論。給出一個20180408000000字串,formatDate函式要處理並返回2018-04-08 00:00:00

以前的解法

let _dete='20180408000000'
function formatStr(str){
    return str.replace(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, "$1-$2-$3 $4:$5:$6")
}
formatStr(_dete);
//"2018-04-08 00:00:00"複製程式碼

後來研究了這樣的解法。這個方式就是根據x的位置進行替換填充資料,不難理解

let _dete='20180408000000'
function formatStr(str,type){
    let _type=type||"xxxx-xx-xx xx:xx:xx";
    for(let i = 0; i < str.length; i++){
        _type = _type.replace('x', str[i]);
    }
    return _type;
}
formatStr(_dete);
result:"2018-04-08 00:00:00"複製程式碼

在之後的幾天,在掘金一篇文章(那些優雅靈性的JS程式碼片段,感謝提供的寶貴方式)的評論裡面發現更好的實現方式,下面根據上面的需求自己進行改造。

let _dete='20180408000000'
function formatStr(str,type){
    let i = 0,_type = type||"xxxx-xx-xx xx:xx:xx";
    return _type .replace(/x/g, () => str[i++])
}
formatStr(_dete);
result:"2018-04-08 00:00:00"複製程式碼

5-4.程式碼複用

上面幾個例子都是js的,說下與html沾邊一點的兩個例子--vue資料渲染。

下面程式碼中,payChannelEn2Cn addZero formatDateTime函式都是在vue的methods裡面。大家注意。

以前寫法

<span v-if="cashType==='cash'">現金</span>
<span v-else-if="cashType==='check'">支票</span>
<span v-else-if="cashType==='draft'">匯票</span>
<span v-else-if="cashType==='zfb'">支付寶</span>
<span v-else-if="cashType==='wx_pay'">微信支付</span>
<span v-else-if="cashType==='bank_trans'">銀行轉賬</span>
<span v-else-if="cashType==='pre_pay'">預付款</span>複製程式碼

這樣寫的問題在於,首先是程式碼多,第二是如果專案有10個地方這樣渲染資料,如果渲染的需求變了。比如銀行轉賬的值從 bank_trans 改成 bank ,那麼就得在專案裡面修改10次。時間成本太大。
後來就使用了下面的寫法,算是一個小重構吧

<span>{{payChannelEn2Cn(cashType)}}</span>複製程式碼

payChannelEn2Cn 函式,輸出結果

payChannelEn2Cn(tag){
    let _obj = {
        'cash': '現金',
        'check': '支票',
        'draft': '匯票',
        'zfb': '支付寶',
        'wx_pay': '微信支付',
        'bank_trans': '銀行轉賬',
        'pre_pay': '預付款'
    };
    return _obj[tag];
}複製程式碼

還有一個例子就是時間戳轉時間的寫法。原理一樣,只是程式碼不同。下面是原來的程式碼。

<span>{{new Date(payTime).toLocaleDateString().replace(/\//g, '-')}} 
{{addZero(new Date(payTime).getHours())}}:
{{addZero(new Date(payTime).getMinutes())}}:
{{addZero(new Date(payTime).getSeconds())}}</span>複製程式碼

addZero時間補零函式

Example:3->03
addZero(i){
    if (i < 10) {
        i = "0" + i;
    }
    return i;
}複製程式碼

問題也和上面的一樣,這裡就不多說了,就寫重構後的程式碼

<span>{{formatDateTime(payTime)}} </span>複製程式碼

formatDateTime函式,格式化字串

formatDateTime(dateTime){
    return `${new Date(payTime).toLocaleDateString().replace(/\//g, '-')} ${this.addZero(new Date(payTime).getHours())}:${this.addZero(new Date(payTime).getMinutes())}:${this.addZero(new Date(payTime).getSeconds())}`;
}複製程式碼
可能很多人看到這裡,覺得重構很簡單,這樣想是對的,重構就是這麼簡單。但是重構也難,因為重構一步登天,需要一個逐步的過程,甚至可以說重構就是一次次的小改動,逐步形成一個質變的過程。如何保證每一次的改動都是有意義的改善程式碼;如何保證每一次的改動都不會影響到專案的正常使用;如果發現某次改動沒有意義,或者改動了反而讓程式碼更糟糕的時候,可以隨時停止或回滾程式碼,這些才是重構的難點。

6.小結

關於重構就說到這裡了,該文章主要是介紹重構,例子方面都是很簡單的一些例子。目的是為了好些理解重構的一些概念。關於重構,可能很複雜,可能很簡單。怎麼重構也是具體情況,具體分析,重構也沒有標準的答案。以後,如果有好的例子,我會第一時間分享,給大傢俱體情況,具體分析的講述:為什麼重構,怎麼重構。

最後,如果大家對文章有什麼建議,看法,歡迎交流,相互學習,共同進步。

-------------------------華麗的分割線--------------------

想了解更多,關注關注我的微信公眾號:守候書閣

重構 - 改善程式碼的各方面問題


相關文章