javascript 代码整洁之道

181 阅读8分钟

介绍

作者根据 Robert C. Martin 《代码整洁之道》总结了适用于 JavaScript 的软件工程原则。 《Clean Code JavaScript》

不必严格遵守本文的所有原则,仅仅只是一份建议。

一、变量(Variables)

1. 使用语义化变量名 ✅

小驼峰式命名方法命名规范 : 类型+对象描述的方式

反例\color{red}{反例}

const yyyymmdstr = moment().format("YYYY/MM/DD");

正例\color{Green}{正例}

const currentDate = moment().format("YYYY/MM/DD");

2. const 定义常量 ✅

使用 const 声明一个常量,该常量在整个程序中都应该是不可变的。

反例\color{red}{反例}

var FIRST_US_PRESIDENT = "George Washington";

正例\color{Green}{正例}

const FIRST_US_PRESIDENT = "George Washington";

3. 使用易于检索名称 ✅

消除魔法字符串(与代码形成强耦合的某一个具体的字符串或数值),由含义清晰的变量代替。

反例\color{red}{反例}

// 525600 是什么?
for (var i = 0; i < 525600; i++) {
  runCronJob();
}

正例\color{Green}{正例}

// Declare them as capitalized `var` globals.
const MINUTES_IN_A_YEAR = 525600;
for (var i = 0; i < MINUTES_IN_A_YEAR; i++) {
  runCronJob();
}

4. 避免重复的描述 ✅

当类/对象名已经有意义时,对其属性命名不需要再次重复。

反例\color{red}{反例}

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

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

正例\color{Green}{正例}

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

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

5. 解释型变量 ✅

当类/对象名已经有意义时,对其属性命名不需要再次重复。

反例\color{red}{反例}

  const cityStateRegex = /^(.+)[,\\s]+(.+?)\s*(\d{5})?$/;
  // 不知道两个参数代表什么含义
  saveCityState(
      cityStateRegex.match(cityStateRegex)[1],
      cityStateRegex.match(cityStateRegex)[2]
  );

正例\color{Green}{正例}

const address = "One Infinite Loop, Cupertino 95014";
const cityZipCodeRegex = /^[^,\\]+[,\\\s]+(.+?)\s*(\d{5})?$/;
const [_, city, zipCode] = address.match(cityZipCodeRegex) || [];

// 清晰知道两个参数的含义
saveCityZipCode(city, zipCode);

二、函数(Functions)

1. 默认参数,代替短路求值 ✅

如果使用默认参数,函数将只为未定义的参数提供默认值。其他 “falsy” 值(如''、""、false、null、0和NaN)将不会替换为默认值。

反例\color{red}{反例}

function createMicrobrewery(name) {
  const breweryName = name || "Hipster Brew Co.";
  // ...
}

正例\color{Green}{正例}

function createMicrobrewery(name = "Hipster Brew Co.") {
  // ...
}

2. 参数少于 3 位 ✅

限制函数参数数量很有必要,这么做使得在测试函数时更加轻松。当确实需要多个参数时,考虑这些参数封装成一个对象。

反例\color{red}{反例}

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

createMenu("Foo", "Bar", "Baz", true);

正例\color{Green}{正例}

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

function createMenu(menuConfig) {
  ...
}

3. 单一职责 ✅

功能单一的函数易于重构、测试和理解

反例\color{red}{反例}

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

正例\color{Green}{正例}

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();
}

4. 明确表明函数功能 ✅

反例\color{red}{反例}

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

const date = new Date();

// It's hard to tell from the function name what is added
addToDate(date, 1);

正例\color{Green}{正例}

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

const date = new Date();
addMonthToDate(1, date);

5. Object.assign 覆盖对象默认值 ✅

反例\color{red}{反例}

const 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);

正例\color{Green}{正例}

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

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

createMenu(menuConfig);  // {title: "Order", body: "Bar", buttonText: "Send", cancellable: true}

6. 避免重复代码 ❌

重复的代码意味着逻辑变化时需要对不止一处进行修改。

反例\color{red}{反例}

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

   render(data);
 });
}

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

   render(data);
 });
}

正例\color{Green}{正例}

function showEmployeeList(employees) {
employees.forEach(employee => {
  const expectedSalary = employee.calculateExpectedSalary();
  const experience = employee.getExperience();

  const data = {
    expectedSalary,
    experience
  };

  switch (employee.type) {
    case "manager":
      data.portfolio = employee.getMBAProjects();
      break;
    case "developer":
      data.githubLink = employee.getGithubLink();
      break;
  }

  render(data);
});
}

7. 避免 flag 作为参数 ❌

flag 值的使用,意味着这个函数做了不止一件事, 违反单一原则。若 flag 为 boolean值,应考虑对函数进行再次划分。

反例\color{red}{反例}

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

正例\color{Green}{正例}

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

 function createTempFile(name) {
   createFile(`./temp/${name}`);
 }

8. 避免函数副作用 ❌

副作用是指函数除了除了“接受一个值并返回一个结果”外,还进行了其他操作(如修改外部全局变量),这些操作就会产生副作用。

反例\color{red}{反例}

 const addItemToCart = (cart, item) => {
   cart.push({ item, date: Date.now() });
 };

正例\color{Green}{正例}

 const addItemToCart = (cart, item) => {
   return [...cart, { item, date: Date.now() }];
 };

8. 避免全局函数 ❌

污染 global 是一种不好的做法,因为你可能会与另一个库发生冲突,在开发中遇到意想不到的结果。

例如:你想扩展 JS 中的 Array,为其添加一个 diff 函数显示两个数组间的差异,此时应如何去做?你可以将 diff 写入 Array.prototype,但这么做会和其他有类似需求的库造成冲突。如果另一个库对 diff 的需求为比较一个数组中首尾元素间的差异呢?

使用 ES6 中的 class 对全局的 Array 做简单的扩展显然是一个更棒的选择。

反例\color{red}{反例}

 Array.prototype.diff = function(comparisonArray) {
   const hash = new Set(comparisonArray);
   return this.filter(item => !hash.has(item))
 }

正例\color{Green}{正例}

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

   diff(comparisonArray) {
     const hash = new Set(comparisonArray);
     return this.filter(item => !hash.has(item))
   }
 }

9. 函数式编程 ❌

函数式的编程 接受一个值并返回一个结果, 让函数更干净且易于测试

反例\color{red}{反例}

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

let totalOutput = 0;

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

正例\color{Green}{正例}

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

const totalOutput = programmerOutput.reduce(
 (totalLines, output) => totalLines + output.linesOfCode,
 0
);

三、对象和数据结构(Objects and Data Structures)

1. Getter & Setter ✅

使用 getter 和 setter 来访问对象上的数据可能比简单地查找对象上的属性要好:

  • get 统一处理返回结果,调整更方便。
  • set 值时,可以方便添加验证。
  • 内部封装功能实现。
  • 当 get 或 set 时,容易捕获日志和错误。
  • 可实现异步获取。

反例\color{red}{反例}

 function makeBankAccount() {
   // ...

   return {
     balance: 0
     // ...
   };
 }

 const account = makeBankAccount();
 account.balance = 100;

正例\color{Green}{正例}

function makeBankAccount() {
 // this one is private
 let balance = 0;

 // a "getter", made public via the returned object below
 function getBalance() {
   return balance;
 }

 // a "setter", made public via the returned object below
 function setBalance(amount) {
   // ... validate before updating the balance
   balance = amount;
 }

 return {
   // ...
   getBalance,
   setBalance
 };
}

const account = makeBankAccount();
account.setBalance(100);

2. 私有化对象成员 ✅

可以通过闭包实现

反例\color{red}{反例}

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

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

const 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

正例\color{Green}{正例}

function makeEmployee(name) {
 return {
   getName() {
     return name;
   }
 };
}

const employee = makeEmployee("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

四、类(Classes)

1. 使用 ES6 的类代替 ES5 的构造函数✅

ES6的类 让对象原型的写法更加清晰、更像面向对象编程的语法

反例\color{red}{反例}

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

 this.age = age;
};

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

const 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 liveBirth() {};

const 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 speak() {};

正例\color{Green}{正例}

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() {
   /* ... */
 }
}

2. 链式方法✅

链式方法让代码不冗余,更有表现力。有争论说方法链不够干净且违反了德米特法则。但这种方法在 JS 及许多库(如 JQuery、Moment)中显得非常实用。

在 class 的函数中返回 this,能够方便的将类需要执行的多个方法链接起来。 反例\color{red}{反例}

 class Car {
   constructor(make, model, color) {
     this.make = make;
     this.model = model;
     this.color = color;
   }

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

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

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

   save() {
     console.log(this.make, this.model, this.color);
   }
 }
 const car = new Car("Ford", "F-150", "red");
 car.setColor("pink");
 car.save();

正例\color{Green}{正例}

class Car {
 constructor(make, model, color) {
   this.make = make;
   this.model = model;
   this.color = color;
 }

 setMake(make) {
   this.make = make;
   // NOTE: Returning this for chaining
   return this;
 }

 setModel(model) {
   this.model = model;
   // NOTE: Returning this for chaining
   return this;
 }

 setColor(color) {
   this.color = color;
   // NOTE: Returning this for chaining
   return this;
 }

 save() {
   console.log(this.make, this.model, this.color);
   // NOTE: Returning this for chaining
   return this;
 }
}

const car = new Car("Ford", "F-150", "red").setColor("pink").save();

3. 组合优于继承✅

继承优于组合的场景:

  • 你的继承表示 is-a 关系,而不是 has-a 关系 (Human->Animal vs User->UserDetails)。
  • 你可以重用基类代码(人可以像所有动物一样移动)。
  • 要通过更改基类对派生类进行全局更改(改变所有动物运动时的热量消耗)。

反例\color{red}{反例}

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

 // ...
}

// Bad because Employees "have" tax data. EmployeeTaxData is not a type of Employee
class EmployeeTaxData extends Employee {
 constructor(ssn, salary) {
   super();
   this.ssn = ssn;
   this.salary = salary;
 }
 // ...
}

正例\color{Green}{正例}

class EmployeeTaxData {
 constructor(ssn, salary) {
   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);
 }
 // ...
}

五、面向对象设计(SOLID)

1、单一责任原则 Single Responsibility Principle (SRP)

上面函数有提到过,一个类具有太多太杂的功能,就需要拆分,一个类只做一件事 反例\color{red}{反例}

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

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

 verifyCredentials() {
   // ...
 }
}

正例\color{Green}{正例}

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()) {
     // ...
   }
 }
}

2、开闭原则 Open/Closed Principle (OCP)

应该对外扩展开放,但对内修改关闭。通俗的讲就是只允许用户在不更改现有代码的情况下添加新功能。 反例\color{red}{反例}

class AjaxAdapter extends Adapter {
 constructor() {
   super();
   this.name = "ajaxAdapter";
 }
}

class NodeAdapter extends Adapter {
 constructor() {
   super();
   this.name = "nodeAdapter";
 }
}

class HttpRequester {
 constructor(adapter) {
   this.adapter = adapter;
 }

 fetch(url) {
   if (this.adapter.name === "ajaxAdapter") {
     return makeAjaxCall(url).then(response => {
       // transform response and return
     });
   } else if (this.adapter.name === "nodeAdapter") {
     return makeHttpCall(url).then(response => {
       // transform response and return
     });
   }
 }
}

function makeAjaxCall(url) {
 // request and return promise
}

function makeHttpCall(url) {
 // request and return promise
}

正例\color{Green}{正例}

class AjaxAdapter extends Adapter {
 constructor() {
   super();
   this.name = "ajaxAdapter";
 }

 request(url) {
   // request and return promise
 }
}

class NodeAdapter extends Adapter {
 constructor() {
   super();
   this.name = "nodeAdapter";
 }

 request(url) {
   // request and return promise
 }
}

class HttpRequester {
 constructor(adapter) {
   this.adapter = adapter;
 }

 fetch(url) {
   return this.adapter.request(url).then(response => {
     // transform response and return
   });
 }
}

3、里氏替换原则 Liskov Substitution Principle (LSP)

派生类(子类)中的对象可以在程序中代替其基类(超类)中的对象。

如果你有一个父类和一个子类,那么父类和子类的同一功能调用,结果一致。这可能令人困惑,所以让我们看一下经典的正方形-矩形例子。从数学上讲,一个正方形就是一个矩形,但是如果你通过继承使用 is-a 关系来建模它,你会发现这是错误的。

反例\color{red}{反例}

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 {
 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);
   const area = rectangle.getArea(); // BAD: Returns 25 for Square. Should be 20.
   rectangle.render(area);
 });
}

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

正例\color{Green}{正例}

class Shape {
 setColor(color) {
   // ...
 }

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

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

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

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

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

function renderLargeShapes(shapes) {
 shapes.forEach(shape => {
   const area = shape.getArea();
   shape.render(area);
 });
}

const shapes = [new Rectangle(4, 5), new Rectangle(4, 5), new Square(5)];
renderLargeShapes(shapes);

4、接口隔离原则 Interface Segregation Principle (ISP)

JavaScript 没有接口,所以这个原则不像其他原则那么严格。然而,即使 JavaScript 缺少类型系统,这一点也很重要。

ISP声明:“不应该强迫客户依赖他们不使用的接口。”由于duck typing的存在,接口在JavaScript中是隐式契约。

在JavaScript中演示这一原则的一个很好的例子是需要大型设置对象的类。不要求客户端设置大量的选项是有益的,因为大多数时候他们不需要所有的设置。让它们成为可选的有助于避免“胖接口”。 反例\color{red}{反例}

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

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

 traverse() {
   // ...
 }
}

const $ = new DOMTraverser({
 rootNode: document.getElementsByTagName("body"),
 animationModule() {} // Most of the time, we won't need to animate when traversing.
 // ...
});

正例\color{Green}{正例}

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() {
   // ...
 }
}

const $ = new DOMTraverser({
 rootNode: document.getElementsByTagName("body"),
 options: {
   animationModule() {}
 }
});

5、依赖反转原则 Dependency Inversion Principle (DIP)

该原则有两个核心点:

  • 高层模块不应该依赖于低层模块。他们都应该依赖于抽象接口。
  • 抽象接口应该脱离具体实现,具体实现应该依赖于抽象接口。

AngularJS,使用依赖注入(Dependency Injection, DI)的形式实现了这个原则。尽管它们不是相同的概念,DIP 防止高级模块了解其低级模块的实现并设置它们。它可以通过 DI 实现这一点。这样做的一个巨大好处是它减少了模块之间的耦合。耦合是一种非常糟糕的开发模式,因为它使代码难以重构。

反例\color{red}{反例}

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

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

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

   // BAD: We have created a dependency on a specific request implementation.
   // We should just have requestItems depend on a request method: `request`
   this.requester = new InventoryRequester();
 }

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

const inventoryTracker = new InventoryTracker(["apples", "bananas"]);
inventoryTracker.requestItems();

正例\color{Green}{正例}

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) {
   // ...
 }
}

// By constructing our dependencies externally and injecting them, we can easily
// substitute our request module for a fancy new one that uses WebSockets.
const inventoryTracker = new InventoryTracker(
 ["apples", "bananas"],
 new InventoryRequesterV2()
);
inventoryTracker.requestItems();