JavaScript 編碼指南

Doraemonls發表於2017-05-23

出其不意

1920年,William Strunk Jr的《英文寫作指南》出版了,這本書給英語的風格定下了一個規範,而且已經沿用至今。程式碼其實也可以使用相似的方法加以改進。

本文接下來的部分是一些指導方針,不是一成不變的法律。如果能夠清晰解釋程式碼含義,當然有很多的理由不這樣做,但是,請保持警惕和自覺。他們能經過時間的檢驗也是有理由的:因為他們通常都是對的。偏離指南應該有好的理由,並不能簡單因為突發奇想或者個人偏好就那麼做。

基本上寫作的基本準則的每一部分都能應用在程式碼上:

  • 讓段落成為文章的基本結構:每一段對應一個主題。
  • 去掉無用的單詞。 .
  • 使用主動語態。
  • 避免一連串鬆散的句子。
  • 將相關的詞語放在一起。
  • 陳述句用主動語態。
  • 平行的概念用平行的結構。

這些都可以用在我們的程式碼風格上。

  1. 讓函式成為程式碼的基本單元。每個函式做一件事。
  2. 去掉無用的程式碼
  3. 使用主動語態
  4. 避免一連串鬆散結構的程式碼
  5. 把相關的程式碼放在一起。
  6. 表示式和陳述語句中使用主動語態。
  7. 用並行的程式碼表達並行的概念。

1、讓函式成為程式碼的基本單元。每個函式做一件事。

軟體開發的本質就是寫作。我們把模組、函式、資料結構組合在一起,就有了一個軟體程式。

理解如何編寫函式並如何構建它們,是軟體開發者的基本技能。

模組是一個或多個函式或資料結構的簡單集合,資料結構是我們如何表示程式的狀態,但在沒有應用函式,資料結構自身不會發生什麼有趣的事情。

JavaScript有三種型別的函式:

  • 交流型函式:執行I/O的函式
  • 功能型函式:一系列指令的合集
  • 對映型函式:給一些輸入,返回相應的輸出

所有有用的程式都需要I / O,並且許多程式遵循一些程式順序,但大多數函式應該像對映函式:給定一些輸入,該函式將返回一些相應的輸出。

一個函式做一件事:如果你的函式是I/O敏感,那麼就不要把I/O和對映(計算)混雜在一起。如果你的函式是為了對映,那麼就不要加入I/O。功能性的函式就違背了這條準則。功能性的函式還違背了另一條準則:避免把鬆散的句子寫在一起。

理想的函式應該是一個簡單的,確定的,純粹功能函式。

  • 給定相同的輸入,返回相同的輸出
  • 沒有副作用

參見《什麼是純粹的函式》

2. 去掉無用程式碼

剛健的文字是簡練的。一句話應該不包含無用的詞語,一段話沒有無用的句子,正如作畫不應該有多餘的線條,一個機器沒有多餘的零件。這就要求作者儘量用短句子,避免羅列所有細節,在大綱裡就列出主題,而不是什麼都說。William Strunk,Jr.,《英文寫作指南》

簡練的程式碼在軟體中也很重要,這是因為更多的程式碼讓bug有了藏匿的空間。更少的程式碼=更少的含有bug的空間=更少bug。

簡練的程式碼更清晰,是因為它有更高的訊雜比:讀者可以減少對的語法理解更多的瞭解它的意義。更少的程式碼=更少的語法噪音=更多資訊的傳遞。

借用《英文寫作指南》的一個詞:簡練的程式碼更有力

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

上面一段程式碼可以簡化為:

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

對於熟悉箭頭函式(ES 2015年加入的新特性)的人來說,這段程式碼可讀性增強了。它去掉了多餘的語法:括號,function關鍵詞,以及return返回值語句。

第一個版本包含了不必要的語法。對於熟悉箭頭語法的人來說,括號,function關鍵詞,和return語句都沒有任何意義。它們存在只是因為還有很多人對ES6的新特性不熟悉。

ES6從2015年就是語言標準了。你應該熟悉它了。

去掉無用的變數

有時候我們傾向給一些實際不需要命名的變數命名。原因是人腦在可用的容量內只能儲存有限的資源,並且每個變數都必須作為離散量子儲存,佔據了我們可用的不多的記憶空間。

因為這個原因,有經驗的開發者都傾向減少不需要的變數命名。

比如,在大多數情況下,你應該去掉變數,只給建立一個返回值的變數。函式名應該能夠提供足夠多的資訊以顯示它的返回值。看下面的例子:

const getFullName = ({firstName, lastName}) => {
  const fullName = firstName + ' ' + lastName;
  return fullName;
};

以及:

const getFullName = ({firstName, lastName}) => (
  firstName + ' ' + lastName
);

開發者常常用來減少變數的另一個做法是:利用函式組合以及Point-free 的風格。

Point-free 風格是指:定義函式時無需引用對其操作的引數。常用的point-free風格方式主要是curry和函式組合。

看一個使用curry的例子:

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關鍵詞,或者=>語法。沒有引數列表,因為這個函式內部並沒有使用引數列表。相反的,它返回的是如何處理引數的一個函式。

下面我們看一下使用函式組合的例子。函式組合是把一個函式結果應用到另一個函式的處理流程。你可能沒有意識到,你其實一直都在用函式組合。當你呼叫.map()或者promise.then()函式的時候,你就在使用它了。例如,它的大部分時候的基本形態,其實都像這樣:f(g(x)).在代數中,這樣的組合被寫成:f ∘ g, 被稱作“g後f”或者“f組合g”。

當你把兩個函式組合在一起時,你就去掉了需要儲存的中間返回值的變數。我們看一下下面這個可以更簡單的程式碼:

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

// With points:
const incThenDoublePoints = n => {
  const incremented = g(n);
  return f(incremented);
};

incThenDoublePoints(20); // 42

// compose2 - Take two functions and return their composition
const compose2 = (f, g) => x => f(g(x));

// Point-free:
const incThenDoublePointFree = compose2(f, g);

incThenDoublePointFree(20); // 42

使用仿函式也能實現類似的效果。使用仿函式也能實現類似的效果。下面這段程式碼就是使用仿函式的一個例子:

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()。當我在Lodash裡使用它們時,一般都這樣引入:

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.使用主動語態

主動語態比被動語態更加直接、有力。 — William Strunk,Jr. 《英文寫作指南》

命名越直接越好。

  • 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)
  • component.onDragStart(handleDragStart)優於component.startDrag(handleDragStart)。

這個例子裡兩種命名方法的第二種,看上去更像是我們嘗試觸發一件事,而不是對這個事件作出響應。

生命週期函式

假設有一個元件,有這樣一個生命週期函式,在它更新之前要呼叫一個事件處理的函式,有以下幾種命名方式:

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

第一種命名使用被動語態。這種方式有點繞口,不如其他方式直觀。

第二種方式稍好,但是給人的意思是這個生命週期方法要呼叫一個函式。componentWillUpdate(handler)讀起來就像是這個元件要更新一個事件處理程式,這就偏離了本意。我們的原意是:”在元件更新前,呼叫事件處理”beforeComponentUpdate()這樣命名更為恰當清晰。

還能更精簡。既然這些都是方法,那麼主語(也就是元件本身)其實已經確定了。呼叫這個方法時再帶有主語就重複了。想象一下看到這段程式碼時,你會看到component.componentWillUpdate()。這就像是在說“吉米,吉米中午要吃牛排”。你其實不需要聽到重複的名字。

  • component.beforeUpdate(doSomething)優於component.beforeComponentUpdate(doSomething)

Functional mixins 是把屬性和方法新增到Object物件上的一種方法。函式一個接一個的組合新增在一起,就像是管道流一樣,或者像組裝線一樣。每個functional mixin的函式都有一個instance作為輸入,把一些額外的東西附加上去,然後再傳遞給下一個函式,就像組裝流水線一樣。

我傾向用形容詞命名mixin 函式。你也可以使用“ing”或者“able”之類的字尾來表示形容詞的含義。例如:

  • const duck = composeMixins(flying,quacking);
  • const box = composeMixins(iterable,mappable);

4、避免一連串鬆散的語句

一連串的句子很快就會無聊冗長了。William Strunk,Jr.,《英文寫作指南》

開發者其實常常講一連串的事件連線成一整個處理過程:一系列鬆散的語句本來就為了一個接一個而設計存在的。但過度使用這樣的流程會導致程式碼像義大利麵一樣錯綜複雜。

這種序列常常被重複,儘管會有些許的不同,有時還會出乎意料的偏離正規。例如,一個使用者介面可能會和另外的使用者介面共享了同樣的元件程式碼。這樣的代價就是程式碼可能被分到不同的生命週期裡並且一個元件可能由多個不同的程式碼塊進行管理。

參考下面這個例子:

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

這段程式碼做了三件事:載入資料,計算相關狀態,然後渲染內容。

在現代的前端應用框架中,這三件事是相互分離的。通過分離,每件事都可以得到比較好的組合或者擴充套件。

例如,我們可以完全替換渲染器,而不用影響其他部分;例如,React有豐富的自定義渲染器:適用於原生iOS和Android應用程式的ReactNative,WebVR的AFrame,用於伺服器端渲染的ReactDOM / Server 等等。

另一個問題是你無法簡單的計算要顯示的資料並且如果沒有第一次載入資料就無法生成顯示頁面。假如你已經載入了資料呢?那麼你的計算邏輯就在接下來的呼叫中變的多餘了。

分離也使得各個部件獨立可測。我喜歡給自己的應用加很多單元測試,並且把測試結果顯示出來,這樣我有任何改動的時候都能看到。但是,如果我要嘗試測試載入資料並渲染的功能,那我就不能只用一些假資料測試渲染部分。正在儲存……

我無法通過單元測試立刻獲得結果。函式分離卻可以讓我們能夠進行獨立的測試。

這個例子就已經說明,分離函式可以讓我們能夠參與到應用的不同生命週期中去。可以在應用載入元件後,觸發資料的載入功能。計算和渲染可以在檢視發生變化的時候進行。

這樣的結果就是更清楚地描述了軟體的責任:可以重用元件相同的結構以及生命週期的回撥函式,效能也更好;在後面工作流程中,我們也節省了不必要的勞動。

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

很多框架或者樣板程式都預設了一種程式的組織方法,那就是按照檔案型別劃分。如果你做一個小的計算器或者To Do的應用,這樣做沒問題;但是如果是大型專案,更好的辦法是按功能對檔案進行分組。

下面以一個To Do 應用為例,有兩種檔案組織結構。

按照檔案型別分類

.
├── components
│   ├── todos
│   └── user
├── reducers
│   ├── todos
│   └── user
└── tests
    ├── todos
    └── user

按照檔案功能分類

.
├── todos
│   ├── component
│   ├── reducer
│   └── test
└── user
    ├── component
    ├── reducer
    └── test

按照功能組織檔案,可以有效避免在資料夾檢視中不斷的上下滾動,直接去到功能資料夾就可以找到要編輯的檔案了。

把檔案按照功能進行組織。

6.陳述句和表示式使用主動語態。

“做出明確的斷言。避免無聊、不出彩、猶豫、不可置否的語氣。使用“not”時應該表達否定或者對立面的意思,而不要用來作為逃避的手段。”William Strunk,Jr., 《英文寫作指南》。

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

比下面的形式更好:

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

儘量選擇語氣強烈的否定句

有時候我們只關係一個變數是否缺失,因此使用主動語法會讓我們被迫加上一個!。在這些情況下,不如使用語氣強烈的否定句式。“not”這個詞和!的語氣相對較弱。

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

函式呼叫時避免使用null和undefined引數型別

不要使用undefined或者null的引數作為函式的可選引數。儘量使用可選的Object做引數。儘量使用可選的Object做引數。

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

// later...

const birthdayParty = createEvent({
  title = 'Birthday Party',
  timeStamp = birthDay
});

優於

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

// later...

const birthdayParty = createEvent(
  'Birthday Party',
  undefined, // This was avoidable
  birthDay
);

6、使用平行結構

平行結構需要儘可能相似的結構表達語義。格式上的形似使得讀者能夠理解不同語句的意義也是相似的。- William Strunk,Jr., 《英文寫作指南》

實際應用中,還有一些額外的問題沒有解決。我們可能會重複的做同一件事情。這樣的情況出現時,就有了抽象的空間。把相同的部分找出來,並抽象成可以在不同地方同時使用的公共部分。這其實就是很多框架或者功能庫做的事情。

以UI控制元件為例來說。十幾年以前,使用jQuery寫出把元件、邏輯應用、網路I/O混雜在一起的程式碼還還很常見。然後人們開始意識到,我們可以在web應用裡也使用MVC框架,於是人們逐漸開始把模型從UI更新的邏輯中分離出來。

最終的結構是:web應用使用了元件化模型的方法,這讓我們可以用JSX或者HTML模板來構建我們的UI元件。

這就讓我們能夠通過相同的方式去控制不同元件的更新,而無需對每一個元件的更新寫重複的程式碼。

熟悉元件化的人可以輕易的看到每個元件的工作原理:有一些程式碼是表示UI元素的宣告性標記,也有一些用於事件處理程式和用在生命週期上的回撥函式,這些回撥函式在需要的時候會被執行。

當我們為相似的問題找到一種模式後,任何熟悉這個模式的人都能很快的理解這樣的程式碼。

結論:程式碼要簡潔,但不是簡單化。

剛健的文字是簡練的。一句話應該不包含無用的詞語,一段話沒有無用的句子,正如作畫不應該有多餘的線條,一個機器沒有多餘的零件。這就要求作者儘量用短句子,避免羅列所有細節,在大綱裡就列出主題,而不是什麼都說。-William Strunk,Jr.,《英文寫作指南》

ES6在2015年是標準化的,但在2017年,許多開發人員避免了簡潔的箭頭功能,隱式回報,休息和傳播操作等的功能。人們以編寫更容易閱讀的程式碼為藉口,但只是因為人們更熟悉舊的模式而已。這是個巨大的錯誤。熟悉來自於實踐,熟悉ES6中的簡潔功能明顯優於ES5的原因顯而易見:相比厚重的語法功能的程式碼,這樣的程式碼更簡潔。

程式碼應該簡潔,而不是簡單化。

簡潔的程式碼就是:

  • 更少的bug
  • 更加便於除錯

bug通常是這樣的:

  • 修理起來耗時耗力
  • 可能引入更多的bug
  • 打亂正常的工作流程

所以簡潔的程式碼應該要:

  • 易寫
  • 易讀
  • 易維護

讓開發者學會並使用新技術比如curry其實是值得的。這樣做也是在讓讀者們熟悉新知識。如果我們還是依然用原來的做法,這也是對閱讀程式碼人的不尊重,就好像在用成年人在和嬰兒講話時使用孩子的口吻一樣。

我們可以假設讀者不理解這段程式碼的實現,但請不要假設閱讀程式碼的人都很笨,或者假設他們連這門語言都不懂。

程式碼應該簡潔,而但不要掉價。掉價才是一種浪費和侮辱。要在實踐中練習,投入精力去熟悉、學習一種新的程式設計語法、一種更有活力的風格。

程式碼應該簡潔,而非簡單化。

相關文章