JavaScript:原型(prototype)

面向对象有一个特征是继承,即重用某个已有类的代码,在其基础上建立新的类,而无需重新编写对应的属性和方法,继承之后拿来即用;

在其他的面向对象编程语言比如Java中,通常是指,子类继承父类的属性和方法;

我们现在来看看,JS是如何实现继承这一个特征的;

要说明这个,我们首先要看看,每个对象都有的一个隐藏属性[[Prototype]]

对象的隐藏属性[[Prototype]]

在JS中,每个对象obj,都有这样一个隐藏属性[[Prototype]],它的值要么是null,要么是对另一个对象anotherObj的引用(不可以赋值为其他类型值),这另一个对象anotherObj,就叫做对象obj的原型;

通常说一个对象的原型,就是在说这个隐藏属性[[Prototype]],也是在说它引用的那个对象,毕竟二者一致;

现在来创建一个非常简单的字面量对象,来查看一下这个属性:

image-20221219202044247

可以看到,对象obj没有自己的属性和方法,但是它还有一个隐藏属性[[Prototype]],数据类型是Object,说明它指向了一个对象(即原型),这个原型对象里面,有很多方法和一个属性;

其他的暂且不论,我们先重点看一下,红框的constructor()方法和__proto__属性;

访问器属性(__proto__)

访问[[Prototype]]

从红框可以看到,属性__proto__是一个访问器属性,有getter/setter特性(这个属性名前后各两个下划线);

问题是,它是用来访问哪个属性的?

我们来调用一下看看:

image-20221219205330882

可以看到,__proto__访问器属性,访问的正是隐藏属性[[Prototype]],或者说,它指向的正是原型对象;

值得一提的是,这是一个老式的访问原型对象的方法,现代编程语言建议使用Object.getPrototypeOf/setPrototypeOf来访问原型对象;

但是考虑兼容性,使用__proto__也是可以的;

请注意,__proto__不能代表[[Prototype]]本身,它只是其一个访问器属性;

设置[[Prototype]]

正因为它是访问器属性,也即具有getter和setter功能,我们现在可以控制对象的原型对象的指向了(并不建议这样做):

image-20221219210505820

如上图,现在将其赋值为null,好了,现在obj对象没有原型了;

image-20221219212732241

如上图,创建了两个对象,并且让obj1没有了原型,让obj2的原型是obj1

看看,此时obj2.name读取到obj1的属性name了,首先obj2在自身属性里找name没有找到,于是去原型上去找,于是找到了obj1name属性了,换句话说,obj2继承了obj1的属性了;

这就是JS实现继承的方式,通过原型这种机制,后面会在继承详细说明继承和原型的关系;

让我们看看下面的代码:

image-20221219214739842

正常的obj2.name = 'Jerry'的添加属性的语句,会成为obj2对象自己的属性,而不会去覆盖原型的同名属性,这是再正常不过了,继承得来的东西。只能读取,不能修改(访问器属性__proto__除外);

现在的问题是,为什么obj2.__proto__undefined?上面不是刚刚赋值为obj1了吗?

原因就在于__proto__是访问器属性,我们读取它实际上是在调用对应的getter/setter方法,而现在obj2的原型(即obj1)并没有对应的getter/setter方法,自然是undefined了;

现在综合一下,看下面代码,与上图做比较:

image-20221219220644268

为什么最后obj2.__proto__输出的是hello world,为什么__proto__成了obj2自己的属性了?

关键就在于红框的三句代码:

第一句let obj2 = {},此时obj2有原型,有访问器属性__proto__,一切正常;

第二句obj2.__proto__ = obj1,这句调用__proto__的setter方法,将[[Prototype]]的引用指向了obj1

这一句完成以后,obj2因为obj1这个原型而没有访问器属性__proto__了;

所以第三句obj2.__proto__ = 'hello world'__proto__已经不再是访问器属性了,而是一个普通的属性名了,所以这句就是一个普通的添加属性的语句了;

构造器(constructor)

在隐藏属性[[Prottotype]]那里,看到其有一个constructor()方法,顾名思义,这就是构造器了;

类对象与函数对象

  • 类对象

在其他编程语言比如Java中,构造方法通常是和类名同名的函数,里面定义了对象的一些初始化代码;

当需要一个对象时,就通过new关键字去调用构造方法创建一个对象;

那在JS中,当我们let obj = {}去创建一个字面量对象的时候,发生了什么?

上面这句代码,其实就是let obj = new Object()的简写,也是通过new关键字去调用一个和类名同名的构造方法去创建一个对象,在这里就是构造方法Object()

这种通过new className()调用构造方法创造的对象,称为类对象;

  • 函数对象

但是,再等一下,JS早期是没有类的概念的,那个时候大家又是怎么去创建对象的呢?

想一下,创建对象是不是需要一个构造方法(即一个函数),本质上是不是new Function()的形式去创建对象?

对咯,早期就是new Function()去创建对象的,这个Function就叫做构造函数;

这种通过new Function()调用构造函数创造的对象,称为函数对象;

构造函数和普通函数又有什么区别呢?除了要求是用function关键字声明的函数,并且命名建议大驼峰以外,几乎是没有区别的:

image-20221219230237600

看,我们声明了一个构造函数Cat(),并通过new Cat()创造了一个对象tom

打印tom发现,它有一个原型,这个原型和字面量对象的原型不一样,它有一个方法一个属性;

方法是constructor()构造器,指向的正是Cat()函数;

属性是另一个隐藏属性[[Prototype]],暂时不去探究它是谁;

也就是说,函数对象的原型,是由另一个原型和constructor()方法组成的对象;

我们可以用代码来验证一下,类对象和函数对象的原型的异同点:

image-20221220180834768

如上所示,创建了一个函数对象tom和一个类对象obj

可以看出:

函数对象的原型的方法constructor()指向构造函数本身;

函数对象的原型的隐藏属性[[Prototype]]和字面量对象(Object对象)的隐藏属性,他们两的引用相同,指向的是同一个对象,暂时不去探究这个对象是什么,就认为它是字面量对象的原型即可;

还可以看到,无论是类对象,还是函数对象,其原型都有constructor()构造器;

这个构造器在创建对象的过程中,具体起了什么样的作用呢?

让我们先看看函数对象tom的这个原型是怎么来的?我们之前一直都是在说对象有一个隐藏属性[[Prototype]]指向原型对象,究竟是哪一步,让这个隐藏属性指向了原型对象呢?

函数的普通属性prototype

事实上,每个函数都有一个属性prototype,默认情况下,这个属性prototype是一个对象,其中只含有一个方法constructor,而这个constructor指向函数本身(还有一个隐藏属性[[Prototype]],指向字面量对象的原型);

可以用代码佐证,如下所示:

image-20221220122721686

注意,prototype要么是一个对象类型,要么是null,不可以是其他类型,这听起来很像隐藏属性[[Prototype]],不过prototype只是函数的一个普通属性,对象是没有这个属性的;

来看下这个属性的特性吧:

image-20221220183641004

可以看到,它不是一个访问器属性,只是一个普通属性,但是它不可配置不可枚举,只能修改值;

它的value值,眼熟吗?正是构造函数创建的函数对象的原型啊;

它居然还有一个特性[[Prototype]],不要把它和value值里面的属性[[Prototype]]弄混,前者是prototype属性的特性,后者是prototype属性的一个隐藏属性,虽然此刻他们都指向字面量对象的原型,但是前者始终指向字面量对象的原型,后者则始终指向原型(而原型是会变的);

这里也不再去追究为什么它会有这样一个特性了,让我们把重点放在prototype属性本身;

new Function()的时候发生了什么

事实上,只有在调用new Function()作为构造函数的时候,才会使用到这个prototype属性;

image-20221220185833703

我们来仔细分析一下上面代码具体发生了什么:

let tom = new Cat()这句代码的执行流程如下:

  • 先调用Cat.prototype属性的特性[[Prototype]](我们知道它指向字面量对象的原型)里面的constructor()构造器,创建一个字面量对象,当然此时这个对象的隐藏属性[[Prototype]]也都已经存在了,将这个对象分配给this指针;
  • 然后将Cat.prototype属性值value复制(注意,这里是复制,不是赋值,这意味着这里不是传引用,而是传值)给字面量对象的隐藏属性[[Prototype]],即tom.__proto__ = Cat.prototype的value值
  • 然后执行构造函数Cat()本身的语句,即this.name = "Tom"
  • 然后返回this指针给tom,即tom引用了这个字面量空对象,同时this指向了tom

现在已经说清楚了new Function()发生的具体过程,上面代码的输出结果也佐证了我们所说的:

函数对象tom的原型正是Cat函数的属性prototype的值value,可以看到他们的constructor()构造器都指向Cat函数本身,并且tom.name的值为Tom

然后我们修改了Cat函数的prototype的值valueCat.prototype = Dog.prototype语句将其设置成了Dog函数的prototype的值value

让我们顺着刚刚说的流程,看看let newTom = new Cat()的执行过程:

  • 先创建字面量空对象,与this绑定;
  • 然后将Cat.prototypevalue值(此时等于Dog.prototype),复制给字面量对象的隐藏属性;
  • 然后执行构造函数Cat()本身的语句,即this.name = "Tom"
  • 然后返回this指针给newTom

输出结果佐证了我们的执行过程,对象newTom的原型正是Dog函数的属性prototype的值value,他们的constructor()构造器都指向了Dog函数本身,但是newTom.name的值依然是"Tom";

从上面前后两个输出结果也可以看出来,最后一步的tom.__proto__ = Cat.prototype确实是复制而不是赋值,否则在Cat.prototype = Dog.prototype语句之后,tom.__proto__ = Cat.prototype = Dog.prototype了(即tom的原型也会是Dog.prototypevalue值),但是输出结果表面并没有改变,依然是Cat.prototype

现在我们已经明白了函数对象的原型为什么是这个样子的,也明白了函数对象的constructor()构造器指向了构造函数本身;

现在让我们像下面这样,使用一下函数对象的constructor()构造器吧:

image-20221220204243512

看上面的代码,我们现在已经知道let tom = new Cat()的时候都发生了什么,也知道此时tom的原型的constructor()构造器指向的是Dog函数;

所以let spike = new tom.constructor()这句代码,当tom去自己的属性里没有找到constructor()方法的时候,就去原型里面去找,于是找到了指向Dog函数的constructor()构造器,所以这句代码就等于let spike = new Dog()

通过这段代码,好好体会一下函数对象的构造器吧。

构造函数和普通函数的区别

其实从技术上来讲,构造函数和普通函数没有区别;

只是默认构造函数采用大驼峰命名法,并通过new操作符去创建一个函数对象;

  • new.target

    我们怎样去判断一个函数的调用是普通调用,还是new操作符调用的呢?

    image-20221221152944768

    如上所示,通过new.target,可以判断该函数是被普通调用的还是通过new关键字调用的;

  • 构造函数的返回值

    构造函数从技术上说,就是一个普通函数,所以当然也可能有return返回值(通常构造函数于情于理都是不会有return语句的);

    image-20221221153625673

    之前说过new Function()的时候的具体流程,我们来看一下:

    • 先创建一个字面量对象,绑定this

    • Cat.prototypevalue值复制给字面量对象的隐藏属性;

    • 执行Cat()函数本身,让字面量对象有属性name

      但是遇到了return语句,本来应该返回this指针的,现在返回了一个空对象{}

    • 所以最后tom指向的是return语句的空对象,而不是最开始创建的空对象;

字面量对象的原型

new Object()的时候发生了什么

我们刚刚说了new Function()创建函数对象的时候,具体发生了什么,现在来看看创建字面量对象的时候,具体发生了什么;

之所以单拿出字面量对象来说,是因为Object是JS其他所有类的祖先,要想创建一个对象,基础便是先new Object()

我们先看一下Objectprototype属性吧,是的,类和函数一样,也有这个属性(注意,是类有这个属性,而不是类的实例即对象有这个属性);

image-20221220205757060

看上图,是不是很眼熟,这不就是字面量对象的原型吗?

image-20221220210050576

是的,如上图所示,就是它;

那么这个原型对象还有原型吗?

image-20221220221559466

如上所示,没有了,指向null了,看样子我们已经走到了原型链的原点了,为了方便,我们就称呼Object.prototype为原始原型吧;

看看它的特性吧:

image-20221220210424571

和函数的prototype属性的特性,如出一辙,但是注意,它的writable属性是false了,这意味着我们再也无法对这个属性做任何操作了;

这是当然,它可是所有类的祖先,怎么能随意更改呢,事实上,所有类的prototype属性都是不可操作的,这是为了确保原型链的完整继承;

这下我们就能明白new Object()的时候大概流程是什么样子了;

let obj = {}为例(其实就是let obj = new Object()):

  • 先调用Objecet.prototype属性的特性[[Prototype]]里面的constructor()构造器(不再继续深究这个构造器了),创建一个字面量对象,绑定this
  • 然后将Object.prototype属性值value,复制给字面量对象的隐藏属性[[Prototype]],即this.__proto__ = Object.prototype
  • 然后执行构造方法Object()本身的语句(不再进一步去研究这个构造方法了),总之此时字面量对象已经有着很多内置方法了;
  • 然后返回this赋值给obj,即obj引用了这对象,同时this指针也就指向了obj

注意,其实new className()的流程不完全是上面这样子,与构造函数的流程还有一点点区别,主要是第二步和第三步,这和类的继承有关系,详细的在后面new className()的时候发生了什么里面具体说明;

更改原始原型

我们刚刚说了,Object.prototype属性的所有特性都是false,意味着我们对这个属性无法再做任何操作了;

这只是再说,我们不能对其本身做任何删改的操作了,但是它本身依然是一个对象,这意味着我们可以正常的向其添加属性和方法;

image-20221220223232974

如上图所示,我们向Object.prototype属性对象里添加了hello()方法,并且由obj对象通过原型调用了这个方法;

类对象的原型

类的prototype属性

在创建类对象的时候,会将类的prototype属性值复制给类对象的原型;

所以说,类对象的原型等于类的prototype属性值;

image-20221223214052596

而类的prototype属性,默认就有两个属性:

  • 构造器constructor:指向类本身;
  • 原型[[Prototype]]:指向父类的prototype属性;

以及

  • 类的普通方法;

从上图中可以看出,A的prototype属性里,除构造器和原型以外,就只有一个普通方法show()

这说明,只有类的普通方法,会自动进入类的prototype属性参与继承;

也就是说,一个类对象的数据结构,如下:

  • 普通属性
  • (原型)prototype属性
    • 构造器
    • 父类的prototype属性(父原型)
    • 方法

另外,类的prototype属性是不可写的,但是类对象的原型则是可以修改的;

类的原型

类本身也是有原型的,就像类对象有原型一样;

image-20221223211958950

可以看到,B的原型就是其父类A,而A作为基类,基类的原型是本地方法;

正因如此,B可以通过原型去调用A的静态方法/属性;

也就是说,静态方法/属性,也是可以继承的,通过类的原型去继承;

继承了哪些东西

当子类去继承父类的时候,到底继承到了父类的哪些东西,也即子类可以用父类的哪些内容;

image-20221223220502179

从结果上来看,我们可以确定如下:

  • 子类继承父类的静态属性/方法(基于类的原型);
  • 子类对象继承父类的普通方法和构造器(基于类的prototype);
  • 子类直接将父类的普通属性作为自己的普通属性(普通属性不参与继承);

由于原型链的存在,这些继承会一路沿着原型链回溯,继承到所有祖宗类;

子类的调用顺序

子类调用方法的顺序:

  • 先从自己的方法里调用,发现没有可调用的方法时;
  • 再沿着原型链,先从父类开始寻找方法,一直往上溯源,直到找到可调用的方法,或者没有而出错;

类与构造函数的区别

我们已经了解了函数对象的原型,原始原型,类对象的原型;

我们把这三种放一起做个比较吧:

image-20221220225535034

我们自定义了类classA,自定义了函数functionA,并创建了类对象clsA和函数对象funcA,以及字面量对象;

可以看出,类对象与函数对象的原型的形式,是一致的,都是两个属性(一个隐藏属性一个构造器),只是各自原型里的constructor()指向各自的类/函数,即红框部分不同;

而他们的原型的原型则是一致的,和字面量对象的原型一样,都指向了原始原型,即绿框部分相同;

上面的输出结果佐证了这一点;

从这也可以看出来,其他类都是继承自原始类Object的,只是原型链的长短罢了,最终都可以溯源到原始类Object

很显然,类与构造函数,很类似;

尽管类对象和函数对象有相似的原型,但是不代表类与构造函数就完全一样了,他们之间的区别还是很大的:

  • 类型不同,定义形式不同

    image-20221221160907543

    类名后不需要括号,构造函数名后需要加括号;

    类的方法声明形式和构造函数的方法不一样;

    打印类和构造函数,类前的类型是class,构造函数前的类型是f,即function

    注意,不能使用typeof操作符,它会认为类和构造函数都是function,应该使用instanceof操作符;

  • prototype不一样

    image-20221221161607599

    如上所示,类的方法,会成为prototype的方法,但是构造函数的方法不会成为prototype的方法;

    函数对象如果想要调用method1()方法,就不能写成let method1 = function(){},而是this.method1 = function(){},将其变为函数对象自己的方法;

  • prototype的特性不一样

    image-20221221162205970

    类的prototype是不可写的,但是构造函数的prototype是可写的;

  • 方法的特性不一样

    image-20221221163658267

    由于函数对象不能通过原型继承方法,这里只展示类的方法的特性,如上所示,类的方法,是不可枚举的,也即不会被for-in语法遍历到;

  • 模式不同

    由于类是后来才有的概念,所以类总是使用严格模式,即不需要显示使用use strict,类总是在严格模式下执行;

    而构造函数则不同,默认是普通模式,需要显式使用use strict才会在严格模式下执行;

  • [[IsClassConstructor]]

    类有隐藏属性[[IsClassConstructor]],其值为true;

    这要求必须使用new关键字去调用它,像普通函数一样调用会出错:

    image-20221221164940516

    但是很显然,构造函数本身就是一个函数,是可以像普通函数一样去调用的;

  • 构造器constructor

    由于函数对象不能通过原型继承方法,所以无法自定义构造器;

    但是类对象可以继承啊,所以可以自定义构造器并在new的时候调用;

    image-20221221170615072

    从图上可以看出,我们是无法去自定义构造函数的构造器的,它会成为函数对象自己的一个方法;

new className()的时候发生了什么

在说明new className()具体流程之前,先了解一下必要的概念:

基类和派生类

class classA {};
class classB extends classA {};

classA这样没有继承任何类(实际上父原型是Object.prototype)的类称为基类;

classB这样继承classB的类,称为classB的派生类;

为什么要分的这么细,是因为在创建类对象时,他们两个的行为不同,后面会说到;

重写构造器

事实上,如果我们不显式自定义构造器,JS也会默认提供一个构造器:

// 基类
constructor() {}

// 派生类
constructor() {
	super();
}

所以我们要重写构造器,也是分两种情况:

// 基类重写构造器
class A {
    constructor() {
        code...
    }
}
    
// 派生类重写构造器
class B extends A() {
    constructor() {
        // 一定要先写super()
        super();
        code...
    }
}

基类new className()时的流程

先来看一下基类创建对象的过程:

image-20221224002125631

执行let a = new A()时,大致流程如下:

  • 首先调用A.prototype的特性[[Prototype]]创建一个字面量对象,同时this指针指向这个字面量对象;
  • 然后执行类A()的定义,A定义的普通属性成为字面量对象的属性并初始化,A.prototypevalue值复制给字面量对象的隐藏属性[[Prototype]]
  • 然后再执行constructor构造器,没有构造器就算了;
  • 返回this指针给变量a,即a此时引用该字面量对象了;

从结果上看,在执行构造器时,字面量对象就已经有原型了,以及属性name,且值初始化为tomA

然后才对属性name重新赋值为jerryA

然而,构造器中对属性的重新赋值,从一开始就决定好了,只是在执行到这句赋值语句之前,暂存在字面量对象中;

派生类new className()时的流程

现在再来看一下派生类创建对象的过程;

image-20221224005351505

执行let b = new B()的大致流程如下:

  • 首先调用B.prototype的特性[[Prototype]]创建一个字面量对象,同时this指针指向这个字面量对象;
  • 然后执行类B()的定义,B定义的普通属性成为字面量对象的属性并初始化,B.prototypevalue值复制给字面量对象的隐藏属性[[Prototype]]
  • 然后再执行constructor构造器(没有显式定义构造器会提供默认构造器),第一句super(),开始进入类A()的定义;
    • 暂存B的属性值,转而赋值为A定义的值,A.prototypevalue值复制给B.__proto__的隐藏属性[[Prototype]];
    • 然后执行constructor构造器(基类没有构造器就算了);
    • 返回this指针;
    • 丢弃A赋值的属性值,重新使用暂存的B的属性值;
  • 继续执行constructor构造器剩下的语句;
  • 返回this指针给变量b,即b引用该字面量对象了;

基类与派生类创建对象时的不同点

通过基类和派生类创建对象的流程对比,可以发现主要区别在于类的属性的赋值上;

属性值从一开始就已经暂存好:

  • 如果构造器constructor中有赋值,则暂存这个值;
  • 如果构造器没有,则暂存类定义中的值;
  • 不管父类及其原型链上同名的属性在中间进行过几次赋值,最终都会重新覆盖为最开始就暂存好的值;

原型链与继承

现在应该已经理解了原型是一个什么样的概念,以及如何去访问原型;

原型链

正如继承有儿子继承父亲,父亲继承爷爷一样,有这样一个往上溯源的关系,原型也可以这样往上溯源,这就是原型链的概念;

用代码去理解一下吧:

image-20221219222154114

我们定义了三个对象A/B/C,并且设置C的原型是B,B的原型是A;

读取C.nameA的时候,首先在C自己的属性里去找,没有找到;

于是去原型B的属性里去找,没有找到;

再去B的原型A的属性里去找,找到并输出;

可以看C展开的一层层结构,可以很清晰的看到原型链的存在;

由此也可以看出,JS是单继承的,同Java一致;

但是正常的继承,肯定不是这样手动去设置对象的原型的,而是自动去设置的;

继承

JS中表示继承的关键字是extends,如果classA extends classB,则说明classA继承classBclassA是子类,classB是父类;

image-20221221194842123

上面代码,classC继承classB,而classB继承classA

所以classC的对象,继承了他们的属性,便有了三个属性nameA/nameB/nameC,这也说明,属性是不放在原型里的,而是会在创建对象的时候,直接成为classC的属性;

classC的原型,有一个属性一个方法,方法是constructor()构造器指向自己,属性是另一个原型;

注意,打印出来的原型后面标注的classX,原型指的是对象,不是类,所以c的原型不是指classB这个类本身,而是指其来源于classB

紫色框:对象c的原型,即c.__proto__ == classC.prototype

橘色框:classB.prototype,即对象c的原型的原型c.__proto__.__proto__ == classB.prototype

绿色框:classA.prototype,即对象c的原型的原型的原型c.__proto__.__proto__.__proto__ == classA.prototype

红色框:Object.prototype,也即原始原型c.__proto__.__proto__.__proto__.__proto__ == Object.prototype

这是一条完整的原型链,从中也能看出继承是什么样的一个形式;

原型高于extends

时刻记住,JS的继承,是依靠原型来实现的;

关键字extends虽然确立了两个类的父子关系,但是这只是一开始确立子类的父原型;

但是父原型是可以中途被修改的,此时子类调用方法,是沿着原型链去寻找的,而不是沿着子类父类的关键字声明去寻找的,这和Java是不一样的:

image-20221223233526394

如图所示,C extends A确立了C一开始的父原型是A.prototypec.show()调用的也是父类A的方法;

但是后面修改c的父原型为B.prototypec.show调用的就不是父类A的方法,而是父原型的方法;

也就是说,原型才是核心,高于extends关键字;

posted @ 2022-12-23 15:39  Journing  阅读(620)  评论(2编辑  收藏  举报