Published on

JavaScript 中的对象

Authors
  • avatar
    Name
    Et cetera
    Twitter
Table of Contents

理解对象

ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象是一组没有特定顺序的值。对象的每个属性或方法都有一个名字,而每个名字都映射到一个值。所以可以把对象想象成一个散列表,其中的每个键都是对象的属性或方法。这里的每个属性或方法都由名字和值组成。

create-object.js
let person = new Object()
person.name = 'aimyon'
person.age = 18
person.job = 'singer'
person.sayName = function () {
  console.log(this.name)
}

早期的 JavaScript 开发者经常使用这种语法来创建对象,但是这种语法有一个缺点,那就是每个实例都要有一组完全相同的属性和方法,这样就会导致无法共享属性和方法。为了解决这个问题,人们开始使用构造函数来创建特定类型的对象。

// 使用对象字面量创建
let person = {
  name: 'aimyon',
  age: 18,
  job: 'singer',
  sayName: function () {
    console.log(this.name)
  },
}

属性的类型

ECMAScript 中有两种属性:数据属性访问器属性

数据属性

  • [[Configurable]]: 表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。默认值为 true
  • [[Enumerable]]: 表示能否通过 for-in 循环返回属性。默认值为 true
  • [[Writable]]: 表示能否修改属性的值。默认值为 true
  • [[Value]]: 包含这个属性的数据值。默认值为 undefined
let person = {
  name: 'aimyon',
}

在使用前面的方式创建对象后,[[Configurable]][[Enumerable]][[Writable]] 特性都被设置为 true,而 [[Value]] 特性被设置为指定的值,根据前面例子 [[Value]] 就会变为 aimyon,之后对这个值的所有修改都会保存这个位置。

要修改属性默认的特性,必须使用 Object.defineProperty() 方法。这个方法接收三个参数:属性所在的对象、属性的名字和一个描述符对象。其中,描述符对象的属性必须是:configurableenumerablewritablevalue。设置其中的一个或多个值,可以修改对应的特性值。

虽然可以对同一个属性多次调用 Object.defineProperty() 方法,但在 configurable: false 时会受限制。

在调用 Object.defineProperty() 方法时,如果不指定,configurableenumerablewritable 特性的默认值都是 false

访问器属性

访问器属性不包含数据值,它们包含一对 gettersetter 函数(不过,这两个函数都不是必需的)。在读取访问器属性时,会调用 getter 函数,这个函数负责返回有效的值;在写入访问器属性时,会调用 setter 函数并传入新值,这个函数负责决定如何处理数据。

访问器属性有如下 4 个特性:

  • [[Configurable]]: 表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。默认值为 true
  • [[Enumerable]]: 表示能否通过 for-in 循环返回属性。默认值为 true
  • [[Get]]: 在读取属性时调用的函数。默认值为 undefined
  • [[Set]]: 在写入属性时调用的函数。默认值为 undefined

访问器属性必须通过 Object.defineProperty() 来定义。

let singer = {
  name_: 'aimyon',
  age: 18,
}

Object.defineProperty(singer, 'name', {
  get() {
    return this.name_
  },
  set(newValue) {
    if (newValue !== this.name_) {
      this.age = 35
    }
  },
})

singer.name = 'taka'
console.log(singer.age) // 35

_ 结尾的属性名是一种常用的约定,用于表示只能通过对象方法内部访问的属性。

定义多个属性 Object.defineProperties()

Object.defineProperties() 方法可以通过描述符一次定义多个属性。

let singer = {}

Object.defineProperties(singer, {
  _name: {
    value: 'aimyon',
    configurable: true,
    enumerable: true,
    writable: true,
  },
  age: {
    value: 18,
    configurable: true,
    enumerable: true,
    writable: true,
  },
  name: {
    get() {
      return this._name
    },
    set(newValue) {
      if (newValue !== this._name) {
        this.age = 35
      }
    },
    configurable: true,
    enumerable: true,
  },
})

上面的例子和之前使用 Object.defineProperty() 定义的例子是一样的。只不过这里使用了 Object.defineProperties() 方法,可以把同时定义所有属性。

读取属性的特性 Object.getOwnPropertyDescriptor()

Object.getOwnPropertyDescriptor() 方法可以取得给定属性的描述符。他接受两个参数:属性所在的对象和要读取其描述符的属性名称。返回值是一个对象,如果是访问器属性,这个对象的属性有 configurableenumerablegetset;如果是数据属性,这个对象的属性有 configurableenumerablewritablevalue

let singer = {}

Object.defineProperties(singer, {
  _name: {
    value: 'aimyon',
    configurable: true,
    enumerable: true,
    writable: true,
  },
  age: {
    value: 18,
    configurable: true,
    enumerable: true,
    writable: true,
  },
  name: {
    get() {
      return this._name
    },
    set(newValue) {
      if (newValue !== this._name) {
        this.age = 35
      }
    },
    configurable: true,
    enumerable: true,
  },
})

let descriptor = Object.getOwnPropertyDescriptor(singer, '_name')
console.log(descriptor.value) // 'aimyon'
console.log(descriptor.configurable) // true
console.log(typeof descriptor.get) // 'undefined'

继承

原型链继承

ECMAScript 中描述了原型链的概念,并将原型链作为实现继承的主要方法。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。

function SuperType() {
  this.property = true
}

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

function SubType() {
  this.subproperty = false
}

// 继承了 SuperType
SubType.prototype = new SuperType()

SubType.prototype.getSubValue = function () {
  return this.subproperty
}

let instance = new SubType()

console.log(instance.getSuperValue()) // true

使用原型链实现继承时,不能使用对象字面量创建原型方法,因为这样会重写原型链。

function SuperType() {
  this.property = true
}

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

function SubType() {
  this.subproperty = false
}

// 继承了 SuperType
SubType.prototype = new SuperType()

// 使用字面量添加新方法,会导致上一行代码无效
SubType.prototype = {
  getSubValue() {
    return this.subproperty
  },
  someOtherMethod() {
    return false
  },
}

let instance = new SubType()

console.log(instance.getSuperValue()) // error

原型链的问题

原型链的问题在于对象实例共享所有继承的属性和方法,这种共享对于函数非常合适。对于包含引用类型的属性的原型,问题就会比较突出。

function SuperType() {
  this.colors = ['red', 'blue', 'green']
}

function SubType() {}

// 继承了 SuperType
SubType.prototype = new SuperType()

let instance1 = new SubType()
instance1.colors.push('black')
console.log(instance1.colors) // 'red,blue,green,black'

let instance2 = new SubType()
console.log(instance2.colors) // 'red,blue,green,black'

盗用构造函数(Constructor stealing)

function SuperType() {
  this.colors = ['red', 'blue', 'green']
}

function SubType() {
  // 继承了 SuperType
  SuperType.call(this)
}

let instance1 = new SubType()
instance1.colors.push('black')
console.log(instance1.colors) // 'red,blue,green,black'

let instance2 = new SubType()
console.log(instance2.colors) // 'red,blue,green'

这个做法解决了原型链继承的两个问题:

  • 通过在子类构造函数中调用超类构造函数,可以在子类中向超类构造函数传递参数。
  • 在实例中,不再共享引用类型的属性。

这种方法的问题在于,方法都在构造函数中定义,因此函数复用就无从谈起了。而且,在超类的原型中定义的方法,对子类而言也是不可见的,结果所有类型都只能使用构造函数模式。

组合继承

组合继承(有时候也叫做伪经典继承)指的是将原型链和盗用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。

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 instance1 = new SubType('aimyon', 18)
instance1.colors.push('black')
console.log(instance1.colors) // 'red,blue,green,black'
instance1.sayName() // 'aimyon'
instance1.sayAge() // 18

let instance2 = new SubType('taka', 35)
console.log(instance2.colors) // 'red,blue,green'
instance2.sayName() // 'taka'
instance2.sayAge() // 35

组合继承避免了原型链和盗用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继承模式。

原型式继承

原型式继承的思路是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。

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

let person = {
  name: 'aimyon',
  friends: ['taka'],
}

let anotherPerson = object(person)
anotherPerson.name = 'taka'
anotherPerson.friends.push('yonezu')

let yetAnotherPerson = object(person)
yetAnotherPerson.name = 'yonezu'
yetAnotherPerson.friends.push('aimyon')

console.log(person.friends) // 'taka,yonezu,aimyon'

object() 函数会创建一个临时性的构造函数,将传入的对象作为这个构造函数的原型,然后返回这个临时类型的一个新实例。本质上,object() 对传入其中的对象执行了一次浅复制

适用于在不需要单独创建构造函数的情况下,想让一个对象与另一个对象保持类似的情况

ECMAScript 5 通过新增 Object.create() 方法规范化了原型式继承。这个方法接收两个参数:一个用作新对象原型的对象和一个为新对象定义额外属性的对象(可选)。

let person = {
  name: 'aimyon',
  friends: ['taka'],
}

let anotherPerson = Object.create(person)
anotherPerson.name = 'taka'
anotherPerson.friends.push('yonezu')

let yetAnotherPerson = Object.create(person)
yetAnotherPerson.name = 'yonezu'
yetAnotherPerson.friends.push('aimyon')

console.log(person.friends) // 'taka,yonezu,aimyon'

Object.create() 的第二个参数与 Object.defineProperties() 方法的第二个参数格式相同:每个属性都是通过自己的描述符定义的。以这种方式指定的任何属性都会覆盖原型对象上的同名属性。

寄生式继承(parasitic inheritance)

寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。

function createAnother(original) {
  let clone = object(original) // 通过调用函数创建一个新对象
  clone.sayHi = function () {
    // 以某种方式来增强这个对象
    console.log('hi')
  }
  return clone // 返回这个对象
}

let person = {
  name: 'aimyon',
  friends: ['taka'],
}

let anotherPerson = createAnother(person)
anotherPerson.sayHi() // 'hi'

在主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。

通过寄生式继承给对象添加函数,会由于不能做到函数复用而降低效率;这一点与构造函数模式类似。

寄生组合式继承

组合继承最大的问题就是无论什么情况下,都会调用两次超类构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性。

寄生组合式继承的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

function inheritPrototype(subType, superType) {
  let prototype = Object.create(superType.prototype) // 创建对象
  prototype.constructor = subType // 增强对象
  subType.prototype = prototype // 指定对象
}

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
}

inheritPrototype(SubType, SuperType)

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

let instance1 = new SubType('aimyon', 18)
instance1.colors.push('black')
console.log(instance1.colors) // 'red,blue,green,black'
instance1.sayName() // 'aimyon'
instance1.sayAge() // 18

let instance2 = new SubType('taka', 35)
console.log(instance2.colors) // 'red,blue,green'
instance2.sayName() // 'taka'
instance2.sayAge() // 35