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

ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。

1 UNDERSTANDING OBJECTS

The canonical way of creating a custom object is to create a new instance of Object and add properties and methods to it, as in this example:

let person = new Object(); 
person.name = "Nicholas"; 
person.age = 29; 
person.job = "Software Engineer"; 
person.sayName = function() { 
 	console.log(this.name); 
}; 

This example creates an object called person that has three properties (name, age, and job) and one method (sayName()). The sayName() method displays the value of this.name, which resolves to person.name. Early JavaScript developers used this pattern frequently to create new objects. A few years later, object literals became the preferred pattern for creating such objects. The previous example can be rewritten using object literal notation as follows

let person = { 
 	name: "Nicholas", 
 	age: 29, 
 	job: "Software Engineer", 
 	sayName() { 
 		console.log(this.name); 
 	} 
}; 

The person object in this example is equivalent to the person object in the prior example, with all the same properties and methods. These properties are all created with certain characteristics that define their behavior in JavaScript.

1.1 Types of Properties

ECMA-262 describes characteristics of properties through the use of internal-only attributes. These attributes are defined by the specification for implementation in JavaScript engines, and as such, these attributes are not directly accessible in JavaScript. To indicate that an attribute is internal, surround the attribute name with two pairs of square brackets, such as [[Enumerable]].

There are two types of properties: data properties and accessor properties.

1.1.1 Data Properties

Data properties contain a single location for a data value. Values are read from and written to this location. Data properties have four attributes describing their behavior:

  • [[Configurable]]—Indicates if the property may be redefined by removing the property via delete, changing the property’s attributes, or changing the property into an accessor property. By default, this is true for all properties defined directly on an object, as in the previous example.
  • [[Enumerable]]—Indicates if the property will be returned in a for-in loop. By default, this is true for all properties defined directly on an object, as in the previous example.
  • [[Writable]]—Indicates if the property’s value can be changed. By default, this is true for all properties defined directly on an object, as in the previous example.
  • [[Value]]—Contains the actual data value for the property. This is the location from which the property’s value is read and the location to which new values are saved. The default value for this attribute is undefined.

When a property is explicitly added to an object as in the previous examples, [[Configurable]], [[Enumerable]], and [[Writable]] are all set to true while the [[Value]] attribute is set to the assigned value. For example:

let person = { 
 	name: "Nicholas" 
}; 

Here, the property called name is created and a value of "Nicholas" is assigned. That means [[Value]] is set to "Nicholas", and any changes to that value are stored in this location.

To change any of the default property attributes, you must use the Object.defineProperty() method. This method accepts three arguments: the object on which the property should be added or modified, the name of the property, and a descriptor object. The properties on the descriptor object match the attribute names: configurable, enumerable, writable, and value. You can set one or all of these values to change the corresponding attribute values. For example:

let person = {}; 
Object.defineProperty(person, "name", { 
 	writable: false, 
 	value: "Nicholas" 
}); 
console.log(person.name); 	// "Nicholas" 
person.name = "Greg"; 
console.log(person.name); 	// "Nicholas"

This example creates a property called name with a value of "Nicholas" that is read-only. The value of this property can’t be changed, and any attempts to assign a new value are ignored in nonstrict mode. In strict mode, an error is thrown when an attempt is made to change the value of a read-only property.

Similar rules apply to creating a nonconfigurable property. For example:

let person = {}; 
Object.defineProperty(person, "name", { 
 	configurable: false, 
 	value: "Nicholas" 
}); 
console.log(person.name); // "Nicholas" 
delete person.name; 
console.log(person.name); // "Nicholas" 

Here, setting configurable to false means that the property cannot be removed from the object. Calling delete on the property has no effect in nonstrict mode and throws an error in strict mode. Additionally, once a property has been defined as nonconfigurable, it cannot become configurable again. Any attempt to call Object.defineProperty() and change any attribute other than writable causes an error:

let person = {}; 
Object.defineProperty(person, "name", { 
 	configurable: false, 
 	value: "Nicholas" 
}); 

// Throws an error
Object.defineProperty(person, "name", { 
 	configurable: true, 
 	value: "Nicholas" 
}); 

So although you can call Object.defineProperty() multiple times for the same property, there are limits once configurable has been set to false.

When you are using Object.defineProperty(), the values for configurable, enumerable, and writable default to false unless otherwise specified. In most cases, you likely won’t need the powerful options provided by Object.defineProperty(), but it’s important to understand the concepts to have a good understanding of JavaScript objects.

1.1.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 

在这个例子中,对象 book 有两个默认属性:year_和 edition。year_中的下划线常用来表示该属性并不希望在对象方法的外部被访问。另一个属性 year 被定义为一个访问器属性,其中获取函数简单地返回 year_的值,而设置函数会做一些计算以决定正确的版本(edition)。因此,把 year 属性修改为 2018 会导致 year_变成 2018,edition 变成 2。这是访问器属性的典型使用场景,即设置一个属性值会导致一些其他变化发生。

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

在不支持 Object.defineProperty()的浏览器中没有办法修改[[Configurable]]或[[Enumerable]]。

1.2 定义多个属性

在一个对象上同时定义多个属性的可能性是非常大的。为此,ECMAScript 提供了 Object.defineProperties()方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。比如:

待补充 233

1.7 对象解构

ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。

下面的例子展示了两段等价的代码,首先是不使用对象解构的:

// 不使用对象解构
let person = { 
 	name: 'Matt', 
 	age: 27 
};

let personName = person.name, 
 	personAge = person.age; 
 	
console.log(personName); // Matt 
console.log(personAge); // 27 

然后,是使用对象解构的:

// 使用对象解构
let person = { 
 	name: 'Matt', 
 	age: 27 
}; 

let { name: personName, age: personAge } = person;
 
console.log(personName); // Matt 
console.log(personAge); // 27 

使用解构,可以在一个类似对象字面量的结构中,声明多个变量,同时执行多个赋值操作。如果想让变量直接使用属性的名称,那么可以使用简写语法,比如:

let person = { 
 	name: 'Matt', 
 	age: 27 
}; 

let { name, age } = person; 
console.log(name); // Matt 
console.log(age); // 27 

解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是 undefined:

let person = { 
 	name: 'Matt', 
 	age: 27 
}; 

let { name, job } = person; 

console.log(name); // Matt 
console.log(job); // undefined 

也可以在解构赋值的同时定义默认值,这适用于前面刚提到的引用的属性不存在于源对象中的情况:

let person = { 
 	name: 'Matt', 
 	age: 27 
}; 

let { name, job='Software engineer' } = person;
 
console.log(name); // Matt 
console.log(job); // Software engineer 

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

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.7.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 
1.7.2 部分解构

需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分:

待补充 try catch

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, undefined 
1.7.3 参数上下文匹配

在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量:

let person = { 
 	name: 'Matt', 
 	age: 27 
}; 

function printPerson(foo, {name, age}, bar) { 
 	console.log(arguments); 
 	console.log(name, age); 
} 

function printPerson2(foo, {name: personName, age: personAge}, bar) { 
 	console.log(arguments); 
 	console.log(personName, personAge); 
} 

printPerson('1st', person, '2nd'); 
// ['1st', { name: 'Matt', age: 27 }, '2nd'] 
// 'Matt', 27
 
printPerson2('1st', person, '2nd'); 
// ['1st', { name: 'Matt', age: 27 }, '2nd'] 
// 'Matt', 27

2 创建对象

虽然使用 Object 构造函数或对象字面量可以方便地创建对象,但存在一个明显不足:创建具有同样接口的多个对象需要重复编写很多代码。

2.1 概述

ECMAScript 5.1 并没有正式支持面向对象的结构,比如类或继承。但巧妙地运用原型式继承可以成功地模拟同样的行为。

ECMAScript 6 开始正式支持类和继承。ES6 的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,ES6 的类都仅仅是封装了 ES5.1 构造函数加原型继承的语法糖而已。

注意(不要误会): 采用面向对象编程模式的 JavaScript 代码还是应该使用 ECMAScript 6 的类。但不管怎么说,理解 ES6 类出现之前的惯例总是有益无害的。特别是 ES6 的类定义本身就相当于对原有结构的封装。因此,在介绍 ES6 的类之前,本书会循序渐进地介绍被类取代的那些底层概念。

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 个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。

2.3 构造函数模式

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("Nicholas", 29, "Software Engineer"); 
let person2 = new Person("Greg", 27, "Doctor");
 
person1.sayName(); // Nicholas 
person2.sayName(); // Greg

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

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

另外,要注意函数名 Person 的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。这是从面向对象编程语言那里借鉴的,有助于在 ECMAScript 中区分构造函数和普通函数。毕竟 ECMAScript 的构造函数就是能创建对象的函数。

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

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

待补充 constructor
上一个例子的最后,person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个constructor 属性指向 Person,如下所示:

console.log(person1.constructor == Person); // true 
console.log(person2.constructor == Person); // true 

待补充 instanceof有介绍吗

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("Nicholas", 29, "Software Engineer"); 
let person2 = new Person("Greg", 27, "Doctor"); 

person1.sayName(); // Nicholas 
person2.sayName(); // Greg 

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 = "Jake"; 
 	this.sayName = function() { 
 		console.log(this.name); 
 	}; 
} 

let person1 = new Person(); 
let person2 = new Person; 

person1.sayName(); // Jake 
person2.sayName(); // Jake 

console.log(person1 instanceof Object); // true 
console.log(person1 instanceof Person); // true 
console.log(person2 instanceof Object); // true 
console.log(person2 instanceof Person); // true 
2.3.1 构造函数也是函数

待补充 new操作符

构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。并没有把某个函数定义为构造函数的特殊语法。任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。比如,前面的例子中定义的 Person()可以像下面这样调用:

待补充 call又是个啥

// 作为构造函数 
let person = new Person("Nicholas", 29, "Software Engineer"); 
person.sayName(); // "Nicholas" 

// 作为函数调用
Person("Greg", 27, "Doctor"); // 添加到 window 对象
window.sayName(); // "Greg" 

// 在另一个对象的作用域中调用
let o = new Object(); 
Person.call(o, "Kristen", 25, "Nurse"); 
o.sayName(); // "Kristen" 

待补充 call 和apply调用函数
这个例子一开始展示了典型的构造函数调用方式,即使用 new 操作符创建一个新对象。然后是普通函数的调用方式,这时候没有使用 new 操作符调用 Person(),结果会将属性和方法添加到 window 对象。这里要记住,在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或者没有使用 call()/apply()调用),this 始终指向 Global 对象(在浏览器中就是 window 对象)。因此在上面的调用之后,window 对象上就有了一个 sayName()方法,调用它会返回"Greg"。最后展示的调用方式是通过 call()(或 apply())调用函数,同时将特定对象指定为作用域。这里的调用将对象 o 指定为 Person()内部的 this 值,因此执行完函数代码后,所有属性和 sayName()方法都会添加到对象 o 上面。

2.3.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 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 

在这里,sayName()被定义在了构造函数外部。在构造函数内部,sayName 属性等于全局 sayName()函数。因为这一次 sayName 属性中包含的只是一个指向外部函数的指针,所以 person1 和 person2共享了定义在全局作用域上的 sayName()函数。这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。

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 

使用函数表达式也可以:

let Person = function() {}; 

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

这里,所有属性和 sayName()方法都直接添加到了 Person 的 prototype 属性上,构造函数体中什么也没有。但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法。与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1 和 person2 访问的都是相同的属性和相同的 sayName()函数。要理解这个过程,就必须理解 ECMAScript 中原型的本质。

2.4.1 理解原型

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

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

这种关系不好可视化,但可以通过下面的代码来理解原型的行为:

待补充 typeof

/** 
 * 构造函数可以是函数表达式
 * 也可以是函数声明,因此以下两种形式都可以:
 * function Person() {} 
 * let Person = function() {} 
 */ 
function Person() {} 

/** 
 * 声明之后,构造函数就有了一个
 * 与之关联的原型对象:
 */ 
console.log(typeof Person.prototype); 
console.log(Person.prototype); 
// { 
// constructor: f Person(), 
// __proto__: Object 
// } 

/** 
 * 如前所述,构造函数有一个 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 
conosle.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 
2.5 对象迭代

在 JavaScript 有史以来的大部分时间内,迭代对象属性都是一个难题。ECMAScript 2017 新增了两个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式。这两个静态方法 Object.values()和 Object.entries()接收一个对象,返回它们内容的数组。Object.values()返回对象值的数组,Object.entries()返回键/值对的数组。

下面的示例展示了这两个方法:

const o = { 
 	foo: 'bar', 
 	baz: 1, 
 	qux: {} 
}; 

console.log(Object.values(o)); 
// ["bar", 1, {}] 

console.log(Object.entries((o))); 
// [["foo", "bar"], ["baz", 1], ["qux", {}]] 

注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制:

const o = { 
 	qux: {} 
}; 

console.log(Object.values(o)[0] === o.qux); 
// true 

console.log(Object.entries(o)[0][1] === o.qux); 
// true 

符号属性会被忽略:

const sym = Symbol(); 
const o = { 
 	[sym]: 'foo' 
}; 

console.log(Object.values(o)); 
// [] 

console.log(Object.entries((o))); 
// [] 
2.5.1 其他原型语法

有读者可能注意到了,在前面的例子中,每次定义一个属性或方法都会把 Person.prototype 重写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型成为了一种常见的做法,如下面的例子所示:

function Person() {} 

Person.prototype = {
 	name: "Nicholas", 
 	age: 29, 
 	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: "Nicholas", 
 	age: 29, 
 	job: "Software Engineer", 
 	sayName() { 
 		console.log(this.name); 
 	} 
};

这次的代码中特意包含了 constructor 属性,并将它设置为 Person,保证了这个属性仍然包含恰当的值。

但要注意,以这种方式恢复 constructor 属性会创建一个[[Enumerable]]为 true 的属性。而原生 constructor 属性默认是不可枚举的。因此,如果你使用的是兼容 ECMAScript 的 JavaScript 引擎,那可能会改为使用 Object.defineProperty()方法来定义 constructor 属性:

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 
});

3 继承

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

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 

以上代码定义了两个类型:SuperType 和 SubType。这两个类型分别定义了一个属性和一个方法。
这两个类型的主要区别是 SubType 通过创建 SuperType 的实例并将其赋值给自己的原型 SubTtype.
prototype 实现了对 SuperType 的继承。这个赋值重写了 SubType 最初的原型,将其替换为
SuperType 的实例。这意味着 SuperType 实例可以访问的所有属性和方法也会存在于 SubType.
prototype。这样实现继承之后,代码紧接着又给 SubType.prototype,也就是这个 SuperType 的
实例添加了一个新方法。最后又创建了 SubType 的实例并调用了它继承的 getSuperValue()方法。
图 8-4 展示了子类的实例与两个构造函数及其对应的原型之间的关系。

待补充 263

4 类

ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。虽然 ECMAScript 6 类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。

4.1 类定义

与函数相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用 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 
4.1.1 类的构成

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

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

// 空类定义,有效 
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 
4.2 类构造函数

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

4.2.1 实例化

使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。唯一可感知的不同之处就是,JavaScript 解释器知道使用 new 和类意味着应该使用 constructor 函数进行实例化。

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

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

来看下面的例子:

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(); 
4.2.2 把类当成特殊函数

ECMAScript 中没有正式的类这个类型。从各方面来看,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 

由此可知,可以使用 instanceof 操作符检查一个对象与类构造函数,以确定这个对象是不是类的实例。只不过此时的类构造函数要使用类标识符,比如,在前面的例子中要检查 p 和 Person。

如前所述,类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用 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 classList = [ 
 	class { 
 		constructor(id) { 
	 		this.id_ = id; 
 			console.log(`instance ${this.id_}`); 
 		} 
 	} 
]; 

function createInstance(classDefinition, id) { 
 	return new classDefinition(id); 
} 

let foo = createInstance(classList[0], 3141); // instance 3141 

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

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

本章前面花了大量篇幅讨论如何使用 ES5 的机制实现继承。ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。

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 的值会反映调用相应方法的实例或者类:

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 {} 是有效的语法。

4.4.2 构造函数、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 时要注意几个问题:

待补充 285

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值