《JavaScript 高级程序设计》第6章 对象、类和面向对象编程

2024年03月11日

6.对象、类和面向对象编程

理解对象

  • 属性分为两种:数据属性和访问器属性
  • 数据属性有4个特征:
    1. [[Configurable]] 表示属性是否可通过 delete 删除并重新定义、修改特性和改为访问器属性
    2. [[Enumerable]] 表示属性是否可以通过 for-in 循环
    3. [[Writable]] 表示属性的值是否可以被修改
    4. [[Value]] 包含属性实际的值
  • 访问器属性不包括数据值,有 getter 和 setter 函数,4个特征:
    1. [[Configurable]]
    2. [[Enumerable]]
    3. [[Get]]
    4. [[Set]]
  • 访问器属性必须通过 Object.defineProperty() 定义
javascript 复制代码
let book = {
  year_: 2017,
  edition: 1
}
Object.defineProperty(book, 'year', {
  get () {
    return this.year_
  },
  set (newValue) {
    if (newValue > 2017) {
      this.year_ = newValue
      this.edition += newValue - 2017
    }
  }
})
book.year = 2018
console.log(book.edition) // 2
  • Object.defineProperties 可以通过多个描述符一次性定义多个属性
  • Object.getOwnPropertyDescriptor 可以取得指定属性的属性描述符
  • Object.getOwnPropertyDescriptors 方法,在每个自有属性上调 getOwnPropertyDescriptor 方法并在一个新对象中返回它们
  • Object.assign 方法接收一个目标对象和一个或多个源对象作为参数,将每个源对象中可枚举属性复制到目标对象(对每个源对象执行的是浅复制,相同的属性会使用最后一个复制的值
  • Object.is 方法用于相等判定,类似于===但是也考虑到 NaN 和 +0、-0 等边界问题
  • ES6 新增的定义和操作对象语法糖:
    • 属性值简写
    • 可计算属性
    • 简写方法名
  • ES6 新增对象解构语法,使用与对象匹配的结构来实现对象属性赋值
javascript 复制代码
let people = {
  name: 'Matt',
  age: 27
}
let {name, age} = people
console.log(name) // Matt
console.log(age) // 27

创建对象

工厂模式

javascript 复制代码
function createPerson(name, age) {
  let o = new Object()
  o.name = name
  o.age = age
  o.sayName = function () {
    console.log(this.name)
  }
  return o
}
let person = createPerson('Tom', 29)

工厂模式可以解决创建多个相似对象的问题,但是没有解决对象标识的问题(对象是什么类型)

构造函数模式

javascript 复制代码
function Person(name, age) {
  this.name = name
  this.age = age
  this.sayName = function () {
    console.log(this.name)
  }
}
let person = new Person('Tom', 29)

创建 Person 实例应使用 new 操作符,new 操作符会执行如下操作

  1. 在内存中创建一个对象
  2. 新对象内部[[prototype]]被赋值为构造函数的 prototype 属性
  3. this 指向新对象
  4. 执行构造函数中的代码
  5. 如果构造函数返回非空对象,则返回该对象;否则返回刚创建的新对象
  • 构造函数也是函数,与普通函数唯一的区别就是调用方式不同。如果不使用 new 操作符调用构造函数,this 会指向 Global 对象
  • 构造函数的问题是其定义的方法会在每个实例上都创建一遍

原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。这个对象就是通过调用构造函数创建的对象实例的原型。使用原型对象的好处是,在其上面定义的属性和方法可以被对象实例共享。

javascript 复制代码
function Person() {}
Person.prototype.name = 'Tom'
Person.prototype.age = 29
Person.prototype.sayName = function () {
  console.log(this.name)
}

let person = new Person()

原型对象(函数 prototype 指向的对象)会自动获得一个名为 constructor 的属性,指向与之关联的构造函数。每次调用构造函数创建的新实例,实例内部 [[Prototype]] 指针会指向构造函数的原型对象。因此,实例与构造函数没有直接联系,但是与构造函数的原型对象有。可以使用 isPrototypeOf 来确定两个对象之间的这种关系。
Object.getPrototypeof 方法可以返回参数内部特性 [[Prototype]] 的值,Object.setPrototypeOf 方法可以向实例私有特性 [[Protptype]] 写入一个新值(严重影响代码性能)。为避免性能下降,可以通过 Object.create() 创建一个新对象同时指定原型。
通过对象访问属性时,首先搜索对象实例本身,如果未发现给定的名称,搜索会沿着指针进入原型对象搜索。虽然可以通过实例读取原型对象的值,但是不能通过实例修改。在实例上添加同名属性会遮蔽原型对象上的同名属性,只有通过 delete 删除实例上的属性后才能访问到原型对象上的同名属性。
单独使用 in 操作符会在可以通过对象访问指定属性时返回 true,在 for-in 循环中时,会返回可以通过对象访问的所有可被枚举的属性(包括实例属性和原型属性)。

对象迭代

  • Object.values 方法接收一个对象并返回对象值的数组
  • Object.entries 方法接收一个对象并返回键值对的数组
  • 原型可以直接通过一个包含所有属性和方法的对象字面量来重写
  • 实例在原型上搜索值是动态的,即使在修改原型之前就存在的实例,在原型对象修改后也可以反映出来(重写原型会切断实例和新原型对象的联系)
  • 原型模式的问题:
    1. 弱化了向构造函数传递初始化参数的能力
    2. 所有属性都是共享

继承

原型链

原型链是 ECMAScript 的主要继承方式,其基本思想为通过原型继承多个引用类型的属性和方法

javascript 复制代码
function SuperType() {
  this.property = true
}

SuperType.prototype.getSuperValue = function () {
  console.log(this.property)
}

function SubType() {
  this.subProperty = false
}

SubType.prototype = new SuperType()
let instance = new SubType()

instance.getSuperValue() // true

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

  1. instanceof 操作符
  2. isPrototypeOf 方法

原型链实现继承的问题:

  1. 原型中包含的引用值会在所有实例间共享
  2. 子类型在实例化时不能给夫类型的构造函数传参

盗用构造函数

盗用构造函数的一个优点是可以在子类构造函数中向父类构造函数传参

javascript 复制代码
function SuperType (name) {
  this.name = name
}

function SubType () {
  superType.call(this, 'Tom')
  this.age = 29
}

let instance = new SubType

盗用构造函数的主要问题跟构造函数模式相同:必须在构造函数中定义方法,因此函数无法重用;子类也不能访问父类原型上定义的方法。

组合继承

组合继承综合了原型链和盗用构造函数,基本思路为使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。

javascript 复制代码
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
}

SubType.prototype = new SuperType()

SubType.prototype.sayAge = function () {
  console.log(this.age)
}

let instance = new SubType('Tom', 29)

原型式继承

javascript 复制代码
function object (o) {
  function F () {}
  F.prototype = o
  return new F()
}

ES5 通过增加 Object.create 方法将上述原型式继承的概念规范化。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(可选)。
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。注意,属性中包含的引用值始终会在相关对象间共享。

寄生式继承

寄生式继承思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象并返回这个对象。

javascript 复制代码
function createAnother (original) {
  let clone = object(original)
  clone.sayHi = function () {
    console.log('hi')
  }
  return clone
}

寄生式继承给对象添加的函数会导致函数难以重用,与构造函数模式类似。

寄生式组合继承

组合继承存在最主要的效率问题是父类构造函数会在创建子类型和子类构造函数中调用两次。为解决这个问题,寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链集成方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。

javascript 复制代码
function inheritPrototype(sunType, superType) {
  let prototype = object(superType.prototype) // 创建父类原型副本
  prototype.constructor = subType // 解决由于重写原型导致默认construcor丢失的问题
  subType.prototype = prototype // 新创建的对象赋值给子类型的原型
}

寄生式组合继承可以算是引用类型继承的最佳模式。

类定义

类定义有两种模式:类声明和类表达式
类可以包含:构造函数方法、实例方法、获取函数、设置函数和静态类方法。

类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符。

实例、原型和类成员

  • 每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享
  • 为了在实例间共享方法,类定义语法把类块中定义的方法作为原型方法
  • 不能在类块中给原型添加原始值或对象作为成员数据
  • 类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键
  • 类定义也支持获取和设置访问器
  • 可以在类上定义静态方法,静态成员每个类上只会创建一次。使用 static 关键字作为前缀。在静态成员中,this 引用类自身
  • 类定义语法支持在原型和类本身上定义生成器方法,可以通过添加一个默认的迭代器把类实例变成可迭代对象:
javascript 复制代码
class Person {
  constructor() {
    this.nicknames = ['Jack', 'Jake', 'J-Dog']
  }

  *[Symbol.iterator] () {
    yield *this.nicknames.entries()
  }
}

let p = new Person()
for (let [idx, nickname] of p) {
  console.log(nickname)
}

继承

ES6 类支持单继承。使用 extends 关键字就可以继承任何拥有 [[Constructor]] 和原型的对象。
派生类的方法可以通过 super 关键字引用它们的原型。
使用 super 时需要注意:

  1. super 只能在派生类构造函数和静态方法中使用
  2. 不能单独引用 super 关键字,要么用它调用构造函数,要么引用静态方法
  3. 调用 super() 会调用父类构造函数,并将返回的实例赋值给 this
  4. super() 的行为如同调用构造函数,如果需要给父类构造函数传参需要手动传入
  5. 如果没有定义类构造函数,在实例化派生类时会调用 super() 并传入所有传给派生类的参数
  6. 在类构造函数中,不能在调用 super() 之前引用 this
  7. 如果在派生类中显式定义了构造函数,要么调用 super() ,要么必须返回一个对象

定义抽象基类(只用于继承,本身不会实例化)可以通过 new.target 实现,在实例化时检测是否为抽象基类可以阻止对抽象基类的实例化。

javascript 复制代码
class Vehicle {
  constructor() {
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated')
    }
  }
}

也可以通过在抽象基类构造函数中进行检查,要求派生类必须定义某个方法。

javascript 复制代码
class Vehicle {
  constructor() {
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated')
    }
    if (!this.foo) {
      throw new Error('Inheriting class must define foo()')
    }
  }
}

继承内置引用类型可以用来扩展内置类型:

javascript 复制代码
class SuperArray extends Array {
  // 洗牌算法
  shuffle() {
    for (let i = this.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i - 1))
      ;[this[i], this[j]] = [this[j], this[i]]
    }
  }
}

可以通过现有特性模拟实现多类继承,将不同类的行为集中到一个类:

javascript 复制代码
class Vehicle {
}

let FooMixin = (Superclass) => class extends Superclass {
  foo() {
    console.log('foo')
  }
}
let BarMixin = (Superclass) => class extends Superclass {
  bar() {
    console.log('bar')
  }
}
let BazMixin = (Superclass) => class extends Superclass {
  baz() {
    console.log('baz')
  }
}

function mix(BaseClass, ...Mixins) {
  return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass)
}

class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {
}
相关文章

Vite项目配置本地HTTPS

React Native 开发环境安装踩坑

《JavaScript 高级程序设计》第10-16章