自学内容网 自学内容网

第 8 章 对象、类与面向对象编程

第 8 章 对象、类与面向对象编程

8.1 理解对象

8.1.1 属性的类型

属性分两种:数据属性和访问器属性。

1.数据属性

数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。

❑ [​[Configurable]​]​:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
❑ [​[Enumerable]​]​:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
❑ [​[Writable]​]​:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
❑ [​[Value]​]​:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。

将属性显式添加到对象之后,​[​[Configurable]​]​、​[​[Enumerable]​]和[​[Writable]​]都会被设置为 true,而[​[Value]​]特性会被设置为指定的值。比如:

let person = {
  name: 'Nicholas',
}

要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。一个属性被定义为不可配置之后,就不能再变回可配置的了。

let person = {}
Object.defineProperty(person, 'name', {
  writable: false,
  configurable: false,
  value: 'Nicholas',
})
console.log(person.name) // 'Nicholas'
person.name = 'Greg'
console.log(person.name) // 'Nicholas'
delete person.name // 非严格模式下对这个属性调用delete没有效果,严格模式下会抛出错误。
console.log(person.name) // 'Nicholas'
// Cannot redefine property: name
Object.defineProperty(person, 'name', {
  configurable: true,
  value: 'Nicholas',
})

在调用 Object.defineProperty()时,configurable、enumerable 和 writable 的值如果不指定,则都默认为 false。

2.访问器属性

访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有 4 个特性描述它们的行为。

❑ [​[Configurable]​]​:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
❑ [​[Enumerable]​]​:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
❑ [​[Get]​]​:获取函数,在读取属性时调用。默认值为 undefined。
❑ [​[Set]​]​:设置函数,在写入属性时调用。默认值为 undefined。

访问器属性是不能直接定义的,必须使用 Object.defineProperty()

// 定义一个对象,包含伪私有成员year_和公共成员edition
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

获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性是不能读取的,非严格模式下读取会返回 undefined,严格模式下会抛出错误。

8.1.2 定义多个属性

Object.define-Properties()方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。数据属性的 configurable、enumerable 和 writable 特性值都是 false。

let book = {}
Object.defineProperties(book, {
  year_: {
    value: 2017,
  },
  edition: {
    value: 1,
  },
  year: {
    get() {
      return this.year_
    },
    set(newValue) {
      if (newValue > 2017) {
        this.year_ = newValue
        this.edition += newValue - 2017
      }
    },
  },
})

8.1.3 读取属性的特性

使用 Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符。这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含 configurable、enumerable、get 和 set 属性,对于数据属性包含 configurable、enumerable、writable 和 value 属性。

let book = {}
Object.defineProperties(book, {
  year_: {
    value: 2017,
  },
  edition: {
    value: 1,
  },
  year: {
    get: function () {
      return this.year_
    },
    set: function (newValue) {
      if (newValue > 2017) {
        this.year_ = newValue
        this.edition += newValue - 2017
      }
    },
  },
})
let descriptor_ = Object.getOwnPropertyDescriptor(book, 'year_')
console.log(descriptor_.value) // 2017
console.log(descriptor_.configurable) // false
console.log(typeof descriptor_.get) // 'undefined'
let descriptor = Object.getOwnPropertyDescriptor(book, 'year')
console.log(descriptor.value) // undefined
console.log(descriptor.enumerable) // false
console.log(typeof descriptor.get) // 'function'

Object.getOwnPropertyDescriptors()静态方法实际上会在每个自有属性上调用 Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。

let book = {}
Object.defineProperties(book, {
  year_: {
    value: 2017,
  },
  edition: {
    value: 1,
  },
  year: {
    get: function () {
      return this.year_
    },
    set: function (newValue) {
      if (newValue > 2017) {
        this.year_ = newValue
        this.edition += newValue - 2017
      }
    },
  },
})
console.log(Object.getOwnPropertyDescriptors(book))
// {
//    edition: {value: 1, writable: false, enumerable: false, configurable: false}
//    year: {enumerable: false, configurable: false, get: ƒ, set: ƒ}
//    year_: {"value": 2017, "writable": false, "enumerable": false, "configurable": false }
// }

8.1.4 合并对象

Object.assign()方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回 true)和自有(Object.hasOwnProperty()返回 true)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[​[Get]​]取得属性的值,然后使用目标对象上的[​[Set]​]设置属性的值。

let dest, src, result
/**
 * 简单复制
 */
dest = {}
src = { id: 'src' }
result = Object.assign(dest, src)
// Object.assign修改目标对象,也会返回修改后的目标对象
console.log(dest === result) // true
console.log(dest !== src) // true
console.log(result) // { id: 'src' }
console.log(dest) // { id: 'src' }
/**
 * 多个源对象
 */
dest = {}
result = Object.assign(dest, { a: 'foo' }, { b: 'bar' })
console.log(result) // { a: 'foo', b: 'bar' }
/**
 * 获取函数与设置函数
 */
dest = {
  set a(val) {
    console.log(`Invoked dest setter with param ${val}`)
  },
}
src = {
  get a() {
    console.log('Invoked src getter')
    return 'foo'
  },
}
Object.assign(dest, src)
// 调用src的获取方法
// 调用dest的设置方法并传入参数"foo"
// 因为这里的设置函数不执行赋值操作
// 所以实际上并没有把值转移过来
console.log(dest) // { }

Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数。

let dest, src, result
/**
 * 覆盖属性
 */
dest = { id: 'dest' }
result = Object.assign(dest, { id: 'src1', a: 'foo' }, { id: 'src2', b: 'bar' })
// Object.assign会覆盖重复的属性
console.log(result) // { id: 'src2', a: 'foo', b: 'bar' }
// 可以通过目标对象上的设置函数观察到覆盖的过程:
dest = {
  set id(x) {
    console.log(x) // 'first', 'second', 'third'
  },
}
Object.assign(dest, { id: 'first' }, { id: 'second' }, { id: 'third' })
// first
// second
// third
/**
 * 对象引用
 */
dest = {}
src = { a: {} }
Object.assign(dest, src)
// 浅复制意味着只会复制对象的引用
console.log(dest) // { a :{} }
console.log(dest.a === src.a) // true

如果赋值期间出错,则操作会中止并退出,同时抛出错误。Object.assign()没有“回滚”之前赋值的概念,因此它是一个尽力而为、可能只会完成部分复制的方法。

8.1.5 对象标识及相等判定

在 ECMAScript 6 之前,有些特殊情况即使是===操作符也无能为力:

// 这些是===符合预期的情况
console.log(true === 1) // false
console.log({} === {}) // false
console.log('2' === 2) // false
// 这些情况在不同JavaScript引擎中表现不同,但仍被认为相等
console.log(+0 === -0) // true
console.log(+0 === 0) // true
console.log(-0 === 0) // true
// 要确定NaN的相等性,必须使用极为讨厌的isNaN()
console.log(NaN === NaN) // false
console.log(isNaN(NaN)) // true

改善这类情况,ECMAScript 6 规范新增了 Object.is(),这个方法与===很像,但同时也考虑到了上述边界情形。这个方法必须接收两个参数:

console.log(Object.is(true, 1)) // false
console.log(Object.is({}, {})) // false
console.log(Object.is('2', 2)) // false
// 正确的0、-0、+0 相等/不等判定
console.log(Object.is(+0, -0)) // false
console.log(Object.is(+0, 0)) // true
console.log(Object.is(-0, 0)) // false
// 正确的NaN相等判定
console.log(Object.is(NaN, NaN)) // true

Object.is 和 === 规则如下

Object.is 和 === 在比较两个值时都不会进行类型转换

  • Object.is
    当比较 NaN 是,Object.is(NaN,NaN) 返回 true
    当比较 +0 和 -0 时,Object.is(+0,-0) 返回 false,因为它能区分正零和负零

  • ===
    NaN === NaN 返回 false, 因为 NaN 不等于任何值,包括它自身
    对于 +0 和 -0,+0 === -0 返回 true,因为严格相等不区分正负

8.1.6 增强的对象语法

1.属性值简写
2.可计算属性

在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语法来添加属性。换句话说,不能在对象字面量中直接动态命名属性。可计算属性本身可以是复杂的表达式。

const nameKey = 'name'
const ageKey = 'age'
const jobKey = 'job'
let uniqueToken = 0
function getUniqueKey(key) {
  return `${key}_${uniqueToken++}`
}
let person = {
  [getUniqueKey(nameKey)]: 'Matt',
  [getUniqueKey(ageKey)]: 27,
  [getUniqueKey(jobKey)]: 'Software engineer',
}
console.log(person) // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }
3.简写方法名

8.1.7 对象解构

解构在内部使用函数 ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject()的定义), nullundefined 不能被解构,否则会抛出错误。

let { length } = 'foobar'
console.log(length) // 6
let { constructor: c } = 4
console.log(c === Number) // true
let { _ } = null // TypeError
let { _ } = undefined // TypeError

解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中。

let personName, personAge
let person = {
  name: 'Matt',
  age: 27,
}
;({ name: personName, age: personAge } = person)
console.log(personName, personAge) // 'Matt', 27
1.嵌套解构

解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性。

let person = {
  name: 'Matt',
  age: 27,
  job: {
    title: 'Software engineer',
  },
}
let personCopy = {}
;({ name: personCopy.name, age: personCopy.age, job: personCopy.job } = person)
// 因为一个对象的引用被赋值给personCopy,所以修改
// person.job对象的属性也会影响personCopy
person.job.title = 'Hacker'
console.log(person) // { name: 'Matt', age: 27, job: { title: 'Hacker' } }
console.log(personCopy) // { name: 'Matt', age: 27, job: { title: 'Hacker' } }

解构赋值可以使用嵌套结构,以匹配嵌套的属性。

let person = {
  name: 'Matt',
  age: 27,
  job: {
    title: 'Software engineer',
  },
}
// 声明title变量并将person.job.title的值赋给它
let {
  job: { title },
} = person
console.log(title) // 'Software engineer'

在外层属性没有定义的情况下不能使用嵌套解构。无论源对象还是目标对象都一样。

let person = {
  job: {
    title: 'Software engineer',
  },
}
let personCopy = {}
// foo在源对象上是undefined
;({
  foo: { bar: personCopy.bar },
} = person)
// TypeError: Cannot destructure property 'bar' of 'undefined' or 'null'.
// job在目标对象上是undefined
;({
  job: { title: personCopy.job.title },
} = person)
// TypeError: Cannot set property 'title' of undefined
2.部分解构

如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分。

let person = {
  name: 'Matt',
  age: 27,
}
let personName, personBar, personAge
try {
  // person.foo是undefined,因此会抛出错误
  ;({
    name: personName,
    foo: { bar: personBar },
    age: personAge,
  } = person)
} catch (e) {}
console.log(personName, personBar, personAge) // 'Matt', undefined, undefine
3.参数上下文匹配

在函数参数列表中也可以进行解构赋值。

8.2 创建对象

8.2.1 概述

8.2.2 工厂模式

工厂模式用于抽象创建特定对象的过程。​

function createPerson(name, age, job) {
  let o = new Object()
  o.name = name
  o.age = age
  o.job = job
  o.sayName = function () {
    console.log(this.name)
  }
  return o
}
let person1 = createPerson('Nicholas', 29, 'Software Engineer')
let person2 = createPerson('Greg', 27, 'Doctor')

这里,函数 createPerson()接收 3 个参数,根据这几个参数构建了一个包含 Person 信息的对象。可以用不同的参数多次调用这个函数,每次都会返回包含 3 个属性和 1 个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)​。

8.2.3 构造函数模式

自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

function Person(name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.sayName = function () {
    console.log(this.name)
  }
}
let person1 = new Person('Nicholas', 29, 'Software Engineer')
let person2 = new Person('Greg', 27, 'Doctor')
person1.sayName() // Nicholas
person2.sayName() // Greg

// person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个 constructor 属性指向 Person
console.log(person1.constructor == Person) // true
console.log(person2.constructor == Person) // true

console.log(person1 instanceof Object) // true
console.log(person1 instanceof Person) // true
console.log(person2 instanceof Object) // true
console.log(person2 instanceof Person) // true

在这个例子中,Person()构造函数代替了 createPerson()工厂函数。实际上,Person()内部的代码跟 createPerson()基本是一样的,只是有如下区别。

❑ 没有显式地创建对象。
❑ 属性和方法直接赋值给了 this。
❑ 没有 return。

按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。

要创建 Person 的实例,应使用 new 操作符。以这种方式调用构造函数会执行如下操作。

(1)在内存中创建一个新对象。
(2)这个新对象内部的[​[Prototype]​]特性被赋值为构造函数的 prototype 属性。
(3)构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)​。
(4)执行构造函数内部的代码(给新对象添加属性)​。
(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数。

在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。只要有 new 操作符,就可以调用相应的构造函数。

1.构造函数也是函数

构造函数与普通函数唯一的区别就是调用方式不同。

2.构造函数的问题

构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。因此对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方法不是同一个 Function 实例。我们知道,ECMAScript 中的函数是对象,因此每次定义函数时,都会初始化一个对象。

console.log(person1.sayName == person2.sayName) // false

要解决这个问题,可以把函数定义转移到构造函数外部:

function Person(name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.sayName = sayName
}
function sayName() {
  console.log(this.name)
}
let person1 = new Person('Nicholas', 29, 'Software Engineer')
let person2 = new Person('Greg', 27, 'Doctor')
person1.sayName() // 'Nicholas'
person2.sayName() // 'Greg'

这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。

8.2.4 原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型,如下所示:

function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
  console.log(this.name)
}
let person1 = new Person()
person1.sayName() // 'Nicholas'
let person2 = new Person()
person2.sayName() // 'Nicholas'
console.log(person1.sayName == person2.sayName) // true

使用函数表达式也可以。

1.理解原型

无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)​。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。对前面的例子而言,Person.prototype.constructor 指向 Person。然后,因构造函数而异,可能会给原型对象添加其他属性和方法。

在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 Object。每次调用构造函数创建一个新实例,这个实例的内部[​[Prototype]​]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[​[Prototype]​]特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露proto属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性完全被隐藏了。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。

/**
 * 构造函数可以是函数表达式
 * 也可以是函数声明,因此以下两种形式都可以:
 *    function Person() {}
 *    let Person = function() {}
 */
function Person() {}
/**
 * 声明之后,构造函数就有了一个
 * 与之关联的原型对象:
 */
console.log(typeof Person.prototype) // 'object'
console.log(Person.prototype) // Person { constructor: ƒ Person() }
/**
 * 如前所述,构造函数有一个prototype属性
 * 引用其原型对象,而这个原型对象也有一个
 * constructor属性,引用这个构造函数
 * 换句话说,两者循环引用:
 */
console.log(Person.prototype.constructor === Person) // true
/**
 * 正常的原型链都会终止于Object的原型对象
 * Object原型的原型是null
 */
console.log(Person.prototype.__proto__ === Object.prototype) // true
console.log(Person.prototype.__proto__.constructor === Object) // true
console.log(Person.prototype.__proto__.__proto__ === null) // true
console.log(Person.prototype.__proto__)
// {
//    constructor: f Object(),
//    toString: ...
//    hasOwnProperty: ...
//    isPrototypeOf: ...
//    ...
// }
let person1 = new Person(),
  person2 = new Person()
/**
 * 构造函数、原型对象和实例
 * 是3 个完全不同的对象:
 */
console.log(person1 !== Person) // true
console.log(person1 !== Person.prototype) // true
console.log(Person.prototype !== Person) // true
/**
 * 实例通过__proto__链接到原型对象,
 * 它实际上指向隐藏特性[[Prototype]]
 *
 * 构造函数通过prototype属性链接到原型对象
 *
 * 实例与构造函数没有直接联系,与原型对象有直接联系
 */
console.log(person1.__proto__ === Person.prototype) // true
console.log(person1.__proto__.constructor === Person) // true
/**
 * 同一个构造函数创建的两个实例
 * 共享同一个原型对象:
 */
console.log(person1.__proto__ === person2.__proto__) // true
/**
 * instanceof检查实例的原型链中
 * 是否包含指定构造函数的原型:
 */
console.log(person1 instanceof Person) // true
console.log(person1 instanceof Object) // true
console.log(Person.prototype instanceof Object) // true

isPrototypeOf()方法允许你检查一个对像是否存在另一个对象的原型链上

console.log(Person.prototype.isPrototypeOf(person1)) // true
console.log(Person.prototype.isPrototypeOf(person2)) // true

Object.getPrototypeOf(),返回参数的内部特性[​[Prototype]​]的值,可以方便地取得一个对象的原型。

console.log(Object.getPrototypeOf(person1) == Person.prototype) // true
console.log(Object.getPrototypeOf(person1).name) // 'Nicholas'

Object 类型还有一个 setPrototypeOf()方法,可以向实例的私有特性[​[Prototype]​]写入一个新值。这样就可以重写一个对象的原型继承关系:

let biped = {
  numLegs: 2,
}
let person = {
  name: 'Matt',
}
Object.setPrototypeOf(person, biped)
console.log(person.name) // 'Matt'
console.log(person.numLegs) // 2
console.log(Object.getPrototypeOf(person) === biped) // true

为避免使用 Object.setPrototypeOf()可能造成的性能下降,可以通过 Object.create()来创建一个新对象,同时为其指定原型:

let biped = {
  numLegs: 2,
}
let person = Object.create(biped)
person.name = 'Matt'
console.log(person.name) // 'Matt'
console.log(person.numLegs) // 2
console.log(Object.getPrototypeOf(person) === biped) // true
2.原型层级

在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。

前面提到的 constructor 属性只存在于原型对象,因此通过实例对象也是可以访问到的。

虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。

function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
  console.log(this.name)
}
let person1 = new Person()
let person2 = new Person()
person1.name = 'Greg'
console.log(person1.name) // 'Greg',来自实例
console.log(person2.name) // 'Nicholas',来自原型

只要给对象实例添加一个属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。即使在实例上把这个属性设置为 null,也不会恢复它和原型的联系。不过,使用 delete 操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象。

hasOwnProperty()方法用于确定某个属性是在实例上还是在原型对象上,这个方法是继承自 Object 的,会在属性存在于调用它的对象实例上时返回 true。

function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
  console.log(this.name)
}
let person1 = new Person()
let person2 = new Person()
console.log(person1.hasOwnProperty('name')) // false
person1.name = 'Greg'
console.log(person1.name) // 'Greg',来自实例
console.log(person1.hasOwnProperty('name')) // true
console.log(person2.name) // 'Nicholas',来自原型
console.log(person2.hasOwnProperty('name')) // false
delete person1.name
console.log(person1.name) // 'Nicholas',来自原型
console.log(person1.hasOwnProperty('name')) // false

注意 ECMAScript 的 Object.getOwnPropertyDescriptor()方法只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用 Object.getOwnProperty-Descriptor()

3.原型和 in 操作符

有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。在单独使用时,in 操作符会在可以通过对象访问指定属性时返回 true,无论该属性是在实例上还是在原型上。只要通过对象可以访问,in 操作符就返回 true,hasOwnProperty()只有属性存在于实例上时才返回 true。

function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
  console.log(this.name)
}
let person1 = new Person()
let person2 = new Person()
console.log(person1.hasOwnProperty('name')) // false
console.log('name' in person1) // true
person1.name = 'Greg'
console.log(person1.name) // 'Greg',来自实例
console.log(person1.hasOwnProperty('name')) // true
console.log('name' in person1) // true
console.log(person2.name) // 'Nicholas',来自原型
console.log(person2.hasOwnProperty('name')) // false
console.log('name' in person2) // true
delete person1.name
console.log(person1.name) // 'Nicholas',来自原型
console.log(person1.hasOwnProperty('name')) // false
console.log('name' in person1) // true

如果要确定某个属性是否存在于原型上,则可以像下面这样同时使用 hasOwnProperty()in 操作符:

function hasPrototypeProperty(object, name) {
  return !object.hasOwnProperty(name) && name in object
}

for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。遮蔽原型中不可枚举(​[​[Enumerable]​]特性被设置为 false)属性的实例属性也会在 for-in 循环中返回,因为默认情况下开发者定义的属性都是可枚举的。

要获得对象上所有可枚举的实例属性,可以使用 Object.keys()方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。

function Person() {}
Person.prototype.name = 'Nicholas'
Person.prototype.age = 29
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
  console.log(this.name)
}
let keys = Object.keys(Person.prototype)
console.log(keys) // 'name', 'age', 'job', 'sayName'
let p1 = new Person()
p1.name = 'Rob'
p1.age = 31
let p1keys = Object.keys(p1)
console.log(p1keys) //  'name', 'age'

如果想列出所有实例属性,无论是否可以枚举,都可以使用 Object.getOwnPropertyNames()

let keys = Object.getOwnPropertyNames(Person.prototype)
console.log(keys) // 'constructor', 'name', 'age', 'job', 'sayName'

因为以符号为键的属性没有名称的概念。因此,Object.getOwnProperty-Symbols()方法就出现了,这个方法与 Object.getOwnPropertyNames()类似,只是针对符号而已:

let k1 = Symbol('k1'),
  k2 = Symbol('k2')
let o = {
  [k1]: 'k1',
  [k2]: 'k2',
}
console.log(Object.getOwnPropertySymbols(o)) //  Symbol(k1), Symbol(k2)
4.属性枚举顺序

for-in 循环和 Object.keys()的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异。

Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。

对象的属性枚举顺序大致遵循以下规则

  1. 数值属性:首先会迭代对象的所有数值键,按照数值的升序进行。
  2. 字符串属性:然后按字符串的插入顺序依次迭代字符串属性
  3. symbol 属性:再然后按 symbol 的插入顺序依次迭代 symbol 属性
  4. 继承属性:当一个对象上有自己的属性,并且还继承一些属性的时候。整体也是按照上述三条规则排序的,但是插入顺序可能需要注意。这里暂不考虑这种特殊复杂情况。
// 输出什么?
const obj = {
  a: 0,
}
obj['1'] = 0
obj[++obj.a] = obj.a++
const values = Object.values(obj)
obj[values[1]] = obj.a
console.log(obj)

8.2.5 对象迭代

Object.values()返回对象值的数组,Object.entries()返回键/值对的数组。非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制,符号属性会被忽略。

const sym = Symbol()
const o = {
  foo: 'bar',
  baz: 1,
  qux: {},
  [sym]: 'foo',
}
console.log(Object.values(o)) // [ 'bar', 1, {} ]
console.log(Object.entries(o)) // [ [ 'foo', 'bar' ], [ 'baz', 1 ], [ 'qux', {} ] ]
console.log(Object.values(o)[2] === o.qux) // true
console.log(Object.entries(o)[2][1] === o.qux) // true
1.其他原型语法

原生 constructor 属性默认是不可枚举的。

2.原型的动态性

因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。

let friend = new Person()
Person.prototype.sayHi = function () {
  console.log('hi')
}
friend.sayHi() // 'hi',没问题!

实例的[​[Prototype]​]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。记住,实例只有指向原型的指针,没有指向构造函数的指针。

function Person() {}
let friend = new Person()
Person.prototype = {
  constructor: Person,
  name: 'Nicholas',
  age: 29,
  job: 'Software Engineer',
  sayName() {
    console.log(this.name)
  },
}
friend.sayName() // friend.sayName is not a function

重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。

3.原生对象原型
4.原型的问题

原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。真正的问题来自包含引用值的属性。

function Person() {}
Person.prototype = {
  constructor: Person,
  name: 'Nicholas',
  age: 29,
  job: 'Software Engineer',
  friends: ['Shelby', 'Court'],
  sayName() {
    console.log(this.name)
  },
}
let person1 = new Person()
let person2 = new Person()
person1.friends.push('Van')
console.log(person1.friends) // [ 'Shelby', 'Court', 'Van' ]
console.log(person2.friends) // [ 'Shelby', 'Court', 'Van' ]
console.log(person1.friends === person2.friends) // true

8.3 继承

很多面向对象语言都支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。

8.3.1 原型链

ECMA-262 把原型链定义为 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
1.默认原型

实际上,原型链中还有一环。默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向 Object.prototype

2.原型与继承关系

原型与实例的关系可以通过两种方式来确定。第一种方式是使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则 instanceof 返回 true。确定这种关系的第二种方式是使用 isPrototypeOf()方法。

console.log(instance instanceof Object) // true
console.log(instance instanceof SuperType) // true
console.log(instance instanceof SubType) // true

console.log(Object.prototype.isPrototypeOf(instance)) // true
console.log(SuperType.prototype.isPrototypeOf(instance)) // true
console.log(SubType.prototype.isPrototypeOf(instance)) // true
3.关于方法

子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。

以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。

4.原型链的问题

主要问题出现在原型中包含引用值的时候。

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' ]

原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。

8.3.2 盗用构造函数

基本思路:在子类构造函数中调用父类构造函数。

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' ]
1.传递参数

相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。

function SuperType(name) {
  this.name = name
}
function SubType() {
  // 继承SuperType并传参
  SuperType.call(this, 'Nicholas')
  // 实例属性
  this.age = 29
}
let instance = new SubType()
console.log(instance.name) // 'Nicholas'
console.log(instance.age) // 29
2.盗用构造函数的问题

盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用。

8.3.3 组合继承

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

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('Nicholas', 29)
instance1.colors.push('black')
console.log(instance1.colors) // [ 'red', 'blue', 'green', 'black' ]
instance1.sayName() // 'Nicholas'
instance1.sayAge() // 29
let instance2 = new SubType('Greg', 27)
console.log(instance2.colors) // [ 'red', 'blue', 'green' ]
instance2.sayName() // 'Greg'
instance2.sayAge() // 27

组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf()方法识别合成对象的能力。

8.3.4 原型式继承

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

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

let person = {
  name: 'Nicholas',
  friends: ['Shelby', 'Court', 'Van'],
}
let anotherPerson = object(person)
anotherPerson.name = 'Greg'
anotherPerson.friends.push('Rob')
let yetAnotherPerson = object(person)
yetAnotherPerson.name = 'Linda'
yetAnotherPerson.friends.push('Barbie')
console.log(person.friends) // [ 'Shelby', 'Court', 'Van', 'Rob', 'Barbie' ]

ECMAScript 5 通过增加 Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)​。在只有一个参数时,Object.create()与这里的 object()方法效果相同:

let person = {
  name: 'Nicholas',
  friends: ['Shelby', 'Court', 'Van'],
}
let anotherPerson = Object.create(person)
anotherPerson.name = 'Greg'
anotherPerson.friends.push('Rob')
let yetAnotherPerson = Object.create(person)
yetAnotherPerson.name = 'Linda'
yetAnotherPerson.friends.push('Barbie')
console.log(person.friends) // [ 'Shelby', 'Court', 'Van', 'Rob', 'Barbie' ]

Object.create()的第二个参数与 Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。

let person = {
  name: 'Nicholas',
  friends: ['Shelby', 'Court', 'Van'],
}
let anotherPerson = Object.create(person, {
  name: {
    value: 'Greg',
  },
})
console.log(anotherPerson.name) // 'Greg'

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

8.3.5 寄生式继承

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

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

let person = {
  name: 'Nicholas',
  friends: ['Shelby', 'Court', 'Van'],
}
let anotherPerson = createAnother(person)
anotherPerson.sayHi() // 'hi'

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object()函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。

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

8.3.6 寄生式组合继承

组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。

function inheritPrototype(subType, superType) {
  let prototype = object(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)
}

这里只调用了一次 SuperType 构造函数,避免了 SubType.prototype 上不必要也用不到的属性,因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此 instanceof 操作符和 isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。

8.4 类

8.4.1 类定义

定义类有两种主要方式:类声明和类表达式。

// 类声明
class Person {}
// 类表达式
const Animal = class {}

与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能:

console.log(FunctionExpression) // undefined
var FunctionExpression = function () {}
console.log(FunctionExpression) // function() {}
console.log(FunctionDeclaration) // FunctionDeclaration() {}
function FunctionDeclaration() {}
console.log(FunctionDeclaration) // FunctionDeclaration() {}
console.log(ClassExpression) // undefined
var ClassExpression = class {}
console.log(ClassExpression) // class {}
console.log(ClassDeclaration) // ReferenceError: ClassDeclaration is not defined
class ClassDeclaration {}
console.log(ClassDeclaration) // class ClassDeclaration {}

另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制:

{
  function FunctionDeclaration() {}
  class ClassDeclaration {}
}
console.log(FunctionDeclaration) // FunctionDeclaration() {}
console.log(ClassDeclaration) // ReferenceError: ClassDeclaration is not defined
类的构成

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。

与函数构造函数一样,多数编程风格都建议类名的首字母要大写,以区别于通过它创建的实例。

类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符。

let Person = class PersonName {
  identify() {
    console.log(Person.name, PersonName.name)
  }
}
let p = new Person()
p.identify() // PersonName PersonName
console.log(Person.name) // PersonName
console.log(PersonName) // ReferenceError: PersonName is not defined

8.4.2 类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

1.实例化

使用 new 调用类的构造函数会执行如下操作。

(1)在内存中创建一个新对象。
(2)这个新对象内部的[​[Prototype]​]指针被赋值为构造函数的 prototype 属性。
(3)构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)​。
(4)执行构造函数内部的代码(给新对象添加属性)​。
(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的。

默认情况下,类构造函数会在执行之后返回 this 对象。构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的 this 对象,那么这个对象会被销毁。不过,如果返回的不是 this 对象,而是其他对象,那么这个对象不会通过 instanceof 操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改。

class Person {
  constructor(override) {
    this.foo = 'foo'
    if (override) {
      return {
        bar: 'bar',
      }
    }
  }
}
let p1 = new Person(),
  p2 = new Person(true)
console.log(p1) // Person { foo: 'foo' }
console.log(p1 instanceof Person) // true
console.log(p2) // { bar: 'bar' }
console.log(p2 instanceof Person) // false

类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符。而普通构造函数如果不使用 new 调用,那么就会以全局的 this(通常是 window)作为内部对象。调用类构造函数时如果忘了使用 new 则会抛出错误。

类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用 new 调用)​。因此,实例化之后可以在实例上引用它:

class Person {}
// 使用类创建一个新实例
let p1 = new Person()
p1.constructor()
// TypeError: Class constructor Person cannot be invoked without 'new'
// 使用对类构造函数的引用创建一个新实例
let p2 = new p1.constructor()
2.把类当成特殊函数

ECMAScript 类就是一种特殊函数。声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数。

class Person {}
console.log(Person) // class Person {}
console.log(typeof Person) // 'function'

类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身:

class Person {}
console.log(Person.prototype) // { constructor: f() }
console.log(Person === Person.prototype.constructor) // true

与普通构造函数一样,可以使用 instanceof 操作符检查构造函数原型是否存在于实例的原型链中:

class Person {}
let p = new Person()
console.log(p instanceof Person) // true

类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用 new 调用时就会被当成构造函数。重点在于,类中定义的 constructor 方法不会被当成构造函数,在对它使用 instanceof 操作符时会返回 false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么 instanceof 操作符的返回值会反转。

class Person {}
let p1 = new Person()
console.log(p1.constructor === Person) // true
console.log(p1 instanceof Person) // true
console.log(p1 instanceof Person.constructor) // false
let p2 = new Person.constructor()
console.log(p2.constructor === Person) // false
console.log(p2 instanceof Person) // false
console.log(p2 instanceof Person.constructor) // true

类是 JavaScript 的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递。

与立即调用函数表达式相似,类也可以立即实例化:

// 因为是一个类表达式,所以类名是可选的
let p = new (class Foo {
  constructor(x) {
    console.log(x)
  }
})('bar') // bar
console.log(p) // Foo {}

8.4.3 实例、原型和类成员

1.实例成员

每次通过 new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。

每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。

2.原型方法与访问器

为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据。

class Person {
  constructor() {
    // 添加到this的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance')
  }
  // Uncaught SyntaxError: Unexpected token
  name: 'Jake'
  // 在类块中定义的所有内容都会定义在类的原型上
  locate() {
    console.log('prototype')
  }
}
let p = new Person()
p.locate() // instance
Person.prototype.locate() // 'prototype'

类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键。类定义也支持获取和设置访问器。语法与行为跟普通对象一样:

class Person {
  set name(newName) {
    this.name_ = newName
  }
  get name() {
    return this.name_
  }
}
let p = new Person()
p.name = 'Jake'
console.log(p.name) // 'Jake'
3.静态类方法

可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。与原型成员类似,静态成员每个类上只能有一个。实例成员在每个类的实例创建时被初始化,每个实例都有自己的一套实例成员。而静态成员在类定义时初始化一次,并且所有类的实例共享静态成员。这意味着静态成员在内存中只有一份,而实例成员的数量与类的实例数量相同。

在 ES5 及之前没有 class 语法,你可以通过直接在函数构造函数的属性上定义来创建静态成员。例如:

function MyClass() {}
MyClass.staticProperty = 'This is a static property in ES5'
MyClass.staticMethod = function () {
  console.log('This is a static method in ES5')
}

静态类成员在类定义中使用 static 关键字作为前缀。在静态成员中,this 引用类自身。

class Person {
  constructor() {
    // 添加到this的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance', this)
  }
  // 定义在类的原型对象上
  locate() {
    console.log('prototype', this)
  }
  //定义在类本身上
  static locate() {
    console.log('class', this)
  }
}
let p = new Person()
p.locate() // instance, Person {}
Person.prototype.locate() // 'prototype', {constructor: ... }
Person.locate() // 'class', class Person {}

静态类方法非常适合作为实例工厂:

class Person {
  constructor(age) {
    this.age_ = age
  }
  sayAge() {
    console.log(this.age_)
  }
  static create() {
    // 使用随机年龄创建并返回一个Person实例
    return new Person(Math.floor(Math.random() * 100))
  }
}
console.log(Person.create()) // Person { age_: ... }
4.非函数原型和类成员

虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:

class Person {
  sayName() {
    console.log(`${Person.greeting} ${this.name}`)
  }
}
//在类上定义数据成员
Person.greeting = 'My name is'
//在原型上定义数据成员
Person.prototype.name = 'Jake'
let p = new Person()
p.sayName() // 'My name is Jake'
5.迭代器与生成器方法

类定义语法支持在原型和类本身上定义生成器方法:

class Person {
  // 在原型上定义生成器方法
  *createNicknameIterator() {
    yield 'Jack'
    yield 'Jake'
    yield 'J-Dog'
  }
  // 在类上定义生成器方法
  static *createJobIterator() {
    yield 'Butcher'
    yield 'Baker'
    yield 'Candlestick maker'
  }
}
let jobIter = Person.createJobIterator()
console.log(jobIter.next().value) // 'Butcher'
console.log(jobIter.next().value) // 'Baker'
console.log(jobIter.next().value) // 'Candlestick maker'
let p = new Person()
let nicknameIter = p.createNicknameIterator()
console.log(nicknameIter.next().value) // 'Jack'
console.log(nicknameIter.next().value) // 'Jake'
console.log(nicknameIter.next().value) // 'J-Dog'

因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:

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) // 'Jack' 'Jake' 'J-Dog'
}

也可以只返回迭代器实例:

class Person {
  constructor() {
    this.nicknames = ['Jack', 'Jake', 'J-Dog']
  }
  [Symbol.iterator]() {
    return this.nicknames.entries()
  }
}
let p = new Person()
for (let [idx, nickname] of p) {
  console.log(nickname) // 'Jack', 'Jake', 'J-Dog'
}

8.4.4 继承

1.继承基础

ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有[​[Construct]​]和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数。

class Vehicle {}
// 继承类
class Bus extends Vehicle {}
let b = new Bus()
console.log(b instanceof Bus) // true
console.log(b instanceof Vehicle) // true
function Person() {}
// 继承普通构造函数
class Engineer extends Person {}
let e = new Engineer()
console.log(e instanceof Engineer) // true
console.log(e instanceof Person) // true

派生类都会通过原型链访问到类和原型上定义的方法。this 的值会反映调用相应方法的实例或者类。

注意 extends 关键字也可以在类表达式中使用,因此 let Bar = class extends Foo {}是有效的语法。

2.构造函数、HomeObject 和 super()

派生类的方法可以通过 super 关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用 super 可以调用父类构造函数。在静态方法中可以通过 super 调用继承的类上定义的静态方法。

class Vehicle {
  constructor() {
    this.hasEngine = true
  }
  static identify() {
    console.log('vehicle')
  }
}
class Bus extends Vehicle {
  constructor() {
    // 不要在调用super()之前引用this,否则会抛出ReferenceError
    super() // 相当于super.constructor()
    console.log(this instanceof Vehicle) // true
    console.log(this) // Bus { hasEngine: true }
  }
  static identify() {
    super.identify()
  }
}
new Bus()
Bus.identify() // 'vehicle'

注意 ES6 给类构造函数和静态方法添加了内部特性[​[HomeObject]​]​,这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。super 始终会定义为[​[HomeObject]​]的原型。

super 只能在派生类构造函数和静态方法中使用。
❑ 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法。

class Vehicle {
  constructor() {
    super() // SyntaxError: 'super' keyword unexpected
  }
}
class Bus extends Vehicle {
  constructor() {
    console.log(super);
    // SyntaxError: 'super' keyword unexpected here
  }
}

❑ 调用 super()会调用父类构造函数,并将返回的实例赋值给 this。
❑ super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。

class Vehicle {
  constructor(licensePlate) {
    this.licensePlate = licensePlate
  }
}
class Bus extends Vehicle {
  constructor(licensePlate) {
    super(licensePlate)
    console.log(this instanceof Vehicle)
  }
}
new Bus() // true
console.log(new Bus('1337H4X')) // Bus { licensePlate: '1337H4X' }

❑ 如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的参数。

class Vehicle {
  constructor(licensePlate) {
    this.licensePlate = licensePlate
  }
}
class Bus extends Vehicle {}
console.log(new Bus('1337H4X')) // Bus { licensePlate: '1337H4X' }

❑ 在类构造函数中,不能在调用 super()之前引用 this。
❑ 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象。

3.抽象基类

有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。

// 抽象基类
class Vehicle {
  constructor() {
    console.log(new.target)
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated')
    }
  }
}
// 派生类
class Bus extends Vehicle {}
new Bus() // class Bus {}
new Vehicle() // class Vehicle {}
// Error: Vehicle cannot be directly instantiated

另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过 this 关键字来检查相应的方法:

// 抽象基类
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()')
    }
    console.log('success! ')
  }
}
// 派生类
class Bus extends Vehicle {
  foo() {}
}
// 派生类
class Van extends Vehicle {}
new Bus() // success!
new Van() // Error: Inheriting class must define foo()
4.继承内置类型

有些内置类型的方法会返回新实例。

class SuperArray extends Array {}
let a1 = new SuperArray(1, 2, 3, 4, 5)
let a2 = a1.filter((x) => !!(x % 2))
console.log(a1) // [1, 2, 3, 4, 5]
console.log(a2) // [1, 3, 5]
console.log(a1 instanceof SuperArray) // true
console.log(a2 instanceof SuperArray) // true

如果想覆盖这个默认行为,则可以覆盖 Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类:

class SuperArray extends Array {
  static get [Symbol.species]() {
    returnArray
  }
}
let a1 = new SuperArray(1, 2, 3, 4, 5)
let a2 = a1.filter((x) => !!(x % 2))
console.log(a1) // [1, 2, 3, 4, 5]
console.log(a2) // [1, 3, 5]
console.log(a1 instanceof SuperArray) // true
console.log(a2instanceofSuperArray) //false
5.类混入

Object.assign()方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用 Object.assign()就可以了。


原文地址:https://blog.csdn.net/qq_36081714/article/details/144374180

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!