目录
前言
理解对象
属性的类型
数据属性【Data Properties】
访问器属性【Accessor Properties】
合并对象
对象标识及相等判定
增强的对象语法
1. 属性值简写
2.可计算属性
3.简写方法名
4.对象解构
5.嵌套解构
创建对象
工厂模式
构造函数模式
原型模式
原型是如何工作的
原型层级
原型和in操作符
属性枚举顺序
对象迭代
另一种原型语法
原型的动态性
原生对象原型
原型的问题
前言
ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。正因为如此(以及其他还未讨论的原因),可以把ECMAScript 的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数。
理解对象
创建自定义对象的通常方式是创建Object 的一个新实例,然后再给它添加属性和方法,如下例所示: sayName()方法会显示this.name
的值,这个属性会解析为person.name
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);
}
};
属性的类型
ECMA-262 标准定义了一些只内部使用的属性,这些属性分为两类:数据属性和访问器属性。为了表明这些属性是内部使用,它们被双中括号包裹着。
数据属性【Data Properties】
- [[Configurable]] 表明属性是否可以通过 delete 重新定义,默认为true
- [[Enumerable]] 表明属性是否可以通过 for-in 循环返回,默认为true
- [[Writable]] 表明是否可以更改属性值,默认为true
- [[Value]] 包含属性的真正值,默认值是 undefined
当用对象字面量定义一个属性值时,[[Configurable]]
,[[Enumerable]]
, 和[[Writable]]
都被设为 true 同时[[Value]]
属性被设为给定的值,
要修改属性的默认特性,就必须使用Object.defineProperty()
方法。这个方法接收3 个参数:
- 要给其添加属性的对象
- 属性的名称
- 一个描述符对象
let person = {};
Object.defineProperty(person, "name", {
configurable: false,
writable: false,
value: "Nicholas"
});
console.log(person.name); // "Nicholas"
person.name = "Greg";
console.log(person.name); // "Nicholas"
delete person.name;
console.log(person.name); // "Nicholas"
// 无法重新定义属性:name
Object.defineProperty(person, "name", {
configurable: true,
value: "Nicholas"
});
设置 writable 为false之后企图改变属性的值没有成功,严格模式下则会抛错
设置 configurable 为false之后企图删除属性没有成功。
设置了 configurable 为false之后,不能再设置为 true,企图设置为 true一律报错
访问器属性【Accessor Properties】
- [[Get]] 获取一个属性值,默认值是 undefined
- [[Set]] 设置一个属性值,默认值是 undefined
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
读取属性值
Object.getOwnPropertyDescriptor() 方法接收俩个参数:对象本身和属性名
合并对象
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 }
因此,Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。
对象标识及相等判定
在 ECMAScript6 之前,全等 === 符号不能判断某些情形
console.log(true === 1); // false
console.log({} === {}); // false
console.log("2" === 2); // false
// These have different representations in the JS engine and yet are treated as equal
console.log(+0 === -0); // true
console.log(+0 === 0); // true
console.log(-0 === 0); // true
// To determine NaN equivalence, the profoundly annoying isNaN() is required
console.log(NaN === NaN); // false
console.log(isNaN(NaN)); // true
ES6 新增了 Object.is() 修复这些问题,如果要判断的对象大于2,则使用递归
function recursivelyCheckEqual(x, …rest) {
return Object.is(x, rest[0]) &&
(rest.length < 2 || recursivelyCheckEqual(…rest));
}
增强的对象语法
1. 属性值简写
当属性名与变量名一样时:
let name = 'Matt';
let person = {
name
};
console.log(person); // { name: 'Matt' }
2.可计算属性
有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为JavaScript 表达式而不是字符串来求值:
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let uniqueToken = 0;
function getUniqueKey(key) {
return `${key}_${uniqueToken++}`;
}
let person = {
[getUniqueKey(nameKey)]: 'Matt',
[getUniqueKey(ageKey)]: 27,
[getUniqueKey(jobKey)]: 'Software engineer'
};
console.log(person); // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }
3.简写方法名
在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式,如下所示:
let person = {
sayName: function(name) {
console.log(`My name is ${name}`);
}
};
person.sayName('Matt'); // My name is Matt
let person = {
sayName(name) {
console.log(`My name is ${name}`);
}
};
person.sayName('Matt'); // My name is Matt
4.对象解构
可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。通过匹配来获取值
// 使用对象解构
let person = {
name: 'Matt',
age: 27
};
let { name: personName, age: personAge } = person;
console.log(personName); // Matt
console.log(personAge); // 27
- 解构赋值不一定与对象的属性匹配,当引用的属性不存在时,会赋值undefined:
- 可以在解构赋值的同时定义默认值,若匹配成功会覆盖默认值:
- 当解构值的数量与对象的属性数量不等时,若值的数量小于属性数量会赋值前面的属性,若大于则会报错。
- 解构并不要求变量必须在解构表达式中声明。
解构在内部使用函数ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。
即会把上例中的person视为一个对象进行处理
这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据ToObject()的定义),null和undefined 不能被解构,否则会抛出错误(这两个类型不能被当成对象进行处理)。
let { _ } = null; // TypeError
let { _ } = undefined; // TypeError
5.嵌套解构
解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性:
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' } }
需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分
在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响arguments对象
创建对象
虽然使用 Object 构造函数或者对象字面量来创建单一对象非常方便,但是不方便创建多个对象。在ES6 之前,没有正式地支持面向对象构造函数----比如类或者继承。不过接下来,你会看到这一过程如何逐渐成功演变。
- 工厂模式 【factory pattern】
- 构造函数模式 【constructor pattern】
- 原型模式 【prototype pattern】
构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。这是从面向对象编程语言那里借鉴的,有助于在ECMAScript 中区分构造函数和普通函数。
工厂模式
工厂模式在软件设计中非常流行,只要有返回新对象的函数,它不是构造函数或者类,那么就是工厂模式。
unction 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");
工厂模式解决了批量创建对象的问题,但是没有解决对象识别问题,所有对象原型都是 Object, 无法识别 Array Data 等类型。
构造函数模式
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");
console.log(person1 instanceof Person); // true,解决了工厂模式中不能识别的问
构造出来的对象的constructor属性会指向原函数:通过这种方式创造出的既是Object的实例又是Person的实例,因此可以体现出对象之间的差异,将对象标识为特定的一种类型。
构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。任何函数只要使用new 操作符调用就是构造函数,而不使用new 操作符调用的函数就是普通函数。
若直接作为函数调用,会将这些属性添加到window对象:
new操作符
使用 new 操作符来新建对象实例,调用构造函数会执行如下操作:
在内存中创建一个新对象。
这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype 属性。
构造函数内部的this 被赋值为这个新对象(即this 指向新对象)。
执行构造函数内部的代码(给新对象添加属性)。
如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
构造函数的问题 ❤❤❤❤❤❤❤
构造函数的缺点是新建每个实例方法都被创建了一次,比如上面的 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)"); // logical equivalent
}
也就是说,每一个Person 实例的sayName 方法都有一个自己的 Function 构造函数,这种重复是没有必要的,
原型模式
每一个新建的函数都有 prototype 属性,这个 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
在这里,sayName() 方法是挂载在原型对象上,构造函数为空。和2.2构造函数模型不一样的是,原型模式的实例共享所有的属性和方法
原型是如何工作的
无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype 属性(指向原型对象)。原型对象只会默认获得一个名为constructor 的属性,指回与之关联的构造函数(相等于指向构造函数Person),并会默认继承来自于Object的方法(会继承一个属性和多个方法)
正常的原型链都会终止于Object 的原型对象 Object 原型的原型是null
使用
Object.getPrototypeOf()
可以方便地取得一个对象的原型,返回参数的内部特性[[Prototype]]
的值setPrototypeOf()方法,可以向实例的私有特性[[Prototype]]写入一个新值(影响性能)
可以通过
Object.create()
来创建一个新对象,同时为其指定原型:
原型层级
每次在访问属性时,总是先在实例上搜索该属性是否存在,不存在则继续在原型上搜索。如果实例和原型同时都有该属性,实例的优先级更高,也就是说在实例上找到了该属性就不再往原型上去找了,即便是该属性值为null。
但是如果使用 delete 操作符移除了该属性,那么搜索会继续走向原型。
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();
person1.name = "Greg";
console.log(person1.name); // "Greg" - 来自实例 instance
console.log(person2.name); // "Nicholas" - 来自原型 prototype
hasOwnProperty 方法可以判断属性是存在实例上(返回true)还是原型上(返回false)。
在被遮蔽时,实例上调用hasPrototypeProperty()
返回false。不过,使用delete
操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象。
ECMAScript 的Object.hasOwnPropertyDescriptor()方法只对实例属性有效。
要取得原型属性的描述符,就必须直接在原型对象上调用Object.getOwnPropertyDescriptor()。如果指定的属性存在于对象上,则返回其属性描述符对象(property descriptor),否则返回 undefined。
原型和in操作符
主要有两种方式使用 in 操作符:直接在对象上操作和在 for-in 循环中操作。
直接在对象上使用,会同时检测实例上和原型上是否存在该属性。
function Person(){}
Person.prototype.name = "God";
let abby = new Person();
abby.hasOwnProperty("name"); // false 只检测实例上
console.log("name" in abby); // true 会同时检测实例上和原型上是否存在该属性
使用这个特性还可以检测只存在原型上的属性
function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object);
}
使用 for-in 循环时,会返回所有可以枚举的对象属性,包括实例上的和原型上的。
Object.keys
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
如果想得到包括不可枚举的属性,使用 Object.getOwnPropertyNames() 方法,注意 constructor 是不可枚举属性
let keys = Object.getOwnPropertyNames(Person.prototype);
console.log(keys); // ["constructor", "name","age","job", "sayName"]
Object.keys()
和Object.getOwnPropertyNames()
都是 for-in 循环很好的替代品
Object.getOwnPropertySymbols()
针对 ES6 新增的 Symbols 类型,Object.getOwnPropertyNames() 方法不再奏效,因为Symbols 没有属性名一说,所以引入了 Object.getOwnPropertySymbols()
let k1 = Symbol('k1'),
k2 = Symbol('k2');
let o = {
[k1]: 'k1',
[k2]: 'k2'
};
console.log(Object.getOwnPropertySymbols(o));
// [Symbol(k1), Symbol(k2)]
属性枚举顺序
for-in
循环,Object.keys()
,Object.getOwnPropertyNames/Symbols()
, 和Object.assign()
在枚举顺序上有很大的不同。
前两个在枚举顺序上是随机的,这是由JavaScript 引擎和浏览器决定的。
后两者则有确定的枚举顺序:数字类型按照增序,字符串和symbols 类型按照插入顺序,在对象字面量里面的会按照逗号分隔顺序
对象迭代
在JavaScript 很长的历史中,迭代对象属性都不太方便。 ECMAScript 2017 引入了两个静态方法 Object.values()
和Object.entries()
可以将对象内容转化为序列化的可迭代的形式。
onst o = {
foo: 'bar',
baz: 1,
qux: {}
};
console.log(Object.values(o));
// ["bar", 1, {}]
console.log(Object.entries((o)));
// [["foo", "bar"], ["baz", 1], ["qux", {}]]
另一种原型语法
注意到上面每次定义原型属性和方法都把 prototype 写了一遍,也可以简写为
function Person() {}
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
但这种写法相当于为Person.prototype这个属性赋了新的值,即重写了默认的原型对象。之前的写法是直接修改属性,所以不会影响。
这种写法会将一个新对象赋给Person.prototype,而不再是随着构造函数一起生成的对象,所以这个对象的constructor属性不再是Person函数,而是Object函数
如上所示,使用构造函数生成一个对象实例friend,它的属性继承于原型对象,也包括constructor属性,因此值是Object函数。
但可以通过使用其他方法来定义constructor 属性为构造函数
function Person() {
}
Person.prototype = {
constructor: Person, // ⚠️
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
// 这种方式等同于
// restore the constructor
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
});
原型的动态性
因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对
象所做的修改也会在实例上反映出来。下面是一个例子:
let friend = new Person();
Person.prototype.sayHi = function() {
console.log("hi");
};
friend.sayHi(); // "hi",没问题!
即使在实例定义之后修改原型对象也会在实例中反映出来
之所以会这样,主要原因是实例与原型之间松散的联系。在调用friend.sayHi()时,首先会从这个实例中搜索名为sayHi 的属性。在没有找到的情况下,运行时会继续搜索原型对象。因为实例和原型之间的链接就是简单的指针,而不是保存的副本,所以会在原型上找到sayHi 属性并返回这个属性保存的函数。
但是重写原型对象并不会反映在实例中,因为实例中指向原型对象的指针[[Prototype]]
是在实例生成时自动赋值的,因为重写原型对象并不会影响指针,所以实例中的方法和属性还是旧的原型对象上的。
function Person() {}
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
let friend = new Person();
Person.prototype = {
name: 'newName',
sayName() {
console.log(this.name);
}
};
friend.sayName(); // Nicholas
原生对象原型
所有原生引用类型的构造函数(包括Object、Array、String 等)都在原型上定义了实例方法。
通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。可以像修改自定义对象原型一样修改原生对象原型,因此随时可以添加方法。
比如,下面的代码就给String原始值包装类型的实例添加了一个startsWith()方法:
String.prototype.startsWith = function (text) {
return this.indexOf(text) === 0;
};
let msg = "Hello world!";
console.log(msg.startsWith("Hello")); // true
原型的问题
原型模式弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。
原型的最主要问题源自它的共享特性,当属性包含引用值时,在一个实例上进行修改会导致其他实例的该属性同样会被修改。
浅复制的问题
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