JS 中对象、类与面向对象编程(二)
这是 JS 中对象、类与面向对象编程章节的第二部分的内容:主要是理解原型,涉及函数、原型对象、prototype 、constructor、 __proto__、原型链的相关概念,以及查看、设置对象原型等方法,和对属性的迭代操作,例如查看实例上的属性等等。看完这部分内容,你将深刻理解它们。
上一部分中介绍了创建对象的几种方式以及存在的问题,包括构造函数依然存在对象的方法重复定义的问题,如果将方法移至全局范围,可以解决对象方法的共享,但会污染全局环境,也没有让对象的属性和方法聚集在一起。接下来我们进入原型模式,这部分涉及到的知识点很多,比较底层,如果有些地方先没看懂,一定坚持看下去,很多东西看到后面的内容就明白了。坚持看下去,保证能看懂。接下来进入第二部分的内容。没看第一部分赶紧去看看吧~
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
所有属性和 sayName() 方法都直接添加到了 Person 的 prototype 属性上,构造函数体中什么也没有。调用构造函数创建的新对象仍然拥有相应的属性和方法。与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。
可能你不清楚为什么这样,我们进一步了解原型的本质。
01. 理解原型
无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向该函数的原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的函数。
function Person() {}
// 声明之后,函数就有了一个与之关联的原型对象:
console.log(typeof Person.prototype); // object
console.log(Person.prototype);
// {
// constructor: f Person(),
// __proto__: Object
// }
function Person() {}; // 方式一创建函数
function Person(name, age, job){ // 方式二创建构造函数
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
console.log(this.name);
};
}
console.log(Person.prototype.constructor === Person); // true
上述创建的函数无论里面有没有内容,都只会在内存中创建下图的结构。如前所述,函数里有一个 prototype 属性引用其原型对象,这个原型对象也有一个 construction 属性,引用这个函数,两者循环引用,如下图。
// 当采用上述方式二创建函数后,执行下行代码出现情况如下图
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
在自定义构造函数时,原型对象默认只会获得 constructor 属性, 其他的所有方法都继承自Object。每次调用构造函数创建一个新实例,这个实例的内部 [[Prototype]] 指针就会被赋值为构造函数的原型对象。脚本中没有访问这个 [[Prototype]] 特性的标准方式, 但 Firefox、Safari 和 Chrome 会在每个对象上暴露 _proto_ 属性,通过这个属性可以访问对象的原型。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
正常的原型链(不同的原型对象链接起来的链)都会终止于 Object 的原型对象,Object 原型对象的原型是 null。
console.dir(Person.prototype); // 打印 Person 的原型对象
console.dir(Object.prototype); // 打印 Object 的原型对象
所以实际上定义一个构造函数后的内存图更加准确是下图所示,着重理解构造函数,原型对象,原型链,_proto_,prototype,constructor 这些概念的区别
我们用特殊的字母来表述他们之间的关系:
A的原型:即A._proto_。任何对象都有原型。
Object 原型对象的原型:即Object.prototype._proto_。为 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
其次,构造函数创建的实例可通过__proto__链接到所对应的构造函数原型对象上,实例上的__proto__实际上指向隐藏特性[[Prototype]],而它指向原型对象,如下列代码所示:
说明:实例的原型(person1._proto_)即对应构造函数的原型对象。
console.log(person1.__proto__ === Person.prototype); // true
conosle.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
对于上文中提到的把所有属性和 sayName() 方法都直接添加到了 Person 的 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();
let person2 = new Person();
Person 的原型对象包含 constructor 属性和其他后来添加的属性。person1 和 person2 都只有一个内部属性[[prototype]] 指向 Person.prototype,而且两者都与构造函数没有直接联系。person1.sayName() 可以正常调用。这是由于对象属性查找机制的原因。
- isPrototypeof():判断调用对象A是否在参数对象B的原型链上
上述介绍中,person1 和 person2 以及 Person 的原型对象说白了都是对象,这里我们怎么判断 A 对象在不在 B 对象的原型链上呢?==》Person.prototype 在不在 person1 的原型链上呢?
这里可以使用 isPrototypeOf() 方法确定两个对象之间的这种关系。本质上,isPrototypeOf()会在传入参数的 [[Prototype]] 如果能找到调用它的对象时返回 true,如下所示:
调用对象A.isPrototypeOf(参数对象B)==>调用对象A是否在参数对象B所指的原型链上。
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Person.prototype.isPrototypeOf(person2)); // true
console.log(Object.prototype.isPrototypeOf(person1)); // true
console.log(Object.prototype.isPrototypeOf(person2)); // true
因为这两个实例(person1、person2)内部都有链接指向 Person.prototype 和 Object.prototype ,所以结果都返回true。
- Object.getPrototypeOf():取得一个对象的原型
Object.getPrototypeOf()方法返回参数的内部特性[[Prototype]]的值。例如:
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(person1.__proto__ == Object.getPrototypeOf(person1)); // true
console.log(Object.getPrototypeOf(person1).name); // "Nicholas"
使用 Object.getPrototypeOf() 可以方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要。
- Object.setPrototypeOf():将第二个参数对象设置为第一个参数对象的原型
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
注意:对象(不是函数)的私有特性 [[Prototype]] 指向 Object.prototype。
警告 Object.setPrototypeOf()可能会严重影响代码性能。该操作相当于修改继承关系,其影响都是微妙且深远的。
- Object.create():创建一个对象,并设置参数对象为其原型,返回创建的对象
为避免使用 Object.setPrototypeOf() 可能造成的性能下降,可以通过 Object.create() 来创建一个新对象,同时为其指定原型:
let biped = {
numLegs: 2
};
let person = Object.create(biped);
person.name = 'Matt';
console.log(person.name);
console.log(person.numLegs);
console.log(Object.getPrototypeOf(person) === biped); // true
02. 原型层级
在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果有要搜索的属性名则返回该名称对应的值。如果没有,搜索会沿着指针进入原型对象,沿着原型链一路搜索直到搜索到返回对应的值或搜到Object 原型对象的原型(null)为止。这就是原型用于在多个对象实例间共享属性和方法的原理。
虽然可以通过实例读取原型对象上的值,但不可以通过实例重写这些值,会在实例对象上创建同名的属性,遮住原型上的属性:
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"; // 在对象实例上添加了与原型上同名的属性name
console.log(person1.name); // "Greg",来自实例
console.log(person2.name); // "Nicholas",来自原型
person1.__proto__.age = 21; // 通过对象实例找到原型对象并修改其属性值
Object.getPrototypeOf(person1).name = "xiaohau"; // 通过对象实例找到原型对象并修改其属性值
console.log(person1);
只要给对象实例添加一个同名属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。在实例上把这个属性设置为 null,依然不能访问到原型上的同名属性,除非使用 delete 操作符可以完全删除实例上的这个同名属性,这样再次搜索这个属性时就会找到原型对象上。
erson1.name = "Greg";
console.log(person1.name); // "Greg",来自实例
console.log(person2.name); // "Nicholas",来自原型
delete person1.name;
console.log(person1.name); // "Nicholas",来自原型
- hasOwnProperty():判断属性是实例属性还是原型属性。
hasOwnProperty() 方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自Object的,会在属性存在于调用它的对象实例上时返回true,如下面的例子所示:
let person1 = new Person();
let person2 = new Person();
console.log(person1.hasOwnProperty("name")); // false name属性在原型上
person1.name = "Greg"; // 在实例对象上添加了name属性
console.log(person1.name); // "Greg",来自实例
console.log(person1.hasOwnProperty("name")); // true name属性在实例上
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
【注意】: ECMAScript 的 Object.getOwnPropertyDescriptor() 方法只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用Object.getOwnPropertyDescriptor()。
03. 原型和 in 操作符
有两种方式使用in操作符:单独使用和在for-in循环中使用。
- 单独使用 in : 属性只要通过对象可以访问,in 操作符就返回 true。该属性在实例上或原型上都可以。
- person1.hasOwnProperty(“name”): name 属性在实例person1上才返回true。
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.hasOwnProperty("name")); // true 实例上有
console.log("name" in person1); // true 实例上有
如果要确定某个属性是否存在于原型上,则可以同时使用 hasOwnProperty() 和 in 操作符:
// 判断属性 name 在对象 object 的原型上
function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object);
}
- 在for-in循环中使用in操作符
可以通过对象访问且可以被枚举( [[Enumerable]] 特性为true)的属性都会返回,包括实例属性和原型属性。
- 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() 列出所有实例属性,无论是否可以枚举
let keys = Object.getOwnPropertyNames(Person.prototype);
console.log(keys); // "[constructor,name,age,job,sayName]"
返回的结果中包含了一个不可枚举的属性 constructor 是指向对应的构造函数,上文中有讲过。
在 ECMAScript 6 新增符号类型之后,相应地增加一个 Object.getOwnPropertyNames() 的兄弟方法 Object.getOwnPropertySymbols() 的需求,因为以符号为键的属性没有名称的概念。因此,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)]
04. 属性枚举顺序
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)]