1 理解对象
1.1 属性的类型
ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义的。因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]。
属性分两种:数据属性和访问器属性
1. 数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置[[Value]]。
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是true。
- [[Writable]]:表示属性的值是否可以被修改。默认情况下,这个特性都是 true。
- [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。
let person = {
name: "Nicholas" // [[Value]]特性会被设置为"Nicholas",
};
要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收 3 个参数:
要给其添加属性的对象、属性的名称、一个描述符对象(设置其中一个或多个:configurable、enumerable、writable 和 value)。
let person = {};
Object.defineProperty(person, "name", {
writable: false,
value: "Nicholas"
});
console.log(person.name); // "Nicholas"
person.name = "Greg";
console.log(person.name); // "Nicholas"
非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性
的值会抛出错误。
一个属性被定义为不可配置之后,就不能再变回可配置的了
let person = {};
Object.defineProperty(person, "name", {
configurable: false,
value: "Nicholas"
});
// 抛出错误
Object.defineProperty(person, "name", {
configurable: true,
value: "Nicholas"
});
2.访问器属性
访问器属性不包含数据值。相反,包含一个获取(getter)函数和一个设置(setter)函数,有以下4 个特性:
- [[Configurable]]:表示属性是否可以通过 delete删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是true。
- [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
- [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。
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
注意
- 不支持 Object.defineProperty()的浏览器无法修改[[Configurable]]或[[Enumerable]]。
- 在 ECMAScript 5以前,开发者会使用两个非标准的访问创建访问器属性:__ defineSetter__()和__defineSetter__()。这两个方法最早是 Firefox 引入的,后来 Safari、Chrome 和 Opera也实现了。
1.2 定义多个属性
在一个对象上同时定义多个属性使用Object.defineProperties()方法:
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;
}
}
}
});
book.year = 2018;
console.log(book.edition); // 2
1.3 读取属性的特性
Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符。这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含configurable、enumerable、get 和 set 属性,对于数据属性包含 configurable、enumerable、writable 和 value 属性。比如:
let book = {};
Object.defineProperties(book, {
year_: {
value: 2017
},
edition: {
value: 1
},
year: {
get: function() {
return this.year_;
},
set: function(newValue){
if (newValue > 2017) {
this.year_ = newValue;
this.edition += newValue - 2017;
}
}
}
});
let descriptor = Object.getOwnPropertyDescriptor(book, "year_");
console.log(descriptor.value); // 2017
console.log(descriptor.configurable); // false
console.log(typeof descriptor.get); // "undefined"
let descriptor = Object.getOwnPropertyDescriptor(book, "year");
console.log(descriptor.value); // undefined
console.log(descriptor.enumerable); // false
console.log(typeof descriptor.get); // "function"
对于数据属性 year_,value 等于原来的值,configurable 是 false,get 是 undefined。
对于访问器属性 year,value 是 undefined,enumerable 是 false,get 是一个指向获取函数的指针。
ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors()静态方法。这个方法会在每个自有属性上调用Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。对于
前面的例子,使用这个静态方法会返回如下对象:
let book = {};
Object.defineProperties(book, {
year_: {
value: 2017
},
edition: {
value: 1
},
year: {
get: function() {
return this.year_;
},
set: function(newValue){
if (newValue > 2017) {
this.year_ = newValue;
this.edition += newValue - 2017;
}
}
}
});
console.log(Object.getOwnPropertyDescriptors(book));
// {
// edition: {
// configurable: false,
// enumerable: false,
// value: 1,
// writable: false
// },
// year: {
// configurable: false,
// enumerable: false,
// get: f(),
// set: f(newValue),
// },
// year_: {
// configurable: false,
// enumerable: false,
// value: 2017,
// writable: false
// }
// }
1.4 合并对象
合并对象:把源对象所有的本地属性一起复制到目标对象上。有时候这种操作也被称为“混入”(mixin),因为目标对象通过混入源对象的属性得到了增强。ECMAScript 6为合并对象提供了 Object.assign()方法。
这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回 true和自有(Object.hasOwnProperty()返回 true)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值。
let dest, src, result;
/**
* 简单复制
*/
dest = {};
src = { id: 'src' };
result = Object.assign(dest, src);
// Object.assign 修改目标对象
// 也会返回修改后的目标对象
console.log(dest === result); // true
console.log(dest !== src); // true
console.log(result); // { id: src }
console.log(dest); // { id: src }
/**
* 获取函数与设置函数
*/
dest = {
set a(val) {
console.log(`Invoked dest setter with param ${val}`);
}
};
src = {
get a() {
console.log('Invoked src getter');
return 'foo';
}
};
Object.assign(dest, src);
// 调用 src 的获取方法
// 调用 dest 的设置方法并传入参数"foo"
// 因为这里的设置函数不执行赋值操作
// 所以实际上并没有把值转移过来
console.log(dest); // { set a(val) {...} }
Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,不能在两个对象间转移获取函数和设置函数:
let dest, src, result;
/**
* 覆盖属性
*/
dest = { id: 'dest' };
result = Object.assign(dest, { id: 'src1', a: 'foo' }, { id: 'src2', b: 'bar' });
// Object.assign 会覆盖重复的属性
console.log(result); // { id: src2, a: foo, b: bar }
* 对象引用
*/
dest = {};
src = { a: {} };
Object.assign(dest, src);
// 浅复制意味着只会复制对象的引用
console.log(dest); // { a :{} }
console.log(dest.a === src.a); // true
如果赋值期间出错,则操作会中止并退出,同时抛出错误Object.assign()没有“回滚”之前赋值的概念,因此它可能只会完成部分复制的方法:
let dest, src, result;
/**
* 错误处理
*/
dest = {};
src = {
a: 'foo',
get b() {
// Object.assign()在调用这个获取函数时会抛出错误
throw new Error();
},
c: 'bar'
};
try {
Object.assign(dest, src);
} catch(e) {}
// Object.assign()没办法回滚已经完成的修改
// 因此在抛出错误之前,目标对象上已经完成的修改会继续存在:
console.log(dest); // { a: foo }
1.5 对象标识及相等判定
ECMAScript 6 之前,有些特殊情况即使是===操作符也无能为力:
// 这些是===符合预期的情况
console.log(true === 1); // false
console.log({} === {}); // false
console.log("2" === 2); // false
// 这些情况在不同 JavaScript 引擎中表现不同,但仍被认为相等
console.log(+0 === -0); // true
console.log(+0 === 0); // true
console.log(-0 === 0); // true
// 要确定 NaN 的相等性,必须使用极为讨厌的 isNaN()
console.log(NaN === NaN); // false
console.log(isNaN(NaN)); // true
为改善这类情况,ECMAScript 6 规范新增了 Object.is(),这个方法与===很像,但同时也考虑到了上述边界情形。这个方法必须接收两个参数:
console.log(Object.is(true, 1)); // false
console.log(Object.is({}, {})); // false
console.log(Object.is("2", 2)); // false
// 正确的 0、-0、+0 相等/不等判定
console.log(Object.is(+0, -0)); // false
console.log(Object.is(+0, 0)); // true
console.log(Object.is(-0, 0)); // false
// 正确的 NaN 相等判定
console.log(Object.is(NaN, NaN)); // true
// 要检查超过两个值,递归地利用相等性传递即可:
function recursivelyCheckEqual(x, ...rest) {
return Object.is(x, rest[0]) &&
(rest.length < 2 || recursivelyCheckEqual(...rest));
}
1.6 增强的对象语法
ECMAScript 6 为定义和操作对象新增了很多极其有用的语法糖特性。这些特性都没有改变现有引擎的行为,但极大地提升了处理对象的方便程度。
注意 相比于以往的替代方案,本节介绍的增强对象语法可以说是一骑绝尘。因此本章及本书(JavaScript高级程序设计 第4版)会默认使用这些新语法特性。
1.属性值简写
在给对象添加变量的时候,开发者经常会发现属性名和变量名是一样的。例如:
let name = 'Matt';
let person = {
name: name
};
console.log(person); // { name: 'Matt' }
// 等价:
let person = {
name
};
2.可计算属性
在对象字面量中完成动态属性赋值:
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let person = {
[nameKey]: 'Matt',
[ageKey]: 27,
[jobKey]: 'Software engineer'
};
console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }
注意 可计算属性表达式中抛出任何错误都会中断对象创建。如果计算属性的表达式有副作用,那就要小心了,因为如果表达式抛出错误,那么之前完成的计算是不能回滚的。
3.简写方法名
let person = {
sayName: function(name) {
console.log(`My name is ${name}`);
}
};
// 简写方式:
let person = {
sayName(name) {
console.log(`My name is ${name}`);
}
};
简写方法名与可计算属性键相互兼容:
const methodKey = 'sayName';
let person = {
[methodKey](name) {
console.log(`My name is ${name}`);
}
}
1.7 对象解构
let person = {
name: 'Matt',
age: 27
};
// 不使用对象解构
let personName = person.name,
personAge = person.age;
// 使用对象解构
let { name: personName, age: personAge } = person;
console.log(personName); // Matt
console.log(personAge); // 27
// 变量直接使用属性的名称
let { name, age } = person;
console.log(name); // Matt
console.log(age); // 27
// 解构赋值不一定与对象的属性匹配
// 赋值的时候可以忽略某些属性,而如果引用的属性不存在
// 该变量的值就是 undefined:
let { name, job } = person;
console.log(name); // Matt
console.log(job); // undefined
// 解构赋值的同时定义默认值
let { name, job='Software engineer' } = person;
console.log(job); // Software engineer
解构在内部使用函数 ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject()的定义),null
和 undefined 不能被解构,否则会抛出错误。
let { length } = 'foobar';
console.log(length); // 6
let { _ } = null; // TypeError
let { _ } = undefined; // TypeError
// 解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,
// 则赋值表达式
// 必须包含在一对括号中:
let personName, personAge;
let person = {
name: 'Matt',
age: 27
};
({name: personName, age: personAge} = person);
1.嵌套解构
let person = {
name: 'Matt',
age: 27,
job: {
title: 'Software engineer'
}
};
// 声明 title 变量并将 person.job.title 的值赋给它
let { job: { title } } = person;
console.log(title); // Software engineer
2.部分解构
需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分。
3.参数上下文匹配
在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量:
let person = {
name: 'Matt',
age: 27
};
function printPerson(foo, {name, age}, bar) {
console.log(arguments);
console.log(name, age);
}
printPerson('1st', person, '2nd');
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27
------2024/1/10------
2 创建对象
使用 Object 构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。
2.1 概述
ES6 的类是封装了 ES5.1 构造函数加原型继承的语法糖,下面介绍被类
取代的那些底层概念。
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 的首字母大写代表是构造函数(构造函数就是能创建对象的函数)
要创建 Person 的实例,应使用 new 操作符。以这种方式调用构造函数会执行如下操作:
- 在内存中创建一个新对象。
- 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性。
- 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
- 执行构造函数内部的代码(给新对象添加属性)。
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
上一个例子的最后,person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个
constructor 属性指向 Person,如下所示:
console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true
constructor 本来是用于标识对象类型的。不过,一般认为 instanceof 操作符是确定对象类型更可靠的方式。
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。在
这个例子中,person1 和 person2 之所以也被认为是 Object 的实例,是因为所有自定义对象都继承
自 Object。
1.构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。任何函数只要使用 new 操作符调用就是构造函数。
// 例1.作为构造函数
let person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); // "Nicholas"
// 例2.作为函数调用
Person("Greg", 27, "Doctor"); // 添加到 window 对象
window.sayName(); // "Greg"
// 例3.在另一个对象的作用域中调用
let o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); // "Kristen"
注意
- 例子2中,在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或者没有使用 call()/apply()调用),this始终指向 Global 对象(在浏览器中就是 window 对象)。 通过 call()(或apply())调用函数,同时将特定对象指定为作用域。这里的调用将
- 例子3中,对象 o 指定为 Person()内部的 this 值,因此执行完函数代码后,所有属性和 sayName()方法都会添 加到对象 o上面。
3.构造函数的问题
构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。person1 和 person2 都有名为 sayName()的方法,但这两个方法不是同一个 Function 实例。逻辑上讲,这个构造函数实际上是这样的:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function("console.log(this.name)"); // 逻辑等价
}
//不同实例上的函数虽然同名却不相等,如下所示
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);
}
在这里,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
ECMAScript 中原型的本质
1.理解原型
- 无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。
- 默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。对前面的例子而言Person.prototype.constructor 指向 Person。
- 然后,因构造函数而异,可能会给原型对象添加其他属性和方法。
在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。
实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
通过下面的图形来理解:
名称 | 含义 |
---|---|
Person | Person 构造函数 |
Person Prototype | Person 的原型对象 |
person1、person2 | Person的两个实例 |
- Person.prototype 指向原型对象
- Person.prototype.contructor 指回 Person 构造函数
- person1 和 person2 都只有一个内部属性指回 Person.prototype
Object 类型还有一个 setPrototypeOf()方法,可以向实例的私有特性[[Prototype]]写入一个新值。
let biped = {
numLegs: 2
};
let person = {
name: 'Matt'
};
Object.setPrototypeOf(person, biped);
console.log(person.name); // Matt
console.log(person.numLegs); // 2
console.log(Object.getPrototypeOf(person) === biped); // true
警告 Object.setPrototypeOf()可能会严重影响代码性能。Mozilla 文档说得很清楚:“在所有浏览器和 JavaScript 引擎中,修改继承关系的影响都是微妙且深远的。这种影响并不仅是执行 Object.setPrototypeOf()语句那么简单,而是会涉及所有访问了那些修改过[[Prototype]]的对象的代码。
为避免使用 Object.setPrototypeOf()可能造成的性能下降,可以通过 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
2.原型层级
person1.sayName()时,会搜索:
如果person1 实例没有 sayName 属性——>person1 的原型有没有 sayName 属性
如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。即使在实例上把这个属性设置为 null,也不会恢复它和原型的联系。不过,使用 delete 操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象。
调用 hasOwnProperty()能够清楚地看到访问的是实例属性还是原型属性。
3.原型和 in 操作符
function Person() {}
Person.prototype.name = "Nicholas";
let person1 = new Person();
// 确定某个属性是否存在于原型上
function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object);
}
只要通过对象可以访问,in 操作符就返回 true,而 hasOwnProperty()只有属性存在于实例上时才返回 true。因此,只要 in 操作符返回 true 且hasOwnProperty()返回 false,就说明该属性是一个原型属性。
- hasPrototypeProperty(): 确认属性只存在于原型上,会受实例上的属性遮蔽
- 要获得对象上所有可枚举的实例属性,可以使用 Object.keys()方法。这个方法接收一个对象作
为参数,返回包含该对象所有可枚举属性名称的字符串数组。 - 如果想列出所有实例属性,无论是否可以枚举,都可以使用 Object.getOwnPropertyNames()
- 以符号为键的属性没有名称的概念。因此,Object.getOwnPropertySymbols()方法就出现了,这个方法与Object.getOwnPropertyNames()类似,只是针对符号,见下方代码:
let k1 = Symbol('k1'),
k2 = Symbol('k2');
let o = {
[k1]: 'k1',
[k2]: 'k2'
};
console.log(Object.getOwnPropertySymbols(o));
// [Symbol(k1), Symbol(k2)]
4.属性枚举顺序
for-in 循环、Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()以及 Object.assign()在属性枚举顺序方面有很大区别。for-in 循环和 Object.keys()的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异。
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和 Object.assign()
的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。
let k1 = Symbol('k1'),
k2 = Symbol('k2');
let o = {
1: 1,
first: 'first',
[k1]: 'sym2',
second: 'second',
0: 0
};
o[k2] = 'sym2';
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(k1), Symbol(k2)]
2.5 对象迭代
ECMAScript 2017 新增了两个静态方法,用于将对象内容转换为序列化。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 sym = Symbol();
const o = {
[sym]: 'foo'
};
console.log(Object.values(o));
// []
console.log(Object.entries((o)));
// []
1.其他原型语法
每次定义一个属性或方法都会把 Person.prototype 重写一遍。为了减少代码冗余:
function Person() {}
Person.prototype = {
name: "Nicholas",
};
这样做只有一个问题:这样重写之后,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
如果 constructor 的值很重要,则可以像下面这样在重写原型对象时专门设置一
下它的值:
function Person() {
}
Person.prototype = {
constructor: Person,
name: "Nicholas",
};
但要注意,以这种方式恢复 constructor 属性会创建一个[[Enumerable]]为 true 的属性。而
原生 constructor 属性默认是不可枚举的。因此,如果你使用的是兼容 ECMAScript 的 JavaScript 引擎,那可能会改为使用 Object.defineProperty()方法来定义 constructor 属性:
// 恢复 constructor 属性
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
});
2.原型的动态性
因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。下面是一个例子:
let friend = new Person();
Person.prototype.sayHi = function() {
console.log("hi");
};
friend.sayHi(); // "hi",没问题!
虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两回事。实例的[[Prototype]]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。记住,实例只有指向原型的指针,没有指向构造函数的指针。来看下面的例子:
function Person() {}
let friend = new Person();
Person.prototype = {
constructor: Person,
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
friend.sayName(); // 错误
在例子中,Person 的新实例是在重写原型对象之前创建的。在调用 friend.sayName()的时候,会导致错误。这是因为 firend 指向的原型还是最初的原型,而这个原型上并没有 sayName 属性。
3.原生对象原型
原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。所有原生引用类型的构造函数(包括 Object、Array、String 等)都在原型上定义了实例方法。比如,
数组实例的 sort()方法就是 Array.prototype 上定义的,而字符串包装对象的 substring()方法也是在 String.prototype 上定义的,如下所示:
console.log(typeof Array.prototype.sort); // "function"
console.log(typeof String.prototype.substring); // "function"
通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。比如,下面的代码就给 String原始值包装类型的实例添加了一个 startsWith()方法:
String.prototype.startsWith = function (text) {
return this.indexOf(text) === 0;
};
let msg = "Hello world!";
console.log(msg.startsWith("Hello")); // true
注意 尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。这样做很可能造成误会,而且可能引发命名冲突(比如一个名称在某个浏览器实现中不存在,在另一个实现中却存在)。另外还有可能意外重写原生的方法。推荐的做法是创建一个自定义的类,继承原生类型。
4.原型的问题
原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默
认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。
我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性
也还好,如前面例子中所示,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题来自包含引用值的属性。来看下面的例子:
function Person() {}
Person.prototype = {
constructor: Person,
friends: ["Shelby", "Court"],
};
let person1 = new Person();
let person2 = new Person();
person1.friends.push("Van");
console.log(person1.friends); // "Shelby,Court,Van"
console.log(person2.friends); // "Shelby,Court,Van"
console.log(person1.friends === person2.friends); // true
这里,Person.prototype 有一个名为 friends 的属性,它包含一个字符串数组。然后这里创建
了两个 Person 的实例。person1.friends 通过 push 方法向数组中添加了一个字符串。由于这个
friends 属性存在于 Person.prototype 而非 person1 上,新加的这个字符串也会在(指向同一个
数组的)person2.friends 上反映出来。如果这是有意在多个实例间共享数组,那没什么问题。但一
般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。
————2024/1/11————
3 继承
ECMAScript中通过原型链实现 实现继承。
3.1 原型链
构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。
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
下图是它们之间的关系:
- 这个例子中实现继承的关键,是 SubType 没有使用默认原型,而是将其替换成了一个新的对象。这个新的对象恰好是 SuperType
的实例。 - instance(通过内部的[[Prototype]])指向SubType.prototype,而SubType.prototype(作为 SuperType 的实例又通过内部的[[Prototype]])指向SuperType.prototype。
- 对前面的例子而言,调用 instance.getSuperValue()经过了 3 步搜索:instance、SubType.prototype 和 SuperType.prototype,最后一步才找到这个方法。对属性和方法的搜索会一直持续到原型链的末端。
1.默认原型
实际上,原型链中还有一环。默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向Object.prototype。这也是为什么自定义类型能够继承包括 toString()、valueOf()在内的所有默认方法的原因。因此前面的例子还有额外一层继承关系。下图 展示了完整的原型链。
2.原型与继承关系
原型与实例的关系可以通过两种方式来确定。第一种方式是使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则 instanceof 返回 true。如下例所示:
console.log(instance instanceof Object); // true
console.log(instance instanceof SuperType); // true
console.log(instance instanceof SubType); // true
从技术上讲,instance 是 Object、SuperType 和 SubType 的实例,因为 instance 的原型链中包含这些构造函数的原型。结果就是instanceof 对所有这些构造函数都返回 true。
确定这种关系的第二种方式是使用 isPrototypeOf()方法。原型链中的每个原型都可以调用这个方法,如下例所示,只要原型链中包含这个原型,这个方法就返回 true:
console.log(Object.prototype.isPrototypeOf(instance)); // true
console.log(SuperType.prototype.isPrototypeOf(instance)); // true
console.log(SubType.prototype.isPrototypeOf(instance)); // true
3.关于方法
子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。来看下面的例子:
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;
};
// 覆盖已有的方法
SubType.prototype.getSuperValue = function () {
return false;
};
let instance = new SubType();
console.log(instance.getSuperValue()); // false
加粗的部分涉及两个方法。第一个方法 getSubValue()是 SubType 的新方法,而第二个方法 getSuperValue()是原型链上已经存在但在这里被遮蔽的方法。后面在 SubType 实例上调用 getSuperValue()时调用的是这个方法。而 SuperType 的实例仍然会调用最初的方法。重点在于上述两个方法都是在把原型赋值为 SuperType 的实例之后定义的。
另一个要理解的重点是,以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链:
// 继承 SuperType
SubType.prototype = new SuperType();
// 通过对象字面量添加新方法,这会导致上一行无效
SubType.prototype = {
getSubValue() {
return this.subproperty;
},
someOtherMethod() {
return false;
}
};
let instance = new SubType();
console.log(instance.getSuperValue()); // 出错!
在这段代码中,子类的原型在被赋值为 SuperType 的实例后,又被一个对象字面量覆盖了。覆盖后的原型是一个 Object 的实例,而不再是 SuperType 的实例。因此之前的原型链就断了。SubType和 SuperType 之间也没有关系了。
4.原型链的问题
主要问题出现在原型中包含引用值的时候。原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性成为了原型属性。下面的例子揭示了这个问题:
function SuperType() {
this.colors = ["red", "blue", "green"];
}
function SubType() {}
// 继承 SuperType
SubType.prototype = new SuperType();
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,black"
在这个例子中,SuperType 构造函数定义了一个 colors 属性,其中包含一个数组(引用值)。每个 SuperType 的实例都会有自己的 colors 属性,包含自己的数组。但是,当 SubType 通过原型继承SuperType 后,SubType.prototype 变成了 SuperType 的一个实例,因而也获得了自己的 colors属性。这类似于创建了 SubType.prototype.colors 属性。最终结果是,SubType 的所有实例都会共享这个 colors 属性。这一点通过 instance1.colors 上的修改也能反映到 instance2.colors上就可以看出来。
原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。
3.2 盗用构造函数
为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(constructor stealing)的技术在开发社区流行起来(这种技术有时也称作“对象伪装”或“经典继承”)。基本思路很简单:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()和 call()方法以新创建的对象为上下文执行构造函数。来看下面的例子:
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"
示例中加粗的代码展示了盗用构造函数的调用。通过使用 call()(或 apply())方法,SuperType构造函数在为 SubType 的实例创建的新对象的上下文中执行了。这相当于新的 SubType 对象上运行了SuperType()函数中的所有初始化代码。结果就是每个实例都会有自己的 colors 属性。
1.传递参数
相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。来看下面的例子:
function SuperType(name){
this.name = name;
}
function SubType() {
// 继承 SuperType 并传参
SuperType.call(this, "Nicholas");
// 实例属性
this.age = 29;
}
let instance = new SubType();
console.log(instance.name); // "Nicholas";
console.log(instance.age); // 29
在这个例子中,SuperType 构造函数接收一个参数 name,然后将它赋值给一个属性。在 SubType构造函数中调用 SuperType 构造函数时传入这个参数,实际上会在 SubType 的实例上定义 name 属性。为确保 SuperType 构造函数不会覆盖 SubType 定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性。
2.盗用构造函数的问题
盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用。
3.3 组合继承
组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。来看下面的例子:
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
在这个例子中,SuperType 构造函数定义了两个属性,name 和 colors,而它的原型上也定义了一个方法叫 sayName()。SubType 构造函数调用了 SuperType 构造函数,传入了 name 参数,然后又定义了自己的属性 age。此外,SubType.prototype 也被赋值为 SuperType 的实例。原型赋值之后,又在这个原型上添加了新方法 sayAge()。这样,就可以创建两个 SubType 实例,让这两个实例都有自己的属性,包括 colors,同时还共享相同的方法。
组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf()方法识别合成对象的能力。
3.4 原型式继承
一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。文章最终给出了一个函数:
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
这个 object()函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。本质上,object()是对传入的对象执行了一次浅复制。来看下面的例子:
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
let yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"
在这个例子中,person 对象定义了另一个对象也应该共享的信息,把它传给 object()之后会返回一个新对象。这个新对象的原型是 person,意味着它的原型上既有原始值属性又有引用值属性。这也意味着 person.friends 不仅是person 的属性,也会跟 anotherPerson 和yetAnotherPerson 共享。这里实际上克隆了两个 person。
ECMAScript 5 通过增加 Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create()与这里的 object()方法效果相同:
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = Object.create(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
let yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"
Object.create()的第二个参数与 Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。比如:
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = Object.create(person, {
name: {
value: "Greg"
}
});
console.log(anotherPerson.name); // "Greg"
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,
属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。
3.5 寄生式继承
寄生式继承的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。基本的寄生继承模式如下:
function createAnother(original){
let clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式增强这个对象
console.log("hi");
};
return clone; // 返回这个对象
}
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = createAnother(person);
anotherPerson.sayHi(); // "hi"
注意 通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。
3.6 寄生式组合继承
组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是
创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。再来看一看这个组合继承的例子:
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);
};
4 类
前几节深入讲解了如何只使用 ECMAScript 5 的特性来模拟类似于类(class-like)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。
为解决这些问题,ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。类(class)是
ECMAScript 中新的基础性语法糖结构,因此刚开始接触时可能会不太习惯。虽然 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
类的构成
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。
空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。
// 空类定义,有效
class Foo {}
// 有构造函数的类,有效
class Bar {
constructor() {}
}
// 有获取函数的类,有效
class Baz {
get myBaz() {}
}
// 有静态方法的类,有效
class Qux {
static myQux() {}
}
类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称
字符串。但不能在类表达式作用域外部访问这个标识符:
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 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。
————2024/1/12————
1.实例化
使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。唯一可感知的不同之处就
是,JavaScript 解释器知道使用 new 和类意味着应该使用 constructor 函数进行实例化。使用 new 调用类的构造函数会执行如下操作:
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的[[Prototype]]指针被赋值为构造函数的 prototype 属性。
(3) 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的:
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();
2.把类当成特殊函数
ECMAScript 中没有正式的类这个类型。ECMAScript 类就是一种特殊函数。声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数。
类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身:
class Person{}
console.log(Person.prototype); // { constructor: f() }
console.log(Person === Person.prototype.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
4.3 实例、原型和类成员
1.实例成员
每次通过new调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。
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
3.静态类方法
与原型成员类似,静态成员每个类上只能有一个,使用 static 关键字作为前缀。在静态成员中,this 引用类自身。
静态类方法非常适合作为实例工厂:
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 = 'Jake';
let p = new Person();
p.sayName(); // My name is Jake
注意 类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过 this引用的数据。
5.迭代器与生成器方法
类定义语法支持在原型和类本身上定义生成器方法:
class Person {
// 在原型上定义生成器方法
*createNicknameIterator() {
yield 'Jack';
yield 'Jake';
yield 'J-Dog';
}
// 在类上定义生成器方法
static *createJobIterator() {
yield 'Butcher';
yield 'Baker';
yield 'Candlestick maker';
}
}
因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:
class Person {
constructor() {
this.nicknames = ['Jack', 'Jake', 'J-Dog'];
}
*[Symbol.iterator]() {
yield *this.nicknames.entries();
// 也可以只返回迭代器实例:
// [Symbol.iterator]() {
// return this.nicknames.entries();
// }
}
}
let p = new Person();
for (let [idx, nickname] of p) {
console.log(nickname);
}
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 {}
是有效的语法。
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
- super 只能在派生类构造函数和派生类的静态方法中使用。
- 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法。
- 调用 super()会调用父类构造函数,并将返回的实例赋值给 this。
- super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
- 如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的参数。
- 在类构造函数中,不能在调用 super()之前引用 this。
- 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象。
3.抽象基类
有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。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(); // class Bus {}
new Vehicle(); // class Vehicle {}
// Error: Vehicle cannot be directly instantiated
4.继承内置类型
class SuperArray extends Array {
shuffle() {
// 洗牌算法
for (let i = this.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this[i], this[j]] = [this[j], this[i]];
}
}
}
let a = new SuperArray(1, 2, 3, 4, 5);
console.log(a instanceof Array); // true
console.log(a instanceof SuperArray); // true
console.log(a); // [1, 2, 3, 4, 5]
a.shuffle();
console.log(a); // [3, 1, 4, 5, 2]
5.类混入
把不同类的行为集中到一个类是一种常见的 JavaScript 模式。如果只是需要混入多个对象的属性,那么使用Object.assign()就可以了。
如果 Person 类需要组合 A、B、C,则需要某种机制实现 B 继承 A,C 继承 B,而 Person再继承 C,从而把 A、B、C 组合到这个超类中。实现这种模式有不同的策略。
class Vehicle {}
let FooMixin = (Superclass) => class extends Superclass {
foo() {
console.log('foo');
}
};
let BarMixin = (Superclass) => class extends Superclass {
bar() {
console.log('bar');
}
};
let BazMixin = (Superclass) => class extends Superclass {
baz() {
console.log('baz');
}
};
class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) {}
let b = new Bus();
b.foo(); // foo
b.bar(); // bar
b.baz(); // baz
注意 很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法
提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。软件设计原则:“组合胜过继承(composition over inheritance)。能提供极大的灵活性。
5 小结
对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实
体。下面的模式适用于创建对象。
工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。这个模式在构造函数模式出现后就很少用了。
使用构造函数模式可以自定义引用类型,可以使用 new 关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。
原型模式解决了成员共享的问题,只要是添加到构造函数 prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。
JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法。除上述模式之外,还有以下几种继承模式。
原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强。
与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。
寄生组合继承被认为是实现基于类型继承的最有效方式。ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型和对象类之间的鸿沟。