前端開發代碼越寫越臃腫該怎麼辦?
工作中新開始的一個項目里,寫代碼前我總會先思考怎麼寫更優雅,邏輯更清晰,然後再去碼,而且寫完後還會審查一遍,把能優化的都盡量優化了。但是後來隨著項目需求越來越多,代碼量蹭蹭地漲,而且很多東西我發現拆成組件寫會更麻煩,即使他們結構類似,於是我就粘貼複製,然後一個個去改。另外一點就是發現 if else越來越多,前期我還不敢用太多,後面就徹底放飛自我了,實在改不動了,改一處就要改多處。
我前端工作經驗一年多,想知道該怎麼避免這種情況。是否是能力差太多,該怎麼彌補?
首先要隨喜下,因為你這種想改變的衝動,將為你帶來一段時間的水平快速提升。
初學一年左右,個人很容易面臨寫程序的能力與對程序結構把控能力不匹配的矛盾:感覺代碼都會寫,但寫多了就感覺到處是問題:不好復用,不好維護。這種對結構不踏實的感覺中,最有代表性,也是最容易被發覺的,便是 if-else的邏輯分支帶來的混亂。
此時建議靜下心來,研究下設計模式,javascript版本的我推薦《JavaScript設計模式(張容銘)》。
另外,平時要保持思考,感覺下代碼的問題在哪,看看能不能想出好辦法應對。這既可以鍛煉思維水平,也有助於加深對一些問題的認知和理解。在這個前提上,去讀別人優秀的代碼和設計模式,才能更加深刻的理解別人「精妙」的一些解決思路。
這將是一個持續時間較長,且充滿樂趣的過程:看著自己的東西一點點變得整潔。
更新:單就簡化if-else而言,個人有一些體會,只說個要點,具體自行查資料:
1 充分利用js中的bool隱性轉換和特殊的與或,如
array array.length dosomthing() ; &<==&> if(array!=null array!=undefined array.length &>0 ){ dosomthing()}
!array || !array.length || dosomething();
array? a() : b();
let i = array.length; while(i--){ dosomething(); } // i減至0時,隱性等於false, 循環中止。
2 用字典代替switch-case(條件比較多,但不太嵌套的情況),如
var map = {1: funcA, 2: funcB, 3:funcC ...};
map[code](args);
3 借鑒簡化的狀態機思路,比較好的分散邏輯分支,讓代碼清晰;
4 鏈式組裝邏輯,這個可以適用於嵌套深步驟多的了(這個是我自己曾經寫的,不是標準設計模式)。曾經幫小夥伴重構過一個邏輯分支圖的代碼,超過20多個環節,原代碼(C#的)是無數的if-else的嵌套,代碼的維護和測試都非常困難。後來我封裝了一個結構,大致如下(用TS語法描述):
interface ILogicUnit{ // 邏輯單元
invoke(args:any): any; // 根據參數進行判斷,觸發下面true和false分支的方法
trueBranch: ILogicUnit; // 如果invoke中判定為true, 則調用 trueBranch.invoke(上一步invoke的結果)
falseBranch: ILogicUnit;
context:any ; // 邏輯嵌套深之後,傳參會很麻煩,可以通過context來解決這個問題
}
interface ILogicLinker{ // 邏輯單元的組裝器
context:any ; //用來在多個邏輯單元中共享變數;
entryUnit: ILogicUnit; // 入口邏輯單元
}
解釋:
a 我們將每一個if-else分支的判斷邏輯,封裝在一個邏輯單元裡面。邏輯單元的命名與邏輯圖上一致(可讀性增強)。
b 每個邏輯單元有兩個出口。構建完所有的邏輯單元後,將所有的邏輯單元根據邏輯圖上的規則,拼接好兩個下游出口端。拼接的過程中賦值好context,這一步在ILoigcLinker的實現中進行,LogicLinker的類名對應邏輯圖的名字。
c 入口邏輯單元.invoke(入口參數)後,接著 (trueBranch or falseBranch).invoke(上一步的返回結果) ... 依次進行鏈式反應,直至程序結束。(末端的邏輯單元沒有分支邏輯,invoke完會就停止了)
說明:
a 這樣做用代碼的調用避免了if-else的嵌套深度;
b 代碼清晰外,主要是好測試。之前所有邏輯分支在一個大if-else中,製造測試條件極其麻煩。一個出問題全部重測。修改分支順序更是要命。現在只要對每個邏輯分支單獨測試就行了,邏輯順序調整也只要改變裝配順序就行了。
5 根據邏輯的複雜度合理應對,現實中經常出來殺雞用牛刀的情況。過度解決問題也是一種浪費。
更新:由於有些人不太能跟得上第4部分的思路,我做一個簡化版的示例,圖和代碼如下:
// 邏輯單元介面
interface ILogicUnit {
// 根據參數進行判斷,觸發下面true和false分支的方法
invoke(args: any): any;
// 如果invoke中判定為true, 則調用 trueBranch.invoke(上一步invoke的結果)
trueBranch: ILogicUnit;
falseBranch: ILogicUnit;
// 邏輯嵌套深之後,傳參會很麻煩,可以通過context來解決這個問題
context: any;
}
// 邏輯單元的組裝器介面
interface ILogicLinker {
//用來在多個邏輯單元中共享變數;
context: any;
// 入口邏輯單元
entryUnit: ILogicUnit;
}
abstract class LogicUnit implements ILogicUnit{
trueBranch: ILogicUnit;
falseBranch: ILogicUnit;
context: any;
invoke(args: any) {
this.check(args) ?
this.trueBranch this.trueBranch.invoke(this.process(true,args))
: this.falseBranch this.falseBranch.invoke(this.process(false,args))
}
//判斷邏輯條件是否為true
check(args:any):boolean{return true;}
//為下一步判斷提供參數輸入
process(checkResult:boolean, args:any):any{return null;}
setBraches(trueBranch:ILogicUnit, falseBranch:ILogicUnit){
this.trueBranch = trueBranch;
this.falseBranch = falseBranch;
this.trueBrach.context = this.context;
this.falseBranch.context = this.context;
}
}
//是否是會員
class IsMemberLogic extends LogicUnit{
check(args: any): boolean {
// 調用後台介面,判斷當前用戶是否為會員。
return true;
}
process(checkResult: boolean, args: any) {
return checkResult this.getMemberInfo();
}
getMemberInfo(){
//todo
}
}
// 導航到註冊頁面
class GotoMemberRegisterPage extends LogicUnit{
invoke(args:any){
// 導航到註冊頁面的代碼
}
}
// 賬號是否凍結的邏輯
class IsMemberLockedLogic extends LogicUnit{
}
// A Logic
class ALogic extends LogicUnit{
}
// B Logic
class BLogic extends LogicUnit{
}
// 會員購物邏輯
class MemberShoppingLogics implements ILogicLinker{
isMemeberLogic: ILogicUnit = new IsMemberLogic();
gotoMemberRegisterPage : ILogicUnit = new GotoMemberRegisterPage ();
isMemberLockedLogic: ILogicUnit= new IsMemberLockedLogic();
aLogic: ILogicUnit = new ALogic();
bLogic: ILogicUnit = new BLogic();
context: any;
entryUnit = this.isMemeberLogic;
constructor(context:any) {
//拼裝邏輯單元
this.isMemeberLogic.setBraches(this.isMemberLockedLogic, this.gotoMemberRegisterPage );
this.isMemberLockedLogic.setBraches(this.aLogic,this.bLogic);
this.context
= this.entryUnit.context
= context; // eg: {user: GlobalServices.userService.getUser()}
}
}
//調用
new MemeberShoppingLogics({...}).entryUnit.invoke();
說明:
- 之所以用typescript,是為了更好的展示程序的結構性特點(面向對象),對於使用強類型語言的,很容易翻譯成對應的。如果翻譯成js,把介面和類型約束去掉就可以了;
- 在前端,有些判斷處理邏輯是非同步的,實戰可能要支持非同步寫法,使用await等。
- 請在腦中感受和模擬下數據的流動和轉化的方向性。如果isMemberLogic判定是true, 就會觸發它的true邏輯分支,也就是isMemberLockedLogic。該邏輯觸發後,又會進一步觸發a或b邏輯……不論邏輯分支圖如何長,用這種小鏈條一樣的單元拼接起來後,就會直接執行到底。
- 在每個logic的invoke中,用控制台記下"邏輯名","判定結果", "傳到下一步的參數", 你很容易追蹤邏輯圖中的執行情況和數據流向,很方便複雜邏輯嵌套時的調試。
- 如果讓你寫,會寫成怎樣呢?是不是有2重以上的if-else。。。如果邏輯分支再深一些呢?
拆成組件寫會更麻煩,說明拆分的時候,只考慮了代碼本身書寫的分散,沒有考慮邏輯的低耦合。
保持代碼足夠simple最大的難點其實是作出正確的抽象。而程序員的成長總是伴隨著不斷地發現和修正錯誤的抽象。
這才是題主下一步思考的應該去往的方向。
而作出正確的抽象又需要兩大能力:
1.對事物進行定性分析的能力
2.對業務流程/工作流的熟悉程度
這兩者都需要大量的經驗來提升。
如何找到正確的抽象是程序員要思考一輩子的問題。
圍繞這個問題其實產生了很多基本的編程原則,比如著名的OOP六大編程原則。
當然它主要是根據基於類繼承的OOP總結出來的。
但是裡面也不乏在其它任何需要「對象」的編程範式中也都適用的原則,比如
迪米特法則(Law Of Demeter),又稱 最少知識原則(Least Knowledge Principle)
單一職責原則(Single Responsibility Principle, SRP)
以及一些在實踐中總結出來的原則,如:
通過SST(Single Source of Truth)原則,避免冗餘/衝突的數據來源,實現DRY(Dont repeat yourself)可以避免對copy/past的代碼進行重構時漏掉一些功能。
再比如人們在對一些編程範式的思考中也得到了一些經驗:
聲明式 的可讀性和可維護性大於 命令式,而命令式則在性能和硬體資源的節約上佔優。
純函數和副作用相互隔離,形成明確的分界,更便於測試(redux,redux-saga)。
等等。
總的來說。。
學習大佬的經驗,再根據這些原則和大佬的經驗去優化代碼積累經驗,就可以變厲害啦!~
什麼組件化,模塊化,架構啊,模式啊。我只想說qtmlgb的吧。分分鐘被產品經理手持需求文檔指著鼻子說「不是針對誰,在座的都是垃圾」。
想解決代碼臃腫先解決需求臃腫。但這在現實世界是不可能的。
解決代碼的內部臃腫只有一個方式:刪。
當然能不能愉快的刪取決於代碼是否disposable。寫disposable code跟什麼組件模塊架構無關甚至相悖。
那怎麼寫disposable code?不好意思我也不知道,只能提供個思路:寫下任何一個功能之前,以第三者能否愉快的刪掉它作為質量評價標準之一。
試想:你馬上離職,對接手的同事說,某某文件夾下面東西想要就要不想要直接刪0副作用。不管你留下幾萬還是幾千萬行代碼,誰敢說你臃腫?身材除外。
感覺題主出發點是好的,思考前會思考如何寫更優化,更清晰,維護性更高,這其實已經很好了,如果我有這樣的同事的話,那真得是太幸運了。
言歸正傳,但是光有這樣好的出發點是不夠的,根據描述,你到最後還是陷入了 cv 程序員的怪圈。據我臆測,我覺的你在寫代碼時認為「優雅」、「清晰」的代碼,可能僅僅是停留在代碼層面了,而不是架構層面。因為我也經歷過程序員小白的階段,當時也會一味追求代碼層面所謂的「優雅」和「簡約」(一些奇奇怪怪的語法),到頭來坑還是給自己填。
所以,寫代碼,一定要多投入精力放到設計這個事情上來,先設計,再寫。尤其是前端開發現在比較流行的組件化開發模式,用組件來抽象頁面,最考驗對於業務狀態的抽象和設計能力。所謂架構啊,代碼擴展性,靈活性,都是在設計這個時間段決定的。我認為新手常見的誤區是這樣的,就是一個需求拿來就寫,之後有了變更就改,然後會在中間做一些所謂的「優化」,然後重複這個過程,直到有一天來知乎提出了類似題主所問的這個問題。
對於前端開發中,如何對組件進行設計,要談這個話題我覺得一時半會根本講不完,所以就不說了。針對你反饋的問題,我倒是可以給你一些建議和心得,僅供參考:
- 代碼量暴增最可能的原因是冗餘代碼或者模板代碼造成的,嘗試使用經典的設計模式來提升代碼的復用性和內聚性,降低耦合性,設計模式這個東西對於前端來講,經常能用到的其實就那麼幾個,比如發布訂閱、代理、策略,嘗試用這幾個模式去思考一些當前代碼是不是能套用一些?
- if...else 暴增的原因一般都是由於複雜的業務變更造成的,這個大家都懂,我當前項目的解決方案,是通過函數式的 composable 特性來解決的,具體你可以搜集資料了解一下,大體思路就是簡單邏輯組成複雜邏輯,複雜邏輯組成超級複雜的邏輯,就和玩樂高一樣。使用函數式來抽象業務有以下幾個好處:
- 封裝的邏輯大部分都是純函數,能夠比較容易的針對業務代碼編寫單元測試
- 由於 if...else 更少,循環更少,中間變數更少,代碼可讀性因此會更高
- 純函數可以很方便的進行復用,而不必在意外在依賴
- 切實地實行測試驅動開發,很多人都對編寫單元測試不屑一顧,有的會說沒時間,有的會說沒必要,有的嫌麻煩,反正各有個的理由,但我覺的這個真的是需要落實的,尤其做業務開發,如果沒有單元測試,改 bug 到後期基本都在改 regression 了
- 嘗試遵守 SOLID 原則,這個東西吧,比較玄學,一般不太可能生搬硬套,只能融會貫通,我的感受是,這些原則每過一段時間,重新思考一遍,都會有不同的看法和心得,對於前端開發,我覺的比較重要的要算依賴倒置、單一職責和開閉這三個原則,對於另外兩個,由於現在前端開發還是比較青睞使用 composable 的函數式思想來進行業務抽象,因此可能不太重要
我之前曾經翻譯過 SOLID 開發原則的相關文章,有興趣可以看一看,我覺的會有一定的幫助的。這些模式對於前端開發,可能不能」照搬「,但是可以借鑒其中的所蘊含的精髓。
目錄如下:
讀懂 SOLID 的「單一職責」原則?segmentfault.com讀懂 SOLID 的「開閉」原則?segmentfault.com讀懂 SOLID 的「里氏替換」原則?segmentfault.com讀懂 SOLID 的「介面隔離」原則?segmentfault.com讀懂 SOLID 的「依賴倒置」原則?segmentfault.com祝工作順利。
if/else 很多就是典型的多態問題,設計上缺乏多態就會導致你被迫用代碼自行實現多態邏輯,體現在代碼中就是大量的 if/else。
推薦閱讀: