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() {};
上述的继承实际上就是最常见的组合寄生式继承:
- 在子类中调用父类的构造函数用于继承属性
- 设置子类自己的属性
- 将子类的 prototype 修改为父类的 prototype 用于继承父类的方法
- 修改 子类的 prototype.constructor 的指向为子类
- 添加子类自己的方法
可以看到这个过程极为繁琐。
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();
组合优于继承
据在四人帮的设计模式,我们应该尽可能使用组合而非继承。有很多理由使用继承,同时也有很多理由使用组合。这里最重要的一点是,如果直觉告诉你要使用继承,那么可以试试看使用组合是否能更好地解决问题?
那你可能会想,什么时候用继承会比较好?这需要视情况而定,但下面罗列了一些继承优于组合的场景:
- 你的继承代表 “is-a” 而非 “has-a”(人 是一种 动物 vs 用户 有 用户详情)
- 你需要重用基类的代码(人可以像所有动物一样移动)
- 你希望修改基类的代码以全局变更所有子类的行为(所有动物移动时都需要消耗热量)
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 是设计模式需要遵循的几个原则简称:
- 单一职责(Single Responsibility Principle, SRP)
- 开闭原则(Open/Closed Principle, OCP)
- 里氏替换(Liskov Substitution Principle,LSP)
- 接口隔离(Interface Segregation Principle,ISP)
- 依赖倒置(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, 那么我们需要:
- 添加一个 CustomAdapter 类
- 在 HttpRequester 中添加一个 makCustomCall 方法
- 修改 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() {}
}
});
依赖倒置
这个原则有两个核心点:
- 高层模块不应该依赖低层模块的实现,他们都应该依赖于抽象接口。
- 抽象接口不应该依赖具体实现,具体实现应该依赖抽象接口。
简单来说即高层模块负责定义接口,而低层模块负责实现接口。
依赖倒置经常会与控制反转(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();