JavaScript面向对象编程
一、对象
1.1 对象是什么?
对于有一定面向对象编程基础(比如C#、Java等等)的开发者来说,对象这个概念一定不陌生。
在像C#、Java语言中,能够基于一个类,创建一个或多个有相同属性和方法的对象。但在JavaScript中,并没有像这些语言存在的类(ECMAScript中没有类的概念),因此,其对象与其他基于类语言的对象有所不同。
1.2 JavaScript中的对象
上一节说到,JavaScript中的对象(以下简称为对象)与其他基于类语言的有所不同。在ECMA-262(ECMAScript可以理解为是JavaScript的一个标准,但实际上JavaScript是ECMA-262标准的实现和扩展)中将对象定义为:“**无序属性的集合,其属性可以包含基本值、对象或者函数。”**简单来说,就是ECMAScript中的对象是一组没有特定顺序的值,每个对象的属性或方法都有一个名字(键名,key),每个名字都能够映射到一个值(value)。可以理解对一组键值对,其中值可以是数据或者函数。
1.3 怎么创建对象?
每个对象都是基于一个引用类型创建的,可以是原生类型,能够是自定义类型。而创建一个对象,可以简单地使用对象字面量方式创建,亦或者使用相对复杂的原生方式创建:
- 对象字面量方式
通过将一对花括号中的内容赋值给具体变量中,JavaScript会自动将其构建成对象。
var person = {
name: "XiaoMing",
age: 25,
job: "teacher",
sayName: function () {
alert(this.name);
}
}
上述代码创建了一个person对象,对象中有三个属性(name,age以及job)和一个方法(sayName),形似一组键值对,值为数据或者函数。这是众多开发者常用的创建对象的方式,其底层原理还是通过原生方式进行创建的。
- 原生方式
使用引用类型的Object类型进行创建,并对其对象的属性进行赋值。
var person = new Object();
person.name = "XiaoMing";
person.age = 25;
person.job = "teacher";
person.sayName = function () {
alert(this.name);
}
与上述使用对象字面量创建的对象一样,拥有三个属性(name,age以及job)和一个方法(sayName),但其编码相对对象字面量方式较为冗余,因此还是推荐使用对象字面量方式创建对象。
1.4 对象的属性特征
1.4.1 对象的属性
在ECMAScript中,对象的属性分为两种类型:数据属性和访问器属性,各自有着相应的属性特征,而属性的特征值,是JavaScript定义对象属性行为的依据(也可以说JavaScript通过属性的特征值来定义对象属性的行为)。
ECMA-262第5版在定义只有内部才使用的特性是,描述了属性的特种特征,这些特征是为了实现JavaScript引擎,因此不能直接访问他们。并且为了表示特征是內部值,ECMA-262将特性放在两对方括号中,例如[[Enumerable]]
1.4.1.1 数据属性
- [[Configurable]] :表示能否通过
delete
删除属性从而重新定义属性,能否修改属性特征,或者能否把属性修改为访问器属性。默认值为true。 - [[Enumerable]] :表示能否通过
for-in
循环返回属性。默认为true。 - [[Writable]] :表示能否修改属性的值。默认为true。
- [[Value]] :包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。默认值为undefine。
1.4.1.2 访问器属性
- [[Configurable]] :表示能否通过
delete
删除属性从而重新定义属性,能否修改属性特征,或者能否把属性修改为访问器属性。默认值为true。 - [[Enumerable]] :表示能否通过
for-in
循环返回属性。默认为true。 - [[Get]] :在读取属性时调用的函数。默认值为undefined。
- [[Set]] :在读取属性时调用的函数。默认值为undefined。
1.4.2 修改默认特征
使用JavaScript原生的Object.defineProperty方法能够修改属性的默认特征。但是需要注意:
- 在调用该方法时,如果不指定,
[[Configurable]]
、[[Enumerable]]
和[[Writable]]
的特征值都是false
(只适用于数据属性)
person = {};
Object.defineProperty(person, "name", {
writable: false, // 当将[[Writable]]特征设置为false时,之后便无法修改属性(name)的值
value: "XiaoMing",
});
alert(person.name); // "XiaoMing"
person.name = "Jacky"; // 尝试修改,虽然不会报错,但无法修改原始值
alert(person.name); // "XiaoMing"
该例子首先创建了一个空对象(person),然后通过.defineProperty()
方法定义一个属性(name),修改该属性的特性([[Writable]]
和[[Value]]
),之后尝试修改该属性值,但无法修改。这是因为将[[Writable]]
特性设置为false
,使得无法修改属性的值。
- 虽然可以使用该方法多次修改同一属性,但当把
[[Configurable]]
特征设置为false
后,就会限制属性的修改
person = {};
Object.defineProperty(person, "name", {
configurable: false,
// 当将[[Configurable]]特征设置为false时,即无法从对象中删除该属性(name)的值
value: "XiaoMing"
});
alert(person.name); // "XiaoMing"
person.name = "Jacky"; // 尝试修改,虽然不会报错,但无法修改原始值
alert(person.name); // "XiaoMing"
// 在[[Configurable]]特征设置为false的前提下
// 再调用该方法修改除了writabele之外的特征,就会抛出异常
Object.defineProperty(person, "name", {
configurable: true,
// 一旦将属性[[Configurable]]特征设置为false(不可配置),就再也能不将其设置回true(可配置)
value: "XiaoMing"
});
该例子同样的使用上一个例子,但改变了该属性的特性(将[[Writable]]
替换成[[``Configurable``]]
),之后尝试修改该属性值,同样无法修改。根据第一个注意点可知,[[Writable]]
特征在默认情况下为false,因此无法修改。同时,[[``Configurable``]]
特征被设置为false,之后再此调用该方法设置该属性时,除了修改[[Writable]]
特征之外,都会抛出异常,因为一旦将[[Configurable]]
特征设置为false
,就再也不能设置回ture
,即无法修改属性特征,但[[Writable]]
特征是用于修改属性值,因此能够修改该特征。
1.4.3 访问器属性定义
访问器属性不包含数据值,但包含一对getter和setter函数(但这两个函数不是必需的)。读取访问器属性时,使用getter函数,能够返回有效值;写入访问器属性时,使用setter函数,能够对传入的数据进行处理。
- 定义单个属性
使用JavaScript原生的Object.defineProperty方法能够定义一个属性(可以是数据属性或访问器属性,该节定义访问器属性)。但是需要注意:
- 不一定需要同时指定getter和setter函数;
- 只指定getter函数意味着属性不能写,尝试写入属性则会被忽略;
- 只指定setter函数意味着属性不能读,尝试读取属性则会返回
undefined
。
var book = {
_year: 2021,
edition: 1
};
Object.defineProperty(book, "year", {
get: function () { // getter函数
return: this._year
},
set: function (newVaule) { // setter函数
if (newValue > 2021) {
this._year = newValue;
this.edition += newValue - 2021
}
}
});
book.year = 2023;
alert(book.edition); // 3
该例子通过对象字面量创建了一个对象(book),其中有两个属性(_year和edition,_year的下划线表示只能通过对象方法访问属性),同时使用Object.defineProperty方法定义了一个属性(year),用于访问_year属性(通过getter函数)以及修改edition属性值(通过setter函数)。
- 定义多个属性
使用JavaScript原生的Object.defineProperties方法能够定义多个属性(可以是数据属性或访问器属性)。
var book = {};
Object.defineProperties(book, {
_year: {
writable: true
value: 2021
},
edition: {
value: 1
},
year: {
get: function () {
return this._year;
},
set: function (newValue) {
if (newValue > 2021) {
this._year = newValue;
this.edition += newValue - 2021
}
}
}
});
book.year; // 2021,通过getter访问器获取
book.yaer = 2023; // 通过setter访问器修改
book.edition; // 3
该例子将上个例子中定义的属性全部集成在一个方法中进行定义,提高代码的可读性。
1.4.2 读取对象的属性特征
使用JavaScript原生的Object.getOwnPropertyDescriptor方法能够读取对象的属性特征。但需要注意:
- 该方法只能用于获取实例属性;
- 若需要获取原型属性的描述符,必须在原型对象上使用该方法。
var book = {};
Object.defineProperties(book, {
_year: {
writable: true
value: 2021
},
edition: {
value: 1
},
year: {
get: function () {
return this._year;
},
set: function (newValue) {
if (newValue > 2021) {
this._year = newValue;
this.edition += newValue - 2021
}
}
}
});
var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
alert(descriptor.value); // 2021
alert(descriptor.configurable); // false
alter(typeof descriptor.get); // "undefined"
var descriptor = Object.getOwnPropertyDescriptor(book, 'year');
alert(descriptor.value); // undefined
alert(descriptor.enumerable); // false
alert(typeof descriptor.get); // "function"
该例子在上个例子的基础上,通过Object.getOwnPropertyDescriptor()方法获取对象属性的描述符,并对该使用描述符获取属性特性。_year是数据属性,而year是访问器属性,因此通过对应描述符获取的特征会有差异,并且返回的值不相同。
1.5 对象访问器属性与对象方法
之前说过,对象属性分为数据属性和访问器属性两种。数据属性好理解,就是有值的属性。而对象访问器属性和对象方法却比较容易混淆。以下进行对比:
- 对象方法
var person = {
firstName: "Ming",
lastName: "Xiao",
fullName: function () {
return "full name: " + firstName + " " + lastName;
}
};
var fullName = person.fullName(); // "full name: Ming Xiao"
- 访问器属性
var person = {
firstName: "Ming",
lastName: "Xiao",
get: fullName () {
return "full name: " + firstName + " " + lastName;
}
// 相当于
// fullName: {
// get: function () {
// return "full name: " + firstName + " " + lastName;
// }
// }
};
var fullName = person.fullName; // "full name: Ming Xiao"
通过两个代码块,能够知道两者没有太大的差异,同时返回的结果也是相同的。只是在调用时,对象方法通过person.fullName()
进行调用,而访问器属性通过person.fullName
进行调用(一个更像在调用方法,一个更像在访问属性)。访问器属性提供了更加简洁的语法,但带来的问题可能就是比较难读懂。
1.6 小结
相比其他基于面向对象的编程语言,JavaScript并没有类的概念,因此ECMAScript中的对象与其他语言不一样。ECMAScript中的对象是一组没有特定顺序的值,每个对象的属性和方法都有对应的“键”和“值”。
JavaScript对象主要通过对象字面量和原生方式创建,通常使用对象字面量更加的简洁明了,是众多开发者创建对象的常用方式。
每个对象的属性分为数据属性和访问器属性两类,其属性特性是为了实现JavaScript引擎,因此无法直接访问,而是通过两个方括号进行标识。我们可以在其中对默认的特征进行修改、定义一个或多个属性以及读取对象属性特征等…
二、创建对象
虽然使用对象字面量和原生方式能够创建对象,但它们也存在缺点,即当使用同一个接口创建很多对象,但是会产生大量的重复代码。为了解决这个问题,出现了工厂模式。
2.1 工厂模式
工厂模式在软件工程中是一种广为人知的设计模式。但是因为在ECMASript中没有类的概念,因此开发人员根据工厂模式的思想,设计了一种函数,用函数来封装特定接口创建对象的细节。解决创建多个相似对象时出现大量重复代码的问题。
// 工厂模式
function createPerson (name, age, job) {
var o = new Object(); // 创建一个Object实例
// 属性赋值
o.name = name;
o.age = age;
o.job = job;
// 定义对象方法
o.sayName = function () {
alert(this.name);
}
// 返回对象
return o;
}
var person1 = createPerson("XiaoMing", 25, "teacher");
var person2 = createPerson("LiHua", 26, "lawyer");
alert(person1 instanceof Object); // true
alert(person2 instanceof Object); // true
在该例子中,创建对象的Object构造函数封装在了createPerson
的方法中,该方法接收三个参数,并将这三个参数赋给对象的属性(name,age和job),同时在方法中为对象创建了一个方法(sayName),最终返回该对象。这样,就能通过同一个函数创建多个相似对象。
于此同时,该模式也暴露出弊端:没有解决对象识别的问题,即如何知道一个对象的类型。
2.2 构造函数模式
由于工厂模式出现了类型无法判断(类型永远是Object)的问题,并且随着ECMAScript的发展,出现了构造函数模式。
2.2.1 构造函数模式的特点
- 构造函数始终以大写字母开头(借鉴其他面向对象语言,并且区别其他ECMAScript函数)
- 调用构造函数必需使用new操作符(具体原因后续解释)
- 自定义的构造函数能够将其实例标识为特定的类型(解决工厂模式的弊端)
- 通过该模式定义的构造函数是定义在Global/window对象中
2.2.2 构造函数执行过程
构造函数用于构造一个对象,需要通过new操作符进行构造,因此可以从两个角度(构造对象和使用new操作符创建)去解析构造函数执行过程。
2.2.2.1 构造对象过程
在JavaScript MDN文档中,关于构造对象的操作解释如下:
Creating a user-defined object requires two steps:
- Define the object type by writing a function.
- Create an instance of the object with
new
.
- 创建一个新对象
- 将构造函数的作用域赋值给新对象
- 执行构造函数中的代码(赋值属性/方法)
- 返回新对象
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function () {
alert(this.name);
}
}
var person1 = new Person("XiaoMing", 25, "teacher"); // 构造对象
2.2.2.2 new操作符过程(前端面试题)
在JavaScript MDN文档中,关于new操作符的操作解释如下:
The
new
keyword does the following things:
- Creates a blank, plain JavaScript object;
- Links (sets the constructor of) the newly created object to another object by setting the other object as its parent prototype;
- Passes the newly created object from Step 1 as the
this
context;- Returns
this
if the function doesn’t return an object.
- 创建一个空对象obj
- 设置原型链(实现继承原型的属性和方法)
- 将函数的this指向该对象obj,并执行函数体
- 判断函数的返回值类型,若为值类型,返回该对象obj;若是引用类型,则返回该引用类型的对象
var Func = function() {
// 函数体
};
var func = new Fun();
// 以上为函数的定义以及使用new操作符创建
//======================================
// 以下为new操作符创建过程
// 1. 创建空对象
var obj = new Object();
// 2. 设置原型链
obj.__proto__ = Func.prototype;
// 3. 将函数的this指向该对象obj,并执行函数体
var result = Func.call(obj);
// 4. 判断函数的返回值类型
if (typeof result == "Object") {
func = result; // 若是引用类型,则返回该引用类型的对象
} else {
func = obj; // 若为值类型,返回该对象obj
}
2.2.3 构造函数模式下创建对象的结构
在构造函数模式下创建的对象,其结构存在constructor属性(该属性实际存在于构造函数的原型中,后续会详细说明),这个属性指向对象,最初的目的用来标识对象ren类型,相比起typeof操作符,使用instanceof操作符更加可靠。
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function () {
alert(this.name);
}
}
var person1 = new Person("XiaoMing", 25, "teacher"); // 构造对象
var person2 = new Person("LiHua", 26, "lawyer"); // 构造对象
alert(person1.constructor == Person); // true,该方式不推荐
alert(person2.constructor == Person); // true,该方式不推荐
alert(person1 instanceof Object); // true
alert(person1 instanceof Person); // true
alert(person2 instanceof Object); // true
alert(person2 instanceof Person); // true
2.2.4 构造函数也是函数(请将构造函数当作函数)
在ECMAScript中,对于任何函数,只要通过new操作符调用,就是构造函数。若不使用new操作符构调用,则与普通函数没有任何区别。构造函数也能够在不同的作用域中调用。
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function () {
alert(this.name);
}
}
// 作为构造函数调用(使用new操作符)
// 典型用法,使用new操作符创建新对象
var person = new Person("XiaoMing", 25, "teacher");
person.sayName(); // "XiaoMing"
// 作为普通函数调用(不使用new操作符)
// 不使用new操作符创建新对象,则将对象的属性和方法都添加至当前作用域中(此时作用域为window)
Person("LiHua", 25, "teacher");
window.sayName(); // "LiHua"
// 在不同作用域中调用
// 在特殊对象的作用域中使用call()/apply()函数来调用构造函数,会将构造函数中的属性和方法赋给特殊对象
var o = new Object();
Person.call(o, "Jacky", 24, "doctor");
o.sayName(); // "Jacky"
2.2.5 构造函数模式的问题
虽然构造函数模式能够处理工厂函数模式的问题,但针对每实例化一个对象,就需要实例化一个方法,对于实例化多个相同类型的对象来说,就比较占用内存。因此只需要解决方法的实例化问题即可,即将方法创建全局对象中,实现重复调用。
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName; // 将构造函数中对方法的定义转移至外部完成
}
// 全局函数,此时用于提供该构造函数调用
function sayName() {
alert(this.name);
}
var person = new Person("XiaoMing", 25, "teacher");
person.sayName(); // "XiaoMing"
这种方式能解决上述构造函数带来的内存问题,但是也引发了其他问题:
- 在全局作用域中定义的函数只能被某个对象调用
- 如果一个对象有多个方法,按照这个处理方式,需要定义大量的全局函数,则自定义的引用类型就毫无封装性可言
为了解决以上问题,便出现了原型模式。
三、原型模式
在ECMAScript中, 每个函数都有一个属性:prototype,这个属性是一个指针,指向该一个对象(即是原型对象)。这个对象是通过调用构造函数而创建的那个对象的原型对象,其用途是包含由特定类型的所有实例共享的属性和方法(即不需要在构造函数中定义对象实例的信息,而是将这些信息直接添加至原型对象中)
3.1 什么是原型对象
通常来说,在创建一个新函数时,JavaScript引擎根据一组特定的规则为这个新函数创建一个prototype的属性,该属性指向原型对象。在默认情况下,所有原型对象都会自动获得constuctor属性,这个属性包含一个指向prototype属性所在函数的指针(即constuctor属性会指向这个有prototype属性函数);原想对象除了包含constructor属性之外,还包括其后来添加的其他方法。
对于自定义的构造函数,其原型默认只会获得constructor属性(并且该属性共享);其他方法都是从Object中继承而来。当自定义的构造函数创建了一个实例,该实例的内部将包含一个内部指针[[Prototype]]
,指向构造函数的原型对象。[[Prototype]]
没有标准的访问方式,具体需要看使用的浏览器如何将[[Prototype]]
实现的(比如Firefox,Safari,Chorme可以通过__proto__属性访问)。
// 自定义构造函数
function Person() {}
// 共享属性(原型对象的属性,由所有该构造函数实例共享)
Person.prototype.name = "XiaoMing";
Person.prototype.age = 25;
Persom.prototype.job = "teacher";
Person.prototype.sayName = function () {
alert(this.name);
}
var peroson1 = new Person();
person1.sayName(); // "XiaoMing"
var peroson2 = new Person();
person2.sayName(); // "XiaoMing"
// 由于属性共享,sayName方法指向同一个Function对象
alert(person1.sayName == person2.sayName); // true
3.1.1 判断构造函数实例的内部指针和原型对象是否存在关系
JavaScript提供了一个方法:prototypeObj.isPrototypeOf,用于检测一个对象是否存在于另一个对象的原型链上(也可以从方法名的字面意思理解:prototypeObj是否为某个实例的原型?)
alert(Person.prototype.isPrototypeOf(person1)); // true
alert(Person.prototype.isPrototypeOf(person2)); // true
3.1.2 返回内部指针[[Prototype]]的值
返回[[Prototype]]指定的对象原型所用的方法是Object.getPrototypeOf,但是目前只在ECMAScript第5版中实现,这对于利用原型实现继承的情况十分重要
alert(Object.getPrototype(person1) == Person.prototype); // true
alert(Onject.isPrototypeOf(person1).name); // "XiaoMing"
3.1.3 读取对象的属性过程(多个对象实例共享原型所保存的属性和方法的基本原理)
每当代码读取对象的属性时,会以给定名字的属性作为目标,在该对象进行一次搜索。
搜索会先从对象实例开始,如果在实例中找到给定名字的属性,则返回该值;如果没有找到,则继续搜索指针指向的原型对象,找到则返回属性值。
需要注意的是:虽然能够在对象实例中访问保存在原型中的值,但却不能通过对象实例重写原型中的值
3.1.3.1 屏蔽原型属性
若想要屏蔽原型对象的属性,只需要在原型对象指向的构造函数所创建的实例中定义好该属性,则根据读取对象属性的流程,则只会在实例中返回该值并停止向其原型对象进行查询,从而实现屏蔽原型属性。
person1.name = "LiHua";
alert(person1.name); // "LiHua"——来自实例
alert(person2.name); // "XiaoMing"——来自原型
3.1.3.2 删除实例属性
使用delete操作符可以完全删除实例的属性,同时也能够恢复与对象原型的属性连接。
delete person1.name; // 删除实例属性name="LiHua"
alert(person1.name); // "XiaoMing"——来自原型
3.1.3.3 检测属性存在实例/原型中
- 使用obj.hasOwnProperty可以检测某一个属性存在于实例对象还是原型对象中。该方法继承于Object,因此方法只在给定的属性存在于实例中时,才会返回true(无论给定属性的值是否为null或者undefined,只要存在则返回true)
- 使用in操作符也能够检测某一个属性存于实例对象还是原型对象中。1)直接使用in操作符,不管属性是在实例对象还是再原型对象中,存在则然会true。因此可以配置.hasOwnProperty()配合使用,确定属性是存在于实例对象中还是原型对象中;2)通过for-in循环,返回所有可枚举的([[Enumerable]]特征为true的属性),能够通过对象访问的属性,不管是存在于实例对象中的还是原型对象中的属性。即使时原型中不可枚举的属性(比如[[Enumerable]]特征为false的属性)也会返回
alert(person1.hasOwnProperty("name")); // false, 表示name属性存在于原型中
person1.name = "LiHua"; // "LiHua"——存在于实例中
alert(person1.name); // "LiHua"
alert(person1.hasOwnProperty("name")); // true, 表示name属性存在于实例中
alert("name" in person1); // true,直接使用in操作符
alert(person2.name); // "XiaoMing"——存在于原型中
alert(person2.hasOwnproperty("name")); // false, 表示name属性存在于原型中
alert("name" in person2); // true,直接使用in操作符
delete person.name;
alert(person1.name); // "XiaoMing"——存在于原型中
alert(person1.hasOwnproperty("name")); // false, 表示name属性存在于原型中
alert("name" in person1); // true,直接使用in操作符
// in操作符与.hasOwnProperty()配合使用
function hasPrototypeProperty(object, name) {
return !object.hasOwnProperty(name) && (name in object);
}
var person = new Person();
alert(hasPrototypeProperty(person, "name")); // true, 表示name属性存在于原型中
person.name = "LiHua";
alert(hasPrototypeProperty(person, "name")); // false, 表示name属性存在于实例中
// 使用for-in循环
var o = {
toString: function() {
return "My Object";
}
}
for (var prop in o) {
alert("Found toString");
}
3.1.3.4 获取可枚举属性/获取对象的所有属性
- 获取可枚举属性
上一节提到通过for-in循环能够返回所有属性,而通过Object.keys方法能够将给定对象的所有可枚举属性组织成字符串数组并返回,可以替换for-in循环
var keys = Object.keys(Person.prototype);
alert(keys); // ["name", "age", "job", "sayName"]
var p1 = new Person();
p1.name = "LiHua";
p1.age = 26;
var p1Keys = Object.keys(p1);
alert(p1Keys); // ["name", "age"]
- 获取对象的所有属性(无论是否枚举)
使用Object.getOwnPropertyNames方法能够在给定对象上找到自身属性对象的字符串数组,同样地,也能够替换替换for-in循环
var keys = Object.getOwnPropertyNames(Person.prototype);
alert(keys); // ["constructor", "name", "age", "job", "sayName"]
3.1.3.5 更加简单的原型语法
比起重复编写object.prototype,更简单的语法便是将原型对象的所有属性和方法写入一个对象字面量中,从而重写整个原型函数
function Person() {}
Person.prototype = {
name: "XiaoMing",
age: 25,
job: "teacher",
sayName: function () {
alert(this.name);
}
}
与之前对原型对象定义属性不同,更加简单的原型语法导致constructor属性不在指向原来的构造函数,而是指向Object的构造函数
var friend = new Person();
// 判断对象类型
alert(friend instanceof Object); // true
alert(friend instanceof Person); // true
// 判断对象的constructor属性
alert(friend.constructor == Person); // false
alert(friend.constructor == Object); // true
若需要解决对象字面量创建原型对象的constructor属性指向问题,则只需要将原型对象的constructor属性再指向构造函数即可
Person.prototype = {
constructor: Person
// 将constructor属性指向构造函数
// 这样会使得constructor属性的[[Enumerable]]特性被设置为true
// 默认情况下,原生的constructor属性是不可枚举的(即[[Enumerable]]特性值为false)
name: "XiaoMing",
age: 25,
job: "teacher",
sayName: function () {
alert(this.name);
}
}
3.1.3.6 重设构造函数(适用于ECMAScript5兼容的浏览器)
这里的重设构造函数,并不是将重新创建构造函数,而是通过Object.defineProperty方法将原型对象的constructor属性指向构造函数,并且能够修改单纯对象字面量定义属性产生的错误:constructor属性的[[Enumerable]]特性被设置为true(默认为false)
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false, // 将[[Enumerable]]特性被设置为false
value: Person
})
3.2 原型的动态性
实例与原型之间的关系是松散耦合的连接。在实例对象找不到属性的情况下,会前往原型对象中搜索(因为这是读取对象的过程,也因为实例与原型之间的连接只是一个指针,而不是一个副本)
function Person() {};
// 创建实例
var friend1 = new Person();
// 在原型对象中添加方法
Person.prototype.sayHi = function () {
alert("hi");
}
// 在实例中调用该方法,证明原型的动态性
friend1.sayHi(); // "hi"
function Person() {}
var friend2 = new Person();
// 重写原型对象,切断与之前存在的实例对象之间的联系
Person.prototype = {
constructor: Person,
name: "XiaoMing",
age: 23,
job: "teacher",
sayName: function () {
alert(this.name);
}
}
friend2.sayName(); // 报错,因为friend指向的原型中不包含该名字命名的属性
如果是重写整个原型对象,则会切断实例[[Prototype]]特性值最初原型之间的联系,而实例中的内部指针仅指向原型对象,不指向构造函数。同时,重写原型对象会切断现有原型与任何之前已经的存在的对象实例之间的联系
3.3 原生对象的原型
所有原生引用类型(Object、Array、String等等)都在其构造函数的原型中定义了方法,通过原生对象的原型,不仅能够获得所有默认方法的引用 ,也能够定义新方法。需要注意的是,尽管能够这样做,但是不推荐在产品化的过程中修改原生对象的原型。
alert(Array.prototype.sort); // "function"
alert(String.prototype.substring); // "function"
// 为原生对象的原型自定义方法
String.prototype.checksWith = function (text) {
return this.indexOf(text) != 1;
}
var msg = "Hello world!";
alert(msg.checksWith("!")); // true
四、原型模式的问题以及处理方式
4.1 原型模式的问题
- 原型模式忽略了向构造函数传递初始化参数,使得所有实例都能够获得相同的属性和方法,在某种程度上带来不便
- 共享性导致的问题,原型中的所有属性会被实例共享,这种共享对函数来说非常合适,基本包含值的属性也还可以(因为可以在实例中添加同名属性而隐藏原型中对盈的属性),但是对于引用类型的属性就问题比较大了,会污染所有实例
function Person() {}
Person.prototype = {
contructor: Person,
name: "XiaoMing",
age: 25,
job: "teacher",
friends: ['Shelby', 'Guy'], // 引用类型值的属性
syaName: function () {
alert(this.name);
}
}
var person1 = new Person();
var person2 = new Person();
person1.friends.push('Van'); // 在person1实例上修改属性
alert(person1.friends); // "Shelby,Guy,Van"
alert(person2.friends); // "Shelby,Guy,Van"
alert(person1.friends === person2.friends); // true
4.2 原型模式问题的处理方式
4.2.1 组合使用构造函数模式+原型模式
这是创建自定义类型最常见方式:构造函数模式用于定义实例属性,原型模式定义党法和共享的属性(如constructor属性),因此,每个函数都会有自己实例属性的副本,同时共享这方法的引用,极大地节省空间。于此同时,该方式还支持向构造函数传递参数
function Person (name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.friend = ['Shelby', 'Guy'];
}
Person.protoptype = {
constructor: Person,
sayName: function () {
alert(this.name);
}
}
var person1 = new Person("XiaoMing", 25, "teacher");
var person2 = new Person("LiHua", 26, "lawyer");
person1.friend.push("Van");
alert(person1.friend); // "Shelby,Guy,Van"
alert(person2.friend); // "Shelby,Guy"
alert(person1.friend == person2.friend); // false, 证明每个实例都有自己的实例副本
alert(person1.sayName == person.sayName); // true, 证明每个实例共享着方法的引用
4.2.2 动态原型模式
将所有信息都封装在一个构造函数内,有必要时通过构造函数中初始化原型(在创建实例时,检查啊某一个应该存在的方法是否有效,来判断是否需要初始化原型。
function Person (name, age, job) {
// 属性
this.name = name;
this.age = age;
this.job = job;
// 方法
if(typeof this.sayName != "function") {
Person.prototype.sayName = function () {
alert(this.name);
}
}
}
var friend = new Person("XiaoMing", 25, "teacher");
friend.sayName();
该例子在每一次创建实例的时候会进行判断,但是只在第一次的时候会初始化原型并添加方法,之后就不在需要初始化原型,同时,对原型所做的修改会立刻在所有实例中得到反映(这是因为原型的动态性)。
使用这种模式能优势:
- 保持了同时使用构造函数和原型的优点;
- 初始化实例时可使用
if
语句检查应该纯存在的任何属性或方法,并且只需要检查一个即可; - 通过该模式创建的对象,能够使用
instanceof
操作符确定其类型。
但需要注意的是,使用该模式时,不能使用对象字面量重写原型,否则会切断构造函数与最初原型的连接(此时的原型对象的constructor属性是指向Object),导致无法获取相关属性。
4.2.3 寄生构造函数模型
创建一个函数,该函数只是封装创建对象的方法,然后返回新创建的对象。
function Person(name, age, job) {
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() {
alert(this.name);
}
return o;
}
var friend = new Person("LiHua", 25, "teacher");
friend.sayName(); // "LiHua"
// 在特殊情况下为对象创建构造函数(创建特殊的数组对象,而不直接修改Array构造函数)
function SpecialArray() {
// 创建数组
var values = new Array();
// 添加要素
values.push.apply(values, arguments);
// 添加方法
values.toPipedString = function () {
return this.join("|");
}
// 返回数组
return values;
}
var colors = new SpecialArray('red', 'green', 'blue');
colors.toPipedString(); // "red|green|blue"
从表面上看,该函数很像典型的构造函数(除了包装函数叫做构造函数,该模式和工厂模式一模一样)并且构造函数在不返回值的情况下,默认返回新实例对象,而通过在构造函数的末尾添加一个return语句则可以重写构造函数时返回的值。
4.2.4 稳妥构造函数
稳妥对象,指的是没有公共属性,而是其方法也不引用this的对象。稳妥对象时最适合在一些安全环境中(该环境会禁止this和new),防止数据被其它应用程序改动时使用
function Person(name, age, job) {
// 创建对象
var o = new Object();
// 可添加私有属性
// 添加方法
o.sayName = function () {
alert(name);
}
return o;
}
var friend = Person("XiaoMing", 25, "teacher");
// 除了.sayName()方法以外,没有其他方式能够访问到数据成员
// 即使有其他代码能够给这个对象添加方法或数据成员,但也没有办法访问到构造函数中的原始函数
friend.sayName(); // "XiaoMing"
与寄生构造模式对比:遵循寄生构造函数类似的模式,但也有不同:1)新创建的实例的方法不引用this;2)不使用new操作符调用构造函数。使用稳妥构造函数模式创建的对象与构造函数之间没有什么关系,因此instanceof操作符对这种对象没有特别的意义
五、继承
对于许多面向对象语言,都支持两种继承方式:
- 接口继承:只继承方法签名
- 实现继承:继承实际的方法
而对于ECMAScript而言,它支支持实现继承,并且实现继承主要式通过原型链实现。
5.1 原型链
通过原型链实现继承的基本思想是利用原型的一个引用类型继承另一个引用类型的属性和方法(假设让一个原型对象等于另一个类型的实例,此使的原型对象将包含指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数地指针),一个原型是另一个类型的实例,这个类型的原型又是另一个类型的实例,这样层层递进,就构成了原型链。
function SuperType() {
this.property = true;
}
SuperType.prototype.getSuperValue = function () {
return this.property;
}
function SubType() {
this.subproperty = false;
}
SubType.prototype = new SuperType(); // SubType的原型等于SuperType的实例,实现原型链继承
SubType.prototype.getSubValue = function () {
return this.subproperty;
};
var instance = new SubType();
alert(instance.getSuperValue());
该例子中,SubType继承了SuperType(通过创建SuperType实例,并将该实例赋给SubType.prototype的实现,实现本质是重写原型对象),SubType原型中会存在一个指针[[Prototype]]并指向SuperType的原型。因此,原来存在于SuperType实例的所有属性和方法,在SubType.prototype中也会存在。intance.constructor属性会指向SuperType,因为原来的SubType.prototype指向了另一个对象的原型,而这个对象的原型的constructor属性指向另一个对象的构造函数。
5.1.1 默认原型(参考资料详解)
所有函数Function的默认原型都是Object的实例,因此,默认原型都会包含一个内部指针,指向Object.prototype
5.1.2 确定原型和实例的关系
确定原型和实例的关系有两种:
- 使用instanceof操作符,用来测试实例与原型中出现过的构造函数,就会返回true
alert(instance instanceof Object); // true
alert(instance instanceof SuperType); // true
alert(instance instanceof SubType); // true
- 使用Object.prototype.isPrototypeOf方法,只要是在原型链中出现过的构造函数,都可以说是该原型链所派生的实例的原型,也会返回true
alert(Object.prototype.isPrototypeOf(instance)); // true
alert(SuperType.prototype.isPrototypeOf(instance)); // true
alert(SubType.prototype.isPrototypeOf(instance)); // true
5.1.3 谨慎定义方法
添加方法的情况:
- 在子类型中重写超类型的某一个同名方法
- 在子类型中添加超类型不存在的方法
function SuperType() {
this.property = true;
}
SuperType.prototype.getSuperValue = function() {
return this.protoptype;
}
function SubType() {
this.prototype = false;
}
// SubType继承于SuperType
SubType.prototype = new SuperType();
// 重写超类型的方法
SubType.prototype.getSuperValue = function () {
return false;
}
// 添加新方法
SubType.prototype.getSubValue = function () {
return this.subprototype;
}
var instance = new SubType();
alert(instance.getSuperValue()); // false
在给原型添加方法的时候一定要在替换原型语句之后(否则会添加失败,因为原型对象被重写了)
5.1.4 原型链实现继承
通过原项链实现继承时,需要注意的是不能使用对象字面量创建原型方法,否则会重写原型
function SuperType() {
this.property = true;
}
SuperType.prototype.getSuperValue = function () {
return this.property;
}
function SubType() {
this.subproperty = false;
}
// SubType继承SuperType
SubType.prototype = new SuperType();
// 重写SubType原型,导致上一行代码无效
Subtype.prototype = {
getSubValue: function () {
return this.subproperty;
},
someOtherMethod: function () {
return false;
}
}
var instance = new SubType();
alert(instance.getSubValue()); // false
5.1.5 原型链问题
在上一章有提到原型模式出现的问题,其实原型链链的问题也相类似,即:
function SuperType() {
this.colors = ["red", "green", "blue"];
}
function SubType() { }
// SubType继承SuperType
// 就跟专门创建了一个SubType.prototype.colors属性一样,但是该属性是数组(引用类型)
SubType.prototype = new SuperType();
var instance1 = new SubType();
intances1.colors.push("black");
alert(instace1.color); // "red,green,blue,black"
var instance2 = new SubType();
alert(instance2.color); // "red,green,blue,black"
- 来自包含引用类型的原值
包含引用类型值得原型属性会被所有实例共享(这也是为什么要在构造函数中定义属性,而不在原型对象中定义),通过原型链来实现继承时,原型实际上变成了另一个类型的实例,因此原先实例的属性也就变成现在的原型属性
- 创建子类型的实例时,不能向超类型的构造函数传递参数
实际上,没有办法在不影响所有对象实例的情况下给超类型的构造函数传递参数
因此,在实际项目中,很少单独使用原型链
5.2 借用构造函数
该方式能够解决上一节中提到的两个原型链问题。其基本思想是使用.call或.apply在子类型构造函数的内部调用超类型的构造函数,可以在之后新创建的实例中执行构造函数
- 解决“来自包含引用类型的原值”的原型链问题
function SuperType() {
this.colors = ["red", "green", "blue"];
}
function SubType() {
// 继承SuperType,在SubType实例化时调用SuperType的构造函数
// 因此能够解决超类中引用类型所带来的问题,使得每个SubType实例都有初始值的副本
SuperType.call(this);
}
var instance1 = new SubType();
instance1.colors.push('black');
alert(instance1.color); // "red,green,blue,black"
var instance2 = new SubType();
alert(instance2.color); // "red,green,blue"
- 解决“创建子类型的实例时,不能向超类型的构造函数传递参数”的问题
function SuperType(name) {
this.name = name;
}
function SubType() {
// SubType继承SuperType
SuperType.call(this, "XiaoMing");
// 实例属性
// 为确保超类型的构造函数不重写子类型的属性,可以在调用超类型构造函数之后,在添加应该在自类型中定义的属性
this.age = 23;
}
var instance = new SubType();
alert(instance.name); // "XiaoMing"
alert(instance.age); // 23
使用这种方式继承能够解决原型链继承的问题,但也出现了其他问题:
- 因为使用的是构造函数,因此也会存在构造函数存在的问题:方法都在构造函数中定义,方法无法复用
- 在超类型的原型中定义的方法,对子类型而言是不可见的,因此所有类型都能使用构造函数模式
因此,这种继承方式也比较少用。
5.3 组合继承(伪经典继承)
这是一种将原型链和借用构造函数的技术组合在一起的继承方式,基本思路是使用原型链实现对属性和方法的继承,通过借用构造函数实现实例属性的继承。
function SuperType(name) {
this.name = name;
this.colors = ['red', 'green', 'blue'];
}
SuperType.prototype.sayName = function () {
alert(this.name);
}
function SubType(name, age) {
// 继承SuperType的属性
SuperType.call(this, name);
// 实例属性
this.age = age;
}
// 继承方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function () {
alert(this.age);
};
var instance1 = new SubType('Xiaofeng.z', 23);
instance1.colors.push('black');
alert(instance1.colors); // "red,green,blue,black"
instance1.sayName(); // "Xiaofeng.z"
instance1.sayAge(); // 23
var instance2 = new SubType('Greg', 19);
alert(instance2.colors); // "red,green,blue"
instance2.sayName(); // "Greg"
instance2.sayAge(); // 29
使用该方式能够避免原型链和构造函数的缺陷,并且融合了他们的优点,同时使用instanceof
操作符和prototypeObj.isPrototypeOf
方法也能够识别基于组合继承创建的对象。但是它也有不足之处,就是无论在什么情况下,都会调用两次超类型的构造函数,一次在创建子类型原型的时候,另一次是在子类型的构造函数的内部。这个问题的解决方式是使用后续会介绍的寄生组合式继承
5.4 原型继承
使用该方法实现继承的基本思想是借助原型,可以基于已有的对象创建新对象,同时还不必因此创建自定义类型
// 基础函数
function object(o) {
function F() { };
F.prototype = o;
return new F();
}
// 基础对象
var person = {
name: "XiaoMing",
friends: ["Shelby", "Court", "Van"]
}
var anOtherPerson = object(person);
anOtherPerson..name = "Lihua";
anOtherPerson.friends.push('Rob');
var yetAnotherPerson = onject(person);
yetAnotherPerson.name = 'ZhangSan';
yetAnotherPerson.friends.push('Barbie');
alert(person.friends); // "Shelby,Court,Van,Rob,Barbie"
在ECMAScript第5版中,通过新增Object.create方法规范了原型式继承,在传入一个参数的情况下,Object.create方法与object方法的行为相同
var person = {
name: "LiHua",
friends: ['Shelby', 'Court', 'Van']
};
var anOtherPerson = Object.create(person); // 传入一个参数
anOtherPerson..name = "Lihua";
anOtherPerson.friends.push('Rob');
var yetAnotherPerson = Object.create(person); // 传入一个参数
yetAnotherPerson.name = 'ZhangSan';
yetAnotherPerson.friends.push('Barbie');
alert(person.friends); // "Shelby,Court,Van,Rob,Barbie"
当Object.create方法传入两个或多个参数时,第二个参数的属性需要以对象的属性参入。
//当Object.create()传入两个参数时
var person = {
name: "LiHua",
friends: ['Shelby', 'Court', 'Van']
};
var anOtherPerson = Object.create(person, {
name: {
value: "LiHua"
}
});
alert(anOtherPerson); // "LiHua"
5.5 寄生式继承
寄生式继承与继承构造函数和工厂模式类似,基本思路是创建一个仅用于封装继承的函数,改函数在内部以某一种方式来增强对象,到最后就好像是它(封装继承的函数)做了所有的工作。但该模式也需要像原型继承那样基于一个对象进行继承,任何能够返回新对象的函数都可以使用此模式
// 基础函数
function object(o) {
function F() { };
F.prototype = o;
return new F();
}
function createAnother(orginal) {
// object函数不是必需的,任何能够返回新对象的函数都是用此模式
var clone = object(original); // 通过调用改函数创建一个对象
clone.sayHi = function () { // 以某种方式增强该对象
alert("hi");
};
return clone; // 返回对象
}
var person = {
name: "XiaoMing",
friends: ["Shelby", "Court", "Van"]
}
var anotherPerson(person);
anotherPerson.sayHi(); // "hi
同样地,使用寄生式继承的方式为对象添加方法,会由于不能做到函数复用而降低效率(与构造函数模式类似)
5.6 寄生组合式继承
通过该方式继承,可以做使用借用构造函数来继承属性、使用原型链的混成形式俩继承方法,其基本思路是不用为了定制子类型的原型而调用超类型的构造函数(只需要超类型原型的一个副本),从本质上说,就是使用寄生式继承来继承超类型的原型,然后将结果指定给子类型的原型
// 基础函数
function object(o) {
function F() { };
F.prototype = o;
return new F();
}
function inheritPrototype(SubType, SuperType) {
var prototype = object(SuperType); // 创建对象(创建超类型副本)
prototype.constructor = SubType; // 增强对象(弥补副本的constructor属性)
SubType.prototyope = prototype; // 指定对象(将副本复制给子类型)
}
function SuperType(name) {
this.name = name;
this.colors = ['red', 'green', 'blue'];
}
SuperType.prototype.sayName = function () {
alert(this.name);
}
function SubType(name, age) {
SuperType.call(this, name);
this.age = age;
}
inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function () {
alert(this.age);
};
这种方式有着高效率的特点,具体体现在它只调用了一次SuperType构造函数,并且避免了在SubType.prototype上面创建不必要、多余的属性。同时保持原型链不变,因此能够正常使用instanceof
操作符和prototypeObj.isPrototypeOf
方法
六、参考资料
- 《JavaScript高级程序设计》第3版
- new 操作符具体干了什么?
- JavaScript中的new操作符的原理解析
- 帮你彻底搞懂JS中的prototype、__proto__与constructor(图解)