我们跳过内置对象那块的api使用部分,直接学习对象相关内容,等对象,函数部分学习完毕后,再来看内置数据类型,对象,集合,迭代器等相关知识。
八,对象、类与面向对象编程
8.1 理解对象
创建自定义对象的通常方式是创建 Object 的一个新实例,然后再给它添加属性和方法,就好比要创造一个人,需要定义从人这个对象创建一个真实的人,并设定性别,年龄等等。
let person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";
person.sayName = function() {
console.log(this.name);
};
不过现在普遍使用下面的方法创建对象
let person = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
小疑问:为什么这里可以直接创建而不用通过new创建呢?
答:JavaScript引擎会识别{}表示一个新的对象,并将其赋值给变量。这个过程是在运行时(runtime)发生的。JavaScript引擎会隐式地创建一个新的对象,并在内部调用Object构造函数来创建这个对象,因此你无需显式地使用new Object()来创建对象。
8.1.1 属性的类型
这里说的属性是JavaScript内置属性,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]。
属性分两:数据属性和访问器属性。
1.数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4个特性描述它们的行为。
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。
let person = {
name: "Henry"
};
这里,我们创建了一个名为 name 的属性,并给它赋予了一个值"Nicholas"。这意味着
[[Value]]特性会被设置为"Henry",之后对这个值的任何修改都会保存这个位置。而其他的几个数据属性默认为true。
要修改属性的默认特性,就必须使用 Object.defineProperty()方法。
let person = {};
Object.defineProperty(person, "name", {
writable: false,
value: "Nicholas"
});
console.log(person.name); // "Nicholas"
person.name = "Greg";
console.log(person.name); // "Nicholas"
其使用方法一目了然,接受三个参数,第一个为修改的对象目标,第二个为对象的属性名,第三个为一个对象,里面可以选填数据属性。
这里面有一个特殊的地方在于,可以对同一个属性多次调用 Object.defineProperty(),但在把 configurable 设置为 false 之后就会受限制了。
2. 访问器属性
访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数。访问器属性有 4 个特性描述它们的行为:
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特
性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。 - [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
- [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。
访问器属性同样要通过 Object.defineProperty()来定义。
// 定义一个对象,包含伪私有成员 year_和公共成员 edition
let book = {
year_: 2017,
edition: 1
};
Object.defineProperty(book, "year", {
get() {
return this.year_;
},
set(newValue) {
if (newValue > 2017) {
this.year_ = newValue;
this.edition += newValue - 2017;
}
}
});
book.year = 2018;
console.log(book.edition); // 2
这里有个需要注意的地方:
如果使用了Object.defineProperty()定义数据属性,那么数据属性的除value的其他三个特性为false,如果没有使用Object.defineProperty()来定义数据属性,那么就是true。
8.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;
}
}
}
});
8.1.3 读取属性的特性
使用 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"
当然类似defineProperty和defineProperties方法,getOwnPropertyDescriptor也有对应获取多个的api,用 Object.getOwnPropertyDescriptor(),该方法接收一个参数,返回值是一个包含该对象所有访问器属性和数据属性的对象集合。
8.1.4 合并对象
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 }
注意,第一个参数是目标对象,第二个之后的对象是源对象(需要合并的内容),当然该api是会影响到目标对象的。
Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使
用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象(基础数据类型深拷贝,引用数据类型浅拷贝)。不能在两个对象间转移获取函数和设置函数。
如果赋值期间出错,则操作会中止并退出,同时抛出错误。Object.assign()没有“回滚”之前
赋值的概念,因此它是一个尽力而为、可能只会完成部分复制的方法。
8.1.5 对象标识及相等判定
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
8.1.6 增强的对象语法
主要介绍一些定义和操作对象的语法糖。
1. 属性值简写
当对象的属性名称和变量名一样的时候可以简写。
2. 可计算属性
在之前对象字面量中直接动态命名属性,因为对象的属性名其实是字符串,有个可计算属性,就可以在对象内通过中括号包围告诉运行时将其作为JavaScript表达式而不是字符串。
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let person = {
[nameKey]: 'Matt',
[ageKey]: 27,
[jobKey]: 'Software engineer'
};
3. 简写方法名
let person = {
sayName(name) {
console.log(`My name is ${name}`);
}
};
person.sayName('Matt'); // My name is Matt
当然匿名函数无法做到上述例子。
8.1.7 对象解构
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='Software engineer' } = person;
console.log(name); // Matt
console.log(job); // Software engineer
只要是对象内可以找到的属性,都可以解构赋值,这里就包括原型链上面的属性,如
const { call } = () => {}
在解构的上下文中,原始数据类型会被当成对象,但根据规定,null和 undefined 不能被解构。
解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中:
let personName, personAge;
let person = {
name: 'Matt',
age: 27
};
({name: personName, age: personAge} = person);
console.log(personName, personAge); // Matt, 27
当然解构赋值对于引用类型也是浅拷贝。
解构赋值可以使用嵌套结构,以匹配嵌套的属性:
let person = {
name: 'Matt',
age: 27,
job: {
title: 'Software engineer'
}
};
// 声明 title 变量并将 person.job.title 的值赋给它
let { job: { title } } = person;
console.log(title); // Software engineer
这里理解成一直嵌套的通过“.”获取属性即可。
2. 部分解构
和Object.assign一样,报错后不会回滚,整个解构赋值只会完成一部分。
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
8.2 创建对象
虽然使用 Object 构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。(引出原型的概念)
8.2.1 概述
8.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");
这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题。(即新创建的对象是什么类型)
8.2.3 构造函数模式
ECMAScript 中的构造函数是用于创建特定类型对象的。像 Object 和 Array 这样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。当一个函数被new操作符修饰后,该函数就为构造函数,构造函数的通过new操作符返回的值就是对象。
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 的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。
要创建 Person 的实例,应使用 new 操作符。以这种方式调用构造函数会执行如下操作。
- 在内存中创建一个新对象。
- 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性。
- 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
- 执行构造函数内部的代码(给新对象添加属性)。
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
上一个例子的最后,person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个constructor 属性指向 Person。
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
实例化的时候,如果没有参数,可以直接通过new操作符赋值
let person2 = new Person;
1. 构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。
2. 构造函数的问题
构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。因此对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方法不是同一个 Function 实例。因为都是做一样的事,所以没必要定义两个不同的 Function 实例。
要解决这个问题,可以把函数定义转移到构造函数外部:
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()函数。这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。