We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
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
虽然平时并不用在乎继承的实现原理,但面试的时候就是喜欢问。👴
ES6 中出现的class给人错觉——js 基于类来实现继承。然而class只是个语法糖,javascript 实际上还是基于原型进行继承。
class
这是因为 JavaScript 中有个特殊的存在:对象。每个对象还都拥有一个原型对象,并可以从中继承方法和属性。
在 JavaScript 中,对象由一组或多组的属性和值组成:
在 JavaScript 中,对象的用途很是广泛,因为它的值既可以是原始类型(number、string、boolean、null、undefined、bigint和symbol),还可以是对象和函数。
number
string
boolean
null
undefined
bigint
symbol
不管是对象,还是函数和数组,它们都是Object的实例,也就是说在 JavaScript 中,除了原始类型以外,其余都是对象。
Object
在 JavaScript 中,函数也是一种特殊的对象,它同样拥有属性和值。所有的函数会有一个特别的属性prototype,该属性的值是一个对象,这个对象便是我们常说的“原型对象”。
prototype
function Person(name) { this.name = name; } console.log(Person.prototype);
可以看到,该原型对象有两个属性:constructor和__proto__。
constructor
__proto__
在 JavaScript 中,__proto__属性指向对象的原型对象,对于函数来说,它的原型对象便是prototype。函数的原型对象prototype有以下特点:
Person
Person.prototype
Object.prototype。
我们可以用这样一张图来描述prototype、__proto__和constructor三个属性的关系:
从这个图中,我们可以找到这样的关系:
对象的属性值可以为任意类型,可以为另外一个对象。这也意味着 JavaScript 可以通过将对象 A 的__proto__属性赋值为对象 B(即A.__proto__ = B),来使用A.__proto__访问 B 的属性和方法。
A.__proto__ = B
A.__proto__
通过这种方式,JavaScript 可以在两个对象之间创建一个关联,使得一个对象可以访问另一个对象的属性和方法,从而实现了继承。
继续以Person为例,当我们使用new Person()创建对象时,JavaScript 就会创建构造函数Person的实例,比如这里我们创建了一个叫“Tom”的Person:
new Person()
const tom = new Person("Tom");
上述这段代码在运行时,JavaScript 引擎通过将Person的原型对象prototype赋值给实例对象tom的__proto__属性,实现了tom对Person的继承,即执行了以下代码:
tom
const tom = {}; tom.__proto__ = Person.prototype; Person.call(tom, "Tom");
可以看到,tom作为Person的实例对象,它的__proto__指向了Person的原型对象,即Person.prototype。
构造函数和constructor属性、原型对象(prototype)和__proto__、实例对象之间的关系:
__proto__ 和 prototype的关系:
一个对象可通过__proto__访问原型对象上的属性和方法,而该原型同样也可通过__proto__访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。tom实例的原型链:
tom --**proto**--> Person.prototype --**proto**--> Object.prototype --**proto**--> null
所以在 JavaScript 中,是通过遍历原型链的方式,来访问对象的方法和属性。
当 JavaScript 试图访问一个对象的属性时,会基于原型链进行查找。
首先会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象;遍历访问对象的整个原型链后如果最终依然找不到,此时会认为该对象的属性值为undefined。
JavaScript 中的所有对象都来自Object,Object.prototype.__proto__ === null。null没有原型,并作为这个原型链中的最后一个环节。
Object.prototype.__proto__ === null
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 引擎会进行以下操作:
tom.toString()
toString()
Person()
Object.prototype
由于通过原型链进行属性的查找,需要层层遍历各个原型对象,此时可能会带来性能问题:
因此,我们在设计对象的时候,需要注意代码中原型链的长度。当原型链过长时,可以选择进行分解,来避免可能带来的性能问题。
上述通过原型链的方式实现 JavaScript 继承的例子中,如果将 p.c 修改为其他值,那么 o.c 同样会改变,这也是
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]
由于两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。
那么要解决这个问题的话,我们就得再看看其他的继承方式,下面我们看看能解决原型属性共享问题的第二种方法。
直接通过代码来了解,如下所示。
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());
可以看到最后打印的 child 在控制台显示,除了 Child1 的属性 type 之外,也继承了 Parent1 的属性 name。这样写的时候子类虽然能够拿到父类的属性值,解决了原型链继承的弊端,但问题是,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。
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());
执行上面的代码,可以看到输出结果,前两种方法的问题都得以解决。
但是这里又增加了一个新问题:通过注释我们可以看到 Parent3 执行了两次,第一次是改变 Child3 的 prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,那么 Parent3 多构造一次就多进行了一次性能开销,这是我们不愿看到的。
Parent3
Child3
call
这里不得不提到的就是 ES5 里面的 Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。
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);
从上面的代码中可以看到,通过 Object.create 这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承 getName 的方法。但是最后两个输出相同,可以看出它的弊端和原型链继承类似,多个实例的引用类型属性指向相同的内存,存在篡改的可能。原因也很简单,其实 Object.create 方法是可以理解为为一些对象实现浅拷贝。
getName
使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。
虽然其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。
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 的方法,结果如下图所示。
从最后的输出结果中可以看到,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());
通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销。
从代码的执行结果可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。
我们可以利用 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 语法糖的底层逻辑。
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");
从编译得到的代码中可以看到,它采用的也是寄生组合继承方式。
The text was updated successfully, but these errors were encountered:
No branches or pull requests
JavaScript 的继承与实现
ES6 中出现的
class
给人错觉——js 基于类来实现继承。然而class
只是个语法糖,javascript 实际上还是基于原型进行继承。这是因为 JavaScript 中有个特殊的存在:对象。每个对象还都拥有一个原型对象,并可以从中继承方法和属性。
原型对象和对象是什么关系
在 JavaScript 中,对象由一组或多组的属性和值组成:
在 JavaScript 中,对象的用途很是广泛,因为它的值既可以是原始类型(
number
、string
、boolean
、null
、undefined
、bigint
和symbol
),还可以是对象和函数。不管是对象,还是函数和数组,它们都是
Object
的实例,也就是说在 JavaScript 中,除了原始类型以外,其余都是对象。在 JavaScript 中,函数也是一种特殊的对象,它同样拥有属性和值。所有的函数会有一个特别的属性
prototype
,该属性的值是一个对象,这个对象便是我们常说的“原型对象”。可以看到,该原型对象有两个属性:
constructor
和__proto__
。在 JavaScript 中,
__proto__
属性指向对象的原型对象,对于函数来说,它的原型对象便是prototype
。函数的原型对象prototype
有以下特点:prototype
)都拥有constructor
属性,该属性指向与之关联的构造函数,在这里构造函数便是Person
函数;Person
函数的原型对象(prototype
)同样拥有自己的原型对象,用__proto__
属性表示。前面说过,函数是Object
的实例,因此Person.prototype
的原型对象为Object.prototype。
我们可以用这样一张图来描述
prototype
、__proto__
和constructor
三个属性的关系:从这个图中,我们可以找到这样的关系:
__proto__
属性指向对象的原型对象;prototype
属性,该属性为该函数的原型对象。使用
prototype
和__proto__
实现继承对象的属性值可以为任意类型,可以为另外一个对象。这也意味着 JavaScript 可以通过将对象 A 的
__proto__
属性赋值为对象 B(即A.__proto__ = B
),来使用A.__proto__
访问 B 的属性和方法。通过这种方式,JavaScript 可以在两个对象之间创建一个关联,使得一个对象可以访问另一个对象的属性和方法,从而实现了继承。
继续以
Person
为例,当我们使用new Person()
创建对象时,JavaScript 就会创建构造函数Person
的实例,比如这里我们创建了一个叫“Tom”的Person
:上述这段代码在运行时,JavaScript 引擎通过将
Person
的原型对象prototype
赋值给实例对象tom
的__proto__
属性,实现了tom
对Person
的继承,即执行了以下代码:可以看到,
tom
作为Person
的实例对象,它的__proto__
指向了Person
的原型对象,即Person.prototype
。构造函数和
constructor
属性、原型对象(prototype
)和__proto__
、实例对象之间的关系:Person.prototype
)都拥有constructor
属性,指向该原型对象的构造函数(Person
);new Person()
)可以创建对象,创建的对象称为实例对象(tom
);__proto__
属性指向构造函数的原型对象(Person.prototype
),实现了该原型对象的继承。__proto__
和prototype
的关系:__proto__
属性来标识自己所继承的原型对象,但只有函数才有prototype
属性,且该属性为该函数的原型对象;__proto__
属性赋值为其构造函数的原型对象prototype
,JavaScript 可以使用构造函数创建对象的方式,来实现继承。一个对象可通过
__proto__
访问原型对象上的属性和方法,而该原型同样也可通过__proto__
访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。tom
实例的原型链:所以在 JavaScript 中,是通过遍历原型链的方式,来访问对象的方法和属性。
通过原型链访问对象的方法和属性
当 JavaScript 试图访问一个对象的属性时,会基于原型链进行查找。
首先会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象;遍历访问对象的整个原型链后如果最终依然找不到,此时会认为该对象的属性值为
undefined
。JavaScript 中的所有对象都来自
Object
,Object.prototype.__proto__ === null
。null
没有原型,并作为这个原型链中的最后一个环节。可以看到,当我们对对象进行属性值的获取时,会触发该对象的原型链查找过程。
比如当调用
tom.toString()
时,JavaScript 引擎会进行以下操作:tom
对象是否具有可用的toString()
方法;tom
的原型对象(Person.prototype
)是否具有可用的toString()
方法;Person()
构造函数的prototype
属性所指向的对象的原型对象(即Object.prototype
)是否具有可用的toString()
方法,于是该方法被调用。由于通过原型链进行属性的查找,需要层层遍历各个原型对象,此时可能会带来性能问题:
因此,我们在设计对象的时候,需要注意代码中原型链的长度。当原型链过长时,可以选择进行分解,来避免可能带来的性能问题。
JS 实现继承的几种方式
上述通过原型链的方式实现 JavaScript 继承的例子中,如果将
p.c
修改为其他值,那么o.c
同样会改变,这也是除了通过原型链的方式实现 JavaScript 继承,JavaScript 中实现继承的方式还包括经典继承(盗用构造函数)、组合继承、原型式继承、寄生式继承,等等。
组合继承模式通过将共享属性定义在父类原型上、将私有属性通过构造函数赋值的方式,实现了按需共享对象和方法,是 JavaScript 中最常用的继承模式。
虽然在继承的实现方式上有很多种,但实际上都离不开原型对象和原型链的内容,因此掌握
__proto__
和prototype
、对象的继承等这些知识,是我们实现各种继承方式的前提。第一种:原型链继承
原型链继承是比较常见的继承方式之一,其中涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。
上面的代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题。
由于两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。
那么要解决这个问题的话,我们就得再看看其他的继承方式,下面我们看看能解决原型属性共享问题的第二种方法。
第二种:构造函数继承(借助 call)
直接通过代码来了解,如下所示。
可以看到最后打印的
child
在控制台显示,除了Child1
的属性type
之外,也继承了Parent1
的属性name
。这样写的时候子类虽然能够拿到父类的属性值,解决了原型链继承的弊端,但问题是,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。因此,从上面的结果就可以看到构造函数实现继承的优缺点,它使父类的引用属性不会被共享,优化了原型链继承的弊端;但缺点也比较明显——只能继承父类的实例属性和方法,不能继承原型属性或者方法。
第三种:组合继承(前两种组合)
这种方式结合了前两种继承方式的优缺点,结合起来的继承,代码如下。
执行上面的代码,可以看到输出结果,前两种方法的问题都得以解决。
但是这里又增加了一个新问题:通过注释我们可以看到
Parent3
执行了两次,第一次是改变Child3
的prototype
的时候,第二次是通过call
方法调用Parent3
的时候,那么Parent3
多构造一次就多进行了一次性能开销,这是我们不愿看到的。第四种:原型式继承
这里不得不提到的就是 ES5 里面的
Object.create
方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。从上面的代码中可以看到,通过
Object.create
这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承getName
的方法。但是最后两个输出相同,可以看出它的弊端和原型链继承类似,多个实例的引用类型属性指向相同的内存,存在篡改的可能。原因也很简单,其实Object.create
方法是可以理解为为一些对象实现浅拷贝。第五种:寄生式继承
使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。
虽然其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。
通过上面这段代码,我们可以看到 person5 是通过寄生式继承生成的实例,它不仅仅有 getName 的方法,而且可以看到它最后也拥有了 getFriends 的方法,结果如下图所示。
从最后的输出结果中可以看到,person5 通过 clone 的方法,增加了 getFriends 的方法,从而使 person5 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。
我在上面第三种组合继承方式中提到了一些弊端,即两次调用父类的构造函数造成浪费,下面要介绍的寄生组合继承就可以解决这个问题。
第六种:寄生组合式继承
结合 原型式继承 和 组合继承 方式:
通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销。
从代码的执行结果可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。
ES6 的 extends 关键字实现逻辑
我们可以利用 ES6 里的 extends 的语法糖,使用关键词很容易直接实现 JavaScript 的继承。
通过使用 tsc 将 typescript 代码转换为 ES5 代码,来了解
extends
语法糖的底层逻辑。从编译得到的代码中可以看到,它采用的也是寄生组合继承方式。
The text was updated successfully, but these errors were encountered: