Fork me on GitHub

整理JavaScript继承方案

构造函数、原型、实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的指针。

一、原型链继承

原型链继承的本质就是重写原型对象,代之以一个新类型的实例。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Animal() {
this.value = 'animal';
}
Animal.prototype.run = function () {
console.log(this.value + ' is running');
}

function Cat() {}
// 创建 Animal 的实例,并将该实例赋值给 Cat.prototype
// 相当于 Cat.prototype.__proto__ = Animal.prototype
Cat.prototype = new Animal();

let cat = new Cat();
cat.value = 'cat'
console.log(cat.run()); // cat is running

原型链继承方案有几个缺点:

  1. 多个实例对引用类型的操作会被篡改
  2. 子类型的原型上的constructor属性被改写
  3. 给子类型原型添加属性和方法必须在替换原型之后
  4. 创建子类型实例时无法向父类型的构造函数传参

问题1

原型链继承方案中,原型实际上会变成另一个类型的实例,如下代码,Cat.prototype 变成了 Animal 的一个实例,所以 Animal 的实例属性 names 就变成了 Cat.prototype 的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Animal(){
this.names = ["cat", "dog"];
}
function Cat(){}

Cat.prototype = new Animal();

var instance1 = new Cat();
instance1.names.push("tiger");
console.log(instance1.names); // ["cat", "dog", "tiger"]

var instance2 = new Cat();
console.log(instance2.names); // ["cat", "dog", "tiger"]

问题2

子类型原型上的constructor属性被重写,Cat.prototype 上丢失了 constructor 属性, Cat.prototype 指向了 Animal.prototype,而 Animal.prototype.constructor 指向了 Animal,所以 Cat.prototype.constructor 指向了 Animal。

1
2
Cat.prototype = new Animal(); 
Cat.prototype.constructor === Animal // true

这个问题解决办法很简单,就是重写 Cat.prototype.constructor 属性,指向自己的构造函数 Cat。

1
2
3
4
5
6
7
8
function Animal(){
this.names = ["cat", "dog"];
}
function Cat(){}

Cat.prototype = new Animal();
//重写 Cat.prototype 的 constructor 属性,指向自己的构造函数 Cat
Cat.prototype.constructor = Cat;

问题3

给子类型原型添加属性和方法必须在替换原型之后,因为子类型的原型会被覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Animal() {
this.value = 'animal';
}
Animal.prototype.run = function () {
console.log(this.value + ' is running');
}
function Cat() {}

Cat.prototype.say = function() {
console.log('miaomiao');
}

Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

var instance = new Cat();
instance.say();
// Uncaught TypeError: instance.say is not a function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Animal() {
this.value = 'animal';
}
Animal.prototype.run = function () {
console.log(this.value + ' is running');
}
function Cat() {}

Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

Cat.prototype.say = function() {
console.log('miaomiao');
}
var instance = new Cat();
instance.say(); // miaomiao

属性遮蔽

改造上面的代码,在 Cat.prototype 上添加 run 方法,但是 Animal.prototype 上也有一个 run 方法,不过它不会被访问到,这种情况称为属性遮蔽 (property shadowing)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Animal() {
this.value = 'animal';
}
Animal.prototype.run = function () {
console.log(this.value + ' is running');
}
function Cat() {}

Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

Cat.prototype.run = function () {
return 'cat ~~~';
}

var instance = new Cat();
instance.value = 'cat';
console.log(instance.run()); // cat ~~~

如何访问被遮蔽的属性?通过 proto 调用原型链上的属性即可

1
instance.__proto__.__proto__.run(); // undefined is running

原型链继承方案有很多问题,实践中很少会单独使用,日常工作中使用 ES6 Class extends(模拟原型)继承方案即可。

二、借用构造函数继承

使用父类的构造函数来增强子类实例,相当于复制父类的实例给子类(不使用原型)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Parent(){
this.color=[1, 2, 3];
}

function Son(){
//继承自Parent
Parent.call(this);
}

var s1 = new Son();
s1.color.push(4);
console.log(s1.color); // [1, 2, 3, 4]

var s2 = new Son();
console.log(s2.color); // [1, 2, 3]

核心代码是Parent.call(this),创建子类实例时调用父类构造函数,于是Son的每个实例都会将Parent中的属性复制一份。

缺点:

  • 只能继承父类的实例属性和方法,不能继承原型属性/方法
  • 无法实现复用,每个子类都有父类实例函数的副本,影响性能

三、组合继承

组合上述两种方法就是组合继承。用原型链实现对原型属性和方法的继承,用借用构造函数技术来实现实例属性的继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function Parent(name){
this.name = name;
this.color=[1, 2, 3];
}
Parent.prototype.sayName = function () {
console.log(this.name);
}

function Son(name, age){
//继承Parent属性,第二次调用Parent()
Parent.call(this, name);
this.age = age;
}

// 继承Parent方法,第一次调用Parent()
Son.prototype = new Parent();
Son.prototype.constructor = Son;

Son.prototype.sayAge = function () {
console.log(this.age);
}

var s1 = new Son('bob', 18);
s1.color.push(4);
console.log(s1.color); // [1, 2, 3, 4]
s1.sayName(); // bob
s1.sayAge(); // 18

var s2 = new Son('anne', 6);
console.log(s2.color); // [1, 2, 3]
s2.sayName(); // anne
s2.sayAge(); // 6

缺点:
两次调用父类构造函数

  • 第一次调用Parent():给Son.prototype写入两个属性name,color。
  • 第二次调用Parent():给实例s1写入两个属性name,color。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
s1
/*
Son {
age: 18
color: (4) [1, 2, 3, 4]
name: "bob",
__proto__: Parent
{
color: (3) [1, 2, 3],
constructor: ƒ Son(name, age)
name: undefined
sayAge: ƒ ()
__proto__:
{
sayName: ƒ ()
constructor: ƒ Parent(name)
__proto__: Object
}
}
}
*/

实例对象s1上的两个属性就屏蔽了其原型对象SubType.prototype的两个同名属性。所以,组合模式的缺点就是在使用子类创建实例对象时,其原型中会存在两份相同的属性/方法。

四、原型式继承

利用一个空对象作为中介,将某个对象直接赋值给空对象构造函数的原型。

1
2
3
4
5
function object(obj){
function F(){}
F.prototype = obj;
return new F();
}

object()对传入其中的对象执行了一次浅复制,将构造函数F的原型直接指向传入的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var Parent = {
name: "bob",
friends: [1, 2, 3]
};

var son1 = object(Parent);
son1.name = 'anne';
son1.friends.push(4);

var son2 = object(Parent);
son2.name = 'lala';
son2.friends.push(5);

console.log(Parent.friends); // [1, 2, 3, 4, 5]

缺点:

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

⚠️ ES5中存在Object.create()的方法,能够代替上面的object方法。

五、寄生式继承

核心:在原型式继承的基础上,增强对象,返回构造函数

1
2
3
4
5
6
7
8
9
function createObject(obj) {
function F() {};
F.prototype = obj;
var clone = new F();
clone.sayHi = function(){ // 以某种方式来增强对象
console.log("hi");
};
return clone;
}
1
2
3
4
5
6
var Parent = {
name: "bob",
friends: [1, 2, 3]
};
var son = createObject(Parent);
son.sayHi(); // hi

缺点:(同原型式继承)

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

六、寄生组合式继承

结合借用构造函数传递参数和寄生模式实现继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function inheritPrototype (subType, superType) {
// 创建对象,创建父类原型的一个副本
var prototype = Object.create(superType.prototype);
// 增强对象,弥补因重写原型而失去的默认的constructor 属性
prototype.constructor = subType;
// 指定对象,将新创建的对象赋值给子类的原型
subType.prototype = prototype;
}

// 父类初始化实例属性和原型属性
function Parent(name){
this.name = name;
this.color=[1, 2, 3];
}
Parent.prototype.sayName = function () {
console.log(this.name);
}
// 借用构造函数传递增强子类实例属性(支持传参和避免篡改)
function Son(name, age){
Parent.call(this, name);
this.age = age;
}

// 将父类原型指向子类
inheritPrototype(Son, Parent);

// 新增子类原型属性
Son.prototype.sayAge = function () {
console.log(this.age);
}

var s1 = new Son('bob', 18);
s1.color.push(4);
console.log(s1.color); // [1, 2, 3, 4]
s1.sayName(); // bob
s1.sayAge(); // 18

var s2 = new Son('anne', 6);
console.log(s2.color); // [1, 2, 3]
s2.sayName(); // anne
s2.sayAge(); // 6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
s1
/*
Son {
age: 18
color: (4) [1, 2, 3, 4]
name: "bob",
__proto__: Parent
{
constructor: ƒ Son(name, age),
name: undefined,
sayAge: ƒ (),
__proto__:
{
sayName: ƒ ()
constructor: ƒ Parent(name)
__proto__: Object
}
}
}
*/

优点:

  • 它只调用了一次SuperType 构造函数,并且因此避免了在SubType.prototype 上创建不必要的、多余的属性
  • 原型链保持不变;因此,还能够正常使用instanceof 和isPrototypeOf()

⚠️ 这是最成熟的方法,也是现在库实现的方法

七、混入方式继承多个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function MyClass() {
SuperClass.call(this);
OtherSuperClass.call(this);
}
// 继承一个类
MyClass.prototype = Object.create(SuperClass.prototype);
// 混合其它
Object.assign(MyClass.prototype, OtherSuperClass.prototype);
// 重新指定constructor
MyClass.prototype.constructor = MyClass;

MyClass.prototype.myMethod = function() {
// do something
};

Object.assign会把 OtherSuperClass原型上的函数拷贝到 MyClass原型上,使 MyClass 的所有实例都可用 OtherSuperClass 的方法。

八、es6类继承extends

extends关键字主要用于类声明或者类表达式中,以创建一个类,该类是另一个类的子类。其中constructor表示构造函数,一个类中只能有一个构造函数,有多个会报出SyntaxError错误,如果没有显式指定构造方法,则会添加默认的 constructor方法,使用例子如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Rectangle {
// constructor
constructor(height, width) {
this.height = height;
this.width = width;
}

// Getter
get area() {
return this.calcArea()
}

// Method
calcArea() {
return this.height * this.width;
}
}

const rectangle = new Rectangle(10, 20);
console.log(rectangle.area); // 200

// 继承
class Square extends Rectangle {
constructor(length) {
super(length, length);
// 如果子类中存在构造函数,则需要在使用“this”之前首先调用 super(),不然会报错。
this.name = 'Square';
}
get area() {
return this.height * this.width;
}
}
const square = new Square(10);
console.log(square.area); // 100

extends继承核心代码和寄生组合式继承方式相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function _inherits(subType, superType) {
// 创建对象,创建父类原型的一个副本
// 增强对象,弥补因重写原型而失去的默认的constructor 属性
// 指定对象,将新创建的对象赋值给子类的原型
subType.prototype = Object.create(superType && superType.prototype, {
constructor: {
value: subType,
enumerable: false,
writable: true,
configurable: true
}
});

if (superType) {
Object.setPrototypeOf
? Object.setPrototypeOf(subType, superType)
: subType.__proto__ = superType;
}
}

⚠️ 补充:Object.create语法

Object.create(proto, [propertiesObject])

参数:

  • proto:必须。新创建对象的原型对象。
  • propertiesObject: 可选。默认为undefined,则是要添加到新创建对象的可枚举属性(即其自身定义的属性,而不是其原型链上的枚举属性)对象的属性描述符以及相应的属性名称。这些属性对应Object.defineProperties()的第二个参数。

返回值:在指定原型对象上添加新属性后的对象。
如果propertiesObject参数不是 null 或一个对象,则抛出一个 TypeError 异常。

1
2
3
4
5
6
7
8
9
10
11
12
var a = {  rep : 'apple' }
var b = new Object(a)
console.log(b) // {rep: "apple"}
console.log(b.__proto__) // {}
console.log(b.rep) // {rep: "apple"}

// Object.create() 方式创建
var a = { rep: 'apple' }
var b = Object.create(a)
console.log(b) // {}
console.log(b.__proto__) // {rep: "apple"}
console.log(b.rep) // {rep: "apple"}

⚠️ 函数声明和类声明的区别

函数声明会提升,类声明不会。首先需要声明你的类,然后访问它,否则像下面的代码会抛出一个ReferenceError。

1
2
3
4
let p = new Rectangle(); 
// ReferenceError

class Rectangle {}

⚠️ ES5继承和ES6继承的区别

  • es5的继承实际上是先创建子类的实例对象,然后通过Parent.call(this)将父类的方法添加到this上
  • es6的继承有所不同,实质上是先创建父类的实例对象this,然后再用子类的构造函数修改this。因为子类没有自己的this对象,所以必须先调用父类的super()方法,否则新建实例报错。

参考文章:
https://juejin.im/post/5ca9cebb6fb9a05e505c5f81
https://github.com/yygmind/blog/issues/7
《javascript高级程序设计》第六章
http://es6.ruanyifeng.com/#docs/class-extends
MDN Object.create()

本文标题:整理JavaScript继承方案

文章作者:tongtong

发布时间:2019年03月19日 - 20:03

最后更新:2019年04月09日 - 19:04

原始链接:https://ilove-coding.github.io/2019/03/19/1-2整理JavaScript继承方案/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!
-------------本文结束-------------