avatar

【重识前端】原型/原型链和继承

探究

我觉得学习一个东西最重要的是搞懂三样东西

What/How/Why

What

什么是原型?什么是原型链?什么是继承?

原型

在JavaScript中,原型也是一个对象,通过原型可以实现对象的属性继承,JavaScript的对象中都包含了一个” [[Prototype]]”内部属性,这个属性所对应的就是该对象的原型。

“[[Prototype]]”作为对象的内部属性,是不能被直接访问的。所以为了方便查看一个对象的原型,Firefox和Chrome中提供了”proto“这个非标准(不是所有浏览器都支持)的访问器(ECMA引入了标准对象原型访问器”Object.getPrototype(object)”)。

用一句话来概括就是: 原型是一个属性可以被继承的对象.原型也可以有自己的原型.

原型链

原型也可以有自己的原型, 而一个个原型通过prototype链接起来的就叫做原型链

继承

儿子可以通过继承获得父亲的属性或者方法;

why

为什么会有原型/原型链和继承?

摘自JavaScript高级程序设计:

继承是OO语言中的一个最为人津津乐道的概念.许多OO语言都支持两种继承方式: 接口继承实现继承 .接口继承只继承方法签名,而实现继承则继承实际的方法.由于js中方法没有签名,在ECMAScript中无法实现接口继承.ECMAScript只支持实现继承,而且其 实现继承 主要是依靠原型链来实现的.

How

原型

怎么找到原型?

我们打开浏览器, 打开控制台(F12), 可以直接输入 const obj = {}, 我们就可以把他直接点开.发现里面有一个__proto__ 其实我们输入的这个对象, 是一个Object构造函数的实例. 我们可以通过实例的__proto__属性找到他的原型;

原型链

怎么找到原型链?

在找之前我们先了解一个原型与构造函数与实例直接的关系.

假定我们有一个构造函数 function A(){}, 我们称他为A

我们有一个实例B, 他是这样来的: const B = new A(), 我们称他为B

A的原型为C

接下里我们写一个等式来很好的展示他们之间的关系

A = prototype => C = construct => A = new => B = __proto__ => C

解释一下: A有一个属性prototype 指向A的原型 C, 而C的属性construct指向A, A通过new方法可以创建实例B, 而B有一个属性__proto__指向了C(A的原型)

OK, 理解完上面的东西之后, 我们再加一个知识点, A的原型C, 也是C的原型的一个实例.

先别懂没懂, 我们看一下实战.

书接上文中的原型, 我再复制一下, 方便阅读

我们打开浏览器, 打开控制台(F12), 可以直接输入 const obj = {}, 我们就可以把他直接点开.发现里面有一个__proto__ 其实我们输入的这个对象, 是一个Object构造函数的实例. 我们可以通过实例的__proto__属性找到他的原型;

我们接着点开__proto__,发现里面有一堆”花里胡哨”的东西, 我们找到了其中一个很眼熟的”靓仔” -> construct;

根据之前的了解

​ A = prototype => C = construct => A = new => B = __proto__ => C

我们点开的这个construct其实那个空对象的构造函数Object, 那么他应该有一个prototype指向了他的原型.

我们找找, 确实发现了有这么一个属性(prototype); 那么点开之后应该与最早的空对象的__proto__的内容一致. 根据等式可得;

总结一下

对象的 __proto__ 属性指向原型, __proto__ 将对象和原型连接起来组成了原型链

接下来进入我们的重头戏继承

继承

其实继承又分为好多种: 原型链继承, 构造函数继承, 组合继承和寄生组合继承;

原型链继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function A(){
this.array = ["a", "b", "c"];
}

function B(){
}

//继承了 A
B.prototype = new A()

var b1 = new B()
b1.array.push("d");
console.log(b1.array); //"a,b,c,d"

var b2 = new B();
console.log(b2.array); //"a,b,c,d"

b2.array.push("e")
console.log(b2.array) //"a,b,c,d,e"
console.log(b1.array) //"a,b,c,d,e"

我们定义了两个构造函数, A和B; 根据之前的知识, 我们让B的原型指向了构造函数A创造出来的实例(new A()); 然后我们用构造函数B创造了实例b1, 往 继承来的属性array 增加一个字符串”d”; 然后我们输出一下, 按照我们的逾期的多了一个”e”的情况下, 还有”a,b,c”;

接下来就有些问题了. 我们按照惯例, 通过构造函数B, 创造了b2. b2确实也有了继承来的属性”array”, 可是这个array并不是我们想要的初始的["a","b","c"]; 我们放弃探究, 继续往这个array里面增加一个e, 输出一下b1的属性array. 确实是可以增加这个e;

可是!

当我们输出b1.array时, 发现b1也被改了, 和之前诡异的b2多出一个d一样.

探究发现这个由于我们用的是引用类型, 所以他们存储的是指向同一个内存的内存地址; 所以才会发生这些问题. 这也暴露了原型链继承时的一个问题.引用类型带来的毛病;

其次, 我们发现我们的B的原型指向A的构造函数时不能传递参数, 这也的话有点呆板

综上原型链的继承在日常中是很少用到的

优点

​ 子类可以通过原型链的查找,实现父类的属性公用与子类的实例

缺点
  • 一些引用数据操作的时候会出问题,两个实例会公用继承实例的引用数据类
  • 谨慎定义方法,以免定义方法也继承对象原型的方法重名
  • 无法直接给父级构造函数使用参数

构造函数继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function A(){
this.array = ["a", "b", "c"];
}

function B(){
A.call(this)
}

//继承了 A
B.prototype = new A()

var b1 = new B()
b1.array.push("d");
console.log(b1.array); //"a,b,c,d"

var b2 = new B();
console.log(b2.array); //"a,b,c"

b2.array.push("e")
console.log(b2.array) //"a,b,c,e"
console.log(b1.array) //"a,b,c,d"

我们奇迹般的发现, 构造函数的继承解决了我们之前发现的JavaScript引用问题.那函数传参的问…

啪!

停, 我们现在来传递一下参数!

我们把上面的例子修改一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function A(name){
this.array = ["a", "b", "c"];
this.name = name
}

function B(){
A.call(this, 'B')
}

//继承了 A
B.prototype = new A()

var b1 = new B()
b1.array.push("d");
console.log(b1.array); //"a,b,c,d"
console.log(b1.name); //"B"

var b2 = new B();
console.log(b2.array); //"a,b,c"
console.log(b1.name); //"B"

b2.array.push("e")
console.log(b2.array) //"a,b,c,e"
console.log(b1.array) //"a,b,c,d"

这样的话我们解决了原型链继承的两个问题(1.无法传参, 2.js引用问题);

该不会有人认为这样就很完美了吧?不会吧!不会吧!

我们冷静下来想想看, 其实这个传递参数是几乎算是写死的. 还不如直接在A里面直接写死好了, 根本就无法复用嘛; 而且,在超类型的原型中定义的方法,对子类型而言也是不可见的,结 果所有类型都只能使用构造函数模式。考虑到这些问题,借用构造函数的技术也是很少单独使用的。

优点
  • 解决了原型链继承的 引用类型操作问题
  • 解决了父类传递参数问题
缺点
  • 仅仅使用借用构造函数模式继承,无法摆脱够着函数。方法在构造函数中定义复用不可谈
  • 对于超类的原型定义的方法对于子类是不可使用的,子类的实例只是得到了父类的this绑定的属性 考虑到这些缺点,单独使用借用构造函数也是很少使用的

组合继承

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
function A(name){
this.name = name
this.array = ['1', '2', '3']
}
A.prototype.sayName = function (){console.log(this.name)}

function B(name, age){
A.call(this, name);
this.age = age;
}

B.prototype = new A();
B.prototype.constructor = A; // 因为原型链继承,会把constructor指向改变,所以要重新指回自身
B.prototype.sayAge = function (){console.log(this.age)}

var a = new B('a', 10)
a.array.push('a')
console.log(a.array) // ["1", "2", "3", "a"]
a.sayName() // 'a'
a.sayAge() // 10

var b = new B('b', 20)
b.array.push('b')
console.log(b.array) // ["1", "2", "3", "b"]
b.sayName()// 'b'
b.sayAge() // 20

在这个例子中, 构造函数A有两个内置属性namearray. A的原型定义了一个方法sayName; 构造函数B在调用A的构造函数时传入了一个参数name, 然后又给B的原型上定义了一个方法sayAge. 这样一来, 就可以让两个B的实例分别拥有自己的属性, 当然也包括了array, 还可以拥有相同的方法.

组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继 承模式。而且(关键),instanceofisPrototypeOf()也能够用于识别基于组合继承创建的对象。

优点
  • 解决了原型链继承引用类型的实例操作导致引用改变
  • 解决了借构造函数继承方式的,父类原型子类实例可以使用
缺点

父类的构造函数被实例换了两次 * 实例会有父类的构造函数的一些this属性、子类的构造函数(prototype)上也有一份实例的上有的属性

原型式继承

道格拉斯·克罗克福德在 2006 年写了一篇文章,题为 Prototypal Inheritance in JavaScript (JavaScript 10 中的原型式继承)。在这篇文章中,他介绍了一种实现继承的方法,这种方法并没有使用严格意义上的 构造函数。他的想法是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。为 了达到这个目的,他给出了如下函数。

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

看起来非常简单

先在object函数内部创建一个临时的构造函数F, 然后将传入的这个对象o作为这个临时构造函数的原型, 最后返回这个临时构造函数的实例.

简单来说就是object对传入的对象进行了浅复制.

浅拷贝和深拷贝可以自行Google一下, 这算一个重要的知识点.

举个🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function object(o){
function F(){}
F.prototype = o
return new F
}

var a = {
name: 'a',
array: ['1', '2'],
}

var qq = object(a)
console.log(qq.name)
qq.array.push('qq')
console.log(qq.array)

var ww = object(a)
console.log(ww.name)
ww.array.push('ww')
console.log(ww.array)
console.log(qq.array)

有没有似曾相识的感觉….原型链继承的老毛病来了…

不过他也不是没有优点的.

在没有必要兴师动众地创建构造函数,而只想让一个对象与另一个对象保持类似的情况下,原型式 继承是完全可以胜任的。

ECMAScript 5 通过新增 Object.create()方法规范化了原型式继承。这个方法接收两个参数:一 个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。在传入一个参数的情况下, Object.create()与 object()方法的行为相同。有兴趣的话可以自己了解一下

优点

再不用创建构造函数的情况下,实现了原型链继承,代码量减少一部分

缺点
  • 一些引用数据操作的时候会出问题,两个实例会公用继承实例的引用数据类
  • 谨慎定义方法,以免定义方法也继承对象原型的方法重名
  • 无法直接给父级构造函数使用参数

寄生继承

寄生继承和原型式继承紧密相关, 同样也是由克罗克福德推而广 之的.寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该 函数在内部以某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。以下代码示范了寄 生式继承模式。

上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
function object(o){
function F(){}
F.prototype = o
return new F
}

function createAnother(original){
var clone=object(original); //通过调用函数创建一个新对象
clone.sayHi = function(){ //以某种方式来增强这个对象
alert("hi");
};
return clone;//返回这个对象
}

在这个例子中,createAnother()函数接收了一个参数,也就是将要作为新对象基础的对象。然 后,把这个对象(original)传递给 object()函数,将返回的结果赋值给 clone。再为 clone 对象 添加一个新方法 sayHi(),最后返回 clone 对象。

1
2
3
4
5
6
var person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
var anotherPerson = createAnother(person);
anotherPerson.sayHi(); //"hi"

这个例子中的代码基于 person 返回了一个新对象——anotherPerson。新对象不仅具有 person 的所有属性和方法,而且还有自己的 sayHi()方法。

在主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。前面示 范继承模式时使用的 object()函数不是必需的;任何能够返回新对象的函数都适用于此模式。

使用寄生继承, 由于不能做到函数复用而降低效率, 这点和构造函数继承很像

优点
  • 再不用创建构造函数的情况下,实现了原型链继承,代码量减少一部分
  • 可以给备份的对象添加一些属性
缺点

类似构造函数一样,创建寄生的方法需要在clone对象上面添加一些想要的属性,这些属性是放在clone上面的一些私有的属性

寄生组合继承(终极方案

前面说过,组合继承是 JavaScript 最常用的继承模式;不过,它也有自己的不足。组合继承最大的 问题就是无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是 在子类型构造函数内部。没错,子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子 类型构造函数时重写这些属性。再来看一看下面组合继承的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function A(name){
this.name = name;
this.array = ['1','2','3']
}
A.prototype.sayName = function(){console.log(this.name)}

function B(name, age){

A.call(this, name) //第二次调用
this.age = age
}
B.prototype.sayAge = function(){console.log(this.age)}

B.prototype = new A() // 第一次调用
B.prototype.constructor = B

第一次调用A构造函数的时候, B的原型上就拥有了A的两个属性和一个方法;

第二次调用A的构造函数时, 为B的实例创建了A的两个的属性和一个方法, 于是实例的两个属性和一个方法就屏蔽了原型上的实例和方法;

是不是感觉有点点浪费, 不过好在我们已经找到解决方案—>寄生组合继承

举一个非常简单的🌰, 并且我尽量逐字逐句的讲清楚

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
function object(o){
function F(){}
F.prototype = o
return new F
} //也可以用Object.create替代

function B(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}

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

function A(name, age){
B.call(this, name); // 只调用了这一次
this.age = age;
}

function inheritPrototype(A, B){
//让prototype可以拿到B的属性和方法...这是原型式继承
var prototype = object(B.prototype);
//由于重写了原型, 所以此时的constructor会指向Object, 所以必须指定一下constructor...那为什么会丢失呢?因为我们穿给object这个方法的是一个对象, 而对象的构造函数就是Object; 在原型那一小节我们说过了, 原型也是对象;
prototype.constructor = A;
// 继承属性之后就需要将原型给指向回去, 为了原型链的正确指向
A.prototype = prototype;
}

inheritPrototype(A, B);

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

基本思想:通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。实际上是在组合继承的基础上,用超类型原型的副本代替调用超类型的构造函数给子类型指定原型

本质上,是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

优点:只调用了一次超类型的构造函数,并且因此避免了在子类型的原型上创建不必要的、多余的属性。与此同时,原型链还能保持不变。因此,还能够正常使用instanceofisPrototypeOf()。普遍认为寄生组合式继承是引用类型最理想的继承方式。

优点
  • 在少一次实例化父类的情况下,实现了原型链继承和借用构造函数
  • 减少了原型链查找的次数(子类直接继承超类的prototype,而不是父类的实例)
缺点

暂无

class继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Parent{
constructor(name){
this.name = name;
}
getName(){
console.log(this.name);
}
}
class Child extends Parent{
constructor(val){
super(val);
}
}
let child = new Child('zhangsan');
child.getName(); //zhangsan
child instanceof Parent // true

说明:核心在于使用 extends 表明继承自哪个父类,并且在子类构造函数中必须调用 super。
class 的本质还是函数,这种表达不过是一种语法糖。

这里就不展开了.

文章作者: Derrick
文章链接: http://derricktel.github.io/2020/07/13/%E3%80%90%E9%87%8D%E8%AF%86%E5%89%8D%E7%AB%AF%E3%80%91%E5%8E%9F%E5%9E%8B%E5%8E%9F%E5%9E%8B%E9%93%BE%E5%92%8C%E7%BB%A7%E6%89%BF/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Derrick
打赏
  • 微信
    微信
  • 支付寶
    支付寶