3

JavaScript:原型(prototype) - Journing

 1 year ago
source link: https://www.cnblogs.com/Journing/p/17000813.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

在其他的面向对象编程语言比如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指针;
  • 然后返回this指针给tom,即tom引用了这个字面量空对象,同时this指向了tom
  • 然后执行构造函数Cat()本身的语句,即this.name = "Tom",于是tom就有了一个属性name
  • 然后将Cat.prototype属性值value复制(注意,这里是复制,不是赋值,这意味着这里不是传引用,而是传值)给tom的隐藏属性[[Prototype]],即tom.__proto__ = Cat.prototype

如果我们用代码去描述上面整个过程,就类似于下面这样:

// let tom = new Cat()的整个具体流程,类似于下面这样let tom = {}; //创建字面量对象,并赋值给变量tomtom.name = "Tom"; // 执行Cat()函数tom.__proto__ = Cat.prototype; // 将Cat的prototype的属性值赋值给tom的隐藏属性[[Prototype]]

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

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

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

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

  • 先创建字面量空对象;
  • 然后赋值给newTom
  • 然后调用Cat()函数本身,即newTom.name = "Tom"
  • 然后执行语句newTom.__proto__ = Cat.prototype,而Cat.prototype = Dog.prototype,所以newTom.__proto__ = Dog.prototype

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

从上面前后两个输出结果也可以看出来,最后一步的tom.__proto__ = Cat.prototype确实是复制而不是赋值,否则在Cat.prototype = Dog.prototype语句之后,tom.__proto__ = Cat.prototype = Dog.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()的时候的具体流程,我们来看一下:

    • 先创建一个字面量空对象;

    • 将空对象赋值给tom

    • 执行Cat()函数,让tom有了属性name

      但是Cat()函数有return语句,返回了一个空对象{},由tom接收了,也就是说tom被覆盖赋值了;

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

字面量对象的原型

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

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

Object为例,因为它是一个类,是JS其他所有类的祖先,这一点与Java类似;

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

image-20221220205757060

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

image-20221220210050576

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

还记得原型链吧,那么这个原型对象还有原型吗?

image-20221220221559466

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

看看它的特性吧:

image-20221220210424571

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

这是当然,它可是所有类的祖先,怎么能随意更改呢;

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

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

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

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

更改原始原型

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

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

image-20221220223232974

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

类对象的原型

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

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

image-20221220225535034

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

可以看出,类对象与函数对象的原型的形式,是一致的,只是各自原型里的constructor()指向各自的类/函数,即红框部分不同;

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

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

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

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

类与构造函数的区别

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

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

    image-20221221160907543

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

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

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

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

  • prototype不一样

    image-20221221161607599

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

    也即构造函数的prototype始终由constructor()和原始原型组成,函数对象无法通过原型去调用在构造函数里定义的方法;

    函数对象如果想要调用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

    从图上可以看出,我们是无法去自定义构造函数的构造器的,它依然还是按照我们所说的流程去创建函数对象的;

    我们现在看看,类自定义构造器,是怎么按照我们的流程去创建类对象的:

    • 先调用classA.prototype的特性[[Prototype]]里的构造器去创建一个字面量空对象;

    • 将空对象赋值给变量clsA

    • 然后执行构造方法classA()本身的语句;

      首先添加了属性outterName

      然后又遇到了constructor()方法(注意该构造器与classA.prototype.constructor不是同一个东西),于是又执行了这个构造器的语句,添加了属性innerName

    由此我们可以得出,类在创建类对象的时候,流程依然是我们所述的流程;

    但是在遇到类里面的同名方法constructor()时候,不会将其作为原型方法,而是会立即运行该构造器;

    另外,像outterName这样的属性,不会成为prototype的属性,也就是说,类只有定义的方法(除了constructor构造器)会进入prototype的属性,成为原型被继承;

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

上面刚刚描述了类自定义构造器之后,创建对象是一个什么样的流程;

现在来仔细理解一下类的构造器,事实上,如果我们不显式自定义构造器,类也会默认提供一个下面这样的构造器:

constructor() { super();}

这里的super()实际上就是在调用其父类的构造方法(注意不是指父类的构造器constructor(),而是指父类自身);

用代码来验证一下吧:

image-20221221191937504

我们先来看一下let c = new classC()的时候,具体流程是什么样的吧:

  • 首先调用classC.prototype属性的特性[[Prototype]](它总是指向原始原型),创建一个字面量空对象;
  • 然后将其赋值给变量c
  • 然后执行构造方法classC()的语句,通常会有添加对象的属性和方法的语句,这里没有;
  • 接着查看是否显式声明了constructor()构造器(如果没有就提供一个默认的构造器),这里有,于是立即执行这个构造器;
    • 首先是super(),实际上就是执行构造函数classA()的语句,于是添加了属性nameA
    • 然后是this.nameB = 'C',于是添加了属性nameC
  • 最后,将classC.prototypevalue值,复制给c的隐藏属性[[Prototype]],即c.__proto__ = classC.prototype

整个完整流程如上所示;

现在来试着对着流程看看let b = new classB()吧:

  • 首先创建字面量空对象;
  • 赋值给变量b
  • 执行classB()的语句,添加了属性nameB
  • 没有构造器,提供默认的构造器,执行super()即执行classA()的语句,于是添加了属性nameA
  • 最后,复制b的原型为classB.prototypevalue值;

输出结果也验证了我们所说的;

操作原型的现代方法

之前已经说过,通过__proto__属性去操作原型的方法,是历史的过时的方法,实际上并不推荐;

现代JS有以下方法,供我们去操作原型:

  • Object.getPrototypeOf(obj)

    此方法,返回对象obj的隐藏属性[[Prototype]]

  • Object.setPrototypeOf(obj, proto)

    此方法,将对象obj的隐藏属性[[Prototype]]指向新的对象proto

  • Object.create(proto, descriptors)

    此方法,创建一个空对象,并将其隐藏属性[[Prototype]]指向proto

    同时,可选参数descriptors可以给空对象添加属性,如下所示:

    image-20221220233321028

原型链与继承

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

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

用代码去理解一下吧:

image-20221219222154114

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

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

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

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

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

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

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

在JS中,继承的关键字也是extends,也是描述类的父子关系的;

image-20221221194842123

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

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

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

注意,打印出来的原型后面标注的classX,原型指的是对象,不是类,所以classC的原型不是指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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK