編碼如作文:寫出高可讀 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) {
    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

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內容