JavaScript——原型和原型链

1,164 阅读9分钟

JS 中的面向对象,围绕原型和原型链知识展开。而在面试中,也会时常问到关于原型和原型链的问题,本文会让你了解原型、原型链以及教你遇到此类面试题时如何思考,相信今天这一篇文章能让你在面对这一类问题的时候能与面试官谈笑风生。

一道面试题

function A() {};
A.prototype.n = 1;
var b = new A();
A.prototype = {//*
  n: 2,
  m: 3
}
var c = new A();

console.log(b.n);
console.log(b.m);

console.log(c.n);
console.log(c.m);

这是一道很经典的关于原型以及原型链的面试题。肯定有小伙伴能解出来,我们就从这道题出发来为大家讲一讲原型,原型链,构造函数,new他们之间的爱恨情仇。 另外,为了让大家更好的理解,请同学们迷路的时候回来看一看这道题目

构造函数(function A() {};)与原型

当我们学习时,要去理解为什么要创造这个方法,并了解人们创造它是为了解决什么问题,这样我们才能真正的理解并且应用到实际中去

那么为什么需要原型和原型链呢? 我们要从构造函数说起

function Person(name, age) {
        this.name = name;
        this.age = age;
        this.drinkwater = function() {
            console.log(this.name);
        };
     }
     let person1 = new Person('jack', 18);
     let person2 = new Person('rose', 17);
     person1.drinkwater(); //jack
     person2.drinkwater(); //rose

上面我们简单的通过构造函数创建了两个新对象,可以看到构造函数很好啊,我们只需要new一下并且传进我们想传的参数便可以得到一个我们想要的对象。

那么,既然他这么好为什么我们还需要原型和原型链呢?看代码

console.log(person1.drinkwater == person2.drinkwater);

结果一定是false,对吧。因为构造函数为person1person2分别都创建了drinkwater方法,这有什么问题吗? 当然有啦问题可大了,就好比大家每天都用的到的饮水机,你觉得饮水机一个人一台他合理🐎?

上面的例子就等于说给person1person2一人一台饮水机,这不是极大的浪费资源?(内存浪费) 于是我们想了个办法,让这些人别自己用自己的饮水机了,当想用的时候就去找公共区域的饮水机来用。那要怎么找呢?

为了解决这个问题,我们便提出了原型和原型链

function Person(name, age) {
        this.name = name;
        this.age = age;
     }

// 通过构造函数的 Person 的 prototype 属性找到 Person 的原型对象
Person.prototype.drinkwater = function() {
  console.log(this.name);
}

let person1 = new Person('jack', 18);
let person2 = new Person('rose', 17);

console.log(p1.drinkwater === p2.drinkwater); // true

我们发现通过给构造函数的prototype添加方法,我们共享了一个饮水机。

什么?prototype居然这么好用。不要急,为了让大家更好的理解剩下的内容,我必须要再逼叨逼叨一下这个构造函数。

简单说一说构造函数

构造函数其实也就是一个函数而已,那么他和普通函数的区别是什么呢?只有一个区别:调用方式的不同。

任何函数(没错,就是任何函数)只要通过new操作符调用它就是构造函数。而不使用new来调用的函数就是普通函数。比如,前面例子中定义的Person()可以这样调用:

Person("Tom", 20);
window.drinkwater(); //Tom

因为是直接调用,没用到new操作符,结果就将属性和方法给了window对象

new的内部发生了什么

下面我会直接给出new的内部流程,如果遇到不理解的地方,不要急,我们先往下看。

当我们以new操作符调用构造函数时会发生:

1.创建一个新对象

2.新对象内部的【【prototype】】特性被赋值为构造函数的prototype属性(*)

3.构造函数内部的this被赋值为这个新对象

4.执行构造函数内部的代码(给对象添加属性)

5.构造函数返回创建的对象

共享饮水机到底是怎么实现的呢?重点就在于(*)行所说到的,构造函数构造出来的新对象的__proto__ 属性(原型) 指向了构造函数的prototype属性。

饮水机被我们放在来构造函数的prototype属性里,当人们(构造出来的对象即实例)想要喝水的时候就通过自己的__proto__ 属性 去构造函数的prototype那里找饮水机(Drinkwater)。

结果就是,人们共享了饮水机(构造出来的对象即实例,共享了方法

回到原型

上面引导了这么多,现在这些概念需要大家全部记忆一下

  1. js分为函数对象和普通对象,每个对象都有__proto__属性,但是只有函数对象才有prototype属性

  2. Object、Function都是js内置的函数, 类似的还有我们常用到的Array、RegExp、Date、Boolean、Number、String

那么__proto__和prototype到底是什么,两个概念理解它们

  1. 属性__proto__是一个对象,它有两个属性,constructor和__proto__,其中的proto就指向原型

  2. 原型对象prototype有一个默认的constructor属性,用于记录实例是由哪个构造函数创建;

现在我们来理一下,实例(构造函数构造出来的对象)、构造函数、原型之间的关系。 话不多说,直接上代码,还是之前的例子:

function Person(name, age) {//构造函数
        this.name = name;
        this.age = age;
     }

Person.prototype.drinkwater = function() { //给原型Person.prototype添加饮水机方法
  console.log(this.name);
}

let person1 = new Person('jack', 18);//创建实例
let person2 = new Person('rose', 17);//创建实例
person1.drinkwater

构造函数创建了实例,并把实例的 proto 属性(原型)指向了自己的prototype让两个实例可以共享方法。(图片画的丑大家将就下吧)

屏幕截图 2021-05-28 094801.png

当人们想喝水的时候会这样首先看看自己有没有饮水机,有?那就用自己的 没有?去看看公共区域有没有公共饮水机可以用。

实例也是这样,当调用方法时,首先看自己有没有这个方法如果没有再去原型中寻找。

这里会有不少的人会误解认为实例的原型就是构造函数的prototype(***)

千万不要这样想, 在创建实例的时候,我们只是把实例的 proto 属性(原型)指向为了构造函数的prototype,注意这里是设置。我们可以更改它的,我们完全可以把实例的__proto__ 属性(原型)设置为其它的(prototype),可以这样说在构造函数创造实例过后,这个实例就和构造函数本身没有关系了,只是它的原型是构造函数的prototype而已,如果我们之后把原型的prototype重写为其它的对象,仍然不会影响实例对原来那个prototype对象中方法的调用 这一点大家可以回头去看最开始的面试题(*),可以更直观的理解。

原型链

现在我在上面那段代码(饮水机)的基础上,进行两个方法调用:

person1.drinkwater(); //jack
person1.toString();   //[object Object]

诶??

我么发现,无论是在构造函数的prototype里还是在实例本身的方法里都没有toString()这个方法啊?

这是因为当我试图访问一个实例的属性或者方法时,它首先搜索这个实例本身;当发现实例没有 定义对应的属性或方法时,它会转而去搜索实例的原型对象;如果原型对象中也搜索不到,它就 去搜索原型对象的原型对象,这个搜索的轨迹,就叫做原型链

以我们的 drinkwater 方法和 toString 方法的调用过程为例,它的搜索过程就是这样子的:

屏幕截图 2021-05-28 102523.png

回到面试题

说了这么多,我们回到这道面试题,相信大家已经能够说出这道题的正确输出了,那么你的输出是什么呢?我们现在来找一下它的原型链

function A() {};
A.prototype.n = 1;
var b = new A();
A.prototype = {//*
  n: 2,
  m: 3
}
var c = new A();

console.log(b.n);
console.log(b.m);

console.log(c.n);
console.log(c.m);

2,3 ,2,3?

还是

1,undefined , 2, 3?

答案是第二个。

现在我们从头开始分析。

  • 构造函数A.prototype增加了一个属性n值为1

  • 构造函数A new了一个实例b出来。实例对象b的原型_proto_就把构造函数的 prototype 的引用给存下来了,这个时候prototype的内容就是一个n = 1外加一个constructor(在这里不重要)。所以 b 实例输出的 n 就是 1;同时由于它没有 m 属性,直接输出 undefined。

有同学会说了,可是后面我们还对 A 的 prototype 做了修改啊!b 如果存的是引用,它应该感知 到我这个修改啊!最关键的地方来了。请注意你修改 A 的 prototype 的形式:A.prototype = {//* n: 2, m: 3 }

虽然随时能给原型添加属性和方法,并且能够立即反映到实例上,但这个重写整个原型是两回事。实例的_proto_指针实在调用构造函数自动赋值的,这个指针即使把原型修改为不同的对象也不会变。所以重写整个原型会切断最初原型和构造函数的联系,但实例引用的仍然是最初的原型。(这里呼应了我上文的***处)

下面这张图希望你可以理解 image.png

从图中我们可以看出,A 单方面切断了和旧 prototype 的关系,而 b 却仍然保留着旧 prototype 的 引用。这就是造成 b 实例和 c 实例之间表现差异的原因。

面对面试

当我们遇到这类题目的时候到底应该怎么办呢?有没有一个比较合理的思路去思考呢?

思考这类题目的关键就在,抓住原型链。只要我们静下心来把原型链找到,这种题目几乎就是秒解的。大家可以试着用这个思路去看看别的关于原型和原型链的题目。

拓展

理解了原型和原型链之后,我们就可以了解到比较经典的一些继承方式(经典继承、组合继承、寄生继承、原型继承、寄生组合继承)以及es6的class类的原理了。class类其实也就是es6的基础性语法糖结构。但这些都是基于原型和原型链的。只要真正理解了原型和原型链那么 理解JavaScript原型编程范式就不难,对于上述的这些拓展也是随手就来了。

参考资料:zh.javascript.info/ JavaScript现代教程 红宝书以及我自己花钱买的一个教程。。这里就不方便说了以免有打广告的嫌疑

如有错误,请指正,希望本文对大家有帮助