JavaScript 程式碼整潔之道

智雲程式設計發表於2019-03-29

目錄

  1. 概述

  2. 變數

  3. 函式

  4. 物件和資料結構

  5. 測試

  6. 併發

  7. 錯誤處理

  8. 格式

  9. 註釋

概述

一張幽默的圖片:軟體質量通過你在閱讀程式碼的時候有多少報怨來進行評估

Robert C. Martin 在 《程式碼整潔之道》 中提到的軟體工程原則,同樣適用於 JavaScript。這不是一個風格參考。它指導如何用 JavaScript 編寫可讀、可複用、可重構的軟體。

並不是每一個原則都必須嚴格遵循,甚至很少得到大家的認同。它們僅用於參考,不過要知道這些原則都是《程式碼整潔之道》的作者們累積多年的集體經驗。

我們在軟體工程方面的技術發展剛剛超過 50 年,我們仍然在學習很多東西。當軟體架構和架構本身一樣古老的時候,我們應該遵循更為嚴格規則。現在,對於你和你的團隊編寫的 JavaScript 程式碼,不妨依據這些準則來進行質量評估。

還有一件事:知道這些不會馬上讓你成為更好的軟體開發者,在工作中常年使用這些準則不能讓你避免錯誤。每一段程式碼都從最初的草圖開始到最終成型,就像為溼粘土塑形一樣。最後,當我們與同行一起審查的時候,再把不完美的地方消除掉。不要因為初稿需要改善而否定自己,需要要否定的只是那些程式碼!

變數

使用有準確意義的變更名

不好:

var yyyymmdstr = moment().format('YYYY/MM/DD');

:

var yearMonthDay = moment().format('YYYY/MM/DD');

在變數的值不會改變時使用 ES6 的常量

在不好的示例中,變數可以被改變。如果你申明一個常量,它會在整個程式中始終保持不變。

不好:

var FIRST_US_PRESIDENT = "George Washington";

:

const FIRST_US_PRESIDENT = "George Washington";

對同一型別的變數使用相同的詞彙

不好:

getUserInfo();
getClientData();
getCustomerRecord();

:

getUser();

使用可檢索的名稱

我們閱讀的程式碼永遠比寫的折。寫可讀性強、易於檢索的的程式碼非常重要。在程式中使用明確意義的變數名會難以理解,對讀者造成傷害。所以,把名稱定義成可檢索的。

不好:

// 見鬼,525600 是個啥?
for (var i = 0; i < 525600; i++) {
  runCronJob();
}

:

// 用 `var` 申明為大寫的全域性變數
var MINUTES_IN_A_YEAR = 525600;
for (var i = 0; i < MINUTES_IN_A_YEAR; i++) {
  runCronJob();
}

使用解釋性的變數

不好:

const cityStateRegex = /^(.+)[,\\s]+(.+?)\s*(\d{5})?$/;
saveCityState(cityStateRegex.match(cityStateRegex)[1], cityStateRegex.match(cityStateRegex)[2]);

:

const cityStateRegex = /^(.+)[,\\s]+(.+?)\s*(\d{5})?$/;
const match = cityStateRegex.match(cityStateRegex)
const city = match[1];
const state = match[2];
saveCityState(city, state);

避免暗示

顯式優於隱式。

不好:

var locations = ['Austin', 'New York', 'San Francisco'];
locations.forEach((l) => {
  doStuff();
  doSomeOtherStuff();
  ...
  ...
  ...
  // 等等,`l` 又是什麼?
  dispatch(l);
});

:

var locations = ['Austin', 'New York', 'San Francisco'];
locations.forEach((location) => {
  doStuff();
  doSomeOtherStuff();
  ...
  ...
  ...
  dispatch(location);
});

不要新增沒必要的上下文

如果你的類名稱/物件名稱已經說明了它們是什麼,不要在(屬性)變數名裡重複。

不好:

var Car = {
  carMake: 'Honda',
  carModel: 'Accord',
  carColor: 'Blue'
};

function paintCar(car) {
  car.carColor = 'Red';
}

:

var Car = {
  make: 'Honda',
  model: 'Accord',
  color: 'Blue'
};

function paintCar(car) {
  car.color = 'Red';
}

短路語法比條件語句更清晰

不好:

function createMicrobrewery(name) {
  var breweryName;
  if (name) {
    breweryName = name;
  } else {
    breweryName = 'Hipster Brew Co.';
  }
}

:

function createMicrobrewery(name) {
  var breweryName = name || 'Hipster Brew Co.'
}

函式

函式引數 (理論上少於等於2個)

限制函式引數的數量極為重要,它會讓你更容易測試函式。超過3個引數會導致組合膨脹,以致於你必須根據不同的引數對大量不同的情況進行測試。

理想情況下是沒有引數。有一個或者兩個引數也還好,三個就應該避免了。多於那個數量就應該考慮合併。通常情況下,如果你有多於2個引數,你的函式會嘗試做太多事情。如果不是這樣,大多數時候可以使用一個高階物件作為引數使用。

既然 JavaScript 允許我們在執行時隨意建立物件,而不需要預先定義樣板,那麼你在需要很多引數的時候就可以使用一個物件來處理。

不好:

function createMenu(title, body, buttonText, cancellable) {
  ...
}

:

var menuConfig = {
  title: 'Foo',
  body: 'Bar',
  buttonText: 'Baz',
  cancellable: true
}

function createMenu(menuConfig) {
  ...
}

一個函式只做一件事

目前這是軟體工程中最重要的原則。如果函式做了較多的事情,它就難以組合、測試和推測。當你讓函式只做一件事情的時候,它們就很容易重構,而且程式碼讀起來也會清晰得多。你只需要遵循本指南的這一條,就能領先於其他很多開發者。

不好:

function emailClients(clients) {
  clients.forEach(client => {
    let clientRecord = database.lookup(client);
    if (clientRecord.isActive()) {
      email(client);
    }
  });
}

:

function emailClients(clients) {
  clients.forEach(client => {
    emailClientIfNeeded(client);
  });
}

function emailClientIfNeeded(client) {
  if (isClientActive(client)) {
    email(client);
  }
}

function isClientActive(client) {
  let clientRecord = database.lookup(client);
  return clientRecord.isActive();
}

函式名稱要說明它做的事

不好:

function dateAdd(date, month) {
  // ...
}

let date = new Date();

// 很難從函式名瞭解到加了什麼
dateAdd(date, 1);

:

function dateAddMonth(date, month) {
  // ...
}

let date = new Date();
dateAddMonth(date, 1);

函式應該只抽象一個層次

如果你有多個層次的抽象,那麼你的函式通常做了太多事情,此時應該拆分函式使其易於複用和易於測試。

不好:

function parseBetterJSAlternative(code) {
  let REGEXES = [
    // ...
  ];

  let statements = code.split(' ');
  let tokens;
  REGEXES.forEach((REGEX) => {
    statements.forEach((statement) => {
      // ...
    })
  });

  let ast;
  tokens.forEach((token) => {
    // lex...
  });

  ast.forEach((node) => {
    // parse...
  })
}

:

function tokenize(code) {
  let REGEXES = [
    // ...
  ];

  let statements = code.split(' ');
  let tokens;
  REGEXES.forEach((REGEX) => {
    statements.forEach((statement) => {
      // ...
    })
  });

  return tokens;
}

function lexer(tokens) {
  let ast;
  tokens.forEach((token) => {
    // lex...
  });

  return ast;
}

function parseBetterJSAlternative(code) {
  let tokens = tokenize(code);
  let ast = lexer(tokens);
  ast.forEach((node) => {
    // parse...
  })
}

刪除重複程式碼

任何情況下,都不要有重複的程式碼。沒有任何原因,它很可能是阻礙你成為專業開發者的最糟糕的一件事。重複程式碼意味著你要修改某些邏輯的時候要修改不止一個地方的程式碼。JavaScript 是弱型別語句,所以它很容易寫通用性強的函式。記得利用這一點!

不好:

function showDeveloperList(developers) {
  developers.forEach(developers => {
    var expectedSalary = developer.calculateExpectedSalary();
    var experience = developer.getExperience();
    var githubLink = developer.getGithubLink();
    var data = {
      expectedSalary: expectedSalary,
      experience: experience,
      githubLink: githubLink
    };

    render(data);
  });
}

function showManagerList(managers) {
  managers.forEach(manager => {
    var expectedSalary = manager.calculateExpectedSalary();
    var experience = manager.getExperience();
    var portfolio = manager.getMBAProjects();
    var data = {
      expectedSalary: expectedSalary,
      experience: experience,
      portfolio: portfolio
    };

    render(data);
  });
}

:

function showList(employees) {
  employees.forEach(employee => {
    var expectedSalary = employee.calculateExpectedSalary();
    var experience = employee.getExperience();
    var portfolio;

    if (employee.type === 'manager') {
      portfolio = employee.getMBAProjects();
    } else {
      portfolio = employee.getGithubLink();
    }

    var data = {
      expectedSalary: expectedSalary,
      experience: experience,
      portfolio: portfolio
    };

    render(data);
  });
}

使用預設引數代替短路表示式

不好:

function writeForumComment(subject, body) {
  subject = subject || 'No Subject';
  body = body || 'No text';
}

:

function writeForumComment(subject = 'No subject', body = 'No text') {
  ...
}

用 Object.assign 設定預設物件

不好:

var menuConfig = {
  title: null,
  body: 'Bar',
  buttonText: null,
  cancellable: true
}

function createMenu(config) {
  config.title = config.title || 'Foo'
  config.body = config.body || 'Bar'
  config.buttonText = config.buttonText || 'Baz'
  config.cancellable = config.cancellable === undefined ? config.cancellable : true;

}

createMenu(menuConfig);

:

var menuConfig = {
  title: 'Order',
  // User did not include 'body' key
  buttonText: 'Send',
  cancellable: true
}

function createMenu(config) {
  config = Object.assign({
    title: 'Foo',
    body: 'Bar',
    buttonText: 'Baz',
    cancellable: true
  }, config);

  // 現在 config 等於: {title: "Foo", body: "Bar", buttonText: "Baz", cancellable: true}
  // ...
}

createMenu(menuConfig);

不要把標記用作函式引數

標記告訴你的使用者這個函式做的事情不止一件。但是函式應該只做一件事。如果你的函式中會根據某個布林引數產生不同的分支,那就拆分這個函式。

不好:

function createFile(name, temp) {
  if (temp) {
    fs.create('./temp/' + name);
  } else {
    fs.create(name);
  }
}

:

function createTempFile(name) {
  fs.create('./temp/' + name);
}

function createFile(name) {
  fs.create(name);
}

避免副作用

如果一個函式不是獲取一個輸入的值並返回其它值,它就有可能產生副作用。這些副作用可能是寫入檔案、修改一些全域性變數,或者意外地把你所有錢轉給一個陌生人。

現在你確實需要在程式中有副作用。像前面提到的那樣,你可能需要寫入檔案。現在你需要做的事情是搞清楚在哪裡集中完成這件事情。不要使用幾個函式或類來完成寫入某個特定檔案的工作。採用一個,就一個服務來完成。

關鍵點是避免覺的陷阱,比如在沒有結構的物件間共享狀態,使用可以被任意修改的易變的資料型別,沒有集中處理髮生的副作用等。如果你能做到,你就能比其他大多數程式設計師更愉快。

不好:

// 下面的函式使用了全域性變數。
// 如果有另一個函式在使用 name,現在可能會因為 name 變成了陣列而不能正常執行。
var name = 'Ryan McDermott';

function splitIntoFirstAndLastName() {
  name = name.split(' ');
}

splitIntoFirstAndLastName();

console.log(name); // ['Ryan', 'McDermott'];

:

function splitIntoFirstAndLastName(name) {
  return name.split(' ');
}

var name = 'Ryan McDermott'
var newName = splitIntoFirstAndLastName(name);

console.log(name); // 'Ryan McDermott';
console.log(newName); // ['Ryan', 'McDermott'];

不要寫入全域性函式

JavaScript 中全域性汙染是一件糟糕的事情,因為它可能和另外庫發生衝突,然而使用你 API 的使用者卻不會知道——直到他們在生產中遇到一個異常。來思考一個例子:你想擴充套件 JavaScript 的原生 Array,使之擁有一個 diff 方法,用來展示兩資料之前的區別,這時你會怎麼做?你可以給 Array.prototype 新增一個新的函式,但它可能會與其它想做同樣事情的庫發生衝突。如果那個庫實現的 diff 只是比如陣列中第一個元素和最後一個元素的異同會發生什麼事情呢?這就是為什麼最好是使用 ES6 的類語法從全域性的 Array 派生一個類來做這件事。

不好:

Array.prototype.diff = function(comparisonArray) {
  var values = [];
  var hash = {};

  for (var i of comparisonArray) {
    hash[i] = true;
  }

  for (var i of this) {
    if (!hash[i]) {
      values.push(i);
    }
  }

  return values;
}

好:

class SuperArray extends Array {
  constructor(...args) {
    super(...args);
  }

  diff(comparisonArray) {
    var values = [];
    var hash = {};

    for (var i of comparisonArray) {
      hash[i] = true;
    }

    for (var i of this) {
      if (!hash[i]) {
        values.push(i);
      }
    }

    return values;
  }
}

喜歡上指令式程式設計之上的函數語言程式設計

如果 Haskell 是 IPA 那麼 JavaScript 就是 O'Douls。就是說,與 Haskell 不同,JavaScript 不是函數語言程式設計語言,不過它仍然有一點函式式的意味。函式式語言更整潔也更容易測試,所以你最好能喜歡上這種程式設計風格。

不好:

const programmerOutput = [
  {
    name: 'Uncle Bobby',
    linesOfCode: 500
  }, {
    name: 'Suzie Q',
    linesOfCode: 1500
  }, {
    name: 'Jimmy Gosling',
    linesOfCode: 150
  }, {
    name: 'Gracie Hopper',
    linesOfCode: 1000
  }
];

var totalOutput = 0;

for (var i = 0; i < programmerOutput.length; i++) {
  totalOutput += programmerOutput[i].linesOfCode;
}

:

const programmerOutput = [
  {
    name: 'Uncle Bobby',
    linesOfCode: 500
  }, {
    name: 'Suzie Q',
    linesOfCode: 1500
  }, {
    name: 'Jimmy Gosling',
    linesOfCode: 150
  }, {
    name: 'Gracie Hopper',
    linesOfCode: 1000
  }
];

var totalOutput = programmerOutput
  .map((programmer) => programmer.linesOfCode)
  .reduce((acc, linesOfCode) => acc + linesOfCode, 0);

封裝條件

不好:

if (fsm.state === 'fetching' && isEmpty(listNode)) {
  /// ...
}

:

function shouldShowSpinner(fsm, listNode) {
  return fsm.state === 'fetching' && isEmpty(listNode);
}

if (shouldShowSpinner(fsmInstance, listNodeInstance)) {
  // ...
}

避免否定條件

不好:

function isDOMNodeNotPresent(node) {
  // ...
}

if (!isDOMNodeNotPresent(node)) {
  // ...
}

:

function isDOMNodePresent(node) {
  // ...
}

if (isDOMNodePresent(node)) {
  // ...
}

避免條件

這似乎是個不可能完成的任務。大多數人第一次聽到這個的時候會說,“沒有 if 語句我該怎麼辦?”回答是在多數情況下都可以使用多型來實現相同的任務。第二個問題通常是,“那太好了,不過我為什麼要這麼做呢?”答案在於我們之前瞭解過整潔的概念:一個函式應該只做一件事情。如果你的類和函式有 if 語句,就意味著你的函式做了更多的事。記住,只做一件事。

不好:

class Airplane {
  //...
  getCruisingAltitude() {
    switch (this.type) {
      case '777':
        return getMaxAltitude() - getPassengerCount();
      case 'Air Force One':
        return getMaxAltitude();
      case 'Cessna':
        return getMaxAltitude() - getFuelExpenditure();
    }
  }
}

:

class Airplane {
  //...
}

class Boeing777 extends Airplane {
  //...
  getCruisingAltitude() {
    return getMaxAltitude() - getPassengerCount();
  }
}

class AirForceOne extends Airplane {
  //...
  getCruisingAltitude() {
    return getMaxAltitude();
  }
}

class Cessna extends Airplane {
  //...
  getCruisingAltitude() {
    return getMaxAltitude() - getFuelExpenditure();
  }
}

避免型別檢查(第1部分)

JavaScript 是無型別的,也就是說函式可以獲取任意型別的引數。有時候你會覺得這種自由是種折磨,因而會不由自主地在函式中使用型別檢查。有很多種方法可以避免型別檢查。首先要考慮的就是 API 的一致性。

不好:

function travelToTexas(vehicle) {
  if (vehicle instanceof Bicycle) {
    vehicle.peddle(this.currentLocation, new Location('texas'));
  } else if (vehicle instanceof Car) {
    vehicle.drive(this.currentLocation, new Location('texas'));
  }
}

:

function travelToTexas(vehicle) {
  vehicle.move(this.currentLocation, new Location('texas'));
}

避免型別檢查(第2部分)

如果你在處理基本型別的資料,比如字串,整數和陣列,又不能使用多型,這時你會覺得需要使用型別檢查,那麼可以考慮 TypeScript。這是普通 JavaScript 的完美替代品,它在標準的 JavaScript 語法之上提供了靜態型別。普通 JavaScript 手工檢查型別的問題在於這樣會寫很多廢話,而人為的“型別安全”並不能彌補損失的可讀性。讓你的 JavaScript 保持整潔,寫很好的測試,並保持良好的程式碼審查。否則讓 TypeScript (我說過,這是很好的替代品)來做所有事情。

不好:

function combine(val1, val2) {
  if (typeof val1 == "number" && typeof val2 == "number" ||
      typeof val1 == "string" && typeof val2 == "string") {
    return val1 + val2;
  } else {
    throw new Error('Must be of type String or Number');
  }
}

:

function combine(val1, val2) {
  return val1 + val2;
}

不要過度優化

現在瀏覽器在執行時悄悄地做了很多優化工作。很多時候你的優化都是在浪費時間。這裡有很好的資源 可以看看哪些優化比較缺乏。把它們作為目標,直到他們能固定下來的時候。

不好:

// 在舊瀏覽器中,每次迴圈的成本都比較高,因為每次都會重算 `len`。
// 現在瀏覽器中,這已經被優化了。
for (var i = 0, len = list.length; i < len; i++) {
  // ...
}

:

for (var i = 0; i < list.length; i++) {
  // ...
}

刪除不用的程式碼

不用的程式碼和重複的程式碼一樣糟糕。在程式碼庫中保留無用的程式碼是毫無道理的事情。如果某段程式碼用不到,那就刪掉它!如果你以後需要它,仍然可以從程式碼庫的歷史版本中找出來。

不好:

function oldRequestModule(url) {
  // ...
}

function newRequestModule(url) {
  // ...
}

var req = newRequestModule;
inventoryTracker('apples', req, 'www.inventory-awesome.io');

:

function newRequestModule(url) {
  // ...
}

var req = newRequestModule;
inventoryTracker('apples', req, 'www.inventory-awesome.io');

物件和資料結構

使用 getter 和 setter

JavaScript 沒有介面或者型別,也沒有像 publicprivate 這樣的關鍵字,所以很難應用設計模式。實事上,在物件上使用 getter 和 setter 訪問資料遠好於直接查詢物件屬性。“為什麼?”你可能會這樣問。那好,下面列出了原因:

  1. 你想在獲取物件屬性的時候做更多的事,不必在程式碼中尋找所有訪問的程式碼來逐個修改。

  2. 在進行 set 的時候可以進行額外的資料檢驗。

  3. 封裝內部表現。

  4. 在獲取或設定的時候易於新增日誌和錯誤處理。

  5. 繼承當前類,可以重寫預設功能。

  6. 可以對物件屬性進行懶載入,比如說從伺服器獲取屬性的資料。

不好:

class BankAccount {
  constructor() {
       this.balance = 1000;
  }
}

let bankAccount = new BankAccount();

// 買鞋...
bankAccount.balance = bankAccount.balance - 100;

:

class BankAccount {
  constructor() {
       this.balance = 1000;
  }

  // It doesn't have to be prefixed with `get` or `set` to be a getter/setter
  withdraw(amount) {
    if (verifyAmountCanBeDeducted(amount)) {
      this.balance -= amount;
    }
  }
}

let bankAccount = new BankAccount();

// 買鞋...
bankAccount.withdraw(100);

讓物件擁有私有成員

這可以通過閉包實現(ES5以之前的版本)。

不好:

var Employee = function(name) {
  this.name = name;
}

Employee.prototype.getName = function() {
  return this.name;
}

var employee = new Employee('John Doe');
console.log('Employee name: ' + employee.getName()); // Employee name: John Doe
delete employee.name;
console.log('Employee name: ' + employee.getName()); // Employee name: undefined

:

var Employee = (function() {
  function Employee(name) {
    this.getName = function() {
      return name;
    };
  }

  return Employee;
}());

var employee = new Employee('John Doe');
console.log('Employee name: ' + employee.getName()); // Employee name: John Doe
delete employee.name;
console.log('Employee name: ' + employee.getName()); // Employee name: John Doe

單一職責原則 (SRP)

正如《程式碼整潔之道》所說,“不應該有超過一個原因來改變類”。往一個類裡塞進許多功能是件誘人的事情,就像在坐飛機的時候只帶一個手提箱一樣。這帶來的問題是,你的類不會在概念上有凝聚力,會有很多因素造成對它的改變。讓你的類需要改變的次數最少是件非常重要的事情。這是因為如果一個類裡塞入了太多功能,你只修改它的一部分,可能會讓人難以理解它為何會影響程式碼庫中其它相關模組。

不好:

class UserSettings {
  constructor(user) {
    this.user = user;
  }

  changeSettings(settings) {
    if (this.verifyCredentials(user)) {
      // ...
    }
  }

  verifyCredentials(user) {
    // ...
  }
}

:

class UserAuth {
  constructor(user) {
    this.user = user;
  }

  verifyCredentials() {
    // ...
  }
}

class UserSettings {
  constructor(user) {
    this.user = user;
    this.auth = new UserAuth(user)
  }

  changeSettings(settings) {
    if (this.auth.verifyCredentials()) {
      // ...
    }
  }
}

開放封裝原則(OCP)

正如 Bertrand Meyer 所說,“軟體實體(類、模組、函式等)應該對擴充套件開放,對修改封閉。”這是什麼意思呢?這個原則基本上規定了你應該允許使用者擴充套件你的模組,但不需要開啟 .js 原始碼檔案來進行編輯。

不好:

class AjaxRequester {
  constructor() {
    // 如果我們需要另一個 HTTP 方法,比如 DELETE,該怎麼辦?
    // 我們必須開啟這個檔案然後手工把它加進去
    this.HTTP_METHODS = ['POST', 'PUT', 'GET'];
  }

  get(url) {
    // ...
  }

}

:

class AjaxRequester {
  constructor() {
    this.HTTP_METHODS = ['POST', 'PUT', 'GET'];
  }

  get(url) {
    // ...
  }

  addHTTPMethod(method) {
    this.HTTP_METHODS.push(method);
  }
}

里氏替換原則(LSP)

這是一個嚇人的術語,但描述的卻是個簡單的概念。它的正式定義為“如果 S 是 T 的子類,那所有 T 型別的物件都可以替換為 S 型別的物件(即 S 型別的物件可以替代 T 型別的物件),這個替換不會改變程式的任何性質(正確性、任務執行等)。”這確實是個嚇人的定義。

對此最好的解釋是,如果你有父類和子類,那麼父類和子類可以交替使用而不會造成不正確的結果。這可能仍然讓人感到疑惑,那麼讓我們看看經典的正方形和矩形的例子。在數學上,正方形也是矩形,但是如果你在模型中通過繼承使用 “is-a” 關係,你很快就會陷入困境。

不好:

class Rectangle {
  constructor() {
    this.width = 0;
    this.height = 0;
  }

  setColor(color) {
    // ...
  }

  render(area) {
    // ...
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor() {
    super();
  }

  setWidth(width) {
    this.width = width;
    this.height = width;
  }

  setHeight(height) {
    this.width = height;
    this.height = height;
  }
}

function renderLargeRectangles(rectangles) {
  rectangles.forEach((rectangle) => {
    rectangle.setWidth(4);
    rectangle.setHeight(5);
    let area = rectangle.getArea(); // 不好:這裡對正方形會返回 25,但應該是 20.
    rectangle.render(area);
  })
}

let rectangles = [new Rectangle(), new Rectangle(), new Square()];
renderLargeRectangles(rectangles);

:

class Shape {
  constructor() {}

  setColor(color) {
    // ...
  }

  render(area) {
    // ...
  }
}

class Rectangle extends Shape {
  constructor() {
    super();
    this.width = 0;
    this.height = 0;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor() {
    super();
    this.length = 0;
  }

  setLength(length) {
    this.length = length;
  }

  getArea() {
    return this.length * this.length;
  }
}

function renderLargeShapes(shapes) {
  shapes.forEach((shape) => {
    switch (shape.constructor.name) {
      case 'Square':
        shape.setLength(5);
      case 'Rectangle':
        shape.setWidth(4);
        shape.setHeight(5);
    }

    let area = shape.getArea();
    shape.render(area);
  })
}

let shapes = [new Rectangle(), new Rectangle(), new Square()];
renderLargeShapes(shapes);

介面隔離原則(ISP)

JavaScript 中沒有介面,所以實行這個原則不能像其它語言那樣嚴格。然而即使對 JavaScript 的弱型別系統來說,它仍然是重要的相關。

ISP 指出,“客戶不應該依賴於那些他們不使用的介面。” 由於 Duck Typing 理論,介面在 JavaScript 中是個隱性契約。

在 JavaScript 中有一個很好的例子來演示這個原則,即一個擁有巨大設定物件的類。比較好的做法是不要求客戶設定大量的選項,因為多數時候他們不需要所有設定。讓這些選項成為可選的有助於防止“胖介面”。

不好:

class DOMTraverser {
  constructor(settings) {
    this.settings = settings;
    this.setup();
  }

  setup() {
    this.rootNode = this.settings.rootNode;
    this.animationModule.setup();
  }

  traverse() {
    // ...
  }
}

let $ = new DOMTraverser({
  rootNode: document.getElementsByTagName('body'),
  animationModule: function() {} // 多數時候我們不需要動畫
  // ...
});

:

class DOMTraverser {
  constructor(settings) {
    this.settings = settings;
    this.options = settings.options;
    this.setup();
  }

  setup() {
    this.rootNode = this.settings.rootNode;
    this.setupOptions();
  }

  setupOptions() {
    if (this.options.animationModule) {
      // ...
    }
  }

  traverse() {
    // ...
  }
}

let $ = new DOMTraverser({
  rootNode: document.getElementsByTagName('body'),
  options: {
    animationModule: function() {}
  }
});

依賴倒置原則(DIP)

這個原則說明了兩個基本問題:

1. 上層模組不應該依賴下層模組,兩者都應該依賴抽象。

2. 抽象不應該依賴於具體實現,具體實現應該依賴於抽象。

這一開始可能很難理解,但是如果你使用 Angular.js,你已經看到了對這個原則的一種實現形式:依賴注入(DI)。雖然它們不是完全相同的概念,DIP 阻止上層模組去了解下層模組的細節並設定它們。它可以通過 DI 來實現。這帶來的巨大好處降低了模組間的耦合。耦合是種非常不好的開發模式,因為它讓程式碼難以重構。

前提已經提到,JavaScript 沒有介面,因此抽象依賴於隱性契約。也就是說,一個物件/類會把方法和屬性暴露給另一個物件/類。在下面的例子中,隱性契約是任何用於 InventoryTracker 的 Request 模組都應該擁有 requestItems 方法。

不好:

class InventoryTracker {
  constructor(items) {
    this.items = items;

    // 不好:我們建立了一個依賴於特定請求的實現。
    // 我們應該只依賴請求方法:`request` 的 requestItems
    this.requester = new InventoryRequester();
  }

  requestItems() {
    this.items.forEach((item) => {
      this.requester.requestItem(item);
    });
  }
}

class InventoryRequester {
  constructor() {
    this.REQ_METHODS = ['HTTP'];
  }

  requestItem(item) {
    // ...
  }
}

let inventoryTracker = new InventoryTracker(['apples', 'bananas']);
inventoryTracker.requestItems();

:

class InventoryTracker {
  constructor(items, requester) {
    this.items = items;
    this.requester = requester;
  }

  requestItems() {
    this.items.forEach((item) => {
      this.requester.requestItem(item);
    });
  }
}

class InventoryRequesterV1 {
  constructor() {
    this.REQ_METHODS = ['HTTP'];
  }

  requestItem(item) {
    // ...
  }
}

class InventoryRequesterV2 {
  constructor() {
    this.REQ_METHODS = ['WS'];
  }

  requestItem(item) {
    // ...
  }
}

// 通過構建外部依賴並注入它們,我們很容易把請求模組替換成
// 一個使用 WebSocket 的新模組。
let inventoryTracker = new InventoryTracker(['apples', 'bananas'], new InventoryRequesterV2());
inventoryTracker.requestItems();

多用 ES6 類語法,少用 ES5 建構函式語法

在經典的 ES5 的類定義中,很難找到易讀的繼承、構造、方法定義等。如果你需要繼承(你會發現做不到),那就應該使用類語法。不過,應該儘可能使用小函式而不是類,直到你需要更大更復雜的物件。

不好:

var Animal = function(age) {
    if (!(this instanceof Animal)) {
        throw new Error("Instantiate Animal with `new`");
    }

    this.age = age;
};

Animal.prototype.move = function() {};

var Mammal = function(age, furColor) {
    if (!(this instanceof Mammal)) {
        throw new Error("Instantiate Mammal with `new`");
    }

    Animal.call(this, age);
    this.furColor = furColor;
};

Mammal.prototype = Object.create(Animal.prototype);
Mammal.prototype.constructor = Mammal;
Mammal.prototype.liveBirth = function() {};

var Human = function(age, furColor, languageSpoken) {
    if (!(this instanceof Human)) {
        throw new Error("Instantiate Human with `new`");
    }

    Mammal.call(this, age, furColor);
    this.languageSpoken = languageSpoken;
};

Human.prototype = Object.create(Mammal.prototype);
Human.prototype.constructor = Human;
Human.prototype.speak = function() {};

好:

class Animal {
    constructor(age) {
        this.age = age;
    }

    move() {}
}

class Mammal extends Animal {
    constructor(age, furColor) {
        super(age);
        this.furColor = furColor;
    }

    liveBirth() {}
}

class Human extends Mammal {
    constructor(age, furColor, languageSpoken) {
        super(age, furColor);
        this.languageSpoken = languageSpoken;
    }

    speak() {}
}

使用方法鏈

在這裡我的意見與《程式碼整潔之道》的觀點不同。有人認為方法鏈不整潔,而且違反了得墨忒耳定律。也許他們是對的,但這個模式在 JavaScript 中非常有用,你可以很多庫中看到,比如 jQuery 和 Lodash。它讓程式碼變得既簡潔又有表現力。在類中,只需要在每個函式結束前返回 this,就實現了鏈式呼叫的類方法。

不好:

class Car {
  constructor() {
    this.make = 'Honda';
    this.model = 'Accord';
    this.color = 'white';
  }

  setMake(make) {
    this.name = name;
  }

  setModel(model) {
    this.model = model;
  }

  setColor(color) {
    this.color = color;
  }

  save() {
    console.log(this.make, this.model, this.color);
  }
}

let car = new Car();
car.setColor('pink');
car.setMake('Ford');
car.setModel('F-150')
car.save();

:

class Car {
  constructor() {
    this.make = 'Honda';
    this.model = 'Accord';
    this.color = 'white';
  }

  setMake(make) {
    this.name = name;
    // NOTE: 返回 this 以實現鏈式呼叫
    return this;
  }

  setModel(model) {
    this.model = model;
    // NOTE: 返回 this 以實現鏈式呼叫
    return this;
  }

  setColor(color) {
    this.color = color;
    // NOTE: 返回 this 以實現鏈式呼叫
    return this;
  }

  save() {
    console.log(this.make, this.model, this.color);
  }
}

let car = new Car()
  .setColor('pink')
  .setMake('Ford')
  .setModel('F-150')
  .save();

多用組合,少用繼承

大家都知道 GoF 的設計模式,其中提到應該多用組合而不是繼承。對於繼承和組合,都有大量的理由在支撐,但這個準則的要點在於,你的想法本能地會想到繼承,但這時候不防多思考一下用組合是否能更好的處理問題——某些時候,的確能。

你可能會考慮:“我什麼時候該用繼承?”這取決於你遇到的問題。這裡有一個不錯的清單說明了什麼時候用繼承比用組合更合適:

  1. 你的繼承是一個“is-a”關係,而不是“has-a”關係(Animal->Human 對比 User->UserDetails)。

  2. 可以從基礎複用程式碼 (人可以像所有動物一樣移動)。

  3. 你想通過修改基礎來實現對所有子類的全域性性更改。(改變動物移動時的熱量消耗)。

不好:

class Employee {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  // ...
}

// 這樣不好,因為 Employees "擁有" 稅務資料。EmployeeTaxData 不是屬於 Employee 的一個型別
class EmployeeTaxData extends Employee {
  constructor(ssn, salary) {
    super();
    this.ssn = ssn;
    this.salary = salary;
  }

  // ...
}

:

class Employee {
  constructor(name, email) {
    this.name = name;
    this.email = email;

  }

  setTaxData(ssn, salary) {
    this.taxData = new EmployeeTaxData(ssn, salary);
  }
  // ...
}

class EmployeeTaxData {
  constructor(ssn, salary) {
    this.ssn = ssn;
    this.salary = salary;
  }

  // ...
}

測試

測試比生產更重要。如果你不進行測試,或者測試的量不夠,那你就不能肯定你寫的程式碼不會造成破壞。測試數量依靠你的開發團隊來決定,但 100% 覆蓋率(所有語句和分支)能讓你擁有巨大的信心,也能使程式設計師們安心。也就是說,你需要一個不錯的測試框架,還需要一個好的覆蓋檢查工具.

沒有什麼理由可以讓你不寫測試。這裡有 大量不錯的 JS 測試框架,可以去找個你們團隊喜歡的來用。如果你找一個適合在你的團隊中使用的工作,就把為每個新產生的特性/方法新增測試作為目標。如果你喜歡測試驅動開發(TDD)的方法,非常好,但要注意在讓你的測試覆蓋所有特性,或者重構過的程式碼。

每次測試一個概念

不好:

const assert = require('assert');

describe('MakeMomentJSGreatAgain', function() {
  it('handles date boundaries', function() {
    let date;

    date = new MakeMomentJSGreatAgain('1/1/2015');
    date.addDays(30);
    date.shouldEqual('1/31/2015');

    date = new MakeMomentJSGreatAgain('2/1/2016');
    date.addDays(28);
    assert.equal('02/29/2016', date);

    date = new MakeMomentJSGreatAgain('2/1/2015');
    date.addDays(28);
    assert.equal('03/01/2015', date);
  });
});

:

const assert = require('assert');

describe('MakeMomentJSGreatAgain', function() {
  it('handles 30-day months', function() {
    let date = new MakeMomentJSGreatAgain('1/1/2015');
    date.addDays(30);
    date.shouldEqual('1/31/2015');
  });

  it('handles leap year', function() {
    let date = new MakeMomentJSGreatAgain('2/1/2016');
    date.addDays(28);
    assert.equal('02/29/2016', date);
  });

  it('handles non-leap year', function() {
    let date = new MakeMomentJSGreatAgain('2/1/2015');
    date.addDays(28);
    assert.equal('03/01/2015', date);
  });
});

併發

使用 Promise 而不是回撥

回撥並不整潔,它會導致過多的巢狀。ES6 的 Promise 是個內建的全域性型別。使用它!

不好:

require('request').get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin', function(err, response) {
  if (err) {
    console.error(err);
  }
  else {
    require('fs').writeFile('article.html', response.body, function(err) {
      if (err) {
        console.error(err);
      } else {
        console.log('File written');
      }
    })
  }
})

:

require('request-promise').get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin')
  .then(function(response) {
    return require('fs-promise').writeFile('article.html', response);
  })
  .then(function() {
    console.log('File written');
  })
  .catch(function(err) {
    console.error(err);
  })

async/await 比 Promise 還整潔

與回撥相當,Promise 已經相當整潔了,但 ES7 帶來了更整潔的解決方案 —— async 和 await。你要做的事情就是在一個函式前加上 async 關鍵字,然後寫下命令形式的邏輯,而不再需要 then 鏈。現在可以使用這個 ES7 特性帶來的便利!

不好:

require('request-promise').get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin')
  .then(function(response) {
    return require('fs-promise').writeFile('article.html', response);
  })
  .then(function() {
    console.log('File written');
  })
  .catch(function(err) {
    console.error(err);
  })

:

async function getCleanCodeArticle() {
  try {
    var request = await require('request-promise')
    var response = await request.get('https://en.wikipedia.org/wiki/Robert_Cecil_Martin');
    var fileHandle = await require('fs-promise');

    await fileHandle.writeFile('article.html', response);
    console.log('File written');
  } catch(err) {
      console.log(err);
    }
  }

錯誤處理

丟擲錯誤是件好事!這表示執行時已經成功檢測到程式出錯了,它停止當前呼叫框上的函式執行,並中止程式(在 Node 中),最後在控制檯通知你,並輸出棧跟蹤資訊。

不要忽略捕捉到的錯誤

捕捉到錯誤卻什麼也不錯,你就失去了糾正錯誤的機會。多數情況下把錯誤記錄到控制檯(console.log)也不比忽略它好多少,因為在少量的控制檯資訊中很難發現這一條。如果嘗試在 try/catch 中封裝程式碼,就意味著你知道這裡可能發生錯,你應該在錯誤發生的時候有應對的計劃、或者處理辦法。

不好:

try {
  functionThatMightThrow();
} catch (error) {
  console.log(error);
}

好:

try {
  functionThatMightThrow();
} catch (error) {
  // 選擇之一(比 console.log 更鬧心):
  console.error(error);
  // 另一個選擇:
  notifyUserOfError(error);
  // 另一個選擇:
  reportErrorToService(error);
  // 或者所有上述三種選擇!
}

不要忽視被拒絕的Promise

這一條與不要忽略從 try/catch 捕捉到的錯誤有相同的原因。

不好:

getdata()
.then(data => {
  functionThatMightThrow(data);
})
.catch(error => {
  console.log(error);
});

好:

getdata()
.then(data => {
  functionThatMightThrow(data);
})
.catch(error => {
  // 選擇之一(比 console.log 更鬧心):
  console.error(error);
  // 另一個選擇:
  notifyUserOfError(error);
  // 另一個選擇:
  reportErrorToService(error);
  // 或者所有上述三種選擇!
});

格式

格式是個很主觀的東西,像這裡提到的許多規則一,你不必完全遵循。要點不在於爭論格式。大量工具 可以自動處理優化格式。用一個!讓工程師爭論格式問題簡直就是在浪費時間和金錢。

對於那些不能自動處理的格式(可以自動處理的包括縮排、Tab或空格、雙引號或單引用等),就看看這裡的指導。

使用一致的大小寫

JavaScript 是無型別的,所以大小寫可以幫助你瞭解變數、函式等。這些規則具有較強的主觀性,所以你的團隊應該選擇需要的。重點不在於你選擇了什麼,而在於要始終保持一致。

不好:

var DAYS_IN_WEEK = 7;
var daysInMonth = 30;

var songs = ['Back In Black', 'Stairway to Heaven', 'Hey Jude'];
var Artists = ['ACDC', 'Led Zeppelin', 'The Beatles'];

function eraseDatabase() {}
function restore_database() {}

class animal {}
class Alpaca {}

:

var DAYS_IN_WEEK = 7;
var DAYS_IN_MONTH = 30;

var songs = ['Back In Black', 'Stairway to Heaven', 'Hey Jude'];
var artists = ['ACDC', 'Led Zeppelin', 'The Beatles'];

function eraseDatabase() {}
function restoreDatabase() {}

class Animal {}
class Alpaca {}

函式呼叫者和被呼叫者應該儘可能放在一起

如果一個函式呼叫另一個函式,那應該讓他們在原始檔中的位置非常接近。理想情況下應該把呼叫者放在被呼叫者的正上方,這會讓你的程式碼更易讀,因為我們都習慣從上往下讀程式碼,就像讀報紙那樣。

不好:

class PerformanceReview {
  constructor(employee) {
    this.employee = employee;
  }

  lookupPeers() {
    return db.lookup(this.employee, 'peers');
  }

  lookupMananger() {
    return db.lookup(this.employee, 'manager');
  }

  getPeerReviews() {
    let peers = this.lookupPeers();
    // ...
  }

  perfReview() {
      getPeerReviews();
      getManagerReview();
      getSelfReview();
  }

  getManagerReview() {
    let manager = this.lookupManager();
  }

  getSelfReview() {
    // ...
  }
}

let review = new PerformanceReview(user);
review.perfReview();

:

class PerformanceReview {
  constructor(employee) {
    this.employee = employee;
  }

  perfReview() {
      getPeerReviews();
      getManagerReview();
      getSelfReview();
  }

  getPeerReviews() {
    let peers = this.lookupPeers();
    // ...
  }

  lookupPeers() {
    return db.lookup(this.employee, 'peers');
  }

  getManagerReview() {
    let manager = this.lookupManager();
  }

  lookupMananger() {
    return db.lookup(this.employee, 'manager');
  }

  getSelfReview() {
    // ...
  }
}

let review = new PerformanceReview(employee);
review.perfReview();

註釋

只註釋業務邏輯複雜的內容

註釋是用來解釋程式碼的,而不是必須的。好的程式碼應該 自注釋

不好:

function hashIt(data) {
  // Hash 碼
  var hash = 0;

  // 字串長度
  var length = data.length;

  // 遍歷資料中所有字元
  for (var i = 0; i < length; i++) {
    // 獲取字元編碼
    var char = data.charCodeAt(i);
    // 生成 Hash
    hash = ((hash << 5) - hash) + char;
    // 轉換為32位整數
    hash = hash & hash;
  }
}

:

function hashIt(data) {
  var hash = 0;
  var length = data.length;

  for (var i = 0; i < length; i++) {
    var char = data.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;

    // 轉換為32位整數
    hash = hash & hash;
  }
}

不要把註釋掉的程式碼留在程式碼庫中

版本控制存在的原因就是儲存你的歷史程式碼。

不好:

doStuff();
// doOtherStuff();
// doSomeMoreStuff();
// doSoMuchStuff();

:

doStuff();

不需要日誌式的註釋

記住,使用版本控制!沒用的程式碼、註釋掉的程式碼,尤其是日誌式的註釋。用 git log 來獲取歷史資訊!

不好:

/**
 * 2016-12-20: Removed monads, didn't understand them (RM)
 * 2016-10-01: Improved using special monads (JP)
 * 2016-02-03: Removed type-checking (LI)
 * 2015-03-14: Added combine with type-checking (JR)
 */
function combine(a, b) {
  return a + b;
}

:

function combine(a, b) {
  return a + b;
}

避免位置標記

位置標記通常只會新增垃圾資訊。通過對函式或變數名以及適當的縮排就能為程式碼帶來良好的視覺化結構。

不好:

////////////////////////////////////////////////////////////////////////////////
// Scope Model Instantiation
////////////////////////////////////////////////////////////////////////////////
let $scope.model = {
  menu: 'foo',
  nav: 'bar'
};

////////////////////////////////////////////////////////////////////////////////
// Action setup
////////////////////////////////////////////////////////////////////////////////
let actions = function() {
  // ...
}

:

let $scope.model = {
  menu: 'foo',
  nav: 'bar'
};

let actions = function() {
  // ...
}

避免在原始檔中新增版權註釋

這是程式碼檔案樹頂層的 LICENSE 檔案應該乾的事情。

不好:

/*
The MIT License (MIT)

Copyright (c) 2016 Ryan McDermott

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
*/

function calculateBill() {
  // ...
}

:

function calculateBill() {
  // ...
}

相關文章