一篇文章带你了解原型和原型链

120 阅读8分钟

这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战

原型和原型链的有关知识在前端的面试题中会经常被问到,这个问题其实有非常多细节点,所以笔者想通过一篇文章整理其中的知识点,分享给大家。

在JavaScript语言中,类使用基于原型的继承,如果两个对象从同一个原型继承属性,那么我们就说这些对象是同一个类的实例。在ES6以前,JavaScript创建类通常是使用工厂函数或者构造函数的方式。ES6新增了相关语法(包括class关键字),才让创建类的方式变得简单,但其原理是不变的,可以把ES6中的类当作是构造函数加原型继承的语法糖,了解这些创建类的方式,可以让我们更加了解类的底层工作原理

定义类

  1. 工厂函数

在JavaScript中,类意味着一组对象从同一个原型对象中继承属性,这些属性在所有对象中是共享的。JavaScript提供了Object.create() 函数用于创建一个新的对象,这个新对象继承指定的原型对象。通常我们定义一个类的时候,都需要初始化一些状态,而这些状态是每个对象独有的,所以常见的做法便是定义一个工厂函数,用于创建和初始化一个类的新实例

const personMethod={
  getName(){
    return `${ this.firstName} ${this.lastName}`
  }
}
function person(firstName,lastName,gender){
  const p=Object.create(personMethod)
  p.firstName=firstName
  p.lastName=lastName
  p.gender=gender
  return p
}

const p1=person('Li','lumy','female')
console.log(p1.getName())//Li lumy

在上面的例子中,我们创建了一个类person,并且通过调用这个函数,传入三个参数,得到了一个person类的实例p1,这个实例继承了personMethod对象的属性,因此拥有getName的方法。

  1. 构造函数

构造函数是一种专门用于初始化新对象的函数。构造函数需要使用new关键字调用。使用new调用构造函数会自动创建新对象,并将构造函数作为该对象的方法调用,最后返回该对象。因此,构造函数本身只需要通过this关键字访问新对象,初始化新对象的状态。那么通过构造函数创建的对象又是如何继承属性的呢?其实构造函数调用的关键在于构造函数的prototype属性将被用作新对象的原型,这意味着用同一构造函数所创建的对象都继承同一个原型对象,下面我们来改造一下上面的例子


function Person(firstName,lastName,gender){
  this.firstName=firstName
  this.lastName=lastName
  this.gender=gender
}
Person.prototype={
  getName(){
    return `${ this.firstName} ${this.lastName}`
  }
}
const p1=new Person('Li','lumy','female')
console.log(p1.getName())//Li lumy

可以看到,使用构造函数创建得到的对象与工厂函数创建对象的效果是一样的。

比较一下两者的区别:

  1. 命名区别,构造函数某种意义上是定义类,而类名按照习惯是以大写字母开头,普通函数和方法则以小写字母开头;
  2. 调用方式不同Person构造函数需要以new关键字调用,而person工厂函数则不需要;
  3. 指定原型对象的方式不同,在工厂函数中,对象的原型使用自定义命名的方式,并通过Object.create指定原型来创建对象,而通过构造函数创建的对象原型是构造函数的prototype属性,是强制性的

原型

刚刚我们了解了如何去定义一个类,类又是如何从一个原型中继承属性,下面我们来聊一下什么是原型。

几乎每一个JavaScript对象都有另一个与之关联的对象,而这另一个对象被称为原型(prototype),第一个对象从这个原型中继承属性。

我们通常会通过字面量的方式去创建一个对象,事实上,这种方式与通过new Object() 方法创建对象是一样的,我们可以看到,这是一种通过构造函数创建对象的形式,所以我们通过字面量创建的对象会自动继承Object.prototype 的属性。类似的,new Array()创建的对象以Array.prototype为原型 ,new Date() 创建的对象以Date.prototype为原型......几乎所有对象都有原型,但只有少数对象有prototype属性。

那么,哪些对象有prototype属性呢?答案其实很明显,只有函数对象才有prototype属性,我们前面讲的,构造函数的prototype属性将被用作新对象的原型。

只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype属性(指向原型对象),默认情况下,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数,在上面的例子中,Person.prototype.constructor指向Person

在自定义构造函数时,原型对象默认只会获得constructor属性,其他的所有属性都会继承自Object。每次调用构造函数创建的对象时,这个对象内部会有一个**[[Prototype]]**指针指向构造函数的原型,在脚本中没有直接访问这个指针的方式,但在FireFox、Safari、Chrome浏览器中会给每个对象上面暴露一个__proto__属性来访问,在其他实现中则会被完全隐藏。至此我们可以看出:实例对象与构造函数之间没有直接联系,但实例对象与构造函数的原型对象通过[[Prototype]]指针联系起来,我们可以通过简单的例子了解它们之间的关系

function Person(){}
const p=new Person()
console.log(typeof Person.prototype)//object
console.log(Person.prototype)//{constructor: ƒ Person(),__proto__: Object}
console.log(p.__proto__===Person.prototype)//true
console.log(Person.prototype.constructor===Person)//true

从上面的例子可以看出,Person构造函数在声明之后就有了一个与之关联的原型对象Person.prototypepPerson的实例对象,p的__proto__指针指向Person.prototype原型对象,而原型对象的constructor属性指回构造函数Person

我们再来观察一下

console.log(Person.prototype.__proto__===Object.prototype)//true
console.log(Person.prototype.__proto__)//{constructor: ƒ Object(),hasOwnProperty: ƒ hasOwnProperty(),isPrototypeOf: ƒ isPrototypeOf(),.....}
console.log(Person.prototype.__proto__.__proto__===null)//true

Person的原型对象的__proto__指针又指向Object.prototype,说明Person.prototypObject的实例对象,因此我们可以把Person.prototype看作是通过new Object(constructor,__proto__)创建而来,其中constructor__proto__是这个实例的特定属性,分别指向PersonObject.prototype

从上面的例子中还可以看到,Person.prototype.__proto__.__proto__===null为true,我们知道Person.prototype.__proto__指向的是Object.prototype,因此可以看作Object.prototype.__proto__===null,这说明Object.prototype是没有原型的对象,它不继承任何属性。

JavaScript中所有的对象都会从Object.prototype中继承属性,它是所有对象的原型。其他原型对象都是常规对象,都有自己的原型。例如Array.prototype是数组的原型对象,它是一个常规的对象,而常规的对象的原型是从Object.prototype继承的,因此,通过new Array() 创建的对象可以从Array.prototypeObject.prototype继承属性。这种原型对象链接起来的序列被称为原型链

原型链

JavaScript中的对象是通过原型链继承的,其基本思想是通过原型继承多个引用类型的属性和方法。我们来梳理一下构造函数、原型、和实例的关系:每个构造函数都有一个原型对象,原型有一个属性(constructor)指回构造函数,而实例有一个内部指针([[Prototype]])指向原型。

那如果原型又是另一个类型的实例呢?那其实原理还是一样的,这个原型的一个内部指针会指向另一个原型,另一个原型的constructor属性指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。

我们可以通过一段代码实现这个原理

function AniAmal(){
  this.property='animal'
}
Animal.prototype.getProperty=function(){
  return this.property
}
function Cat(){
  this.catProperty='cat'
}
Cat.prototype=new Animal()
Cat.prototype.getCatProperty=function(){
  return this.catProperty
}
const cat=new Cat()
console.log(cat.getProperty())//animal
console.log(cat.getCatProperty())//cat
console.log(cat.hasOwnProperty('catProperty'))//true

从上面的例子我们可以看到catCat类型的实例,因此它继承了Cat.prototype的属性,它可以调用getCatProperty方法,因为Cat.prototype又是Animal的实例,cat又可以继承了Animal实例的属性,所以cat也可以调用getProperty方法。

那为什么cat实例又拥有hasOwnProperty方法呢?因为任何函数的默认原型是Object的实例,AniAmal的实例会默认继承Object.prototype的属性,因此,cat实例会拥有包括tostring、valueOf、hasOwnProperty等所有默认方法。

为什么是AniAmal的实例继承Object.prototype的属性呢?因为Cat.prototype被改写成为AniAmal的实例,cat能够拥有这些默认方法是因为从AniAmal的实例中继承。

下面我们通过一张图来总结原型链的基本原理

image-20220118085628435

每一个JavaScript对象都有自己的原型对象,其内部通过一个[[Prototype]]指针指向原型对象,而原型对象通过constrctor属性指向其构造函数,构造函数的prototype属性指向原型对象,原型链就是相互连接关系的不断循环,最终到达Object.prototype,它是原型链的终点