編碼如作文:寫出高可讀 JS 的 7 條原則

共 5914 字,讀完需 8 分鐘。編譯自 Eric Elliott 的文章,好的程序員寫出來的代碼就如同優美的詩賦,給閱讀的人帶來非常愉悅的享受。我們怎麼能達到那樣的水平?要搞清楚這個問題,先看看好的文章是怎麼寫出來的。

William Strunk 在 1920 年出版的《The Elements of Style》 一書中列出了寫出好文章的 7 條原則,過了近一個世紀,這些原則並沒有過時。對於工程師來說,代碼是寫一遍、修改很多遍、閱讀更多遍的重要產出,可讀性至關重要,我們可以用這些寫作原則指導日常的編碼,寫出高可讀的代碼。

需要注意的是,這些原則並不是法律,如果違背它們能讓代碼可讀性更高,自然是沒問題的,但我們需要保持警惕和自省,因為這些久經時間考驗的原則通常是對的,我們最好不要因為奇思異想或個人偏好而違背這些原則。

7 條寫作原則如下:

  1. 讓段落成為寫作的基本單位,每個段落只說 1 件事情;
  2. 省略不必要的詞語;
  3. 使用主動式;
  4. 避免連串的鬆散句子;
  5. 把相關內容放在一起;
  6. 多用肯定語句;
  7. 善用平行結構;

對應的,在編碼時:

  1. 讓函數成為編碼的基本單位,每個函數只做 1 件事情;
  2. 省略不必要的代碼;
  3. 使用主動式;
  4. 避免連串的鬆散表達式;
  5. 把相關的代碼放在一起;
  6. 多用肯定語句;
  7. 善用平行結構;

1. 讓函數成為編碼的基本單位,每個函數只做 1 件事情

The essence of software development is composition. We build software by composing modules, functions, and data structures together.

軟體開發的本質是組合,我們通過組合模塊、函數、數據結構來構造軟體。理解如何編寫和組合函數是軟體工程師的基本技能。模塊通常是一個或多個函數和數據結構的集合,而數據結構是我們表示程序狀態的方法,但是在我們調用一個函數之前,通常什麼也不會發生。在 JS 中,我們可以把函數分為 3 種:

  • I/O 型函數 (Communicating Functions):進行磁碟或者網路 I/O;
  • 過程型函數 (Procedural Functions):組織指令序列;
  • 映射型函數 (Mapping Functions):對輸入進行計算、轉換,返回輸出;

雖然有用的程序都需要 I/O,大多數程序都會有過程指令,程序中的大多數函數都會是映射型函數:給定輸入時,函數能返回對應的輸出。

每個函數只做一件事情: 如果你的函數是做網路請求(I/O 型)的,就不要在其中混入數據轉換的代碼(映射型)。如果嚴格按照定義,過程型函數很明顯違背了這條原則,它同時也違背了另外一條原則:避免連串的鬆散表達式。

理想的函數應該是簡單的、確定的、純粹的:

  • 輸入相同的情況下,輸出始終相同;
  • 沒有任何副作用;

關於純函數的更多內容可以參照這裡。

2. 省略不必要的代碼

「Vigorous writing is concise. A sentence should contain no unnecessary words, a paragraph no unnecessary sentences, for the same reason that a drawing should have no unnecessary lines and a machine no unnecessary parts. This requires not that the writer make all sentences short, or avoid all detail and treat subjects only in outline, but that every word tell.」

簡潔的代碼對軟體質量至關重要,因為更多的代碼等同於更多的 bug 藏身之所,換句話說:更少的代碼 = 更少的 bug 藏身之所 = 更少的 bug。

簡潔的代碼讀起來會更清晰,是因為它有更高的信噪比 (Signal-to-Noise Ratio):閱讀代碼時更容易從較少的語法噪音中篩選出真正有意義的部分,可以說,更少的代碼 = 更少的語法噪音 = 更高的信號強度

借用《The Elements of Style》中的原話:簡潔的代碼更有力,比如下面的代碼:

function secret (message) {n return function () {n return message;n }n};n

可以被簡化為:

const secret = msg => () => msg;n

顯然,對熟悉箭頭函數的同學來說,簡化過的代碼可讀性更好,因為它省略了不必要的語法元素:花括弧、function 關鍵字、return 關鍵字。而簡化前的代碼包含的語法要素對於傳達代碼意義本身作用並不大。當然,如果你不熟悉 ES6 的語法,這對你來說可能顯得比較怪異,但 ES6 從 2015 年之後已經成為新的語言標準,如果你還不熟悉,是時候去升級了。

省略不必要的變數

我們常常忍不住去給實際上不需要命名的東西強加上名字。問題在於人的工作記憶是有限的,閱讀代碼時,每個變數都會佔用工作記憶的存儲空間。因為這個原因,有經驗的程序員會儘可能的消除不必要的變數命名。

比如,在大多數情況下,你可以不用給只是作為返回值的變數命名,函數名應該足夠說明你要返回的是什麼內容,考慮下面的例子:

// 稍顯累贅的寫法nconst getFullName = ({firstName, lastName}) => {n const fullName = firstName + + lastName;n return fullName;n};nn// 更簡潔的寫法nconst getFullName = ({firstName, lastName}) => (n firstName + + lastNamen);n

減少變數的另外一種方法是利用 point-free-style,這是函數式編程裡面的概念。

point-free-style 是不引用函數所操作參數的一種函數定義方式,實現 point-free-style 的常見方法包括函數組合(function composotion)和函數科里化(function currying)。

先看函數科里化的例子:

const add = a => b => a + b;nn// Now we can define a point-free inc()n// that adds 1 to any number.nconst inc = add(1);nninc(3); // 4n

細心的同學會發現並沒有使用 function 關鍵字或者箭頭函數語法來定義 inc 函數。add 也沒有列出所 inc 需要的參數,因為 add 函數自己內部不需要使用這些參數,只是返回了能自己處理參數的新函數。

函數組合是指把一個函數的輸出作為另一個函數輸入的過程。不管你有沒有意識到,你已經在頻繁的使用函數組合了,鏈式調用的代碼基本都是這個模式,比如數組操作時使用的 map,Promise 操作時的 then。函數組合在函數式語言中也被稱之為高階函數,其基本形式為:f(g(x))。

把兩個函數組合起來的時候,就消除了把中間結果存在變數中的需要,下面來看看函數組合讓代碼變簡潔的例子:

先定義兩個基本操作函數:

const g = n => n + 1;nconst f = n => n * 2;n

我們的計算需求是:給定輸入,先對其 +1,再對結果 x2,普通做法是:

// 需要操作參數、並且存儲中間結果nconst incThenDoublePoints = n => {n const incremented = g(n);n return f(incremented);n};nnincThenDoublePoints(20); // 42n

使用函數組合的寫法是:

// 接受兩個函數作為參數,直接返回組合nconst compose = (f, g) => x => f(g(x));nconst incThenDoublePointFree = compose(f, g);nincThenDoublePointFree(20); // 42n

使用仿函數 (funcot) 也能實現類似的效果,在仿函數中把參數封裝成可遍歷的數組,然後使用 map 或者 Promise 的 then 實現鏈式調用,具體的代碼如下:

const compose = (f, g) => x => [x].map(g).map(f).pop();nconst incThenDoublePointFree = compose(f, g);nincThenDoublePointFree(20); // 42n

如果你選擇使用 Promise 鏈,代碼看起來也會非常的像。

基本所有提供函數式編程工具的庫都提供至少 2 種函數組合模式:

  • compose:從右向左執行函數;
  • pipe:從左向右執行函數;

lodash 中的 compose() 和 flow() 分別對應這 2 種模式,下面是使用 flow() 的例子:

import pipe from lodash/fp/flow;npipe(g, f)(20); // 42n

如果不用 lodash,用下面的代碼也可以實現相同的功能:

const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);npipe(g, f)(20); // 42n

如果上面介紹的函數組合你覺得很異類,並且你不確定你會怎麼使用它們,請仔細思考下面這句話:

The essence of software development is composition. We build applications by composing smaller modules, functions, and data structures.

從這句話,我們不難推論,理解函數和對象的組合方式對工程師的重要程度就像理解電鑽和衝擊鑽對搞裝修的人重要程度。當你使用命令式代碼把函數和中間變數組合在一起的時候,就如同使用膠帶把他們強行粘起來,而函數組合的方式看起來更自然流暢。

在不改變代碼作用,不降低代碼可讀性的情況下,下面兩條是永遠應該謹記的:

  • 使用更少的代碼;
  • 使用更少的變數;

3. 使用主動式

「The active voice is usually more direct and vigorous than the passive.」

主動式通常比被動式更直接、有力,變數命名時要儘可能的直接,不拐彎抹角,例如:

  • 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)

事件監聽函數(Event Handlers)和生命周期函數(Licecycle Methods)比較特殊因為他們更大程度是用來說明什麼時候該執行而不是應該做什麼,它們的命名方式可以簡化為:"<時機>,<動詞>"。

下面是事件監聽函數的例子:

  • element.onClick(handleClick) 優於 element.click(handleClick)
  • component.onDragStart(handleDragStart) 優於 component.startDrag(handleDragStart)

仔細審視上面兩例的後半部分,你會發現,它們讀起來更像是在觸發事件,而不是對事件做出響應。

至於生命周期函數,考慮 React 中組件更新之前應該調用的函數該怎麼命名:

  • componentWillBeUpdated(doSomething)
  • componentWillUpdate(doSomething)
  • beforeUpdate(doSomething)

componentWillBeUpdated 用了被動式,意指將要被更新,而不是將要更新,有些饒舌,明顯不如後面兩個好。

componentWillUpdate 更好點,但是這個命名更像是去調用 doSomething,我們的本意是:在 Component 更新之前,調用 doSomething,beforeComponentUpdate 能更清晰的表達我們的意圖。

進一步簡化,因為這些生命周期方法都是 Component 內置的,在方法中加上 Component 顯得多餘,可以腦補下直接在 Componenent 實例上調用這個方法的語法:component.componentWillUpdate,我們不需要把主語重複兩次。顯然,component.beforeUpdate(doSomething) 比 component.beforeComponentUpdate(doSomething)更直接、簡潔、準確。

還有一種函數叫 [Functional Mixins][8],它們就像裝配流水線給傳進來的對象加上某些方法或者屬性,這種函數的命名通常會使用形容詞,如各種帶 "ing" 或 "able" 後綴的辭彙,示例:

const duck = composeMixins(flying, quacking); // 會像鴨子叫nconst box = composeMixins(iterable, mappable); // 可遍歷的n

4. 避免連串的鬆散表達式

「…a series soon becomes monotonous and tedious.」

連串的鬆散代碼常常會變的單調乏味,而把不強相關但按先後順序執行的語句組合到過程式的函數中很容易寫出義大利面式的代碼(spaghetti code)。這種寫法常常會重複很多次,即使不是嚴格意義上的重複,也只有細微的差別。

比如,界面上的不同組件之間幾乎共享完全相同的邏輯結構,考慮下面的例子:

const drawUserProfile = ({ userId }) => {n const userData = loadUserData(userId);n const dataToDisplay = calculateDisplayData(userData);n renderProfileData(dataToDisplay);n};n

drawUserProfile 函數實際上做了 3 件不同的事情:載入數據、根據數據計算視圖狀態、渲染視圖。在大多數現代的前端框架裡面,這 3 件事情都做了很好的分離。通過把關注點分離,每個關注點的擴展和組合方式就多了很多。

比如說,我們可以把渲染部分完全替換掉而不影響程序的其他部分,實例就是 React 家族的各種渲染引擎:ReactNative 用來在 iOS 和 Android 中渲染 APP,AFrame 來渲染 WebVR,ReactDOM/Server 來做服務端渲染。

drawUserProfile 函數的另一個問題是:在數據載入完成之前,沒有辦法計算視圖狀態完成渲染,如果數據已經在其他地方載入過了會怎麼樣,就會做很多重複和浪費的事情。

關注點分離的設計能夠使每個環節能夠被獨立的測試,我喜歡為應用添加單元測試,並在每次修改代碼時查看測試結果。試想,如果把數據獲取和視圖渲染代碼寫在一起,單元測試將會變的困難,要麼需要傳入偽造的數據,要麼轉而採用比較笨重的 E2E 測試,而後者通常比較難立即給反饋,因為它們的運行比較耗時。

在使用 React 的場景下,drawUserProfile 中已經有了 3 個獨立的函數可以接入到 Component 生命周期方法上,數據載入可以在 Component 掛載之後觸發,而數據計算和渲染則可以在視圖狀態發生變化時觸發。結果是,程序不同部分的職責被做了清晰的劃分,每個 Component 都有相同的結構和生命周期方法,這樣的程序運行起來會更穩定,我們也會少很多重複的代碼。

5. 把相關代碼放在一起

很多框架和項目腳手架都規定了按代碼類別來組織文件的方式,如果僅僅是開發一個簡單的 TODO 應用,這樣做無可厚非,但是在大型項目中,按照業務功能去組織代碼通常更好。可能很多同學會忽略代碼組織與代碼可讀性的關係,想想看是否接手過看了半天還不知道自己要修改的代碼在哪裡的項目呢?是什麼原因造成的?

下面分別是按代碼類別和業務功能來組織一個 TODO 應用代碼的兩種方式:

按代碼類別組織

├── componentsn│ ├── todosn│ └── usern├── reducersn│ ├── todosn│ └── usern└── testsn ├── todosn └── usern

按業務功能組織

├── todosn│ ├── componentn│ ├── reducern│ └── testn└── usern ├── componentn ├── reducern └── testn

當按業務功能組織代碼的時候,我們修改某個功能的時候不用在整個文件樹上跳來跳去的找代碼了。關於代碼組織,《The Art of Readable Code》中也有部分介紹,感興趣的同學可以去閱讀。

6. 多用肯定語句

「Make definite assertions. Avoid tame, colorless, hesitating, non-committal language. Use the word not as a means of denial or in antithesis, never as a means of evasion.」

要做出確定的斷言,避免使用溫順、無色、猶豫的語句,必要時使用 not 來否定、拒絕或逃避。典型的:

  • isFlying 優於 isNotFlying
  • late 優於 notOnTime

If 語句

先處理錯誤情況,而後處理正常邏輯:

if (err) return reject(err);n// do something...n

優於先處理正常後處理錯誤:(對錯誤取反的判斷讀起來確實累)

if (!err) {n // ... do somethingn} else {n return reject(err);n}n

三元表達式

把肯定的放在前面:

{n [Symbol.iterator]: iterator ? iterator : defaultIteratorn}n

優於把否定的放在前面(有個設計原則叫 Do not make me think,用到這裡恰如其分):

{n [Symbol.iterator]: (!iterator) ? defaultIterator : iteratorn}n

恰當的使用否定

有些時候我們只關心某個變數是否缺失,如果使用肯定的命名會強迫我們對變數取反,這種情況下使用 "not" 前綴和取反操作符不如使用否定語句直接,比如:

  • if (missingValue) 優於 if (!hasValue)
  • if (anonymous) 優於 if (!user)
  • if (isEmpty(thing)) 優於 if (notDefined(thing))

善用命名參數對象

不要期望函數調用者傳入 undefined、null 來填補可選參數,要學會使用命名的參數對象,比如:

const createEvent = ({n title = Untitled,n timeStamp = Date.now(),n description = n}) => ({ title, description, timeStamp });nn// later...nconst birthdayParty = createEvent({n title: Birthday Party,n description: Best party ever!n});n

就比下面這種形式好:

const createEvent = (n title = Untitled,n timeStamp = Date.now(),n description = n) => ({ title, description, timeStamp });nn// later...nconst birthdayParty = createEvent(n Birthday Party,n undefined, // 要儘可能避免這種情況n Best party ever!n);n

7. 善用平行結構

「…parallel construction requires that expressions of similar content and function should be outwardly similar. The likeness of form enables the reader to recognize more readily the likeness of content and function.」

平行結構是語法中的概念,英語中的平行結構指:內容相似、結構相同、無先後順序、無因果關係的並列句。不管是設計模式還是編程範式,都可以放在這個範疇中思考和理解,如果有重複,就肯定有模式,平行結構對閱讀理解非常重要。

軟體開發中遇到的絕大多數問題前人都遇到並解決過,如果發現在重複做同樣的事情,是時候停下來做抽象了:找到相同的地方,構建一個能夠很方便的添加不同的抽象層,很多庫和框架的本質就是在做這類事情。

組件化是非常不錯的例子:10 年前,使用 jQuery 寫出把界面更新、應用邏輯和數據載入混在一起的代碼是再常見不過的,隨後人們意識到,我們可以把 MVC 模式應用到客戶端,於是就開始從界面更新中剝離數據層。最後,我們有了組件化這個東西,有了組件化,我們就能用完全相同的方式去表達所有組件的更新邏輯、生命周期,而不用再寫一堆命令式的代碼。

對於熟悉組件化概念的同學,很容易理解組件是如何工作的:部分代碼負責聲明界面、部分負責在組件生命周期做我們期望它做的事情。當我們在重複的問題上使用相同的編碼模式,熟悉這種模式的同學很快就能理解代碼在幹什麼。

總結:代碼應該簡單而不是過於簡化

Vigorous writing is concise. A sentence should contain no unnecessary words, a paragraph no unnecessary sentences, for the same reason that a drawing should have no unnecessary lines and a machine no unnecessary parts. This requires not that the writer make all sentences short, or avoid all detail and treat subjects only in outline, but that every word tell.

簡潔的代碼是有力的,它不應該包含不必要的變數、語法結構,不要求程序員一定要把代碼寫的最短,或者省略很多細節,而是要求代碼中出現的每個變數、函數都能清晰、直觀的傳達我們的意圖和想法。

代碼應該是簡潔的,因為簡潔的代碼更容易寫(通常代碼量更少)、更容易讀、更好維護,簡潔的代碼就是更難出 bug、更容易調試的代碼。bug 修復通常會費時費力,而修復過程可能引發更多的 bug,修復 bug 也會影響正常的開發進度。

認為寫出熟悉的代碼才是可讀性更高的代碼的同學,實際上是大錯特錯,可讀性高的代碼必然是簡潔和簡單的,雖然 ES6 早在 2015 年已經成為新的標準,但到了 2017 年,還是有很多同學不會使用諸如箭頭函數、隱式 return、rest 和 spread 操作符之類的簡潔語法。對新語法的熟悉需要不斷的練習,投入時間去學習和熟悉新語法以及函數組合的思想和技術,熟悉之後,就會發現代碼原來還可以這樣寫。

最後需要注意的是,代碼應該簡潔,而不是過於簡化。

One More Thing

本文作者王仕軍,商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。如果你覺得本文對你有幫助,請點贊!如果對文中的內容有任何疑問,歡迎留言討論。想知道我接下來會寫些什麼?歡迎訂閱我的掘金專欄或知乎專欄:《前端周刊:讓你在前端領域跟上時代的腳步》。

推薦閱讀:

輸入URL以後和編碼
計算機最底層的機器語言是如何變成物理電平信號輸給CPU的呢?
Ingress 中的 passcode 有哪些解碼技巧?
如何解決python不支持中文路徑的問題?
GB2312及其擴展標準和Unicode之間有什麼區別和優劣勢?

TAG:前端工程师 | 编码 | 最佳实践 |