开发者社区 > 博文 > js基础之原型链
分享
  • 打开微信扫码分享

  • 点击前往QQ分享

  • 点击前往微博分享

  • 点击复制链接

js基础之原型链

  • jd****
  • 2021-02-19
  • IP归属:北京
  • 15520浏览

    概述

    要了解什么是原型链,首先我们要了解什么是原型。

    在之前的文章 V8引擎的内存管理分析 中我们讲到,在JavaScript中,函数也是对象,是可以直接添加属性和方法的。在浏览器的底层实现中,每当我们声明一个构造函数,浏览器就会默认为它添加一个特殊的属性prototype(即原型),而这个特殊的属性,就是我们所说的构造函数的原型。JavaScript的继承机制,正是借助这个神奇的属性实现的!

    那么什么是原型链呢?我们可以先简单地理解为:在实现对象的继承时,会形成一条基于原型的链式结构,我们就称其为原型链。下面就详细探讨原型链的实现。

    原型链的构造过程

    1. 原型的构造

    比如现在我们声明了一个构造函数:

    function People(){
    	this.name = "夕山雨";
    	this.age = 24;
    	...
    }
    

    借助内存管理那篇文章的讲解,我们知道,浏览器会在函数定义区为这个构造函数开辟一块内存,将其作为一个对象保存。之后浏览器会为这个构造函数添加一个默认的属性prototype,该属性是一个对象,它有一个属性constructor指向构造函数自身,于是构造函数就会变成下面的结构:在这里插入图片描述

    从图中可以看到,此时构造函数People作为一个对象,拥有了一个prototype属性,指向堆区的一个原型对象,而这个原型对象也有一个constructor属性,指向该原型对象所属的构造函数。这样,浏览器就完成了对构造函数原型的构造。

    2. 对象的创建

    到目前为止,我们还不知道浏览器为构造函数添加这个特殊的属性有何意义,而当我们讨论到对象的创建时,它的作用就体现出来了。比如我们现在使用上述构造函数构造了下面一个对象:

    let lj = new People();
    

    在生成一个对象lj时,浏览器会向这个对象内部添加一个特殊的内部属性__proto__,这个特殊的属性就指向构造函数的原型protptype。于是内存中的结构就变成了下面的样子:

    在这里插入图片描述

    我们使用构造函数People构造的对象lj现在有了一个指向该构造函数原型的内部指针,也就是说,我们现在可以通过该指针来访问构造函数的原型属性了。比如我们为该原型对象添加了一个方法:

    People.prototype.run = function(){
      ...
    }
    

    那么理论上我们就可以使用lj._proto_.run()来调用该原型上的方法。当然我们平时并不是这么调用的,我们会通过lj.run()来直接调用原型上的方法。这是因为浏览器允许我们使用统一的方式访问对象自身及原型对象上的方法和属性。浏览器会先从对象自身查找要访问的方法和属性,如果找不到,就会通过内部指针__proto__到原型对象上查找,因此我们通常感受不到该内部指针的存在(实际上该指针也是不推荐访问的,因为向开发者暴露该指针只是浏览器开发商的通俗约定,并非国际规范)。

    讨论了这么多,我们似乎还是没有了解原型对象存在的必要性。对于上面的情况,我们完全可以直接将这些属性和方法添加到对象中,反倒省了去原型对象查找的麻烦。实际上,当我们使用People构造函数构造多个对象时,prototype的价值就体现出来了。如图:

    在这里插入图片描述

    由于每个构造出来的对象都是独立的,假如没有原型对象,每一个对象都必须有一个run方法,他们在内存中都占有独立的内存,但是每个对象的run方法又都是一样的,因此非常浪费内存。

    我们可以很自然地想到,既然每个对象的run方法都是一样的,那只要在内存中保存一个,然后让所有对象都拿到对它的引用不就可以了?没错,原型对象就是用来保存所有这样的公共方法和属性而存在的。所有由构造函数People构造出的对象都会有一些公共的属性和方法,我们将他们保存在构造函数的prototype属性中,然后让所有的实例指向它。现在无论该构造函数构造出了多少实例,公共方法和属性都只在内存中保存一份,大大节省了内存。

    3. 原型链与继承

    现在我们知道了浏览器会自动为构造函数添加一个属性prototype用于保存公共方法和属性。这时如果我们将另一个构造函数所构造出的对象赋值给该构造函数的原型属性会发生什么呢?如图:

    在这里插入图片描述

    实际上原型链已经出现在了图中!我们可以使用下面的语句构造上面的关系:

    function Chinese(){}
    function People(){}
    People.prototype.run = function(){}
    Chinese.prototype = new People();
    
    let carter = new Chinese();
    //使用Chinese对象调用People原型上的方法run
    carter.run();
    

    现在,由Chinese构造出的对象carter就可以调用People原型上的方法了!

    在上述语句中,我们通过Chinese.prototype = new People();在内存中构造出一个People对象(图中上方正中间的peo),该对象内部有一个__proto__指向People的原型对象。而由于它现在又是Chinese的原型对象,因此Chinese构造出的对象carter也有一个内部指针__proto__指向peo,也就是说,现在我们可以通过carter.__proto__.__proto__访问到People原型对象上的方法run了。由于浏览器自动查找原型的机制,我们可以省略上述所有的__proto__,直接通过carter.run()调用People原型上的方法run。

    图中由对象carter指向对象peo,再由peo指向People原型的指针链,就是我们构造出的原型链。在构造这条原型链的同时,我们也实现了JavaScript的继承,因为我们现在已经能在Chinese对象上调用People构造函数的原型方法了。

    假如我们现在又写了以下语句:

    function ChineseMan = function(){}
    ChineseMan.prototype = new Chinese();
    
    let lj = new ChineseMan();
    lj.run();
    

    现在ChineseMan的原型是一个Chinese实例,那么按照上述分析,我们可以借助原型链访问到Chinese的原型上的方法,由于Chinese的原型是People的实例,因此我们还可以继续上溯,访问到People原型中的方法。这样我们就可以通过lj.__proto__.__proto__.__proto__访问People的原型对象。现在,我们构造出了更长的原型链,我们称ChineseMan继承自Chinese,而Chinese继承自People。

    原型链有什么用?

    面向对象语言的继承有两种实现方式,一种是接口继承,另一种是实现继承。前者是继承函数的签名,后者是继承实际的方法。因为JavaScript没有函数签名,所以只支持实现继承,而这种实现继承就是通过原型链来实现的(引自JavaScript高级程序设计)。

    从上面的话我们知道,原型链就是用来实现JavaScript的继承的。对于上面的例子,在People的原型对象上暴露出的方法run,既可以被People构造出的实例调用,又可以被继承自People的Chinese的实例调用,同时还可以被ChineseMan的实例调用,这样浏览器只需要在内存中维护一个名为run的函数即可,这大大节省了内存空间。

    上述原型链存在的问题

    我们知道,在JavaScript中,通过指针引用的内存是共享的。也就是说,该构造函数的所有实例和继承自该构造函数的所有实例,都是在操作同一块内存区。这就是说,假如People的原型对象上有一个属性name,那么一旦通过某个实例修改了它的值,所有能访问到该属性的实例得到的结果都会发生变化(这样该属性很快就会变成不可预测的)。

    大多数情况下,我们希望继承下来的属性应当是不可被随意更改的,因此上述实现继承的方式很少用于属性的继承(方法的继承不存在这个问题)。

    另外,我们上面讲到,构造函数的原型对象中有一个constructor指针,指向该构造函数,而通过语句:

    Chinese.prototype = new People();
    let carter = new Chinese();
    

    这样重新为Chinese的prototype赋值后,由于People的实例是没有constructor属性的,现在Chinese的原型也就没有了指向Chinese的指针,这样我们就无法通过carter.__proto__.constructor来找到carter的构造函数。实际上当我们去访问carter.constructor时,由于浏览器的查找机制,返回的结果会是People(来自原型链的查找结果),我们无法通过原型对象检测该对象对应的构造函数了!

    不过这个问题很容易解决,那就是重新为Chinese的原型对象增加constructor属性,即:

    Chinese.prototype.constructor = Chinese;
    

    上述构造原型链的方式还有一个问题,就是不能在构造子类的实例时,向父类的构造函数传递参数。对上面的例子来说,我们在构造Chinese的实例时,无法向People的构造函数传递参数,只能在执行Chinese.prototype = new People();时进行传值,这样无法满足动态构造父类的需求。

    因此在实际编码中,我们一般不使用上述简单的方式构造原型链。实际的构造方法我们会在后续文章中讨论。

    补充 - 从原型链看new关键字

    我们知道,JavaScript使用new关键字来生成一个对象,即上述的:

    let carter = new Chinese();
    

    那么浏览器在背后都做了哪些工作,以及我们如何自己实现一个具有new功能的函数呢?

    实际上浏览器遇到new关键字时主要做了三件事:

    1. 创建一个空对象
    2. 向该对象添加__proto__属性,指向构造函数的原型对象
    3. 调用构造函数为上述对象赋值

    现在我们可以实现一个简单的函数如下:

    function myNew(constructor, ...args){
      //生成一个空对象
      let obj = {};
      //将obj的__proto__指向构造函数的原型对象
      obj.__proto__ = constructor.prototype;
      //调用构造函数,构造obj
      constructor.apply(obj, args);
      return obj;
    }
    
    let carter = myNew(Chinese, "夕山雨");
    

    这就是一个最简单版本的new的实现了。

    总结

    以上就是对JavaScript原型链的介绍。原型链作为JavaScript最重要的概念之一,深刻影响了JavaScript这门语言很多的语法特性,希望大家多理解,多思考。也希望大家继续关注后面关于JavaScript继承方式的介绍,会介绍很多构造原型链的方式。