Skip to content

一. 对象原型和函数原型


1. 对象的隐式原型[[prototype]]

  • JS当中每个对象都有一个特殊的内置属性 [[prototype]],这个特殊的属性可以指向另外一个对象

    • [[prototype]]是一个隐藏属性,标准中是不可获取的浏览器提供了通过__proto__方式可以获取对象的原型
  • 使用__proto__是有争议的,也不鼓励使用它。因为它从来没有被包括在ECMAScript语言规范中,但是现代浏览器都实现了它。

  • __proto__ 属性已在es6语言规范中标准化,用于确保Web浏览器的兼容性,因此它未来将被支持。它已被不推荐使用,现在更推荐使用Object.getPrototypeOf/Reflect.getPrototypeOfObject.setPrototypeOf/Reflect.setPrototypeOf,但是设置对象的[[Prototype]]是一个缓慢的操作,如果性能是一个问题,应该避免

  • 那么这个隐式原型对象有什么用呢?

    • 当我们通过引用对象的属性key来获取一个value时,它会触发[[Get]]的操作

    • 这个操作会首先检查该对象是否有对应的属性,如果有的话就使用它

    • 如果该对象中没有该属性,那么会访问其内置属性[[prototype]]指向的对象是否存在该属性

      • 即访问对象属性的时候,没有就会去对象的隐式原型上查找,像函数对象的显式原型只是用来给创建的对象设置隐式原型的

      • 访问对象的属性,如果不存在该属性,则会去该对象的隐式原型对象身上查找该属性,如果也没有,就会一层一层往上找,即从该对象的原型链上查找该属性

      • 对某个对象不存在的属性进行赋值操作的时候,不能通过原型链查找的方式找到原型链上的对象的属性进行赋值操作,除非有获取到指向原型链对象上的该属性的直接或间接的引用

        js
        function Fruit() {} 
        function Orange() {}
        Fruit.sayHi = function() {
          console.log('hi~')
        }
        
        function Fn() {}
        Fn.prototype = Fruit
        Orange.__proto__ = new Fn()
        // Object.setPrototypeOf(Orange, Fruit)
        // Orange = Object.create(Fruit)
        console.log(Orange.sayHi === Fruit.sayHi) // true 这里Orange.sayHi是通过查找原型链找到的Fruit对象身上sayHi方法
        Orange.sayHi = 'hehe' // 这里的赋值操作,并不会通过原型链查找的方式找到Fruit对象身上的sayHi方法,所以不会修改Fruit对象身上sayHi方法,而是直接给Orange对象添加了一个sayHi属性
        console.log(Fruit.sayHi) // f () { console.log('hi~') }
        console.log(Orange.sayHi) // hehe
  • 那么如果通过字面量直接创建一个对象,这个对象也会有这样的属性吗?如果有,应该如何获取这个属性呢?

    • 答案是有的,只要是对象都会有这样的一个内置属性[[prototype]]
  • 获取的方式有两种:

    • 方式一:通过对象的__proto__属性可以获取到(但是这个是早期浏览器自己添加的,存在一定的兼容性问题)
    • 方式二:通过 Object.getPrototypeOf方法可以获取到
    js
    var obj = {
      name: 'later'
    }
    console.log(obj.__proto__) // {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, …}
    console.log(Object.getPrototypeOf(obj)) // {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, …}
    console.log(obj.__proto__ === Object.getPrototypeOf(obj)) // true
    
    // 原型:在JS中,每一个对象都会有一个内置属性[[prototype]],这个属性就是对象的原型,这个属性的值也是一个对象,是原对象的原型对象。访问对象中属性时,会先在对象自身进行查找,如果没有找到,那么会去对象的原型对象上查找

2. 函数的显式原型prototype

  • 那么我们知道上面的东西对于我们的构造函数创建对象来说有什么用呢?

    • 用来构建对象时, 给对象设置隐式原型的
    • 查找属性的时候,是通过隐式原型去查找的
    • 箭头函数是没有显式原型的,因为其不能作为构造函数使用,作为对象时,是有隐式原型的
  • 这里我们又要引入一个新的概念:所有的非箭头函数都有一个显式属性prototype(注意:不是__proto__

    js
    function foo() {}
    console.log(foo.prototype) // {constructor: ƒ} 在vscode编辑器中是有prototype这个属性的提示的,而__proto__则没有
  • 你可能会问题,是不是因为函数是一个对象,所以它有prototype的属性呢?

    • 不是的,因为它是一个函数,才有了这个特殊的属性(ECMA标准中的规定的)
    • 而普通对象是没有这个属性的

二. new操作符、constructor属性


1. new操作原型赋值

  • 我们前面讲过new关键字的步骤如下:

    1. 在堆内存中创建一个新的空对象
    2. 构造函数的显式原型prototype属性赋值给该空对象的隐式原型[[prototype]]属性
    3. 构造函数内部的this指向(绑定)该空对象
    4. 执行构造函数中(函数体)的代码
    5. 如果构造函数没有明确指定返回一个非空对象,则返回该创建出来的新对象
  • 根据第二点可得知:

    • 所有通过构造函数new操作符创建出来的所有实例对象的[[prototype]]属性都指向该构造函数的显式原型prototype对象
    js
    function Person() {
      // 1. 创建一个空对象
      // 2. 将Person的prototype原型(显式隐式)赋值给空的对象的__proto__(隐式原型)
      // ...
    }
    
    var p1 = new Person()
    var p2 = new Person()
    console.log(p1.__proto__ === Person.prototype) // true
    console.log(p2.__proto__ === Person.prototype) // true
    console.log(p1.__proto__ === p2.__proto__) // true

2. 将公共方法放在构造函数的显式原型上

js
function Student(name, age) {
  this.name = name
  this.age = age
  // 1.方式一: 每new一个对象,都会创建如下一个函数对象
  // this.running = function() {
  //   console.log(this.name + 'running~')
  // }
}

// 当我们多个对象拥有共同的值时, 我们可以将它放到构造函数对象的显式原型上(即只有一个该对象,隐式调用该方法的时候,函数内部this会绑定到调用者身上)
// 由构造函数创建出来的所有对象, 都会共享这些属性
Student.prototype.running = function() {
  console.log(this.name + 'running~')
}

var stu1 = new Student('小明', 18) // 小明running~
var stu2 = new Student('小红', 18) // 小红running~
stu1.running()
stu2.running()
// 隐式原型的作用
// 1> stu1的隐式原型是谁? Student.prototype对象
// 2> stu1.running查找:
//  * 先在自己身上查找, 没有找到
//  * 去原型去查找

3. 显式原型对象上的constructor属性

  • 事实上,显式原型对象上面是有一个constructor属性的

    • 默认情况下,显式原型对象上都会添加一个constructor属性的,指向当前的函数对象
    js
    function Foo() {}
    console.log(Foo.prototype.constructor) // ƒ Foo() {}
    console.log(Foo.prototype.constructor.name) // Foo
    console.log(Foo.prototype.constructor === Foo) // true
    
    var f1 = new Foo()
    console.log(f1.__proto__.constructor) // f Foo() {}
    console.log(f1.__proto__.constructor.name) // Foo
    console.log(f1.__proto__.constructor === Foo.prototype.constructor) // true

4. 构造函数创建对象的内存表现

js
function Person(name, age) {
  this.name = name
  this.age = age
}

Person.prototype.running = function() {}
Person.prototype.address = '中国'
Person.prototype.info = '中国很美丽'

var p1 = new Person('why', 18)
p1.height = 1.88
p1.address = '广州市'

var p2 = new Person('kobe', 30)
p2.isAdmin = true
image-20220610145115733

5. 重写原型对象

  • 如果我们需要在原型上添加过多的属性,通常我们会重写整个原型对象:

    js
    function Person() {}
    Person.prototype = {
      name: 'later',
      age; 18,
      running: function() {
        console.log(this.name + '在吃东西')
      }
    }
  • 前面我们说过,每创建一个函数,就会同时创建它的prototype对象,这个对象也会自动获取constructor属性

    • 而我们这里相当于给prototype重新赋值了一个对象,那么这个新对象的constructor属性,会指向Object构造函数,而不是Person构造函数了

6. 原型对象的constructor

  • 如果希望constructor指向Person,那么可以手动添加:

  • 上面的方式虽然可以,但是也会造成constructor[[Enumerable]]特性被设置了true

    • 通过对象点语法的方式,直接给对象添加的属性,数据属性描述符都是为true
    • 默认情况下,原生的constructor属性是不可枚举的
    • 如果希望解决这个问题, 就可以使用我们前面介绍的Object.defineProperty()方法了
    js
    function Person() {}
    Person.prototype = {
      name: 'later',
      age; 18,
      running: function() {
        console.log(this.name + '在吃东西')
      }
    }
    
    // 通过属性描述符定义一个属性时,可配置、可写、可枚举等数据属性描述法默认为false,value默认为undefined
    Object.defineProperty(Person.prototype, 'constructor', {
      value: Person 
    })

三. 原型链的查找顺序


1. 面向对象的特性 - 继承

  • 面向对象有三大特性:封装、继承、多态
    • 封装:我们前面将属性和方法封装到一个类中,可以称之为封装的过程
    • 继承:继承是面向对象中非常重要的,不仅仅可以减少重复代码的数量,也是多态前提(纯面向对象中)
    • 多态:不同的对象在执行时表现出不同的形态
  • 那么这里我们核心讲继承,那么继承是做什么呢?
    • 继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要直接继承过来使用即可
    • 在很多编程语言中,继承也是多态的前提
  • 那么JS当中如何实现继承呢?
    • 不着急,我们先来看一下js原型链的机制
    • 再利用原型链的机制实现一下继承

2. JS原型链

  • 在真正实现继承之前,我们先来理解一个非常重要的概念:原型链

    • 我们知道,访问对象上的某个属性,如果该属性在当前对象中不存在就会去它的原型对象上面查找
    • 原型对象上面也没有该属性,就会去原型对象的原型对象上面查找,这就形成了一种链条结构,称之为原型链
    原型链:
    	每个对象都有自己的原型对象,原型对象也有自己的原型对象。
    	在访问对象的属性时,会沿着对象自身 => 自身的原型对象 => 原型对象的原型对象...这样的链条一路查找上去,这条链式结构就叫做原型链。
    	原型链的尽头是 Object的原型对象的[[prototype]]属性,值为null。
    image-20220610220334818

3. Object的原型

  • 那么什么地方是原型链的尽头呢?

    js
    var obj = {
      name: 'later'
    }
    console.log(obj.__proto__.__proto__) // null
  • 我们会发现它打印的是nullnode环境中打印有时候会显示是 [Object: null prototype] {}

    • 事实上这个原型就是我们最顶层的原型了
    • Object直接创建出来的对象的原型都是 [Object: null prototype] {}
  • 那么我们可能会问题: [Object: null prototype] {}原型有什么特殊吗?

    • 特殊一:该对象有原型属性,但是它的原型属性已经指向的是null,也就是已经是顶层原型了
    • 特殊二:该对象上有很多默认的属性和方法
    image-20220610224129502

4. Object是所有类的父类

  • 从我们上面的Object原型我们可以得出一个结论:原型链最顶层的原型对象就是Object的原型对象
js
function Person(name, age) {
  this.name = name
  this.age = age
}
Person.prototype.running = function() {
  console.log(this.name + 'running~')
}

var p1 = new Person('why', 23)
console.log(p1) // Person {name: 'why', age: 23}
console.log(p1.valueOf()) // Person {name: 'why', age: 23}
console.log(p1.toString()) // [object Object]
image-20220610224604176

四. 原型链实现的继承


1. 通过原型链实现继承

  • 如果我们现在需要实现继承,那么就可以利用原型链来实现了:

    js
    // 1. 定义父类构造函数
    function Person() {
      this.name = 'why'
    }
    
    // 2. 父类原型上添加内容
    Person.prototype.running = function() {
      console.log(this.name + 'running~')
    }
    
    // 3. 定义子类构造函数
    function Student() {
      this.sno = 111
    }
    
    // 4. 创建父类对象,作为子类的原型对象
    var p = new Person()
    Student.prototype = p
    
    // 5. 在子类原型上添加内容
    Student.prototype.studying = function() {
      console.log(this.name + 'studying~')
    }
    • 目前stu的原型是p对象,而p对象的原型是Person默认的原型,里面包含running等函数
    • 注意:步骤4和步骤5不可以调整顺序,否则会有问题
    image-20220611001002003

五. 借用构造函数继承


1. 原型链继承的弊端

  • 但是目前有一个很大的弊端:某些属性其实是保存在p对象上的
    • 第一,我们通过直接打印对象是看不到这个属性的
    • 第二,这个属性会被多个对象共享,如果这个对象是一个引用类型,那么就会造成问题
    • 第三,不能给Person传递参数(让每个stu有自己的属性),因为这个对象是一次性创建的(没办法定制化)

2. 借用构造函数继承

  • 为了解决原型链继承中存在的问题,开发人员提供了一种新的技术: constructor stealing(有很多名称:借用构造函数或称之为经典继承或者称之为伪造对象):

    • steal是偷窃、剽窃的意思,但是这里可以翻译成借用
  • 借用构造函数继承的做法非常简单:在子类型构造函数的内部调用父类型构造函数

    • 因为函数可以在任意的时刻被调用
    • 因此通过apply()call()方法也可以在新创建的对象上执行构造函数
    js
    function Person(name, age) {
      this.name = name
      this.age = age
    }
    Person.prototype.running = function() {
      console.log(this.name + 'running~')
    }
    function Student(name, age) {
      Person.call(this, name, age)
    }
    
    var p1 = new Person()
    Student.prototype = p1
    
    var stu1 = new Student('later', 23)
    console.log(stu1) // Student {name: 'later', age: 23}
    stu1.running() // laterrunning~

六. 寄生组合实现继承


1. 组合借用继承的问题

  • 组合继承是JS最常用的继承模式之一:

    • 如果你理解到这里,点到为止,那么组合来实现继承只能说问题不大
    • 但是它依然不是很完美,但是基本已经没有问题了
  • 组合继承存在什么问题呢?

    • 组合继承最大的问题就是无论在什么情况下,都会调用两次父类构造函数

      • 一次在创建子类原型的时候

        js
        var p1 = new Person()
      • 另一次在子类构造函数内部(也就是每次创建子类实例的时候)

        js
        Person.call(this, name, age)
    • 另外,如果你仔细按照流程走了上面的每一个步骤,你会发现:所有的子类实例事实上会拥有两份父类的属性

      • 一份在当前的实例自己里面(也就是person本身的),另一份在子类对应的原型对象中(也就是Person.__proto__里面)
      • 当然,这两份属性我们无需担心访问出现问题,因为默认一定是访问实例本身这一部分的

2. 原型式继承函数

  • 原型式继承的渊源

    • 这种模式要从道格拉斯·克罗克福德(Douglas Crockford,著名的前端大师,JSON的创立者)在2006年写的一篇文章说起: Prototypal Inheritance in JavaScript(在Javascirpt中使用原型式继承)
    • 在这篇文章中,它介绍了一种继承方法,而且这种继承方法不是通过构造函数来实现的
    • 为了理解这种方式,我们先再次回顾一下js想实现继承的目的:重复利用另外一个对象的属性和方法
  • 最终的目的:student对象的原型指向了person对象

    js
    function Person(name, age) {
      this.name = name
      this.age = age
    }
    Person.prototype.running = function() {
      console.log(this.name + 'running~')
    }
    function Student(name, age) {
      Person.call(this, name, age) // 调用父类构造函数
    }
    // var p1 = new Person()
    // Student.prototype = p1
    function createObject(obj) {
      function Func() {}
      Func.prototype = obj
      return new Func()
      // Func.__proto__ = obj
      // return Func
      // 这里为什么不可以返回Func函数对象呢?因为如果返回Func函数对象,其本身是存在某些只读的内置属性,如name,length等,如果返回Func函数,在调用父类构造函数的时候,如果内部有对该对象的name或length属性进行操作时,可能就会查找到返回的Func函数的内置只读属性,会导致相关操作引发报错
    }
    Student.prototype = createObject(Person.prototype)
    
    var stu1 = new Student('later', 23)
    console.log(stu1) // Student {name: 'later', age: 23}
    stu1.running() // laterrunning~
    js
    // 方式一 兼容性最好
    function createObject(obj) {
      function Func() {}
      Func.prototype = obj
      return new Func()
    }
    // 方式二 性能不好
    function createObject(obj) {
      var newObj = {}
      // Object.setPrototypeOf(obj, prototype) 设置对象的原型
      // obj:要设置其原型的对象
      // prototype:该对象的新原型(一个对象或null)
      Object.setPrototypeOf(newObj, obj) // newObj.__proto__ = obj
      return newObj
    }
    // 方式三
    // Object.create()方法 创建一个新对象,使用指定提供的对象来作为新创建对象的原型__proto__
    Student.prototype = Object.create(Person.prototype)

3. 寄生式继承函数

  • 寄生式(Parasitic)继承

    • 寄生式(Parasitic)继承是与原型式继承紧密相关的一种思想,并且同样由道格拉斯·克罗克福德(Douglas Crockford)提出和推广的
    • 寄生式继承的思路是结合原型类继承和工厂模式的一种方式
    • 即创建一个封装函数继承过程的函数,该函数在内部以某种方式来增强对象,最后再将这个对象返回
    js
    function object(obj) {
      function Func() {}
      Func.prototype = obj
      return new Func()
    }
    function createStudent(person) {
      var newObj = object(person)
      newObj.studying = function() {
        console.log(this.name + 'studying')
      }
      return newObj
    }

4. 寄生组合式继承

  • 现在我们来回顾一下之前提出的比较理想的组合继承

    • 组合继承是比较理想的继承方式,但是存在两个问题:
      • 问题一:构造函数会被调用两次:一次在创建子类型原型对象的时候,一次在创建子类型实例的时候
      • 问题二:父类型中的属性会有两份:一份在原型对象中,一份在子类型实例中
  • 事实上突然,我们现在可以利用寄生式继承将这两个问题给解决掉:

    • 你需要先明确一点:当我们在子类型的构造函数中调用父类型.call(this, 参数)这个函数的时候,就会将父类型中的属性和方法复制一份到了子类型中,而父类型本身里面的内容,我们不再需要
    • 这个时候,我们还需要获取到一份父类型的原型对象中的属性和方法
    • 能不能直接让子类型的原型对象 = 父类型的原型对象呢?
    • 不要这么做,因为这么做意味着以后修改了子类型原型对象的某个引用类型的时候,父类型原生对象的引用类型也会被修改
    • 我们使用前面的寄生式思想就可以了
  • 寄生组合继承的代码

    js
    function object(obj) {
      function F() {}
      F.prototype = obj
      return new F()
    }
    
    function inheritPrototype(subType, superType) {
      // subType.prototype = object(superType.prototype) // es6之前早期写法, 无兼容性问题
      // subType.prototype.__proto__ = superType.prototype // __proto__不是早期ecma的标准(es6中标准化了),浏览器实现的
      // Object.setPrototypeOf(subType.prototype, superType.prototype) // 直接修改对象的原型的操作都是有性能问题的
      subType.prototype = Object.create(superType.prototype) // 这个不错,创建一个新对象,指定其原型对象,无性能问题
      Object.defineProperty(subType.prototype, 'constructor', {
        enumerable: false,
        configurable: true,
        writable: true,
        value: superType
      })
      // 子类继承父类的类方法(兼容写法)
      Object.setPrototypeOf ? Object.setPrototypeOf(subType, superType) : subType.__proto__ = superType
    }
    
    inheritPrototype(Student, Person)

Released under the MIT License.