“程式是寫給人讀的,只是偶爾讓計算機執行一下。”——Donald Ervin Knuth(高德納)
關於程式碼素養
我們常常談到“素養”一詞,是指個人在專業領域內實踐訓練而成的一種修養,在不同的領域中有不同的體現,如在音樂領域中,“音樂素養”是指個人對於音樂的感覺程度,對音高節奏的把控,對不同流派音樂的鑑賞能力等,而在程式設計領域,也有不同的素養,反映出對基本功、程式碼整潔度、專業態度等等方面,所謂“程式碼素養”,簡單來說,就是指程式碼寫的是否優雅美觀可維護。
絕對完美的程式碼是不存在的,程式碼素養並不是指完美主義。在翻譯領域有“信,達,雅”的標準,“雅”之所以放在最後,是因為要達到它,需要有比較高的水準和經驗積累。類比到程式設計領域,我們在程式設計時,第一時間想到的是如何將業務邏輯實現出來,而不是如何把程式碼優雅地寫出來,所以寫程式碼沒有所謂的絕對優雅。但是,作為一名專業的前端工程師,確切的說,應該是專業的軟體工程師,編寫優雅的程式碼應當是時刻保持的追求,它更像是一個準繩,如同每個人知道自己該做什麼,不該做什麼,所謂原則,所謂底線,體現出所謂的“程式碼素養”
。
破窗理論
破窗理論,原義指窗戶破損了,建築無人照管,人們放任窗戶繼續破損,最終自己也參與破壞活動,在外牆上塗鴉,任垃圾堆積,最後走向傾頹。
破窗理論在實際中非常容易出現,往往第一個人的程式碼寫的不好,第二個人就會有類似“反正他已經寫成這樣了,那我也只能這樣了”的思想,導致程式碼越維護越冗雜,最後一刻轟然坍塌,變成無人想去維護的垃圾。
整潔的程式碼
整潔的程式碼如同優美的散文,試想讀過的一本好書,能夠隨著作者的筆鋒跌宕起伏,充滿了畫面感,調動了自己的喜怒哀樂。程式碼雖然沒有那樣的高潮迭起,但整潔的程式碼應當充滿張力,能夠在某一時刻利用這種張力將情節推向高潮。
我更喜歡把寫程式碼類比於寫文章講故事,寫程式碼是創作的過程,作者需要將自己想表達的東西通過程式碼的形式展現出來,而整潔的程式碼如同講故事一般,娓娓道來,引人入勝,不好的程式碼則讓人感覺毫無頭緒,通篇不知道在講什麼。
整潔程式碼原則
在現代化的前端開發中,有很多自動化工具可以幫助我們寫出規範的程式碼,如eslint
,tslint
等各種輔助校驗工具,知名的規範如google規範
、airbnb規範
等等也從各個細節方面約束,幫助我們形成合理規範的程式碼風格。
本小節不再重複語言層面的程式碼風格,根據實際重構專案,總結出一系列開發過程中需要時刻注意的原則,按照重要程度優先順序排列。
1. DRY(Don’t Repeat Yourself)
相信作為一名軟體工程師,大家都聽說過最基本的DRY原則,很多設計模式,包括物件導向本身,都是在這條原則上做努力。
DRY顧名思義,是指“不要重複自己”,它實際上強調了一個抽象性原則,如果同樣或類似的程式碼片段出現了兩次以上,那麼應該將它抽象成一個通用方法或檔案,在需要使用的地方去依賴引入,確保在改動的時候,只需調整一處,所有的地方都改變過來,而不是到每個地方去找到相應的程式碼來修改。
在實際工作中,我見過兩種在這條原則上各自走向極端的程式碼:
- 一種是完全沒有抽象概念,重複的程式碼散落在各處,更奇葩的是,有一部分的抽象,但更多的是重複,比如在common下抽取了一個
data.js
的資料處理檔案,部分頁面中引用了該檔案,而更多頁面完全拷貝了該檔案中的幾個不同方法程式碼。而作者的意圖則是令人啼笑皆非——只用到小部分程式碼,沒必要引入那麼整個檔案。且不論現代化的前端構建層面可以解決這個問題,即使是引入了整個大檔案,這部分多餘的程式碼在gzip之後也不會損失多少效能,但這種到處copy的行為帶來後續的維護成本是翻倍的。 - 對於這種行為還遇到另外一個理由,就是工期時間短,改不動之前的程式碼,怕造成外網問題,那就拷貝一份相同的邏輯來修改。比如支付邏輯,原有的邏輯為單獨的UI浮層+單個支付購買,現在產品提出“打包購買”需求,原有的程式碼邏輯又比較複雜,出現了“改不動”的現象,於是把UI層和購買邏輯的幾個檔案整個拷貝過來,修改幾下,形成了新的“打包購買”模組,後來產品又提出“按條購買”,按照上述”改不動“原則,又拷貝了一份“按條購買”的模組。這樣一來呼叫處的邏輯就會冗餘重複,需要根據不同的購買方式引入不同UI元件和支付邏輯,另外如果新添需求,如支援“分期付款”,那麼將改動的是非常多的檔案,最可悲的是,最後想要把程式碼重構為一處統一呼叫的人,將會面對三份“改不動”的壓力,需要眾多邏輯中對比分析提取相同之處,工作量已經不能用翻倍來衡量,而這種工作量往往無法得到產品的認同和理解。
- 另一種極端是過度設計,在寫每個邏輯的時候都去抽象,讓程式碼的可讀性大大下降,一個簡單的for迴圈都要複用,甚至變數定義,這種程式碼維護起來也是比較有成本的,還有將迥然不同的邏輯過度抽象,使得抽象方法變得非常複雜,經常“牽一髮而動全身”,這種行為也是不可取的。
這也是將該原則排在首位的原因,這種行為導致的重構工作量是最大的,保持良好的程式碼維護性是一種素養,更是一種責任,如果自己在這方面逃避或偷懶,將把這塊工作量翻倍地加在將來別人或自己的身上。
2. SRP(Single Responsibility Principle)
SRP也是一個比較著名的設計原則——單一職責,在物件導向的程式設計中,認為類應該具有單一職責,一個類的改變動機應當只有一個。
對於前端開發來說,最需要貫徹的思想是函式應當保持單一職責,一個函式應當只做一件事,這樣一來是保證函式的可複用性,更單一的函式有更強的複用性,二來可以讓整體的程式碼框架更加清晰,細節都封裝在一個個小函式中。另外一點也和單一職責有關,就是無副作用的函式,也稱純函式,我們應當儘量保證純函式的數量,非純函式是不可避免的,但應當儘量減少它。
把SRP原則排在第二位,因為它非常的重要,沒有人願意看一團亂麻的邏輯,在維護程式碼時,如果沒有一個清晰的邏輯結構,所有的資料定義、資料處理、DOM操作等等一系列細節的程式碼全部放在一個函式中,導致這個函式非常的冗長,讓人本能地產生心理排斥,不願去檢視內部的邏輯。
所有的複雜邏輯放在一個函式中,相信大家看到這樣的程式碼都會眉頭一皺:
show: function(a, b) {
if (!isInit) {
init();
isInit = true;
} // reset this.balance = 0;
this.isAllBalance = false;
var shouldShowLayer = true, preSelectedTermId = 0, needAddress = course.address_state, showTerms, termsObj;
var hasPunish = false;
this.course = course = course || {
};
opt = opt || {
};
opt.showMax = opt.showMax || 6;
(isIosApp || b.isIAP) &
&
(usekedian = !0, priceSymbol = '<
i class="icon-font i-kedian">
<
/i>
'), f.splice(b.showMax), layer.show({
$container:b.$container, content:termSelectorTpl({
terms:f, curTermId:b.curTermId || d, name:a.name, hasPunish:h, userInfo:j
}, {
renderTime:T.render.time.renderCourseTime, renderCourseTime:renderCourseTime, hideUserInfo:b.hideUserInfo, hideTitle:b.hideTitle, hidePayPrice:b.hidePayPrice, confirmText:b.confirmText, sys_time:a.sys_time
}), cls:"term-select-new", allowMove:function(a) {
return opt.allowMove || ($target.closest('.select-content').length &
&
$('.term-select-new .select-time').height() + $('.term-select-new .select-address').height() + $('.term-select-new .select-discounts').height() >
(winWidth >
360 ? 190 : winWidth >
320 ? 175 : 150));
}, afterInit:function(c) {
if (needAddress) {
that.loadAddress();
// 如果需要地址,且是 app 的話,螢幕可見性切換時需要更新下地址 if (isApp) {
$(document).on(visibilityChange, function (e) {
// console.log('visibilityChange',document[hidden]);
if (!document[hidden]) {
// true 參數列示必須重新整理 that.loadAddress(true);
}
});
}
} that.afterTermSelect();
$dom.on('click', '.layer-close', function() {
setTimeout(function() {
!opt.noAutoHide &
&
layer.hide();
}, 100);
opt.onCancel &
&
opt.onCancel();
});
$dom.on('click', '.term', function(e) {
var $this = $(this);
var $terms = $('.term');
if (!$this.hasClass('disabled')) {
$terms.removeClass('selected');
$this.addClass('selected');
} that.afterTermSelect();
});
$dom.on('click', '.layer-comfirm', function(e) {
var $this = $(this);
var termId = $dom.find('.term.selected').data('term-id');
var termName = $dom.find('.term.selected').find('.term-title').html();
var discountId = $dom.find('.discounts-list_item.selected').data('discount-id');
var couId = $dom.find('.discounts-list_item.selected .discounts-coupon').data('cou-id');
var directPay = false;
// ios 手Q IAP if (that.toRecharge) {
// 需要充值的金額數目 var toRechargePrice = that.curPrice - that.balance;
if (isIosApp) {
require.async('api', function (api) {
api.invoke('api', 'balanceRecharge', {
amount: toRechargePrice
});
// 充值完成設定回撥 api.addEventListener('balanceRechargeCallBack', function(data) {
// 支付成功的話 // code=0為成功,其他表示失敗 // mode=1表示走充值檔位回撥,2表示直接充值回撥,如果ios 直接充值成功則直接支付 var directPay = data.code === 0 &
&
data.mode === 2;
// 執行回撥重新整理資料 that.toGetBalance(that.course, termId, function() {
directPay &
&
$this.trigger('click');
});
});
});
} else {
var toRechargePrice = that.curPrice - that.balance;
if (that.rechargeMap &
&
Object.keys(that.rechargeMap).indexOf("" + toRechargePrice) >
-1) {
that.opt.onComfirmClick &
&
that.opt.onComfirmClick(1);
iosPay.iosRecharge({
productId: that.rechargeMap[toRechargePrice], count: toRechargePrice, succ: function() {
that.toGetBalance(that.course, $('.term.selected').data('term-id'));
}
});
} else {
that.opt.onComfirmClick &
&
that.opt.onComfirmClick(2);
// T.jump('/iosRecharge.html?_bid=167&
_wv=2147483651');
that.jumpPage('/iosRecharge.html?_bid=167&
_wv=2147483651');
}
} return;
} if (!termId) {
require.async(['modules/tip/tip'], function(Tip) {
Tip.show(opt.dialogTitle);
});
return true;
} // check address if (needAddress &
&
!that.addressid) {
if (course.must_fill_mailing || !$dom.find('.select-address').hasClass('z-no')) {
// 沒填地址的話地址框要標紅,然後需要滑到視窗讓使用者看到 var $cnt = $dom.find('.select-content');
var $addressWrap = $dom.find('.select-address_wrapper').addClass('z-err');
var cntRect = $cnt[0].getBoundingClientRect();
var addressBoxRect = $addressWrap[0].getBoundingClientRect();
// console.log('>
>
>
>
>
', cntRect, addressBoxRect);
if (addressBoxRect.bottom >
cntRect.bottom) {
$cnt.scrollTop($cnt.scrollTop() + (addressBoxRect.bottom - cntRect.bottom));
} return;
}
} if (that.isAllBalance &
&
that.opt.onComfirmClick) {
that.opt.onComfirmClick(3);
} opt.cb &
&
opt.cb(termId, discountId, couId, termName, that.isAllBalance, that.payBalance, that.addressid);
setTimeout(function() {
!opt.noAutoHide &
&
layer.hide();
}, 300);
});
$dom.on('click', '.discounts-list_item', function(e) {
var $this = $(this);
var $discounts = $('.discounts-list_item');
var isSelected = $this.hasClass('selected');
if (!$this.hasClass('disabled')) {
$discounts.removeClass('selected');
$this.addClass(isSelected ? '' : 'selected');
that.setPayPrice();
}
});
$dom.on('click', '.address-person .i-edit2, .address-add', function() {
var termId = $dom.find('.term.selected').data('term-id');
var courseId = that.course.cid;
var src = '/addrEdit.html?_bid=167&
_wv=2147483649&
ns=1&
fr=' + (location.pathname.indexOf('allCourse.html') >
-1 ? 4 : location.pathname.indexOf('courseDetail.html') >
-1 ? 2 : 3) + '&
course_id=' + courseId + '&
term_id=' + termId;
// T.jump(src);
that.jumpPage(src);
}).on('click', '.select-address_title .i-right-light', function(e) {
var $addressDom = $dom.find('.select-address');
var isOpen = !$addressDom.hasClass('z-no');
if (isOpen) {
$addressDom.addClass('z-no');
that.theAddressid = that.addressid;
that.addressid = undefined;
} else {
$addressDom.removeClass('z-no');
that.addressid = that.theAddressid;
}
});
}
});
} else {
opt.cb &
&
opt.cb(opt.curTermId || preSelectedTermId);
}
}複製程式碼
單一職責並不一定要通過很多函式來完成,也可以用分段達到目的,如同這樣:
show(data) {
data &
&
this.setData(data);
const renderData = {
data: this.data, courseData: this.data.courseData, termList: this.termList, userInfo: this.userInfo, addrList: this.addrList, isIAP: this.isIAP, balance: betterDisplayNum(this.balance), curPrice: betterDisplayNum(this.curPrice), curTermId: this.curTermId, discountList: this.discountList, curDisId: this.curDisId, jdSelectId: this.jdSelectId, curAddrId: this.curAddrId
};
const formatters = {
// formatters termFormatter, priceFormatter, okBtnFormatter, balanceFormatter, priceFormatterWithDiscount
};
console.log('[render data]: ', renderData);
const html = payLayerTpl(renderData, formatters);
// 記錄滾動條位置 this._setScrollTop();
// 防止重複append if (this.$view) {
this.$view.replaceWith(html);
} else {
this.$container.append(html);
} afterUIRender(() =>
{
this.$view = $('.' + COMPONENT_NAME).show();
this._setContentHeight();
// 動態設定滾動區域的高度 this._restoreScrollTop();
// 恢復滾動位置 this._initEvent();
this._initCountDown();
// 限時折扣倒數計時
});
}複製程式碼
雖然這個函式也沒有維持單一職責,但通過“分段”的形式清晰的表明了內部的流程邏輯,這樣的程式碼看起來就會比所有細節揉在一個函式中好很多。
對於單一職責來說,保持起來還是比較困難的,主要在於職責的拆分,有時過於細緻的職責拆分也會給閱讀帶來比較大的困難,對於這種情況,還是拿寫作來對比,單一職責相當於文章的一個“段落”,對於文章來說,每個段落都有它的中心思想,可以用一句話描述出來,如果你發現函式的中心思想很模糊,或者需要很多語言去描述它,那也許它已經有很多個職責該拆分了。
3. LKP(Least Knowledge Principle)
LKP原則是最小知識原則,又稱“迪米特”法則,也就是說,一個物件應該對另一個物件有最少的瞭解,你內部如何複雜都沒關係,我只關心呼叫的地方。
保持暴露介面的簡介易用性也是API設計的通用規則,在實際中發現了這樣的一個UI元件:
module.exports = {
show: function(course, opt) {
// 此處省略一堆邏輯
}, jumpPage: function(url) {
// 此處省略一堆邏輯
}, afterTermSelect: function() {
// 此處省略一堆邏輯
}, setPrice: function() {
// 此處省略一堆邏輯
}, setBalance: function() {
// 此處省略一堆邏輯
}, toGetBalance: function(course, curTermId, cb) {
// 此處省略一堆邏輯
}, setDiscounts: function(course, curTermId, curPrice) {
// 此處省略一堆邏輯
}, filterDiscounts: function(discounts, curPrice) {
// 此處省略一堆邏輯
}, isSuitCoupon: function(cou, curPrice) {
// 此處省略一堆邏輯
}, setPayPrice: function() {
// 此處省略一堆邏輯
}, setTermTips: function(wording) {
// 此處省略一堆邏輯
}, loadAddress: function(needUpdate) {
// 此處省略一堆邏輯
}, setAddress: function(addressid) {
// 此處省略一堆邏輯
}
}複製程式碼
這個UI元件暴露了非常多的方法,有業務邏輯,有檢視邏輯,還有工具方法,這時會給維護者帶來比較大的困擾,本能的以為這些暴露出去的方法都在被使用,所以想重構其中某些方法都有些不好下手,而實際上,外部呼叫的方法僅僅是show
而已。
一個好的封裝,無論內部多麼複雜,它暴露出來的一定是最簡潔實用的介面,而內部邏輯是獨立維護的,如上述程式碼,作為一個UI元件來說,提供最基本的show/hide
方法即可,有必要時可加入update
方法自更新,而無需暴露眾多細節,造成呼叫者和維護者的困擾。
4. 可讀性基本定理
可讀性基本定理——“程式碼的寫法應當使別人理解它所需的時間最小化”。
程式碼風格和原則不是一概而論的,我們經常需要對一些編碼原則和方案進行取捨,例如對於三元表示式的取捨,當我們覺得兩種方案都佔理時,那麼唯一的評判標準就是可讀性基本定理,無論寫法多麼的高超炫技,最好的程式碼依舊是讓人第一時間能夠理解的程式碼。
5. 有意義的名稱
程式碼的可讀性絕大部分依賴於變數和函式的命名,一個好的名稱能夠一針見血地幫助維護者理解邏輯,如同寫文章中的“文筆”,文筆優異者總能將故事娓娓道來,引人入勝。
不過要起好名稱還是很難的,尤其是我們不是以英語為母語,更是新增了一層障礙,有些人認為糾結在名稱上會導致效率變低,開發第一時間應該完成需求的開發。這樣說並沒有錯,我們在開發過程中應當專注於功能邏輯,但不要完全忽視命名,所謂“文筆”是需要鍛鍊的,思考的越多,命名就會愈加的水到渠成,到後來也就不太會影響工作效率了。
在這裡推薦鮑勃大叔提到的童子軍規,每一次看自己的程式碼,都進行一次重構,最簡單的重構便是改名,也許一開始覺得命名還比較貼合,但邏輯越寫越不符合初始的命名了,當回顧程式碼時,我們可以順手對變數和方法進行重新命名,現代編輯工具也很容易做到這一點。
文不對題的命名是最可怕的,如:
function checkTimeConflict(opts) {
if (opts.param.passcard || (T.bom.get('autopay') &
&
T.bom.get('term_id'))) {
selectToPay({
result: {
}
}, opts);
} else {
DB.checkTimeConflict({
param: {
course_id: opts.param.courseId, term_id: opts.param.termId
}, succ: function(data) {
selectToPay(data, opts);
}, err: function(data) {
dealErr(opts, data);
}
});
}
}複製程式碼
這個函式被命名為check*
開頭的,本意是檢測課程時間是否衝突,但內部邏輯卻包含了支付整個流程,此時對於呼叫者來說,如果不去細看內部邏輯,很有可能就會錯誤的認為check
函式沒有副作用導致事故發生。
6. 適當的註釋維護
註釋是一個比較有爭議性的話題,有人認為可讀的函式變數就很清晰,不需要額外的註釋,且註釋有不可維護性,如:
// 1-PC, 2-android手QH5, 3-android APP, 4-ios&
非手QH5, 5-IOS APPvar platform = isAndroidApp ? 3 : isIosApp ? 5 : 4;
複製程式碼
實際上,這個欄位的含義早已發生了改變,但由於修改者只修改了邏輯,並沒有注意到這一行註釋,導致這個老註釋提供了錯誤資訊,此時的註釋不僅變成了無效註釋,甚至會導致維護人的誤解,造成bug的產生。
對於這種情況,要麼維護註釋,要麼在註釋裡面註明介面文件,維護文件,在其他情況下,適當的註釋是有必要的,對於複雜的邏輯,如果有一個簡練的註釋,對於程式碼可讀性的幫助是極大的,但有些不必要的註釋可以去掉,註釋的取捨關鍵在於可讀性基本定理,如:
const filterFn = (term) =>
{
if (rule.hideEndTerms &
&
term.is_end) {
return false;
// 隱藏已結束的期
} if (rule.hideSignEndTerms &
&
term.is_out_of_date) {
return false;
// 隱藏已結束報名的期
} if (rule.hideAppliedTerms &
&
courseUtil.isTermApplied(term)) {
return false;
// 隱藏已報名的期
} if (rule.hideZeroAllowedTerms &
&
courseUtil.isTermZero(term)) {
return false;
// 隱藏名額已滿的期
} if (rule.productType === productType.PACKAGE) {
return false;
// 隱藏課程包的班級
} return true;
};
複製程式碼
對於上述邏輯來說,雖然通過變數可以大致猜出功能含義,但一眼看上去就能清晰掌握邏輯結構,歸功於註釋的簡明與清晰。
小結
本文提到的6個程式碼編寫的原則,前三個偏向於程式碼維護性,後三個偏向於程式碼可讀性,整個可維護性和可讀性構成了程式碼的基本素養。作為一名前端開發工程師,想要擁有良好的程式碼素養,首先要讓自己的程式碼可維護,不給別人的維護帶來巨大的成本和工作量,其次儘量保證程式碼的美觀可讀,整潔的程式碼人見人愛,如同閱讀一本好書,令人心情愉悅。
”程式碼素養“是一種態度,真正熱愛程式設計的程式設計師一定不會缺失“程式碼素養”。我們通常稱“寫程式碼”為“程式設計”
,而不是“程式編寫”,“設計”一詞體現出了我們的程式碼是一件作品,也許不如“藝術品”那麼精緻,但也不是什麼粗麻爛布,如果在寫程式碼時天馬行空,得過且過,抱著只要能實現功能的思想,那這部“作品“是不具有觀賞價值的,這不僅僅體現出程式碼編寫者的”不專業”,更是反映出對待程式設計這件事的態度,程式碼的整潔程度、可維護性取決於你是否真正“在意”你的程式碼,每個程式設計師不一定熱愛程式設計,但請你一定要以“認真”的態度對待自己的專業。
"clean code"
的作者鮑勃大叔提到,有人曾送給他一條腕帶,上面寫著“Test Obsessed”,他發覺自己帶上後再也無法取下了,不僅是因為腕帶很緊,更是因為它也是一條精神上的緊箍咒。在程式設計時,我們下意識的看下自己的手腕,是否能發現一條隱形的腕帶呢?
來源:https://juejin.im/post/5af18dff518825671c0e8469?utm_medium=fe&utm_source=weixinqun