在编程中,继承是指将特性从父代传递给子代,以便新代码可以重用并基于现有代码的特性进行构建。继承就是对对象的一种扩展,扩展一类对象得到新的对象,使新的对象能够复用被扩展对象现有的行为或特性。在基于类的继承方式下,当一个子类完成继承时,由该子类所创建的对象既具有其子类中单独定义的属性,又具有其父类中定义的属性(以及父类的父类,依此类推)。Java,C#这类语言使用类来管理对象(实例),对象的创建基于类,对象的继承也是基于类(类的继承映射对象的继承)。JavaScript引入了原型机制来管理程序中的对象,也是基于原型模式,JavaScript完成对象的继承实现,提高了JavaScript程序的代码重用性、灵活性和扩展性。基于类的继承又叫接口继承,JavaScript这种继承实现称为实现继承,实现继承支持运行时改变继承关系,它更灵活。
非类继承
在JavaScript 原型机制中,我们介绍了JavaScript的原型机制,可以看到,JavaScript原型机制已经在某种意义上支持了继承。但要实现工作良好的继承方法,仍需要一些额外工作。下面让我们逐步展开。另外需要说明,大部分讲解JavaScript继承的内容中,仍会采用父类、子类表述继承关系,本文不会如此,既然JavaScript不是基于类完成的继承,那么采用父类、子类便不再准确。
严格来说JavaScript中的继承关系不是发生在构造函数之间,JavaScript中的继承关系发生在构造函数创建的对象之间。构造函数创建的对象获得了函数体中描述的属性,构造函数本身不具备这些属性,复用的是构造函数中描述的属性(获取这些属性的方式仍然是要调用构造函数创建对象)而不是构造函数自身的属性。在Java和C#中,类和对象是不同的事物,类关系完全映射对象关系是严谨的,但是在JavaScript中函数也是对象,如果“构造函数A”创建的对象复用了“构造函数B”创建的对象的属性或方法我们称“构造函数A”继承了“构造函数B”,同时“构造函数A”本身并没有“构造函数B”的属性,这样就与继承的概念不符。
诚然,如果“构造函数A”创建的对象复用了“构造函数B”创建的对象的属性或方法,那一定是“构造函数A”的复用了“构造函数B”的描述(创建对象的描述)。但是将函数的描述与函数的属性或方法混为一谈仍然是不严谨的。要时刻记得,在JavaScript中函数也是对象。
在本文中我决定使用父代函数、子代函数、子代对象来表述继承关系。当一个构造函数SubFunction
创建的对象继承了另一个构造函数SuperFunction
描述的对象定义时,我们称SubFunction
为子代函数,称SuperFunction
为父代函数。称继承了其他对象属性或方法的对象为子代对象。称被子代对象所继承的对象为父代对象。
父代函数、子代函数一定是按照构造函数调用而调用的函数。
1. 原型链继承
基于原型的查找机制,实现基本的继承,代码如下:
function Person(name, age) {
this.name = name;
this.age = age;
this.friends = [];
}
Person.prototype.introduce = function() {
return 'My name is ' + this.name;
};
function Engineer(job) {
this.job = job;
}
// 实现继承
Engineer.prototype = new Person('Guang', 32);
// 修复 constructor
Engineer.prototype.constructor = Engineer;
const engineer = new Engineer('Software Engineer');
通过更改构造函数的prototype
属性,实现了最简单的继承,这个继承实现难以良好地工作。第一个问题是非函数属性无法真实继承,子代(函数Engineer
创建的)对象虽然很好的继承了父代函数(Person
)中定义的方法和prototype
属性对象上的方法,但是不能够良好的继承父代函数中定义的非函数属性。关于非函数属性继承的问题分两种情况:
- 当使用赋值语句为对象设置属性值时,只会修改对象本身的属性或者添加这个属性,通过为对象属性赋值无法修改原型中的属性;
- 如果子代函数创建的对象直接操作原型上引用值类型的属性,这个影响会广播给所有通过子代函数创建的对象,发生严重的错误。
在代码示例中,为engineer
的name
赋值,会在engineer
自身添加属性name
,无法修改父代对象的name
。如果engineer
直接使用引用值类型friends
,产生的作用会广播给所有(通过函数Engineer
创建的)子代对象。请看验证代码:
// 赋值前访问的是原型中的 name
console.log(engineer.name); // 输出 "Guang"
console.log(Object.getPrototypeOf(engineer).name); // 输出 "Guang"
// 赋值engineer.name后,engineer自身获得name,原型中的name不变
engineer.name = 'Greg';
console.log(engineer.name); // 输出 "Greg"
console.log(Object.getPrototypeOf(engineer).name); // 输出 "Guang"
// engineer继承的introduce工作良好
console.log(engineer.introduce()); // 输出 "My name is Greg"
// 创建一个 engineer2,并通过 engineer 调用 friend 数组
const engineer2 = new Engineer('Hardware Engineer');
engineer.friends.push('Wei');
console.log(engineer.friends); // 输出 ['Wei']
console.log(engineer2.friends); // 输出 ['Wei']
第二个问题是调用构造函数传参问题,因为是在使用子代函数创建对象之前进行原型绑定实现的继承,在调用子代函数创建对象时,无法为父代函数的调用传参,这一点和基于类的继承有很大的区别。综上所述,实践中很少单独使用原型链完成继承。
2. 借用构造函数继承
为了解决原型链继承模式中的两个问题,可以使用借用构造函数的继承方式(有时候也叫伪造对象或经典继承)。在这种模式中,通过在子代函数中把当前this
绑定到父代函数上进行调用,以获得父代函数中描述的属性。请看示例代码:
function Person(name, age) {
this.name = name;
this.age = age;
this.friends = [];
this.sayHi = function() {
return 'Hi, I am ' + this.name;
}
}
Person.prototype.introduce = function() {
return 'My name is ' + this.name;
};
function Engineer(name, age, job) {
// 实现继承
// Function.prototype.call() 函数指定函数运行时的`this`值,并且支持传递调用参数。
// 这里把Engineer函数中的`this`值传递给Person,
Person.call(this, name, age);
this.job = job;
}
const engineer1 = new Engineer('Guang', 32, 'Software Engineer');
const engineer2 = new Engineer('Greg', 29, 'Hardware Engineer');
使用此种继承方式,每次调用子代函数时都会调用父代函数,解决了向父代函数传值的问题,同时将子代函数作用域中的this
传递给父代函数进行调用,完全继承了父代函数中描述的属性,并且子代对象间不共享这些属性。注意:父代函数的调用语句要书写在子代函数实现的顶部,否则父代函数中描述的属性会覆盖子代函数中描述的同名属性。
请看验证代码:
console.log(engineer1.name); // 输出 "Guang"
console.log(engineer2.name); // 输出 "Greg"
console.log(engineer1.sayHi()); // 输出 "Hi, I am Guang"
console.log(engineer2.sayHi()); // 输出 "Hi, I am Greg"
engineer1.friends.push('Wei');
engineer2.friends.push('Tian');
console.log(engineer1.friends); // 输出 ['Wei']
console.log(engineer2.friends); // 输出 ['Tian']
可以看到,engineer1
和engineer2
分别获得了父代函数中描述的属性,并且不共享。
借用构造函数继承模式也有问题,子代对象无法继承父代函数prototype
属性中的方法,如果调用engineer1.introduce()
,程序会报错。
engineer1.introduce(); // 报错: engineer1.introduce is not a function
3. 组合继承
组合继承又叫伪经典继承,它是将原型链继承和借用构造函数继承组合到一起的继承模式。这样,既可以安全的继承父代函数中描述的属性,又继承了父代函数prototype
属性中的方法。代码示例如下:
function Person(name, age) {
this.name = name;
this.age = age;
this.friends = [];
this.sayHi = function() {
return 'Hi, I am ' + this.name;
}
}
Person.prototype.introduce = function() {
return 'My name is ' + this.name;
};
function Engineer(name, age, job) {
// 继承属性
Person.call(this, name, age);
this.job = job;
}
// 继承方法
Engineer.prototype = new Person();
// 修复 prototype 中的 constructor
Engineer.prototype.constructor = Engineer;
const engineer1 = new Engineer('Guang', 32, 'Software Engineer');
const engineer2 = new Engineer('Greg', 29, 'Hardware Engineer');
验证继承实现效果:
console.log(engineer1.name); // 输出 "Guang"
console.log(engineer2.name); // 输出 "Greg"
console.log(engineer1.sayHi()); // 输出 "Hi, I am Guang"
console.log(engineer2.sayHi()); // 输出 "Hi, I am Greg"
engineer1.friends.push('Wei');
engineer2.friends.push('Tian');
console.log(engineer1.friends); // 输出 ['Wei']
console.log(engineer2.friends); // 输出 ['Tian']
console.log(engineer1.introduce()); // 输出 "My name is Guang"
console.log(engineer2.introduce()); // 输出 "My name is Greg"
组合式继承融合了原型链继承和借用构造函数继承的优点,避免了它们的缺点。组合式继承达到了工作良好的效果。不过也有一个问题,组合式继承中指定一个父代对象为子代函数prototype
属性,来继承父代对象中的方法。这样做的结果是,父代函数中描述的属性和方法,既出现在子代对象自身,又出现在子代对象的原型上,自身属性会遮蔽原型上的同名属性,这个结果不影响程序的功能,但还是造成内存的浪费。这是组合式继承的瑕疵之处。
验证代码如下:
console.log(Object.hasOwn(engineer1, 'name')); // 输出 true
console.log(Object.hasOwn(Object.getPrototypeOf(engineer1), 'name')); // 输出 true
console.log(engineer1.name === Object.getPrototypeOf(engineer1).name); // 输出 false
console.log(engineer1.name); // 输出 "Guang"
Object.getPrototypeOf(engineer1).name = 'engineer prototype name';
console.log(engineer1.name); // 输出 "Guang"
console.log(Object.getPrototypeOf(engineer1).name); // 输出 "engineer prototype name"
4. 原型式继承
如果我们期望继承的是一个简单对象的属性和方法,而不是一个构造函数描述的属性和方法,那么原型式继承是简单有效的。在一个工厂方法中,它通过指定子代对象的原型为父代对象,完成子代对象对父代对象的继承。 示例代码如下:
function object(obj) {
function F() {}
F.prototype = obj;
return new F();
}
const person = {
name: 'Guang',
age: 32,
friends: [],
introduce: function() {
return 'My name is ' + this.name;
}
};
const personCopy1 = object(person);
const personCopy2 = object(person);
原型式继承的本质就是基于一个对象为原型,创建一个新对象。可以使用Object.create()
代替工厂函数object()
。即:
const person = {
name: 'Guang',
age: 32,
friends: [],
introduce: function() {
return 'My name is ' + this.name;
}
};
const personCopy1 = Object.create(person);
const personCopy2 = Object.create(person);
原型式继承和原型链继承都是基于原型机制,它们都可以很好地继承父代对象的方法。它们的不同点在于原型式继承面向的是对象,原型链式继承面向构造函数。原型式继承具有类似于原型链式继承的问题:无法继承原型上的非函数属性,以及所有的子代对象和父代对象共享父代对象的引用值类型属性的变化。 验证代码如下:
personCopy1.friends.push('Wei');
console.log(personCopy1.friends); // 输出 ['Wei']
console.log(person.friends); // 输出 ['Wei']
console.log(personCopy2.friends); // 输出 ['Wei']
5. 寄生式继承
寄生式继承是原型式继承的增强版。当你在工厂函数中创建对象后没有直接返回,而是先对新对象进行扩展后返回,就是寄生式继承。 示例代码如下:
const person = {
name: 'Guang',
age: 32,
friends: [],
introduce: function() {
return 'My name is ' + this.name;
}
};
function createAnother(obj) {
const clone = Object.create(obj);
clone.sayHi = function() {
return 'Hi';
}
return clone;
}
const personCopy1 = createAnother(person);
const personCopy2 = createAnother(person);
寄生式继承在为对象添加方法不能够将方法添加到子代对象的原型上,因为子代对象的原型就是父代对象,这么做就是把新方法添加到父代对象上了。将方法添加到子代对象自身存在性能问题。
6. 寄生组合式继承
组合式继承是原型链继承和借用构造函数继承的组合,上文我们也说到过,组合式继承有一个在子代对象原型上添加无效属性的瑕疵。将组合式继承和寄生式继承再组合,可以解决这个小问题。在寄生组合式继承中,不再直接将一个父代对象设置为子代函数的prototype
属性,而是采用寄生式继承父代函数的prototype
属性,进行扩展后(调整构造函数相关属性),设置为子代函数的prototype
属性。
寄生组合式继承的示例代码如下:
function inheritPrototype(ChildConstructor, SuperConstructor) {
const newPrototype = Object.create(SuperConstructor.prototype);
newPrototype.constructor = ChildConstructor;
ChildConstructor.prototype = newPrototype;
}
function Person(name, age) {
this.name = name;
this.age = age;
this.friends = [];
this.sayHi = function() {
return 'Hi, I am ' + this.name;
}
}
Person.prototype.introduce = function() {
return 'My name is ' + this.name;
};
function Engineer(name, age, job) {
// 继承属性
Person.call(this, name, age);
this.job = job;
}
// 继承方法
inheritPrototype(Engineer, Person);
const engineer1 = new Engineer('Guang', 32, 'Software Engineer');
const engineer2 = new Engineer('Greg', 29, 'Hardware Engineer');
寄生组合式继承达到了组合式继承同样的继承效果,同时避免了在子代对象原型上创建不必要的、多余的属性。 验证代码如下:
// 1. 工作良好
console.log(engineer1.name); // 输出 "Guang"
console.log(engineer2.name); // 输出 "Greg"
console.log(engineer1.sayHi()); // 输出 "Hi, I am Guang"
console.log(engineer2.sayHi()); // 输出 "Hi, I am Greg"
engineer1.friends.push('Wei');
engineer2.friends.push('Tian');
console.log(engineer1.friends); // 输出 ['Wei']
console.log(engineer2.friends); // 输出 ['Tian']
console.log(engineer1.introduce()); // 输出 "My name is Guang"
console.log(engineer2.introduce()); // 输出 "My name is Greg"
// 2. 子代对象原型上没有必要用的、冗余的属性
console.log(Object.hasOwn(engineer1, 'name')); // 输出 true
console.log(Object.hasOwn(Object.getPrototypeOf(engineer1), 'name')); // 输出 false
7. class
声明的继承
ECMAScript 6引入了class
声明,同时引入了关键字extends
来表达class
声明之间的继承。使用它们可以更简洁、优雅的实现继承。示例代码如下:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
this.friends = [];
this.sayHi = function() {
return 'Hi, I am ' + this.name;
}
}
introduce() {
return 'My name is ' + this.name;
}
}
class Engineer extends Person {
constructor(name, age, job) {
super(name, age);
this.job = job;
}
}
const engineer1 = new Engineer('Guang', 32, 'Software Engineer');
const engineer2 = new Engineer('Greg', 29, 'Hardware Engineer');
使用class
声明和关键字extends
完成的继承是对Java、C#类继承的语法模仿,这个继承实现和寄生组合式继承效果相同,验证代码可以直接使用寄生组合式继承。如果你的程序运行环境支持class
声明和关键字extends
,非常建议直接使用它们实现你的继承机制。如果你的程序运行环境不支持,你仍然可以使用它进行开发,然后使用Babel工具将其转换成ECMAScript 5的代码。你会发现Babel转换class
声明的继承时,将它转换成了寄生组合式继承。