編寫可讀的代碼法焰,對于以代碼謀生的程序員而言秧荆,是一件極為重要的事。從某種角度來說埃仪,代碼最重要的功能是能夠被閱讀乙濒,其次才是能夠被正確執(zhí)行。一段無法正確執(zhí)行的代碼,也許會使項目延期幾天颁股,但它造成的危害只是暫時和輕微的么库,畢竟這種代碼無法通過測試并影響最終的產(chǎn)品;但是甘有,一段能夠正確執(zhí)行诉儒,但缺乏條理、難以閱讀的代碼亏掀,它造成的危害卻是深遠(yuǎn)和廣泛的:這種代碼會提高產(chǎn)品后續(xù)迭代和維護(hù)的成本忱反,影響產(chǎn)品的穩(wěn)定,破壞團(tuán)隊的團(tuán)結(jié)(霧)幌氮,除非我們花費數(shù)倍于編寫這段代碼的時間和精力缭受,來消除它對項目造成的負(fù)面影響。
在最近的工作和業(yè)余生活中该互,我對「如何寫出可讀的代碼」這個問題頗有一些具體的體會米者,不妨記錄下來吧。
JavaScript 是動態(tài)和弱類型的語言宇智,使用起來比較「輕松隨意」蔓搞,在 IE6 時代,輕松隨意的習(xí)慣確實不是什么大問題随橘,反而能節(jié)省時間喂分,提高出活兒的速度。但是机蔗,隨著當(dāng)下前端技術(shù)的快速發(fā)展蒲祈,前端項目規(guī)模的不斷膨脹,以往那種輕松隨意的編碼習(xí)慣萝嘁,已經(jīng)成為項目推進(jìn)的一大阻力梆掸。
這篇文章討論的是 ES6/7 代碼,不僅因為 ES6/7 已經(jīng)在大部分場合替代了 JavaScript牙言,還因為 ES6/7 中的很多特性也能幫助我們改善代碼的可讀性酸钦。
變量命名
變量命名是編寫可讀代碼的基礎(chǔ)。只有變量被賦予了一個合適的名字咱枉,才能表達(dá)出它在環(huán)境中的意義涵亏。
命名必須傳遞足夠的信息妈踊,形如 getData 這樣的函數(shù)命名就沒能提供足夠的信息,讀者也完全無法猜測這個函數(shù)會做出些什么事情畅形。而 fetchUserInfoAsync 也許就好很多抹沪,讀者至少會猜測出说铃,這個函數(shù)大約會遠(yuǎn)程地獲取用戶信息学赛;而且因為它有一個 Async 后綴河哑,讀者甚至能猜出這個函數(shù)會返回一個 Promise 對象。
命名的基礎(chǔ)
通常,我們使用名詞來命名對象河爹,使用動詞來命名函數(shù)。比如:
monkey.eat(banana); // the money eats a banana const apple = pick(tree); // pick an apple from the tree
這兩句代碼與自然語言(右側(cè)的注釋)很接近桐款,即使完全不了解編程的人也能看懂大概咸这。
有時候,我們需要表示某種集合概念魔眨,比如數(shù)組或哈希對象媳维。這時可以通過名詞的復(fù)數(shù)形式來表示,比如用 bananas 表示一個數(shù)組遏暴,這個數(shù)組的每一項都是一個 banana侄刽。如果需要特別強(qiáng)調(diào)這種集合的形式,也可以加上 List 或 Map 后綴來顯式表示出來朋凉,比如用 bananaList 表示數(shù)組州丹。
有些單詞的復(fù)數(shù)形式和單數(shù)形式相同,有些不可數(shù)的單詞沒有復(fù)數(shù)形式(比如 data杂彭,information)墓毒,這時我也會使用 List 等后綴來表示集合概念。
命名的上下文
變量都是處在上下文(作用域)之內(nèi)亲怠,變量的命名應(yīng)與上下文相契合所计,同一個變量,在不同的上下文中团秽,命名可以不同主胧。舉個例子,假設(shè)我們的程序需要管理一個動物園习勤,程序的代碼里有一個名為 feedAnimals 的函數(shù)來喂食動物園中的所有動物:
function feedAnimals(food, animals) { // ... // 上下文中有 bananas, peaches, monkey 變量 const banana = bananas.pop(); if (banana) { monkey.eat(banana); } else { const peach = peaches.pop(); monkey.eat(peach); } // ... }
負(fù)責(zé)喂食動物的函數(shù) feedAnimals 函數(shù)的主要邏輯就是:用各種食物把動物園里的各種動物喂飽踪栋。也許,每種動物能接受的食物種類不同姻报,也許己英,我們需要根據(jù)各種食物的庫存來決定每種動物最終分到的食物,總之在這個上下文中吴旋,我們需要關(guān)心食物的種類损肛,所以傳給 money.eat 方法的實參對象命名為 banana 或者 peach,代碼很清楚地表達(dá)出了它的關(guān)鍵邏輯:「猴子要么吃香蕉荣瑟,要么吃桃子(如果沒有香蕉了)」治拿。我們肯定不會這樣寫:
// 我們不會這樣寫 const food = bananas.pop(); if(food) { monkey.eat(food); } else { const food = peaches.pop(); monkey.eat(food); }
Monkey#eat 方法內(nèi)部就不一樣了,這個方法很可能是下面這樣的(假設(shè) eat 是 Monkey 的基類 Animal 的方法):
class Animal{ // ... eat(food) { this.hunger -= food.energy; } // ... } class Monkey extends Animal{ // ... }
如代碼所示笆焰,「吃」這個方法的核心邏輯就是根據(jù)食物的能量來減少動物(猴子)自身的饑餓度劫谅,至于究竟是吃了桃子還是香蕉,我們不關(guān)心,所以在這個方法的上下文中捏检,我們直接將表示食物的函數(shù)形參命名為 food荞驴。
想象一下,假設(shè)我們正在編寫某個函數(shù)贯城,即將寫一段公用邏輯熊楼,我們會選擇去寫一個新的功能函數(shù)來執(zhí)行這段公用邏輯。在編寫這個新的功能函數(shù)過程中能犯,往往會受到之前那個函數(shù)的影響鲫骗,變量的命名也是按照其在之前那個函數(shù)中的意義來的。雖然寫的時候不感覺有什么阻礙踩晶,但是讀者閱讀的單元是函數(shù)(他并不了解之前哪個函數(shù))执泰,會被深深地困擾。
嚴(yán)格遵循一種命名規(guī)范的收益
如果你能夠時刻按照某種嚴(yán)格的規(guī)則來命名變量和函數(shù)渡蜻,還能帶來一個潛在的好處术吝,那就是你再也不用記住哪些之前命名過(甚至其他人命名過)的變量或函數(shù)了。特定上下文中的特定含義只有一種命名方式晴楔,也就是說顿苇,只有一個名字。比如税弃,「獲取用戶信息」這個概念纪岁,就叫作 fetchUserInfomation,不管是在早晨還是傍晚则果,不管你是在公司還是家中幔翰,你都會將它命名為 fetchUserInfomation 而不是 getUserData。那么當(dāng)你再次需要使用這個變量時西壮,你根本不用翻閱之前的代碼或依賴 IDE 的代碼提示功能遗增,你只需要再命名一下「獲取用戶信息」這個概念,就可以得到 fetchUserInfomation 了款青,是不是很酷做修?
分支結(jié)構(gòu)
分支是代碼里最常見的結(jié)構(gòu),一段結(jié)構(gòu)清晰的代碼單元應(yīng)當(dāng)是像二叉樹一樣抡草,呈現(xiàn)下面的結(jié)構(gòu)饰及。
if (condition1) { if (condition2) { ... } else { ... } } else { if (condition3) { ... } else { ... } }
這種優(yōu)美的結(jié)構(gòu)能夠幫助我們在大腦中迅速繪制一張圖,便于我們在腦海中模擬代碼的執(zhí)行康震。但是燎含,我們大多數(shù)人都不會遵循上面這樣的結(jié)構(gòu)來寫分支代碼。以下是一些常見的腿短,在我看來可讀性比較差的分支語句的寫法:
不好的做法:在分支中 return
function foo() { if (condition) { // 分支1的邏輯 return; } // 分支2的邏輯 }
這種分支代碼很常見屏箍,而且往往分支 2 的邏輯是先寫的绘梦,也是函數(shù)的主要邏輯,分支 1 是后來對函數(shù)進(jìn)行修補(bǔ)的過程中產(chǎn)生的赴魁。這種分支代碼有一個很致命的問題卸奉,那就是,如果讀者沒有注意到分支1中的 return(我敢保證尚粘,在使用 IDE 把代碼折疊起來后择卦,沒人能第一時間注意到這個 return),就不會意識到后面一段代碼(分支 2)是有可能不會執(zhí)行的郎嫁。我的建議是,把分支 2 放到一個 else 語句塊中祈噪,代碼就會清晰可讀很多:
function foo() { if (condition) { // 分支 1 的邏輯 } else { // 分支 2 的邏輯 } }
如果某個分支是空的泽铛,我也傾向于留下一個空行,這個空行明確地告訴代碼的讀者辑鲤,如果走到這個 else盔腔,我什么都不會做。如果你不告訴讀者月褥,讀者就會產(chǎn)生懷疑弛随,并嘗試自己去弄明白。
不好的做法:多個條件復(fù)合
if (condition1 && condition2 && condition3) { // 分支1:做一些事情 } else { // 分支2:其他的事情 }
這種代碼也很常見:在若干條件同時滿足(或有任一滿足)的時候做一些主要的事情(分支1宁赤,也就是函數(shù)的主邏輯)舀透,否則就做一些次要的事情(分支2,比如拋異常决左,輸出日志等)愕够。雖然寫代碼的人知道什么是主要的事情,什么是次要的事情佛猛,但是代碼的讀者并不知道惑芭。讀者遇到這種代碼,就會產(chǎn)生困惑:分支2到底對應(yīng)了什么條件继找?
在上面這段代碼中遂跟,三種條件只要任意一個不成立就會執(zhí)行到分支 2,但這其實本質(zhì)上是多個分支:1)條件 1 不滿足婴渡,2)條件 1 滿足而條件 2 不滿足幻锁,3)條件 1 和 2 都滿足而條件 3 不滿足。如果我們籠統(tǒng)地使用同一段代碼來處理多個分支缩搅,那么就會增加閱讀者閱讀分支 2 時的負(fù)擔(dān)(需要考慮多個情況)越败。更可怕的是,如果后面需要增加一些額外的邏輯(比如硼瓣,在條件 1 成立且條件 2 不成立的時候多輸出一條日志)究飞,整個 if-else 都可能需要重構(gòu)置谦。
對這種場景,我通常這樣寫:
if (condition1) { if (condition2) { // 分支1:做一些事情 } else { // 分支2:其他的事情 } } else { // 分支3:其他的事情 }
即使分支 2 和分支 3 是完全一樣的亿傅,我也認(rèn)為有必要將其分開媒峡。雖然多了幾行代碼,收益卻是很客觀的葵擎。
萬事非絕對谅阿。對于一種情況,我不反對將多個條件復(fù)合起來酬滤,那就是當(dāng)被復(fù)合的多個條件聯(lián)系十分緊密的時候签餐,比如 if(foo && foo.bar)。
不好的做法:使用分支改變環(huán)境
let foo = someValue; if (condition) { foo = doSomethingTofoo(foo); } // 繼續(xù)使用 foo 做一些事情
這種風(fēng)格的代碼很容易出現(xiàn)在那些屢經(jīng)修補(bǔ)的代碼文件中盯串,很可能一開始是沒有這個 if 代碼塊的氯檐,后來發(fā)現(xiàn)了一個 bug,于是加上了這個 if 代碼塊体捏,在某些條件下對 foo 做一些特殊的處理冠摄。如果你希望項目在迭代過程中,風(fēng)險越積越高几缭,那么這個習(xí)慣絕對算得上「最佳實踐」了河泳。
事實上,這樣的「補(bǔ)丁」積累起來年栓,很快就會摧毀代碼的可讀性和可維護(hù)性拆挥。怎么說呢?當(dāng)我們在寫下上面這段代碼中的 if 分支以試圖修復(fù) bug 的時候韵洋,我們內(nèi)心存在這樣一個假設(shè):我們是知道程序在執(zhí)行到這一行時竿刁,foo 什么樣子的;但事實是搪缨,我們根本不知道食拜,因為在這一行之前,foo 很可能已經(jīng)被另一個人所寫的嘗試修復(fù)另一個 bug 的另一個 if 分支所篡改了副编。所以负甸,當(dāng)代碼出現(xiàn)問題的時候,我們應(yīng)當(dāng)完整地審視一段獨立的功能代碼(通常是一個函數(shù))痹届,并且多花一點時間來修復(fù)他呻待,比如:
const foo = condition ? doSomethingToFoo(someValue) : someValue;
我們看到,很多風(fēng)險都是在項目快速迭代的過程中積累下來的队腐。為了「快速」迭代蚕捉,在添加功能代碼的時候,我們有時候連函數(shù)這個最小單元的都不去了解柴淘,僅僅著眼于自己插入的那幾行迫淹,希望在那幾行中解決/hack掉所有問題秘通,這是十分不可取的。
我認(rèn)為敛熬,項目的迭代再快肺稀,其代碼質(zhì)量和可讀性都應(yīng)當(dāng)有一個底線。這個底線是应民,當(dāng)我們在修改代碼的時候话原,應(yīng)當(dāng)完整了解當(dāng)前修改的這個函數(shù)的邏輯,然后修改這個函數(shù)诲锹,以達(dá)到添加功能的目的繁仁。注意,這里的「修改一個函數(shù)」和「在函數(shù)某個位置添加幾行代碼」是不同的归园,在「修改一個函數(shù)」的時候改备,為了保證函數(shù)功能獨立,邏輯清晰蔓倍,不應(yīng)該畏懼在這個函數(shù)的任意位置增刪代碼。
函數(shù)
函數(shù)只做一件事情
有時盐捷,我們會自作聰明地寫出一些很「通用」的函數(shù)偶翅。比如,我們有可能寫出下面這樣一個獲取用戶信息的函數(shù) fetchUserInfo:其邏輯是:
- 當(dāng)傳入的參數(shù)是用戶ID(字符串)時碉渡,返回單個用戶數(shù)據(jù)聚谁;
- 而傳入的參數(shù)是用戶ID的列表(數(shù)組)時,返回一個數(shù)組滞诺,其中的每一項是一個用戶的數(shù)據(jù)形导。
async function fetchUserInfo(id) { const isSingle = typeof idList === 'string'; const idList = isSingle ? [id] : id; const result = await request.post('/api/userInfo', {idList}); return isSingle ? result[0] : result; }
// 可以這樣調(diào)用 const userList = await fetchUserInfo(['1011', '1013']); // 也可以這樣調(diào)用 const user = await fetchUserInfo('1017');
這個函數(shù)能夠做兩件事:1)獲取多個用戶的數(shù)據(jù)列表;2)獲取單個用戶的數(shù)據(jù)习霹。在項目的其他地方調(diào)用 fetchUserInfo 函數(shù)時朵耕,也許我們確實能感到「方便」了一些。但是淋叶,代碼的讀者一定不會有相同的體會阎曹,當(dāng)讀者在某處讀到 fetchUserInfo(['1011', '1013']) 這句調(diào)用的代碼時,他就會立刻對 fetchUserInfo 產(chǎn)生「第一印象」:這個函數(shù)需要傳入用戶ID數(shù)組煞檩;當(dāng)他讀到另外一種調(diào)用形式時处嫌,他一定會懷疑自己之前是不是眼睛花了。讀者并不了解背后的「潛規(guī)則」斟湃,除非規(guī)則是預(yù)先設(shè)計好并且及時地更新到文檔中熏迹。總之凝赛,我們絕不該一時興起就寫出上面這種函數(shù)注暗。
遵循一個函數(shù)只做一件事的原則坛缕,我們可以將上述功能拆成兩個函數(shù)fetchMultipleUser 和 fetchSingleUser 來實現(xiàn)。在需要獲取用戶數(shù)據(jù)時友存,只需要選擇調(diào)用其中的一個函數(shù)祷膳。
async function fetchMultipleUser(idList) { return await request.post('/api/users/', {idList}); }
async function fetchSingleUser(id) { return await fetchMultipleUser([id])[0]; }
上述改良不僅改善了代碼的可讀性,也改善了可維護(hù)性屡立。舉個例子直晨,假設(shè)隨著項目的迭代,獲取單一用戶信息的需求不再存在了膨俐。
如果是改良前勇皇,我們會刪掉那些「傳入單個用戶ID來調(diào)用 fetchUserInfo」的代碼,同時保留剩下的那些「傳入多個用戶ID調(diào)用 fetchUserInfo」的代碼焚刺, 但是 fetchUserInfo 函數(shù)幾乎一定不會被更改敛摘。這樣,函數(shù)內(nèi)部 isSingle 為 true 的分支乳愉,就留在了代碼中兄淫,成了永遠(yuǎn)都不會執(zhí)行的「臟代碼」,誰愿意看到自己的項目中充斥著永遠(yuǎn)不會執(zhí)行的代碼呢蔓姚?
對于改良后的代碼捕虽,我們(也許借助IDE)能夠輕松檢測到 fetchSingleUser 已經(jīng)不會被調(diào)用了,然后放心大膽地直接刪掉這個函數(shù)坡脐。
那么泄私,如何界定某個函數(shù)做的是不是一件事情?我的經(jīng)驗是這樣:如果一個函數(shù)的參數(shù)僅僅包含輸入數(shù)據(jù)(交給函數(shù)處理的數(shù)據(jù))备闲,而沒有混雜或暗含有指令(以某種約定的方式告訴函數(shù)該怎么處理數(shù)據(jù))晌端,那么函數(shù)所做的應(yīng)當(dāng)就是一件事情。比如說恬砂,改良前的 fetchUserInfo 函數(shù)的參數(shù)是「多個用戶的ID數(shù)組或單個用戶的ID」咧纠,這個「或」字其實就暗含了某種指令。