JavaScript代碼風格要素
1920年,由威廉·斯特倫克(William Strunk jr .)撰寫的《英語寫作手冊:風格的要素(The Elements of Style)》出版了,這本書列舉了7條英文寫作的準則,過了一個世紀,這些準則并沒有過時。對于工程師來說,你可以在自己的編碼風格中應用類似的建議來指導日常的編碼,提高自己的編碼水平。
需要注意的是,這些準則不是一成不變的法則。如果違背它們,能夠讓代碼可讀性更高,那么便沒有問題,但請特別小心并時刻反思。這些準繩是經受住了時間考驗的,有充分的理由說明:它們通常是正確的。如果要違背這些規則,一定要有充足的理由,而不要單憑一時的興趣或者個人的風格偏好。
書中的寫作準則如下:
- 以段落為基本單位:一段文字,一個主題。
- 刪減無用的語句。
- 使用主動語態。
- 避免一連串松散的句子。
- 相關的內容寫在一起。
- 從正面利用肯定語句去發表陳述。
- 不同的概念采用不同的結構去闡述。
我們可以應用相似的理念到代碼編寫上面:
- 一個function只做一件事,讓function成為代碼組合的最小單元。
- 刪除不必要的代碼。
- 使用主動語態。
- 避免一連串結構松散的,不知所云的代碼。
- 將相關的代碼寫在一起。
- 利用判斷true值的方式來編寫代碼。
- 不同的技術方案利用不同的代碼組織結構來實現。
1.一個function只做一件事,讓function成為代碼組合的最小單元
軟件開發的本質是“組合”。 我們通過組合模塊,函數和數據結構來構建軟件。
理解如果編寫以及組合方法是軟件開發人員的基本技能。
模塊是一個或多個function和數據結構的簡單集合,我們用數據結構來表示程序狀態,只有在函數執行之后,程序狀態才會發生一些有趣的變化。
JavaScript中,可以將函數分為3種:
- I/O 型函數 (Communicating Functions):函數用來執行I/O。
- 過程型函數 (Procedural Functions):對一系列的指令序列進行分組。
- 映射型函數 (Mapping Functions):給定一些輸入,返回對應的輸出。
有效的應用程序都需要I/O,并且很多程序都遵循一定的程序執行順序,這種情況下,程序中的大部分函數都會是映射型函數:給定一些輸入,返回相應的輸出。
每個函數只做一件事情:如果你的函數主要用于I/O,就不要在其中混入映射型代碼,反之亦然。嚴格根據定義來說,過程型函數違反了這一指導準則,同時也違反了另一個指導準則:避免一連串結構松散,不知所云的代碼。
理想中的函數是一個簡單的、明確的純函數:
同樣的輸入,總是返回同樣的輸出。
無副作用。
也可以查看,“什么是純函數?”
2. 刪除不必要的代碼
簡潔的代碼對于軟件而言至關重要。更多的代碼意味更多的bug隱藏空間。更少的代碼 = 更少的bug隱藏空間 = 更少的bug
簡潔的代碼讀起來更清晰,因為它擁有更高的“信噪比”:閱讀代碼時更容易從較少的語法噪音中篩選出真正有意義的部分。可以說,更少的代碼 = 更少的語法噪聲 = 更強的代碼含義信息傳達
借用《風格的元素》這本書里面的一句話就是:簡潔的代碼更健壯。
- function secret (message) {
- return function () {
- return message;
- }
- };
可以簡化成:
- const secret = msg => () => msg;
對于那些熟悉簡潔箭頭函數寫法的開發來說,可讀性更好。它省略了不必要的語法:大括號,function關鍵字以及return語句。
而簡化前的代碼包含的語法要素對于傳達代碼意義本身作用并不大。它存在的唯一意義只是讓那些不熟悉ES6語法的開發者更好的理解代碼。
ES6自2015年已經成為語言標準,是時候去學習它了。
刪除不必要的代碼
有時候,我們試圖為不必要的事物命名。問題是人類的大腦在工作中可用的記憶資源有限,每個名稱都必須作為一個單獨的變量存儲,占據工作記憶的存儲空間。
由于這個原因,有經驗的開發者會盡可能地刪除不必要的變量。
例如,大多數情況下,你應該省略僅僅用來當做返回值的變量。你的函數名應該已經說明了關于函數返回值的信息。看看下面的:
- const getFullName = ({firstName, lastName}) => {
- const fullName = firstName + ' ' + lastName;
- return fullName;
- };
對比
- const getFullName = ({firstName, lastName}) => (
- firstName + ' ' + lastName
- );
另一個開發者通常用來減少變量名的做法是,利用函數組合以及point-free-style。
Point-free-style是一種定義函數方式,定義成一種與參數無關的合成運算。實現point-free風格常用的方式包括函數科里化以及函數組合。
讓我們來看一個函數科里化的例子:
- const add2 = a => b => a + b;
- // Now we can define a point-free inc()
- // that adds 1 to any number.
- const inc = add2(1);
- inc(3); // 4
看一下inc()函數的定義方式。注意,它并未使用function關鍵字,或者=>語句。add2也沒有列出一系列的參數,因為該函數不在其內部處理一系列的參數,相反,它返回了一個知道如何處理參數的新函數。
函數組合是將一個函數的輸出作為另一函數的輸入的過程。 也許你沒有意識到,你一直在使用函數組合。鏈式調用的代碼基本都是這個模式,比如數組操作時使用的.map(),Promise 操作時的promise.then()。函數組合在函數式語言中也被稱之為高階函數,其基本形式為:f(g(x))。
當兩個函數組合時,無須創建一個變量來保存兩個函數運行時的中間值。我們來看看函數組合是怎么減少代碼的:
- const g = n => n + 1;
- const f = n => n * 2;
- // 需要操作參數、并且存儲中間結果
- const incThenDoublePoints = n => {
- const incremented = g(n);
- return f(incremented);
- };
- incThenDoublePoints(20); // 42
- // compose2 - 接受兩個函數作為參數,直接返回組合
- const compose2 = (f, g) => x => f(g(x));
- const incThenDoublePointFree = compose2(f, g);
- incThenDoublePointFree(20); // 42
你可以利用函子(functor)來做同樣的事情。在函子中把參數封裝成可遍歷的數組。讓我們利用函子來寫另一個版本的compose2:
- const compose2 = (f, g) => x => [x].map(g).map(f).pop();
- const incThenDoublePointFree = compose2(f, g);
- incThenDoublePointFree(20); // 42
當每次使用promise鏈時,你就是在做這樣的事情。
幾乎每一個函數式編程類庫都提供至少兩種函數組合方法:從右到左依次運行的compose();從左到右依次運行的pipe()。
Lodash中的compose()以及flow()分別對應這兩個方法。下面是使用pipe 的例子:
- import pipe from 'lodash/fp/flow';
- pipe(g, f)(20); // 42
下面的代碼也做著同樣的事情,但代碼量并未增加太多:
- const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
- pipe(g, f)(20); // 42
如果函數組合這個名詞聽起來很陌生,你不知道如何使用它,請仔細想一想:
軟件開發的本質是組合,我們通過組合較小的模塊,方法以及數據結構來構建應用程序。
不難推論,工程師理解函數和對象組合這一編程技巧就如同搞裝修需要理解鉆孔機以及氣槍一樣重要。
當你利用“命令式”代碼將功能以及中間變量拼湊在一起時,就像瘋狂使用膠帶和膠水將這些部分胡亂粘貼起來一樣,而函數組合看上去更流暢。
記住:
- 用更少的代碼。
- 用更少的變量。
3. 使用主動語態
主動語態比被動語態更直接,跟有力量,盡量多直接命名事物:
- myFunction.wasCalled()優于myFunction.hasBeenCalled()
- createUser優于User.create()
- notify()優于Notifier.doNotification()
命名布爾返回值時最好直接反應其輸出的類型:
- isActive(user)優于getActiveStatus(user)
- isFirstRun = false;優于firstRun = false;
函數名采用動詞形式:
- increment()優于plusOne()
- unzip()優于filesFromZip()
- filter(fn, array)優于matchingItemsFromArray(fn, array)
事件處理
事件處理以及生命周期函數由于是限定符,比較特殊,就不適用動詞形式這一規則;相比于“做什么”,它們主要用來表達“什么時候做”。對于它們,可以“<什么時候去做>,<動作>”這樣命名,朗朗上口。
- element.onClick(handleClick)優于element.click(handleClick)
- element.onDragStart(handleDragStart)優于component.startDrag(handleDragStart)上面兩例的后半部分,它們讀起來更像是正在嘗試去觸發一個事件,而不是對其作出回應。
生命周期函數
對于組件生命周期函數(組件更新之前調用的方法),考慮一下以下的命名:
- componentWillBeUpdated(doSomething)
- componentWillUpdate(doSomething)
- beforeUpdate(doSomething)第一個種我們使用了被動語態(將要被更新而不是將要更新)。這種方式很口語化,但含義表達并沒有比其它兩種方式更清晰。
第二種就好多了,但生命周期函數的重點在于觸發處理事件。componentWillUpdate(handler)讀起來就好像它將立即觸發一個處理事件,但這不是我們想要表達的。我們想說,“在組件更新之前,觸發事件”。beforeComponentUpdate()能更清楚的表達這一想法。
進一步簡化,因為這些方法都是組件內置的。在方法名中加入component是多余的。想一想如果你直接調用這些方法時:component.componentWillUpdate()。這就好像在說,“吉米吉米在晚餐吃牛排。”你沒有必要聽到同一個對象的名字兩次。顯然,
component.beforeUpdate(doSomething)優于component.beforeComponentUpdate(doSomething)
函數混合是指將方法作為屬性添加到一個對象上面,它們就像裝配流水線給傳進來的對象加上某些方法或者屬性。
我喜歡用形容詞來命名函數混合。你也可以經常使用"ing"或者"able"后綴來找到有意義的形容詞。例如:
const duck = composeMixins(flying, quacking);
const box = composeMixins(iterable, mappable)
4.避免一連串結構松散的,不知所云的代碼
開發人員經常將一系列事件串聯在一個進程中:一組松散的、相關度不高的代碼被設計依次運行。從而很容易形成“意大利面條”代碼。
這種寫法經常被重復調用,即使不是嚴格意義上的重復,也只有細微的差別。例如,界面不同組件之間幾乎共享相同的核心需求。 其關注點可以分解成不同生命周期階段,并由單獨的函數方法進行管理。
考慮以下的代碼:
- const drawUserProfile = ({ userId }) => {
- const userData = loadUserData(userId);
- const dataToDisplay = calculateDisplayData(userData);
- renderProfileData(dataToDisplay);
- };
這個方法做了三件事:獲取數據,根據獲取的數據計算view的狀態,以及渲染。
在大部分現代前端應用中,這些關注點中的每一個都應該考慮分拆開。通過分拆這些關注點,我們可以輕松地為每個問題提供不同的函數。
比如,我們可以完全替換渲染器,它不會影響程序的其他部分。例如,React的豐富的自定義渲染器:適用于原生iOS和Android應用程序的ReactNative,WebVR的AFrame,用于服務器端渲染的ReactDOM/Server 等等...
drawUserProfile的另一個問題就是你不能在沒有數據的情況下,簡單地計算要展示的數據并生成標簽。如果數據已經在其他地方加載過了會怎么樣,就會做很多重復和浪費的事情。
分拆關注點也使得它們更容易進行測試。我喜歡對我的應用程序進行單元測試,并在每次修改代碼時查看測試結果。但是,如果我們將渲染代碼和數據加載代碼寫在一起,我不能簡單地將一些假數據傳遞給渲染代碼進行測試。我必須從端到端測試整個組件。而這個過程中,由于瀏覽器加載,異步I/O請求等等會耗費時間。
上面的drawUserProfile代碼不能從單元測試測試中得到即時反饋。而分拆功能點允許你進行單獨的單元測試,得到測試結果。
上文已經已經分析出單獨的功能點,我們可以在應用程序中提供不同的生命周期鉤子給其調用。 當應用程序開始裝載組件時,可以觸發數據加載。可以根據響應視圖狀態更新來觸發計算和渲染。
這么做的結果是軟件的職責進一步明確:每個組件可以復用相同的結構和生命周期鉤子,并且軟件性能更好。在后續開發中,我們不需要重復相同的事。
5.功能相連的代碼寫在一起
許多框架以及boilerplates規定了程序文件組織的方法,其中文件按照代碼類別分組。如果你正在構建一個小的計算器,獲取一個待辦事宜的app,這樣做是很好的。但是對于較大的項目,通過業務功能特性將文件分組在一起是更好的方法。
按代碼類別分組:
- .
- ├── components
- │ ├── todos
- │ └── user
- ├── reducers
- │ ├── todos
- │ └── user
- └── tests
- ├── todos
- └── user
按業務功能特性分組:
- .
- ├── todos
- │ ├── component
- │ ├── reducer
- │ └── test
- └── user
- ├── component
- ├── reducer
- └── test
當你通過功能特性來將文件分組,你可以避免在文件列表上下滾動,查找編輯所需要的文件這種情況。
6.利用判斷true值的方式來編寫代碼
要做出確定的斷言,避免使用溫順、無色、猶豫的語句,必要時使用 not 來否定、拒絕。典型的
- isFlying優于isNotFlying
- late優于notOneTime
if 語句
- if (err) return reject(err);
- // do something
優于
- if (!err) {
- // ... do something
- } else {
- return reject(err);
- }
三元判斷語句
- {
- [Symbol.iterator]: iterator ? iterator : defaultIterator
- }
優于
- {
- [Symbol.iterator]: (!iterator) ? defaultIterator : iterator
- }
恰當的使用否定
有時候我們只關心一個變量是否缺失,如果通過判斷true值的方式來命名,我們得用!操作符來否定它。這種情況下使用 "not" 前綴和取反操作符不如使用否定語句直接。
if (missingValue)優于if (!hasValue)
if (anonymous)優于if (!user)
if (!isEmpty(thing))優于if (notDefined(thing))
函數調用時,避免用null以及undefined代替某一個參數
不要在函數調用時,傳入undefined或者null作為某個參數的值。如果某些參數可以缺失,更推薦傳入一個對象:
- const createEvent = ({
- title = 'Untitled',
- timeStamp = Date.now(),
- description = ''
- }) => ({ title, description, timeStamp });
- const birthdayParty = createEvent({
- title: 'Birthday Party',
- description: 'Best party ever!'
- });
優于
- const createEvent = (
- title = 'Untitled',
- timeStamp = Date.now(),
- description = ''
- ) => ({ title, description, timeStamp });
- const birthdayParty = createEvent(
- 'Birthday Party',
- undefined, // This was avoidable
- 'Best party ever!'
- );
不同的技術方案利用不同的代碼組織結構來實現
迄今為止,應用程序中未解決的問題很少。最終,我們都會一次又一次地做著同樣的事情。當這樣的場景發生時,意味著代碼重構的機會來啦。分辨出類似的部分,然后抽取出能夠支持每個不同部分的公共方法。這正是類庫以及框架為我們做的事情。
UI組件就是一個很好的例子。10 年前,使用 jQuery 寫出把界面更新、應用邏輯和數據加載混在一起的代碼是再常見不過的。漸漸地,人們開始意識到我們可以將MVC應用到客戶端的網頁上面,隨后,人們開始將model與UI更新邏輯分拆。
最終,web應用廣泛采用組件化這一方案,這使得我們可以使用JSX或HTML模板來聲明式的對組件進行建模。
最終,我們就能用完全相同的方式去表達所有組件的更新邏輯、生命周期,而不用再寫一堆命令式的代碼
對于熟悉組件的人,很容易看懂每個組件的原理:利用標簽來表示UI元素,事件處理器用來觸發行為,以及用于添加回調的生命周期鉤子函數,這些鉤子函數將在必要時運行。
當我們對于類似的問題采用類似的模式解決時,熟悉這個解決模式的人很快就能理解代碼是用來做什么的。
結論:代碼應該簡單而不是過于簡單化
盡管在2015,ES6已經標準化,但在2017,很多開發者仍然拒絕使用ES6特性,例如箭頭函數,隱式return,rest以及spread操作符等等。利用自己熟悉的方式編寫代碼其實是一個幌子,這個說法是錯誤的。只有不斷嘗試,才能夠漸漸熟悉,熟悉之后,你會發現簡潔的ES6特性明顯優于ES5:與語法結構偏重的ES5相比,簡潔的es6的代碼很簡單。
代碼應該簡單,而不是過于簡單化。
簡潔的代碼有以下優勢:
- 更少的bug可能性
- 更容易去debug
但也有如下弊端:
- 修復bug的成本更高
- 有可能引用更多的bug
- 打斷了正常開發的流程
簡潔的代碼同樣:
- 更易寫
- 更易讀
- 更好去維護
清楚自己的目標,不要毫無頭緒。毫無頭緒只會浪費時間以及精力。投入精力去訓練,讓自己熟悉,去學習更好的編程方式,以及更有更有活力的代碼風格。
代碼應該簡單,而不是簡單化。