Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript/extends #17

Open
canvascat opened this issue Apr 19, 2021 · 0 comments
Open

JavaScript/extends #17

canvascat opened this issue Apr 19, 2021 · 0 comments

Comments

@canvascat
Copy link
Owner

canvascat commented Apr 19, 2021

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");

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant