前端知库

ES5 有哪些继承方式?为什么寄生组合继承最好?

原型链继承会让所有实例共享父类的引用属性,构造函数继承拿不到父类原型上的方法。组合继承两者都解决了,但父类构造函数被调用两次。寄生组合继承用 Object.create(Parent.prototype) 连接原型链,只调用一次父构造函数,ES6 的 class extends 做的就是这件事。

参考答案

简答

继承要做到两件事:子类实例有自己的一份父类属性,比如每只狗有自己的 name 和 tags;子类实例能用父类原型上的方法,比如 eat。ES5 的几种继承方式,就是围绕这两件事一步步改进的。

原型链继承是 Child.prototype = new Parent(),方法能用了,可是父类的属性挂在子类原型上,被所有实例共享,一只狗往 tags 里加东西,所有狗都变了。构造函数继承是在子类构造函数里 Parent.call(this),每个实例有了自己的属性,可是父类原型上的方法拿不到。组合继承把两者合起来,属性和方法都对了,但父类构造函数被调用了两次,子类原型上还多出一份没用的属性。

寄生组合继承把 Child.prototype = new Parent() 换成 Child.prototype = Object.create(Parent.prototype)。Object.create 只创建一个以父类原型为原型的空对象,不执行父类构造函数,于是父构造函数只在 Parent.call(this) 时调用一次,原型链也连上了。再把 constructor 指回子类,就是 ES5 里最完整的写法。

要注意,现在直接用 class 和 extends,它在原型链上的效果和寄生组合继承一致,还额外连上了构造函数之间的原型链,让子类能继承父类的静态方法。

前几种写法各有什么问题

JavaScript
function Animal(name) {
  this.name = name;
  this.tags = [];
}
Animal.prototype.eat = function () {
  return `${this.name} 在吃`;
};

// 原型链继承
function Dog1() {}
Dog1.prototype = new Animal('默认');
const a = new Dog1();
const b = new Dog1();
a.tags.push('黑色');
console.log(b.tags); // ['黑色'],被共享了

// 构造函数继承
function Dog2(name) {
  Animal.call(this, name);
}
console.log(typeof new Dog2('小黑').eat); // undefined,拿不到原型方法

原型链继承里,tags 只在 new Animal('默认') 时创建了一次,存在 Dog1.prototype 上。a.tags.push 没有给 a 新建属性,而是沿原型链找到这个共享数组改了它,所以 b.tags 也变了。而且所有实例的 name 都是“默认”,创建子类实例时没法给父类传参。

构造函数继承里,Animal.call(this, name) 把父类的属性赋到了每个实例自己身上,问题解决了。可是 Dog2.prototype 和 Animal.prototype 没有任何联系,eat 自然找不到。

寄生组合继承

JavaScript
function Animal(name) {
  this.name = name;
  this.tags = [];
}
Animal.prototype.eat = function () {
  return `${this.name} 在吃`;
};

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function () {
  return `${this.name} 汪汪`;
};

const a = new Dog('小黑', '柴犬');
const b = new Dog('小白', '柯基');
a.tags.push('黑色');

console.log(b.tags.length); // 0
console.log(a.eat()); // 小黑 在吃
console.log(a.bark()); // 小黑 汪汪
console.log(a instanceof Animal); // true
console.log(a.constructor === Dog); // true
console.log(Object.hasOwn(Dog.prototype, 'name')); // false

b.tags.length 是 0,因为每个实例都通过 Animal.call(this, name) 拿到了自己的 tags。a.eat() 能调用,因为查找路线是 a → Dog.prototype → Animal.prototype,eat 在最后这一层。

最后一行是和组合继承的区别。组合继承写的是 Dog.prototype = new Animal(),这次调用会在 Dog.prototype 上留下一份 name 和 tags,虽然被实例自己的属性挡住用不到,但白白多占内存、多调用一次构造函数。Object.create 不调用构造函数,Dog.prototype 上就干干净净的。

为什么要写 Dog.prototype.constructor = Dog?因为整个原型对象被换掉了,新对象上没有 constructor,沿原型链会找到 Animal。不改的话 a.constructor 是 Animal,依赖它的代码会出错。

原型链本身怎样工作,见《原型链、class 和继承之间是什么关系?》。

常见追问

class extends 和寄生组合继承完全一样吗?

实例方法的原型链效果一样。另外 extends 还会执行 Object.setPrototypeOf(Dog, Animal),让子类构造函数能继承父类的静态方法;子类构造函数里必须先调用 super() 才能用 this;class 也不能不带 new 调用。

什么是原型式继承和寄生式继承?

原型式继承就是 Object.create(obj),以一个现有对象为原型创建新对象,适合简单地复用一个对象。寄生式继承是在原型式继承的基础上,再给新对象添加一些方法后返回。寄生组合继承的名字就来自用这种方式处理原型。