用Object构造函数或对象字面量创建对象,有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。
8.2.1 概述综观
ECMAScript 6开始正式支持类和继承。ES6的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,ES6的类都仅仅是封装了ES5.1构造函数加原型继承的语法糖而已。
8.2.2 工厂模式
工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。
(本书后面还会讨论其他设计模式及其在JavaScript中的实现。)
下面的例子展示了一种按照特定接口创建对象的方式:
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('Amy', 29, 'engineer');let person2 = createPerson('Bill', 27, 'Doctor');
函数createPerson()接收3个参数,根据这几个参数构建了一个包含Person信息的对象。可以用不同的参数多次调用这个函数,每次都会返回包含3个属性和1个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。
8.2.3 构造函数模式
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('Amy', 29, 'engineer');let person2 = new Person('Bill', 27, 'Doctor');person1.sayName(); // Amyperson2.sayName(); // Bill
Person()构造函数代替了createPerson()工厂函数。
实际上,Person()内部的代码跟createPerson()基本是一样的,只是有如下区别:
❑ 没有显式地创建对象。
❑ 属性和方法直接赋值给了this。
❑ 没有return。
注:函数名Person的首字母大写了。
按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。
这是从面向对象编程语言那里借鉴的,有助于在ECMAScript中区分构造函数和普通函数。
要创建Person的实例,应使用new操作符。
以这种方式调用构造函数会执行如下操作:
(1)在内存中创建一个新对象。
(2)这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype属性。
(3)构造函数内部的this被赋值为这个新对象(即this指向新对象)。
(4)执行构造函数内部的代码(给新对象添加属性)。
(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
上一个例子的最后,person1和person2分别保存着Person的不同实例。这两个对象都有一个constructor属性指向Person,如下所示:
console.log(person1.constructor === Person); // trueconsole.log(person2.constructor === Person); // true
constructor本来是用于标识对象类型的。不过,一般认为instanceof操作符是确定对象类型更可靠的方式。前面例子中的每个对象都是Object的实例,同时也是Person的实例
定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。
在这个例子中,person1和person2之所以也被认为是Object的实例,是因为所有自定义对象都继承自Object
构造函数不一定要写成函数声明的形式。
赋值给变量的函数表达式也可以表示构造函数:
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('Amy', 29, 'engineer');let person2 = new Person('Bill', 27, 'Doctor');
在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。
只要有new操作符,就可以调用相应的构造函数:
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();let person2 = new Person;
1.构造函数也是函数
构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。
并没有把某个函数定义为构造函数的特殊语法。
任何函数只要使用new操作符调用就是构造函数,而不使用new操作符调用的函数就是普通函数。
2.构造函数的问题
主要问题在于,其定义的方法会在每个实例上都创建一遍。
因此对前面的例子而言,person1和person2都有名为sayName()的方法,但这两个方法不是同一个Function实例。当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新Function实例的机制是一样的。因此不同实例上的函数虽然同名却不相等
因为都是做一样的事,所以没必要定义两个不同的Function实例。
况且,this对象可以把函数与对象的绑定推迟到运行时。
要解决这个问题,可以把函数定义转移到构造函数外部:
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('Amy', 29, 'engineer');let person2 = new Person('Bill', 27, 'Doctor');person1.sayName(); // Amyperson2.sayName(); // Bill
sayName()被定义在了构造函数外部。
在构造函数内部,sayName属性等于全局sayName()函数。
因为这一次sayName属性中包含的只是一个指向外部函数的指针,所以person1和person2共享了定义在全局作用域上的sayName()函数。
这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。
8.2.4 原型模式
每个函数都会创建一个prototype属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。
使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型
如下所示:
function Person() {};Person.prototype.name = 'Amy';Person.prototype.age = 29;Person.prototype.sayName = function() {console.log(this.name);}let person1 = new Person();let person2 = new Person();person1.sayName(); // Amyperson2.sayName(); // Amyconsole.log(person1.sayName === person2.sayName) // true
使用函数表达式也可以:
let Person = function(){};
1.理解原型
只要创建一个函数,就会为这个函数创建一个prototype属性(指向原型对象)。
默认,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数。
对前面的例子而言,Person.prototype.constructor指向Person。
在自定义构造函数时,原型对象默认只会获得constructor属性,其他的所有方法都继承自Object。
每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。
脚本中没有访问这个[[Prototype]]特性的标准方式,但Firefox、Safari和Chrome会在每个对象上暴露proto属性,通过这个属性可以访问对象的原型。
关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
构造函数.protoType ==> 原型对象
实例.proto ==> 原型对象
实例.proto.constructor ==> 构造函数
本质上,isPrototypeOf()会在传入参数的[[Prototype]]指向调用它的对象时返回true,
如下所示:
console.log(Person.prototype.isPrototypeOf(person1)); // trueconsole.log(Person.prototype.isPrototypeOf(person2)); // true
通过原型对象调用isPrototypeOf()方法检查了person1和person2。因为这两个例子内部都有链接指向Person.prototype,所以结果都返回true
ECMAScript的Object类型有一个方法叫Object.getPrototypeOf(),返回参数的内部特性[[Prototype]]的值。
使用Object.getPrototypeOf()可以方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要
例如:
console.log(Object.getPrototypeOf(person1) === Person.prototype); // trueconsole.log(Object.getPrototypeOf(person1).name); // Amy
第一行代码简单确认了Object.getPrototypeOf()返回的对象就是传入对象的原型对象。第二行代码则取得了原型对象上name属性的值,即”Amy”。
Object类型还有一个setPrototypeOf()方法,可以向实例的私有特性[[Prototype]]写入一个新值。
这样就可以重写一个对象的原型继承关系。
注: 会严重影响代码性能
为避免使用Object.setPrototypeOf()可能造成的性能下降,可以通过Object.create()来创建一个新对象,同时为其指定原型
2.原型层级
在通过对象访问属性时,会按照这个属性的名称开始搜索。
搜索开始于对象实例本身。
如果在这个实例上发现了给定的名称,则返回该名称对应的值。
如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。
因此,在调用person1.sayName()时,会发生两步搜索。
首先,JavaScript引擎会问:“person1实例有sayName属性吗?”
答案是没有。
然后,继续搜索并问:“person1的原型有sayName属性吗?”
答案是有。
于是就返回了保存在原型上的这个函数。
这就是原型用于在多个对象实例间共享属性和方法的原理。
虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。
如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。
即使在实例上把这个属性设置为null,也不会恢复它和原型的联系。
使用delete操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象。
hasOwnProperty()方法用于确定某个属性是在实例上还是在原型对象上。
这个方法是继承自Object的,会在属性存在于调用它的对象实例上时返回true
function Person() {};Person.prototype.name = 'Amy';Person.prototype.age = 29;let person1 = new Person();person1.name = 'Bill'console.log(person1.hasOwnProperty('name')); // trueconsole.log(person1.hasOwnProperty('age')); // false// 来自实例,则为true// 来自原型,则为false
通过调用hasOwnProperty()能够清楚地看到访问的是实例属性还是原型属性
3.原型和in操作符
有两种方式使用in操作符:单独使用;在for-in循环中使用。
在单独使用时,in操作符在可以通过对象访问指定属性时返回true,无论该属性是在实例上还是在原型上。
来看下面的例子:
function Person() {};Person.prototype.name = 'Amy';Person.prototype.age = 29;let person1 = new Person();person1.name = 'Bill'console.log('name' in person1); // trueconsole.log('age' in person1); // true
如果要确定某个属性是否存在于原型上,则可以像下面这样同时使用hasOwnProperty()和in操作符:
function Person() {};Person.prototype.name = 'Amy';Person.prototype.age = 29;let person1 = new Person();person1.name = 'Bill'function hasPrototypeProperty(object, name) { // 就是这个return !object.hasOwnProperty(name) && (name in object);}console.log(hasPrototypeProperty(person1, 'name')); // falseconsole.log(hasPrototypeProperty(person1, 'age')); // true// 首先存在于实例上,则返回false// 没在实例上,在原型上,则返回true// 即检验属性是否存在于实例上
在for-in循环中使用in操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。
遮蔽原型中不可枚举([[Enumerable]]特性被设置为false)属性的实例属性也会在for-in循环中返回,因为默认情况下开发者定义的属性都是可枚举的。
要获得对象上所有可枚举的实例属性,可以使用Object.keys()方法。接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。
比如:
function Person() {};Person.prototype.name = 'Amy';Person.prototype.age = 29;let keys = Object.keys(Person.prototype);console.log(keys); // ["name", "age"]
想列出所有实例属性,无论是否可以枚举,都可以使用Object.getOwnPropertyNames():
function Person() {};Person.prototype.name = 'Amy';Person.prototype.age = 29;let keys = Object.getOwnPropertyNames(Person.prototype);console.log(keys); // ["constructor", "name", "age"]
注:返回的结果中包含了一个不可枚举的属性constructor
Object.keys()和Object.getOwnPropertyNames()在适当的时候都可用来代替for-in循环。
Object.getOwnProperty-Symbols()方法,与Object.getOwnPropertyNames()类似,只是针对符号而已
4.属性枚举顺序
for-in循环、Object.keys()、Object.getOwnPropertyNames()、Object.getOwnProperty-Symbols()以及Object.assign()在属性枚举顺序方面有很大区别。
for-in循环和Object.keys()的枚举顺序是不确定的。
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。
8.2.5 对象迭代
两个静态方法Object.values()和Object.entries(),接收一个对象,返回它们内容的数组。
Object.values()返回对象值的数组
Object.entries()返回键/值对的数组
用法:
const o = {foo: 'bar',baz: 1,qux: {}};console.log(Object.values(o)); // [ 'baz', 1, {}]console.log(Object.entries(o));// [["foo", "bar"], ["baz", 1], ["qux", {}]]
注意,非字符串属性会被转换为字符串输出
符号属性会被忽略
1.其他原型语法
2.原型的动态性
因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来
3.原生对象原型
原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。
所有原生引用类型的构造函数(包括Object、Array、String等)都在原型上定义了实例方法
比如,数组实例的sort()方法就是Array.prototype上定义的,而字符串包装对象的substring()方法也是在String.prototype上定义的
4.原型的问题
首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。
虽然这会带来不便,但还不是原型的最大问题。
原型的最主要问题源自它的共享特性。
我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。
另外包含原始值的属性也还好,如前面例子中所示,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。
真正的问题来自包含引用值的属性。
