"Code tailor",为前端开发者提供技术相关资讯以及系列基础文章,微信关注“小和山的菜鸟们”公众号,及时获取最新文章。
前言
在开始学习之前,我们想要告诉您的是,本文章是对JavaScript
语言知识中 "对象、类与面向对象编程" 部分的总结,如果您已掌握下面知识事项,则可跳过此环节直接进入题目练习
- 对象的基本构造
- 对象声明及使用
- 类
- 对象的结构赋值
- 继承
- 包装对象
如果您对某些部分有些遗忘,👇🏻 已经为您准备好了!
汇总总结
ECMA-262
将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。正因为如此(以及其他还未讨论的原因),可以把ECMAScript
的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数。
对象的基本构造
创建自定义对象的通常方式是创建 Object 的一个新实例,然后再给它添加属性和方法,如下例 所示:
let person = new Object()
person.name = 'XHS-rookies'
person.age = 18
person.job = 'Software Engineer'
person.sayName = function () {
console.log(this.name)
}
这个例子创建了一个名为 person
的对象,而且有三个属性(name
、age
和 job
)和一个方法(sayName()
)。sayName()
方法会显示 this.name
的值,这个属性会解析为 person.name
。早期JavaScript
开发者频繁使用这种方式创建新对象。几年后,对象字面量变成了更流行的方式。前面的例子如果使用对象字面量则可以这样写:
let person = {
name: 'XHS-rookies',
age: 18,
job: 'Software Engineer',
sayName() {
console.log(this.name)
},
}
这个例子中的 person
对象跟前面例子中的 person
对象是等价的,它们的属性和方法都一样。这些属性都有自己的特征,而这些特征决定了它们在 JavaScript
中的行为。
对象声明及使用
综观 ECMAScript
规范的历次发布,每个版本的特性似乎都出人意料。ECMAScript 5.1
并没有正式 支持面向对象的结构,比如类或继承。但是,正如接下来几节会介绍的,巧妙地运用原型式继承可以成 功地模拟同样的行为。ECMAScript 6
开始正式支持类和继承。ES6
的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,ES6
的类都仅仅是封装了ES5.1
构造函数加原型继承的语法糖而已。
工厂模式
工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。下面的例子展示了一种按照特定接口创建对象的方式:
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('XHS-rookies', 18, 'Software Engineer')
let person2 = createPerson('XHS-boos', 18, 'Teacher')
这里,函数 createPerson()
接收 3 个参数,根据这几个参数构建了一个包含 Person
信息的对象。可以用不同的参数多次调用这个函数,每次都会返回包含 3 个属性和 1 个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。
构造函数模式
ECMAScript
中的构造函数是用于创建特定类型对象的。像 Object
和 Array
这 样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为 自己的对象类型定义属性和方法。 比如,前面的例子使用构造函数模式可以这样写:
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('XHS-rookies', 18, 'Software Engineer')
let person2 = new Person('XHS-boos', 18, 'Teacher')
person1.sayName() // XHS-rookies
person2.sayName() // XHS-boos
在这个例子中,Person()
构造函数代替了createPerson()
工厂函数。实际上,Person()
内部 的代码跟 createPerson()
基本是一样的,只是有如下区别。
- 没有显式地创建对象。
- 属性和方法直接赋值给了
this
。 - 没有
return
。
另外,要注意函数名 Person
的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的, 非构造函数则以小写字母开头。这是从面向对象编程语言那里借鉴的,有助于在 ECMAScript
中区分构 造函数和普通函数。毕竟 ECMAScript
的构造函数就是能创建对象的函数。
要创建 Person
的实例,应使用 new
操作符。以这种方式调用构造函数会执行如下操作。
(1)在内存中创建一个新对象。
(2)这个新对象内部的 [[Prototype]]
特性被赋值为构造函数的 prototype
属性。
(3)构造函数内部的 this
被赋值为这个新对象(即 this
指向新对象)。
(4)执行构造函数内部的代码(给新对象添加属性)。
(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
上一个例子的最后,person1
和 person2
分别保存着 Person
的不同实例。这两个对象都有一个 constructor
属性指向 Person
,如下所示:
console.log(person1.constructor == Person) // true
console.log(person2.constructor == Person) // true
constructor
本来是用于标识对象类型的。不过,一般认为 instanceof
操作符是确定对象类型更可靠的方式。前面例子中的每个对象都是 Object
的实例,同时也是 Person
的实例,如下面调用 instanceof
操作符的结果所示:
console.log(person1 instanceof Object) // true
console.log(person1 instanceof Person) // true
console.log(person2 instanceof Object) // true
console.log(person2 instanceof Person) // true
定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。在 这个例子中,person1
和 person2
之所以也被认为是 Object
的实例,是因为所有自定义对象都继承自 Object
(后面再详细讨论这一点)。构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数:
let Person = function (name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = function () {
console.log(this.name)
}
}
let person1 = new Person('XHS-rookies', 18, 'Software Engineer')
let person2 = new Person('XHS-boos', 18, 'Teacher')
person1.sayName() // XHS-rookies
person2.sayName() // XHS-boos
console.log(person1 instanceof Object) // true
console.log(person1 instanceof Person) // true
console.log(person2 instanceof Object) // true
console.log(person2 instanceof Person) // true
在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。只要有 new
操作符,就可以调用相应的构造函数:
function Person() {
this.name = 'rookies'
this.sayName = function () {
console.log(this.name)
}
}
let person1 = new Person()
let person2 = new Person()
person1.sayName() // rookies
person2.sayName() // rookies
console.log(person1 instanceof Object) // true
console.log(person1 instanceof Person) // true
console.log(person2 instanceof Object) // true
console.log(person2 instanceof Person) // true
1. 构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。并没有把某个函数定义为构造函数的特殊语法。任何函数只要使用 new
操作符调用就是构造函数,而不使用 new
操作符调用的函数就是普通函数。比如,前面的例子中定义的 Person()
可以像下面这样调用:
// 作为构造函数
let person = new Person('XHS-rookies', 18, 'Software Engineer')
person.sayName() // "XHS-rookies"
// 作为函数调用
Person('XHS-boos', 18, 'Teacher') // 添加到 window 对象
window.sayName() // "XHS-boos"
// 在另一个对象的作用域中调用
let o = new Object()
Person.call(o, 'XHS-sunshineboy', 25, 'Nurse')
o.sayName() // "XHS-sunshineboy"
这个例子一开始展示了典型的构造函数调用方式,即使用 new
操作符创建一个新对象。然后是普通函数的调用方式,这时候没有使用 new
操作符调用 Person()
,结果会将属性和方法添加到 window
对象。这里要记住,在调用一个函数而没有明确设置 this
值的情况下(即没有作为对象的方法调用,或 者没有使用 call()/apply()
调用),this
始终指向 Global
对象(在浏览器中就是 window
对象)。 因此在上面的调用之后,window
对象上就有了一个 sayName()
方法,调用它会返回 "Greg"
。最后展示的调用方式是通过 call()
(或apply()
)调用函数,同时将特定对象指定为作用域。这里的调用将 对象 o
指定为 Person()
内部的 this
值,因此执行完函数代码后,所有属性和 sayName()
方法都会添加到对象 o
上面。
2. 构造函数的问题
构造函数虽然有用,但也不是没有问题。构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。因此对前面的例子而言,person1
和 person2
为 sayName()
的方法,但这两个方法不是同一个 Function
实例。我们知道,ECMAScript
中的函数是对象,因此每次定义函数时,都会初始化一个对象。逻辑上讲,这个构造函数实际上是这样的:
function Person(name, age, job) {
this.name = name
this.age = age
this.job = job
this.sayName = new Function('console.log(this.name)') // 逻辑等价
}
这样理解这个构造函数可以更清楚地知道,每个 Person
实例都会有自己的 Function
实例用于显 示 name
属性。当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新 Function
实例的机制是一样的。因此不同实例上的函数虽然同名却不相等,如下所示:
console.log(person1.sayName == person2.sayName) // false
因为都是做一样的事,所以没必要定义两个不同的 Function
实例。况且,this
对象可以把函数 与对象的绑定推迟到运行时。 要解决这个问题,可以把函数定义转移到构造函数外部:
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('XHS-rookies', 18, 'Software Engineer')
let person2 = new Person('XHS-boos', 18, 'Teacher')
person1.sayName() // XHS-rookies
person2.sayName() // XHS-boos
在这里,sayName()
被定义在了构造函数外部。在构造函数内部,sayName
属性等于全局 sayName()
函数。因为这一次 sayName
属性中包含的只是一个指向外部函数的指针,所以 person1
和 person2
共享了定义在全局作用域上的 sayName()
函数。这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法, 那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。
原型模式
每个函数都会创建一个 prototype
属性,这个属性是一个对象,包含应该由特定引用类型的实例 共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型,如下所示:
function Person() {}
Person.prototype.name = 'XHS-rookies'
Person.prototype.age = 18
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
console.log(this.name)
}
let person1 = new Person()
person1.sayName() // "XHS-rookies"
let person2 = new Person()
person2.sayName() // "XHS-rookies"
console.log(person1.sayName == person2.sayName) // true
使用函数表达式也可以:
let Person = function () {}
Person.prototype.name = 'XHS-rookies'
Person.prototype.age = 18
Person.prototype.job = 'Software Engineer'
Person.prototype.sayName = function () {
console.log(this.name)
}
let person1 = new Person()
person1.sayName() // "XHS-rookies"
let person2 = new Person()
person2.sayName() // "XHS-rookies"
console.log(person1.sayName == person2.sayName) // true
这里,所有属性和 sayName()
方法都直接添加到了Person
的 prototype
属性上,构造函数体中什么也没有。但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法。与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1
和 person2
访问的都是相同的属性和相同的 sayName()
函数。要理解这个过程,就必须理解 ECMAScript
中原型的本质。(详细学习 ECMAScript
中的原型请见:对象原型)
其他原型语法
有读者可能注意到了,在前面的例子中,每次定义一个属性或方法都会把 Person.prototype
重写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法 的对象字面量来重写原型成为了一种常见的做法,如下面的例子所示:
function Person() {}
Person.prototype = {
name: 'XHS-rookies',
age: 18,
job: 'Software Engineer',
sayName() {
console.log(this.name)
},
}
在这个例子中,Person.prototype
被设置为等于一个通过对象字面量创建的新对象。最终结果是一样的,只有一个问题:这样重写之后,Person.prototype
的 constructor
属性就不指向 Person
了。在创建函数时,也会创建它的prototype
对象,同时会自动给这个原型的 constructor
属性赋值。而上面的写法完全重写了默认的prototype
对象,因此其 constructor
属性也指向了完全不同的新对象(Object
构造函数),不再指向原来的构造函数。虽然 instanceof
操作符还能可靠地返回值,但我们不能再依靠 constructor
属性来识别类型了,如下面的例子所示:
let friend = new Person()
console.log(friend instanceof Object) // true
console.log(friend instanceof Person) // true
console.log(friend.constructor == Person) // false
console.log(friend.constructor == Object) // true
这里,instanceof
仍然对 Object
和 Person
都返回 true
。但 constructor
属性现在等于 Object
而不是 Person
了。如果constructor
的值很重要,则可以像下面这样在重写原型对象时专门设置一 下它的值:
function Person() {}
Person.prototype = {
constructor: Person,
name: 'XHS-rookies',
age: 18,
job: 'Software Engineer',
sayName() {
console.log(this.name)
},
}
这次的代码中特意包含了 constructor
属性,并将它设置为 Person
,保证了这个属性仍然包含恰当的值。 但要注意,以这种方式恢复 constructor
属性会创建一个 [[Enumerable]]
为 true
的属性。而原生 constructor
属性默认是不可枚举的。因此,如果你使用的是兼容 ECMAScript
的 JavaScript
引擎, 那可能会改为使用 Object.defineProperty()
方法来定义 constructor
属性:
function Person() {}
Person.prototype = {
name: 'XHS-rookies',
age: 18,
job: 'Software Engineer',
sayName() {
console.log(this.name)
},
}
// 恢复 constructor 属性
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person,
})
类
前几节深入讲解了如何只使用 ECMAScript 5
的特性来模拟类似于类(class-like
)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。
为解决这些问题,ECMAScript 6
新引入的class
关键字具有正式定义类的能力。类(class
)是 ECMAScript
中新的基础性语法糖结构,因此刚开始接触时可能会不太习惯。虽然 ECMAScript 6
类表面 上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。
类定义
与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用 class
关键 字加大括号:
// 类声明
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
类的构成
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。 空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。
与函数构造函数一样,多数编程风格都建议类名的首字母要大写,以区别于通过它创建的实例(比如,通过 class Foo {}
创建实例 foo
):
// 空类定义,有效
class Foo {}
// 有构造函数的类,有效
class Bar {
constructor() {}
}
// 有获取函数的类,有效
class Baz {
get myBaz() {}
}
// 有静态方法的类,有效
class Qux {
static myQux() {}
}
类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 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
类构造函数
constructor
关键字用于在类定义块内部创建类的构造函数。方法名 constructor
会告诉解释器 在使用 new
操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函 数相当于将构造函数定义为空函数。
实例化
使用 new
操作符实例化 Person 的操作等于使用 new
调用其构造函数。唯一可感知的不同之处就 是,JavaScript
解释器知道使用 new
和类意味着应该使用 constructor
函数进行实例化。 使用 new
调用类的构造函数会执行如下操作。
(1)在内存中创建一个新对象。
(2)这个新对象内部的 [[Prototype]]
指针被赋值为构造函数的 prototype
属性。
(3)构造函数内部的 this
被赋值为这个新对象(即 this
指向新对象)。
(4)执行构造函数内部的代码(给新对象添加属性)。
(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
来看下面的例子:
class Animal {}
class Person {
constructor() {
console.log('person ctor')
}
}
class Vegetable {
constructor() {
this.color = 'orange'
}
}
let a = new Animal()
let p = new Person() // person ctor
let v = new Vegetable()
console.log(v.color) // orange
类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的:
class Person {
constructor(name) {
console.log(arguments.length)
this.name = name || null
}
}
let p1 = new Person() // 0
console.log(p1.name) // null
let p2 = new Person() // 0
console.log(p2.name) // null
let p3 = new Person('Jake') // 1
console.log(p3.name) // Jake
默认情况下,类构造函数会在执行之后返回 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
则会抛出错误:
function Person() {}
class Animal {}
// 把 window 作为 this 来构建实例
let p = Person()
let a = Animal()
// TypeError: class constructor Animal cannot be invoked without 'new'
类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用 new
调用)。因此,实例化之后可以在实例上引用它:
class Person {}
// 使用类创建一个新实例
let p1 = new Person()
p1.constructor()
// TypeError: Class constructor Person cannot be invoked without 'new'
// 使用对类构造函数的引用创建一个新实例
let p2 = new p1.constructor()
实例、原型和类成员
类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在 于类本身的成员。
1. 实例成员
每次通过 new
调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this
) 添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给 实例继续添加新成员。
每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:
class Person {
constructor() {
// 这个例子先使用对象包装类型定义一个字符串
// 为的是在下面测试两个对象的相等性
this.name = new String('xhs-rookies')
this.sayName = () => console.log(this.name)
this.nicknames = ['xhs-rookies', 'J-Dog']
}
}
let p1 = new Person(),
p2 = new Person()
p1.sayName() // xhs-rookies
p2.sayName() // xhs-rookies
console.log(p1.name === p2.name) // false
console.log(p1.sayName === p2.sayName) // false
console.log(p1.nicknames === p2.nicknames) // false
p1.name = p1.nicknames[0]
p2.name = p2.nicknames[1]
p1.sayName() // xhs-rookies
p2.sayName() // J-Dog
2. 原型方法与访问器
为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。
class Person {
constructor() {
// 添加到 this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance')
}
// 在类块中定义的所有内容都会定义在类的原型上
locate() {
console.log('prototype')
}
}
let p = new Person()
p.locate() // instance
Person.prototype.locate() // prototype
可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据:
class Person {
name: 'xhs-rookies'
}
// Uncaught SyntaxError: Unexpected token
类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键:
const symbolKey = Symbol('symbolKey')
class Person {
stringKey() {
console.log('invoked stringKey')
}
[symbolKey]() {
console.log('invoked symbolKey')
}
['computed' + 'Key']() {
console.log('invoked computedKey')
}
}
let p = new Person()
p.stringKey() // invoked stringKey
p[symbolKey]() // invoked symbolKey
p.computedKey() // invoked computedKey
类定义也支持获取和设置访问器。语法与行为跟普通对象一样:
class Person {
set name(newName) {
this.name_ = newName
}
get name() {
return this.name_
}
}
let p = new Person()
p.name = 'xhs-rookies'
console.log(p.name) // xhs-rookies
3. 静态类方法
可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。与原型成员类似,静态成员每个类上只能有一个。 静态类成员在类定义中使用 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 = 'xhs-rookies'
let p = new Person()
p.sayName() // My name is xhs-rookies
注意 类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添 加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过this
引用的数据(注意在不同情况下使用this
的情况会略有些不同,详细this
学习请见this-MDN)。
5. 迭代器与生成器方法
类定义语法支持在原型和类本身上定义生成器方法:
class Person {
// 在原型上定义生成器方法
*createNicknameIterator() {
yield 'xhs-Jack'
yield 'xhs-Jake'
yield 'xhs-J-Dog'
}
// 在类上定义生成器方法
static *createJobIterator() {
yield 'xhs-Butcher'
yield 'xhs-Baker'
yield 'xhs-Candlestick maker'
}
}
let jobIter = Person.createJobIterator()
console.log(jobIter.next().value) // xhs-Butcher
console.log(jobIter.next().value) // xhs-Baker
console.log(jobIter.next().value) // xhs-Candlestick maker
let p = new Person()
let nicknameIter = p.createNicknameIterator()
console.log(nicknameIter.next().value) // xhs-Jack
console.log(nicknameIter.next().value) // xhs-Jake
console.log(nicknameIter.next().value) // xhs-J-Dog
因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:
class Person {
constructor() {
this.nicknames = ['xhs-Jack', 'xhs-Jake', 'xhs-J-Dog']
}
*[Symbol.iterator]() {
yield* this.nicknames.entries()
}
}
let p = new Person()
for (let [idx, nickname] of p) {
console.log(nickname)
}
// xhs-Jack
// xhs-Jake
// xhs-J-Dog
//也可以只返回迭代器实例:
class Person {
constructor() {
this.nicknames = ['xhs-Jack', 'xhs-Jake', 'xhs-J-Dog']
}
[Symbol.iterator]() {
return this.nicknames.entries()
}
}
let p = new Person()
for (let [idx, nickname] of p) {
console.log(nickname)
}
// xhs-Jack
// xhs-Jake
// xhs-J-Dog
对象的解构赋值
ECMAScript 6
新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。 下面的例子展示了两段等价的代码,首先是不使用对象解构的:
// 不使用对象解构
let person = {
name: 'xhs-Matt',
age: 18,
}
let personName = person.name,
personAge = person.age
console.log(personName) // xhs-Matt
console.log(personAge) // 18
然后,是使用对象解构的:
// 使用对象解构
let person = {
name: 'xhs-Matt',
age: 18,
}
let { name: personName, age: personAge } = person
console.log(personName) // xhs-Matt
console.log(personAge) // 18
使用解构,可以在一个类似对象字面量的结构中,声明多个变量,同时执行多个赋值操作。如果想让变量直接使用属性的名称,那么可以使用简写语法,比如:
let person = {
name: 'xhs-Matt',
age: 18,
}
let { name, age } = person
console.log(name) // xhs-Matt
console.log(age) // 18
解构不成功以及对象解构可以指定一些默认值的情况,这些详细内容可以见我们的解构赋值文章,在对象中我们不过多赘述。
继承
本章前面花了大量篇幅讨论如何使用 ES5
的机制实现继承。ECMAScript 6
新增特性中最出色的一 个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。
继承基础
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
的值会反映调用相应方法的实例或者类:
class Vehicle {
identifyPrototype(id) {
console.log(id, this)
}
static identifyClass(id) {
console.log(id, this)
}
}
class Bus extends Vehicle {}
let v = new Vehicle()
let b = new Bus()
b.identifyPrototype('bus') // bus, Bus {}
v.identifyPrototype('vehicle') // vehicle, Vehicle {}
Bus.identifyClass('bus') // bus, class Bus {}
Vehicle.identifyClass('vehicle') // vehicle, class Vehicle {}
注意: extends
关键字也可以在类表达式中使用,因此 let Bar = class extends Foo {}
是有效的语法。
构造函数、HomeObject 和 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
只能在派生类构造函数和静态方法中使用。
class Vehicle {
constructor() {
super()
// SyntaxError: 'super' keyword unexpected
}
}
- 不能单独引用
super
关键字,要么用它调用构造函数,要么用它引用静态方法。
class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(super)
// SyntaxError: 'super' keyword unexpected here
}
}
- 调用
super()
会调用父类构造函数,并将返回的实例赋值给this
。
class Vehicle {}
class Bus extends Vehicle {
constructor() {
super()
console.log(this instanceof Vehicle)
}
}
new Bus() // true
super()
的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
class Vehicle {
constructor(licensePlate) {
this.licensePlate = licensePlate
}
}
class Bus extends Vehicle {
constructor(licensePlate) {
super(licensePlate)
}
}
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
。
class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(this)
}
}
new Bus()
// ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor
- 如果在派生类中显式定义了构造函数,则要么必须在其中调用
super()
,要么必须在其中返回 一个对象。
class Vehicle {}
class Car extends Vehicle {}
class Bus extends Vehicle {
constructor() {
super()
}
}
class Van extends Vehicle {
constructor() {
return {}
}
}
console.log(new Car()) // Car {}
console.log(new Bus()) // Bus {}
console.log(new Van()) // {}
包装对象
原始值包装类型
为了方便操作原始值,ECMAScript
提供了 3 种特殊的引用类型:Boolean
、Number
和 String
。 这些类型具有本章介绍的其他引用类型一样的特点,但也具有与各自原始类型对应的特殊行为。每当用到某个原始值的方法或属性时,后台都会创建一个相应原始包装类型的对象,从而暴露出操作原始值的 各种方法。来看下面的例子:
let s1 = 'xhs-rookies'
let s2 = s1.substring(2)
在这里,s1
是一个包含字符串的变量,它是一个原始值。第二行紧接着在 s1
上调用了 substring()
方法,并把结果保存在 s2
中。我们知道,原始值本身不是对象,因此逻辑上不应该有方法。而实际上 这个例子又确实按照预期运行了。这是因为后台进行了很多处理,从而实现了上述操作。具体来说,当 第二行访问 s1
时,是以读模式访问的,也就是要从内存中读取变量保存的值。在以读模式访问字符串 值的任何时候,后台都会执行以下 3 步:
(1)创建一个 String
类型的实例;
(2)调用实例上的特定方法;
(3)销毁实例。
可以把这 3 步想象成执行了如下 3 行 ECMAScript
代码:
let s1 = new String('xhs-rookies')
let s2 = s1.substring(2)
s1 = null
这种行为可以让原始值拥有对象的行为。对布尔值和数值而言,以上 3 步也会在后台发生,只不过 使用的是 Boolean
和 Number
包装类型而已。 引用类型与原始值包装类型的主要区别在于对象的生命周期。在通过 new
实例化引用类型后,得到 的实例会在离开作用域时被销毁,而自动创建的原始值包装对象则只存在于访问它的那行代码执行期 间。这意味着不能在运行时给原始值添加属性和方法。比如下面的例子:
let s1 = 'xhs-rookies'
s1.color = 'red'
console.log(s1.color) // undefined
这里的第二行代码尝试给字符串 s1 添加了一个 color
属性。可是,第三行代码访问 color
属性时, 它却不见了。原因就是第二行代码运行时会临时创建一个 String
对象,而当第三行代码执行时,这个对象已经被销毁了。实际上,第三行代码在这里创建了自己的 String
对象,但这个对象没有 color
属性。
可以显式地使用 Boolean
、Number
和String
构造函数创建原始值包装对象。不过应该在确实必 要时再这么做,否则容易让开发者疑惑,分不清它们到底是原始值还是引用值。在原始值包装类型的实 例上调用 typeof
会返回 "object"
,所有原始值包装对象都会转换为布尔值true
。
另外,Object
构造函数作为一个工厂方法,能够根据传入值的类型返回相应原始值包装类型的实 例。比如:
let obj = new Object('xhs-rookies')
console.log(obj instanceof String) // true
如果传给 Object
的是字符串,则会创建一个 String
的实例。如果是数值,则会创建 Number
的 实例。布尔值则会得到 Boolean
的实例。
注意,使用 new
调用原始值包装类型的构造函数,与调用同名的转型函数并不一样。例如:
let value = '18'
let number = Number(value) // 转型函数
console.log(typeof number) // "number"
let obj = new Number(value) // 构造函数
console.log(typeof obj) // "object"
在这个例子中,变量 number
中保存的是一个值为 25 的原始数值,而变量 obj
中保存的是一个 Number
的实例。
虽然不推荐显式创建原始值包装类型的实例,但它们对于操作原始值的功能是很重要的。每个原始值包装类型都有相应的一套方法来方便数据操作。
题目自测
一:所有对象都有原型。
- A: 对
- B: 错
二:以下哪一项会对对象 person 有副作用?
const person = {
name: 'Lydia Hallie',
address: {
street: '100 Main St',
},
}
Object.freeze(person)
- A:
person.name = "Evan Bacon"
- B:
delete person.address
- C:
person.address.street = "101 Main St"
- D:
person.pet = { name: "Mara" }
三:使用哪个构造函数可以成功继承Dog
类?
class Dog {
constructor(name) {
this.name = name
}
}
class Labrador extends Dog {
// 1
constructor(name, size) {
this.size = size
}
// 2
constructor(name, size) {
super(name)
this.size = size
}
// 3
constructor(size) {
super(name)
this.size = size
}
// 4
constructor(name, size) {
this.name = name
this.size = size
}
}
- A: 1
- B: 2
- C: 3
- D: 4
题目解析
一、
Answer:B
除了基本对象(base object
),所有对象都有原型。基本对象可以访问一些方法和属性,比如 .toString
。这就是为什么你可以使用内置的 JavaScript
方法!所有这些方法在原型上都是可用的。虽然JavaScript
不能直接在对象上找到这些方法,但 JavaScript
会沿着原型链找到它们,以便于你使用。
二、
Answer:C
使用方法 Object.freeze
对一个对象进行 冻结。不能对属性进行添加,修改,删除。
然而,它仅对对象进行浅冻结,意味着只有 对象中的 直接 属性被冻结。如果属性是另一个 object
,像案例中的 address
,address
中的属性没有被冻结,仍然可以被修改。
三、
Answer:B
在子类中,在调用 super
之前不能访问到 this
关键字。 如果这样做,它将抛出一个 ReferenceError:1
和 4 将引发一个引用错误。
使用 super
关键字,需要用给定的参数来调用父类的构造函数。 父类的构造函数接收 name
参数,因此我们需要将 name
传递给 super
。
Labrador
类接收两个参数,name
参数是由于它继承了 Dog
,size
作为 Labrador
类的额外属性,它们都需要传递给 Labrador
的构造函数,因此使用构造函数 2 正确完成。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。