此章节是本书的重中之重,主要介绍对象的概念、对象的创建、继承、类,其中一定要掌握创建对象和继承的逻辑。
一、理解对象
可以把 ECMAScript 的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数。
对象的属性分两种:数据属性和访问器属性。
1. 数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有4
个特性描述它们的行为。
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
- [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
- [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。
2. 访问器属性
访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。
获取(getter)函数:在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。
设置(setter)函数:在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。
访问器属性有4 个特性描述它们的行为。
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。 12
- [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
- [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。
// 定义一个对象,包含伪私有成员year_和公共成员edition
let book = {
year_: 2017,
edition: 1
};
Object.defineProperty(book, "year", {
get() { //get 获取函数,将对象属性与函数进行绑定,当属性被访问时,对应函数被执行。
return this.year_;
},
set(newValue) { //set 设置函数,将对象属性与函数进行绑定,当属性被赋值时,对应函数被执行。
if (newValue > 2017) {
this.year_ = newValue;
this.edition += newValue - 2017;
}
}
});
book.year = 2018;
console.log(book.edition); // 2
2.合并对象
Object.assign()方法。这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回 true)和自有(Object.hasOwnProperty()返回 true)属性复制到目标对象。
3.对象标识及相等判定
ECMAScript 6 规范新增了 Object.is(),这个方法与===很像
console.log(Object.is(true, 1)); // false
console.log(Object.is({}, {})); // false
console.log(Object.is(NaN, NaN)); // true
二、创建对象
1.工厂模式
工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)
function createPerson(name, age, job) {
let o = new Object(); 1
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() { 2
console.log(this.name);
};
return o;
} 3
let person1 = createPerson("Nicholas", 29, "Software Engineer");
let person2 = createPerson("Greg", 27, "Doctor");
2.构造函数模式
构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。要创建实例,应使用 new 操作符。
构造函数也是函数 。任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。
构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。
3.原型模式
什么是原型???
每个函数都会创建一个 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
无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向
原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自Object。实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
/**
* 构造函数可以是函数表达式
* 也可以是函数声明,因此以下两种形式都可以:
* function Person() {} 11
* let Person = function() {}
*/
function Person() {}
12
/**
* 声明之后,构造函数就有了一个
* 与之关联的原型对象:
*/ 13
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 检查实例的原型链中
* 是否包含指定构造函数的原型:
*/ 1
console.log(person1 instanceof Person); // true
console.log(person1 instanceof Object); // true
console.log(Person.prototype instanceof Object); // true
Object.getPrototypeOf()
返回参数的内部特性[[Prototype]]的值。可以方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要。
Object.create()
可以通过 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
虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。如果在实例上添加了一个
与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。null,也不会恢复它和原型的联系。不过,使用 delete 操作符可以完全删除实例上的这个属性,
in 操作符
in 操作符会在可以通过对象访问指定属性时返回 true,无论该属性是在实例上还是在原型上。只要 in 操作符返回 true 且 hasOwnProperty()返回 false,就说明该属性是一个原型属性。在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回。
function Person() {}
Person.prototype.name = "Nicholas"; 4
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
}; 5
let person1 = new Person();
let person2 = new Person();
6
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
person1.name = "Greg"; 7
console.log(person1.name); // "Greg",来自实例
console.log(person1.hasOwnProperty("name")); // true
console.log("name" in person1); // true
console.log(person2.name); // "Nicholas",来自原型 8
console.log(person2.hasOwnProperty("name")); // false
console.log("name" in person2); // true
delete person1.name; 9
console.log(person1.name); // "Nicholas",来自原型
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
Object.keys()
要获得对象上所有可枚举的实例属性,可以使用 Object.keys()方法。但是不能枚举原型属性
4.对象迭代
原型的动态性 :因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。因为实例和原型之间的链接就是简单的指针,而不是保存的副本,所以会在原型上找到 sayHi 属性并返回这个属性保存的函数。实例只有指向原型的指针,没有指向构造函数的指针。重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。
原型的问题 :
- 它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。
- 原型的最主要问题源自它的共享特性。 这就是实际开发中通常不单独使用原型模式的原因。
三、继承
实现继承是ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。
1 原型链
ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。
实际上,原型链中还有一环。默认情况下,所有引用类型都继承自 Object(默认原型),这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例。这也是为什么自定义类型能够继承包括 toString()、valueOf()在内的所有默认方法的原因。
相关方法
function SuperType() {
this.property = true;
} 4
SuperType.prototype.getSuperValue = function() {
return this.property;
};
5
function SubType() {
this.subproperty = false;
}
6
// 继承SuperType
SubType.prototype = new SuperType();
// 新方法
SubType.prototype.getSubValue = function () { 7
return this.subproperty;
};
// 覆盖已有的方法 8
SubType.prototype.getSuperValue = function () {
return false;
};
let instance = new SubType(); 9
console.log(instance.getSuperValue()); // false
原型链的问题
- 主要问题出现在原型中包含引用值的时候。前面在谈到原型的问题时也提到过,原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。
- 子类型在实例化时不能给父类型的构造函数传参。
2 盗用构造函数
在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply()和 call()方法以新创建的对象为上下文执行构造函数。
function SuperType() {
this.colors = ["red", "blue", "green"]; 3
}
function SubType() {
// 继承 SuperType 4
SuperType.call(this);
}
let instance1 = new SubType(); 5
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green"
//SuperType构造函数在为 SubType 的实例创建的新对象的上下文中执行了。
//这相当于新的 SubType 对象上运行了 SuperType()函数中的所有初始化代码。
盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。
盗用构造函数的主要缺点
- 使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。
- 此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
由于存在这些问题,盗用构造函数基本上也不能单独使用。
3 组合继承
组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。
基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。
//1.构造函数定义两个属性 name color
function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
//2.它的原型上定义一个方法 sayName
SuperType.prototype.sayName = function() {
console.log(this.name);
};
//3.再构建一个函数,调用刚才的构造函数,并传参,另外定义自己的age属性
function SubType(name, age){
// 继承属性
SuperType.call(this, name);
this.age = age;
}
// 4.第二个构造函数的原型定义为刚才第一个构造函数的实例.继承方法
SubType.prototype = new SuperType();
//5.给第二个构造函数的原型定义一个方法 sayAge
SubType.prototype.sayAge = function() {
console.log(this.age);
};
//]6.生成一个第二个函数的实例,传参,参数实际上是第一个构造函数所需的参数
//实例即继承了第一个构造函数的属性,又继承了第二个构造函数的方法
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()方法识别合成对象的能力。
4 原型式继承
出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。
Crockford 推荐的原型式继承适用于这种情况:你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给 object(),然后再对返回的对象进行适当修改。
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。
5 寄生式继承
创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。
寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。
object()函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。
6 寄生式组合继承
寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调
用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。
function inheritPrototype(subType, superType) { //这个函数接收两个参数:子类构造函数和父类构造函数。
let prototype = object(superType.prototype); // 创建对象,创建父类原型的副本
prototype.constructor = subType; // 增强对象,解决由于重写原型导致默constructor 丢失的问题
subType.prototype = prototype; // 赋值对象
}
组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是
创建子类原型时调用,另一次是在子类构造函数中调用。
寄生式组合继承可以算是引用类型继承的最佳模式。
四、类
类(class)是ECMAScript 中新的基础性语法糖结构,实际上它背后使用的仍然是原型和构造函数的概念。
1 类定义
与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数
声明可以提升,但类定义不能:
class Person {}
类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称
字符串。但不能在类表达式作用域外部访问这个标识符。
2 类构造函数
ECMAScript 类就是一种特殊函数。constructor 关键字用于在类定义块内部创建类的构造函数。使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。唯一可感知的不同之处就
是,JavaScript 解释器知道使用 new 和类意味着应该使用 constructor 函数进行实例化。 构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的 this 对象,那么这个对象会被销毁。
class Animal {}
class Person {
constructor() { //constructor 关键字用于在类定义块内部创建类的构造函数。方法名 constructor会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。
console.log('person ctor');
}
}
6
class Vegetable {
constructor() {
this.color = 'orange';
}
} 7
let a = new Animal();
let p = new Person(); // person ctor 8
let v = new Vegetable();
console.log(v.color); // orange
类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符。而普通构造函数如果不使用 new 调用,那么就会以全局的 this(通常是 window)作为内部对象。
3 实例、原型和类成员
每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。类定义语法把在类块中定义的方法作为原型方法。可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据。
4 继承
虽然类继承使用的是新语法,但背后依旧使用的是原型链。
使用 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() {}
在类构造函数中使用 super 可以调用父类构造函数。
class Vehicle {
constructor() { 7
this.hasEngine = true;
}
}
8
class Bus extends Vehicle {
constructor() {
// 不要在调用super()之前引用this,否则会抛出ReferenceError
super(); // 相当于super.constructor() 9
console.log(this instanceof Vehicle); // true
console.log(this); // Bus { hasEngine: true }
} 10
}
new Bus();
总结
对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。
下面的模式适用于创建对象。
- 工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。这个模式在构造函数模式出现后就很少用了。
- 使用构造函数模式可以自定义引用类型,可以使用 new 关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。
- 原型模式解决了成员共享的问题,只要是添加到构造函数 prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。
JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。
原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。
- 盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。
- 目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。
- 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强。
- 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。
- 寄生组合继承被认为是实现基于类型继承的最有效方式。
ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型和对象类之间的鸿沟。