原型链和原型对象

377 阅读10分钟

前言

之前写博客,经常需要引用一些基础的内容,每次都花不少时间找合适的文章,索性花点时间自己写,也当是巩固下基础。于是有了这个系列(JS核心基础)的文章。

目前已经完成的文章:

js从编译到执行过程 - 掘金 (juejin.cn)

从异步到promise - 掘金 (juejin.cn)

从promise到await - 掘金 (juejin.cn)

浅谈异步编程中错误的捕获 - 掘金 (juejin.cn)

作用域和作用域链 - 掘金 (juejin.cn)

一,概述

实际项目开发过程中,很多人觉得原型链用到的不多,但是实际上无所不在,只是开发者没意识到罢了。本文将采用图解的方法一步步解析原型链体系。并解释Object.prototype.toString.call(obj)来判断数据类型的原理。

二,为什么要引入原型的概念

在js中,其实是没有类的概念的,但是对象却大量存在。在没有类的情况下,js采用的是通过构造函数来创建新的对象:

function Person(){
  this.属性名 = 属性值;
  this.方法名 = function() {
      方法体
}
var person = new Person();

这里的Person就是个构造函数,通常我们开头大写让它区别于一般函数。

于是我们创建不同的对象将是如下的写法:

function Animal(name) {
  this.name = name
  this.eat = (food) => {
      console.log('它喜欢吃',food)
  }
}
const dog = new Animal('小狗')
const cat = new Animal('小猫')
dog.eat('骨头')
cat.eat('可爱多')

如上代码中,虽然创建了猫和狗两个不同的对象,但是每创建一个实例,都需要重新创建这个eat方法,再把它添加到新的实例中。

每个eat方法都需要单独的内存空间存储,这无疑是巨大的浪费。

console.log(dog.eat===cat.eat)//false,说明两个方法在堆内存中的引用地址不一样,存储在不同位置了。

既然实例的方法都是一样的,那我们就可以想到把共用的属性和方法放置在一个共用的地方,让所有的实例都能共享着访问到。

于是就引入了原型(prototype) 的概念。

三,原型的实现原理

假设我们现在是js语言的开发者,为了实现上文说的共享属性和方法,我们可以做如下设定:

  1. 当我们使用function创建一个函数的时候,会默认添加一个prototype属性,该属性存储引用地址,指向该函数的原型对象
  2. 当我们使用构造函数创建新的实例对象时,会默认添加一个__proto__属性,该属性存储引用地址,指向该构造函数的原型对象。

于是对于上文的代码,就会有如下的示意图:

1-原型的设定.drawio.png

然后基于这两个设定,我们修改代码为:

function Animal(name) {
  this.name = name
}
Animal.prototype.eat=(food)=>{
  console.log('吃',food)
}
const dog=new Animal("小狗")
const cat=new Animal("小猫")
dog.eat('骨头')
cat.eat('可爱多')
console.log(dog.eat===cat.eat)//true

要让dog.eat能访问到原型对象上的eat方法,还需要增加一个设定:

3,访问对象的属性时,JavaScript会首先在对象自身的属性内查找,若没有找到,则会跳转到该对象的原型对象中查找。

于是示意图变成:

2-实例对象指向原型对象.drawio.png

如上图所示,实例对象dog和cat中并没有eat方法,但是顺着红色的箭头能在Animal的原型对象上找到。这样eat方法就是共用的了,不需要额外的内存分配。

四,原型链的形成

4.1,区分prototype__proto__

每个函数function都有一个prototype,即显式原型(属性)。

每个实例对象都有一个__proto__,可称为隐式原型(属性)。

于是可以明确的是,我们所说的原型链,实际上说的是隐式原型链(使用__proto__连接起来的)。

4.2,原型链

JavaScript中所有的对象都具备自己的原型对象。而原型对象自身也是一个对象,它也有自己的原型对象,这样层层上溯,就形成了一个类似链表的结构,这就是原型链(prototype chain)

要形成完整的原型链,这里又要增加一个设定:

4,在js中,任何对象都是Object的实例对象,因而隐式原型链的终点指向Object.prototype,且Object.prototype的__proto__指向null,当我们查找属性/方法的时候,会顺着原型链往上找,直到原型链的尾部(提前找到了则终止)。

如上文,我们创建了一个构造函数Animal,它的原型对象是个对象,说明也是Object的实例,于是原型的指向示意图变成:

3,原型链.drawio.png

五,原型链体系的细化

到这里,我们已经构建了普通对象的原型和原型链体系。但是js中,存在一些特殊的内置对象,比如Function,Number,ArrayBoolean,String等。对于他们又是怎么处理的呢?

这里需要区分三种情况

Function
Number,ArrayBoolean,String等内置函数形式的对象
Object

5.1,Function

Function是个非常特殊的内置函数/对象,因为它可以看作Function自身的实例,它既是函数(具备prototype),又是自身的实例(具备__proto__)。

所以它的显示原型和隐式原型都指向Function.prototype。

于是示意图变成:

4,Function的原型链.drawio.png 可以看出来我们的构造函数Animal或者普通函数,其实都是Function的实例对象。

这里有个思考:公共方法定义在Function.prototype上,那么Animal的实例对象dag和cat能否访问?答案是不能,看上图可知(红色箭头),它们的原型链不经过Function.prototype。

5.2,Number,Array,Boolean,String等内置函数

首先,他们是函数,上文说过,函数会默认添加显示原型prototype

他们的显示原型都指向各自默认的原型对象(这个原型对象上存放着他们各自的一些方法)。

其次,又因为他们是函数,而任何函数都是Function的实例对象,于是同时存在隐式原型__proto__指向Function的原型对象。

为了避免图太乱,这里把这四个放一起,并且先不画上文的Animal部分:

5,四种类型.drawio.png

而当我们新建一个数组时:

let arr=new Array(2, 3);

它是Array的实例对象,其原型图便会是下图这个样子,其中一些数组的通用方法如splice、shoft、join等,就存放在Array.prototype或者说arr.__proto__上面(这两个是同一个),对应的原型链关系是:

6,arr的原型链形态.drawio.png 看上图的绿色箭头,就是arr的原型链。

这就是我们新建的数组,能直接使用数组的slice,join等方法的原因。

5.2,Object对象

在4.2节说过:隐式原型链的终点指向Object.prototype,且Object.prototype__proto__指向null。

所以,一个普通对象的原型链应该是非常短的,如下代码:

let obj1={
  name:"小狗",
  behavior:function(){
    console.log("喜欢倒立")
  }
}

所对应的原型链如下图绿色箭头,就一条:

7,object的原型链.drawio.png

按照这张图,可以做如下验证:

console.log(obj1.__proto__===Object.prototype)//true
console.log(obj1.__proto__.__proto__)//null

值得注意的是,我们在判断数据类型时,常常使用到的Object.prototype.toString.call(obj)来判断数据类型。

这是因为在Object.prototype上有一个toString方法,返回的是值类型。也就是说它可以精准地判断输入值的数据类型。

function f1(){
  var a=1
}
console.log(Object.prototype.toString.call(f1))   //[object Function]
console.log(Object.prototype.toString.call([]))  //[object Array]
console.log(Object.prototype.toString.call({}))  //[object Object]
console.log(Object.prototype.toString.call(null))  //[object Null]
console.log(Object.prototype.toString.call(undefined))  //[object Undefined]
console.log(Object.prototype.toString.call(1))   //[object Number]

这里利用call,就是改变this指向,让()里面的来调用Object.prototype.toString方法。

这里很多人没有意识到我们为啥要这样写?因为既然Object的原型对象上有toString方法,如上文,所有类都是Object的实例对象,因此toString()方法都能在原型链最后访问到,那为啥又要调用Object.prototype.toString方法,而不直接使用toString方法,让它自己顺着原型链找到这个.toString方法然后调用呢?

实际上,所有类(除了Object自身)在继承Object的时候,在对应原型对象(如Array.prototype)上重写了toString()方法。 这就导致数组啊函数啊之类的直接使用toString的时候,原型链先访问到这个重写的toString方法,而访问不到Object.prototype中定义的toString方法。

所以,当我们要判断数据类型的时候,才使用Object.prototype.toString.call(obj)直接调用Object.prototype.toString来判断数据类型。

六,原型链的属性问题

如上文所说,当我们读取属性时,原对象没找到,会自动到原型链中查找,找到则终止,没找到则顺着原型链查找。

那我们要设置属性呢?

当我们设置新属性的时候,生效范围取决于新属性设置在哪一层。

当我们想新属性在全部实例对象中生效,就可以设置到原型对象上,而如果是仅仅当前这个实例对象生效,则只需定义在当前的实例对象上。

function  Fn(){           //构造函数		
}
var fn1=new Fn()
var fn2=new Fn()
Fn.prototype.a="1"
console.log(fn1.a,fn1)  //1 Fn {}
fn2.b="2"
console.log(fn2.b,fn2,fn2.a)//2 Fn { b: '2' } 1

对应的原型链示意图:

8,对象的属性设定.drawio.png

可以直观地看到,b仅仅定义在fn2上,而a定义在原型链上,这样fn1和fn2都可以访问。

七,完整的原型链示意图

结合上文所说,这就规划出了一个完整的原型链体系。至于constructor属性,没啥有效的作用,本文就不讲了,免得图片箭头太多:

9,完整原型链.drawio.png

八,原型链在组合继承中的应用

通过原型链就可以在JavaScript中实现继承,JavaScript中的继承相当灵活,有多种继承的实现方法,这里只介绍一种最常用的继承方法也就是组合继承

如下代码:

function Animal(name) {
  this.name = name
}
Animal.prototype.eat=(food)=>{
  console.log('吃',food)
}
function Dog(name, weight) {
  Animal.call(this, name)
  this.weight = weight
}

Dog.prototype = new Animal()
const dog=new Dog('小狗','20kg')
console.log(dog.name,dog.weight)
dog.eat("粑粑")

对应的原型链原理图是:

10,组合继承.drawio.png

如上图的红色箭头指示的链条,就是dog的原型链,可以看到的是,实例对象dog继承了Animal的属性name,并且能在原型链上找到eat方法。

这一切的原因就基于:Dog.prototype = new Animal()这行代码,改变了Dog的原型对象,从而手动地变更原型链。

九,总结

综上所述,原型链体系地设定如下:

1. 当我们使用function创建一个函数的时候,会默认添加一个prototype属性,该属性存储引用地址,指向该函数的**原型对象**。
2. 当我们使用构造函数创建新的实例对象时,会默认添加一个`__proto__`属性,该属性存储引用地址,指向该构造函数的原型对象。
3,访问对象的属性时,JavaScript会首先在对象自身的属性内查找,若没有找到,则会跳转到该对象的原型对象中查找。而设定属性时,影响范围取决于设置在原型链地位置。
4,我们所说地原型链是指隐式原型链。在js中,任何对象都是Object的实例对象,因而隐式原型链的终点指向Object.prototype,且Object.prototype的__proto__指向null,当我们查找属性/方法的时候,会顺着原型链往上找,直到原型链的尾部(提前找到了则终止)。
5Function是个非常特殊的内置函数/对象,因为它可以看作Function自身的实例,它既是函数(具备prototype),又是自身的实例(具备__proto__)。所以它的显示原型和隐式原型都指向Function.prototype6Number,ArrayBoolean,String等内置函数,他们的显示原型都指向各自默认的原型对象(这个原型对象上存放着他们各自的一些方法)。其次,又因为他们是函数,而任何函数都是Function的实例对象,于是同时存在隐式原型__proto__指向Function的原型对象。
7,js中地任何对象都是Object的实例对象,因而隐式原型链的终点指向Object.prototype,且Object.prototype的__proto__指向null