轻松入门前端【1】—— 原型链与继承

344 阅读8分钟

原型链

每一个函数拥有原型(prototype)属性,如果该函数充当构造函数去 new 实例,则会构成一条原型链,每个实例成员都可以使用原型上的属性和方法

function Person() {}

Person.prototype.say = function() {
  console.log('hello')
}

const jack = new Person()
const marry = new Person()

jack.say()    // hello
marry.say()   // hello

一个构造函数与它的原型及实例的关系图如下:

function Person() {}
const jack = new Person()

console.log(jack.__proto__ === Person.prototype)        // true,实例对象-→原型     
console.log(jack.constructor === Person)                // true,实例对象-→构造函数
console.log(Person.prototype.constructor === Person)    // true,构造函数←→原型

任何原型链的最顶层都指向 Object 的原型,只需要记住原型、构造函数、实例对象三者的关系即可,把其他原型当成 Object 原型的实例对象

function Person() {}
const jack = new Person()

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

特性

原型

原型本质是一个对象,其内部包含默认属性值 constructor__proto__,在原型中添加的属性会被添加到该对象中,且原型会作为实例对象的父层作用域对象,当实例对象找不到指定属性时,会往上从自身原型中查找

构造函数的静态属性与原型及实例对象没有作用域的关联,因此静态属性不会影响原型及实例对象

function Person() {}
const jack = new Person()

Person.prototype.x = 'x'
Person.y = 'y' 

console.log(jack.x)     // x
console.log(jack.y)     // undefined
console.log(Person.x)   // undefined
console.log(Person.y)   // y

上述代码执行后的原型内部属性:

注意,父级作用域的概念不只存在于 实例对象 -- 原型 之间,也存在于 原型 -- 原型 之间

function Person() {}
const jack = new Person()

Object.prototype.x = 'x'

console.log(jack.x)             // x
console.log(jack.__proto__.x)   // x,Person原型中没有x属性,会从上级原型中进行查找

风险

实例对象更改原型属性

当实例对象想要读取属性数据时,发生如下操作:

  1. 判断实例中有无该指定属性,如果找到,则直接读取
  2. 如果没有,则往原型中寻找,直至找到顶层作用域,没有返回 undefined

当实例对象想要写入数据属性时,发生如下操作:

  1. 判断实例中有无该指定属性,如果找到,则直接修改
  2. 默认无法对原型数据进行修改,因此在实例对象中创建该属性再进行写入数据
function Person() {}
const jack = new Person()

Person.prototype.x = 'x'
Object.prototype.y = 'y'

console.log(jack.x)             // x,从自身原型中读取
console.log(jack.y)             // y,自身原型找不到,继续往上级Object原型中读取
console.log(jack.z)             // undefined,都找不到则返回undefined

jack.x = 'jack_x'

console.log(jack.__proto__.x)   // x,实例对象无法修改原型属性
console.log(jack.x)             // jack_x
console.log(jack)               // Person {x: "jack_x"},在自身内部添加了x属性

不过写操作的限制只是一层浅保护,它的判断是值类型和引用类型的引用地址是否前后相等,如果实例对象直接更改原型引用类型属性中的内部属性,是可以进行修改的(和 const 允许修改引用类型内部属性一样)

function Person() {}
const jack = new Person()
const lucy = new Person()

Person.prototype.obj = {
  x: 'x'
}

jack.obj.x = 'jack_x'

console.log(jack.__proto__.obj.x)   // jack_x,原型内部属性被实例对象修改了
console.log(jack)                   // Person {},实例对象内部并不会增加属性
console.log(lucy.obj.x)             // jack_x,由于原型属性被修改,所以所有的实例对象都会被影响

为了不让实例对象有机会更改原型属性,可以在实例内部增加同名属性,由于优先寻找自身作用域,就可以避免上述情况

由于这个特性,实例对象需要写操作的属性,一般都是自身拥有而非存储在原型上,所以原型一般存储值类型和函数

function Person() {}
const jack = new Person()
const lucy = new Person()

Person.prototype.obj = {
  x: 'x'
}

jack.obj = {x: 'jack_x'}

console.log(jack.__proto__.obj.x)   // x
console.log(jack)                   // Person { obj: { x: 'jack_x' } }
console.log(lucy.obj.x)             // x

原型方法丢失this

在原型中绑定函数属性时,如果声明函数使用的是箭头函数的方式,则会丢失 this 值,因为箭头函数不绑定 this

function Person() {}
const jack = new Person()


Person.prototype.say = () => {
  console.log(`my name is ${this.name}`)
}

Person.prototype.doing = function() {
  console.log(`i am doing ${this.thing}`)
}

jack.name = 'jack'
jack.thing = 'cooking'

jack.say()        // my name is undefined
jack.doing()      // i am doing cooking

构造函数

任何函数(箭头函数除外)只要通过 new 来生成实例化对象就可以作为构造函数,否则普通函数并无区别,为了与普通函数的功能区分,一般用作构造函数的函数名都会首字母大写

构造函数生成实例化对象本质上是建立原型链之间的关联


new

执行机制

使用new操作符创建对象实例时发生的事情:

  1. 在构造函数中自动创建一个空对象充当实例对象
  2. 建立原型链,空对象指向构造函数的原型对象
  3. 将构造函数的作用域赋给新对象(this指向该对象)并执行构造函数的代码(因此构造函数中使用this声明的属性和方法会复制给新对象)
  4. 如果是返回值为原始类型,则返回变更为 return this,如果返回值为对象,则正常返回对象
function Person(name) {
  this.name = name
}

// const jack = new Person()相当于:
const jack = {}
jack.name = 'name'    // 因为有this的存在,new的时候this指向jack,执行构造函数等同于该行代码
jack.__proto = Person.prototype
jack.constructor = Person

构造函数公开的属性和方法需要使用 this 表示,否则不会在实例化对象的时候在该对象中创建对应值

function Person() {
  this.name = 'jack'
  const age = 12
}

const jack = new Person()
console.log(jack)     // Person { name: 'jack' }

new 将 this 绑定给实例对象的优先级高于更改 this 函数

const obj = {}

function Person() {
  this.name = 'Person'
}

const _Person = Person.bind(obj)
const jack = new _Person()

console.log(jack)     // Person { name: 'Person' },this仍是指向jack,在内部创建了name属性

内部实现

function _new(Fn, ...rest) {
  const instance = Object.create(Fn.prototype)          // 创建空对象{},并连接实例对象到原型的链路
  const result = Fn.apply(instance, rest)               // 将this指向实例,传入构造参数执行构造函数,并接收构造函数返回值 
  return result instanceof Object ? result : instance   // 如果构造函数返回值为引用类型则直接返回,否则返回实例对象
}

测试原型链连接

function Person(name) {
  this.name = name
  this.say = () => {
    console.log(`my name is ${this.name}`)
  }
}

Person.prototype.x = 'x'
const jack = _new(Person, 'jack')

jack.say()                                // my name is jack
console.log(jack.x)                       // x
console.log(jack.constructor === Person)  // true

测试构造函数有返回值情况

function Person() { 
  return new Map()
}
function Animal() {
  return 'animal'
}

const jack = _new(Person)
const lulu = _new(Animal)

console.log(jack)   // Map(0) {}
console.log(lulu)   // Animal {}

箭头函数

箭头函数使用 new 关键字会报错,因此箭头函数无法作为构造函数使用,其本质原因:

  • 箭头函数拥有 __proto__ 属性,其自身存在原型链,但是没有 prototype 属性,导致无法连接原型链
  • 箭头函数没有 this 所以无法将构造函数的公开属性传递给实例对象
const fn = () => {}
Function.prototype.x = 'x'

const instance = new fn()     // 报错:fn is not a constructor

console.log(fn.__proto__.x)   // x,说明箭头函数与Funtion原型链有所关联
console.log(fn.prototype)     // undefined,箭头函数没有原型属性

构造函数继承

两个构造函数之间可以实现继承关系,如所有构造函数都继承于 Object 构造函数

console.log(Number.prototype.__proto__   === Object.prototype)    // true
console.log(Boolean.prototype.__proto__  === Object.prototype)    // true
console.log(String.prototype.__proto__   === Object.prototype)    // true
console.log(Function.prototype.__proto__ === Object.prototype)    // true
console.log(Array.prototype.__proto__    === Object.prototype)    // true
console.log(Map.prototype.__proto__      === Object.prototype)    // true
console.log(Set.prototype.__proto__      === Object.prototype)    // true
console.log(Date.prototype.__proto__     === Object.prototype)    // true
console.log(RegExp.prototype.__proto__   === Object.prototype)    // true
console.log(Error.prototype.__proto__    === Object.prototype)    // true

值类型也能访问到 Object 原型链路是因为在使用值类型属性时,js 会隐式转换使用包装类去访问

const num = 123
console.log(num.__proto__.__proto__ === Object.prototype)    // true,相当于包装类Number去访问

继承特点(Child 构造函数继承 Father 构造函数,Child 实例化对象 child):

  • child 实例内部拥有 Child、Father 构造函数内的公开属性
  • child 实例可以访问 Child、Father 原型链路
class Father {
  constructor() {
    this.father = 'father'
  }
}


class Child extends Father {
  constructor() {
    super()
    this.child = 'child'
  }
}

Father.prototype.x = 'x'
const child = new Child()

console.log(child)      // Child { father: 'father', child: 'child' }
console.log(child.x)    // x

功能实现

由继承的两个特点可知:

  • 为了让子实例内部同时拥有子、父构造函数内公开的属性,则需要在 new 实例的时候执行一遍父构造函数与子构造函数,由于 new 默认执行子构造函数,则需要在子构造函数中去调用一次父构造函数(需要将 this 绑定至实例)
  • 为了让子实例能访问到父构造函数原型链,则需要建立原型链连接,参考 Object 与其他子类的原型链连接可知,令 子原型 = 父实例 即可

function Father() {
  this.father = 'father'
}

function Child() {
  if(Child.extendsFn) {
    Child.extendsFn.call(this)
  }
  this.child = 'child'
}

const _extends = (Father, Child) => {
  Child.prototype = new Father()      // 绑定原型链
  Child.extendsFn = Father            // 让子构造函数内部得以执行父构造函数
}

_extends(Father, Child)

Father.prototype.x = 'x'

const child = new Child()
console.log(child)                    // Father { father: 'father', child: 'child' },注意这里的标志是Father
console.log(child.x)                  // x

此时原型链关系:

// 实例与子父构造函数原型链连接
console.log(child instanceof Child)                             // true
console.log(child instanceof Father)                            // true

// 缺失一些原型链造成的原型链路混乱,js认为child的构造函数为Father
console.log(child.constructor === Father)                       // true
console.log(child.__proto__.constructor === Father)             // true

// 父构造函数自身的原型链路正常
console.log(child.__proto__.__proto__ === Father.prototype)     // true
console.log(child.__proto__.__proto__.constructor === Father)   // true

上面的实现思路存在两个问题:

  • 在实例化子对象的时候,没有必要去实例化父对象
  • 由于没有绑定 子原型.constructor = 子构造函数 原型链路,导致子原型链错误

为了解决这个问题,需要把父级构造函数从继承原型链中去除,直接方法是不使用父构造函数去 new 实例,同时绑定 子原型.constructor = 子构造函数 即可

const _extends = (Father, Child) => {
  Child.prototype = Object.create(Father.prototype)   // 去除父级构造函数的关联,直接建立子原型=父实例
  Child.prototype.constructor = Child                 // 建立子原型-→子构造函数的关系
  Child.extendsFn = Father                            // 让子构造函数内部得以执行父构造函数
}

此时原型链关系:

console.log(child)                                              // Child { father: 'father', child: 'child' } 

// 实例与子父构造函数原型链连接
console.log(child instanceof Child)                             // true
console.log(child instanceof Father)                            // true

// 子构造函数自身的原型链路正常
console.log(child.constructor === Child)                        // true
console.log(child.__proto__.constructor === Child)              // true

// 父构造函数自身的原型链路正常
console.log(child.__proto__.__proto__ === Father.prototype)     // true
console.log(child.__proto__.__proto__.constructor === Father)   // true