JavaScript基础之原型/继承


原型

定义

所有的引用类型的数据都有 __proto__ 这个属性,该属性即为隐式原型,所有的函数都有 prototype 属性,该属性即为显式原型

这两个属性分别是什么?有什么联系?

prototype

我们从原型的定义上知道所有的函数都有 prototype 属性,输出一个函数看看:

function Foo() {}
console.dir(Foo)

观察输出结果,我们发现了 __proto__prototype 这两个不可枚举属性,先不管 __proto__ 这个属性,继续查看 prototype 属性。prototype 有只有两个属性:__proto__constructor,这意味着 prototype 指向了一个对象,继续研究 constructor ,我们发现这个属性指向了函数本身,确定一下:

function Foo() {}
console.log(Foo.prototype.constructor === Foo) // true

好了,我们知道了 prototype 这个属性指向一个对象,这个对象默认会拥有两个不可枚举属性 __proto__constructor ,其中的 constructor 指向函数本身

了解了 prototype 属性,你肯定会想去了解我们一直略过的 __proto__ 属性,别着急我们先从构造函数说起。

构造函数

解释

在 JavaScript 中并没有类的概念,但有类的模拟实现,也就是我们常说的构造函数。

构造函数就是一个普通的函数,只不过为了便于区分,我们将构造函数的首字母大写

// 这是一个构造函数
function Foo() {}

// 这也是一个构造函数
function bar() {}

但是这样解释或许有点让人费解,函数 === 构造函数

我们换一种更准确的说法:函数不是构造函数,但是当且仅当使用 new 时,函数调用会变成“构造函数调用”

作用

构造函数有什么用?观察以下代码:

function Person(name, age) {
    this.name = name
    this.age = age
}
Person.prototype.weight = 50

const person1 = new Person('Jack', 18)
const person2 = new Person('Mick', 20)
console.log(person1)
console.log(person2)

我们发现通过 new 操作符,实例化的对象拥有构造器属性 nameage 和原型属性 weight ,但是 weight 属性并不在实例对象中而是在实例对象的 __proto__ 属性所指的对象中。

原型属性并不需要我们去传值,而是直接从构造函数的 prototype 中获取,这个操作我们称之为“继承”,“继承”的行为是在 new 操作符内部实现的。

使用 new 来调用函数,或者说发生构造函数调用时:

  1. 创建一个全新的对象
  2. 将新对象连接到构造函数的 prototype 所指的对象
  3. 这个新对象会绑定到函数调用的 this
  4. 这个函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象

我们发现了一个很微妙的事情:原型属性既在构造函数的 prototype ,又在其实例化对象的 __proto__

那么这两者有什么联系?__proto__ 到底是什么?

_proto_

我们从原型的定义上知道所有的引用类型的数据都有 __proto__ 属性,输出一个对象看看:

const obj = {}
console.log(obj)

观察输出结果,我们发现这个对象只拥有一个不可枚举属性 __proto__ ,继续查看 __proto__ 属性,发现都是一些不可枚举属性都指向了 Object 的方法,其中有一个属性比较特殊 constructor,在之前构造函数的 prototype 上出现了 constructor,那么这个 constructor 指向谁呢?在上面的代码中它指向了 Object ,这是否意味着这个“空对象”的构造函数就是 Object 呢?更进一步是否构造函数的 prototype 就是其实例化的对象的 __proto__ 呢?

观察下面的代码:

function Foo() {}
const foo = new Foo()
console.log(Foo.prototype === foo.__proto__) // true
console.log(foo.__proto__.constructor === Foo) // true

我们可以确定:

__proto__属性是该引用类型数据的构造函数的 prototype 属性

constructor

在构造函数的 prototype 属性中,constructor 指向构造函数本身;在实例对象中,其 __proto__constructor 指向其构造函数,我们认为这两者是一个意思。

但真的是一个意思吗?观察下面的代码:

function Foo() {}
Foo.prototype = {}

const obj = new Foo()
console.log(obj.constructor === Foo) // false
console.log(obj.constructor === Object) // true

看起来应该是 Foo() 构造了 obj 对象,但是实质上是 Object() 构造的。

Foo 的原型被更改为一个“空对象”,那么在 new 这个函数的时候,创建的新对象连接到了这个“空对象”上,这个“空对象”不是函数,那么它会去找这个“空对象”的原型 __proto__ ,这个原型是函数(Object),于是连接到到这个函数,constructor 指向这个函数。这其实就是原型链。

原型链

每个对象都拥有一个原型对象,对象的原型可能也是继承其他原型对象,一层一层的,以此类推,这种关系就是原型链。

所有的原型( Object.prototype 除外)都是对象,所以原型对象的构造函数都为 Object()

Object.prototype 的隐式原型 __proto__null。即原型链的顶端为 null

包装对象

我们知道 JavaScript 中有8种数据类型:

  • 原始类型:String、Number、Boolean、Undefined、Null、BigInt、Symbol
  • 引用类型:Object(Object、Array、Date、Math)

那么这些数据都是怎么产生的,例如,let a = 'Hello World',JavaScript 怎么将 a 声明为字符串类型,a 变量上的方法又是哪来的?

实际上,在我们创建一个原始数据类型时,JavaScript 底层创建一个基于此类型的包装对象的实例:

var name = 'echo'
var name_ = name.toUpperCase()

// 创建 String 实例,将实例赋予变量 name
// 在实例上调用指定的方法
// 销毁这个实例
var string = new String('echo')
var name = string
var name_ = name.toUpperCase()
string = null
console.log(name) // echo
console.log(name_) // ECHO

String、Number、Boolean属于包装对象,包装对象是一种声明周期只有一瞬的对象,创建与销毁都由底层实现。

Undefined、Null、BigInt、Symbol不属于包装对象。

既然原始数据类型有包装对象,那么引用类型呢?

引用类型中,不存在所谓的包装对象,但是有各种各样的”包装类“——构造函数。

const obj = new Object()
const arr = new Array()
const reg = new RegExp()
const fn = new Function()
console.log(obj) // {}
console.log(arr) // []
console.log(reg) // /(?:)/
console.log(fn) // ƒ anonymous() {}

原型链

这些包装对象和”包装类“本身就是构造函数,那么它们也是被 Function() 构造出来的:

原始构造函数Function()扮演着创世主女娲的角色,她创造了Object()、Number()、String()、Date()、function fn(){}等第一批人类(也就是构造函数),而人类同样具备了繁衍的能力(使用new操作符),于是Number()繁衍出了数据类型数据,String()诞生了字符串,function fn(){}作为构造函数也诞生了各种各样的对象后代。

我们可以通过如下代码论证这一点:

// 所有函数对象的 __proto__ 都指向 Function.prototype,包括Function本身
Number.__proto__ === Function.prototype //true
Number.constructor === Function //true

String.__proto__ === Function.prototype //true
String.constructor === Function //true

Object.__proto__ === Function.prototype //true
Object.constructor === Function //true

Array.__proto__ === Function.prototype //true
Array.constructor === Function //true

Function.__proto__ === Function.prototype //true
Function.constructor === Function //true

所以当实例访问某个属性时,会先查找自己有没有,如果没有就通过 __proto__访问自己构造函数的 prototype 有没有,前面说构造函数的原型是一个对象,如果原型对象也没有,就继续顺着构造函数 prototype 中的__proto__继续查找到构造函数 Object() 的原型,再看有没有,如果还没有,就返回 undefined,因为再往上就是 null 了,这个过程就是我们熟知的原型链,说的再准确点,就是__proto__ 访问过程构成了原型链。

function F() {}
Object.prototype.a = function () {
    console.log('a')
}
Function.prototype.b = function () {
    console.log('b')
}
var f = new F()
f.a() // a
/*  f 本身没有找到 a 方法,那么会去 f 的隐式原型(__proto__)上找
    --> 如果还是没有,那么会去 F 的隐式原型(__proto__)上找
    --> Object 的显示原型上恰好有 a 方法
*/

f.b() // referenceError:b is not defined
/*  f 本身没有找到 b 方法,那么会去 f 的隐式原型(__proto__)上找
    --> 如果还是没有,那么会去 F 的隐式原型(__proto__)上找
    --> Object 的显式原型上还是没有 b 方法 ,由于 Object.prototype 的 __proto__ 是 null ,因此报错,没有 b 方法(b未定义)
*/

F.a() // a
F.b() // b

instanceof

instanceof 会一直寻找到原型链的末端,直到找到 __proto__ 等于 prototype 返回 true ,否则返回 false

instanceof 左边如果不是引用类型的话,会直接返回 false

Object 是由 Function() 构造出来的,即 Object.__proto__ 指向 Function

思考下面的代码:

Function instanceof Object // true
Function instanceof Function // true
Object instanceof Function // true
Object instanceof Object //true

分别解释一下:

  • Function instanceof Object // true
    1. 去找 Function__proto__ 的属性,看看是否为 Object,发现不是
    2. 去找 Function 原型对象的 __proto__ 属性,看看是否为 Object,发现是,返回 true
  • Function instanceof Function // true
    1. 去找 Function__proto__ 的属性,看看是否为 Function,发现是,返回 true
  • Object instanceof Function // true
    1. 去找 Object__proto__ 的属性,看看是否为 Function,发现是,返回 true
  • Object instanceof Object //true
    1. 去找 Object__proto__ 的属性,看看是否为 Object,发现不是
    2. 去找 Function 原型对象的 __proto__ 属性,看看是否为 Object,发现是,返回 true

继承

原型链继承

子类型的原型为父类型的一个实例对象

// 父类型
function Person(name, age) {
   this.name = name,
   this.age = age,
   this.play = [1, 2, 3]
   this.setName = function () {}
}
Person.prototype.setAge = function () {}

// 子类型
function Student(price) {
   this.price = price
   this.setScore = function () {}
}

Student.prototype = new Person() // 子类型的原型为父类型的一个实例对象

var s1 = new Student(15000)
var s2 = new Student(14000)
console.log(s1, s2)

但这种方式实现的本质是通过将子类的原型指向了父类的实例,所以子类的实例就可以通过 __proto__ 访问到 Student.prototype 也就是 Person 的实例,这样就可以访问到父类的私有方法,然后再通过 __proto__ 指向父类的 prototype 就可以获得到父类原型上的方法。于是做到了将父类的私有、公有方法和属性都当做子类的公有属性

子类继承父类的属性和方法是将父类的私有属性和公有方法都作为自己的公有属性和方法,我们都知道在操作基本数据类型的时候操作的是值,在操作引用数据类型的时候操作的是地址,如果说父类的私有属性中有引用类型的属性,那它被子类继承的时候会作为公有属性,这样子类1操作这个属性的时候,就会影响到子类2。

s1.play.push(4)
console.log(s1, s2)

注意:我们需要在子类中添加新的方法或者是重写父类的方法时候,切记一定要放到替换原型的语句之后

function Person(name, age) {
   this.name = name,
   this.age = age
}
Person.prototype.setAge = function () {
   console.log("111")
}
function Student(price) {
   this.price = price
   this.setScore = function () { }
}
// Student.prototype.sayHello = function () {}
// 在这里写子类的原型方法和属性是无效的,
// 因为会改变原型的指向,所以应该放到重新指定之后
Student.prototype = new Person()

Student.prototype.sayHello = function () { }

var s1 = new Student(15000)
console.log(s1)

特点:

  • 父类新增原型方法/原型属性,子类都能访问到
  • 简单,易于实现

缺点:

  • 无法实现多继承
  • 来自原型对象的所有属性被所有子类实例共享
  • 子类实例改变继承的父类实例属性或方法时会影响另一个子类实例
  • 创建子类实例时,无法向父类构造函数传参
  • 要想为子类新增属性和方法,必须要在Student.prototype = new Person() 之后执行,不能放到构造器中

利用构造函数继承

在子类型构造函数中通过call()调用父类型构造函数

function Person(name, age) {
    this.name = name,
    this.age = age,
    this.setName = function () {}
}
Person.prototype.setAge = function () {}

function Student(name, age, price) {
    Person.call(this, name, age)
    // 相当于:
    //   this.name = name
    //   this.age = age
    this.price = price
}
var s1 = new Student('Tom', 20, 15000)

特点:

  • 解决了原型链继承中子类实例共享父类引用属性的问题
  • 创建子类实例时,可以向父类传递参数
  • 可以实现多继承(call多个父类对象)

缺点:

  • 实例并不是父类的实例,只是子类的实例
  • 只能继承父类的实例属性和方法,不能继承原型属性和方法
  • 无法实现函数复用,每个子类都有父类实例函数的副本,影响性能

组合继承

通过调用父类构造函数,继承父类的属性并保留传参的优点,然后通过将父类实例作为子类原型,实现函数复用

function Person(name, age) {
    this.name = name,
    this.age = age,
    this.setAge = function () { }
}
Person.prototype.setAge = function () {
    console.log("111")
}

function Student(name, age, price) {
    Person.call(this,name,age) // 第二次调用父类构造函数
    this.price = price
    this.setScore = function () { }
}

Student.prototype = new Person() // 第一次调用父类构造函数

Student.prototype.constructor = Student // 组合继承也是需要修复构造函数指向的

Student.prototype.sayHello = function () {}

var s1 = new Student('Tom', 20, 15000)
var s2 = new Student('Jack', 22, 14000)
var p1 = new Person('Mick', 24)

console.log(s1, s2)
console.log(s1.constructor) // Student
console.log(p1.constructor) // Person

优点

  • 可以继承实例属性/方法,也可以继承原型属性/方法
  • 不存在引用属性共享问题
  • 可传参
  • 函数可复用

缺点

  • 调用了两次父类构造函数,生成了两份实例

改进1

function Person(name, age) {
    this.name = name,
        this.age = age,
        this.setAge = function () { }
}
Person.prototype.setAge = function () {
    console.log("111")
}

function Student(name, age, price) {
    Person.call(this, name, age)
    this.price = price
    this.setScore = function () { }
}

Student.prototype = Person.prototype

Student.prototype.sayHello = function () { }

var s1 = new Student('Tom', 20, 15000)
console.log(s1)

这种方式通过父类原型和子类原型指向同一对象,子类可以继承到父类的公有方法当做自己的公有方法,而且不会初始化两次实例方法/属性,避免的组合继承的缺点。

但是这种方法无法辨别实例是子类还是父类创造的,子类和父类的构造函数指向是同一个。

console.log(s1.constructor) // Person

这种做法甚至不算继承,只是形似继承,不需要 Student ,只需要将 Student 上的属性和方法全都写到 Person 上就能用 Person 创建任何想要的实例对象,代码还更简单一些。

改进2

借助原型可以基于已有的对象来创建对象,var B = Object.create(A) 以A对象为原型,生成了B对象。B继承了A的所有属性和方法。

function Person(name, age) {
    this.name = name,
    this.age = age
}
Person.prototype.setAge = function () {
    console.log("111")
}

function Student(name, age, price) {
    Person.call(this, name, age)
    this.price = price
    this.setScore = function () {}
}

Student.prototype = Object.create(Person.prototype) // 核心代码
Student.prototype.constructor = Student // 核心代码

var s1 = new Student('Tom', 20, 15000)

console.log(s1 instanceof Student, s1 instanceof Person) // true true
console.log(s1.constructor) // Student
console.log(s1)

完美解决组合继承的缺点。这样做的唯一缺点是需要创建一个新对象然后把旧对象抛弃掉,不能修改已有的默认对象。

ES6之后可以通过 Object.setPrototypeOf() 来修改:

Object.setPrototypeOf(Student.prototype, Person.prototype)
// 替换下面的代码
// Student.prototype = Object.create(Person.prototype) // 核心代码
// Student.prototype.constructor = Student // 核心代码

原型式继承

function object(o) {
    function F() {}
    F.prototype = o
    return new F()
}

object() 就是 Object.create()polyfill 代码。object() 对传入其中的对象执行了一次浅复制,将构造函数 F 的原型直接指向传入的对象。

var person = {
    name: 'Jack',
    friends: ['Tom', 'Mick']
}

var anotherPerson = object(person)
console.log(anotherPerson.friends) // ['Tom', 'Mick']

这种模式要去你必须有一个对象作为另一个对象的基础

在这个例子中,person 作为另一个对象的基础,把 person 传入 object 中,该函数就会返回一个新的对象

这个新对象将 person 作为原型,所以它的原型中就包含一个基本类型和一个引用类型

所以意味着如果还有另外一个对象关联了 personanotherPerson 修改数组 friends 的时候,也会体现在这个对象中。

缺点:

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

寄生式继承

寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数

function createAnother(o) {
    var clone = Object.create(o) // 创建一个新对象
    clone.sayHi = function() { // 添加方法
        console.log('hi')
    }
    return clone // 返回这个对象
}

var person = {
    name: 'Jack'
}

var anotherPeson = createAnother(person)
anotherPeson.sayHi() // hi

基于 person 返回了一个新对象 anotherPeson,新对象不仅拥有了 person 的属性和方法,还有自己的 sayHi 方法

在主要考虑对象而不是自定义类型和构造函数的情况下,这是一个有用的模式

缺点(同原型式继承):

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

寄生组合式继承

function Person(name, age) {
    this.name = name,
    this.age = age
}
Person.prototype.setAge = function () {
    console.log("111")
}

function Student(name, age, price) {
    // 继承父类实例属性
    Person.call(this, name, age)
    // 自身的实例属性和方法
    this.price = price
    this.setScore = function () {}
}

// 使用寄生式继承来继承父类的原型,在将结果指定给子类型的原型
function inheritPrototype(subType, superType) {
    var prototype = Object.create(superType.prototype)
    prototype.constructor = subType
    subType.prototype = prototype
}

// 继承
inheritPrototype(Student, Person)

var s1 = new Student('Jack', 18, 15000)
console.log(s1)

使用 Object.create() 来实现:组合继承改进2

ES6中 class/extends 继承

ES6 中引入了 class 关键字,class 可以通过 extends 关键字实现继承,还可以通过 static 关键字定义类的静态方法,这比 ES5 的通过修改原型链实现继承,要清晰和方便很多。

ES5 的继承,实质是先创造子类的实例对象 this,然后再将父类的方法添加到 this 上面( Parent.apply(this) )。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到 this 上面(所以必须先调用 super 方法),然后再用子类的构造函数修改 this

需要注意的是,class关键字只是原型的语法糖,JavaScript继承仍然是基于原型实现的

// 定义父类
class Person {
    // 调用类的构造方法
    constructor(name, age) {
        this.name = name
        this.age = age
    }
    // 定义一般的方法
    showName() {
        console.log("调用父类的方法")
        console.log(this.name, this.age)
    }
}

let p1 = new  Person('kobe', 39)
console.log(p1)

// 定义一个子类
class Student extends Person {
    constructor(name, age, salary) {
        super(name, age) // 通过 super 调用父类的构造方法
        this.salary = salary
    }
    showName() {
        // 在子类自身定义方法
        console.log("调用子类的方法")
        console.log(this.name, this.age, this.salary)
    }
}

let s1 = new Student('wade', 38, 1000000000)
console.log(s1)
s1.showName()

参考

JS 疫情宅在家,学习不能停,七千字长文助你彻底弄懂原型与原型链,武汉加油!!中国加油!!(破音)

《你不知道的JavaScript上卷》

JS基础-函数、对象和原型、原型链的关系

JavaScript常见的六种继承方式

JavaScript六种继承方式详解


评论
 上一篇
CSS基础之position CSS基础之position
position 用于指定一个元素在文档中的定位方式。top,right,bottom 和left 属性则决定了该元素的最终位置。 定位类型 定位元素(positioned element)是其计算后位置属性为 relative, abs
2020-08-22
下一篇 
JavaScript基础之this/call/apply/bind JavaScript基础之this/call/apply/bind
thisthis 是什么 当一个函数被调用时,会创建一个活动记录(或者称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this 就是这个记录的一个属性,会在函数执行的过程中用到。 如果对这个解
2020-08-17
  目录