JavaScript高级程序设计第四版学习记录-第八章对象、类与面向对象编程(一)(对象)

ECMA-262 将对象定义为一组属性的无序集合。
严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。
正因为如此(以及其他还未讨论的原因),可以把 ECMAScript 的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数。

8.1 理解对象

1、显式创建 Object 实例的两种方式
(1)第一种是使用 new 操作符和 Object 构造函数,创建 Object 的一个新实例,然后再给它添加属性和方法。
(2)另一种方式是使用对象字面量(object literal)表示法。
注意:在使用对象字面量表示法定义对象时,并不会实际调用 Object 构造函数。

例:创建一个名为 person 的对象,有三个属性(name、age 和 job)和一个方法sayName(),该方法会显示 this.name 的值,这个属性会解析为 person.name
下面两种定义方式是等价的,它们的属性和方法都一样。这些属性都有自己的特征,而这些特征决定了它们在 JavaScript 中的行为。

// new操作符和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); 
 } 
};

8.1.1 属性的类型 Object.defineProperty(obj, prop, descriptor)

ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义的。因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]

属性分两种:数据属性和访问器属性。

1、数据属性

在这里插入图片描述
(1) 在像前面例子中那样将属性显式添加到对象之后,[[Configurable]][[Enumerable]][[Writable]]都会被设置为 true,而[[Value]]特性会被设置为指定的值。比如:

let person = { 
 name: "Nicholas" 
}; 

这里,我们创建了一个名为 name 的属性,并给它赋予了一个值"Nicholas"。这意味着[[Value]]特性会被设置为"Nicholas",之后对这个值的任何修改都会保存这个位置。

(2) 要修改属性的默认特性,就必须使用 Object.defineProperty(obj, prop, descriptor)方法,接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。其中, 描述符对象上的属性可以包含:configurableenumerablewritablevalue,跟相关特性的名称一一对应
根据要修改的特性,可以设置其中一个或多个值。

// 例1 修改属性的默认特性-writeable为false, 属性的值不可被修改
let person = {}; 
Object.defineProperty(person, "name", { 
 writable: false, 
 value: "Nicholas" 
}); 
console.log(person.name); // "Nicholas" 
person.name = "Greg"; 
console.log(person.name); // "Nicholas"

例1 创建了一个名为 name 的属性并给它赋予了一个writable: false只读不可修改的值"Nicholas"。这个属性的值就不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性的值会抛出错误。

// 例2 修改属性的默认特性-configurable为false, 属性不可配置
let person = {}; 
Object.defineProperty(person, "name", { 
 configurable: false, 
 value: "Nicholas" 
}); 
console.log(person.name); // "Nicholas" 
delete person.name; 
console.log(person.name); // "Nicholas"

例2 configurable 设置为 false,意味着这个属性不能从对象上删除。非严格模式下对这个属性调用delete没有效果,严格模式下会抛出错误。

// 例3 一个属性被定义为不可配置之后,不能变回可配置
let person = {}; 
Object.defineProperty(person, "name", { 
 configurable: false, 
 value: "Nicholas" 
}); 
// 抛出错误
Object.defineProperty(person, "name", { 
 configurable: true, 
 value: "Nicholas" 
});

例3 一个属性被定义为不可配置、将其configurable 属性设置为false之后,就不能再变回可配置的了。再次调用Object.defineProperty()时,除了单向改变 writablefalse以外, 修改其他属性都会导致错误
在这里插入图片描述

因此,虽然可以对同一个属性多次调用 Object.defineProperty(),但在把 configurable 设置为 false 之后就会受限制了。

在调用 Object.defineProperty()时,configurableenumerablewritable 的值如果不指定,则都默认为 false

多数情况下,可能都不需要 Object.defineProperty()提供的这些强大的设置,但要理解 JavaScript 对象,就要理解这些概念。

2、访问器属性

访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。

在读取访问器属性时,会调用获取函数getter,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数setter并传入新值,这个函数必须决定对数据做出什么修改。

访问器属性有 4 个特性描述它们的行为。
[[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true
[[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true
[[Get]]:获取函数,在读取属性时调用。默认值为 undefined
[[Set]]:设置函数,在写入属性时调用。默认值为 undefined

访问器属性是不能直接定义的,必须使用 Object.defineProperty()
这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。其中, 描述符对象上的属性可以包含:configurableenumerablegetset,跟相关特性的名称一一对应。

// 定义一个book对象, 包含两个默认属性, 伪私有成员year_和公共成员edition
let book = { 
 year_: 2017, // _下划线表示该属性不希望在对象方法的外部被访问, 伪私有成员
 edition: 1
}; 
// 在book对象上定义一个访问器属性year
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_edition其中,year_中的下划线常用来表示该属性并不希望在对象方法的外部被访问。
又在book对象上定义了一个访问器属性year,其中get获取函数简单地返回 year_的值,而set设置函数会做一些计算以决定正确的版本(edition)。
因此,把 year 属性修改为 2018 会导致 year_变成 2018,edition 变成 2。
这是访问器属性的典型使用场景,即设置一个属性值会导致一些其他变化发生。

获取函数get和设置函数set不一定都要定义。
只定义获取函数get意味着属性是只读的,尝试修改属性会被忽略。在严格模式下,尝试写入只定义了获取函数get的属性会抛出错误。
类似地,只有一个设置函数set的属性是不能读取的,非严格模式下读取会返回 undefined,严格模式下会抛出错误。

在不支持 Object.defineProperty()的浏览器中没有办法修改[[Configurable]][[Enumerable]]

在这里插入图片描述

8.1.2 定义多个属性 Object.defineProperties(obj, props)

在一个对象上同时定义多个属性:
ECMAScript 提供了 Object.defineProperties(obj, props)方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。

例: 这段代码在 book 对象上定义了两个数据属性 year_edition,还有一个访问器属性 year。最终的对象跟上一节示例中的一样。
唯一的区别是所有属性都是同时定义的,并且数据属性的configurableenumerablewritable 特性值都是 false

let book = {}; 
Object.defineProperties(book, { 
 year_: { 
 	value: 2017 
 }, 
 edition: { 
 	value: 1 
 }, 
 year: { 
	 get() { 
	 	return this.year_; 
	 },
	 set(newValue) { 
	 	if (newValue > 2017) { 
	 		this.year_ = newValue; 
	 		this.edition += newValue - 2017; 
	 	} 
	 } 
 } 
});

8.1.3 读取属性的特性 Object.getOwnPropertyDescriptor(obj, prop), Object.getOwnPropertyDescriptors(obj)

注意 ECMAScriptObject.getOwnPropertyDescriptor()方法只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用 Object.getOwnPropertyDescriptor()

使用 Object.getOwnPropertyDescriptor(obj, prop)方法可以取得指定属性的属性描述符。
Object.getOwnPropertyDescriptor(obj, prop)方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含configurableenumerablegetset 属性,对于数据属性包含 configurableenumerablewritablevalue 属性。

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;
      }
    }
  }
});

// 数据属性 year_
let descriptor = Object.getOwnPropertyDescriptor(book, 'year_');
console.log(descriptor.value); // 2017
console.log(descriptor.configurable); // false
console.log(typeof descriptor.get); // "undefined"
// 访问器属性 year
let descriptor2 = Object.getOwnPropertyDescriptor(book, 'year');
console.log(descriptor2.value); // undefined
console.log(descriptor2.enumerable); // false
console.log(typeof descriptor2.get); // "function"

对于数据属性 year_value 等于原来的值,configurablefalsegetundefined。对于访问器属性 yearvalueundefinedenumerablefalseget 是一个指向获取函数的指针。
在这里插入图片描述

ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors(obj)静态方法。Object.getOwnPropertyDescriptors(obj)这个方法实际上会在每个自有属性上调用 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 
// } 
// }

在这里插入图片描述

8.1.4 合并对象 Object.assign(target, …sources)

“合并”(merge)两个对象,是把源对象所有的本地属性一起复制到目标对象上。
这种操作也被称为“混入”(mixin),因为目标对象通过混入源对象的属性得到了增强。

ECMAScript 6 专门为合并对象提供了 Object.assign()方法。
Object.assign(target, ...sources)这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举属性(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 } 
/** 
 * 获取函数与设置函数
 */ 
let dest, 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()实际上对每个源对象执行的是浅复制,只会复制对象的引用。如果多个源对象都有相同的属性,则使用最后一个复制的值。

此外,从源对象访问器属性取得的值,比如get获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移get获取函数和set设置函数

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 = { 
 set id(x) { 
 	console.log(x); 
 } 
}; 
Object.assign(dest, { id: 'first' }, { id: 'second' }, { id: 'third' }); 
// first 
// second 
// third 


/** 
 * 对象引用
 */ 
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 }

8.1.5 对象标识及相等判定 Object.is(value1, value2)

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

NaN(Not a Number) - isNaN()函数
在这里插入图片描述

为改善这类情况,ECMAScript 6 规范新增了 Object.is(value1, value2),这个方法与===很像,但同时也考虑到了上述边界情形。这个方法必须接收用于比较的两个参数。

// 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)); 
}

8.1.6 增强的对象语法

ECMAScript 6 为定义和操作对象新增了很多极其有用的语法糖特性。这些特性都没有改变现有引擎的行为,但极大地提升了处理对象的方便程度。
本节介绍的所有对象语法同样适用于 ECMAScript 6 的类。

1、属性值简写(只使用变量名(不写冒号)就会自动被解释为同名的属性键)

在给对象添加变量的时候,开发者经常会发现属性名和变量名是一样的。例如:

let name = 'Matt'; 
let person = { 
 name: name 
}; 
console.log(person); // { name: 'Matt' } 

可以使用简写属性名语法简化。
简写属性名只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键。如果没有找到同名变量,则会抛出 ReferenceError
以下代码和之前的代码是等价的:

let name = 'Matt'; 
let person = { 
 name // name: name 的省略形式
}; 
console.log(person); // { name: 'Matt' } 

代码压缩程序会在不同作用域间保留属性名,以防止找不到引用。以下面的代码为例,即使参数标识符name只限定于函数作用域,编译器也会保留初始的 name 标识符。

function makePerson(name) { 
 return { 
 	// 只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键
 	name // name: name 的省略形式
 }; 
} 
let person = makePerson('Matt'); 
console.log(person); // {name: "Matt"}

如果使用 Google Closure 编译器压缩,那么函数参数会被缩短(name --> a),而属性名不变:

function makePerson(a) { 
 return { 
 	name: a 
 }; 
} 
var person = makePerson("Matt"); 
console.log(person); // Matt

2、可计算属性 (中括号包围的对象属性键)

在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语法来添加属性。
换句话说,不能在对象字面量中直接动态命名属性

例:使用namekey/agekey/jobkey变量的值, 通过中括号语法添加成为person的属性

const nameKey = 'name'; 
const ageKey = 'age';
const jobKey = 'job'; 
let person = {}; 
person[nameKey] = 'Matt'; 
person[ageKey] = 27; 
person[jobKey] = 'Software engineer'; 
console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }

有了可计算属性,就可以在对象字面量中完成动态属性赋值。
中括号包围的对象属性键告诉运行时将其作为 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' }

因为被当作 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 

简写方法名对获取函数get和设置函数set也是适用的
在这里插入图片描述

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'; // 通过set方法设置this.name_ = name
person.sayName(); // My name is Matt 

简写方法名与可计算属性键相互兼容

const methodKey = 'sayName'; 
let person = { 
 [methodKey](name) { 
 	console.log(`My name is ${name}`); 
 } 
} 
person.sayName('Matt'); // My name is Matt 

在这里插入图片描述

注意 简写方法名对于本章后面介绍的 ECMAScript 6 的类更有用

8.1.7 对象解构

ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。
简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值
对象解构赋值是浅拷贝。

let person = { 
 name: 'Matt', 
 age: 27 
};
// 不使用对象解构
let personName = person.name, personAge = person.age; 
console.log(personName); // Matt 
console.log(personAge); // 27 

// 使用对象解构
let { name: personName2, age: personAge2 } = person; 
console.log(personName2); // Matt 
console.log(personAge2); // 27

使用解构,可以在一个类似对象字面量的结构中,声明多个变量,同时执行多个赋值操作。
如果想让变量直接使用属性的名称,那么可以使用简写语法
,比如:

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; // name变量直接使用了属性的名称, 但job属性不存在
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

解构在内部使用函数ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject()的定义),nullundefined 不能被解构,否则会抛出错误

let { length } = 'foobar'; 
console.log(length); // 6 
let { constructor: c } = 4; 
console.log(c === Number); // true 
let { _ } = null; // TypeError 
let { _ } = undefined; // TypeError 

在这里插入图片描述
解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中:

let personName, personAge; // 事先声明了变量personName personAge
let person = { 
 name: 'Matt', 
 age: 27 
}; 
// 则赋值表达式必须包含在一对括号中
({name: personName, age: personAge} = person); 
console.log(personName, personAge); // Matt, 27

1、嵌套解构

解构对于引用嵌套的属性或赋值目标没有限制。
为此,可以通过解构来复制对象属性:
(下面的例子还证明了对象解构赋值是浅拷贝。)

let person = { 
 name: 'Matt', 
 age: 27, 
 job: { 
 	title: 'Software engineer' 
 } 
}; 
let personCopy = {}; 
({ 
 name: personCopy.name, 
 age: personCopy.age, 
 job: personCopy.job 
} = person); 
console.log(person); 
// { name: 'Matt', age: 27, job: { title: 'Software engineer' } }
console.log(personCopy); 
// { name: 'Matt', age: 27, job: { title: 'Software engineer' } }


// 因为一个对象的引用被赋值给 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; // 声明title变量
console.log(title); // Software engineer

console.log(job) // 并没有声明job, 上面仅声明了title
// Uncaught ReferenceError: job is not defined at <anonymous>:1:13

let { job } = person; // 声明job 
console.log(job);
// {title: "Software engineer"}

在外层属性没有定义的情况下不能使用嵌套解构。无论源对象还是目标对象都一样

let person = { 
 job: { 
 	title: 'Software engineer' 
 } 
}; 
let personCopy = {}; 

// foo 在源对象person上是 undefined 
({ 
 foo: { 
 bar: personCopy.bar 
 } 
} = person); 
// TypeError: Cannot destructure property 'bar' of 'undefined' or 'null'. 


// job 在目标对象personCopy上是 undefined 
({ 
 job: { 
 title: personCopy.job.title 
 } 
} = person); 
// TypeError: Cannot set property 'title' of undefined

在这里插入图片描述

2、部分解构

需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分

let person = { 
 name: 'Matt', 
 age: 27 
}; 
let personName, personBar, personAge; 
try { 
 // person.foo 是 undefined,因此会抛出错误, 解构赋值终止, 后面的personAge为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

8.2 创建对象

在这里插入图片描述

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"); 

这里,函数 createPerson()接收 3 个参数,根据这几个参数构建了一个包含 Person 信息的对象。
可以用不同的参数多次调用这个函数,每次都会返回包含 3 个属性和 1 个方法的对象。
这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。

8.2.3 构造函数模式

ECMAScript 中的构造函数是用于创建特定类型对象的。像 ObjectArray 这样的原生构造函数,运行时可以直接在执行环境中使用
// 创建一个包含 3 个字符串值的数组:
let colors = new Array("red", "blue", "green");
当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

8.2.2中的例子可以使用构造函数模式改写:

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) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

上一个例子中,person1person2 分别保存着 Person 的不同实例。这两个对象都有一个constructor 属性指向 Person,如下所示:

console.log(person1.constructor == Person); // true 
console.log(person2.constructor == Person); // true 

constructor 本来是用于标识对象类型的。不过,一般认为 instanceof 操作符是确定对象类型更可靠的方式前面例子中的每个对象都是 Person 的实例,同时也是 Object 的实例,是因为所有自定义对象都继承自 Object
定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处

如下面调用instanceof 操作符的结果所示:

console.log(person1 instanceof Object); // true 
console.log(person1 instanceof Person); // true 实例被标识为特定类型Person
console.log(person2 instanceof Object); // true 
console.log(person2 instanceof Person); // true 实例被标识为特定类型Person

构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数

// 用赋值给变量Person的函数表达式来表示构造函数
let Person = function(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 
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 操作符,就可以调用相应的构造函数:

function Person() { 
 this.name = "Jake"; 
 this.sayName = function() { 
 console.log(this.name); 
 }; 
} 
let person1 = new Person(); 
let person2 = new Person;
person1.sayName(); // Jake 
person2.sayName(); // Jake 
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操作符-普通函数)

构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。
并没有把某个函数定义为构造函数的特殊语法。任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。

比如,前面的例子中定义的 Person()可以像下面这样调用:

// 作为构造函数 
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" 

(1) 这个例子一开始展示了典型的构造函数调用方式,即使用 new 操作符创建一个新对象。

(2) 然后是普通函数的调用方式,这时候没有使用 new 操作符调用 Person(),结果会将属性和方法添加到 window 对象。
这里要记住,在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或者没有使用 call()/apply()调用),this 始终指向 Global 对象(在浏览器中就是 window 对象)
因此在上面的调用之后,window 对象上就有了一个 sayName()方法,调用它会返回"Greg"

(3) 最后展示的调用方式是通过 call()(或 apply())调用函数,同时将特定对象指定为作用域。这里的调用将对象 o指定为 Person()内部的 this 值,因此执行完函数代码后,所有属性和 sayName()方法都会添加到对象 o 上面

2、构造函数的问题(定义的方法会在每个实例上都创建一遍)

构造函数虽然有用,但也不是没有问题。
构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。

因此对前面的例子而言,person1person2 都有名为 sayName()的方法,但这两个方法不是同一个 Function 实例。因为ECMAScript 中的函数function是对象,因此每次定义函数时,都会初始化一个对象

逻辑上讲,这个构造函数实际上是这样的:

function Person(name, age, job){ 
 this.name = name; 
 this.age = age; 
 this.job = job; 
 this.sayName = new Function("console.log(this.name)"); // 与前面方法逻辑等价
 // this.sayName = 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; 
} 
// 全局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 
console.log(person1.sayName == person2.sayName); // true

在这里,sayName()被定义在了构造函数外部。在构造函数内部,sayName 属性等于全局 sayName()函数。因为 sayName 属性中包含的只是一个指向外部函数的指针,所以 person1person2共享了定义在全局作用域上的 sayName()函数

这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。

8.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

使用函数表达式也可以:

let Person = function() {}; 
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()方法都直接添加到了 Personprototype 属性上,构造函数体中什么也没有。但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法

与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1person2 访问的都是相同的属性和相同的 sayName()函数。要理解这个过程,就必须理解 ECMAScript 中原型的本质。

1、理解原型 Object.setPrototypeOf(obj, prototype), Object.getPrototypeOf(obj), Object.create(proto)

无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。
默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。(对前面的例子而言,Person.prototype.constructor 指向 Person。)
然后,因构造函数而异,可能会给原型对象添加其他属性和方法

在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自Object
每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。
脚本中没有访问这个[[Prototype]]特性的标准方式,但 FirefoxSafariChrome会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性完全被隐藏了

关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有

这种关系不好可视化,但可以通过下面的代码来理解原型的行为
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

/**
 * 构造函数可以是函数表达式
 * 也可以是函数声明,因此以下两种形式都可以:
 * function Person() {}
 * let Person = function() {}
 */
function Person() {}
/**
 * 声明之后,构造函数就有了一个
 * 与之关联的原型对象:
 */
console.log(typeof Person.prototype);
// object
console.log(Person.prototype);
// {
// constructor: f Person(),
// __proto__: Object
// }
/**
 * 如前所述,构造函数有一个 prototype 属性引用其原型对象,
 * 而这个原型对象也有一个constructor 属性,引用这个构造函数
 * 换句话说,两者循环引用:
 */
console.log(Person.prototype.constructor === Person); // true
/**
 * 正常的原型链都会终止于 Object 的原型对象
 * Object 原型的原型是 null
 */
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Person.prototype.__proto__.constructor === Object); // true
console.log(Person.prototype.__proto__.__proto__ === null); // true
console.log(Person.prototype.__proto__);
// {
// constructor: f Object(),
// toString: ...
// hasOwnProperty: ...
// isPrototypeOf: ...
// ...
// }

let person1 = new Person();
let person2 = new Person();

/**
 * 构造函数Person、原型对象Person.prototype和实例person1 
 * 是 3 个完全不同的对象:
 */
console.log(person1 !== Person); // true
console.log(person1 !== Person.prototype); // true
console.log(Person.prototype !== Person); // true

/**
 * 实例通过__proto__链接到原型对象,
 * 它实际上指向隐藏特性[[Prototype]]
 *
 * 构造函数通过 prototype 属性链接到原型对象
 *
 * 实例与构造函数没有直接联系,与原型对象有直接联系
 */
console.log(person1.__proto__ === Person.prototype); // true
console.log(person1.__proto__.constructor === Person); // true
/**
 * 同一个构造函数创建的两个实例
 * 共享同一个原型对象:
 */
console.log(person1.__proto__ === person2.__proto__); // true
/**
 * instanceof 检查实例的原型链中
 * 是否包含指定构造函数的原型:
 */
console.log(person1 instanceof Person); // true
console.log(person1 instanceof Object); // true
console.log(Person.prototype instanceof Object); // true

在这里插入图片描述

上图展示了 Person 构造函数(Person())、Person 的原型对象(Person.prototype)和 Person 现有两个实例(person1, person2)之间的关系。

注意
Person.prototype 指向原型对象,而 Person.prototype.contructor 指回 Person 构造函数。
原型对象包含 constructor 属性和其他后来添加的属性。
Person 的两个实例 person1person2 都只有一个内部属性指回 Person.prototype,而且两者都与构造函数没有直接联系。
另外要注意,虽然这两个实例都没有属性和方法,但 person1.sayName()可以正常调用。这是由于对象属性查找机制的原因。

虽然不是所有实现都对外暴露了[[Prototype]],但可以使用 isPrototypeOf()方法确定两个对象之间的这种关系。本质上,isPrototypeOf()会在传入参数的[[Prototype]]指向调用它的对象时返回 true,如下所示:

console.log(Person.prototype.isPrototypeOf(person1)); // true 
console.log(Person.prototype.isPrototypeOf(person2)); // true 

这里通过原型对象调用 isPrototypeOf()方法检查了 person1person2。因为这两个例子内部都有链接指向 Person.prototype,所以结果都返回 true

ECMAScriptObject 类型有一个方法叫 Object.getPrototypeOf(obj),返回参数的内部特性[[Prototype]]的值。例如:

console.log(Object.getPrototypeOf(person1) == Person.prototype); // true 
console.log(Object.getPrototypeOf(person1).name); // "Nicholas" 

第一行代码简单确认了 Object.getPrototypeOf()返回的对象就是传入对象的原型对象。
第二行代码则取得了原型对象上 name 属性的值,即"Nicholas"
使用 Object.getPrototypeOf()可以方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要(本章后面会介绍)。
在这里插入图片描述

Object 类型还有一个 setPrototypeOf(obj, prototype)方法,可以向实例的私有特性[[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(proto)来创建一个新对象,同时为其指定原型。
Object.create(proto)方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__

在这里插入图片描述

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、原型层级(原型的搜索机制) hasOwnProperty()判断实例上的自有属性

在通过对象访问属性时,会按照这个属性的名称开始搜索。
搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。

因此,在调用 person1.sayName()时,会发生两步搜索。首先,JavaScript 引擎会问:“person1 实例有 sayName 属性吗?”答案是没有。然后,继续搜索并问:“person1 的原型有 sayName 属性吗?”答案是有。于是就返回了保存在原型上的这个函数。在调用 person2.sayName()时,会发生同样的搜索过程,而且也会返回相同的结果。这就是原型用于在多个对象实例间共享属性和方法的原理

注意 前面提到的 constructor 属性只存在于原型对象,因此通过实例对象也是可以访问到的。

虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。
如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。

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",来自实例
console.log(person2.name); // "Nicholas",来自原型

在这个例子中,person1name 属性遮蔽了原型对象上的同名属性。虽然 person1.nameperson2.name 都返回了值,但前者返回的是"Greg"(来自实例),后者返回的是"Nicholas"(来自原型)。当 console.log()访问 person1.name 时,会先在实例上搜索个属性。因为这个属性在实例上存在,所以就不会再搜索原型对象了。而在访问 person2.name 时,并没有在实例上找到这个属性,所以会继续搜索原型对象并使用定义在原型上的属性。

只要给对象实例添加一个属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。即使在实例上把这个属性设置为 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",来自实例
console.log(person2.name); // "Nicholas",来自原型
delete person1.name; 
console.log(person1.name); // "Nicholas",来自原型

这个修改后的例子中使用 delete 删除了 person1.name,这个属性之前以"Greg"遮蔽了原型上的同名属性。然后原型上 name 属性的联系就恢复了,因此再访问 person1.name 时,就会返回原型对象上这个属性的值。

hasOwnProperty()方法用于确定某个属性是在实例上还是在原型对象上, 继承自 Object,会在属性存在于调用它的对象实例上时返回 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 
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 
delete person1.name; 
console.log(person1.name); // "Nicholas",来自原型
console.log(person1.hasOwnProperty("name")); // false

在这个例子中,通过调用 hasOwnProperty()能够清楚地看到访问的是实例属性还是原型属性
调用 person1.hasOwnProperty("name")只在重写 person1name 属性的情况下才返回 true,表明此时 name 是一个实例属性,不是原型属性。
图 8-2 形象地展示了上面例子中各个步骤的状态。(为简单起见,图中省略了 Person 构造函数。)
在这里插入图片描述

注意 ECMAScriptObject.getOwnPropertyDescriptor()方法只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用 Object.getOwnPropertyDescriptor()

3、原型和in操作符 (in / for-in / Object.keys() / Object.getOwnPropertyNames() / Object.getOwnPropertySymbols())

有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。

(1) 在单独使用时,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 

delete person1.name; 
console.log(person1.name); // "Nicholas",来自原型
console.log(person1.hasOwnProperty("name")); // false 
console.log("name" in person1); // true

在上面整个例子中,name 随时可以通过实例或通过原型访问到。因此,调用"name" in persoon1时始终返回 true,无论这个属性在原型上或者在实例上。

如果要确定某个属性是否存在于原型上,则可以像下面这样同时使用 hasOwnProperty()in 操作符
只要通过对象可以访问,in 操作符就返回 true,而 hasOwnProperty()只有属性存在于实例上时才返回 true。因此,只要 in 操作符返回 truehasOwnProperty()返回 false,就说明该属性是一个原型属性。

// 自定义函数hasPrototypeProperty 用于确定某个属性是否存在于原型上
function hasPrototypeProperty(object, name){ 
 return !object.hasOwnProperty(name) && (name in object); 
} 
function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let person = new Person(); 
console.log(hasPrototypeProperty(person, "name")); // true 
person.name = "Greg"; 
console.log(hasPrototypeProperty(person, "name")); // false

在这里,name 属性首先只存在于原型上,所以 hasPrototypeProperty()函数返回 true。而在实例上重写这个属性后,实例上也有了这个属性,因此 hasPrototypeProperty()函数返回 false。即便此时原型对象还有 name 属性,但因为实例上的属性遮蔽了它,所以不会用到

(2) 在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。遮蔽原型中不可枚举([[Enumerable]]特性被设置为 false)属性的实例属性也会在 for-in 循环中返回,因为默认情况下开发者定义的属性都是可枚举的。

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let person = new Person(); // 具有原型属性
person.name2 = "Greg"; // 实例属性
for ( let i in person) {
	// 所有可以被枚举的属性都会被返回 
	console.log(i); // name name2 age job sayName
}

在这里插入图片描述
(3) 要获得对象上所有可枚举的实例属性,可以使用 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]"

这里,keys 变量保存的数组中包含"name"、“age”、“job"和"sayName”。这是正常情况下通过for-in 返回的顺序。而在 Person 的实例p1上调用时,Object.keys()返回的数组中只包含实例p1上的"name"和"age"两个属性。

(4) 如果想列出所有实例属性,无论是否可以枚举,都可以使用 Object.getOwnPropertyNames()

注意,下例返回的结果中包含了一个不可枚举的属性 constructor

let keys = Object.getOwnPropertyNames(Person.prototype); 
console.log(keys); // "[constructor,name,age,job,sayName]" 

Object.keys()-(所有可枚举的实例属性)和 Object.getOwnPropertyNames()-(所有实例属性无论是否可枚举)在适当的时候都可用来代替 for-in 循环。

(5) 在 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()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)]

8.2.5 对象迭代

ECMAScript 2017 新增了两个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式。这两个静态方法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", {}]]

这两个方法执行对象的浅复制

const o = { 
 qux: {}
};
console.log(Object.values(o)[0] === o.qux); 
// true 
console.log(Object.entries(o)[0][1] === o.qux); 
// true 都为 {}
console.log(Object.entries(o))
// [["qux", {}]]

符号属性会被忽略

const sym = Symbol(); 
const o = { 
 [sym]: 'foo' 
}; 
console.log(Object.values(o)); 
// [] 
console.log(Object.entries((o))); 
// []

1、其他原型语法(对象字面量创建prototype)

在前面的例子中,每次定义一个属性或方法都会把 Person.prototype 重写一遍。
为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型。

function Person() {} 
Person.prototype = { // 通过对象字面量创建的新对象
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
};

在这里插入图片描述
在这里插入图片描述
在这个例子中,Person.prototype 被设置为等于一个通过对象字面量创建的新对象,最终结果是一样的。

只有一个问题:这样重写之后,Person.prototypeconstructor 属性就不指向 Person了。在创建函数时,也会创建它的 prototype 对象,同时会自动给这个原型的 constructor 属性赋值。
而上面的写法完全重写了默认的 prototype 对象,因此其 constructor 属性也指向了完全不同的新对象(Object 构造函数),不再指向原来的构造函数

虽然 instanceof 操作符还能可靠地返回值,但不能再依靠 constructor 属性来识别类型了,
instanceof仍然对ObjectPerson都返回true。但constructor属性现在等于Object而不是 Person

let friend = new Person(); 

// instanceof 仍然对 Object 和 Person 都返回 true
console.log(friend instanceof Object); // true 
console.log(friend instanceof Person); // true

// constructor属性现在等于 Object 而不是 Person
//  constructor属性指向了完全不同的新对象(Object构造函数)
console.log(friend.constructor == Person); // false 
console.log(friend.constructor == Object); // true

如果 constructor 的值很重要,则可以在重写原型对象时专门设置一下它的值
特意包含了 constructor 属性,并将它设置为 Person,保证了这个属性仍然包含恰当的值。

function Person() { 
} 
Person.prototype = { 
 constructor: Person, 
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
}; 

但要注意,以这种方式恢复 constructor 属性会创建一个[[Enumerable]]true 的属性。而原生 constructor 属性默认是不可枚举的。
在这里插入图片描述
因此,如果你使用的是兼容 ECMAScriptJavaScript 引擎,那可能会改为使用 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"

以上代码先创建一个 Person 实例并保存在 friend 中。然后一条语句在 Person.prototype 上添加了一个名为 sayHi()的方法。虽然 friend 实例是在添加方法之前创建的,但它仍然可以访问这个方法。
这样主要是实例与原型之间松散的联系。在调用 friend.sayHi()时,首先会从这个实例中搜索名为 sayHi 的属性。在没有找到的情况下,运行时会继续搜索原型对象。
因为实例和原型之间的链接就是简单的指针,而不是保存的副本,所以会在原型上找到 sayHi 属性并返回这个属性保存的函数。

虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两回事。
实例的[[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()的时候,会导致错误。这是因为 friend 指向的原型还是最初的原型,而这个原型上并没有 sayName 属性。
重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。
在这里插入图片描述

3、原生对象原型

原型模式之所以重要,不仅体现在自定义类型上,而且它也是实现所有原生引用类型的模式。

所有原生引用类型的构造函数(包括 ObjectArrayString 等)都在原型上定义了实例方法
比如,数组实例的 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 

如果给定字符串的开头出现了调用 startsWith()方法的文本,那么该方法会返回 true。因为这个方法是被定义在 String.prototype 上,所以当前环境下所有的字符串都可以使用这个方法。msg是个字符串,在读取它的属性时,后台会自动创建 String 的包装实例,从而找到并调用 startsWith()方法。

在这里插入图片描述

4、原型的问题

原型模式也不是没有问题。

(1) 原型弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值,会带来不便。
(2) 原型的最主要问题源自它的共享特性。

原型上的所有属性是在实例间共享的,这对函数来说比较合适。
另外包含原始值的属性也还好,如前面例子中所示,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。
真正的问题来自包含引用值的属性。

来看下面的例子:

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 

这里,Person.prototype 有一个名为 friends 的属性,它包含一个字符串数组。然后这里创建了两个 Person 的实例。person1.friends 通过 push 方法向数组中添加了一个字符串。由于这个friends 属性存在于 Person.prototype 而非 person1 上,新加的这个字符串也会在(指向同一个数组的)person2.friends 上反映出来。

如果这是有意在多个实例间共享数组,那没什么问题。但一般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值