JavaScript高级深入浅出:小白都能懂的面向对象、原型与继承

467 阅读26分钟

介绍

本文是 JavaScript 高级深入浅出系列的第十篇,将详细了解到 JS 中的面向对象、原型与继承相关知识

正文

1. 面向对象定义

1.1 什么是面向对象

面向对象是现实的抽象方式

对象是 JS 中非常重要的概念,这是因为对象可以将多个相关联的数据封装到一起,更好的描述一个事物:

比如一只猫,可以有属性年龄(age)名字(name)体重(weight)颜色(color)品种(breed)等等

用对象来描述事物,更有利我们将现实中的事物,抽离成代码中的数据结构:

  • 所以某些编程语言是纯面向对象的编程语言,比如 Java
  • 在实现任何事物的时候都是先抽象成一个类,再 new 这个类

1.2 JS 中的面向对象

JS 其实支持了多种编程范式,包括了函数式编程面向对象编程

  • JS的对象被设计为一组属性的无序集合,像是一个哈希表,有 key 和 value 结合
  • key 是一个标识符名称,value 可以是任意类型,也可以是其他对象或者函数类型
  • 如果值是一个函数,我们就可以称之为对象的方法

创建对象的方式

使用new

// 这种方式也是早期使用 JS 的人比较频繁使用的方式
var person = new Object()  // new Object() 创建了一个空对象
obj.name = 'alex'
obj.age = 19
obj.run = function() {}

使用字面量

var person = {
    name: 'alex',
    age: 19,
    run: function() {}
}

2. 对于对象属性的操作

var obj = {
  name: 'alex',
  age: 18,
}

// 读取属性
console.log(obj.name)
// 写入属性
obj.age = 20
// 删除属性
delete obj.age

2.1 定义单个属性

通过属性描述符可以精准的添加或者修改对象的属性

Object.defineProperty()方法用于配置属性描述符

2.1.1 Object.defineProperty

Object.defineProperty方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象

Object.defineProperty(obj, prop, descriptor)

接收 3 个参数:

  • obj要定义属性的对象
  • prop要定义或修改属性的名称或 Symbol
  • descriptor要定义或修改的属性描述符

2.1.2 属性描述符分类

属性描述符分为两类:

  • 数据属性(Data Properties)描述符(Descriptor)
  • 存取属性(Accessor 访问器 Properties)描述符(Desriptor)
configurableenumerablevaluewritablegetset
数据描述符
存取描述符

2.1.3 数据属性描述符

数据属性描述符有以下特性:

  • [[Configurable]]:表示属性是否可以通过delete删除属性,是否能够修改它的特性,或者是否可以将它修改为存取属性描述符
    • 当我们直接在一个对象上定义某个属性时,这个属性的[[Configurable]]true
    • 当我们通过属性描述符定义一个属性时,这个属性的[[Configurable]]默认为false
  • [[Enumerable]]:表示属性是否可以通过for-in或者Object.keys()返回该属性
    • 当我们直接在一个对象上定义该属性时,这个属性的[[Enumerable]]true
    • 当我们通过属性描述符定义一个属性时,这个属性的[[Enumerable]]默认为false
  • [[Writable]]:表示是否可以修改属性的值
    • 当我们直接在一个对象上定义该属性时,这个属性的[[Writable]]true
    • 当我们通过属性描述符定义一个属性时,这个属性的[[Writable]]默认为false
  • [[value]]:属性的 value 值,读取属性时会返回其值,修改属性时,会对其进行更改
    • 默认这个值时undefined
var obj = {
  name: 'obj',
}

// 如果当前是数据属性描述符,可以接收 4 个属性描述符项
Object.defineProperty(obj, 'age', {
  value: 20,
  // 不可删除,不可重新定义属性配置符
  configurable: false,
  // 打印和 for-in 和 Object.keys() 都无法访问
  enumerable: false,
  // 无法被写入
  writable: false,
})

2.1.4 存取属性描述符

数据属性描述符有以下特性:

  • [[Configurable]]:表示属性是否可以通过delete删除属性,是否能够修改它的特性,或者是否可以将它修改为存取属性描述符
    • 当我们直接在一个对象上定义某个属性时,这个属性的[[Configurable]]true
    • 当我们通过属性描述符定义一个属性时,这个属性的[[Configurable]]默认为false
  • [[Enumerable]]:表示属性是否可以通过for-in或者Object.keys()返回该属性
    • 当我们直接在一个对象上定义该属性时,这个属性的[[Enumerable]]true
    • 当我们通过属性描述符定义一个属性时,这个属性的[[Enumerable]]默认为`false
  • [[get]]:获取属性时会执行的函数,默认undefined
  • [[set]]:设置属性时会执行的函数,默认undefined
var obj = {
  name: 'obj',
  _age: 20,
}

// 存取属性描述符
Object.defineProperty(obj, 'age', {
  configurable: true,
  enumerable: true,
  get() {
    console.log(`返回 age 的值,${this._age}`)
    return this._age
  },
  set(newVal) {
    this._age = newVal
    console.log(`捕获 age 的新值,${newVal}`)
  },
})

obj.age = 20 // age is change, and the new value is 20
console.log(obj.age) // 返回 age 的值,20

2.1.5 属性描述符可接收项总结

注:这里默认值是在使用属性描述符定义一个属性的情况下

描述符作用默认值
configurable当此描述符为true时,能操作属性描述符和从对应对象上删除false
enumerable当此描述符为true时,此属性能出现在对象的枚举属性上(可被遍历)false
value属性对应的值,可以是值、函数、对象undefined
writable当此描述符为true时,此属性的 value 能被赋值运算符更改false
get属性的 getter 函数,当访问该属性的 value 时,该函数被触发,执行时不传入任何参数,但会传入 this。该函数的返回值会作为属性的值undefined
set属性的 setter 函数,当对该属性的 value 赋值时,该函数被触发。该函数接收一个参数(也就是被赋予的新值),会传入赋值对象的 this 对象undefined

2.1.6 配置属性描述符案例

// 案例一:创建一个属性
let obj = {
  name: 'alex',
}

Object.defineProperty(obj, 'age', {
  value: 18,
  // 这种方式创建的属性默认是不可被枚举的,需要打开枚举才能被遍历出来
  enumerable: true,
})

console.log(obj) // { name: 'alex', age: 18 }
// 案例二:不允许某个属性能被遍历
const obj = {
  name: 'obj',
  age: 20,
  gender: 'Male',
}

Object.defineProperty(obj, 'age', {
  // 将不可枚举更改为false
  enumerable: false,
})

for (const k in obj) {
  // 在遍历时就不会出现 age
  console.log(k, obj[k])
}

2.2 同时定义多个属性

通过Object.defineProperties()方法直接在一个对象上定义多个新的属性或者修改现有属性,并返回对象

let obj = {
  _age: 19,
}

Object.defineProperties(obj, {
  name: {
    configurable: false,
    writable: true,
    enumerable: true,
    value: 'obj',
  },
  age: {
    configurable: false,
    enumerable: false,
    get() {
      return this._age
    },
    set(val) {
      this._age = val
    },
  },
})

console.log(obj)

如果使用属性描述符来定义一个新的属性时, configurableenumerable都是true,只想设置setget时,可以这样做:

const obj = {
  name: 'obj',
  _age: 18,
  set age(newVal) {
    console.log('age属性 set')
    this._age = newVal
  },
  get age() {
    console.log('age 属性 get')
    return this._age
  },
}

obj.age = 22
console.log(obj.age)

3. 对象方法补充

3.1 获取对象的属性操作符

  • Object.getOwnPropertyDescriptor(object, "attr name")
  • Object.getOwnPropertyDesciptors(object)

3.2 禁止对象扩展新属性

  • Object.preventExtensions(object)
  • 该方法给一个对象添加新属性会失败(严格模式下会报错)
'use strict'
let obj = {
  name: 'obj',
}

Object.preventExtensions(obj)
obj.age = 20  // 报错 

3.3 密封对象

  • Object.seal(object)
  • 不允许对象添加新属性和删除已有属性,不允许修改属性操作符
  • 其核心就是调用了preventExtensions函数,并将属性描述符configurable: false
'use strict'
let obj = {
  name: 'obj',
}

Object.seal(obj)
obj.name = 'alex' // 正常
delete obj.name  // 报错
obj.age = 20  // 报错
Object.defineProperty(obj, 'name', {
  writable: true,
})  // 报错

3.4 冻结对象

  • Object.freeze(obj)
  • 不允许更改已有属性的值,不允许增加属性,不允许删除属性,不允许修改属性操作符
  • 其核心是调用了seal,并将属性描述符writable: false
'use strict'
let obj = {
  name: 'obj',
}

Object.freeze(obj)
obj.name = '123'  // 报错
delete obj.name  // 报错
obj.age = 20  // 报错
Object.defineProperty(obj, 'name', {
  writable: true,
})  // 报错

注意:freeze方法只能冻结一级属性

'use strict'
let obj = {
  name: 'alex',
  foo: {
    name: 'foo',
  },
}

Object.freeze(obj)

obj.foo.name = 'foo2'  // 不报错
obj.name = 'obj' // 报错

4. 创建多个对象的方案

比如我们现在想要创建一个对象,所有的对象都基于Person模板对象,都有nameageheight等属性和方法,但是他们的值均不相同,那么采用什么方式创建比较好呢?

4.1 工厂模式

我们很快就能想象到一种创建对象的方式:工厂模式

  • 工厂模式是一种很常见的设计模式
  • 通常我们会有一个工厂函数,通过该方法来生产对象
'use strcit'
// createPerson 就是一个工厂函数
function createPerson(name, age, height) {
  return {
    name: name,
    age: age,
    height: height,
    running: function() {
      console.log(this.name, 'is running')
    },
    eating: function() {
      console.log(this.name, 'is eating')
    },
  }
}

var ZhangSan = createPerson('zhangsan', 18, 1.8)
var LiSi = createPerson('lisi', 25, 1.88)
var WangWu = createPerson('wangwu', 19, 1.75)
// ......

工厂模式的缺点:

获取不到对象的真实类型(任何通过工厂模式创建的对象都属于 Object 类型)

4.2 构造函数

  • 构造函数也称之为构造器(constructor),通常是我们在创建对象时调用的方法,ES6 引入了 class(只是这种形式的语法糖,最终还是会转成这种形式的)
  • 在其他面向对象的编程语言中,构造函数时存在于一个类中的方法,称之为构造方法
  • JS 中的构造函数也是一个普通的函数
  • 如果一个普通的函数使用了new进行调用,那么该函数就是一个构造函数
function foo() {}
// 直接调用,foo 只是一个普通函数
foo()
// 使用 new 关键字 foo 就是一个构造函数
// 如果不向构造函数中传入参数,可以不写小括号,但是不建议
new foo()

new 与普通调用的区别

如果一个函数被操作符new调用了,那么会执行以下操作:

  1. 在内存中创建一个新的空对象
  2. 这个对象内部的[[prototype]]属性会被更改为该构造函数的[[prototype]]
  3. 构造函数内部的 this,会指向创建出来的新对象
  4. 执行函数的内部代码(函数体)
  5. 如果构造函数没有返回非空对象,那返回的就是创建出来的新对象
var f1 = new foo()
console.log(f1)
// 此时,f1 打印 foo {},说明 new 的新对象是属于 foo 的

使用构造函数创建批量对象

'use strcit'
// createPerson 就是一个工厂方法
function Person(name, age, height) {
  this.name = name
  this.age = age
  this.height = height
  this.running = function() {
    console.log(this.name, 'is running')
  }
  this.eating = function() {
    console.log(this.name, 'is eating')
  }
}
var ZhangSan = new Person('zhangsan', 18, 1.8)
var LiSi = new Person('lisi', 25, 1.88)
var WangWu = new Person('wangwu', 19, 1.75)
console.log(ZhangSan, LiSi, WangWu)
// 打印出这三个对象是属于 Person 类型的

构造函数的缺点

前置知识

function foo() {
  function bar() {
    console.log('bar')
  }
  return bar
}

var f1 = foo()
var f2 = foo()

console.log(f1 === f2)  // false

f1f2其实是两个独立的函数,虽然函数体相同,但是会占用两块内存空间

function Person(name) {
  this.name = name
  this.running = function() {
    console.log(this.name, 'is running')
  }
}
var p1 = new Person('zhangsan')
var p2 = new Person('lisi')
console.log(p1.running === p2.running)  // false

上面示例代码中,Person是一个构造函数,p1p2是两个实例对象,running方法是是一个实例方法,显然,这两个实例对象的实例方法的方法体都是相同的,而且我们也希望相同,但是却占用了两个内存空间。随即可以推算出来,创建的对象越多,内存也将占用的越多,但是大部分占用的内存的函数其实都是重复的

因为创建函数要开辟新内存空间,在节省内存的理想情况下,所有实例中的实例函数其实都要共用同一个函数,也就是p1.running === p2.running,最终不同的实例对象调用此函数无非就是修改函数内部的 this 而已。

接下来,我们将了解解决方案:prototype 原型

5. 原型

5.1 对象的原型

对象的原型一般称之为隐式原型

JS 中所有的对象都有一个特殊的内置属性[[prototype]],这个特殊的属性将指向一个对象。

// 这个对象中其实还隐藏着一个属性 [[Prototype]]
var obj = { name: 'alex' }

早期 ECMA 没有规范如何查看[[prototype]]这个属性的,部分浏览器给对象提供了__proto__属性,来方便我们查看原型对象。在生产环境中不要随意使用这个属性,可能有一些浏览器没有实现这个功能

在 ES5+ 后,为了方便在生产环境中访问原型,提供了一个方法Object.getPrototypeOf()

var obj = { name: 'alex' }
console.log(Object.getPrototypeOf(obj))  // [Object: null prototype] {}

说明obj对象的原型是Object,原型是一个空对象,并且Object的原型是null

总结

  • JS 中每一个对象都有[[prototype]]属性,该属性指向该对象的原型**(隐式原型)**

  • 在早期为了能看到这个原型,部分浏览器实现了__proto__属性来查看[[prototype]]属性

  • 后面 ES5 版本 ECMA 规范实现了Object.getPrototypeOf()属性来查看[[prototype]]

5.1.1 对象原型的作用

当我们在访问某个对象的某个属性时,会触发该属性的[[getter]],分为两步操作:

  • 在当前的对象中查看属性,如果找到就使用
  • 没有找到,就沿着原型链去查找

5.2 函数的原型

函数的原型称之为显式原型

函数也是一个对象,对于函数来说,它也是有__proto__属性的(有兼容问题,只有部分浏览器实现)

同时函数还会多出一个显式原型属性prototype(ECMA 规范的,没有兼容问题)

5.2.1 再看 new

上文中说到在 new 的过程中:

  • 这个对象内部的[[prototype]]属性会被更改为该构造函数的[[prototype]]

其实就是内部实现了类似于这样的

function foo() {
   this.__proto__ = foo.prototype
}
// 通过 new 创建的实例
var f1 = foo()
var f2 = foo()
// f1 和 f2 的 __proto__ 都是 foo.prototype
console.log(f1.__proto__ === foo.prototype)
console.log(f2.__proto__ === foo.prototype)

5.2.2 看图理解

function Person() {}

var p1 = new Person()
var p2 = new Person()
  • 创建 Person,在内存中创建一个函数对象,GO 中的 Person 将会指向此函数对象。在函数对象中,包括了[[parentScope]]函数执行体prototype,而prototype将会指向 Person 的原型对象

微信截图_20211201113219.png

  • new的过程,创建出p1p2,内存中开辟出p1p2,内部包含了__proto__属性,指向的就是其构造函数也就是Person的原型对象

微信截图_20211201114102.png

  • 所以p1.__proto__ === p2.__proto__ === Person.prototype,上图的流程就是内部帮助我们做的

  • 所以我们在访问实例对象中的某个属性的时候,就会先看自身有没有这个属性,如果没有则会根据__proto__属性来找原型,看看原型中有没有这个对象

    // 因此会有这个操作
    p1.__proto__.name = 'alex'
    console.log(p2.name) // alex
    // 不过 __proto__ 有兼容问题,不建议直接操作__proto__,而是操作构造函数的 prototype
    Person.prototype.name = 'zhang'
    console.log(p1.name) // zhang
    console.log(p2.name) // zhang
    

5.2.3 函数原型上的属性

function foo() {}
console.log(foo.prototype) // 虽然打印出来这个对象是 {} 空的,但是有很多内置属性

原型上有一个constructor属性

console.log(foo.prototype.constructor) // 值是指向 foo 自己
// 因此也会有这样的操作
console.log(foo.prototype.constructor.prototype.constructor.rototype.constructor)

同时,我们也可以在原型上添加自己的属性

function foo() {}
foo.prototype.message = 'hello world'
foo.prototype.age = 20

// 所有通过 new foo 的实例对象的 __proto__ 都将携带 message 和 age
var f1 = new foo()
console.log(f1.__proto__.message)
// 根据属性查找规则 f1.message 也是可以找到
console.log(f1.message)

如果添加的属性过多了,每次都要使用xxx.prototype过于繁琐,所以有时候我们还可以直接修改prototype

foo.prototype = {
    name: 'alex',
    age: 20,
    message: 'hello world',
    run: function() {}
}

直接覆盖有一个大问题:根据 ECMA 规范,prototype是需要内置一个constructor属性的,但是我们直接修改函数原型指向,指向的新的对象中是没有constrcutor属性,需要手动指定一下

foo.prototype = {
    // 不要直接这样定义!因为默认原型的 constructor 属性的 enumerable 是 false
    // constructor: foo,
    name: 'alex',
    age: 20,
    message: 'hello world',
    run: function() {}
}
// 开发中要这么做
Object.defineProperty(foo.prototype, 'constructor', {
  configurable: true,
  writable: true,
  enumerable: false,
  value: foo,
})

5.3 回到 4.2 的问题

在 5.1 中我们说到,每次 new 一个实例对象为了存放实例函数,都要开辟内存空间,学习了上文的原型知识,就能轻松想到解决方案了

// 原来的代码
function Person(name) {
  this.name = name
  this.running = function() {
    console.log(this.name, 'is running')
  }
}
var p1 = new Person('zhangsan')
var p2 = new Person('lisi')
console.log(p1.running === p2.running) // false
// 将实例方法挂载到构造函数的原型上,这样所有实例对象的实例函数都是指向同一个函数
function Person(name) {
  this.name = name
}
Person.prototype.running = function() {
  console.log(this.name, 'is running')
}
var p1 = new Person('zhangsan')
var p2 = new Person('lisi')
console.log(p1.running === p2.running) // true
  • 从上文中我们了解到这里:Person.prototypep1.__proto__p2.__proto__指向同一块内存空间,在Person.prototype中挂载了一个 running 函数

  • p1.running寻找running的函数中,首先会在本对象上找,找不到就通过__proto__再找,最终找到了p1.__proto__running函数,指向的还是Person.prototype.running这一块内存空间

  • 因此Person.prototype.running === p1.__proto__.running === p2.__proto__.running

  • 轻松解决了实例函数造成内存空间占用过多的问题

思考:普通的属性也能放在原型中吗?

答案显然是不能

function Person(name) {
  Person.prototype.name = name
}

var p1 = new Person('zhangsan')
var p2 = new Person('lizi')

console.log(p1.name) // lisi

因为prototype共用的,所以 p2 的 name 覆盖了 Person 原型的 name。

因此只能将相同的逻辑放在共用的原型上,而特有属性需要放在自己内部进行维护

5.4 原型与实例断开链接

通过字面量来重新定义原型,定义的位置不同,可能会出现断开链接的隐患

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

var a1 = new Person('zhangsan')

Person.prototype = {
  running: function() {},
}
Object.defineProperty(Person.prototype, 'constructor', {
  configurable: true,
  enumerable: false,
  writable: true,
  value: Person,
})

// 如果创建实例在覆盖 prototype 之前,
// 那么 a1.__proto__ 和 Person.prototype 就不指向同一个对象了
console.log(Person.prototype.running) // running
console.log(a1.running) // undefined
console.log(Person.prototype === a1.__proto__) // false

这个很好理解,只要避免定义实例在重新定义原型前就好了

5.5 原型链

前置知识:

JS 由于设计问题,和其他的纯面向对象不同,所有 JS 的对象都是克隆而来的,每个对象都会保存其原型信息,所有对象的根原型对象是Object的原型对象

var obj = {
  name: 'alex',
  message: '123',
}

console.log(obj.address)
  • 上文中我们说到,在访问对象的属性时触发了[[getter]]操作
  • 首先会在本对象上找是否有该属性,找到直接返回
  • 找不到,就去本对象的原型对象(__proto__)上找,如果还找不到,就去原型对象的原型对象上找
  • 直到找到所有对象的根原型对象(Object原型对象)的原型null为止,如果没有找到就返回undefined
  • 这种查找链条叫做原型链(prototype chain)
  • Object 的原型对象上有很多默认的属性和方法,也正是因为原型链,所有对象都拥有这些属性和方法
var obj = {}
// 1. 创建一个空对象 {}
// 2. 将空对象的 this 修改为 obj
// 3. 将 obj.__proto__ = Object.prototype
// 4. 返回这个新对象
console.log(obj.__proto__ === Object.prototype)  // true
console.log(Object.prototype.__proto__)  // null

5.6 Object 的原型对象是最顶层的原型对象

微信截图_20211202103921.png

  • 构造函数的prototype显式原型指向构造函数的原型对象
  • 实例对象的__proto__隐式原型指向构造函数的原型对象
  • 构造函数的原型对象__proto__隐式原型指向 Object 的原型对象
  • 所有的对象或函数往上溯源原型,最终都会找到 Object 的原型对象

迷惑点:

Object.__proto__ === Function.prototype // true
  • 和上图中是一样的道理,函数的显式原型会在内部指向到 Object 的原型对象

6. 实现继承

function Person() {
  
}
var p1 = new Person()

上面的示例代码中,我们是如何称呼 Person 的?

  • 在 JS 中,Person 被称为是一个构造函数
  • 从面向对象编程语言的开发者看来,Person 是一个类,因为通过类我们可以创建实例对象 a1
  • 从编程范式的角度来看,Person 确实是一个类

6.1 面向对象的特性

面向对象有三大特性:封装、继承、多态

  • 封装:之前示例代码中,将类和方法封装到一个对象中,可以称之为封装的过程
  • 继承:继承是面向对象中最重要的,不仅仅可以减少重复的代码量,也是多态的前提(纯面向对象中)
  • 多态:不同的对象在执行中会表现出不同的形态

6.2 封装

function foo(name) {
  this.name = name
}

foo.prototype.running = function() {}

简化来说,将属性和方法封装到一个类中,这个过程是封装的过程

在纯面向对象的语言中,封装其实更多的是指将实现细节隐藏起来,只向外界暴露部分可操作的接口

6.3 继承

  • 继承是做什么的?
    • 继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要继承过来即可
  • JS 当中是如何实现继承的?
    • 通过原型链的机制去实现继承

6.3.1 为什么需要继承

function Student(name, sno) {
  this.name = name
  this.sno = sno
}
Student.prototype.running = function() {
  console.log(this.name, ' running')
}
Student.prototype.studying = function() {
  console.log(this.name, 'studying')
}

function Teacher(name, title) {
  this.name = name
  this.title = title
}
Student.prototype.running = function() {
  console.log(this.name, ' running')
}
Student.prototype.teaching = function() {
  console.log(this.name, 'teaching')
}

通过构造函数StudentTeacher的对比发现,我们有很多重复的代码namerunning,有没有可能可以让两者有一个公共的构造函数呢?

6.4 原型链实现继承

// 父类:公共属性和方法
function Person() {
  this.name = 'alex'
}
Person.prototype.running = function() {
  console.log(this.name, ' running')
}
// 子类:私有属性和方法
function Student() {
  this.sno = 123
}
// 为什么这里需要这样做呢?
Student.prototype = new Person()

var s1 = new Student()
s1.running() // 成功继承到 Person 的方法

为什么Student.prototype = new Person()就实现了继承?我们看图

  • Person 构造函数与原型对象的关系

微信截图_20211202120909.png

  • new Person() 的过程
    • 创建一个空对象
    • 将 Person 函数的 this 指向这个空对象
    • 将空对象的[[prototype]]指向函数的 Prototype
    • 执行函数体
    • 返回创建出来的对象(此时 name 属性就存在于这个创建的对象上)
  • Student.prototype = new Person(),此时 Student 的原型对象是一个空对象,__proto__指向 Person 的原型对象

微信截图_20211202134014.png

  • 创建 s1 的 Student 实例对象,s1.__proto__指向 Student 的原型对象,访问s1.name,在本对象中找不到,根据原型链,去 s1 的原型也就是 Student 的原型对象去找,找到了 name

微信截图_20211202135644.png

  • 访问s1.running,本对象找不到,Student 的原型对象也找不到,就去 Student 原型对象的原型对象,也就是 Person 的原型对象,找到 running 函数,执行,至此就通过原型链实现了“继承”

原型链实现继承的弊端

  • 弊端一:打印 s1 对象,某些属性是看不到的(因为继承的属性和方法都放在了原型对象或者父原型对象上

  • 弊端二:类型归属错误,这个时候打印 s1 返回是一个 Person 类型。(因为 Student 的原型对象没有 constructor属性,自己定义一个就可以了

    Object.defineProperty(Student.prototype, 'constructor', {
      enumerable: false,
      configurable: true,
      writable: true,
      value: Student,
    })
    
  • 弊端三:公用属性存在的弊端

    function Person() {
      this.name = 'alex'
      this.friends = []
    }
    function Student() {
      this.sno = 123
    }
    Student.prototype = new Person()
    
    var s1 = new Student()
    var s2 = new Student()
    
    s1.friends.push('s1 的朋友')
    // 因为 s1 和 s2 用到的都是 Student 原型对象中的 friends
    // 所以 s1 影响了 s2,但是这不是我们所期望的
    console.log(s2.friends) // ["s1 的朋友"]
    
    // 迷惑点:
    s1.name = 's1'
    console.log(s2.name)  // alex 
    // 之所以看似 s1 没有影响 s2,实际上像这种直接赋值操作,如果在本对象中找不到,则直接在本对象中创建一个新的属性叫做 name,值是 s1
    // 所以其实这里 s1.name 和 s2.name 根本不是一个东西了
    
  • 弊端四:在上面代码中,我们在 new 的过程中没有传参,就无法做到自定义化

6.5 借用构造函数实现继承

为了解决原型链继承中存在的诸多弊端,社区提供了一种方案constructor stealing(有很多译称:借用构造函数、经典继承或者叫伪造对象)

function Person(name, friends) {
  this.name = name
  this.friends = friends
}
Person.prototype.running = function() {
  return 'running is good'
}
function Student(name, friends, sno) {
  // 通过 call 来隐式调用 Person 函数
  // 在 new 一个对象的时候,执行函数体代码
  // 其实就是 s1 和 s2 执行了 Person 函数,只不过函数内的 this 是 s1 和 s2
  Person.call(this, name, friends)
  this.sno = sno
}
Student.prototype = new Person()

var s1 = new Student('alex', ['s1'], 123)
// 解决弊端四
console.log(s1) // { name: 'alex', son: 123 }
// 同时这种情况也解决了弊端三
var s2 = new Student('zhangsan', ['s2'], 456)
s1.friends.push('s3')
console.log(s2.friends) // [ 's2' ]

Person.call(this, ...args) 运用这种方式非常巧妙的解决了无法传参的问题,就相当于是“借用了”构造函数,同时解决了弊端三和弊端四

微信截图_20211202150847.png

构造函数继承的弊端

借用构造函数来实现继承也是有一定的弊端的:

  • Person函数被调用了 3 次(call 两次,new Person() 一次)
  • Student原型对象中存在冗余的属性(new Person() 的时候添加的)

6.6 原型式继承

这种模式由道格拉斯·克罗克福德(Douglas Crockford,前端大师,JSON 创始人)在 2006 年发布的文章 Prototypal Inheritance in JavaScript(在 JS 种使用原型式继承)中提出

这篇文章中,提出了一种继承的方法,而且这种继承方法不是通过构造函数实现的

不过这种继承仅局限于对象

var obj = {
  name: 'alex',
  age: 18,
}

function createObject(o) {
  var obj = {}
  Object.setPrototypeOf(obj, o)
  return obj
}

// 因为 Douglas 提出的时候还没有 setPrototype 方法,所以他是这样实现的
function createObject2(o) {
  function fn() {}
  fn.prototype = o
  return new fn()
}

var newObj = createObject2(obj)

// { name: 'alex', age: 18 }
console.log(newObj.__proto__)

这样就实现了一个继承效果

// 在最新的 ECMA 规范中,Object 有一个默认方法 create,和我们自己写的 createObject 效果一样
var obj = {
  name: 'alex',
  age: 18,
}

var newObj = Object.create(obj)
// { name: 'alex', age: 18 }
console.log(newObj.__proto__)

6.7 寄生式继承

寄生式继承是和原型式继承紧密相关的一种继承思想,并且也是由 Douglas 提出并且推广的

寄生式继承是结合工厂模式与原型式继承的一种方式

即创建一个封装继承过程的函数,该函数在内部以某种方式来增强对象,最后将这个对象返回

var person = {
  name: '',
  running: function() {
    console.log('running')
  },
}

function createStudent(name) {
  var stu = Object.create(person)
  stu.name = name
  stu.studying = function() {
    console.log('studying')
  }
  return stu
}

var s1 = createStudent('alex')
var s2 = createStudent('zhangsan')
// 实现了继承
console.log(s1.name, s2.name)

当然,这种方式也有弊端:

  • 这种方式只能作用于对象
  • 每个对象都有公共方法studying,创建多个对象,就占用多个内存空间,但是每个内存空间的函数都是一样的

6.8 寄生组合式继承

回顾我们设想的所有继承方式中,借用构造函数实现继承似乎是一种理想的方式

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

Person.prototype.running = function() {
  console.log(this.name, ' running')
}

function Student(name, age, friends, sno) {
  Person.call(this, name, age, friends)
  this.sno = sno
}

Student.prototype = new Person()

Object.defineProperty(Student.prototype, 'constructor', {
  enumerable: false,
  configurabl: true,
  writable: true,
  value: Student,
})

Student.prototype.studying = function() {
  console.log(this.name, ' studying')
}

var s1 = new Student('alex', 18, ['john'], 123)
s1.running()

想要一种比较完美的继承方案,就需要将借用构造函数(也叫组合式)继承方式的两种弊端解决掉:

  • 调用了多次构造函数
  • Student 的原型对象中存在冗余的属性

实际上,我们可以通过寄生组合式继承的方式来解决掉这两个弊端:

Student.prototype = new Person()
// 👆 这一行改为 👇,就直接解决了两个弊端
Student.prototype = Object.create(Person.prototype)

微信截图_20211202175140.png

接下来,我们还可以进行优化,我们发现,对于继承最核心的代码是这两个

Student.prototype = Object.create(Person.prototype)

Object.defineProperty(Student.prototype, 'constructor', {
  enumerable: false,
  configurabl: true,
  writable: true,
  value: Student,
})

实际上我们可以封装为一个函数

// inherit:继承
function inheritPrototype(obj, proto) {
  obj.prototype = Object.create(proto.prototype)
  Object.defineProperty(obj.prototype, 'constructor', {
    enumerable: false,
    configurabl: true,
    writable: true,
    value: obj,
  })
}

inheritPrototype(Student, Person)

核心代码

Object.create()这一函数比较新,很多社区的老代码其实用的是我们上文中的createObject()方法,下面是寄生组合式继承的核心代码

function createObject(proto) {
  function Fn() {}
  Fn.prototype = proto
  return new Fn()
}

function inheritPrototype(obj, proto) {
  obj.prototype = createObject(proto.prototype)
  Object.defineProperty(obj.prototype, 'constructor', {
    enumerable: false,
    configurable: true,
    writable: true,
    value: obj,
  })
}

测试一下

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

Person.prototype.greeting = function() {
  console.log(this.name, 'hello world')
}

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

inheritPrototype(Student, Person)

Student.prototype.studying = function() {
  console.log(this.name, this.age, 'studying')
}

var s1 = new Student('alex', 18)
var s2 = new Student('john', 20)

s1.greeting()  // 调用成功
s2.studying()  // 调用成功

7. 原型补充

Object.create(obj, propDescriptors),创建一个对象,原型是传入的第一个参数

var obj = {
  name: 'alex',
}

// 第二个参数属性描述符是加在了返回的对象上
var newObj = Object.create(obj, {
  age: {
    value: 20,
    enumerable: true,
  },
})
// { age: 20 }   { name: 'alex' }
console.log(newObj, newObj.__proto__)

obj.hasOwnProperty(propName),判断某个属性是否是挂载在本身上的(不是在原型上)

console.log(newObj.hasOwnProperty('age')) // true
console.log(newObj.hasOwnProperty('name')) // false

in操作符,判断对象是否可以访问到某个属性(无论是不是在原型中)

console.log(age in newObj) // true
console.log(name in newObj) // true

instanceof操作符,检测构造函数的 prototype 是否出现在某个实例对象的原型链上

function foo1() {
  this.mname = 'alex'
}

function foo2() {
  this.name = 'zhangsan'
}

function foo3() {
  this.age = 20
}

foo3.prototype = Object.create(foo1.prototype)

var f1 = new foo3()

console.log(f1 instanceof foo1) // true
console.log(f1 instanceof Object) // true
console.log(f1 instanceof foo2) // false

// 顶层构造函数默认继承自 Object
console.log(foo1.prototype.__proto__)  // Object 的原型对象:[Object: null prototype] {}

obj.isPrototypeOf(obj),检测对象是否存在于某个实例对象的原型链上

var obj = {
  name: 'alex',
}

var newObj = Object.create(obj)

console.log(obj.isPrototypeOf(newObj))  // true

instanceofisPrototypeOf的使用场景区别:

  • instanceof的右边一定要是一个函数
  • isPrototype则是要一个对象

8. 原型继承关系

image.png

上图是社区非常著名的关于原型继承的图片,下面我们将深入剖析这张图片:

// 我们就从这段代码讲起
function foo() {}

var f1 = new foo()

var o1 = new Object()

我们上文中也说了,在 JS 的世界中,万物生于 Object 的原型对象,所有的对象(函数也是对象)都克隆自 Object 的原型对象。当然 Object 的原型对象也有原型,是 null。

function foo() {}

我们创建了一个函数,名字叫做foo,此函数由new Function()而来,所以foo.__proto__ = Function.prototypeFunction则是 JS 的内置构造函数,Function.prototype.__proto__指向Object.prototype

var f1 = new foo()

当我们使用new关键字时,foo 函数就变成了一个构造函数。

  • 首先会创建出一个空对象
  • 将构造函数内部的 this 指向这个空对象
  • 将这个空对象的__proto__指向构造函数的prototype
  • 执行构造函数体
  • 返回新对象。

此时,前两行的内存空间其实是这样的:

微信截图_20211203163041.png

此时,上面那幅图的上半部分和下半部分我们已经讲完了,接下来讲中间部分

var o1 = new Object()

使用new Object()创建对象,至少说明了两个事实:

  • Object是一个构造函数
  • Objectnew Function()而来

那么Object构造函数的__proto__指向Function.prototype

这一行的内存空间是这样的

微信截图_20211203155130.png

此时,你再回头看看那幅图,是不是清晰了很多呢?

总结

本文中,你学到了三大知识点:

1. 关于面向对象

  • 了解了什么是面向对象,以及 JS 中的面向对象

  • 了解了两种创建对象的方式,使用对象字面量{}其实也是通过new Object()创建的

  • 了解了对象属性描述符的概念与操作

  • 了解了几种 Object.prototype 挂载的默认方法

2. 关于原型

  • 对象的原型是隐式原型,[[Prototype]]部分浏览器实现了__proto__属性(此属性慎用,有兼容问题
  • 函数的原型是显式原型 Prototype 属性
  • 了解了 new 的过程
  • 了解了原型链,与定义原型Object.prototype.__proto__ = null

3. 关于继承

  • 探讨了几种继承方式,并挖掘出每一种继承方式的缺陷
  • 结合多种继承方式,写出一种较为完美的继承方案寄生组合式继承
  • 关于原型的补充,了解了几种关于判断原型的方法和操作符
  • 最后,通过一张经典原型图收尾,彻底掌握原型与继承