JavaScript-OOP.md

在平时的 CodeReview 中要如何去分辨代码的好坏呢?最近看到 https://github.com/ryanmcdermott/clean-code-javascript 这个仓库的总结,感觉蛮有参考价值的。特别是关于类这一章,涉及很多设计模式的思想。刚好相关的中文仓库似乎缺失了类这章节的翻译,以下为类以及 SOLID 这个章节的翻译并添加个人理解。

Class

最好使用 ES2015/ES6 classes 而非 ES5 的函数

传统的 ES5 类的继承,构造,以及内部方法可读性非常差,在需要继承(当然你也可以无需继承)的场景下,推荐使用 ES2015/ES6 classes。如果你需要一个大型且极度复杂的对象,那么可以使用 function 替代 class。

Bad

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`");
  }
	// 调用父类的构造函数,初始化当前子类的 this 的 age 属性
  Animal.call(this, age);
  // 初始化子类的 this 的 furColor 属性
  this.furColor = furColor;
};

// 将子类的 prototype 修改为父类的 prototype
Mammal.prototype = Object.create(Animal.prototype);
// 修改子类的 prototype.constructor 的指向
Mammal.prototype.constructor = Mammal;
// 给子类的 prototype 添加额外的函数
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() {};

上述的继承实际上就是最常见的组合寄生式继承:

  1. 在子类中调用父类的构造函数用于继承属性
  2. 设置子类自己的属性
  3. 将子类的 prototype 修改为父类的 prototype 用于继承父类的方法
  4. 修改 子类的 prototype.constructor 的指向为子类
  5. 添加子类自己的方法

可以看到这个过程极为繁琐。

Good

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

可以看到使用 class 则简单的多,只需要 extends 关键字,并且在子类中的 constructor 的 super 来实现继承。

这里有个注意点是:子类如果写了 constructor, 而不写 super 或是在调用 this 后再调用 super,则会报错,这是因为 constructor 不写则默认为父类的 constructor,如果写了 constructor,但是没有调用 super,则无法生成 this,这点与 ES5 也是有区别的。即 ES5 子类继承前就有 this 了,而 ES6 的 class 子类则是 super 之后才有的 this。

链式调用

这种模式在 JavaScript 中非常有用,在诸如 JQuery, Lodash 之类的仓库中,非常常见。这种模式可以让你的代码看起来简洁明了。基于上述原因,我可以直言,使用链式调用然后看看你的代码将是如此优雅。在方法中,返回 this,就可以实现。

Bad

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

Good

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

  setMake(make) {
    this.make = make;
    // 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);
     // NOTE: 返回 this
    return this;
  }
}

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

组合优于继承

据在四人帮的设计模式,我们应该尽可能使用组合而非继承。有很多理由使用继承,同时也有很多理由使用组合。这里最重要的一点是,如果直觉告诉你要使用继承,那么可以试试看使用组合是否能更好地解决问题?

那你可能会想,什么时候用继承会比较好?这需要视情况而定,但下面罗列了一些继承优于组合的场景:

  1. 你的继承代表 “is-a” 而非 “has-a”(人 是一种 动物 vs 用户 有 用户详情)
  2. 你需要重用基类的代码(人可以像所有动物一样移动)
  3. 你希望修改基类的代码以全局变更所有子类的行为(所有动物移动时都需要消耗热量)

Bad

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

  // ...
}

// 反例:因为员工 有 税收,而不是说 税收是一种 员工
class EmployeeTaxData extends Employee {
  constructor(ssn, salary) {
    super();
    this.ssn = ssn;
    this.salary = salary;
  }

  // ...
}

Good

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

实际上 React 的设计哲学也是提倡组合优于继承,详情可以参考 Composition vs Inheritance

SOLID

SOLID 是设计模式需要遵循的几个原则简称:

  1. 单一职责(Single Responsibility Principle, SRP)
  2. 开闭原则(Open/Closed Principle, OCP)
  3. 里氏替换(Liskov Substitution Principle,LSP)
  4. 接口隔离(Interface Segregation Principle,ISP)
  5. 依赖倒置(Dependency Inversion Principle,DIP)

###单一职责

如代码整洁之道所说,修改一个类的理由不应该超过一个 。如同一个航班只能带一个行李箱,因此我们将所有行李都塞入这个箱子,我们总是倾向于在一个类里实现许多功能。然而这么做的问题在于你的类无法做到概念上的内聚,并且经常不得不进行修改。最小化一个类的修改次数是非常重要的,这是因为如果一个类充斥过多的功能,当我们对其中一部分修改时,无法预测到这个修改会对依赖这个类的其他模块带来什么影响。

Bad

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

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

  verifyCredentials() {
    // ...
  }
}

UserSettings 类负责修改设置,同时还负责了鉴权,而鉴权应该要从 UserSettings 分离出来。

Good

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

开闭原则

“代码实体(类,模块,方法等)应该对拓展开放,对修改闭合”。

简单来说就是:以增代改。

Bad

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
}

假设将来我们需要添加一个 customAdapter, 那么我们需要:

  1. 添加一个 CustomAdapter 类
  2. 在 HttpRequester 中添加一个 makCustomCall 方法
  3. 修改 fetch 方法,判断 adpater.name

但是实际上我们只不过是想添加一个自定义的请求方法,却要对 HttpRequester 进行修改。实际上 HttpRequester 应该不关心具体 request 过程。修改如下:

Good

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

经过这样调整,不管拓展多少个新 Adapter 都不需要对 HttpRequester 进行修改。

里氏替换

“子类对象应该可以直接替换其超类对象被使用”

对于这个原则有一个比较好的解释是:如果你有一个父类和一个子类,那么用子类任意替换掉父类而不会出错。下面是一个经典的例子,数学上来说,正方形是一种长方形,但是如果你真的使用 “is-a” 来构造的话,很快你就会发现问题所在。

Bad

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);
    // 这里,如果我们传入的是一个 square,那么计算结果将会是 25,与预期的 20 不符
    const area = rectangle.getArea();
    rectangle.render(area);
  });
}

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

可以看到在设置宽高以及面积时,实际上正方形与长方形的行为时不一致的,那么这里 正方形不应该从 长方形继承。我们可以抽象出一个更高的层级 Shape,长方形与正方形相同的方法可以放在 Shape 基类中,而不同点则各自实现。

Good

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

接口隔离

JavaScript 没有接口这个概念,因此这一条不像其他语言一样会被严格执行(不过 TypeScript 可以参考一下)。但即便如此,即使是 JavaScript 这样缺少类型系统的语言中,这条原则也是极为重要的。

接口隔离指的是:客户端不应该依赖它不需要的接口,一个类对另一个类的依赖应该建立在最小的接口上。

一个比较好的例子是:一个包含很多设置项的 class。客户端无需设置大量的可选项,因为大部分情况是无需设置所有的选项,将这些选项设为可选就可以避免 “胖接口”。

Bad

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() {} // 多数时候我们并不需要 animationModule
  // ...
});

Good

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

依赖倒置

这个原则有两个核心点:

  1. 高层模块不应该依赖低层模块的实现,他们都应该依赖于抽象接口。
  2. 抽象接口不应该依赖具体实现,具体实现应该依赖抽象接口。

简单来说即高层模块负责定义接口,而低层模块负责实现接口。

依赖倒置经常会与控制反转(IoC)联系在一起,IoC 是实现 DIP 的一种方式,而要实现 IoC 一种常见的方法是 Dependency Injection (DI,依赖注入) 。

这两者实际上不是一个概念,DIP 指的是高层模块不应该知道低层模块的具体实现。而依赖注入则是将低层模块的实现提供给高层模块的具体方法。

这种做法最大的好处是可以减少模块之间的耦合度。

如刚刚说的,JavaScript 没有接口的概念,因此抽象接口的依赖是隐式的,即对象向外部暴露的方法和属性。下面例子中将 InventoryTracker 视为高层模块,InventoryRequester 为低层模块,接口指的是 InventoryTracker 模块依赖到 InventoryRequester 模块暴露的 requestItem,即隐式要求所有的 InventoryRequester 都应该有 requestItem 方法。那么具体实现其实就是说不应该在 InventoryTracker 去实例化一个 InventoryRequester,而应该通过构造函数注入进来。

Bad

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

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

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

   // 这里我们不应该去实例化一个具体的 Requester,我们应该只使用 Requester 实例的 requestItem
    this.requester = new InventoryRequester();
  }

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

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

Good

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

// 这里我们在外部实例化 InventoryRequester 并且注入,我们就可以简单就将 InventoryRequesterV1 替换 InventoryRequesterV2
const inventoryTracker = new InventoryTracker(
  ["apples", "bananas"],
  new InventoryRequesterV2()
);
inventoryTracker.requestItems();