理解对象
创建对象:是创建 Object 的一个新实例,然后再给它添加属性和方法
let person = new Object();
person.name = "Nicholas";
person.sayName = function() {
console.log(this.name);
};
对象字面量变成了更流行的方式。
let person = {
name: "Nicholas",
age: 29,
sayName() {
console.log(this.name);
}
};
属性的类型
属性分两种:数据属性和访问器属性
数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。
数据属性有 4 个特性描述它们的行为。
Configurable
:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
Enumerable
:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
Writable
:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
Value
:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。默认值为 undefined。
要修改属性的默认特性,就必须使用 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"
这个例子创建了一个名为 name 的属性并给它赋予了一个只读的值"Nicholas"。这个属性的值就 不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性 的值会抛出错误。
在调用 Object.defineProperty()时,configurable、enumerable 和 writable 的值如果不 指定,则都默认为 false。
访问器属性
访问器属性不包含数据值。
相反,它们包含一个获取(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的值为2018,会调用set函数,导致year_变成 2018,edition 变成 2。这是访问器属性的典型使用场景,即设置一个属性值会导致一些其他变化发生。
获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽 略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性 是不能读取的,非严格模式下读取会返回 undefined,严格模式下会抛出错误。
定义多个属性
在一个对象上同时定义多个属性的可能性是非常大的。为此,ECMAScript 提供了 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_和 edition,还有一个访问器属性 year。最终的对象跟上面示例中的一样。唯一的区别是所有属性都是同时定义的,并且数据属性的 configurable、enumerable 和 writable 特性值都是 false。
读取属性的特性
使用 Object.getOwnPropertyDescriptor()
方法可以取得指定属性的属性描述符。
这个方法接收两个参数:属性所在的对象
和要取得其描述符的属性名
。返回值是一个对象,对于访问器属性包含 configurable、enumerable、get 和 set 属性,对于数据属性包含 configurable、enumerable、 writable 和 value 属性。比如:读取上面定义的多个属性
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"
ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors()
静态方法。这个方法实际上 会在每个自有属性上调用 Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。
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
// }
// }
合并对象
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 = {};
result = Object.assign(dest, { a: 'foo' }, { b: 'bar' });
console.log(result); // { a: foo, b: bar }
/**
* 获取函数与设置函数
*/
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()而言拷贝的只是属性值,如果对象的属性值为简单类型(string,number),通过Object.assign()得到的新对象为深拷贝;如果属性值为对象或其他引用类型,那对于这个对象而言其实是浅拷贝
的。如果多个源对象都有相同的属性,则使 用最后一个复制的值。
对象标识及相等判定
在 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.属性值简写
简写属性名只要使用变量名(不用再写冒号)就会自动被解释为同 名的属性键。如果没有找到同名变量,则会抛出 ReferenceError。
let name = 'Matt';
let person = {
name: name
};
//简写为
let person = {
name
};
console.log(person); // { name: 'Matt' }
2. 可计算属性
有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时 将其作为 JavaScript 表达式而不是字符串来求值:
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}`);
}
};
person.sayName('Matt'); // My name is Matt
简写方法名对获取函数和设置函数也是适用的
let person = {
name_: '',
get name() {
return this.name_;
},
set name(name) {
this.name_ = name;
},
sayName() {
console.log(`My name is ${this.name_}`);
}
};
person.name = 'Matt';
person.sayName(); // My name is Matt
对象解构
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;
如果想 让变量直接使用属性的名称,那么可以使用简写语法,比如:
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
null 和 undefined 不能被解构,否则会抛出错误
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
2. 部分解构
需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及 多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分:
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
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
创建对象
虽然使用 Object 构造函数
或对象字面量
可以方便地创建对象,但这些方式也有明显不足:创建具 有同样接口的多个对象需要重复编写很多代码。
工厂模式
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 个方法的对象。这种工厂模式虽 然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)
。
构造函数模式
是用于创建特定类型对象的.
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) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
上一个例子的最后,person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个 constructor 属性指向 Person,如下所示
console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true
constructor 本来是用于标识对象类型的
。不过,一般认为 instanceof
操作符是确定对象类型 更可靠的方式。前面例子中的每个对象都是 Object 的实例,同时也是 Person 的实例,如下面调用 instanceof 操作符的结果所示:
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true
1. 构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。
任何函数只要使用 new 操作符调用就是构造函数
,而不使用 new 操 作符调用的函数就是普通函数。
// 作为构造函数
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"
这里要记住,在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或 者没有使用 call()/apply()调用),this 始终指向 Global 对象(在浏览器中就是 window 对象)。
2. 构造函数的问题
构造函数虽然有用,但也不是没有问题。构造函数的主要问题在于,其定义的方法会在每个实例上 都创建一遍
。
。因此对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方 法不是同一个 Function 实例。我们知道,ECMAScript 中的函数是对象,因此每次定义函数时,都会 初始化一个对象。逻辑上讲,这个构造函数实际上是这样的:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function("console.log(this.name)"); // 逻辑等价
}
这样理解这个构造函数可以更清楚地知道,每个 Person 实例都会有自己的 Function 实例用于显 示 name 属性。当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新 Function 实例的机制是一样的。因此不同实例上的函数虽然同名却不相等,如下所示:
console.log(person1.sayName == person2.sayName); // false
因为都是做一样的事,所以没必要定义两个不同的 Function 实例。况且,this 对象可以把函数 与对象的绑定推迟到运行时。 要解决这个问题,可以把函数定义转移到构造函数外部:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName() {
console.log(this.name);
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Nicholas
person2.sayName(); // Greg
在这里,sayName()被定义在了构造函数外部
。在构造函数内部,sayName 属性等于全局 sayName() 函数。因为这一次 sayName 属性中包含的只是一个指向外部函数的指针,所以 person1 和 person2 共享了定义在全局作用域
上的 sayName()函数。这样虽然解决了相同逻辑的函数重复定义的问题,但 全局作用域也因此被搞乱
了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法, 那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。
原型模式
每个函数都会创建一个 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
与构造函数模 式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1 和 person2 访问的 都是相同的属性和相同的 sayName()函数。
1. 理解原型
只要创建一个函数
,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)
。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数
。对前面的例子而言,Person.prototype.constructor 指向 Person。然后,因构造函数而异,可能会给原型对象添加其他属性和方法。
在自定义构造函数时,原型对象默认只会获得 constructor 属性
,其他的所有方法都继承自 Object。每次调用构造函数创建一个新实例,这个实例的内部
Prototype
指针就会被赋值为构造函数的原型对象
。脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性 完全被隐藏了。
关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
构造函数、原型对象和实例 是 3 个完全不同的对象:实例通过__proto__链接到原型对象, 构造函数通过 prototype 属性链接到原型对象。
console.log(person1.__proto__ === Person.prototype); // true
![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=C%3A%5CUsers%5C
虽然这两 个实例都没有属性和方法,但 person1.sayName()可以正常调用。这是由于对象属性查找机制的原因。
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Person.prototype.isPrototypeOf(person2)); // true
这里通过原型对象调用 isPrototypeOf()
方法检查了 person1 和 person2。因为这两个例子内 部都有链接指向 Person.prototype,所以结果都返回 true。
ECMAScript 的 Object 类型有一个方法叫 Object.getPrototypeOf(),返回参数的内部特性 [[Prototype]]的值。例如:
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(Object.getPrototypeOf(person1).name); // "Nicholas"
使用 Object.getPrototypeOf()
可以 方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要。
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()可能会严重影响代码性能。
可以通过 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. 原型层级
在通过对象访问属性
时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身
。如果在这个 实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象
,然后在原型对象上找到属性后,再返回对应的值。
虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。如果在实例上添加了一个 与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。也就是虽然 不会修改它,但会屏蔽对它的访问。
即使在实例上把这个属性设置为 null,也不会恢复它和原型的联 系。
使用 delete
操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索 原型对象。
hasOwnProperty()
方法用于确定某个属性是在实例上还是在原型对象
上。这个方法是继承自 Object 的,会在属性存在于调用它的对象实例上时返回 true
,如下面的例子所示:
person1.name = "Greg";
console.log(person1.name); // "Greg",来自实例
console.log(person1.hasOwnProperty("name")); // true
console.log(person2.name); // "Nicholas",来自原型
console.log(person2.hasOwnProperty("name")); // false
注意 ECMAScript 的
Object.getOwnPropertyDescriptor()
方法只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用 Object.getOwnPropertyDescriptor()。
3. 原型和 in 操作符
有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。在单独使用时,in 操作符会在可 以通过对象访问指定属性时返回 true,无论该属性是在实例上还是在原型上。来看下面的例子:
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();
let person2 = new Person();
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
person1.name = "Greg";
console.log(person1.name); // "Greg",来自实例
console.log(person1.hasOwnProperty("name")); // true
console.log("name" in person1); // true
console.log(person2.name); // "Nicholas",来自原型
console.log(person2.hasOwnProperty("name")); // false
console.log("name" in person2); // true
这里,name 属性首先只存在于原型上,所以 hasPrototypeProperty()
返回 true。而在实例 上重写这个属性后,实例上也有了这个属性,因此 hasPrototypeProperty()返回 false。即便此时 原型对象还有 name 属性,但因为实例上的属性遮蔽了它,所以不会用到。
let person = new Person();
console.log(hasPrototypeProperty(person, "name")); // true
person.name = "Greg";
console.log(hasPrototypeProperty(person, "name")); // false
在 for-in
循环中使用 in 操作符时,可以通过对象访问且可枚举的属性
都会返回,包括实例属性和原型属性
。遮蔽原型中不可枚举([[Enumerable]]特性被设置为 false)属性的实例属性也会 在 for-in 循环中返回,因为默认情况下开发者定义的属性都是可枚举的。
要获得对象上所有可枚举的实例属性,可以使用 Object.keys()
方法。这个方法接收一个对象作 为参数,返回包含该对象所有可枚举属性名称的字符串数组。比如:
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
let keys = Object.keys(Person.prototype);
console.log(keys); // "name,age,job,sayName"
let p1 = new Person();
p1.name = "Rob";
p1.age = 31;
let p1keys = Object.keys(p1);
console.log(p1keys); // "[name,age]"
如果想列出所有实例属性,无论是否可以枚举,都可以使用 Object.getOwnPropertyNames()
:
let keys = Object.getOwnPropertyNames(Person.prototype);
console.log(keys); // "[constructor,name,age,job,sayName]"
注意,返回的结果中包含了一个不可枚举的属性 constructor
。Object.keys()和 Object. getOwnPropertyNames()在适当的时候都可用来代替 for-in 循环。
在 ECMAScript 6 新增符号类型
之后,相应地出现了增加一个 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() 的枚举顺序是不确定的,取决于 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)]
对象迭代
这两个静态方法 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", {}]]
1. 其他原型语法
在前面的例子中,每次定义一个属性或方法都会把 Person.prototype 重 写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法 的对象字面量
来重写原型成为了一种常见的做法,如下面的例子所示:
function Person() {}
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
这样重写之后,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",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
但要注意,以这种方式恢复 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
});
2. 原型的动态性
因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对 象所做的修改也会在实例上反映出来。
let friend = new Person();
Person.prototype.sayHi = function() {
console.log("hi");
};
friend.sayHi(); // "hi",没问题!
虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两回事。
实例的Prototype指针是在调用构造函数时自动赋值的
,这个指针即使把原型修改为不同 的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。 记住,实例只有指向原型的指针,没有指向构造函数的指针
。来看下面的例子:
function Person() {}
let friend = new Person(); //friend实例指向的Person原型对象上没有sayName等属性
Person.prototype = { //Person原型上重写属性,原型对象变了
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"
4. 原型的问题
原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力
,会导致所有实例默 认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共 享特性。
我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性 也还好,如前面例子中所示,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题 来自包含引用值的属性
。来看下面的例子:
function Person() {}
Person.prototype = {
constructor: Person,
name: "Nicholas",
age: 29,
job: "Software Engineer",
friends: ["Shelby", "Court"],
sayName() {
console.log(this.name);
}
};
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
继承
很多面向对象语言都支持两种继承:接口继承和实现继承。 前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签 名。实现继承
是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链
实现的。
原型链
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 展示了子类的实例与两个构造函数及其对应的原型之间的关系。
这个例子中实现继承的关键,是 SubType 没有使用默认原型,而是将其替换成了一个新的对象。这个 新的对象恰好是 SuperType 的实例
。这样一来,SubType 的实例不仅能从 SuperType 的实例中继承属性 和方法,而且还与 SuperType 的原型挂上了钩。于是 instance(通过内部的[[Prototype]])指向 SubType.prototype,而 SubType.prototype(作为 SuperType 的实例又通过内部的[[Prototype]]) 指向 SuperType.prototype。
注意,getSuperValue()方法还在 SuperType.prototype 对象上, 而 property 属性则在 SubType.prototype 上。这是因为 getSuperValue()是一个原型方法,而 property 是一个实例属性。SubType.prototype 现在是 SuperType 的一个实例,因此 property 才会存储在它上面。
还要注意,由于 SubType.prototype 的 constructor 属性被重写为指向 SuperType,所以 instance.constructor 也指向 SuperType。
原型链扩展了前面描述的原型搜索机制。我们知道,在读取实例上的属性时,首先会在实例上搜索 这个属性。如果没找到,则会继承搜索实例的原型。在通过原型链实现继承之后,搜索就可以继承向上, 搜索原型的原型。对前面的例子而言,调用 instance.getSuperValue()经过了 3 步搜索:instance、 SubType.prototype 和 SuperType.prototype,最后一步才找到这个方法。对属性和方法的搜索会 一直持续到原型链的末端。
1. 默认原型
默认情况下,所有引用类型都继承自 Object
,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例
,这意味着这个实例有一个内部指针指向 Object.prototype。这也是为什么自定义类型能够继承包括 toString()、valueOf()在内的所有默认方法的原因。因此前面的例子还有额外一层继承关系。图 8-5 展示了完整的原型链
SubType 继承 SuperType,而 SuperType 继承 Object。在调用 instance.toString()时,实 际上调用的是保存在 Object.prototype 上的方法。
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
后面在 SubType 实例 上调用 getSuperValue()时调用的是新方法。而 SuperType 的实例仍然会调用最初的方法。
重点 在于上述两个方法都是在把原型赋值为 SuperType 的实例之后定义
的。
另一个要理解的重点
是,以对象字面量方式创建原型方法会破坏之前的原型链
,因为这相当于重写了原型链
。下面是一个例子:
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() {
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"
第二个问题是,子类型在实例化时不能给父类型的构造函数传参
。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题, 就导致原型链基本不会被单独使用
.
盗用构造函数
为了解决原型包含引用值导致的继承问题
,一种叫作“盗用构造函数”(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
2. 盗用构造函数的问题
盗用构造函数的主要缺点
,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用
。此外,子类也不能访问父类原型上定义的方法
,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用。
组合继承
组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数
,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性
。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。来看下面的例子:
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
是 JavaScript 中使用最多的继承模式。而且组合继 承也保留了 instanceof 操作符和 isPrototypeOf()方法识别合成对象的能力。
原型式继承
ECMAScript 5 通过增加 Object.create()
方法将原型式继承的概念规范化了。
这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。
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"
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合
。但要记住, 属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。
寄生式继承
寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象
。基本的寄生继承模式如下:
function createAnother(original){
let clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式增强这个对象
console.log("hi");
};
return clone; // 返回这个对象
}
在这段代码中,createAnother()函数接收一个参数,就是新对象的基准对象。这个对象 original 会被传给 object()函数,然后将返回的新对象赋值给 clone。接着给 clone 对象添加一个新方法 sayHi()。最后返回这个对象。可以像下面这样使用 createAnother()函数:
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = createAnother(person);
anotherPerson.sayHi(); // "hi"
这个例子基于 person 对象返回了一个新对象。新返回的 anotherPerson 对象具有 person 的所 有属性和方法,还有一个新方法叫 sayHi()。
寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object()函数不是寄生式 继承所必需的,任何返回新对象的函数都可以在这里使用。
注意:通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。
寄生式组合继承
组合继承
其实也存在效率问题
。最主要的效率问题就是父类构造函数始终会被调用两次
:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。
寄生式组合继承
通过盗用构造函数继承属性
,但使用混合式原型链继承方法
。
基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型
,然后将返回的新对象赋值给子类原型。寄生式组合继承的基本模式如下所示:
function inheritPrototype(subType, superType) {
let prototype = object(superType.prototype); // 创建对象
prototype.constructor = subType; // 增强对象
subType.prototype = prototype; // 赋值对象
}
inheritPrototype()函数实现了寄生式组合继承的核心逻辑。
这个函数接收两个参数:子类构造函数和父类构造函数。
在这个函数内部,第一步是
创建父类原型的一个副本
。第二步给返回的 prototype 对象
设置 constructor 属性
,解决由于重写原型导致默认 constructor 丢失的问题。最后将
新创建的对象赋值给子类型的原型
。
如下例所示,调用 inheritPrototype()就可以实现前面例 子中的子类型原型赋值。
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); //只调用了一次SuperType
SubType.prototype.sayAge = function() {
console.log(this.age);
};
这里只调用了一次 SuperType 构造函数
,避免了 SubType.prototype 上不必要也用不到的属性, 因此可以说这个例子的效率更高。
而且,原型链仍然保持不变,因此 instanceof 操作符和 isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。
类
前几节深入讲解了如何只使用 ECMAScript 5 的特性来模拟类似于类(class-like)的行为。不难看出, 各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。
为解决这些问题,ECMAScript 6 新引入的 class 关键字具有正式定义类的能力
。
类(class)是 ECMAScript 中新的基础性语法糖结构,因此刚开始接触时可能会不太习惯。虽然 ECMAScript 6 类表面 上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。
类定义
与函数类型相似,定义类也有两种主要方式:类声明
和类表达式
。这两种方式都使用 class 关键字加大括号
:
// 类声明
class Person {}
// 类表达式
const Animal = class {};
函数与类的相同点和区别:
相同:表达式定义在被求值前都不能引用(没有提升)
区别:函数声明可以提升,类声明不能提升;
函数是函数作用域,类是块作用域。
如下:
console.log(FunctionExpression); // undefined //函数表达式不可以提升
var FunctionExpression = function() {};
console.log(FunctionExpression); // function() {}
console.log(FunctionDeclaration); // FunctionDeclaration() {} //函数声明可以提升
function FunctionDeclaration() {}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassExpression); // undefined //类表达式不可以提升
var ClassExpression = class {};
console.log(ClassExpression); // class {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined //类声明不可以提升 报错
class ClassDeclaration {}
console.log(ClassDeclaration); // class ClassDeclaration {}
{
function FunctionDeclaration() {}
class ClassDeclaration {}
}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined
类的构成
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。 空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。
与函数构造函数一样,多数编程风格都建议类名的首字母要大写
,以区别于通过它创建的实例(比 如,通过 class Foo {}创建实例 foo):
// 空类定义,有效
class Foo {}
// 有构造函数的类,有效
class Bar {
constructor() {}
}
// 有获取函数的类,有效
class Baz {
get myBaz() {}
}
// 有静态方法的类,有效
class Qux {
static myQux() {}
}
类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称 字符串
。但不能在类表达式作用域外部访问这个标识符。
let Person = class PersonName {
identify() {
console.log(Person.name, PersonName.name);
}
}
let p = new Person();
p.identify(); // PersonName PersonName
console.log(Person.name); // PersonName
console.log(PersonName); // ReferenceError: PersonName is not defined
类构造函数
constructor 关键字
用于在类定义块内部创建类的构造函数
。方法名 constructor 会告诉解释器 在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函 数相当于将构造函数定义为空函数。
1.实例化
使用 new 操作符实例化 Person
的操作等于使用 new 调用其构造函数
。唯一可感知的不同之处就 是,JavaScript 解释器知道使用 new 和类意味着应该使用 constructor 函数进行实例化。
使用 new 调用类的构造函数会执行如下操作。
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的
Prototype
指针被赋值为构造函数的 prototype 属性。(3) 构造函数内部的 this 被赋值为这个新对象(即
this 指向新对象
)。(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
来看下面的例子:
class Animal {}
class Person {
constructor() {
console.log('person ctor');
}
}
class Vegetable {
constructor() {
this.color = 'orange';
}
}
let a = new Animal();
let p = new Person(); // person ctor
let v = new Vegetable();
console.log(v.color); // orange
类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的:
class Person {
constructor(name) {
console.log(arguments.length);
this.name = name || null;
}
}
let p1 = new Person; // 0
console.log(p1.name); // null
let p2 = new Person(); // 0
console.log(p2.name); // null
let p3 = new Person('Jake'); // 1
console.log(p3.name); // Jake
默认情况下,类构造函数会在执行之后返回 this 对象
。构造函数返回的对象会被用作实例化的对 象,如果没有什么引用新创建的 this 对象,那么这个对象会被销毁。不过,如果返回的不是 this 对 象,而是其他对象,那么这个对象不会通过 instanceof 操作符检测出跟类有关联
,因为这个对象的原型指针并没有被修改。
class Person {
constructor(override) {
this.foo = 'foo';
if (override) {
return {
bar: 'bar'
};
}
}
}
let p1 = new Person(),
p2 = new Person(true);
console.log(p1); // Person{ foo: 'foo' }
console.log(p1 instanceof Person); // true
console.log(p2); // { bar: 'bar' }
console.log(p2 instanceof Person); // false
类构造函数
与构造函数
的主要区别
是:
调用类构造函数必须使用 new 操作符
。而普通构造函数如果 不使用 new 调用,那么就会以全局的 this(通常是 window)作为内部对象。调用类构造函数时如果 忘了使用 new 则会抛出错误:
function Person() {}
class Animal {}
// 把 window 作为 this 来构建实例
let p = Person();
let a = Animal();
// TypeError: class constructor Animal cannot be invoked without 'new'
2. 把类当成特殊函数
[1]. 通过 typeof
操作符检测类标识符,表明它是一个函数:
class Person {}
console.log(Person); // class Person {}
console.log(typeof Person); // function
[2]. 类标识符有 prototype
属性,而这个原型也有一个 constructor
属性指向类自身:
class Person{}
console.log(Person.prototype); // { constructor: f() }
console.log(Person === Person.prototype.constructor); // true
[3]. 可以使用 instanceof
操作符检查构造函数原型是否存在于实例的原型链中:
class Person {}
let p = new Person();
console.log(p instanceof Person); // true
重点在于,类中定义的 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 {}
实例、原型和类成员
1. 实例成员
每次通过new调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this) 添加“自有”属性。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。
每个实例都对应一个唯一的成员对象
,这意味着所有成员都不会在原型上共享
:
class Person {
constructor() {
// 这个例子先使用对象包装类型定义一个字符串
// 为的是在下面测试两个对象的相等性
this.name = new String('Jack');
this.sayName = () => console.log(this.name);
this.nicknames = ['Jake', 'J-Dog']
}
}
let p1 = new Person(),
p2 = new Person();
p1.sayName(); // Jack
p2.sayName(); // Jack
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false
p1.name = p1.nicknames[0];
p2.name = p2.nicknames[1];
p1.sayName(); // Jake
p2.sayName(); // J-Dog
2. 原型方法与访问器
为了在实例间共享
方法,类定义语法把在类块中定义
的方法作为原型方法
。
class Person {
constructor() {
// 添加到 this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance');
}
// 在类块中定义的所有内容都会定义在类的原型上
locate() {
console.log('prototype');
}
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype
可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据
:
class Person {
name: 'Jake'
}
// Uncaught SyntaxError: Unexpected token
类方法等同于对象属性
,因此可以使用字符串、符号或计算的值作为键:
const symbolKey = Symbol('symbolKey');
class Person {
stringKey() {
console.log('invoked stringKey');
}
[symbolKey]() {
console.log('invoked symbolKey');
}
['computed' + 'Key']() {
console.log('invoked computedKey');
}
}
let p = new Person();
p.stringKey(); // invoked stringKey
p[symbolKey](); // invoked symbolKey
p.computedKey(); // invoked computedKey
类定义也支持获取``get
和设置访问器``set
。语法与行为跟普通对象一样:
class Person {
set name(newName) {
this.name_ = newName;
}
get name() {
return this.name_;
}
}
let p = new Person();
p.name = 'Jake';
console.log(p.name); // Jake
3. 静态类方法
这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。
与原型成员类似,静态成员每个类上只能有一个。
静态类成员在类定义中使用 static 关键字
作为前缀。在静态成员中,this 引用类自身
。其他所 有约定跟原型成员一样:
class Person {
constructor() {
// 添加到 this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance', this);
}
// 定义在类的原型对象上
locate() {
console.log('prototype', this);
}
// 定义在类本身上
static locate() {
console.log('class', this);
}
}
let p = new Person();
p.locate(); // instance, Person {}
Person.prototype.locate(); // prototype, {constructor: ... }
Person.locate(); // class, class Person {}
静态类方法非常适合作为实例工厂
:
class Person {
constructor(age) {
this.age_ = age;
}
sayAge() {
console.log(this.age_);
}
static create() {
// 使用随机年龄创建并返回一个 Person 实例
return new Person(Math.floor(Math.random()*100));
}
}
console.log(Person.create()); // Person { age_: ... }
4. 非函数原型和类成员
虽然类定义并不显式支持在原型或类上添加成员数据
,但在类定义外部
,可以手动添加
:
class Person {
sayName() {
console.log(`${Person.greeting} ${this.name}`);
}
}
// 在类上定义数据成员
Person.greeting = 'My name is';
// 在原型上定义数据成员
Person.prototype.name = 'Jake';
let p = new Person();
p.sayName(); // My name is Jake
5. 迭代器与生成器方法
类定义语法支持在原型
和类本身
上定义生成器方法
:
class Person {
// 在原型上定义生成器方法
*createNicknameIterator() {
yield 'Jack';
yield 'Jake';
yield 'J-Dog';
}
// 在类上定义生成器方法
static *createJobIterator() {
yield 'Butcher';
yield 'Baker';
yield 'Candlestick maker';
}
}
let jobIter = Person.createJobIterator();
console.log(jobIter.next().value); // Butcher
console.log(jobIter.next().value); // Baker
console.log(jobIter.next().value); // Candlestick maker
let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value); // Jack
console.log(nicknameIter.next().value); // Jake
console.log(nicknameIter.next().value); // J-Dog
因为支持生成器方法,所以可以通过添加一个默认的迭代器
,把类实例变成可迭代对象
:
class Person {
constructor() {
this.nicknames = ['Jack', 'Jake', 'J-Dog'];
}
*[Symbol.iterator]() {
yield *this.nicknames.entries();
}
}
let p = new Person();
for (let [idx, nickname] of p) {
console.log(nickname);
}
// Jack
// Jake
// J-Dog
也可以只返回迭代器实例:
class Person {
constructor() {
this.nicknames = ['Jack', 'Jake', 'J-Dog'];
}
[Symbol.iterator]() {
return this.nicknames.entries(); //只返回迭代器实例
}
}
let p = new Person();
for (let [idx, nickname] of p) {
console.log(nickname);
}
// Jack
// Jake
// J-Dog
继承
本章前面花了大量篇幅讨论如何使用 ES5 的机制实现继承。ECMAScript 6 新增特性中最出色的一 个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。
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
派生类都会通过原型链访问到类和原型上定义的方法
.
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 时要注意几个问题:
super 只能在派生类构造函数和静态方法中使用。
不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法
调用 super()会调用父类构造函数,并将返回的实例赋值给 this
super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的 参数。
在类构造函数中,不能在调用 super()之前引用 this
如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回 一个对象。
3. 抽象基类
有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。
虽然 ECMAScript 没 有专门支持这种类的语法 ,但通过 new.target
也很容易实现。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. 继承内置类型
ES6 类为继承内置引用类型提供了顺畅的机制
5. 类混入
把不同类的行为集中到一个类是一种常见的 JavaScript 模式。虽然 ES6 没有显式支持多类继承,但 通过现有特性可以轻松地模拟这种行为。
注意 Object.assign()方法是为了混入对象行为而设计的。只有在需要混入类的行为 时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用 Object.assign()就可以了。
在下面的代码片段中,extends 关键字后面是一个 JavaScript 表达式。任何可以解析为一个类或一 个构造函数的表达式都是有效的。这个表达式会在求值类定义时被求值。
class Vehicle {}
function getParentClass() {
console.log('evaluated expression');
return Vehicle;
}
class Bus extends getParentClass() {}
// 可求值的表达式
混入模式
可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被 继承的类。如果 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
通过写一个辅助函数,可以把嵌套调用展开:
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');
}
};
function mix(BaseClass, ...Mixins) {
return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
}
class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {}
let b = new Bus();
b.foo(); // foo
b.bar(); // bar
b.baz(); // baz
很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法 提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众 所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被 很多人遵循,在代码设计中能提供极大的灵活性。