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

王仕軍發表於2017-05-15

共 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) {
    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-style 的常見方法包括函式組合(function composotion)函式科裡化(function currying)

先看函式科裡化的例子:

const add = a => b => a + b;

// Now we can define a point-free inc()
// that adds 1 to any number.
const inc = add(1);

inc(3); // 4複製程式碼

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

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

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

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

const g = n => n + 1;
const f = n => n * 2;複製程式碼

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

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

incThenDoublePoints(20); // 42複製程式碼

使用函式組合的寫法是:

// 接受兩個函式作為引數,直接返回組合
const compose = (f, g) => x => f(g(x));
const incThenDoublePointFree = compose(f, g);
incThenDoublePointFree(20); // 42複製程式碼

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

const compose = (f, g) => x => [x].map(g).map(f).pop();
const incThenDoublePointFree = compose(f, g);
incThenDoublePointFree(20); // 42複製程式碼

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

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

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

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

import pipe from 'lodash/fp/flow';
pipe(g, f)(20); // 42複製程式碼

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

const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
pipe(g, f)(20); // 42複製程式碼

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

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 更新之前,呼叫 doSomethingbeforeComponentUpdate 能更清晰的表達我們的意圖。

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

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

const duck = composeMixins(flying, quacking);   // 會像鴨子叫
const box = composeMixins(iterable, mappable);  // 可遍歷的複製程式碼

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

“…a series soon becomes monotonous and tedious.”

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

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

const drawUserProfile = ({ userId }) => {
  const userData = loadUserData(userId);
  const dataToDisplay = calculateDisplayData(userData);
  renderProfileData(dataToDisplay);
};複製程式碼

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

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

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

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

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

5. 把相關程式碼放在一起

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

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

按程式碼類別組織

├── components
│   ├── todos
│   └── user
├── reducers
│   ├── todos
│   └── user
└── tests
    ├── todos
    └── user複製程式碼

按業務功能組織

├── todos
│   ├── component
│   ├── reducer
│   └── test
└── user
    ├── component
    ├── reducer
    └── test複製程式碼

當按業務功能組織程式碼的時候,我們修改某個功能的時候不用在整個檔案樹上跳來跳去的找程式碼了。關於程式碼組織,《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);
// do something...複製程式碼

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

if (!err) {
  // ... do something
} else {
  return reject(err);
}複製程式碼

三元表示式

把肯定的放在前面:

{
  [Symbol.iterator]: iterator ? iterator : defaultIterator
}複製程式碼

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

{
  [Symbol.iterator]: (!iterator) ? defaultIterator : iterator
}複製程式碼

恰當的使用否定

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

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

善用命名引數物件

不要期望函式呼叫者傳入 undefined、null 來填補可選引數,要學會使用命名的引數物件,比如:

const createEvent = ({
  title = 'Untitled',
  timeStamp = Date.now(),
  description = ''
}) => ({ title, description, timeStamp });

// later...
const birthdayParty = createEvent({
  title: 'Birthday Party',
  description: 'Best party ever!'
});複製程式碼

就比下面這種形式好:

const createEvent = (
  title = 'Untitled',
  timeStamp = Date.now(),
  description = ''
) => ({ title, description, timeStamp });

// later...
const birthdayParty = createEvent(
  'Birthday Party',
  undefined, // 要儘可能避免這種情況
  'Best party ever!'
);複製程式碼

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

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

相關文章