对象、类与面向对象编程
ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。正因为如此(以及其他还未讨论的原因),可以把 ECMAScript 的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数
理解对象
属性的类型
数据属性:包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4个特性描述它们的行为
- Configurable: 表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认为 true,一个属性被定义为不可配置之后,就不能再变回可配置的了,再次调用 Object.defineProperty()并修改任何非 writable 属性会导致错误
- Enumerable: 表示属性是否可以通过 for-in 循环返回,即是否为可迭代的,默认为 true
- Writable: 表示属性的值是否可以被修改,默认为 true
- Value:包含属性实际的值,默认为 undefined
要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值访问器属性:访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数
- Configurable: 表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认为 true
- Enumerable: 表示属性是否可以通过 for-in 循环返回,即是否为可迭代的,默认为 true
- Get: 获取函数,在读取属性时调用。默认值为 undefined
- Set: 设置函数,在写入属性时调用。默认值为 undefined
定义多个属性
在一个对象上同时定义多个属性的可能性是非常大的。为此,ECMAScript 提供了 Object.defineProperties()方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应
数据属性的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;
}
}
}
});
读取属性的特性
- Object.getOwnPropertyDescriptor(): 返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性)
- Object.getOwnPropertyDescriptors() 方法用来获取一个对象的所有自身属性的描述符。
合并对象
- Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象
Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值,如果赋值期间出错,则操作会中止并退出,同时抛出错误
对象标识及相等判定
Object.is() 方法判断两个值是否是相同的值。
增强的对象语法
- 属性值简写: 属性名与变量名一样时,只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键
- 可计算属性:在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为 JavaScript 表达式而不是字符串来求值
- 简写方法名
对象解构
使用与对象匹配的结构来实现对象属性赋值
- 嵌套结构
- 部分解构 (顺序解构,开始的赋值成功后续的出错,则解构赋值只会完成一部分)
- 参数上下文匹配(传递函数参数时也可解构赋值)
let person = {
name: 'Matt',
age: 27
};
let { name: personName, age: personAge } = person;
创建对象
工厂模式
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");
构造函数模式
构造函数代码与工厂函数类似,但没有显式的创建对象,属性和方法直接赋值给了this,没有return。另构造函数名称首字母大写
new 操作符执行操作
- 在内存中创建一个新对象
- 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性
- 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
- 执行构造函数内部的代码(给新对象添加属性)。
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
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
原型模式
无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象),默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数,其他的所有方法都继承自Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome会在每个对象上暴露proto属性,通过这个属性可以访问对象的原型
- Object.getPrototypeOf():返回指定对象的原型
- Object.setPrototypeOf() 方法设置一个指定的对象的原型 ( 即, 内部[[Prototype]]属性)到另一个对象或 null。警告: 由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在各个浏览器和 JavaScript 引擎上都是一个很慢的操作。其在更改继承的性能上的影响是微妙而又广泛的,这不仅仅限于 obj.proto = … 语句上的时间花费,而且可能会延伸到任何代码,那些可以访问任何[[Prototype]]已被更改的对象的代码。如果你关心性能,你应该避免设置一个对象的 [[Prototype]]。相反,你应该使用 Object.create()来创建带有你想要的[[Prototype]]的新对象
- hasOwnProperty() 方法会返回一个布尔值,指示对象自身属性中是否具有指定的属性
- in 操作符:如果指定的属性在指定的对象或其原型链中,则in 运算符返回true。
- Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和使用 for…in 循环遍历该对象时返回的顺序一致
- Object.getOwnPropertyNames()方法返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括Symbol值作为名称的属性)组成的数组
- Object.getOwnPropertySymbols() 方法返回一个给定对象自身的所有 Symbol 属性的数组。
如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性
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
对象迭代
- Object.values()方法返回一个给定对象自身的所有可枚举属性值的数组,值的顺序与使用for…in循环的顺序相同 ( 区别在于 for-in 循环枚举原型链中的属性 )。
- Object.entries()方法返回一个给定对象自身可枚举属性的键值对数组,其排列与使用 for…in 循环遍历该对象时返回的顺序一致(区别在于 for-in 循环也枚举原型链中的属性)
重写原型
重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型
同一构造函数的实例对象,共享原型上的引用属性
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
});
继承
继承是面向对象编程中讨论最多的话题。很多面向对象语言都支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的
原型链
构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型
原型与继承关系:
- instanceof 运算符用于测试构造函数的prototype属性是否出现在对象的原型链中的任何位置
- isPrototypeOf() 方法用于测试一个对象是否存在于另一个对象的原型链上
缺陷:
- 原型中包含的引用值会在所有实例间共享
- 子类型在实例化时不能给父类型的构造函数传参
盗用构造函数(“对象伪装”或“经典继承”)
实现:在子类构造函数中调用父类构造函数
通过使用 call()(或 apply())方法,SuperType构造函数在为 SubType 的实例创建的新对象的上下文中执行了。这相当于新的 SubType 对象上运行了SuperType()函数中的所有初始化代码。结果就是每个实例都会有自己的 colors 属性,可传参
缺陷:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用
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"
组合继承(“伪经典继承”)
实现:是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性
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
原型式继承
2006 年,Douglas Crockford 在文章:《JavaScript 中的原型式继承》(“Prototypal Inheritance in JavaScript” 中提出
ECMAScript 5 通过增加 Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create()与这里的 object()方法效果相同
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
寄生式继承
寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象
通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似
function createAnother(original){
let clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式增强这个对象
console.log("hi");
};
return clone; // 返回这个对象
}
寄生式组合继承
组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用
寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。寄生式组合继承的基本模式如下所示
调用 inheritPrototype()就可以实现前面例子中的子类型原型赋值
这里只调用了一次 SuperType 构造函数,避免了 SubType.prototype 上不必要也用不到的属性,因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此 instanceof 操作符和 isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式
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); // 第二次调用 SuperType()
this.age = age;
}
SubType.prototype = new SuperType(); // 第一次调用 SuperType()
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
console.log(this.age);
};
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);
};
类
ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。类(class)是ECMAScript 中新的基础性语法糖结构
类定义
类声明和类表达式。这两种方式都使用 class 关键字加大括号
类声明无法提升,同时受块作用域限制
// 类声明
class Person {}
// 类表达式
const Animal = class {};
{
function FunctionDeclaration() {}
class ClassDeclaration {}
}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined
// 空类定义,有效
class Foo {}
// 有构造函数的类,有效
class Bar {
constructor() {}
}
// 有获取函数的类,有效
class Baz {
get myBaz() {}
}
// 有静态方法的类,有效
class Qux {
static myQux() {}
}
类构造函数
constructor 关键字用于在类定义块内部创建类的构造函数。方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数
类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符
实例、原型和类成员
类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员
- 实例成员:每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享
- 原型方法与访问器:为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法,可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据
- 静态类方法: 在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例,与原型成员类似,静态成员每个类上只能有一个
静态类方法非常适合作为实例工厂
- 非函数原型和类成员: 虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加.类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过 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_: ... }
继承
ES6 原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链
- 继承基础:ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容),extends 关键字也可以在类表达式中使用,因此 let Bar = class extends Foo {}是有效的语法
- 构造函数、HomeObject 和 super():super关键字用于访问和调用一个对象的父对象上的函数。
- 抽象基类: 有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化
- 继承内置类型:
- 类混入: 多类继承
很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性
小结
对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实
体。下面的模式适用于创建对象
- 工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。这个模式在构造函数模式出现后就很少用了
- 使用构造函数模式可以自定义引用类型,可以使用 new 关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享
- 原型模式解决了成员共享的问题,只要是添加到构造函数 prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法
JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性
除上述模式之外,还有以下几种继承模式
- 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强
- 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费
- 寄生组合继承被认为是实现基于类型继承的最有效方式
ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定
义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型
和对象类之间的鸿沟