Skip to content

JavaScript/extends #17

@canvascat

Description

@canvascat

JavaScript 的继承与实现

虽然平时并不用在乎继承的实现原理,但面试的时候就是喜欢问。👴

ES6 中出现的class给人错觉——js 基于类来实现继承。然而class只是个语法糖,javascript 实际上还是基于原型进行继承。

这是因为 JavaScript 中有个特殊的存在:对象。每个对象还都拥有一个原型对象,并可以从中继承方法和属性。

原型对象和对象是什么关系

在 JavaScript 中,对象由一组或多组的属性和值组成:

在 JavaScript 中,对象的用途很是广泛,因为它的值既可以是原始类型(numberstringbooleannullundefinedbigintsymbol),还可以是对象和函数。

不管是对象,还是函数和数组,它们都是Object的实例,也就是说在 JavaScript 中,除了原始类型以外,其余都是对象。

在 JavaScript 中,函数也是一种特殊的对象,它同样拥有属性和值。所有的函数会有一个特别的属性prototype,该属性的值是一个对象,这个对象便是我们常说的“原型对象”。

function Person(name) {
  this.name = name;
}
console.log(Person.prototype);

image

可以看到,该原型对象有两个属性:constructor__proto__

在 JavaScript 中,__proto__属性指向对象的原型对象,对于函数来说,它的原型对象便是prototype。函数的原型对象prototype有以下特点:

  • 默认情况下,所有函数的原型对象(prototype)都拥有constructor属性,该属性指向与之关联的构造函数,在这里构造函数便是Person函数;
  • Person函数的原型对象(prototype)同样拥有自己的原型对象,用__proto__属性表示。前面说过,函数是Object的实例,因此Person.prototype的原型对象为Object.prototype。

我们可以用这样一张图来描述prototype__proto__constructor三个属性的关系:

img

从这个图中,我们可以找到这样的关系:

  • 在 JavaScript 中,__proto__属性指向对象的原型对象;
  • 对于函数来说,每个函数都有一个prototype属性,该属性为该函数的原型对象。

使用 prototype__proto__ 实现继承

对象的属性值可以为任意类型,可以为另外一个对象。这也意味着 JavaScript 可以通过将对象 A 的__proto__属性赋值为对象 B(即A.__proto__ = B),来使用A.__proto__访问 B 的属性和方法。

通过这种方式,JavaScript 可以在两个对象之间创建一个关联,使得一个对象可以访问另一个对象的属性和方法,从而实现了继承。

继续以Person为例,当我们使用new Person()创建对象时,JavaScript 就会创建构造函数Person的实例,比如这里我们创建了一个叫“Tom”的Person

const tom = new Person("Tom");

上述这段代码在运行时,JavaScript 引擎通过将Person的原型对象prototype赋值给实例对象tom__proto__属性,实现了tomPerson的继承,即执行了以下代码:

const tom = {};
tom.__proto__ = Person.prototype;
Person.call(tom, "Tom");

Tom

可以看到,tom作为Person的实例对象,它的__proto__指向了Person的原型对象,即Person.prototype

Tom prototype

构造函数和constructor属性、原型对象(prototype)和__proto__、实例对象之间的关系:

  1. 每个函数的原型对象(Person.prototype)都拥有constructor属性,指向该原型对象的构造函数(Person);
  2. 使用构造函数(new Person())可以创建对象,创建的对象称为实例对象(tom);
  3. 实例对象通过将__proto__属性指向构造函数的原型对象(Person.prototype),实现了该原型对象的继承。

__proto__prototype的关系:

  • 每个对象都有__proto__属性来标识自己所继承的原型对象,但只有函数才有prototype属性,且该属性为该函数的原型对象;
  • 通过将实例对象的__proto__属性赋值为其构造函数的原型对象prototype,JavaScript 可以使用构造函数创建对象的方式,来实现继承。

一个对象可通过__proto__访问原型对象上的属性和方法,而该原型同样也可通过__proto__访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。tom实例的原型链:

tom --**proto**--> Person.prototype --**proto**--> Object.prototype --**proto**--> null

所以在 JavaScript 中,是通过遍历原型链的方式,来访问对象的方法和属性。

通过原型链访问对象的方法和属性

当 JavaScript 试图访问一个对象的属性时,会基于原型链进行查找。

首先会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象;遍历访问对象的整个原型链后如果最终依然找不到,此时会认为该对象的属性值为undefined

JavaScript 中的所有对象都来自ObjectObject.prototype.__proto__ === nullnull没有原型,并作为这个原型链中的最后一个环节。

const o = { a: 1, b: 2 };
const p = { b: 3, c: 4 };
o.__proto__ = p;
// {a:1, b:2} ---> {b:3, c:4} ---> null
console.log(o.a); // 1
console.log(o.b); // 2
console.log(o.c); // 4
console.log(o.d); // undefined

可以看到,当我们对对象进行属性值的获取时,会触发该对象的原型链查找过程。

比如当调用tom.toString()时,JavaScript 引擎会进行以下操作:

  1. 先检查tom对象是否具有可用的toString()方法;
  2. 如果没有,则检查tom的原型对象(Person.prototype)是否具有可用的toString()方法;
  3. 如果也没有,则检查Person()构造函数的prototype属性所指向的对象的原型对象(即Object.prototype)是否具有可用的toString()方法,于是该方法被调用。

由于通过原型链进行属性的查找,需要层层遍历各个原型对象,此时可能会带来性能问题:

  • 当试图访问不存在的属性时,会遍历整个原型链;
  • 在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。

因此,我们在设计对象的时候,需要注意代码中原型链的长度。当原型链过长时,可以选择进行分解,来避免可能带来的性能问题。

JS 实现继承的几种方式

上述通过原型链的方式实现 JavaScript 继承的例子中,如果将 p.c 修改为其他值,那么 o.c 同样会改变,这也是

除了通过原型链的方式实现 JavaScript 继承,JavaScript 中实现继承的方式还包括经典继承(盗用构造函数)、组合继承、原型式继承、寄生式继承,等等。

  • 原型链继承方式中引用类型的属性被所有实例共享,无法做到实例私有;
  • 经典继承方式可以实现实例属性私有,但要求类型只能通过构造函数来定义;
  • 组合继承融合原型链继承和构造函数的优点,它的实现如下:
function Parent(name) {
  // 私有属性,不共享
  this.name = name;
}
// 需要复用、共享的方法定义在父类原型上
Parent.prototype.speak = function () {
  console.log("hello");
};
function Child(name) {
  Parent.call(this, name);
}
// 将子类的 __proto__ 指向父类原型
Child.prototype = Parent.prototype;

组合继承模式通过将共享属性定义在父类原型上、将私有属性通过构造函数赋值的方式,实现了按需共享对象和方法,是 JavaScript 中最常用的继承模式。

虽然在继承的实现方式上有很多种,但实际上都离不开原型对象和原型链的内容,因此掌握__proto__prototype、对象的继承等这些知识,是我们实现各种继承方式的前提。

第一种:原型链继承

原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。

function Parent1() {
  this.name = "parent1";
  this.play = [1, 2, 3];
}
function Child1() {
  this.type = "child2";
}
Child1.prototype = new Parent1();
console.log(new Child1());

上面的代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题。

const s1 = new Child1();
const s2 = new Child2();
s1.play.push(4);
console.log(s1.play, s2.play);
// [1, 2, 3, 4]
// [1, 2, 3, 4]

由于两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。

那么要解决这个问题的话,我们就得再看看其他的继承方式,下面我们看看能解决原型属性共享问题的第二种方法。

第二种:构造函数继承(借助 call)

直接通过代码来了解,如下所示。

function Parent1() {
  this.name = "parent1";
}
Parent1.prototype.getName = function () {
  return this.name;
};
function Child1() {
  Parent1.call(this);
  this.type = "child1";
}
const child = new Child1();
console.log(child);
console.log(child.getName());

image

可以看到最后打印的 child 在控制台显示,除了 Child1 的属性 type 之外,也继承了 Parent1 的属性 name。这样写的时候子类虽然能够拿到父类的属性值,解决了原型链继承的弊端,但问题是,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。

因此,从上面的结果就可以看到构造函数实现继承的优缺点,它使父类的引用属性不会被共享,优化了原型链继承的弊端;但缺点也比较明显——只能继承父类的实例属性和方法,不能继承原型属性或者方法。

第三种:组合继承(前两种组合)

这种方式结合了前两种继承方式的优缺点,结合起来的继承,代码如下。

function Parent3() {
  this.name = "parent3";
  this.play = [1, 2, 3];
}
Parent3.prototype.getName = function () {
  return this.name;
};
function Child3() {
  Parent3.call(this);
  this.type = "child3";
}
Child3.prototype = new Parent3();
Child3.prototype.constructor = Child3;
const s3 = new Child3();
const s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play);
console.log(s3.getName());
console.log(s4.getName());

执行上面的代码,可以看到输出结果,前两种方法的问题都得以解决。

image

但是这里又增加了一个新问题:通过注释我们可以看到 Parent3 执行了两次,第一次是改变 Child3prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,那么 Parent3 多构造一次就多进行了一次性能开销,这是我们不愿看到的。

第四种:原型式继承

这里不得不提到的就是 ES5 里面的 Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。

const parent4 = {
  name: "parent4",
  friends: ["p1", "p2", "p3"],
  getName: function () {
    return this.name;
  },
};
const person4 = Object.create(parent4);
person4.name = "tom";
person4.friends.push("jerry");
const person5 = Object.create(parent4);
person5.friends.push("lucy");
console.log(person4.name);
console.log(person4.name === person4.getName());
console.log(person5.name);
console.log(person4.friends);
console.log(person5.friends);

image

从上面的代码中可以看到,通过 Object.create 这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承 getName 的方法。但是最后两个输出相同,可以看出它的弊端和原型链继承类似,多个实例的引用类型属性指向相同的内存,存在篡改的可能。原因也很简单,其实 Object.create 方法是可以理解为为一些对象实现浅拷贝。

第五种:寄生式继承

使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。

虽然其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。

const parent5 = {
  name: "parent5",
  friends: ["p1", "p2", "p3"],
  getName: function () {
    return this.name;
  },
};
function clone(original) {
  const clone = Object.create(original);
  clone.getFriends = function () {
    return this.friends;
  };
  return clone;
}
const person5 = clone(parent5);
console.log(person5.getName());
console.log(person5.getFriends());

通过上面这段代码,我们可以看到 person5 是通过寄生式继承生成的实例,它不仅仅有 getName 的方法,而且可以看到它最后也拥有了 getFriends 的方法,结果如下图所示。

image

从最后的输出结果中可以看到,person5 通过 clone 的方法,增加了 getFriends 的方法,从而使 person5 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。

我在上面第三种组合继承方式中提到了一些弊端,即两次调用父类的构造函数造成浪费,下面要介绍的寄生组合继承就可以解决这个问题。

第六种:寄生组合式继承

结合 原型式继承组合继承 方式:

function clone(parent, child) {
  child.prototype = Object.create(parent.prototype);
  child.prototype.constructor = child;
}
function Parent6() {
  this.name = "parent6";
  this.play = [1, 2, 3];
}
Parent6.prototype.getName = function () {
  return this.name;
};
function Child6() {
  Parent6.call(this);
  this.friends = "child5";
}
clone(Parent6, Child6);
Child6.prototype.getFriends = function () {
  return this.friends;
};
const person6 = new Child6();
console.log(person6);
console.log(person6.getName());
console.log(person6.getFriends());

image

通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销。

从代码的执行结果可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。

ES6 的 extends 关键字实现逻辑

我们可以利用 ES6 里的 extends 的语法糖,使用关键词很容易直接实现 JavaScript 的继承。

class Animal {
  speed: number;
  name: string;
  constructor(name: string) {
    this.speed = 0;
    this.name = name;
  }
  run(speed: number) {
    this.speed = speed;
    alert(`${this.name} runs with speed ${this.speed}.`);
  }
  stop() {
    this.speed = 0;
    alert(`${this.name} stands still.`);
  }
}

class Rabbit extends Animal {
  constructor(name: string) {
    super(name);
    this.speed = 9;
  }
  hide() {
    alert(`${this.name} hides!`);
  }
}

let animal = new Animal("My animal");

通过使用 tsc 将 typescript 代码转换为 ES5 代码,来了解 extends 语法糖的底层逻辑。

"use strict";
var __extends = (this && this.__extends) || (function () {
  var extendStatics = function (d, b) {
    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf
    extendStatics = Object.setPrototypeOf ||
      ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
      function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
    return extendStatics(d, b);
  };
  return function (d, b) {
    if (typeof b !== "function" && b !== null)
      throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
    extendStatics(d, b);
    // 令构造函数d的prototype的constructor等于自身
    function __ () { this.constructor = d; }
    // 令__实例的__proto__等于b.prototype
    d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    // 最后new __()产生的对象含有一个指向d的constructor属性以及一个指向b.prototype的__proto__属性
  };
})();
var Animal = /** @class */ (function () {
  function Animal (name) {
    this.speed = 0;
    this.name = name;
  }
  Animal.prototype.run = function (speed) {
    this.speed = speed;
    alert(this.name + " runs with speed " + this.speed + ".");
  };
  Animal.prototype.stop = function () {
    this.speed = 0;
    alert(this.name + " stands still.");
  };
  return Animal;
}());
var Rabbit = /** @class */ (function (_super) {
  __extends(Rabbit, _super);
  function Rabbit (name) {
    var _this = _super.call(this, name) || this;
    _this.speed = 9;
    return _this;
  }
  Rabbit.prototype.hide = function () {
    alert(this.name + " hides!");
  };
  return Rabbit;
}(Animal));
var animal = new Animal("My animal");

从编译得到的代码中可以看到,它采用的也是寄生组合继承方式。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions