对象、类与面向对象编程
ECMA-262 将对象定义为一组属性的无序集合
理解对象
属性的类型
ECMA-262 使用一些内部特性来描述属性的特征,属性分为两种:数据属性和访问器属性
1. 数据属性
数据属性包含一个保存数据值的位置,在这个位置可以读取和写入值。数据属性有 4 个特性描述它的行为:
- [[Configurable]]:默认为 true。表示是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性
- [[Enumerable]]:默认为 true。表示属性是否可以通过 for-in 循环返回
- [[Writable]]:默认为 true。表示属性的值是否可以被修改
- [[value]]: 默认为 undefined。包含属性实际的值,即前面提到的那个读取和写入属性值的位置
Object.defineProperty() 修改属性的默认特性,如果不指定 configurable / enumerable / writable,则默认为 false:
let person = {}
Object.defineProperty(person, 'name', {
writable: false,
value: 'Nicholas',
})
2. 访问器属性
访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。访问器属性有 4 个特性描述它的行为:
- [[Configurable]]:默认为 true。表示是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性‘
- [[Enumerable]]:默认为 true。表示属性是否可以通过 for-in 循环返回
- [[Get]]:默认为 undefined。获取函数,在读取属性时调用
- [[Set]]:默认为 undefined。设置函数,在写入属性时调用
访问器属性是不能直接定义的,必须使用 Object.definedProperty():
let book = {
year_: 2004, // 下划线表示该属性并不希望在对象外部被访问
edition: 1,
}
Object.defineProperty(book, 'year', {
get() {
return this.year_
},
set(newValue) {
if (newValue > 2004) {
this.year_ = newValue
this.edition += newValue - 2004
}
},
})
book.year = 2005
console.log(book.edition) // 2
定义多个属性
Object.defineProperties() 可以通过多个描述符一次定义多个属性,如果数据属性不指定 configurable / enumerable / writable,则默认为 false
读取属性的特性
Object.getOwnPropertyDescriptor() 可以取得给定属性的描述符
ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors() 静态方法,可以取得给定对象所有自有属性的描述符
合并对象
Object.assign() 可以把任意多个源对象可枚举的自有属性浅拷贝到目标对象,然后返回目标对象。对每个符合条件的属性,这个方法会使用源对象上的 [[Get]] 取得属性值,然后使用目标对象上的 [[Set]] 设置属性值
const dest = {
set a(val) {
console.log(`Invoked dest setter with param ${val}`)
},
}
const src = {
get a() {
console.log('Invoked src getter')
return 'foo'
},
}
Object.assign(dest, src)
// Invoked src getter
// Invoked dest setter with param foo
console.log(dest)
- 如果多个源对象有相同的属性,则使用最后一个复制的值
- 从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象,即不能在两个对象间转移获取函数和设置函数
- 如果赋值期间出错,则操作会中止并退出,同时抛出错误。因此 Object.assign() 是一个尽力而为、可能只会完成部分复制的方法
对象标识及相等判定
Object.is() 用于比较两个值是否相等,与 === 的行为基本一致,但是它对 NaN 和 +0/-0 作了特殊处理
要检查超过两个值,递归地利用相等性传递即可:
function recursivelyCheckEqual(x, ...rest) {
return Object.is(x, rest[0]) && (rest.length < 2 || recursivelyCheckEqual(...rest))
}
增强的对象语法
ECMAScript 6 为定义和操作对象新增了很多及其有用的语法糖特性:
- 属性值简写
- 可计算属性
- 简写方法名
对象解构
对象解构就是使用与对象匹配的结构来实现对象属性赋值
如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中,否则会被当成一个代码块:
let personName, personAge
let person = {
name: 'Nicholas',
age: 29,
}
;({ name: personName, age: personAge } = person)
- 嵌套解构
- 部分解构:需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分
- 参数上下文匹配:在函数参数列表中也可以进行解构赋值
创建对象
使用 Object 构造函数和对象字面量可以方便的创建对象,但是这种方式有个缺点:创建具有同样接口的多个对象需要重复编写很多代码
工厂模式
工厂模式用于抽象创建特定对象的过程
下面的例子展示了一种按照特定接口创建对象的方式:
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')
这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)
构造函数模式
ECMAScript 中的构造函数就是用于创建特定类型对象的
构造函数不一定要写成函数声明的形式,赋值给变量的函数表达式也可以
使用 new 操作符调用构造函数会执行如下操作:
- 在内存中创建一个新对象
- 这个新对象内部的 [[Prototype]] 特性被赋值为构造函数的 prototype 属性
- this 指向新对象
- 执行构造函数内部的代码
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
instanceof
操作符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上
构造函数的问题在于其定义的方法会在每个实例上都创建一遍
原型模式
每个函数都会创建一个 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 指向构造函数,实例对象.__proto__ 指向原型对象
isPrototypeOf()
方法用于检测当前对象是否是传入对象的原型
Object.getPrototypeOf()
方法返回传入对象的原型
Object.setPrototypeOf()
方法用于设置传入对象的原型,但其可能会严重影响性能,因为其涉及所有访问了那些修改过 [[Prototype]] 的对象的代码
Object.create()
方法创建一个新对象,将参数作为新创建的对象的__proto__
2. 原型层级
hasOwnProperty()
方法用于确定某个属性实在实例上还是在原型对象上,继承自 Object
Object.getOwnPropertyDescriptor()
方法也只对实例有效
3. 原型和 in 操作符
有两张方式使用 in 操作符:
- 单独使用,会在可以通过对象访问指定属性时返回 true,无论该属性存在于实例还是原型中
- 在 for-in 循环中使用
单独使用 in 结合 'hasOwnProperty()' 方法可以确定属性是否存在于原型上:
function hasPrototypeProperty(object, name) {
return !object.hasOwnProperty(name) && name in object
}
在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回
Object.keys()
方法返回对象上所有可枚举的实例属性
Object.getOwnPropertyNames()
方法返回对象上所有实例属性,无论是否可枚举
Object.getOwnPropertySymbols()
方法同上,但是只针对符号
4. 属性枚举顺序
- for-in 和 Object.keys() 的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异
- Object.getOwnPropertyNames() / Object.getOwnPropertySymbols() / Object.assign() 的枚举顺序是确定的,先以升序枚举数值键,然后以插入顺序枚举字符串和符号键,在对象字面量中定义的键以它们逗号分隔的顺序插入
const k1 = Symbol('k1')
const k2 = Symbol('k2')
let o = {
1: 1,
first: 'first',
[k2]: 'k2',
second: 'second',
0: 0,
}
o[k1] = 'k1'
o[3] = 3
o.third = 'third'
o[2] = 2
console.log(Object.getOwnPropertyNames(o)) // ['0', '1', '2', '3', 'first', 'second', 'third']
console.log(Object.getOwnPropertySymbols(o)) // [Symbol(k2), Symbol(k1)]
对象迭代
Object.values() / Object.entires
:非字符串属性会被转换为字符串输出,且执行对象的浅复制,符号属性会被忽略
1. 其他原型语法
function Person() {}
Person.prototype = {
name: 'Nicholas',
age: 29,
job: 'Software Engineer',
sayName() {
console.log(this.name)
},
}
// 恢复 constructor 属性
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person,
})
2. 原生对象原型
不推荐在产品环境中修改原生对象原型,而是创建一个自定义类继承原生类型
3. 原型的问题
最主要的问题源自它的共享特性,针对包含引用值的属性,会导致实例间相互影响,故通常不单独使用原型模式
继承
实现继承是 ECMAScript 唯一支持的继承方式,且主要通过原型链实现
原型链
ECMA-262 把原型链定义为 ECMAScript 的主要继承方式,其基本思想是通过原型继承多个引用类型的属性和方法
1. 默认原型
任何函数的默认原型都是一个 Object 的实例
2. 原型与继承关系
确定原型与实例的关系:
instanceof
操作符,检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上isPrototypeOf()
方法,如果传入的对象是实例的原型,则返回 true
3. 原型链的问题
- 主要问题在于原型中包含的引用值会在所有实例间共享
- 子类型在实例化时不能给父类型的构造函数传参
这些问题导致原型链基本不会单独使用
盗用构造函数
为了解决原型包含引用值导致继承问题,一种叫作“盗用构造函数(constructor stealing)”的技术在开发社区流行起来,有时也称作“对象伪装”或“经典继承”
基本思路:在子类构造函数中调用父类构造函数
function SuperType() {
this.colors = ['red', 'blue', 'green']
}
function SubType() {
// 继承了 SuperType
SuperType.call(this)
}
let instance1 = new SubType()
1. 传递参数
相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参
2. 盗用构造函数的问题
主要缺点:必须在构造函数中定义方法,导致函数不能重用。此外子类也不能访问父类原型上定义的方法,导致所有类型只能使用构造函数模式
故“盗用构造函数”基本上也不能单独使用
组合继承
组合继承(伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来
基本思路:通过原型链继承原型上的属性和方法,通过盗用构造函数继承实例属性
这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性
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)
}
原型式继承
原型式继承的思路:即使不自定义类型也可以通过原型实现对象之间的信息共享
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合
ECMAScript 5 通过 Object.create() 方法将原型式继承的概念规范化了
const person = {
name: 'Nicholas',
friends: ['Shelby', 'Court', 'Van'],
}
const anotherPerson = Object.create(person, {
name: {
value: 'Greg',
},
})
寄生式继承
寄生式继承的思路:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象
function createAnother(original) {
const clone = Object.create(original) // 通过调用函数创建一个新对象(任何返回新对象的函数都可以在这里使用)
clone.sayHi = function () {
// 以某种方式增强这个对象
console.log('hi')
}
return clone // 返回这个对象
}
通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似
寄生式组合继承
组合继承存在效率问题:父类构造函数始终会被调用两次(一次是创建子类原型时调用,另一次是在子类构造函数中调用),且子类的原型上会存在多余的属性(构造函数中的)
寄生式组合继承的基本思路:通过盗用构造函数来继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本
function inheritPrototype(subType, superType) {
const 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)
}
类
ECMAScript 6 类表面上看起来可以支持正式第面向对象编程,但实际上背后使用的仍然是原型和构造函数的概念
类定义
类定义主要有两种方式:类声明和类表达式
// 类声明
class Person {}
// 类表达式
const Animal = class {}
默认情况下,类定义中的代码都在严格模式下执行
类构造函数与构造函数的主要区别:类构造函数必须使用 new 操作符, 否则会抛出错误;而普通构造函数如果不使用 new 调用,那么就会以全局的 this 作为内部对象
从各方面看,ECMAScript 类就是一种特殊函数
typeof 类名 === ’function‘
- 类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身
- 可以使用 instanceof 操作符检测构造函数原型是否存在于实例的原型链中
类是 JavaScript 的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递
实例、原型和类成员
1. 实例成员
通过 new 调用类标识符,都会执行构造函数。在这个函数的内部,可以为新创建的实例添加“自有”属性
2. 原型方法与访问器
为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法
类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键
类定义也支持获取和设置访问器,语法与行为跟普通对象一样:
class Person {
get name() {
return this.name_
}
set name(newName) {
this.name_ = newName
}
}
3. 静态类方法
静态类成员在类定义中使用 static 关键字作为前缀。在静态成员中,this 引用类自身
静态类方法非常适合作为实例工厂:
class Person {
constructor(age) {
this.age_ = age
}
sayAge() {
console.log(this.age_)
}
static create() {
return new Person(Math.floor(Math.random() * 100))
}
}
4. 迭代器与生成器方法
类定义语法支持在原型和类本身上定义生成器方法,所以可以通过一个默认的迭代器,把类实例变成可迭代对象
class Person {
constructor() {
this.nickNames = ['di', 'diqiu', 'didi']
}
// *[Symbol.iterator]() {
// yield* this.nickNames
// }
// 也可以只返回迭代器实例
[Symbol.iterator]() {
return this.nickNames.values()
}
}
const p = new Person()
for (const nickName of p) {
console.log(nickName)
}
继承
ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链
1. 继承基础
ES6 类支持单继承,使用 extends 关键字,就可以继承任何拥有 [[Construct]] 和原型的对象(类和普通的构造函数)
派生类都会通过原型链访问到类和原型上定义的方法
2. 构造函数、HomeObject 和 super()
派生类的方法可以通过 super 关键字引用它们的原型
super 关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部
在类构造函数中使用 super 可以调用父类构造函数:
class Vehicle {
constructor() {
this.hasEngine = true
}
}
class Bus extends Vehicle {
constructor() {
/* 不要在 super 之前引用 this,否则会抛出 ReferenceError */
super() // 相当于 super.constructor()
console.log(this instanceof Vehicle) // true
console.log(this) // Bus {hasEngine: true}
}
}
new Bus()
在静态方法中可以通过 super 调用继承的类上的静态方法:
class Vehicle {
static identify() {
console.log('vehicle')
}
}
class Bus extends Vehicle {
static identify() {
super.identify()
}
}
Bus.identify() // vehicle
ES6 给类构造函数和静态方法内部添加了内部属性 [[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。super 始终会定义为 [[HomeObject]] 的原型
在使用 super 时需要注意几个问题:
- super 只能在派生类的构造函数和静态方法中使用
- 不能单独使用 super 关键字。要么用它调用构造函数,要么用它引用静态方法
- 调用 super() 会调用父类的构造函数,并将返回的实例赋值给 this
- 如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的参数
- 在类构造函数中,不能在调用 super() 之前引用 this
- 如果在派生类中显示定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象
3. 抽象基类
有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化
虽然 ECMAScript 没有专门支持这种类的语法,但通过 new.target(保存通过 new 关键字调用的类或函数) 也很容易实现
通过在实例化时检测 new.target 是不是抽象基类,可以阻止对抽象基类实例化:
// 抽象基类
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() // Bus {}
new Vehicle() // Uncaught Error: Vehicle cannot be directly instantiated
另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法(原型方法在嗲用类构造函数之前就已经存在了):
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() // Uncaught Error: Inheriting class must define foo()
4. 继承内置类型
ES6 类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:
有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型时一致的。如果想覆盖这个默认行为,则可以覆盖 Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类:
class SuperArray extends Array {
static get [Symbol.species]() {
return Array
}
}
const a1 = new SuperArray(1, 2, 3, 4)
const a2 = a1.map(x => x * x)
console.log(a1 instanceof SuperArray) // true
console.log(a2 instanceof SuperArray) // false
5. 类混入
把不同类的行为集中到一个类是一种常见的 JavaScript 模式。虽然 ES6 没有显示支持多类继承,但通过现有特性可以轻松地模拟这种行为
注意
Object.assign() 方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用 Object.assign() 就足够了
很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不是继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承”