目录
- [1. 概述](#1. 概述)
- [2. 工厂模式](#2. 工厂模式)
- [3. 构造函数模式](#3. 构造函数模式)
-
- [3.1 创建的格式](#3.1 创建的格式)
- [3.2 JS内部执行步骤](#3.2 JS内部执行步骤)
- [3.3 constructor 构造器](#3.3 constructor 构造器)
- [3.4 构造函数也是函数](#3.4 构造函数也是函数)
- [3.5 构造函数的问题](#3.5 构造函数的问题)
- [4. 原型模式 prototype](#4. 原型模式 prototype)
-
- [4.1 理解原型本质](#4.1 理解原型本质)
- [4.2 原型层级(访问一个属性,查询的次序)](#4.2 原型层级(访问一个属性,查询的次序))
-
- [4.2.1 查询次序:实例优先,原型次之](#4.2.1 查询次序:实例优先,原型次之)
- [4.2.2 辨别实例属性](#4.2.2 辨别实例属性)
- [4.2 原型 和 in 操作符](#4.2 原型 和 in 操作符)
- [4.3 属性枚举顺序](#4.3 属性枚举顺序)
- [5. 对象迭代](#5. 对象迭代)
-
- [5.1 对象字面量重写(overwrite)原型](#5.1 对象字面量重写(overwrite)原型)
- [5.2 原型的动态性](#5.2 原型的动态性)
- [5.3 原生对象的原型](#5.3 原生对象的原型)
- [5.4 原型的问题](#5.4 原型的问题)
- [6. 心得](#6. 心得)
1. 概述
在学习类和继承之前,理解原型的底层原理非常重要,只有理解了原型,才能更好的理解类和继承。那么原型到底是什么呢? ECMAScript 为什么要引进原型?
ECMAScript 5.1 并没有正式支持面向对象的结构,比如类或继承。ECMAScript 6 开始正式支持类和继承。ES 6 的类旨在完全涵盖之前规范设计的基于原型的继承模式 。ES 6 的类仅仅是封装了 ES5.1 构造函数加原型继承的语法糖。采用面向对象编程模式的 JavaScript 代码还是应该使用 ECMAScript 6 的 类 。本文循序渐进地介绍被类封装的那些底层原理。
本章对于不熟悉 C/C++ 和 设计模式 的程序员,看起来会有点小难度。但是只要读仔细,多读两遍,将每一个示例自己调试一下。基本都会理解。
2. 工厂模式
产生原因: Object构造函数和字面量两种方式可以创建对象,但是在创建具有同样接口的多个对象需要重复编写很多代码。
于是前辈们就使用了工厂模式创建。看看下面的代码:
js
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 个方法的对象。
**缺点:**这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即全部都是Object类型的,没有自己定义类型)。
3. 构造函数模式
3.1 创建的格式
ECMAScript 中的构造函数是用于创建特定类型对象的。像 Object 和 Array 这样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。
js
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 的构造函数就是能创建对象的函数。
3.2 JS内部执行步骤
创建 Person 的实例,应使用 new 操作符。以这种方式调用构造函数会执行如下步骤:
- 在内存中创建一个新对象。
- 这个新对象内部的 [[Prototype]] 特性被赋值为构造函数的 prototype 属性。
- 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
- 执行构造函数内部的代码(给新对象添加属性)。
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
3.3 constructor 构造器
constructor 是一个指针,它指向一个对象。JS中函数就是对象,那么构造函数也是对象,constructor 就是指向构造函数。上一个例子的最后, person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个 constructor 属性指向 Person ,如下所示:
js
console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true
constructor 本来是用于标识对象类型的。不过,一般认为 instanceof 操作符是确定对象类型更可靠的方式。前面例子中的每个对象都是 Object 的实例,同时也是 Person 的实例,如下面调用instanceof 操作符的结果所示:
js
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true
构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数:
js
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 操作符,就可以调用相应的构造函数:
js
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
3.4 构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。js中并没有把某个函数定义为构造函数的特殊语法。任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。比如,前面的例子中定义的 Person() 可以像下面这样调用:
js
// 作为构造函数
let person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); // "Nicholas"
// 作为函数调用
Person("Greg", 27, "Doctor"); // 添加到 window 对象 ,Person内部的this,变成了window
window.sayName(); // "Greg"
// 在另一个对象的作用域中调用
let o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); // "Kristen"
这个例子一开始展示了典型的构造函数调用方式,即使用 new 操作符创建一个新对象。然后是普通函数的调用方式,这时候没有使用 new 操作符调用 Person() ,结果会将属性和方法添加到 window 对象。这里要记住,在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或者没有使用 call() / apply() 调用), this 始终指向 Global 对象(在浏览器中就是 window 对象)。因此在上面的调用之后, window 对象上就有了一个 sayName() 方法,调用它会返回 "Greg" 。最后展示的调用方式是通过 call() (或 apply() )调用函数,同时将特定对象指定为作用域。这里的调用将对象 o 指定为 Person() 内部的 this 值,因此执行完函数代码后,所有属性和 sayName() 方法都会添加到对象 o 上面。
3.5 构造函数的问题
构造函数的缺点: 其定义的方法会在每个实例上都创建一遍, 因此对前面的例子而言, person1 和 person2 都有名为sayName() 的方法,但这两个方法不是同一个 Function 实例。ECMAScript 中的函数是对象,因此每次定义函数时,都会初始化一个对象。逻辑上讲,这个构造函数实际上是这样的:
js
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function("console.log(this.name)"); // 逻辑等价,每次创建Person的实例,都会为sayName创建新的对象。
}
这样理解这个构造函数可以更清楚地知道,每个 Person 实例都会有自己的 Function 实例用于显示 name 属性。当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新 Function实例的机制是一样的。因此不同实例上的函数虽然同名却不相等(不是同一个地址),如下所示:
js
console.log(person1.sayName == person2.sayName); // false
因为都是做一样的事,所以没必要定义两个不同的 Function 实例。况且 this 对象可以把函数与对象的绑定推迟到运行时。要解决这个问题,可以把函数定义转移到构造函数外部:
js
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() 函数。这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。
4. 原型模式 prototype
终于主角上场了,prototype,翻译为"原型、雏形",在函数中,它是一个引用,指向一个原型对象 。
每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含特定引用类型的实例共享的属性和方法 。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中赋给对象实例的值,可以直接赋值给它们的原型,如下所示:
js
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" 和person1的调用结果一样。他们的实列是同一个。
console.log(person1.sayName == person2.sayName); // true
使用函数表达式也可以:
js
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() 方法都直接添加到了 Person 的 prototype 属性上,构造函数体中什么也没有。但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法。与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1 和 person2 访问的都是相同的属性和相同的 sayName() 函数。要理解这个过程,就必须理解 ECMAScript 中原型的本质。
4.1 理解原型本质
无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。对前面的例子而言, Person.prototype.constructor 指向 Person 。然后,因构造函数而异,可能会给原型对象添加其他属性和方法。
在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自Object 。每次调用构造函数创建一个新实例,这个实例的内部 [[Prototype]] 指针就会被赋值为构造函数的原型对象。脚本中没有访问这个 [[Prototype]] 特性的标准方式,但 Firefox、Safari 和 Chrome会每个对象上暴露 proto 属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性完全被隐藏了。
这里看示例前,先看看 [[Prototype]] 、prototype 、proto 三个关键字的具体含义:
-
[[Prototype]] : 对象的内部特性,也可以说是内部属性,就是中还有[[Enumerable]] 等类似的内部属性。程序中无法直接访问 这个属性,如同 private 一般
-
prototype : 函数的属性 ,每个函数都有一个prototype属性,用来指向原型对象
-
proto: 实例化对象(instance)的属性 ,因为 [[Prototype]] 是内部特性,不可访问。程序员又想看看里面情况,于是乎 各个浏览器开放了这个属性。所以它不是 ECMAScript 原生的(native),其实各个浏览器调用Object.getPrototypeOf() 来获取[[prototype]]指向的对象来赋值给__proto__
下面继续理解这句话:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
这种关系不好可视化,但可以通过下面的代码来理解原型的行为:
js
/**
* 构造函数可以是函数表达式
* 也可以是函数声明,因此以下两种形式都可以:
* 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 ,Person.prototype 也是对象,那么它也有自己的原型对象,即Object.prototype
console.log(Person.prototype.__proto__.constructor === Object); // true
console.log(Person.prototype.__proto__.__proto__ === null); // true 说明原型对象的自己的原型对象位null
console.log(Person.prototype.__proto__);
// {
// constructor: f Object(),
// toString: ...
// hasOwnProperty: ...
// isPrototypeOf: ...
// ...
// }
let person1 = new Person(),
person2 = new Person();
let myPrototype = Object.getPrototypeOf(person1);
console.log(myPrototype);
console.log("自己获取的原型和浏览器获取的原型:"+ (myPrototype === person1.__proto__));
/**
* 构造函数、原型对象和实例
* 是 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.prototype ,可以通过下图 看出各个对象之间的关系。
熟悉C语言的人可以理解为 :Person 构造函数 创建 Person的原型对象,当实例化person1 和 person2 时,都会 创建 [[Prototype]] 指针 指向 Person的原型对象。
上图展示了 Person 构造函数、 Person 的原型对象和 Person 现有两个实例之间的关系。注意,Person.prototype 指向原型对象,而Person.prototype.contructor 指回 Person 构造函数。原型对象包含 constructor 属性和其他后来添加的属性。 Person 的两个实例 person1 和 person2 都有一个内部属性指回 Person.prototype ,而且两者都与构造函数没有直接联系。另外要注意,虽然这两个实例都没有属性和方法,但 person1.sayName() 可以正常调用。这是由于对象属性查找机制的原因。
虽然不是所有实现都对外暴露了 [[Prototype]] ,但可以使用 isPrototypeOf() 方法确定两个对象之间的这种关系。如下所示:
js
console.log(Person.prototype.isPrototypeOf(person1)); // true, 判定Person.prototype是否也是 person1的 原型对象,结果为true
console.log(Person.prototype.isPrototypeOf(person2)); // true
这里通过原型对象调用 isPrototypeOf() 方法检查了 person1 和 person2 。因为这两个例子内部都有链接指向Person.prototype ,所以结果都返回 true 。ECMAScript 的 Object 类型有一个方法叫 Object.getPrototypeOf() ,返回参数的内部特性[[Prototype]] 的值。例如:
js
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(Object.getPrototypeOf(person1).name); // "Nicholas" ,这里先就是获取原型对象,然后获取原型对象的属性name
第一行代码简单确认了 Object.getPrototypeOf() 返回的对象就是传入对象的原型对象。第二行代码则取得了原型对象上 name 属性的值,即 "Nicholas" 。使用 Object.getPrototypeOf() 可以方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要(本文后面会介绍)。
Object 类型还有一个 setPrototypeOf() 方法,可以向实例的私有特性 [[Prototype]] 写入一个新值。这样就可以重写一个对象的原型继承关系:
js
let biped = {
numLegs: 2
};
let person = {
name: 'Matt'
};
Object.setPrototypeOf(person, biped); //使用biped的原型对象覆盖了person本身自己的对象。
console.log(person.name); // Matt
console.log(person.numLegs); // 2 numLegs不是biped原型对象的属性,为什么也可以访问?
console.log(Object.getPrototypeOf(person) === biped); // true
警告 Object.setPrototypeOf() 可能会严重影响代码性能。Mozilla 文档说得很清楚:"在所有浏览器和 JavaScript 引擎中,修改继承关系的影响都是微妙且深远的。这种影响并不仅是执行 Object.setPrototypeOf() 语句那么简单,而是会涉及所有访问了那些修改过 [[Prototype]] 的对象的代码。
为避免使用 Object.setPrototypeOf() 可能造成的性能下降,可以通过 Object.create() 来创建一个新对象,同时为其指定原型:
js
let biped = {
numLegs: 2
};
let person = Object.create(biped); //这里使用biped作为 create的参数,则新创建对象的原型 则会使用 biped的原型对象
person.name = 'Matt';
console.log(person.name); // Matt
console.log(person.numLegs); // 2
console.log(Object.getPrototypeOf(person) === biped); // true
4.2 原型层级(访问一个属性,查询的次序)
4.2.1 查询次序:实例优先,原型次之
在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。因此,在调用 person1.sayName() 时,会发生两步搜索。首先,JavaScript 引擎会问:" person1 实例有 sayName 属性吗?"答案是没有。然后,继续搜索并问:" person1 的原型有 sayName 属性吗?" 答案是有。于是就返回了保存在原型上的这个函数。在调用person2.sayName() 时,会发生同样的搜索过程,而且也会返回相同的结果。这就是原型用于在多个对象实例间共享属性和方法的原理。
总结: 实例优先,原型次之
注意 前面提到的 constructor 属性只存在于原型对象,通过实例对象也是可以访问到的。
虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。下面看一个例子:
js
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" ,来自原型对象
在这个例子中, person1 的 name 属性遮蔽了原型对象上的同名属性。虽然 person1.name 和 person2.name 都返回了值,但前者返回的是 "Greg" (来自实例),后者返回的是 "Nicholas" (来自原型)。当 console.log() 访问 person1.name 时,会先在实例上搜索个属性。因为这个属性在实例上存在,所以就不会再搜索原型对象了。而在访问 person2.name 时,并没有在实例上找到这个属性,所以会继续搜索原型对象并使用定义在原型上的属性。
只要给对象实例添加一个属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。即使在实例上把这个属性设置为 null ,也不会恢复它和原型的联系。不过, 使用 delete 操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象。
js
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; //这里彻底释放了实例属性name的地址了。
console.log(person1.name); // "Nicholas"
这个修改后的例子中使用 delete 删除了 person1.name ,这个属性之前以 "Greg" 遮蔽了原型上的同名属性。然后原型上 name 属性的联系就恢复了,因此再访问 person1.name 时,就会返回原型对象上这个属性的值。
4.2.2 辨别实例属性
hasOwnProperty() 方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自 Object的,会在属性存在于调用它的对象实例上时返回 true ,如下面的例子所示:
js
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 现在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
在这个例子中,通过调用 hasOwnProperty() 能够清楚地看到访问的是实例属性还是原型属性。调用person1.hasOwnProperty("name") 只在重写 person1 上 name 属性的情况下才返回 true ,表明此时 name 是一个实例属性,不是原型属性。图 8-2 形象地展示了上面例子中各个步骤的状态。(为简单起见,图中省略了 Person 构造函数。)
上面代码的执行顺序如图:
- 开始阶段
- person1 定义一个实例对象自己的属性 person1.name = "Greg";
- person1 删除自身属性 delete person1.name;
注意 ECMAScript 的 Object.getOwnPropertyDescriptor() 方法只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用 Object.getOwnPropertyDescriptor() 。
4.2 原型 和 in 操作符
in 操作符两种用途:
- for-in 循环中使用。
- 单独使用, in 操作符可以用来判断对象是否可以访问某个属性 ,无论该属性是在实例上还是在原型上。来看下面的例子:
js
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 操作符:
js
function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object); //不在实例中,且还可以访问到
}
只要通过对象可以访问, in 操作符就返回 true ,而 hasOwnProperty() 只有属性存在于实例上时才返回 true 。因此,只要 in 操作符返回 true 且 hasOwnProperty() 返回 false ,就说明该属性是一个原型属性。来看下面的例子:
js
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 属性,但因为实例上的属性遮蔽了它,所以不会用到。
在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。遮蔽原型中不可枚举( [[Enumerable]] 特性被设置为 false )属性的实例属性也会在 for-in 循环中返回 ,因为默认情况下开发者定义的属性都是可枚举的。(这句话中文有点难理解,英语原文: Instance properties that shadow a non-enumerable prototype property (a property that has [[Enumerable]] set to false) will be returned in the for-in loop because all developer-defined properties are enumerable by default .)
要获得对象上所有可枚举的实例属性,可以使用 Object.keys() 方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。比如:
js
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() 返回的数组中只包含 "name" 和"age" 两个属性。
如果想列出所有实例属性,无论是否可以枚举,都可以使用 Object.getOwnPropertyNames() :
js
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() 类似,只是针对符号而已:
js
let k1 = Symbol('k1'),
k2 = Symbol('k2');
let o = {
[k1]: 'k1',
[k2]: 'k2'
};
console.log(Object.getOwnPropertySymbols(o));
// [Symbol(k1), Symbol(k2)]
4.3 属性枚举顺序
for-in 循环、 Object.keys() 、 Object.getOwnPropertyNames() 、 Object.getOwnPropertySymbols() 以及 Object.assign() 在属性枚举顺序方面有很大区别。
- for-in 循环和 Object.keys()的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异。
- Object.getOwnPropertyNames() 、 Object.getOwnPropertySymbols() 和 Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。
js
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)]
5. 对象迭代
在 JavaScript 有史以来的大部分时间内,迭代对象属性都是一个难题。ECMAScript 2017 新增了两个静态方法,用于将对象内容转换为序列化的------更重要的是可迭代的------格式。这两个静态方法 Object.values() 和 Object.entries() 接收一个对象,返回它们内容的数组。 Object.values() 返回对象值的数组, Object.entries() 返回键/值对的数组。
js
const o = {
foo: 'bar',
baz: 1,
qux: {}
};
console.log(Object.values(o)); // ["bar", 1, {}] , 返回的是包含3个值的数组
console.log(Object.entries((o))); // [["foo", "bar"], ["baz", 1], ["qux", {}]] 返回 3个键/值的数组
注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制:
js
const o = {
qux: {}
};
console.log(Object.values(o)[0] === o.qux); // true ,说明Object.values()返回对象的引用属性 还是指向原对象的引用属性,即同一个属性对象(同一个地址)
console.log(Object.entries(o)[0][1] === o.qux);// true
符号属性会被忽略:
js
const sym = Symbol();
const o = {
[sym]: 'foo'
};
console.log(Object.values(o)); // []
console.log(Object.entries((o))); // []
5.1 对象字面量重写(overwrite)原型
在前面的例子中,每次定义一个属性或方法都会把 Person.prototype 重写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型成为了一种常见的做法,如下面的例子所示:
js
function Person() {};
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
在这个例子中, Person.prototype 被设置为等于一个通过对象字面量创建的新对象。最终结果是一样的,只有一个问题:这样重写之后, Person.prototype 的 constructor 属性就不指向 Person了。在创建函数时,也会创建它的 prototype 对象,同时会自动给这个原型的 constructor 属性赋值。而上面的写法完全重写了默认的 prototype 对象,因此其 constructor 属性也指向了完全不同的新对象( Object 构造函数),不再指向原来的构造函数。虽然 instanceof 操作符还能可靠地返回值,但我们不能再依靠 constructor 属性来识别类型了,如下面的例子所示:
js
let friend = new Person();
console.log(friend instanceof Object); // true
console.log(friend instanceof Person); // true
console.log(friend.constructor == Person); // false , 发现已经不再是构造器不再是 Person了。
console.log(friend.constructor == Object); // true
这里, instanceof 仍然对 Object 和 Person 都返回 true 。但 constructor 属性现在等于 Object 而不是 Person 了。如果 constructor 的值很重要,则可以像下面这样在重写原型对象时专门设置一下它的值:
js
function Person() {
}
Person.prototype = {
constructor: Person, //专门来设定prototype的构造函数
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
这次的代码中特意包含了 constructor 属性,并将它设置为 Person ,保证了constructor属性的值保持不变。
但要注意,以这种方式恢复 constructor 属性会创建一个 [[Enumerable]] 为 true 的属性。而原生 constructor 属性默认是不可枚举的。因此,如果你使用的是兼容 ECMAScript 的 JavaScript 引擎,那可能会改为使用 Object.defineProperty() 方法来定义 constructor 属性:
js
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
});
5.2 原型的动态性
因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。下面是一个例子:
js
let friend = new Person();
Person.prototype.sayHi = function() {
console.log("hi");
};
friend.sayHi(); // "hi" - works!
以上代码先创建一个 Person 实例并保存在 friend 中。然后一条语句在 Person.prototype 上添加了一个名为 sayHi() 的方法。虽然 friend 实例是在添加方法之前创建的,但它仍然可以访问这个方法。之所以会这样,主要原因是实例与原型之间松散的联系。在调用 friend.sayHi() 时,首先会从这个实例中搜索名为 sayHi 的属性。在没有找到的情况下,运行时会继续搜索原型对象。因为实例和原型之间的链接就是简单的指针,而不是保存的副本,所以会在原型上找到 sayHi 属性并返回这个属性保存的函数。
虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两回事。实例的 [[Prototype]] 指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型 。记住,实例只有指向原型的指针,没有指向构造函数的指针。来看下面的例子:
js
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 属性。下图展示了这里面的原因:
- 原型赋值(重写)之前
- 原型赋值(重写)之后
重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。
5.3 原生对象的原型
原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。所有原生引用类型的构造函数(包括 Object 、 Array 、 String 等)都在原型上定义了实例方法。比如,数组实例的 sort() 方法就是 Array.prototype 上定义的,而字符串包装对象的 substring() 方法也是在 String.prototype 上定义的,如下所示:
js
console.log(typeof Array.prototype.sort); // "function"
console.log(typeof String.prototype.substring); // "function"
通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。可以像修改自定义对象原型一样修改原生对象原型,因此随时可以添加方法。比如,下面的代码就给 String原始值包装类型的实例添加了一个 startsWith() 方法:
js
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() 方法。
注意 尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。这样做很可能造成误会,而且可能引发命名冲突(比如一个名称在某个浏览器实现中不存在,在另一个实现中却存在)。另外还有可能意外重写原生的方法。推荐的做法是创建一个自定义的类,继承原生类型。
5.4 原型的问题
原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。
我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性也还好,如前面例子中所示,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题来自包含引用值的属性。来看下面的例子:
js
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 上反映出来。如果这是有意在多个实例间共享数组,那没什么问题。但一般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。
6. 心得
我们在创建新对象时,在内存中申请空间从而创建了全新的实例属性,有了原型而且还可以共享原型中已经存在的属性和方法。