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

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

William Strunk 在 1920 年出版的《The Elements of Style》 一書中列出了寫出好文章的 7 條原則,過了近一個世紀,這些原則並無過期。對於工程師來講,代碼是寫一遍、修改不少遍、閱讀更多遍的重要產出,可讀性相當重要,咱們能夠用這些寫做原則指導平常的編碼,寫出高可讀的代碼。html

須要注意的是,這些原則並非法律,若是違背它們能讓代碼可讀性更高,天然是沒問題的,但咱們須要保持警戒和自省,由於這些久經時間考驗的原則一般是對的,咱們最好不要由於奇思異想或我的偏好而違背這些原則。前端

7 條寫做原則以下:java

  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.express

軟件開發的本質是組合,咱們經過組合模塊、函數、數據結構來構造軟件。理解如何編寫和組合函數是軟件工程師的基本技能。模塊一般是一個或多個函數和數據結構的集合,而數據結構是咱們表示程序狀態的方法,可是在咱們調用一個函數以前,一般什麼也不會發生。在 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

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

相關文章
相關標籤/搜索