深入学习JS原型

101 阅读18分钟

深入JS原型

对象基础铺垫

对象的定义:若干属性的集合,属性表示为键值对的形式

一切引用类型都是对象,对象是属性的集合

对象都是通过函数创建的,例如我们熟悉的字面量创建对象:

 const obj = {a: 10, b: 20}
 //相当于
 const obj = new Object()
 obj.a = 10
 obj.b = 20

构造函数

在深入原型之前,我们先来了解一下构造函数,因为原型离不开构造函数

构造函数的成员

构造函数的成员分为两种,分别是实例成员静态成员

  • 实例成员

    在构造函数内部,通过this添加的成员,实例成员只能通过实例化的对象来访问

     function User(name, age){
         //实例成员
         this.name = name
         this.age = age
     }
    
  • 静态成员

    在构造函数本身上添加的成员只能通过构造函数来访问

     User.sex = '男'
    

通过构造函数创建对象

创建对象这个过程也叫做实例化,使用new操作符就可以实例化一个对象

 const user = new User('小包', 20)

new操作符创建一个新对象的详细过程,发生了一下几件事(详见模拟new操作符):

  1. 创建一个空对象{}
  2. 将构造函数的原型跟新创建的对象{}连接起来
  3. 将构造函数的this指向新创建的对象,也就是将属性绑定到{}
  4. 需要根据构造函数返回类型返回对应的对象

每个实例的方法是否共享

这要取决于如何定义方法,分为两种情况:

  • 在构造函数上直接定义方法:不共享

    通过在构造函数内使用this来添加方法的话,生成实例时都是会开辟一块内存空间存储这个方法,这样会导致内存极大的浪费,从而影响性能

     function User(name, age){
         this.eat = function() {
             console.log('我爱吃饭');
         }
     }   
     ​
     const user1 = new User('小包', 20)
     const user2 = new User('小啵', 21)
     ​
     user1.eat() //我爱吃饭
     user2.eat() //我爱吃饭
     console.log(user1.eat === user2.eat);   //false
    
  • 通过原型添加方法:共享

    构造函数通过原型分配的函数,是所有对象共享的

     function Car(name){
         this.name = name 
         
     }   
     Car.prototype.drive = function() {
         console.log('我好开');
     }
     ​
     const car1 = new Car('丰田')
     const car2 = new Car('本田')
     ​
     car1.drive()    //我好开
     car2.drive()    //我好开
     console.log(car1.drive === car2.drive)  //true
    

每个实例的属性是否共享

属性存储的值如果是基本类型的话,那么不存在共享问题,相不相等主要看值本身

 const user1 = new User('小包', 20)
 const user2 = new User('小啵', 20)
 console.log(user1.name === user2.name); //false
 ​
 const user1 = new User('小包', 20)
 const user2 = new User('小包', 21)
 console.log(user1.name === user2.name); //true

定义构造函数规则

公共属性放在构造函数里面,公共方法放在原型对象上面

原型

在上述构造函数的描述中,我们都遇到了原型,现在了解一下原型

原型是什么

User.prototype就是原型,原型是一个对象,即原型对象

每个函数都会创建一个prototype属性,这个属性是一个对象包含应该由特定引用类型的实例共享的属性和方法,实际上,这个对象就是通过调用构造函数创建的对象的原型(实例的原型)

原型的作用

在它上面定义的属性和方法都可以被对象实例共享

可以通过User.prototype.method共享方法,不会多次开辟内存空间

理解原型

只要创建一个函数(函数表达式和函数声明都可以),就会按照特定的规则为这个函数创建一个prototype属性指向原型对象,也就是键值为原型对象

默认情况下,所有原型对象自动获得一个constructor属性,指回与之关联的构造函数

自定义构造函数时,除了会默认获得这个constructor属性之外,其他的方法都继承于Object

现在,我实例化一个对象,这个实例内部的[[Prototype]]指针对被赋值为构造函数的原型对象,但是在实例身上,并不存在prototype这个属性,在chromesafarifirefox会在每个对象上暴露一个__proto__属性可以通过这个属性直接访问原型对象

实例与构造函数原型之间有直接的关系,但实例与构造函数之间没有

理解原型1.png

补充API:

  • isPrototypeOf:这个方法可以确定两个对象的关系,本质上,isPrototypeOf会在传入参数的[[Prototype]]指向调用它的对象时返回true

     console.log(User.prototype.isPrototypeOf(user)) 
     //返回true,因为user的[[prototype]]指向User.prototype
    
  • getPrototypeOf:这个方法可返回参数内部特性Prototype的值

  • setPrototypeOf可以向实例的私有属性Prototype写入一个新值,可能会严重影响代码性能,因为会涉及所有访问了那些修改过[[Prototype]]的对象的代码

  • Object.create可以用这个方法来创建一个新对象,同时指定其原型,避免setPrototypeOf造成的性能下降

原型层级

在通过对象访问属性时,会按照这个属性的名称开始搜索,搜索开始于对象实例本身,如果在这个实例上发现了给定的属性名称,则返回,如果搜索不到,则继续沿着指针进入原型对象,在原型对象上找,如果找到则返回,找不到则继续上述过程,这就是原型用于在多个对象实例间共享属性和方法的原理

通过上述查找属性的过程,我们可以引出遮蔽效果:

  • 如果在实例上添加了一个与原型对象中同名的属性,那么就会在实例上创建这个属性,这个属性会遮住原型上面的属性
  • 即使现在把这个属性设置为null,也不会恢复它和原型的联系,还是会产生遮蔽
  • 但是可以使用delete操作符将实例上的同名属性完全删除,让标识符解析过程能够继续搜索原型对象

由于可能出现遮蔽,所以有一个方法可以确定某个属性实在实例上还是原型上:hasOwnProperty() ,这个方法继承于Object如果实例上存在所找属性,则返回true,否则返回false

 user.hasOwnProperty('name') //false,实例上不存在name属性

原型与in运算符

in操作符可以单独使用也可以在for-in循环中使用

  • 单独使用in

    这时候**in操作符会在可以通过对象访问指定属性时返回true,无论该属性是在实例上还是原型上**

     "name" in user  //true,标识user可以访问到name属性
    

    通过这个运算符和hasOwnProperty,我们可以实现判断某个对象是否存在于原型上

     function hasPrototypeProperty(object, name){
         //只有既不存在于实例上又能够通过in访问时,才是原型上的属性
         return !Object.hasOwnProperty(name) && (name in object)
     }
    
  • for-in中使用

    for-in循环中使用in操作符时,可以通过对象访问并且可以被枚举的属性都会返回,包括实例属性和原型属性

    遮蔽原型中不可枚举属性的实例属性也会在for-in循环中返回,因为默认情况下开发者定义的属性都是可枚举的

    获取对象上所有可枚举实例属性的方法:Object.keys(obj)

    获取对象上所有实例属性(不管能不能枚举) 的方法:Object.getOwnPropertyNames(obj)

    获取对象上所有Symbol属性的方法:Object.getOwnPropertySymbols(obj)

属性枚举顺序

for-in循环、Object.keys()的枚举顺序是不一定的,取决于JS引擎,可能因浏览器而异

Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.assign()的枚举顺序是确定的,先以升序枚举数值键,再插入顺序枚举字符串和符号键

重写原型对象

很多时候,如果我们要频繁的将一个一个的属性和方法添加到User.prototype上面,通常我们不会一个一个的添加,而是会直接通过一个包含所有属性和方法的对象字面量来重写原型

 function User(){}
 //用一个对象字面量来重写原型
 User.prototype = {
     name: '小包',
     age: 19
 }
 console.dir(User.prototype.constructor);    //打印了Object

通过上述设置之后,会出现一个问题:重写之后,User.prototypeconstructor属性就不指向User了,因为上面的写法完全重写了默认的prototype对象,因此constructor属性也指向了完全不同的新对象(object构造函数) ,不再指向原来的构造函数了

但是此时,instanceof操作符又是能够可靠地返回值的,但是constructor属性就不能够正常返回了

 const user = new User()
 console.log(user instanceof Object) //true
 console.log(user instanceof User)   //true
 console.log(user.constructor === User)  //false
 console.log(user.constructor === Object)    //true

所以,我们为了避免出现这个问题,应该constructor重新做一个定向将他的值重新设置为原来的构造函数

并且,我们要让我们设置的**constructor属性是不可枚举的,所以要使用Object.defineProperty进行设置**

 User.prototype = {
     name: '小包',
     age: 19
 }
 Object.defineProperty(User.prototype, 'constructor', {
     enumerable: false,  //设置为不可枚举
     value: User //将constructor重新设置为User
 })

原型的动态性

原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做修改也会在实例上反映出来

这是因为:实例和原型之间松散的联系,如果在调用user.sayHi()的时候,在实例上不能找到这个属性,则会去原型上面寻找,因为实例和原型之间的链接就是简单的指针,而不是保存的副本,所以会在原型上面找到这个属性并调用

 const user = new User()
 User.prototype.sayHi = function(){
     console.log('我是小包')
 }
 user.sayHi()    //我是小包

虽然上面所说原型具有动态性,能修改后立即反映在所有实例对象上,但是这跟重写整个原型是两码事

实例的[[Prototype]]指针是在调用构造函数的时候自动赋值的这个指针即使把原型修改为不同的对象也不会变,重写整个原型会切断最初原型与构造函数之间的联系,但是实例引用的仍然是最初的原型

 const user = new User()
 User.prototype = {
     name: '小包',
     sayHi: function(){
         console.log('我是小包')
     }
 }
 user.sayHi()    //报错

原型动态性的坑.jpg

继承

JS的继承主要通过原型链实现

原型链

原型链定义

实际上,实现继承的基本思想就是使用原型继承多个引用类型的属性和方法

之前说过构造函数、原型、实例三者的关系,那么现在如果原型是另一个实例的实例呢,这就意味着这个原型内部有一个指针指向另一个原型,相应的另一个原型也有一个指针指向另一个构造函数,这样实例和原型之间就构造了一条原型链

 function People(hand){
     this.hand = hand
 }
 People.prototype.say = function(){
     console.log('哈哈哈')
 }
 ​
 function User(name){
     this.name = name
 }
 ​
 //让User继承Perple
 User.prototype = new People()
 User.prototype.findSay = function(){
     this.say() 
 }
 ​
 //实例化User
 const user = new User('小包')
 user.findSay()  //'哈哈哈' 可以看到现在User实例已经可以找到原型链上People上的方法了

在上面这段代码中,有很多细节需要我们理解:

  • 由于User没有使用默认的原型,而是使用People的实例作为新的原型对象,所以**User的实例不仅能够在People实例中继承属性,还与People的原型挂上了钩**
  • 现在的实例user通过内部的[[Prototype]]指向User.prototype,而**User.prototype(也就是People的实例)通过内部的[[Prototype]]指向People.prototype**
  • 由于User.prototype被重写,所以他的**constructor也会被重新指向**,现在的指向变成了Prople,所以**user.constructoruser通过原型链在原型上找到constructor属性)也指向People**

默认原型

实际上,原型链中还有一个重要的部分,那就是默认情况下所有引用类型都继承自Object任何函数的默认原型都是一个Object的实例,意味着这个实例有一个内部指针指向Object.prototype,能够继承toString等对象方法

此处要画一个原型链的图!!!!!!!!!!!!!!!!!!!

原型与继承关系

原型和实例的关系可以通过两种方式来确定

  • **instanceof**操作符:

    如果一个实例的原型链上出现过相应的构造函数,那么instanceof就会返回true

    虽然说User.prototype的原型被修改了,但是instanceof还是能重定向到它原本的构造函数User

    从技术上讲,user的原型链上包括这些构造函数的原型

     console.log(user instanceof Object) //true
     console.log(user instanceof User)   //true
     console.log(user instanceof People) //true
    
  • 使用**isPrototypeoOf**方法:

    原型链中的每个原型都可以调用这个方法,只要原型链包含这个原型,就返回true

     console.log(Object.prototype.isPrototypeOf(user))   //true
     console.log(People.prototype.isPrototypeOf(user))   //true
     console.log(User.prototype.isPrototypeOf(user))     //true
    

原型链的问题

通过上述讲述,我们已经清楚:原型链是实现继承的重要基础

但是原型链也存在一些问题:

  • 原型中包含的引用会在所有实例间共享,这也就是为什么属性通常会在构造函数中定义而不是在原型上定义的原型:

     function People(){
         this.body = ['hand', 'leg', 'head']
     }
     ​
     function User(){ }
     ​
     User.prototype = new People()
     const user1 = new User()
     user1.body.push('meat') //修改了原型上的body
     console.log(user1.body) // ['hand', 'leg', 'head', 'meat']
     ​
     const user2 = new User()
     console.log(user2.body) // ['hand', 'leg', 'head', 'meat']
    
  • 子类型在实例化的时候不能给父类型的构造函数传参

    实际上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数

盗用构造函数

在上述原型链中,我们提到了它存在的问题

而为了解决原型中包含的引用会在所有实例间共享这个问题,我们可以采用盗用构造函数的技术解决

基本思路

在子类构造函数中调用父类构造函数

因为函数就是在特定上下文中执行代码的简单对象,所以可以使用apply等方法以新创建的对象为上下文执行构造函数

 function People(){
     this.body = ['hand', 'leg', 'head']
 }
 ​
 function User(){
     People.call(this)
 }
 ​
 User.prototype = new People()
 const user1 = new User()
 user1.body.push('meat')
 console.log(user1.body) // ['hand', 'leg', 'head', 'meat']
 ​
 const user2 = new User()
 console.log(user2.body) // ['hand', 'leg', 'head']

上述这段代码中,在User中调用了People构造函数,通过使用call这个方法PeopleUser创建的新对象实例上下文中执行,也就是说,相当于新的User对象上运行了People函数中的所有初始化代码,结果就是每一个实例都会有自己的body属性

传递参数

盗用构造函数不仅能够解决原型中包含的引用在所有实例间共享的问题,还能够在子类构造函数中向父类构造函数传参

 function People(ear){
     this.ear = ear
 }
 ​
 function User(){
     //向父类构造函数传参
     People.call(this, 'good')
 }
 ​
 User.prototype = new People()
 const user1 = new User()
 console.log(user1.ear)  //good
 ​
 const user2 = new User()
 console.log(user2.ear)  //good

存在问题

盗用构造函数的主要缺点,就是构造函数模型自定义类型的问题:必须在构造函数中定义方法,因此函数不能复用

子类也不能访问父类原型上的方法,所有类型只能使用构造函数模式

组合继承

也叫伪经典继承,综合了原型链和盗用构造函数的优点

基本思路

使用原型链继承原型上的属性和方法,通过盗用构造函数继承实例属性

这样既可以把方法在原型上实现复用,又可以让每个实例都有自己的属性

 function People(ear){
     this.ear = ear
     this.body = ['hand', 'leg', 'head']
 }
 People.prototype.say = function(){
     console.log(this.ear);
 }
 ​
 function User(){
     //继承属性
     People.call(this, 'good')
 }
 ​
 //继承方法
 User.prototype = new People()
 const user1 = new User()
 user1.say() //good
 user1.body.push('meat')
 console.log(user1.body) // ['hand', 'leg', 'head', 'meat']
 ​
 const user2 = new User()
 user2.say() //good
 console.log(user2.body) // ['hand', 'leg', 'head']

在这段代码中,综合了上述两种继承方法的优点:

使用原型链,将实例化后的People作为User的原型,就可以继承People的公共方法

而使用盗用构造函数,就能在每一个实例上添加自己的属性

这种方法是JS中使用最多的继承模式

并且其还保留了instanceofisProtortypeOf()识别合成对象的能力

原型式继承

作用

一种新的不涉及严格意义上构造函数的继承方法,出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享

 const object = function(o){
     function F(){}
     F.prototype = o
     return new F()
 }
 ​
 const person = {
     name: '小包',
     eat: ['meat', 'vegetable', 'milk']
 }
 ​
 const person2 = object(person)
 person2.name = '小啵'
 person2.eat.push('rice')
 ​
 console.log(person.eat) //['meat', 'vegetable', 'milk', 'rice']

通过上述代码可以看出,这个object函数将原型链继承的核心代码封装成了一个函数,但是这个函数有了不同的使用场景:如果有一个已知对象,想在它的基础上在创建一个新对象,那么只需要把已知对象传给object函数即可

Object.create

在ES5中,就将上面的原型式继承规范化了,增加了**Object.create**方法

这个方法要接收两个参数:第一个参数是作为新对象原型的对象,第二个参数是给新对象定义额外属性

其中,第一个参数的效果则与我们上面的object函数效果是相同的

第二个参数的效果则与Object.defineProperties的第二个参数一样,新增属性通过描述符来描述,并且以这种方式增加的属性会遮蔽原型上的同名属性

总的来说,原型式继承非常适合不需要单独创建构造函数,当仍然需要在对象间共享信息的场合

寄生式继承

基本思路

与原型式继承很类似,主要思想就是在原型式继承的基础上以某种方式增强对象,然后返回这个对象

 const object = function(o){
     function F(){}
     F.prototype = o
     return new F()
 }
 ​
 function createAnother(original){
     const clone = object(original)
     clone.say = function(){
         console.log('哈哈哈')
     }
     return clone
 }
 ​
 const person = {
     name: '小包',
     eat: ['meat', 'vegetable', 'milk']
 }
 ​
 const person2 = createAnother(person)
 person2.say()   //哈哈哈

上述代码是一个最简单的寄生式继承案例,最然看起来只比原型式继承多挂载一个方法,但是这只是演示一下挂载一个新的方法来增强新对象,接下来将继续讲解其更强大的用法

寄生式组合继承

解决的问题

在组合继承中,其实存在效率问题,主要的问题就是父类构造函数始终会被调用两次:

  • 一次是在创建子类原型时调用
  • 一次实在子类构造函数中调用

本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了

 //组合继承的例子
 function People(ear){
     this.ear = ear
     this.body = ['hand', 'leg', 'head']
 }
 People.prototype.say = function(){
     console.log(this.ear);
 }
 ​
 function User(){
     //继承属性,此处调用一次父类构造函数
     People.call(this, 'good')
 }
 ​
 //继承方法,此处又调用一次父类构造函数
 User.prototype = new People()   
 const user1 = new User()

基本思路

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法

基本思路就是使用寄生式继承来继承父类的原型对象,然后将返回的新对象赋值给子类的原型对象

 const object = function(o){
     function F(){}
     F.prototype = o
     return new F()
 }
 function inheritPrototype(subType, superType){
     let prototype = object(superType.prototype) //获取父类原型对象副本
     prototype.constructor = subType     //将获取的副本的constructor指向子类,以此增强副本原型对象
     subType.prototype =prototype    //将子类的原型对象指向副本原型对象
 }

上述的inheritPrototype是实现寄生式组合继承的核心逻辑,这个函数可以将父类型和子类型绑定起来,而且不用重新调用父类型函数

 function SuperType(name) {
     this.name = name
     this.colors = ['red', 'blue', 'green']
 }
 ​
 SuperType.prototype.sayName = function(){
     console.log(this.name)
 }
 ​
 function SubType(name, age) {
     //只有此处调用了一次父类型函数
     SuperType.call(this.name)
     this.age = age
 }
 ​
 inheritPrototype(SubType, SuperType)
 ​
 SubType.prototype.sayAge = function() {
     console.log(this.age)
 }

不知道系列

属性设置和屏蔽

现在我们来详细分析一下属性的赋值操作:obj.foo = 'bar'

  • 如果**obj对象中包含名为foo的普通数据访问属性,这条赋值语句则只会修改已有的属性值**
  • 如果**foo不是直接存在于obj上**,[[Prototype]]链就会遍历,如果在原型链上还找不到,则直接被添加到obj

  • 如果**foo存在于obj的原型链上层**,那么就会发生屏蔽,这时就会出现三种情况:

    • 如果在[[Prototype]]上层存在名为foo的普通数据访问属性,并且没有被标注为只读,那么就会直接在obj中添加一个名为foo的新属性,是屏蔽属性
    • 如果在[[Prototype]]上层存在名为foo的普通数据访问属性,并且被标注为只读了,那么就会无法修改已有属性或者在obj上创建屏蔽属性,该赋值语句会被忽略,严格模式下会报错
    • 如果[[Prototype]]上层存在foo并且他是一个setter,那么就一定会调用这个setterfoo不会被添加到obj,也不会重新定义foo这个setter

有些情况会隐式产生屏蔽:

 var anotherObj = {
     a: 2
 }
 var myObj = Object.create(anotherObj)
 anotherObj.a    //2
 myObj.a //2
 myObj.a++   //这里会产生隐式屏蔽
 anotherObj.a    //2
 myObj.a //3
 myObj.hasOwnProperty('a')   //true

在上述代码中,myObj.a++操作相当于执行了myObj.a = myObj.a + 1,会隐式给myObj本身添加一个属性a