在 2015 年的时候,class
才正式随着ES6
的发布加入到了JavaScript
家族中来。
在写这个内容的时候,笔者对class
的使用经验寥寥无几。因此,本文既是分享,也是学习。
面向对象编程(Object-oriented programming,简称 OOP)是一种软件编程范式,它是一种基于对象和类的编程方法。
类(class)
是面向对象编程中的重要概念,简单来说,开发者用class
来创建对象的结构和行为的定义。
类的一些重要概念和特点包括:
在ES6
规范发布之前,JavaScript
是没有class
这个正式的概念的。但是语言规范的制定者和社区开发者们都需要OOP
这样的编程范式来应对自己的需求。
在类出现在规范上之前,开发者创建对象的方式有很多种,并且都有自己各自的问题。我们会花一点时间逐一讨论这些方案。
首先是工厂函数
创建对象:
function createPerson(name, age) {
let o = new Object()
o.name = name
o.age = age
o.sayName = function() {
console.log(this.name)
}
return o;
}
const p1 = createPerson('Lily', 24);
const p2 = createPerson('Juice', 20)
利用工厂函数创建对象的不足在于无法判断新创建的对象的类型
。
在JavaScript
中存在构造函数
的概念,用于创建特定类型的对象。
一个典型的构造函数即一个以大写字母开头命名的函数,函数体内使用this
来设置返回对象的属性,不需要显式创建对象和指定return
对象,这也是跟工厂函数的差别之一:
function Person(name, age) {
this.name = name
this.age = age
this.getAge = function() {
console.log(this.age)
return this.age
}
}
// 通过构造函数创建对象
const p1 = new Person('July', 18);
const p2 = new Person('Rose', 18);
在很多初级 JavaScript 开发面试的时候有一道经典面试题,面试官可能会问使用new
操作费创建对象会执行那些操作。
这个问题的答案如下:
[[prototype]]
被赋值给了构造函数的prototype
属性this
指向了新创建的对象return
返回值,默认返回的是新对象)如上所示的示例,p1
存在以下属性连接:
p1.constructor === Person
显然,我们可以通过constructor
来确定对象的类型是Person
。
但是,大多数情况下你可能会看到开发者使用instanceof
来确定对象的类型:
console.log(p1 instanceof Person); // true
构造函数创建对象解决了工厂函数无法确定对象类型的问题,但是还是有缺点:每次创建的对象都会添加构造函数里定义的方法
。上述例子中创建的p1
和p2
对象都具有名为getAge
的函数方法,二者虽然在定义的代码上看是相同的,实际却不等。
为了弥补构造函数这个瑕疵,我们可以利用JavaScript
的原型模式来解决问题。
我们知道,构造函数也是函数。每一个函数都有一个prototype
属性,这个属性是一个对象,此对象上的属性和方法可以被所有由此构造函数创建的对象实例所共享。
更多原型模式的知识推荐阅读《JavaScript 高级程序设计(第四版)》
function Person() {}
Person.prototype.name = 'Rose'
Person.prototype.getName = function() {
console.log(this.name)
}
let p1 = new Person()
p1.getName(); // Rose
let p2 = new Person()
p2.getName(); // Rose
原型的关系如图所示:
因此,p1
和p2
执行getName
方法输出的是构造函数的prototype(原型对象)
里共享的属性name
。
原型是有层级的,构造函数的原型对象也是对象,因此也有原型。上述例子中,Person
函数的原型对象的原型就是顶层对象的原型Object.prototype
,因此p1
对象可以调用p1.toString()
方法。
这个方法源于Object.prototype
原型对象,当我们给p1
添加上toString()
方法之后:
p1.toString = function() {
console.log('p1')
}
它在调用时会先从自身搜索这个方法,不存在则往原型对象上搜索共享方法,直到Object.prototype
对象为止。
上述例子我们主动添加了toString()
方法,则后续调用时会执行此方法输入p1
。
很多面向对象语言中的继承包含接口继承和实现继承,而JavaScript
中的函数没有类型签名,因此接口继承无从谈起。JavaScript
中的继承即实现继承,主要由原型链的机制来实现。
代码示例来源于《JavaScript 高级程序设计》
我们来看如下的代码:
function SuperType() {
// 设置 this 的属性
this.property = true;
}
SuperType.prototype.getSuperValue = function() {
return this.property;
}
function SubType() {
this.subProperty = false;
}
// 构造函数不使用默认原型,而是设置为 SuperType 的实例对象
// 补充:默认原型就是 Object.prototype
SubType.prototype = new SuperType();
SubType.prototype.getSubTypeValue = function() {
return this.subProperty;
}
// 创建实例
let instance = new SubType();
console.log(instance.getSuperValue()); // 输出 true
如图所示:
SubType
构造函数创建的实例对象继承了SuperType
的原型上的方法,因此可以调用getSuperValue()
,并且在原型链上搜索property
的时候,先从自身搜索、再从原型搜索。而原型恰好是SuperType
的实例,因此原型上存在property
属性。
JavaScript
的继承由此展开。
首先原型继承之后,继承的属性在不同的实例中是共享的。其次,子类型实例化的时候无法给父类型的构造函数传参。再次,子构造函数可以重新通过字面量的方式设置原型对象,从而会让之前的继承断裂。
为了在创建实例的时候给父类型的构造函数传参,有人提出了一个“没什么用的”方案:
function SuperType(name) {
this.name = name;
this.colors = ['red'];
};
function SubType(name) {
SuperType.call(this, name)
}
let instance = new SubType('Rose')
instance.colors.push('aliceblue')
console.log(instance.colors); // red, aliceblue
console.log(instance.name); // Rose
let instance2 = new SubType('Jay')
console.log(instance.colors); // red
console.log(instance.name); // Jay
不错吧,利用call
函数可以给父类型构造函数传参了,实例也能通过父类型的属性初始化自己的属性,并且不会影响其他实例。
但是,这种被称为盗用构造函数
的模式实例化的对象却无法使用父类型构造函数原型上的方法,因此很多人觉得这玩意你看了笑笑就好。
来看看这个函数:
function createObject(obj) {
function F(){}
F.prototype = obj
return new F();
}
当我们传入一个对象的时候,返回值就是一个新的对象,这个新对象是createObject
函数内临时构造函数F
的实例,并且由于我们指定了这个实例的原型对象为传入的对象,那么最终返回的对象实例的原型就是传入的对象。
这在某种程度上也是一种继承方式,并且这种继承方式不需要我们定义构造函数。需要注意的是,这种方式所创建的对象会共享传入作为原型的对象,当你真的需要基于一个对象去快速创建新对象,并且需要共享这个原型对象的属性的话,这种方式确实很适合你。
为了解决盗用构造函数模式的缺点,有人提出了组合继承
的实现方案:
组合什么?当然是组合原型继承和盗用构造函数模式继承的优点!
function SuperType(name) {
this.name = name
this.color = ['red']
}
SuperType.prototype.sayName = function() {
console.log(this.name)
}
function SubType(name, age) {
// 继承父类型 SuperType 的属性
SuperType.call(this, name) // 第二次调用
this.age = age
}
// 继承父类型原型上的方法
SubType.prototype = new SuperType() // 第一次调用
SubType.prototype.sayAge = function() {
console.log(this.age)
}
let instance1 = new SubType('Rose', 18)
instance1.color.push('blue')
console.log(instance1.color) // red, blue
instance1.sayName() // Rose
instance1.sayAge() // 18
let instance2 = new SubType('Jay', 99)
console.log(instance1.color) // red 颜色是构造函数内创建的,独立的(当然实例的原型链上也有color,这个是共享的)
instance1.sayName() // Jay
instance1.sayAge() // 99
组合继承弥补了原型继承和盗用构造函数继承的缺点,因此被社区广泛接受和推广。
但是,这种方式依然存在不足之处:SuprtType()
被调用了两次。
后来,开发者们考虑到可以利用原型式继承去代替显式地创建SuperType
的实例对象设置原型的方案。
function SuperType(name) {
this.name = name;
this.color = ['red']
}
SuperType.prototype.sayName = function() {
console.log(this.name)
}
function SubType(name, age) {
SuperType.call(this, name)
this.age = age
}
function inheritProperty(subType, superType) {
let prototype = createObject(superType.prototype); 设置父类型的原型,创建一个对象作为原型对象
prototype.constructor = subType; // 修复了原型上 constructor 的指向
subType.prototype = prototype; // 设置子类型原型对象
}
inheritProperty(SubType, SuperType); // 通过原型式继承设置原型,而非显式通过 new SuperType 创建原型
// 增强原型
SubType.prototype.sayAge = function() {
console.log(this.age)
}
如此一来即可只调用一次SuperType
原型函数,并且最终SubType
对象实例的instanceof
依然有效。这
这就是ES6
之前的继承最佳模式,也是那个时候开发者编写OOP
范式代码的基础。
在ES6
发布之后,Class
正式为类和面向对象编程提供了语言规范级别的定义,开发者们可以不必再处理冗长的继承逻辑代码,直接使用官方提供的新语法糖Class
即可。
举个例子,首先是简单的类:
// 不可实例化的抽象基类,通过 new.target 控制使其无法通过 new 实例化,从而得到一个仅用于继承的基类
class Animal {
// 使用 new 调用操作符创建类的实例的时候会调用这个构造函数(但这个构造函数是可选的,不包含构造函数的类定义也是合法的)
constructor(name) {
// 实例化的时候检测到 target 为 Animal 即抛出异常
if (new.target === Animal) {
throw new Error("Animal class cannot be instantiated.");
}
this.name = name;
}
// 访问器
get getName() {
return this.name;
}
// 实例方法
makeSound() {
console.log("Some generic sound");
}
// 静态方法
static staticMethod() {
console.log("Animal static method called");
}
// 生成器
*generateSounds() {
yield "Sound 1";
yield "Sound 2";
}
// 迭代器
[Symbol.iterator]() {
let index = 0;
const sounds = ["Sound 1", "Sound 2"];
return {
next: () => ({
value: sounds[index++],
done: index > sounds.length,
}),
};
}
}
Class 是特殊的函数:
typeof Animal
输出function
,因此你想要将之定义在数组中、像其他对象或函数引用一样作为参数传递亦或是立即实例化、查看原型都是可以的,即使我们平时几乎不会这么做。
为了实现多继承,使用混入
机制:
// 混入实现多继承
const SoundMixin = {
// 实例方法
makeSound() {
console.log("Mixin sound");
// 使用 super 调用基类方法
super.makeSound();
}
// 新的实例方法
additionalMethod() {
console.log("Mixin additional method");
},
// 新的静态方法
staticMixinMethod() {
console.log("Mixin static method");
}
};
继续,添加一个子类,并且子类继承父类:
// 使用混入创建一个新类
class Dog extends Animal {
constructor(name, breed) {
// 派生类必须选择:1.调用 super 函数 2.显式 return 一个对象
super(name);
this.breed = breed;
}
// 重写父类方法
makeSound() {
console.log("Woof! Woof!");
// 使用 super 调用基类方法
super.makeSound();
}
// 新的实例方法
fetch() {
console.log("Fetching the ball");
}
}
接着使用混入机制将混入的对象设置为类的原型,再来创建类的实例:
super 只能在派生类(继承了其他类的类)构造函数和静态方法中使用,并且在 constructor 函数内先于
this
使用之前调用super()
(等同于调用父类的构造函数)
// 将混入的方法添加到子类
Object.assign(Dog.prototype, SoundMixin);
// 创建类的实例
const myDog = new Dog("Buddy", "Golden Retriever");
// 调用实例方法
myDog.makeSound(); // 输出: Woof! Woof! \n Some generic sound
myDog.additionalMethod(); // 输出: Mixin additional method
// 调用静态方法
Dog.staticMethod(); // 输出: Animal static method called
Dog.staticMixinMethod(); // 输出: Mixin static method
// 使用访问器
console.log(myDog.getName); // 输出: Buddy
// 使用生成器
for (const sound of myDog.generateSounds()) {
console.log(sound);
}
// 使用迭代器
for (const sound of myDog) {
console.log(sound);
}
上述的代码示例涉及了类许多的知识点,但如果你想更加详细地了解类,欢迎阅读《JavaScript 高级程序设计》。
在创建类的实例方法定义时,推荐使用方法链模式:
当链变长的时候,连续调用的代码会更加简洁和清晰,你可以在像
lodash
、rxjs
这样的库上看到这样的模式。
// 不推荐的写法
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();
推荐优化:
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");
car.setColor("pink")
.save();
组合和继承各自有各自的优点,选择哪种模式取决于具体的场景,但是我们也有可以参考的点:
is-a(是一个....)
的关系,而非has-a(有一个...)
上述三点似乎有些抽象,让我们来看一个代码示例:
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;
}
// ...
}
优化代码:
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);
}
// ...
}
一个类应该只有一个引起它变化的原因,如果一个类负责太多不同的事情,那么当其中某个功能发生变化的时候,就可能导致整个类需要修改,这使得代码更加脆弱、难以维护和理解。
将类设计得简洁、保持单一性,有利于减少修改类的逻辑的次数,这样能够降低引入错误的风险,对于阅读代码的人来说也更有好处。
举个反面例子:
class UserSettings {
constructor(user) {
this.user = user;
}
changeSettings(settings) {
if (this.verifyCredentials()) {
// ...
}
}
verifyCredentials() {
// ...
}
}
优化一下,将验证内容分离出去:
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()) {
// ...
}
}
}
当我们需要修改验证逻辑的时候,就不会修改任何UserSettings
的内容了,这在代码变得复杂之后,效果尤为明显。
这是面向对象设计中的"开闭原则"(Open/Closed Principle)的表述。Bertrand Meyer指出,软件实体(如类、模块、函数等)应该对扩展开放,但对修改关闭。理解这一原则主要包括以下几个方面:
对扩展开放(Open for Extension): 意味着系统的设计应该允许在不修改现有代码的情况下引入新功能。新的功能可以通过添加新的代码、新的类、或者新的模块来实现,而不需要修改已经存在的代码。
对修改关闭(Closed for Modification): 意味着一旦一个软件实体(如类)被创建并投入使用,就不应该再对其进行修改。这样可以确保已有的代码在添加新功能时不会受到影响,从而提高系统的稳定性和可维护性。
具体来说,这一原则的目的是为了避免在引入新功能时对已有的代码进行修改,因为这样的修改可能引入错误,破坏已有的功能,或者导致不稳定性。相反,通过允许扩展现有系统,可以保持系统的稳定性,并且更容易维护。
一个常见的实践是通过使用抽象接口和多态性来实现开闭原则。通过定义接口或抽象类,系统的不同部分可以通过实现这些接口或继承这些抽象类来扩展功能,而无需修改已有的代码。这样的设计使得系统更具弹性,更容易适应变化。
举个例子:
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
}
上面的例子里,让开发者需要添加新的网络适配器的时候,就必须修改基础的HttpRequester
类的逻辑,让我们来优化一下:
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
});
}
}
在适配器中提供相同的request
方法,从而提高了扩展性,又不需要修改基础类的逻辑。
Liskov Substitution Principle(LSP)是面向对象设计中 SOLID 原则的一部分,强调了子类型(子类)应该能够替代其基类型(基类)而不引起问题。LSP的存在有几个重要原因:
可替代性(Substitutability): LSP确保了在程序中的任何需要基类型的地方都可以安全地使用子类型,而不需要了解具体的子类型。这提高了代码的可复用性和灵活性。
一致性和可预测性: 当子类型可以替代基类型时,程序的行为更为一致和可预测。如果在使用基类型的地方使用子类型,程序的行为应该与基类型的预期行为一致,而无需考虑具体的子类型。
简化设计和理解: LSP鼓励设计者创建简单一致的类层次结构。通过确保子类型可以替代基类型,我们避免了在使用这些类型时需要进行过多的条件判断或特例处理。这样的设计更易于理解和维护。
降低耦合度: 当子类型可以替代基类型时,系统的各个部分之间的耦合度降低。这意味着可以更轻松地对系统进行修改和扩展,而不会引入意外的行为或破坏现有的功能。
便于测试: LSP有助于创建更容易测试的代码。如果子类型可以替代基类型,我们可以使用基类型的实例或模拟来进行测试,而不需要在测试时专门处理每个具体的子类型。
总体而言,Liskov Substitution Principle在面向对象设计中的价值在于创建更为一致、可扩展和可维护的代码,提高代码的质量和可读性,同时减少引入错误的风险。
编写继承的代码时,子类如需新增功能,则尽可能避免新功能方法名与父类方法重名,以免让父类方法调用时出错。
准确地说,继承复用的关键在于不影响原功能,子类应该能够替代基类而不引起错误!
举个例子:
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
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;
}
}
在这个例子中,Square
类继承自 Rectangle
类,因为正方形可以被视为一种特殊情况的长方形。然而,由于正方形要求宽度和高度始终相等,Square
类覆盖了 setWidth
和 setHeight
方法以保持这一特性。
现在,让我们看一个使用这些类的情况:
function calculateArea(rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(10);
return rectangle.getArea();
}
const rectangle = new Rectangle(5, 10);
console.log(calculateArea(rectangle)); // 输出 50
const square = new Square(5);
console.log(calculateArea(square)); // 输出 25,而不是预期的 50
在这个例子中,calculateArea
函数期望一个长方形,但当我们将一个正方形传递给它时,由于正方形覆盖了 setWidth
和 setHeight
方法,导致最终计算的面积不符合预期。这就违反了 Liskov Substitution Principle。
推荐的写法如下:
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);
依赖反转原则(Dependency Inversion Principle,DIP)是面向对象设计原则之一,提倡高层模块不应该依赖于底层模块,二者都应该依赖于抽象;而抽象不应该依赖于细节,细节应该依赖于抽象。这个原则有助于实现松耦合,提高代码的灵活性和可维护性。
先看一个正面例子:
// 定义抽象的消息发送器接口
class MessageSender {
send(message) {}
}
// 实现具体的邮件发送器
class EmailSender extends MessageSender {
send(message) {
console.log(`Sending email: ${message}`);
}
}
// 实现具体的短信发送器
class SmsSender extends MessageSender {
send(message) {
console.log(`Sending SMS: ${message}`);
}
}
// 高层模块(业务逻辑)依赖于抽象
class NotificationService {
constructor(messageSender) {
this.messageSender = messageSender;
}
sendNotification(message) {
this.messageSender.send(message);
}
}
// 使用示例
const emailSender = new EmailSender();
const smsSender = new SmsSender();
const notificationServiceWithEmail = new NotificationService(emailSender);
notificationServiceWithEmail.sendNotification("Hello via email!");
const notificationServiceWithSms = new NotificationService(smsSender);
notificationServiceWithSms.sendNotification("Hello via SMS!");
再看一个反面例子:
// 高层模块直接依赖于底层模块的具体实现,违反了依赖反转原则
class NotificationServiceViolatingDIP {
constructor() {
this.emailSender = new EmailSender();
}
sendNotification(message) {
this.emailSender.send(message);
}
}
// 使用示例
const notificationServiceViolatingDIP = new NotificationServiceViolatingDIP();
notificationServiceViolatingDIP.sendNotification("Hello via email!");
如此一来,高层模块和邮件发送器耦合性很紧密,如果要扩展发送器类型则需要修改高层模块代码,相对正面例子来说灵活性和可维护性都有所降低。
关于JavaScript
类的分享就到这里了,下次我将分享一些关于测试的内容。
Bye.