捋一遍 JavaScript 的继承

近几日,在知乎上关于JS 是否是真 OOP 的话题讨论得热火朝天。对于新手来说,围观这样的技术争论还是能有很多收获的。围观过程中,发现自己对 JavaScript 的继承掌握得还不够牢固。故借这样一个时机,再捋一遍 JavaScript 的继承知识点。

继承( inheritance )

什么是继承? Wikipedia 上这样写道:

继承(英语:inheritance)是面向对象软件技术当中的一个概念。如果一个类别A“继承自”另一个类别B,就把这个A称为“B的子类别”,而把B称为“A的父类别”也可以称“B是A的超类”。继承可以使得子类别具有父类别的各种属性和方法,而不需要再次编写相同的代码。在令子类别继承父类别的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能。另外,为子类别追加新的属性和方法也是常见的做法。

许多 OO 语言如 Java 都支持两种继承方式:

  • 接口继承
  • 实现继承

JavaScript 只支持实现继承,而且其实现基础主要是原型链来实现的。

从原型链说起

prototype[[Prototype]]__proto__

  • 对象的prototype属性是一个指向其原型对象的指针。

  • 对象实例( Instance )内部的[[Prototype]]属性是一个指向该对象的原型对象的指针,它是一个特殊的、隐形的属性,无法读取。

  • __proto__[[Prototype]]的一个setter版本,通过如下方式可以设置一个实例的[[Prototype]]的指向:

    function Foo() {};
    instance.__proto__ = Foo.prototype;
    

为避免混淆,下文用:

  • Construtor 代表构造函数;constructor 代表指向构造函数的指针。
  • Prototype 代表原型对象;prototype 代表指向原型对象的指针。
  • [[Prototype]] 代表实例中指向原型对象的内部指针。

原型链的实现

首先回顾一下构造函数(Constructor)、原型(Prototype)和实例(Instance)三者之间的关系:每个构造函数都有一个原型对象,原型对象包含一个指针constructor,指向构造函数,而实例则包含一个指向原型对象的内部指针[[Prototype]]

原型链的基本概念是:让原型对象等于另一个引用类型的实例,此时原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。接着,另一个原型又是另一个引用类型的实例,上述关系依然成立,实例与原型如此链接起来,构成了原型链。

接下来,手动实现一个原型链:

function SuperFoo() {
    this.superBar = 'superBar';
}

SuperFoo.prototype.getSuperBar = function() {
    return this.superBar;
}

function SubFoo() {
    this.subBar = 'subBar';
}

// 通过让原型对象等于另一个类型的实例实现继承
SubFoo.prototype = new SuperFoo();

SubFoo.prototype.getSubBar = function() {
  return this.subBar;
}

let instance = new SubFoo();
console.log(instance);

// [object Object] {
//   getSubBar: function () {
//   return this.subBar;
// },
//   getSuperBar: function () {
//     return this.superBar;
// },
//   subBar: "subBar",
//   superBar: "superBar"
// }

这个例子的原型链通过可视化图示表示是这样的:

Prototype-Chain-Original

可以看到, SubFoo 的新原型对象不仅拥有 SuperFoo 实例的所有属性和方法,而且还包含一个[[Prototype]]指针指向 SuperFoo 的原型对象。这里需要注意的一点就是,SubFoo 的原型指向了 SuperFoo 的原型,而这个原型对象的constructor属性指向的是 SuperFoo,这使SubType.prototype.constructor指向了 SuperFoo:

> consle.log(SubType.prototype.constructor);

// function SuperFoo() {
//  this.superBar = 'superBar';
// }

原型搜索机制

当以读取模式访问一个实例属性时,首先会在实例中搜索该属性。如果没有找到该属性,则会继续搜索实例的原型,以此方式沿着原型链向上,直到找到该属性为止。若前行到原型链末端仍然找不到该属性,则返回undefined

引用类型的默认原型

所有引用类型默认继承的了 Object,而这个继承也是通过原型链实现的,也就是说,所有函数的默认原型都是 Object 的实例,我们可以完善一下上面的原型链图示:

ProtoType-Chain-With-Default-ProtoType

SubFoo 继承了 SuperFoo, SuperFoo则继承了 Object。当调用,instance.toString()时,JavaScript 会沿着原型链向上,在Object.prototype中找到toString()方法。

JavaScript 继承的几种模式

组合继承( Combination Inheritance )

在生产环境中,如果只通过原型链的原生继承,是无法写出好的 OO 范式代码的。原因有以下两点:

  1. 当一个原型对象中包含值为引用类型的属性时,这个属性会被所有实例共享;并且,即使该属性被定义在构造函数中,通过原型来实现继承时,原先的构造函数中的属性也会变成现在的原型属性,被所有实例所共享。

    我们可以通过一个例子体验一下:

    function SuperFoo() {
        this.superBar = 'superBar';
        this.arr = [1, 2, 3, 4];
    }
    
    SuperFoo.prototype.getSuperBar = function() {
        return this.superBar;
    }
    
    function SubFoo() {
        this.subBar = 'subBar';
    }
    
    SubFoo.prototype = new SuperFoo();
    
    SubFoo.prototype.getSubBar = function() {
      return this.subBar;
    }
    
    let instanceA = new SubFoo();
    instanceA.arr.push(5);
    console.log(instanceA.arr); // [1, 2, 3, 4, 5]
    
    let instanceB = new SubFoo();
    instanceB.arr.push(6);
    console.log(instanceB.arr); // [1, 2, 3, 4, 5, 6]
    
    
  2. 在创建子类型的实例时,不能向父类型的构造函数中传参。

在原型链的基础上,我们在创建子类时,借用父类型的构造函数,便可以解决以上两个问题:

function SuperFoo(color) {
 this.color = color;
 this.tagList = ['a', 'b', 'c', 'd'];
}

SuperFoo.prototype.getColor = function() {
 return this.color;
}

function SubFoo(color, size) {
+   SuperFoo.call(this, color);
 this.size = size;
}

SubFoo.prototype = new SuperFoo();
+ SubFoo.prototype.constructor = SubFoo;
SubFoo.prototype.getSize = function() {
return this.size;
}

let instanceA = new SubFoo('green', 20);
instanceA.tagList.push('e');
console.log(instanceA.tagList); // ['a', 'b', 'c', 'd', 'e'];
console.log(instanceA.getSize()); // 20
console.log(instanceA.getColor()); // 'green'

let instanceB = new SubFoo('black', 0);
instanceB.tagList.push('f');
console.log(instanceB.tagList); // ['a', 'b', 'c', 'd', 'f'];
console.log(instanceB.getSize()); // 0
console.log(instanceB.getColor()); // 'black'

在这里,我们借助父类的构造函数来实现继承实例属性,使用原型链实现对原型属性和方法的继承。这样一来,各个实例的实例互不影响,而且可以在创建子类型的实例时,可以向父类型的构造函数中传参。

我们称这种继承模式为「组合继承」。组合继承并不是完美的,它也有个缺陷:会调用两次父类型的构造函数。

在上面的例子中指的是以下两次:

  • SuperFoo.call(this, color);
  • SubFoo.prototype = new SuperFoo();

这样会产生两组colortagList属性,一组在SubFoo的实例上,一组在SubFoo的 Prototype 上。

原型式继承( Prototypal Inheritance )

有时我们需要用到继承的需求可能仅仅是只想让一个对象与另一个对象保持一致,所以并不需要去创建构造函数,只需基于已有的对象创建新的对象即可。可以通过这样一个函数实现:

function object(obj) {
    function F() {};
    F.prototype = obj;
    return new F();
}

这个函数相对于对obj进行了一次浅复制( Shallow Copy )。通过object基于传入的对象返回一个新对象,这个新对象将传入的对象作为原型。

这个函数由 Douglas Crockford 在 2006 年提出,后来 ES5 通过新增的Object.create()方法规范了原型式继承。这个方法接收两个参数,第一个是用作新对象的原型对象的对象,第二参数(可选)为新对象定义额外属性的对象。仅有一个参数时,Object.create()行为与object函数相同。

let superFoo = {
    color: 'green',
    size: 'small'
};

let subFoo = Object.create(superFoo, {
    tag: {
        value: 'a'
    }
});
subFoo.color = 'yellow';

console.log(subFoo); // {color: "yellow", tag: "a"}
console.log(subFoo.size); // 'small'

当然,也可以使用Object.create()实现之前的类式继承:

function SuperFoo(color) {
    this.color = color;
    this.tagList = ['a', 'b', 'c', 'd'];
}

SuperFoo.prototype.getColor = function() {
    return this.color;
}

function SubFoo(color, size) {
    SuperFoo.call(this, color);
    this.size = size;
}

SubFoo.prototype = Object.create(SuperFoo.prototype);
SubFoo.prototype.getSize = function() {
  return this.size;
}

let instance = new SubFoo('green', 20);
instance.tagList.push('7');
console.log(instance); // {color: "green", tagList: Array(4), size: 20}
console.log(instance instanceof SuperFoo); // true

寄生式继承( Parasitic Inheritance )

寄生式继承进一步拓展了原生式继承,它在后者的继承上再封装一个函数,用于enhance新对象:

function createSubOne(superOne) {
    let subOne = object(superOne);
    subOne.printInfo = function() {
        console.log('info');
    }
    return subOne;
}

这里要注意的是,当调用createSubOne时,printInfo会被重复创建,无法做到复用。这是寄生式继承的缺陷。

寄生组合式继承( Parasitic Combination Inheritance )

寄生组合式继承的思想是:借用父类型构造函数来继承属性,通过寄生式继承来继承方法。它解决了组合继承重复调用父类构造函数的问题。寄生组合式继承的基本模式如下:

function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

接下来,我们用寄生组合式继承来改写之前的组合继承式代码:

function SuperFoo(color) {
    this.color = color;
    this.tagList = ['a', 'b', 'c', 'd'];
}

SuperFoo.prototype.getColor = function() {
    return this.color;
}

function SubFoo(color, size) {
    SuperFoo.call(this, color);
    this.size = size;
}

+ inheritPrototype(SubFoo, SuperFoo);
SubFoo.prototype.getSize = function() {
  return this.size;
}

let instance = new SubFoo('green', 20);

这时,SubFoo 的原型对象相较于组合继承发生了如下变化,避免了在SubType.prototype上面创建不必要、多余的属性:

PCI

ES6 中的 class

ES6 中引入了class,首先要明确的一点是,这不是什么新鲜事物,class它只是提供了一个更方便的语法来创建构造函数,本质上就是语法糖。

在 ES5 中,我们这样定义一个“类”:

function Foo(x, y) {
    this.x = x;
    this.y = y
}

Foo.prototype.describe = function () {
    return this.x + ',' + this.y;
}

而使用 ES6 语法:

class Foo {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `${this.x}, ${this.y}`;
    }
}

声明 class 并不发生变量提升

虽然 class 本质上就是Function,但是与声明函数不同,声明class并不发生提升(hoist)。

foo(); // no error

function foo() {};

new Foo(); // ReferenceError

class Foo {}

这是因为class可以extends关键词配合实现继承,而子类必须在父类之后才能声明,所以才会有此限制。

ES6 中的继承写法

使用 ES5 写一段继承代码是十分麻烦的,并且代码可读性不高。使用 ES6 实现就很优雅了, 继承通过extends关键词实现,继承父类实例属性通过super关键词实现。可以把之前寄生组合式继承的示例代码改造一下:

class SuperFoo {
    constructor(color) {
        this.color = color;
        this.tagList = ['a', 'b', 'c', 'd'];
    }
    
    getColor() {
        return this.color;
    }
}

class SubFoo extends SuperFoo {
    constructor(color, size) {
        super(color);
        this.size = size;
    }
    
    getSize() {
        return this.size;
    }
}

console.log(Object.getPrototypeOf(SubFoo) === SuperFoo); // true

在子类中,若要调用父类方法或属性,也可以通过super关键字:

class SubFoo extends SuperFoo {
    // ....
    
    printColor() {
        console.log(super.getColor());
    }
}

小结

想要真正了解 JavaScript 的继承机制和它的面向对象的特性,原型机制和原型链是一定要掌握的,即使有了 ES6 的class也不能忽略这部分知识点。

回顾了一下 JavaScript 的原型机制和继承之后。针对「JS 是否是真的 OOP 」这个问题,我谈谈自己的看法:

相较于其他面向对象的语言,JavaScript 中的确不存在真正的「类」,ES6中的class也只是原型链的语法糖。但是 OOP 首先是一种设计思想,它并不规定一个固定的实现方案,所以不能因为「 JavaScript 的继承是基于原型链的」、「ES6 中的class只是语法糖,不是真正的类」等原因就认为 JavaScript 的 OOP 是假的。况且,这门语言本身的运行 API 大部分也是基于类的风格设计的。

当然,除了 OO 以外,JavaScript 还支持函数式编程( Functional Programming )以及面向过程( Procedure Oriented )的编程范式。

参考资料

Show Comments