Skip to main content

深入理解 JS 继承

一、继承分类

如图所示,JS 中继承可以按照是否使用 object 函数,将继承分成两部分。

二、继承方式

1、原型链继承

Child.prototype = new Parent()

将父类的实例作为子类的原型。

优点:父类方法可以被子类实例复用。

缺点:

  • 父类的引用属性会被所有子类实例共享。
  • 子类构建实例时不能向父类传递参数。

示例:

// 父类
function City() {
this.city = 'Guangzhou'
this.alias = ['Sui', 'Huacheng']
}

// 子类
function District(name) {
this.name = name
}

// 继承
District.prototype = new City()

// 使用
var district1 = new District('Haizhu')
console.log(district1) // {name: 'Haizhu'}
console.log(district1.name) // Haizhu
console.log(district1.city) // Guangzhou

var district2 = new District('Tianhe')
console.log(district2) // {name: 'Tianhe'}
console.log(district2.city) // Guangzhou

// 缺点1:父类属性如果是引用类型则该属性会被子类实例共享
district1.city = 'GZC'
console.log(district1.city) // GZC
console.log(district2.city) // Guangzhou
district1.alias.push('Yangcheng')
console.log(district1.alias) // ['Sui', 'Huacheng', 'Yangcheng']
console.log(district2.alias) // ['Sui', 'Huacheng', 'Yangcheng']

console.log(City)
// ƒ City() { this.city = 'Guangzhou', this.alias = ['Sui', 'Huacheng'] }

原型链继承的优点体现在父类方法可以被子类复用,例如:

// 父类
function City() {
this.city = 'Guangzhou'
}

// 父类函数
City.prototype.sayNum = function () { console.log('020') }

// 子类
function District(name) {
this.name = name
}

// 继承
District.prototype = new City()

// 使用
var district1 = new District('Haizhu')
console.log(district1.sayNum) // ƒ () { console.log('020') }

子类实例 district1 的打印结果:

从打印结果可以看出,父类的函数 sayNum 会被子类实例复用。

2、构造函数继承

Parent.call(this)

将父类构造函数的内容复制给子类的构造函数,这是所有继承中唯一不涉及 prototype 的继承,优劣势与原型链继承相反。

优点:

  • 父类的引用属性不会被共享。
  • 子类构建实例时可以向父类传递参数。

缺点:父类的方法不能被子类实例复用。

示例:

// 父类
function City() {
this.city = 'Guangzhou'
this.alias = ['Sui', 'Huacheng']
}

// 子类 & 继承
function District(name) {
this.name = name
City.call(this)
}

// 使用
var district1 = new District('Haizhu')
console.log(district1) // {name: 'Haizhu', city: 'Guangzhou', alias: ['Sui', 'Huacheng']}
console.log(district1.name) // Haizhu
console.log(district1.city) // Guangzhou

var district2 = new District('Tianhe')
console.log(district2) // {name: 'Tianhe', city: 'Guangzhou', alias: ['Sui', 'Huacheng']}
console.log(district2.city) // Guangzhou

// 优点1:父类属性在子类实例中不共享
district1.city = 'GZC'
console.log(district1.city) // GZC
console.log(district2.city) // Guangzhou
district1.alias.push('Yangcheng')
console.log(district1.alias) // ['Sui', 'Huacheng', 'Yangcheng']
console.log(district2.alias) // ['Sui', 'Huacheng']

console.log(City)
// ƒ City() { this.city = 'Guangzhou', this.alias = ['Sui', 'Huacheng'] }

构造函数继承原型链继承最明显的区别在于构造函数继承没有使用 prototype 继承,而是在子类里面执行父类的构造函数,相当于把父类的代码复制到子类里面执行一遍,这样做的另一个优点就是可以给父类传参,例如:

// 父类
function City(district) {
this.city = 'Guangzhou'
this.district = district
}

// 子类 & 继承
function District(name) {
City.call(this, name)
}

// 使用
var district1 = new District('Haizhu')
console.log(district1) // {city: 'Guangzhou', district: 'Haizhu'}
console.log(district1.name) // undefined
console.log(district1.district) // Haizhu

构造函数的缺点体现在父类的方法不能被子类复用,例如:

// 父类
function City() {
this.city = 'Guangzhou'
sayNum1 = () => { console.log('440100') }
}

// 父类函数
City.prototype.sayNum2 = function () { console.log('020') }

// 子类 & 继承
function District(name) {
this.name = name
City.call(this)
}

// 使用
var district1 = new District('Haizhu')
console.log(district1.sayNum1) // undefined
console.log(district1.sayNum2) // undefined

子类实例 district1 的打印结果:

从打印结果可以看出,父类的函数 sayNum 不会被复用。

3、组合继承

Parent.call(this)

Child.prototype = new Parent()

原型式继承和构造函数继承的组合,要独享的属性使用构造函数继承,要共享的属性使用原型链继承,兼具了二者的优点。

优点:

  • 父类的方法可以被复用。
  • 父类的引用属性不会被共享。
  • 子类构建实例时可以向父类传递参数。

缺点:调用了两次父类的构造函数,第一次给子类的原型添加了父类的属性,第二次又给子类的构造函数添加了父类的属性,从而覆盖了子类原型中的同名参数,这种被覆盖的情况造成了性能上的浪费。

示例:

// 父类
function City(district) {
this.city = 'Guangzhou'
this.alias = ['Sui', 'Huacheng']
this.district = district
}

// 父类函数
City.prototype.sayNum = function () { console.log('020') }

// 子类 & 构造函数继承
function District(name) {
City.call(this, name)
}

// 原型链继承
District.prototype = new City()

// 使用
var district1 = new District('Haizhu')
var district2 = new District('Tianhe')

// 优点1:父类的方法可以被复用
console.log(district1.sayNum) // ƒ () { console.log('020') }

// 优点2:父类的引用属性不会被共享(实现引用属性独享)
district1.city = 'GZC'
console.log(district1.city) // GZC
console.log(district2.city) // Guangzhou
district1.alias.push('Yangcheng')
console.log(district1.alias) // ['Sui', 'Huacheng', 'Yangcheng']
console.log(district2.alias) // ['Sui', 'Huacheng']

// 优点3:子类构建实例时可以向父类传递参数
console.log(district1.name) // undefined
console.log(district1.district) // Haizhu

console.log(City)
// ƒ City(district) { this.city = 'Guangzhou' this.alias = ['Sui', 'Huacheng'] this.district = district }

组合继承的缺点体现在调用了两次父类的构造函数,例如下面子类实例 district1 的打印结果:

从打印结果可以看出,子类实例添加了父类属性,覆盖了子类实例原型种添加的父类属性,造成了性能上的浪费。

为什么组合继承会调用两次父类构造函数?


第一次调用:Child.prototype = new Parent()

new 的过程中,执行了父类构造函数。


第二次调用:Parent.call(this,name,like)

call 的作用是改变函数执行时的上下文。比如:A.call(B)。其实,最终执行的还是 A 函数,只不过是用 B 来调用而已。所以 Parent.call(this,name,like) 也就是执行了父类构造函数。

上述不使用 Object.create 3 种继承方式的总结

原型链继承构造函数继承组合继承
父类方法是否可被子类实例复用可复用不可复用可复用
子类实例间是否共享父类的引用类型属性共享不共享不共享
子类实例是否能向父类传参不能传参能传参能传参
优点父类方法可被子类实例复用子类实例间独享父类引用类型属性;子类实例能向父类传参前两者优点都有
缺点子类实例间共享父类引用类型属性;子类实例不能向父类传参父类方法不可被子类实例复用父类属性重复调用

4、原型式继承

F.prototype = proto

return new F()

创建了一个空的构造函数,然后把它的 prototype 指向参数 proto,最后返回一个实例对象完成继承。原型式继承本质上是对参数对象的一个浅复制,ES5 的 Object.create() 函数,就是基于原型式继承的。

优点:父类方法可以被子类实例复用。

缺点:

  • 父类的引用属性会被所有子类实例共享。
  • 子类构建实例时不能向父类传递参数。

示例:

// clone 函数
function clone(proto) {
function F() { }
F.prototype = proto
return new F()
}

// 父类
var gz = {
city: "Guangzhou",
alias: ['Sui', 'Huacheng']
};

// 子类实例
var district1 = clone(gz);
var district2 = clone(gz);

// 缺点1:父类属性如果是引用类型则该属性会被子类实例共享
district1.city = 'GZC'
console.log(district1.city) // GZC
console.log(district2.city) // Guangzhou
district1.alias.push('Yangcheng')
console.log(district1.alias) // ['Sui', 'Huacheng', 'Yangcheng']
console.log(district2.alias) // ['Sui', 'Huacheng', 'Yangcheng']

console.log(gz) // {city: 'Guangzhou', alias: ['Sui', 'Huacheng', 'Yangcheng']}

上面代码中的 clone 函数可转变为 Object.create

var district2 = clone(gz);
// =>
var district2 = Object.create(gz);

原型式继承的优点体现在父类方法可以被子类复用,例如:

// clone 函数
function clone(proto) {
function F() { }
F.prototype = proto
return new F()
}

// 父类
var gz = {
city: "Guangzhou",
alias: ['Sui', 'Huacheng'],
sayNum: () => { console.log('020') }
};

// 父类函数
gz.sayNum = function () { console.log('020') }

// 子类实例
var district1 = clone(gz);
console.log(district1.sayNum) // ƒ () { console.log('020') }

子类实例 district1 的打印结果:

从打印结果可以看出,父类的函数 sayNum 会被子类实例复用。

5、寄生式继承

F.prototype = proto

let f = new F()

f.fn = ...

return new F()

在原型式继承的基础上,创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回构造函数。

优缺点:与原型式继承相同。

示例:

function cloneX(proto) {
function F() { }
F.prototype = proto
// 为构造函数新增属性/方法,以增强函数
let f = new F()
f.sayNum = function () {
console.log('020')
}
return f
}

// 父类
var gz = {
city: "Guangzhou",
alias: ['Sui', 'Huacheng']
};

// 子类实例
var district1 = cloneX(gz);
var district2 = cloneX(gz);

// 缺点1:父类属性如果是引用类型则该属性会被子类实例共享
district1.city = 'GZC'
console.log(district1.city) // GZC
console.log(district2.city) // Guangzhou
district1.alias.push('Yangcheng')
console.log(district1.alias) // ['Sui', 'Huacheng', 'Yangcheng']
console.log(district2.alias) // ['Sui', 'Huacheng', 'Yangcheng']

console.log(gz) // {city: 'Guangzhou', alias: ['Sui', 'Huacheng', 'Yangcheng']}

子类实例 district1 的打印结果:

6、寄生组合继承

inherit(District, City)

前面的组合继承会两次调用父类的构造函数造成浪费,而寄生组合继承就可以解决这个问题,寄生组合继承就是结合构造函数传递参数和寄生式继承来实现继承。

优点:

  • 父类的方法可以被复用。
  • 父类的引用属性不会被共享。
  • 子类构建实例时可以向父类传递参数。

缺点:这是最成熟的方法,也是现在库实现继承的方法。

示例:

// inherit 函数
function inherit(child, father) {
var prototype = Object.create(father.prototype) // 创建对象,创建了父类原型的浅复制
prototype.constructor = child // 增强对象,弥补因重写原型而失去的默认的 constructor 属性
child.prototype = prototype // 指定对象,将新创建的对象赋值给子类的原型
}

// 父类
function City(district) {
this.city = 'Guangzhou'
this.alias = ['Sui', 'Huacheng']
this.district = district
}

// 父类函数
City.prototype.sayNum = function () { console.log('020') }

// 子类 & 构造函数继承
function District(name) {
City.call(this, name)
}

// 将父类原型指向子类
inherit(District, City)

// 使用
var district1 = new District("Haizhu")
var district2 = new District("Tianhe")

// 优点1:父类的方法可以被复用
console.log(district1.sayNum) // ƒ () { console.log('020') }

// 优点2:父类的引用属性不会被共享(实现引用属性独享)
district1.city = 'GZC'
console.log(district1.city) // GZC
console.log(district2.city) // Guangzhou
district1.alias.push('Yangcheng')
console.log(district1.alias) // ['Sui', 'Huacheng', 'Yangcheng']
console.log(district2.alias) // ['Sui', 'Huacheng']

// 优点3:子类构建实例时可以向父类传递参数
console.log(district1.name) // undefined
console.log(district1.district) // Haizhu

console.log(City)
// ƒ City(district) { this.city = 'Guangzhou' this.alias = ['Sui', 'Huacheng'] this.district = district }

子类实例 district1 的打印结果:

从打印结果可以看出,寄生组合继承解决了组合继承调用两次父类构造函数的缺点。

7、ES6 Class extends

class Child extends Parent

constructor() { super() }

ES6 Class extends 继承的结果与寄生组合继承相似,本质上,ES6 继承是一种语法糖。但寄生组合继承是先创建子类实例 this 对象,然后再对其增强;而 ES6 先将父类实例对象的属性和方法加到 this 上(所以必须先调用 super 方法),再用子类的构造函数修改 this。

super 的作用:将父类的 this 对象继承给子类。

与 ES5 继承的异同

相同点:本质上 ES6 继承是 ES5 继承的语法糖

不同点:

  • ES6 继承中子类的构造函数的原型链指向父类的构造函数,ES5 中使用的是构造函数复制,没有原型链指向。
  • ES6 子类实例的构建,基于父类实例,ES5 中不是。

实现原理:

class A {
}

class B {
}

Object.setPrototypeOf = function (obj, proto) {
obj.__proto__ = proto;
return obj;
}

Object.setPrototypeOf(B.prototype, A.prototype);

Object.setPrototypeOf(B, A);

示例:

// 父类
class City {
constructor(district) {
this.city = 'Guangzhou'
this.alias = ['Sui', 'Huacheng']
this.district = district
this.sayNum = () => { console.log('020') }
}
}

// 子类 & ES6 Class extends 继承
class District extends City {
constructor(name) {
super(name);
}
}

// 使用
var district1 = new District("Haizhu")
var district2 = new District("Tianhe")

// 优点1:父类的方法可以被复用
console.log(district1.sayNum) // () => { console.log('020') }

// 优点2:父类的引用属性不会被共享(实现引用属性独享)
district1.city = 'GZC'
console.log(district1.city) // GZC
console.log(district2.city) // Guangzhou
district1.alias.push('Yangcheng')
console.log(district1.alias) // ['Sui', 'Huacheng', 'Yangcheng']
console.log(district2.alias) // ['Sui', 'Huacheng']

// 优点3:子类构建实例时可以向父类传递参数
console.log(district1.name) // undefined
console.log(district1.district) // Haizhu

console.log(City)
// class City { constructor(district) { this.city = 'Guangzhou' this.alias = ['Sui', 'Huacheng'] this.district = district } }

3. 总结

原型链继承构造函数继承组合继承原型式继承寄生式继承寄生组合继承ES6 Class extends
父类方法是否可被子类实例复用可复用不可复用可复用可复用可复用可复用可复用
子类实例间是否共享父类的引用类型属性共享不共享不共享共享共享不共享不共享
子类实例是否能向父类传参不能传参能传参能传参不能传参不能传参能传参能传参