【趣味JavaScript】一文让你读懂JavaScript原型对象与原型链的继承,探秘属性的查找机制! 《重置版》

🚀 个人主页 极客小俊
✍🏻 作者简介:web开发者、设计师、技术分享博主
🐋 希望大家多多支持一下, 我们一起学习和进步!😄
🏅 如果文章对你有帮助的话,欢迎评论 💬点赞👍🏻 收藏 📂加关注

前言

有些新手朋友可能听说过这么一句话,就是js中存在两个链条,它们分别为:作用域链原型链

它们彼此的区别在于作用域链是为了访问变量数据而存在的一种链条访问机制

原型链是访问对象属性或者方法而存在的一种机制!

其中这里的原型链就是今天我要说的主题!

我们学习js必须要知道什么是原型、原型链、构成函数、实例对象这些彼此之间的关系和应用范围!

如果你没有搞明白js中的原型链也就说明你没有把js学明白!

那么接下来就跟着我一起开始学习吧!

为什么要使用原型?

原型js中也称之为原型模式, 那么为什么要有这种模式存在呢?

因为我们js面向对象不是说过吗?面向对象编程其实就是一种模块、一种封装对吧!

如果你还没有完全明白javascript面向对象建议你一定要听我讲完下面的知识点!

构造函数模式

有的时候我们会使用构造函数模式创建对象

虽然这种构造函数创建对象,看起来没什么大问题,但是并非没有缺点!

这里我们来看一段简单的构造函数模式创建对象的案例吧!

代码

function Person(username,age,job){
    this.username=username;
    this.age=age;
    this.job=job;
    this.say=function (){
        console.log('我的名字叫:'+this.username+',年龄:'+this.age+',职业:'+this.job);
    }
}

var test=new Person('张三',18,'ui设计师');
test.say();


这就是一段非常简单的构造函数模式封装对象的方法!

构造函数模式的缺点

任何一种技术的出现,都是为了弥补旧技术的不足!

那么构造函数模式有什么缺点呢?为什么又要搞出一个原型模式呢? 搞出这么个东西意义何在?

这里我先卖个关子,想知道的话就继续往下看吧! 😛😛😛

从上面的代码角度上看,确实感觉不出什么奇怪之处!

那我们再来看一个构造函数模式的例子:

代码

//构造函数
function Person(){
    this.say=function (num){
        console.log('测试方法'+num);
    }
}
//实例化
var obj_1=new Person();
var obj_2=new Person();

//调用
obj_1.say(1);
obj_2.say(2);

console.log(obj_1.say===obj_2.say);   //返回false

然后我们看看下面这张图你就知道其中缺点在什么地方了!

如图

此时你会发现,这个函数居然会不相等!! 为什么呢?

从另一个角度来讲构造函数每执行一次就会把构造函数中的方法也重新在内存中生成一份相同的方法!

比如: 执行一万次构造函数,那么就会在内存中创建一万次构造函数里面的方法, 这对内存是一种非常大的消耗

要知道在ECMAScript中只要是函数都是对象 这句话!

之所以会返回false就是因为函数的底层地址是不一样的! 不要以为方法名称一样就是一样! 那你就错了

如图

并且函数本身也是对象, 你定义一个函数,也就相当于实例化了一个函数对象, 其实就会在内存中开辟一个空间,地址也会不一样!

上面的这行代码

this.say=function (num){
    console.log('测试方法'+num);
}

其实从一定逻辑上讲也可以看成以下形式

this.say=new Function (num){
    console.log('测试方法'+num);
}

这样子其实你更好理解每个构造函数实例中的方法,其实是不同Function的实例

所以说像这种在内存中无限创建很多完成同样方法的Function实例是完全没有必要的!

那么有没有什么好的方案可以解决这个问题呢?

我们完全可以使所有的对象共享同一个方法,那么构造函数执行一万次,在内存中也只会存在一份相应的方法!

通常是可以把这个函数方法转移到构造函数外部

代码说明

function Person(username,age,city){
    this.name=username;
    this.age=age;
    this.city=city;
    this.say=say;
    /*this.say=function() {
         console.log(this.name+'的年龄是:'+this.age);
     }*/
}

//把say方法写在全局作用域中
function say() {
    //这里的this要清楚是谁在调用say这个方法 this自然就指向谁
    console.log(this.name+'的年龄是:'+this.age);
}
var test1=new Person('张三',33,'北京市');
var test2=new Person('李四',66,'深圳市');

//判断test1与test2之间的方法是否是共用的
console.log(test1.say==test2.say);

如图

案例2

以下案例也是同样的道理!

//构造函数
function Person(){
    this.say=say;
}

//定义到全局下
function say(num){
    console.log('测试方法'+num);
}

//实例化
var obj_1=new Person();
var obj_2=new Person();

//调用
obj_1.say(1);
obj_2.say(2);

console.log(obj_1.say===obj_2.say);   //返回true

目的我们是达到了,但是这样写就真的行了吗? 不会存在其他问题吗?

分析

很明显在开发当中 如果把函数这样子写在全局作用域中,在多人开发的时候那么就会出现命名冲突,或者说这样做会 污染全局作用域的命名空间, 所以说我们在项目开发的时候都是尽量地不在全局作用域中写变量和函数

那么如何来解决这样的问题呢? 就是接下来我要说的原型和原型链,然后根据情况来解决相应的问题!

原型的定义

我们说在js当中一切皆对象,对吧, 那么在js函数对象中都有一个内置的Prototype属性

这个属性指向一个对象,你可以把这个Prototype属性想象成一个指针, 它指向一个对象

而这个对象就成为原函数对象原型,俗称原型对象

我们来看一段简单的代码:

function Test(){

}

console.log(Test.prototype);

var T1=new Test();
var T2=new Test();
var T3=new Test();

console.log(T1.__proto__);
console.log(T2.__proto__);
console.log(T3.__proto__);

结果

这里还要给大家科普一个小知识,就是普通对象没有原型对象的,也就是说不是函数对象 也可以说成不是通过new Function创建的对象,那么就不会存在原型对象

我们来验证一下

代码如下

//普通对象
var json={}
console.log(json.prototype);

//元素对象
var oDiv = document.getElementById("connent");
console.log(oDiv.prototype);


//函数对象
function test(){

}
console.log(test.prototype);

结果如下

所以说首先只有函数对象才会有原型对象

理解构造函数、实例化对象、原型对象彼此之间的关系

这样就形成了每个函数对象其实都有一个指向另一个对象的指针

这里我们要说明一点的就是,只有函数才有一个prototype属性,这个prototype属性就是我们的原型对象

同时从图中也可以看到原型当中含有一个constructor 属性,而这个属性指向的就是当前原型对象构造函数

我们一般会拿这个构造函数通过new创建出来实例对象,而实例对象是没有prototype属性的!

如果你在一个实例对象上调用prototype必然返回undefined, 而一个实例对象靠的是使用__proto__的隐式属性,进行访问原型对象!

如图

如果按照这个逻辑推理的话,你可以使用以下代码进行验证一下,是否正确:

console.log(T1.__proto__==Test.prototype);  		//返回true
console.log(Test.prototype.constructor==Test);	    //返回true

结果证明的确是这样子,实例对象自己会有一个指针属性为__proto__, 用它来指向构造函数原型对象

其实你也可以使用isPrototypeOf方法来判断当前这个实例对象中的__proto__指针到底是不是能够指向到本身构造函数原型对象中!

例如

//构造函数1
function Person(name,age) {

}

var p1=new Person();


//构造函数2
function Test(){

}
var test=new Test();

console.log(Person.prototype.isPrototypeOf(p1));   //返回true
console.log(Person.prototype.isPrototypeOf(test)); //返回false

以上我们用了isPrototypeOf方法来监测一个实例对象中的__proto__指针是否指向对应的原型对象

js中我们还可以使用一个叫Object.getPrototypeOf()的方法来监测实例对象原型对象之间的关系

例如

console.log(Object.getPrototypeOf(p1)==Person.prototype);  //返回true
console.log(Object.getPrototypeOf(p1)==Test.prototype);    //返回false

所以从结果上看

使用这个Object.getPrototypeOf方法返回的就是当前实例对象__proto__属性所指向的原型对象

同时constructor属性也的确指向了本身的构造函数

这样原型对象实例对象之间就通过__proto__连接在一起,形成了一个链条, 而所谓的原型链也就是实例对象原型对象之间的链条关系, __proto__这条线,也就是原型链的关键

并且这条链条,从图中我们也可以看到,还可以往上走到一个叫Object.prototype的地方!

实例对象属性和方法搜索的优先级

上面说了,有了原型对象,那么实例对象可以共享原型对象中的属性方法

那么问题来了,这些实例对象又是如何进行查找属性和方法的呢?

举个栗子

function createPerson(name,age) {
    this.name=name;
    this.age=age;

    this.say=function (){
        console.log('2.构造函数中定义的say方法!');
    }
}

createPerson.prototype.say=function () {
    console.log('我的名字叫:'+this.name);
}

var a=new createPerson('张三','33');
var b=new createPerson('李四','55');
var c=new createPerson('王武','66');


c.say=function(){
    console.log('1.实例对象c 定义的say方法!');
}

a.say();
b.say();
c.say();

结果如下:

代码分析

按照这个查找逻辑上来看的话,调用的查找方式如下:

先在实例对象上查找定义的属性方法,优先级最高,如果找不到的情况下,然后再是构造函数中进行查找我们定义的属性方法, 最后如果也找不到的情况下,就到原型对象中去寻找!

注意:这并不是把原型对象中的属性方法覆盖了,只是优先调用的顺序而已!

例如

function Person(){

}

Person.prototype.username='张三';
Person.prototype.age=30;
Person.prototype.job='设计师';
Person.prototype.say=function (){
    console.log('我是'+this.username);
}

var p1=new Person();
var p2=new Person();

p1.username='李四';

console.log(p1.username);
console.log(p2.username);

代码分析

首先这里构造函数中我们什么都没有定义的情况下,这里就是先搜索实例对象本身,如果在实例对象中找到了具有给定的属性或者方法则进行返回!

如果没有找到,则会根据一个叫__proto__指针原型对象中去寻找,如果找到就返回! 如果最终都没有找到则返回undefined

那么这个案例中,则执行了两次搜索!先询问了实例对象本身是否具有, 然后顺着指针到原型对象中去询问

所以说我们在实例对象上调用属性方法的时候,都会出现以上相同的搜索过程!

而有了这个搜索模式的帮助下,多个实例对象则可以共享原型对象所定义的属性方法就是这个原理!

如图

这里我再次提一嘴,前面不是使用到了constructor属性吗, 这个属性也默认是共享的,也就是所有实例对象默认情况下,都可以通过访问这个属性来确定构造函数是谁!

所以大家也应该注意一下,就是如果你在实例对象上定义了一个属性或者方法,而且原型对象中也定义了同名的属性或者方法,依照查找的顺序会依次搜索实例对象--->构造函数--->原型对象, 即便是同名也是优先调用最先找到的位置!

也就是说当你在实例对象上定义一个与原型对象中同名属性方法的时候,会自动屏蔽原型对象中的同名属性和方法,注意这里也仅仅是屏蔽,而不是覆盖! 当然换句话说也可以理解为你在实例对象上添加同名属性和方法的时候,会阻止访问原型对象中的同名属性和方法,明白这个意思吧!

即便是你在实例对象上把某个属性和方法的值定义为null , 那么访问的时候也只会停留在实例对象这个层面,而不会恢复其指向构造函数原型对象的链接!

但是如果你使用delete操作符是可以完全删除实例属性同时也删除构造函数中的同名属性,从而让我们能够访问原型对象中的同名属性!

例如

function Person(){
    this.username='李四';
}

Person.prototype.username='王五';
Person.prototype.age=30;
Person.prototype.job='设计师';
Person.prototype.say=function (){
    console.log('我是'+this.username);
}

var p1=new Person();

p1.username=null;

delete p1.username;

console.log(p1.username);  //这里输出的结果来自于 原型对象

其实我们就可以按照这个查找逻辑,来修改原型对象从而实现父子继承的关系 这个我们后面再说!

__proto__的真正含义!

那么实例对象到底底层是如何查到原型对象中去的呢?

这其实就要说到刚刚我们提及到的__proto__这个东西了! 嘿嘿

我们来看一张图:

如图

每个实例对象都会有一个 __ proto__ 属性,这个属性是自动生成的, __ proto__ 属性指向自己的原型对象

而且实例对象也就是通过这一条__ proto__线路,找到原型对象中的属性方法

这就是我马上要提到的原型链

特别注意

这里我提醒一下,可能你以前看到的也的确是叫__proto__这个

但是目前Chrome打印出来之后效果提示的是[[Prototype]]

如图

这里只是显示变了而已,代码层面上,实例对象还是可以继续使用__proto__这个属性的

然而__proto__的真正意义也就在于两个字:查找 也就是接下来要说的原型链

因为原型链就是通过__proto__属性形成的,任何对象普通对象函数对象都有__proto__属性

prototype与__proto__的区别

其实我们在上面的图中也能看出来彼此的一个很明显的区别:

__proto__ 实例对象指向原型对象的指针,我们俗称隐式原型,并且是每个实例对象都会有的一个属性!

prototype构造函数/函数才有的原型对象,我们俗称为显式原型

这里我特别提一下,其实prototype就是一个用来设置原型,而另外一个__proto__则用来查找数据,如果你还不明白,那么就看下面的原型链解释就清楚了!

所以说大家不要再把__proto__与函数的 func.prototype 属性混淆了!

原型链

理解了以上这些是什么之后,那么接下来,我们就可以来研究一下什么是原型链了!

并且js中实现继承主要是依靠原型链来实现! 所以我们才需要学习原型链的原理!

在学习原型链之前,你先记住一个概念,就是原型链: 其实是实例对象原型对象之间的链条!

我们之前不是说了 实例对象在搜索属性或者方法的优先级吗 ?

就是说如果当你调用一个属性或者方法时, 其实首先是会在当前实例对象上进行搜索, 如果没有那么就到构造函数中去进行搜索,如果还是没有,那么就到原型对象当中去是进行搜索

那么有人就会问了,如果 当前构造函数的原型对象中也没有呢? 接下来js会干什么呢?

揭晓谜底吧,请看下图:

如图

分析

以上这张图就是默认情况下,如果说person这个实例对象如果顺着__proto__这个链条没有在Person.prototype中找到想找的方法或数据,那么又会继续顺着__proto__这个链条往上走,继续寻找,

那么找谁呢? 这是js设计者规定的,默认就会找到一个Object.prototype原型对象当中去, 那么这样子的查找链条也就形成了原型链

并且既然这里有一个所谓的Object.prototype原型对象 那么这个对象也会有一个属性叫constructor的属性,来返指向其它的构造函数Object, 这里的Object.prototype原型对象自身其实也有一个__proto__, 因为我们说了,实例对象才有这个属性,所以还可以向上访问,不过这里再往上就只能返回null了,因为没有了!

小结

js其实在面向对象的设计之初就是依照近原则,当我们要使用一个方法和数据时,js会优先查找自身,如果没有就查找离自己最近的,这里也就指的是构造函数,如果自己没有,他就会沿着原型链__proto__这个链条,向上查找,如果还没有找到,它还会沿着原型链继续向上查找,直到找到Object.prototype原型对象

Object.prototype原型对象默认也是会有一些方法在里面的

如图

所以这里 其实就是解释了js中每个函数都存在原型对象属性prototype

并且在js中所有函数的默认的原型对象都是Object实例对象,而且默认还形成一个层层嵌套的形式,这也就是默认原型链

那我们要Object原型对象干嘛,它里面又没几个方法属性来满足我们日常开发的需求对吧,

所以我们需要扩大我们的原型链条, 这就要谈论到继承

原型对象中的this指向

当我们使用new操作符来执行一个函数的时候,这个时候,构造函数中的this会指向到该实例对象

原型对象中如果函数方法出现的this 那么也是指向的该实例对象

举个栗子

function Test(name,age,company,salary){
    this.username=name;
    this.age=age;
    this.company=company;
    this.salary=salary;
    console.log(this);//打印this
}

Test.prototype.say=function (){
    console.log(this); //打印this
}

var test=new Test('张三',18,'重庆科技','9K');

//打印实例对象
console.log(test);

结果如下

由此可见构造函数中的this原型对象中方法中的this 其实就是当前new出来的实例对象

原型链继承的实现

js继承就是通过原型链来实现的,那么到底如何实现呢?

我们废话不多说,直接看个案例!

代码

//猫类
function Cat(){
    this.username='小猫';
}

//狗类
function Dog(){
    this.username='小狗';
}

//老虎类
function tiGer(){
    this.username='老虎';
}

//猫类的原型对象中有一个方法
Cat.prototype.behavior=function (){
    console.log('【'+this.username+'】 这种动物真的会要咬人!!....');
    console.log(this);//谁调用this归谁!
}

//实例化猫类
var cat=new Cat();

//把狗类的原型对象指向猫
Dog.prototype=cat;

//实例化狗类
var dog=new Dog();

//把老虎的原型对象指向狗
tiGer.prototype=dog;

//实例化老虎类
var tiger=new tiGer();

//调用方法
tiger.behavior();
dog.behavior();

这里我们修改了原型对象的指向, 也就是修改了构造函数prototype属性值,对吧!

那么这样一来会造就什么样的情况呢?

简单一点说, 我们就会顺着一个: C实例−>C原型(B实例)−>B原型(A实例)−>A原型 这样一个过程来进行查找!

也就是实例tiger-->Tiger原型对象(dog实例)--->Dog原型对象(cat实例)--->Cat原型对象 进行查找

这里也很明显,tiGer实例原型对象都没有一个叫behavior的方法, 那么就会顺着一条线路,一直往上寻找

如图

当然你也可以通过修改__proto__来实现!

代码如下

//猫类
function Cat() {
    this.username = '小猫';
}

//狗类
function Dog() {
    this.username = '小狗';
}

//老虎类
function tiGer() {
    this.username = '老虎';
}

//猫类的原型对象中有一个方法
Cat.prototype.behavior = function () {
    console.log('【' + this.username + '】 这种动物真的会要咬人!!....');
    console.log(this);//谁调用this归谁!
}

//实例化猫类
var cat = new Cat();

//实例化狗类
var dog = new Dog();

//实例化老虎类
var tiger = new tiGer();

//修改原型链指针
tiger.__proto__= dog;
dog.__proto__= cat;

//console.log(tiger);
tiger.behavior();

原理分析

首先,定义了三个构造函数:CatDogTiger,每个构造函数都有一个属性username, 分别赋值为小猫"、"小狗"和"老虎

接下来,在Cat类的原型对象中定义了一个方法behavior,该方法用于打印出动物的名字以及调用该方法的对象信息。

然后,通过实例化Cat类创建了一个名为cat实例对象,并将Dog类的原型对象指向了cat

这样就建立了一个继承关系,即Dog类会继承Cat类的属性和方法。

接着,通过实例化Dog类创建了一个名为dog实例对象,并将Tiger类的原型对象指向了dog。同样地,这也建立了一个继承关系,即Tiger类会继承Dog类的属性和方法。

最后,通过实例化Tiger类创建了一个名为tiger的对象,并调用了它的behavior方法。

由于原型链上的继承关系,调用这个dog.behavior()tiger.behavior()都会查找到最终的原型对象也就是Cat.prototype中的behavior方法进行调用!

当然如果这里再调用Object.prototype.__proto__往上就没有了,就会返回null

这样就形成了一个父子级别的关系,因为我们通过修改prototype或者__proto__形成了一个链条

毕竟原型对象,其实也是一个Object实例,所以它也有一个__proto__属性,本身它在一个普通原型对象下的指向为Object.Prototype原型对象,也就是说所有函数的默认原型对象都是Object的实例, 但是这里我们把它修改了!

通过__proto__相连接, 每个继承父函数实例对象都包含一个__proto__指针

最后会指向我们指定父函数prototype原型对象

这样一直可以以此类推,进行迭代父函数原型对象, 利用__proto__属性一直可以再往上一层继承。

在这个程中就形成了原型链

我们也可以使用Chrome并且打印一下实例对象来进行查看这个链条的走向!

console.log(tiger);

如图

这里如果眼尖的朋友可能已经注意到了一个问题,那就是constructor这个属性显示不见了, 构造函数的指向也不对了、原型的显示也不对了, 全部都指向了Cat构造函数, 当然从继承的效果上是不影响的!

我们可以用以下代码测试一下:

console.log(tiGer.prototype.constructor);
console.log(Dog.prototype.constructor);

如图

原因:简单点说因为修改原型对象的时候,指向了另一个新的实例对象,所以把 constructor给丢失了!

如果你想看上去比较合理一点,加入以下代码

解决方案

Dog.prototype.constructor=Dog;
tiGer.prototype.constructor=tiGer;

修改之后如图

这就是原型链查找的关系,一层一层的链接关系就是:原型链

有些实例对象能够直接调用Object.prototype中的方法也是因为存在原型链的机制!

所以说JavaScript原型链用于实现继承就是这样实现的!

给大家专门准备了一张通用默认原型链原理图,拿去背吧!!

如图

基于原型链的继承

看了以上的案例和图例之后,我们应该就对javascript中的继承有个深入的理解了!

JavaScript对象其实都会有一个指向一个原型对象链条, 当我们试图访问一个对象的属性时,它不仅仅在该对象本身上去进行搜寻,还会搜寻该对象的原型,以及原型的原型依次层层向上搜索,直到找到一个名字匹配的属性为止, 或到原型链的末尾!

对象属性的继承

但是有一点我觉得值得注意,就是修改原型链 也就是使用{ __proto__: ... } obj.__proto__ 有点不同,前者是标准且未被弃用的一种方式!

举个栗子

var obj={ 
    a: 值1, 
    b: 值2,
    __proto__: c 
}

比如在像这样的对象字面量中,c的值必须为 null 或者指向另一个对象用来当做字面量所表示的对象原型链,而其他的如ab将变成对象自有属性, 这种语法读起来非常自然,并且兼容性也比较不错!

我们来看个实际的小案例

代码如下

const obj = {
    a: '张三',
    b: '李四',
    __proto__: {
        b: "王五",
        c: "绿巨人",
    },
};

console.log(obj);
console.log(obj.a);
delete obj.b;
console.log(obj.b);
console.log(obj.c);

分析

当前obj的原型链中具有属性 b和c两个属性
如果obj.__proto__.__proto__ 依照之前的图例肯定是访问到Object.prototype
最后obj.__proto__.__proto__.__proto__则是 null, 这里就是原型链的末尾,值为null
完整的原型链看起来像这样:
{ a: 张三, b: 李四 } ---> { b:王五, c: 绿巨人 } ---> Object.prototype ---> null

那么要说继承关系的话,那就是__proto__ 设置了原型链,也就是说它在这里的原型链被指定为另一个对象字面量!

即便是这里我使用了delete obj.b删除了属性b,也会从__proto__这个链条找到原型链中所继承来的属性b

如图

但是注意了如果这里我没有使用delete obj.b来删除了属性b 那么,当我们调用obj.b返回的则是李四而不是王五,这里其实叫做属性遮蔽(Property Shadowing),意思是虽然没有访问到王五,但这只是被遮住了而已,并不是被覆盖和删除的意思!

当然我们也可以根据这个原理来创建更长的原型链,并在原型链上查找一个属性

代码如下

const obj = {
    a: 1,
    b: 2,
    // __proto__ 设置了原型链。它在这里被指定为另一个对象字面量。
    __proto__: {
        b: 3,
        c: 4,
        // __proto__ 设置了原型链。它在这里被指定为另一个对象字面量。
        __proto__: {
            d: 5,
        },
    },
};
console.log(obj.d); // 输出5

那么它的原型链就是如下这样:

{ a: 1, b: 2 } ---> { b: 3, c: 4 } ---> { d: 5 } ---> Object.prototype ---> null

其实就是这样就可以嵌套很多层出来,让对象看起来更加有层次结构,也方便管理一些特殊的数据!

对象方法的继承

方法或者函数继承js中其实和属性继承也没有差别!

特别要说明的其实也就是this的指向,当继承的方法被调用时,this值指向的是当前继承的对象,而不是拥有该函数属性的对象

代码说明

const parent = {
    username: '张三',
    age:35,
    method() {
        return '我的年龄是'+(this.age + 1)+'岁';
    }
}

console.log(parent.method()); //输出36


//然后我们通过child继承了parent的对象
const child = {
    __proto__: parent,
}
console.log(child.method());//输出36
child.age = 5; 

console.log(child.method());

代码分析

当调用 parent.method 时,this指向了parent所以按照正常逻辑执行 所以输出36

然后我们通过child继承了parent的对象

现在调用 child.method 时,this虽然指向了child,但是又因为 child 继承的是 parent 的方法,
首先在 child 上寻找有没有method方法, 但由于child本身没有名为method方法,则会根据原型链__proto__找上去,最后找到即 parent.method方法来执行!

然后我们在 child添加一个age属性赋值为5, 这会就会遮蔽parent上的age属性

child对象现在的看起来是如下这样的:
{ age: 5, __proto__: { username: '张三', age: 35, method: [Function] } }

最后输出:6, 是因为child 现在拥有age属性,就不会去找parent对象中的age属性了, 但是方法还是会去找parent中的method方法, 而方法中的this.age现在表示 child.age然后再这个基础上+1结果就是这样了!

更多参考案例

function Test() {
    this.username = '张三';
    this.age = 33;
    this.job='软件开发';
}

Test.prototype.say=function (){
    return '我的名字叫:'+this.username+',我的年龄是:'+this.age+'我的职业是:'+this.job;
}

var test=new Test();

//新建一个对象,并且修改原型链
var obj = {
    username:'李四',
    age:'35',
    __proto__:test
}


console.log(obj);
console.log(obj.username);
console.log(obj.age);
console.log(obj.say());

另类继承实现方法

修改构造函数this指向从而实现继承

我们有时候可以借助call方法来实现简单的继承效果!

举个栗子

function Animal(name,age,food){
    this.username=name;
    this.age=age;
    this.eat=function (){
        console.log('这只['+this.username+']动物要吃['+food+']');
    }
}


Animal.prototype.color='黑色';
Animal.prototype.say=function (){
    console.log('我的名字叫'+this.name);
}

function Panda(name,age,eat){
    this.like='玩耍';
    Animal.call(this,name,age,eat); //借用一下
}

var p1=new Panda('熊猫盼盼',18,'竹叶');

//打印输出
console.log(p1);
p1.eat();

结果

这个案例中,应用了call修改this指向来达到一个共享的目的!

但是这种使用call等方法来修改this严格意义上来讲,只能算借用!

因为这种方式有一个很大的缺点,就是不能继承所谓父类原型里面的属性方法,不然你看上图,打印的结果当中并没有出现Animal类原型对象中的color属性say方法

所以这种继承方式如何和prototype修改原型方式结合一起使用就会有意想不到的效果,并且参数的传递也会更加灵活多变!

举个栗子

//定义构造函数
function Person(userename,age,sex){
    this.name=userename;
    this.age=age;
    this.sex=sex;
    this.type='人类';
}
Person.prototype.say=function(){
    console.log("hello world");
}

function Student(username,age,sex,score){
    //借用Person构造函数
    Person.call(this,username,age,sex);
    //定义属性
    this.score=score
}

//改变原型指向
Student.prototype=new Person();//不传值
Student.prototype.behavior=function(){
    console.log("英语学习!!");
}


var s1=new Student("张三",15,"男","100分")

//打印结果看看
console.log(s1);
console.log(s1.type);
console.log(s1.name);
console.log(s1.age);
console.log(s1.sex);
console.log('考试得分:'+s1.score);

s1.behavior();
s1.say();

代码分析

从上面的代码中,我们可以看到构造函数Student中我们借用了Person构造函数, 然后在通过prototype修改原型指向,这样一来,不仅可以获取到父类构造函数中的属性和方法 也可以获取到父类原型对象中的属性和方法

这时都可以通过__proto__这个链条拿到!

如图

通过循环复制实现继承

我们的原型对象prototype既然是一个对象,那么我们也可以通过循环复制的手法把父级原型对象里面的属性和方法拷贝到目标原型对象下,同时也可以结合call方法借用构造函数中的属性和方法

代码如下

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

Person.prototype.type = "人类";
Person.prototype.nationality = "中国";
Person.prototype.job = '软件开发';
Person.prototype.like = '足球,篮球,游戏';
Person.prototype.test = 123;


function Student(username,age) {
    Person.call(this,username,age);
}

var per = Person.prototype;
var stu = Student.prototype;


//过滤不需要的属性
var arr=['type','nationality','test'];
for (k in per) {
    if(arr.indexOf(k)==-1){
        stu[k] = per[k];
    }
}

var s1=new Student('李四',18);
var s2=new Student('王五',25);

console.log(s1);
console.log(s2);

如图

__proto__的兼容性

根据MDN官方的建议,其实__proto__是被弃用了的!

那么到底我们平常使用什么来修改原型的指针呢?

JavaScript 中,你的确是可以通过直接修改实例对象__proto__ 属性来达到目的!

__proto__ 是一个非标准的属性,它在大多数的js环境中都可以使用,包括浏览器Node.js 但是由于这个属性是非标准的,它在一些环境中可能不可用,或者在未来的标准中可能会被弃用,也就是说虽然一些浏览器仍然支持__proto__,但也许已从相关的web标准中移除,也许正准备移除或者出于兼容性而保留!

__proto__浏览器兼容性如下表

所以如果可以的话我们尽量不使用__proto__而改成其他,例如:Object.setPrototypeOf方法

举个栗子

让我们使用Object.setPrototypeOf来实现一个简单的继承

代码如下

function Test(){

}

Test.prototype.company='重庆科技';

function Test2(){

}

Test2.prototype.num=100;


function Test3(){

}

Test3.prototype.username='张三';

//实现继承
Object.setPrototypeOf(Test2.prototype, Test.prototype);
Object.setPrototypeOf(Test3.prototype, Test2.prototype);

var t3=new Test3();
console.log(t3);

效果

再看一个案例!

const a = { company : '重庆科技' };
const b = { age: 33 };
const c = { username: "张三" };

//实现继承
Object.setPrototypeOf(a, b);
Object.setPrototypeOf(b, c);

console.log(a);
console.log(a.username);
console.log(a.age);
console.log(a.company);

效果如下

所以我觉得可以的情况下,尽量使用标准的Object.setPrototypeOf方法来实现继承

因为Object.setPrototypeOf方法基本上被所有现代浏览器引擎所支持, 并且也允许动态地修改对象的原型

原型链与继承查找机制

当你访问一个对象的属性方法时,如果这个对象本身没有这个属性方法,那么js会在这个对象的原型中寻找这个属性或方法,如果找到了,就会使用它, 如果还是找不到,就会在原型的原型中寻找,以此类推,直到找到为止, 而继承的关键,也就在于自定义修改原型的指向!

所以当你把之前的原型链图分析透彻,你就会知道原型链就是通过__proto__属性形成的,任何对象普通对象函数对象都有__proto__属性,并且其核心思想也就是通过__proto__这个链条来进行查找数据!

DOM原型链的形成

这其实也很好的解释了我们javascriptDOM属性和方法也是这样子进行查找的

html

 <div id="connent"></div>

js

var oDiv = document.getElementById("connent");

console.log(oDiv.__proto__);
console.log(oDiv.__proto__.__proto__);
console.log(oDiv.__proto__.__proto__.__proto__);
console.log(oDiv.__proto__.__proto__.__proto__.__proto__);
console.log(oDiv.__proto__.__proto__.__proto__.__proto__.__proto__);
console.log(oDiv.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__);
console.log(oDiv.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__);

结果如下

其实这样你就会知道当一个DOM元素在使用某个属性和方法的时候,是怎么进行查找的它的链条也是通过__proto__来进行查找的,对吧! 其中顺序如下

↓HTMLDivElement
↓HTMLElement、
↓Element、
↓Node、
↓EventTarget
↓Object

通过__proto__就形成了JavaScript中与DOM文档对象模型相关的概念了

这里也给大家简单介绍一下,方便理解!

  1. HTMLDivElement: 这是一个代表 HTML <div> 元素的类, 它继承了 HTMLElement 的属性和方法,包括可以用来改变元素样式的属性和方法,当然这里我只是举个栗子,不一定就是div元素根据你打印的情况决定!
  2. HTMLElement: 这是一个基础类,代表任何 HTML 元素, 所有的 HTML元素都继承了 HTMLElement 的属性和方法
  3. Element: 这是一个基础类,代表任何 HTML 或 XML 元素, 它定义了所有元素共享的属性方法,例如 getAttribute()setAttribute()
  4. Node: 这是所有DOM节点的基类,包括元素、文本节点、注释等, 它定义了一些通用的属性方法,如 parentNodechildNodes
  5. EventTarget: 这个接口表示可以添加或删除事件监听器的事件目标
  6. Object: 这个也就是顶层的Object构造函数

在w3c也有这些属性和方法的详细解释

如图

而且这些继承关系这样子一直走下来查找的属性方法的关键就是原型链, 可以说没有原型链就没有现在的javascript

当查找对象的某个属性方法的时候,首先在当前对象中查找,如果没有去对象的__proto__中去查找, 这样子一直到最顶层null,而这样的__proto__形成的一条查找链条就是原型链 现在你可以感受一下是不是如此呢!

并且继承也就是修改原型的指向,即__proto__prototype

以上这些接口一起构成了JavaScript DOM API应用程序编程接口, 这样来允许我们以代码编程方式操作网页中的元素内容、结构和样式。

DOM中所有的属性方法你都可以看做为一个原型链的继承关系!

其实你可以去通过js创建一些xml、svg、普通元素以及文档模型!

代码如下

// 创建一个新的XML文档
var xmlDoc = document.implementation.createDocument(null, null);
// 创建根元素
var root = xmlDoc.createElement("root");
xmlDoc.appendChild(root);
// 创建一个子元素
var child = xmlDoc.createElement("child");
// 设置子元素的内容
var childText = xmlDoc.createTextNode("This is a child element");
child.appendChild(childText);
// 将子元素添加到根元素
root.appendChild(child);

// 打印XML文档
console.log(xmlDoc.__proto__);
console.log(xmlDoc.__proto__.__proto__);
console.log(xmlDoc.__proto__.__proto__.__proto__);
console.log(xmlDoc.__proto__.__proto__.__proto__.__proto__);
console.log(xmlDoc.__proto__.__proto__.__proto__.__proto__.__proto__);


console.log("------------------------------------------------------------");

var oDiv = document.getElementById("oDiv");
console.log(oDiv.__proto__);
console.log(oDiv.__proto__.__proto__);
console.log(oDiv.__proto__.__proto__.__proto__);
console.log(oDiv.__proto__.__proto__.__proto__.__proto__);
console.log(oDiv.__proto__.__proto__.__proto__.__proto__.__proto__);


console.log("------------------------------------------------------------");


console.log(document.__proto__);
console.log(document.__proto__.__proto__);
console.log(document.__proto__.__proto__.__proto__);
console.log(document.__proto__.__proto__.__proto__.__proto__);
console.log(document.__proto__.__proto__.__proto__.__proto__.__proto__);

console.log("------------------------------------------------------------");

// 创建一个新的SVG文档
var svgNS = "http://www.w3.org/2000/svg";
var svgDoc = document.implementation.createDocument(svgNS, "svg", null);

// 添加根元素
var root = svgDoc.documentElement;

// 添加一个矩形元素
var rect = svgDoc.createElementNS(svgNS, "rect");
rect.setAttribute("x", 10);
rect.setAttribute("y", 10);
rect.setAttribute("width", 100);
rect.setAttribute("height", 100);
rect.setAttribute("fill", "blue");
root.appendChild(rect);

// 添加一个圆形元素
var circle = svgDoc.createElementNS(svgNS, "circle");
circle.setAttribute("cx", 120);
circle.setAttribute("cy", 120);
circle.setAttribute("r", 50);
circle.setAttribute("fill", "red");
root.appendChild(circle);

// 将SVG文档添加到HTML文档中
document.body.appendChild(root);


console.log(rect.__proto__);
console.log(rect.__proto__.__proto__);
console.log(rect.__proto__.__proto__.__proto__);
console.log(rect.__proto__.__proto__.__proto__.__proto__);
console.log(rect.__proto__.__proto__.__proto__.__proto__.__proto__);
console.log(rect.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__);
console.log(rect.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__);
console.log(rect.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__.__proto__);

然后看看他们的__proto__原型链的指针你就明白了!

如图

使用原型和原型链的好处

到这里学了那么多,我们使用原型链到底有什么好处呢?

其实原型链也可以适当的帮助我们优化代码,减少代码冗余,提高程序代码的复用性!

举个栗子

现在有一个属性应该出现在每一个实例上,那我们就可以重用它,尤其是对于方法或者函数这种类型的属性!

比如说现在有多个实例字面量对象,每一个对象都是一个容器,而里面都包含一个 getValue方法 可以用来访问的值对象本身内部的某值!

代码如下

const arr = [
    { value: '张三', getValue() { return this.value; } },
    { value: '李四', getValue() { return this.value; } },
    { value: '王五', getValue() { return this.value; } },
];

但是你可以想一下,这样子做好吗? 每一个对象都基本上有同样的代码, 这就是冗余且不必要的代码,并且我以前也说过,一个函数在一个对象中,就会开辟一块内存空间,如果代码巨大的情况下,这样子做非常耗费内存资源!

所以你可以尝试优化一下,当然优化的办法有很多,这里我们重点讨论的就是原型链

你可以试想一下将 getValue方法移动到所有盒子的原型链[[prototype]]上!

那么我们加以修改一下,变成如下形式

代码

//公共使用
const _ObjPublic={
    getValue() {
        return this.value;
    }
}

const arr = [
    { value: '张三', __proto__:_ObjPublic },
    { value: '李四', __proto__:_ObjPublic },
    { value: '王五', __proto__:_ObjPublic },
];


console.log(arr[0].getValue());
console.log(arr[1].getValue());
console.log(arr[2].getValue());  

效果如下

这样一来所有对象中的 getValue方法 都会根据原型链的原理找到并引用相同的函数,降低了内存使用率!

但是上面这样一个一个手动去捆绑原型链太麻烦了, 如果代码多了这样一个一个的去修改也是一件很大工程量的事情! 那么怎么办呢?

这时,我们就可以使用构造函数方式创建实例对象

因为当我们使用构造函数来构造的实例对象它会自动为实例对象设置 原型链(__proto__)属性

构造函数是使用 new 调用的函数 还记得吗!

其实在js的设计之初,就给我们考虑过这些问题, 大致我分为以下几个用途:

  1. 优化和简化代码,实现代码重用
  2. 根据__proto__的链条实现属性和方法继承,只要是这个__proto__链条上的东西,都可以被调用到

所以在js中就有了这样一个说法:js是基于对象的脚本语言,但是也有人说js是基于原型的脚本语言!

我们单纯的来说一下prototype原型对象 用它来实现代码重用与属性和方法的共享!

所以为了避免了代码冗余,公共使用的属性方法,我们是可以设置到原型对象中的!

方法

构造函数名.prototype.属性=值;

构造函数名.prototype.方法=function(){
  ..代码段..
} 

然后通过构造函数实例化的所有实例对象都可以使用该构造函数对应原型对象中的属性方法

也就是说这个类型的实例对象就都会共享这些属性方法 也就是通过原型链(__proto__)在进行查找!

这样做的一个好处是:减少了内存占用, 并且也实现了代码重用! 也是使用原型对象的一大优点

代码如下

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

createPerson.prototype.say=function () {
    console.log('我的名字叫【'+this.name+'】, 我的年龄是:'+this.age);
}

var a=new createPerson('张三','33');
var b=new createPerson('李四','55');

a.say();
b.say();


console.log(a.say===b.say);

如图

大家可以看到,say方法,我没有定义到实例对象上,也没有定义到构造函数当中,而是定义到了原型对象里面!

并且这样子做就相当于所有的实例对象都共享一个方法,那么它们的地址都是相等的了!

这样做的好处,在于节约内存开销 为什么这样说呢?

我们来看下面这张图:

如图

这就是让公用的方法或者属性在内存中只存在一份,所以prototype就是这样来实现数据的共享, 不然的话你每一次new都会在内存中创建一份属性或者方法出来

而不管我们实例化多少次对象出来,原型对象里面的属性和方法只生成一次,所以会节省内存, 同时提高代码的可重用性和可维护性 。

也就是说只要是通过 new 创建的实例对象,无论多少次,它们的__proto__都是指向构造函数的prototype

如图

所以我们给构造函数原型对象添加一些方法,就能让创建的多个实例对象共享同一个方法,减少内存的使用。

当然你也可以把所有的属性和方法都添加到原型对象当中,构造函数中就不用再去定义了,看情况来决定!

相当于构造函数创建的每一个实例都会自动将构造函数prototype属性作为其 原型链__proto__

你完全可以使用Object.getPrototypeOf方法来进行验证以下

console.log(Object.getPrototypeOf(a) === createPerson.prototype);  //返回true
console.log(Object.getPrototypeOf(b) === createPerson.prototype);  //返回true
console.log(createPerson.prototype.constructor === createPerson);  //返回true

字面量与原型链之间的关系

JavaScript 中的一些字面量语法会隐式的创建原型链__proto__

这里我给大家举几个案例就会明白了~~

举栗

对于使用对象字面量创建的对象,__proto__返回的是:Object的原型对象

你也可以理解为对象字面量没有 __proto__ 的情况下,自动将Object.prototype 作为它们的__proto__

代码

var obj = {}
console.log(obj.__proto__);
console.log(Object.getPrototypeOf(obj) === Object.prototype);  //返回true

对于使用数组字面量创建的对象,__proto__返回的是:数组的原型对象

也就是说数组字面量会自动将 Array.prototype 作为它们的 __proto__

代码

var arr = [];
console.log(arr.__proto__);
console.log(Object.getPrototypeOf(arr) === Array.prototype); //返回true

如果是正则表达式字面量,则会自动将 RegExp.prototype 作为这些字面量的 __proto__

const regexp = /abc/;
console.log(Object.getPrototypeOf(regexp) === RegExp.prototype) // true

对于使用字符串字面量方式创建的字符串对象,__proto__返回的是:字符串的原型对象,也就是说

如果是字符串字面量,则会自动将 String.prototype 作为这些字面量的 __proto__

var str = "";
console.log(str.__proto__);
console.log(Object.getPrototypeOf(str) === String.prototype) // true

如果是使用数字字面量方式创建的数值对象,__proto__返回的是:数值的原型对象,也就数值字面量,则会自动将 Number.prototype 作为这些字面量的 __proto__

var num = 100;
console.log(num.__proto__);
console.log(Object.getPrototypeOf(num) === Number.prototype) // true

那么如果是函数呢,一个函数名称其实也算是一种函数字面量的形式!

__proto__返回的是:函数的原型对象,也就是Function.prototype

也就是会自动将 Function.prototype 作为这些函数字面量__proto__值!

var fn = function () {
}
console.log(fn.__proto__);
console.log(Object.getPrototypeOf(fn) === Function.prototype) // true

所以说这又解释了为什么有些属性和方法只是在特定的构造函数上定义的, 而它们又自动在所有特定的实例对象上才可以使用,对吧!

比如像 map()这样的数组方法只是在 Array.prototype 上定义的方法,而它又只会自动在所有数组实例上可用,就是因为这个原因!

性能与原型链

了解原型继承的模型是使用javascript编写复杂代码的重要基础,另外我们还要注意代码中原型链的长度,在必要时可以将其分解,以避免潜在的性能问题!

因为原型链上较深层的属性方法的查找, 在时间上可能会对性能产生负面影响,这在性能至关重要的代码中可能会格外明显, 因为如果尝试访问不存在的属性始终会遍历整个原型链,也就是原型链中的每个可枚举属性都将被枚举, 那么层次多了反而不好!

所以说我们在遍历对象的属性时,最好先判断一下 要检查对象是否具有在其自身上是否有定义的属性,而不是让__proto__自动的去搜索其原型链上的某个地方! 必要的情况下可以使用hasOwnProperty()判断

举个栗子

function Graph() {
    this.vertices = [];
    this.edges = [];
}

Graph.prototype.addVertex = function (v) {
    this.vertices.push(v);
};

const g = new Graph();
// 当前原型链为: g ---> Graph.prototype ---> Object.prototype ---> null

//检查对象自身是否有vertices属性  返回true
console.log(g.hasOwnProperty("vertices"));  
//检查对象自身是否有nope属性  返回false
console.log(g.hasOwnProperty("nope"));      
//检查对象自身是否有addVertex属性  返回false
console.log(g.hasOwnProperty("addVertex")); 
//检查原型对象自身是否有addVertex属性  返回true
console.log(Object.getPrototypeOf(g).hasOwnProperty("addVertex")); 

最后总结

原型链其实是一种关系的链条, 它是让实例对象原型对象之间产生关系一种链条!

而这个关系是通过原型([[Prototype]])也就是__proto__来进行关联的!

而也只有实例对象才有这个__proto__不标准的属性,当然这里的意思是有的游览器并不支持这个属性!

那么有了原型链我们实例对象在进行查找属性的时候则按照以下规则:

首先在实例对象上查找,如果有则使用自身带有的属性或方法,如果没有则通过__proto__指向的原型对象进行 查找,找到则使用, 如果找不到则继续向原型对象__proto__进行查找, 找到则使用,以此类推, 如果最终未找到则会报错!

同时,我们也对构造函数有了一个深入的了解,也就是构造函数实例化的时候会生成一个叫prototype的属性,它就是构造函数原型对象, 这个对象中还有一个默认存在的属性constructor用来指向原型对象所在的构造函数的指针!

实例对象__proto__属性指向的是构造函数原型对象,这个对象的指向是可以被修改的,从而实现层层继承

也就是说原型指向是可以被改变的, 也不管你是修改prototype还是__proto__最终原来本身指向的原型会指向到一个新的原型对象上从而通过__proto__查找链条来实现继承关系!

"👍点赞" "✍️评论" "💙收藏"

大家的支持就是我坚持下去的动力!

如果以上内容有任何错误或者不准确的地方,🤗🤗🤗欢迎在下面 👇 留个言指出、或者你有更好的想法,🤝🤝🤝🤝欢迎一起交流学习❤️❤️❤️❤️❤️


关注 极客小俊 微信公众号 不定期更新免费技术干货



VX技术交流

posted @ 2023-12-03 02:45  极客小俊  阅读(146)  评论(0编辑  收藏  举报