对象详解(原型链、继承)
ECMA-262 把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。” 我们可以把ECMAScript 的对象想象成散列表:无非就是一组名值对,其中值可以是数据或函数。
1. 理解对象
1.1 属性类型
简而言之,就是描述对象属性的各种特征。
属性类型分为两种:数据属性和访问器属性。一个属性只能为数据属性或访问器属性,也就是说如果一个描述符同时重新定义(value或writable)和(get或set)关键字,将会产生一个异常。
1.1.1 数据属性
数据属性包含4个特性:
[[Configurable]]
:表示能否通过delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。默认值为true,一旦修改为false,就不能再次修改为true。[[Enumerable]]
:表示能否通过for-in 循环返回属性。默认值为true。[[Writable]]
:表示能否修改属性的值。默认值为true。[[Value]]
:包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。默认值为undefined。
当对象属性为数据属性时,[[Get]]
、[[Set]]
均为 undefined。
1.1.2 访问器属性
访问器属性有如下4 个特性:
[[Configurable]]
:表示能否通过delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。默认值为true,一旦修改为false,就不能再次修改为true。[[Enumerable]]
:表示能否通过for-in 循环返回属性。默认值为true。[[Get]]
:在读取属性时调用的函数。默认值为undefined。[[Set]]
:在写入属性时调用的函数。默认值为undefined。
不一定非要同时指定getter 和setter。只指定getter 意味着属性是不能写,尝试写入属性会被忽略,在严格模式下会抛出错误。类似地,只指定setter 函数的属性也
不能读,否则在非严格模式下会返回undefined,而在严格模式下会抛出错误。
当对象属性为访问器属性时,[[Value]]
为 undefined,[[Writable]]
为false。
1.1.3 操作属性
不论是数据属性,还是访问器属性,都不能直接访问、修改,必须调用方法。
1.1.3.1 Object.defineProperty()
该方法方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
Object.defineProperty(obj, prop, descriptor)
var book = {
_year: 2004,
edition: 0,
};
Object.defineProperty(book, "edition", {
configurable: false,
value: 1,
});
Object.defineProperty(book, "year", {
get: function() {
return this._year;
},
set: function(newValue) {
if (newValue > 2004) {
this._year = newValue;
this.edition += newValue - 2004;
}
}
});
console.log(book.edition); //1
book.year = 2005;
console.log(book.edition); //2
PS:下划线是一种常用的记号,用于表示只能通过对象方法访问的属性。
1.1.3.2 Object.defineProperties()
改方法直接在一个对象上定义多个新的属性或修改多个现有属性,并返回该对象。
Object.defineProperties(obj, props)
var book = {};
Object.defineProperties(book, {
_year: {
value: 2004
},
edition: {
value: 1
},
year: {
get: function() {
return this._year;
},
set: function(newValue) {
if (newValue > 2004) {
this._year = newValue;
this.edition += newValue - 2004;
}
}
}
});
注意:当使用 Object.defineProperty()、Object.defineProperties() 为对象添加数据属性时,如果只指定了
value
特性时,Configurable
、Enumerable
、Writable
均会置为false(修改数据属性则不会):var person = { name: 'Mary', age: 20, }; Object.defineProperties(person, { name: { value: 'Tom', }, age: { value: 20, }, }); var descriptors = Object.getOwnPropertyDescriptors(person); console.log(descriptors); // { // name: { // value: 'Tom', // writable: true, // enumerable: true, // configurable: true // }, // age: { // value: 20, // writable: true, // enumerable: true, // configurable: true // } // }
1.1.3.3 Object.getOwnPropertyDescriptor()
该方法返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性)
Object.getOwnPropertyDescriptor(obj, prop)
// 紧接上例
// ...
var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
console.log(descriptor.value); //2004
console.log(descriptor.configurable); //false
console.log(typeof descriptor.get); //"undefined"
var descriptor = Object.getOwnPropertyDescriptor(book, "year");
console.log(descriptor.value); //undefined
console.log(descriptor.enumerable); //false
console.log(typeof descriptor.get); //"function"
2. 原型 prototype
要理解 JavaScript 对象原型,首先需要了解一下 js 对象创建的进阶过程。
2.1 背景
2.1.1 工厂模式
这种模式只是用函数简单封装了创建对象的细节,如下例:
function createPerson(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 person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");
工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即q确定一个对象的类型)。为解决这个问题,就出现了构造函数模式。
2.2.2 构造函数模式
使用构造函数模式将前面的例子重写如下:
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
alert(this.name);
};
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
使用构造函数创建新实例,必须使用new 操作符。以这种方式调用构造函数实际上会经历以下4个步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(因此this 就指向了这个新对象);
- 执行构造函数中的代码(为这个新对象添加属性);
- 返回新对象。
(1) 特性
任何函数,只要通过new 操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过new 操作符来调用,那它就是一个普通函数。
例如,前面例子中定义的Person()函数可以通过下列任何一种方式来调用。
// 当作构造函数使用
var person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); //"Nicholas"
// 作为普通函数调用
Person("Greg", 27, "Doctor"); // 添加到window
window.sayName(); //"Greg"
// 在另一个对象的作用域中调用
var o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); //"Kristen"
(2) 缺点
使用构造函数模式也有缺点,就是每个方法都要在每个实例上重新创建一遍。因为ECMAScript 中的函数是对象,因此每定义一个函数,也就是实例化了一个对象。也就是说,以这种方式创建函数,会导致不同的作用域链和标识符解析。
针对这种问题,就出现了原型模式。
2.2 原型模式
我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象(原型对象),而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
alert(this.name);
};
var person1 = new Person();
person1.sayName(); //"Nicholas"
var person2 = new Person();
person2.sayName(); //"Nicholas"
alert(person1.sayName == person2.sayName); //true
2.2.1 理解原型对象
首先,要明白(构造)函数、原型对象、实例之间究竟有什么关系:
- (构造)函数与原型对象
- 只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个指向函数的原型对象的 prototype 属性
- 在默认情况下,所有原型对象都会自动获得一个 constructor(构造函数)属性,这个属性是一个指向prototype 属性所在函数的指针
- 实例与原型对象
- 调用构造函数创建一个新实例后,该实例的内部将包含一个指针,指向构造函数的原型对象。该指针是内部属性[[Prototype]],部分浏览器支持
__proto__
,最好使用Object.getPrototypeOf()
访问该属性
- 调用构造函数创建一个新实例后,该实例的内部将包含一个指针,指向构造函数的原型对象。该指针是内部属性[[Prototype]],部分浏览器支持
更加直观的方式如下图所示:
综上所述:
- 实例与(构造)函数没有直接连接,二者通过原型对象连接
- (构造)函数有 prototype 属性,指向原型对象
- 实例有
__proto__
内部属性,指向原型对象
2.2.2 原型搜索机制
实例属性有3个来源:构造函数、原型对象、实例自定义。
代码是如果读取实例的某个属性的呢?代码的每次读取操作都可以看做是一次搜索:(1)搜索实例本身,若是找到该属性,则返回该属性的值,否则执行下一步;(2)搜索指针指向的原型对象,如果在原型对象中找到了这个属性,则返回该属性的值,否则返回 undefined。
所以实例与原型的同名属性会覆盖原型对象的属性,不过,使用delete 操作符则可以完全删除实例属性,从而让我们能够重新访问原型中的属性。
function Person(name) {
this.name = name;
}
Person.prototype.sayName = function() {
console.log(this.name);
}
var friend = new Person('Mary');
friend.age = '20';
console.log(friend); // Person { name: 'Mary', age: '20' }
friend.sayName(); // Mary
friend.sayName = function() {
console.log('my name is ' + this.name);
}
friend.sayName(); // my name is Mary
delete friend.sayName;
friend.sayName(); // Mary
(1) 原型的动态性
由于在原型中查找值的过程是一次搜索,所以如果先创建了实例后修改原型,修改结果也会体现在实例上:
// 接上例
Person.prototype.sayHi = function() {
console.log("hi");
};
friend.sayHi(); //"hi"(没有问题!)
(2) 重写原型对象
尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就不一样了:
Person.prototype = {
sayHi: function() {
console.log('hi');
}
};
friend.sayHi(); // TypeError
console.log(Person.prototype); // { sayHi: [Function: sayHi] }
console.log(Object.getPrototypeOf(friend)); // Person { sayName: [Function] }
console.log(Person.prototype.constructor); // [Function: Object]
console.log(Object.getPrototypeOf(friend).constructor); // [Function: Person]
此时,就等于切断了构造函数与最初原型之间的联系,如下图所示:
可以发现,新原型的 constructor 属性不再指向Person 了。前面曾经介绍过,每创建一个函数,就会同时创建它的 prototype 对象,这个对象也会自动获constructor 属性。而我们在这里使用的语法,本质上完全重写了默认的prototype 对象,因此constructor 属性也就变成了新对象的constructor 属性(指向Object 构造函数),不再指向Person 函数。
如果constructor 的值真的很重要,可以像下面这样特意将它设置回适当的值:
Person.prototype = {
constructor : Person,
sayHi: function() {
console.log('hi');
}
};
注意,以这种方式重设constructor 属性会导致它的[[Enumerable]]特性被设置为true。默认情况下,原生的constructor 属性是不可枚举的,可以试一试Object.defineProperty()
。
//重设构造函数,只适用于ECMAScript 5 兼容的浏览器
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
});
2.2.3 原生对象的原型
所有原生引用类型(Object、Array、String,等等)都在其构造函数的原型上定义了方法。例如 Array.prototype.sort()
、String.prototype.substring()
等。
通过原生对象的原型,不仅可以取得所有默认方法的引用,而且也可以定义新方法。
String.prototype.startsWith = function(text) {
return this.indexOf(text) == 0;
};
var msg = "Hello world!";
alert(msg.startsWith("Hello")); //true
尽管可以这样做,但不推荐在产品化的程序中修改原生对象的原型。
2.2.4 相关方法
(1) Object.getPrototypeOf()
返回指定对象的原型(内部[[Prototype]]
属性的值)
Object.getPrototypeOf(obj)
语法:obj 必须为对象类型,一般是实例对象
返回:给定对象的原型。如果没有[继承](#3. 继承)属性,则返回
null
var proto = {};
var obj = Object.create(proto);
Object.getPrototypeOf(obj) === proto; // true
(2) Object.getOwnPropertyNames(obj)
返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括Symbol值作为名称的属性)组成的数组。
Object.getOwnPropertyNames(obj)
语法:obj 必须为对象类型
返回:在给定对象上找到的自身(不包括原型、继承)属性对应的字符串数组。
function Person(name) {
this.name = name;
}
Person.prototype.sayName = function() {
console.log(this.name);
}
var friend = new Person('Mary');
friend.sayHi = function() {
console.log('hi');
}
console.log(Object.getOwnPropertyNames(friend)); // ["name", "sayHi"]
console.log(Object.getOwnPropertyNames(Person)); // ["length", "name", "arguments", "caller", "prototype"]
如果想获取一个对象自身可枚举的属性,可以使用
Object.keys
(3) Object.prototype.hasOwnProperty(obj)
obj.hasOwnProperty(prop)
语法:obj 只能是对象实例;prop 只能是字符串形式的属性或 Symbol 值
返回:判断某个对象是否含有指定的属性的布尔值 Boolean
function Person(){
}
Person.prototype.name = "Nicholas";
var person = new Person();
console.log(person1.hasOwnProperty("name")); //false
person.name = "Greg";
console.log(person1.hasOwnProperty("name")); //true
(4) in
prop in object
语法:prop 可以是一个字符串类型或者 symbol 类型的属性名或者数组索引(非symbol类型将会强制转为字符串)
返回:如果指定的属性在指定的对象或其原型链中,返回true
var proto = { name: 'Mary' };
var obj = Object.create(proto);
console.log('name' in obj); // true
var trees = new Array("redwood", "bay", "cedar", "oak", "maple");
0 in trees // 返回true
"length" in trees // 返回true (length是一个数组属性)
结合
Object.getOwnPropertyNames()
与 in,可以,就可以确定一个属性到底是存在于对象中,还是存在于原型中function isPrototypeProperty(object, name) { return !object.hasOwnProperty(name) && (name in object); }
3. 继承
许多 OO 语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。如前所述,由于函数没有签名,在ECMAScript 中无法实现接口继承。ECMAScript 只支持实现继承,而且其实现继承主要是依靠原型链来实现的。
实现原型链有一种基本模式(方便记忆简写如下):
ChildFunc.prototype = new ParentFunc()
小红书将继承方式分为6种:原型链、借用构造函数、组合原型链与构造函数、原型式、寄生式、寄生组合式。为方便记忆,我将其分了个类。
3.1 最常用:组合使用构造函数模式和原型模式
有时候也叫做伪经典继承,要了解这种方式,首先要了解原型链、构造函数继承的含义
3.1.1 原型链**
首先简单回顾一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象 prototype,原型对象都包含一个指向构造函数的指针 constructor,而实例都包含一个指向原型对象的内部指针 [[Prototype]]。而原型链的本质就是让子类的原型指向父类的实例(借用 OO 语言的概念方便理解),示例如下:
function SuperType() {
this.property = true;
}
SuperType.prototype.getSuperValue = function() {
return this.property;
};
function SubType() {
this.subproperty = false;
}
//继承了SuperType
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function() {
return this.subproperty;
};
var instance = new SubType();
alert(instance.getSuperValue()); //true
关系图如下:
通过实现原型链,本质上扩展了前面介绍的原型搜索机制。拿上面的例子来说,调用 instance.getSuperValue()
会经历三个搜索步骤:1)搜索实例;2)搜索SubType.prototype
;3)搜索 SuperType.prototype
,最后一步才会找到该方法。在找不到属性或方法的情况下,搜索过程总是要一环一环地前行到原型链末端才会停下来。
注意事项
- 不要忘记默认原型Object
所有引用类型默认都继承了Object,而这个继承也是通过原型链实现的。上例中的完整的原型链:
-
给子类添加方法的代码一定要放在替换原型的语句之后
-
通过原型链实现继承时,不能使用对象字面量创建原型方法
-
原型链的问题
-
父类构造函数中定义的引用类型的属性会被所有子类实例共享
function SuperType() { this.colors = ["red", "blue", "green"]; } function SubType() {} //继承了SuperType SubType.prototype = new SuperType(); var instance1 = new SubType(); instance1.colors.push("black"); alert(instance1.colors); //"red,blue,green,black" var instance2 = new SubType(); alert(instance2.colors); //"red,blue,green,black"
-
在创建子类型的实例时,不能向超类型的构造函数中传递参数
-
3.1.2 构造函数继承
这种技术的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数:
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
function SubType() {
//继承了SuperType
SuperType.call(this, "Nicholas");
this.age = 29;
}
var instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
console.log(instance1.name); //"Nicholas";
console.log(instance1.age); //29
var instance2 = new SubType();
console.log(instance2.colors); //"red,blue,green"
虽然借用构造函数解决了原型链的缺陷,但其自身仍有问题:1)无发复用函数;2)无法继承父类原型对象定义的属性、方法。
3.1.3 组合使用
构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。重写之前的例子:
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
};
function SubType(name, age) {
//继承属性
SuperType.call(this, name);
this.age = age;
}
//继承方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
console.log(this.age);
};
var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29
var instance2 = new SubType("Greg", 27);
console.log(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27
3.2 最简单:原型式继承
道格拉斯·克罗克福德在2006 年写了一篇文章,题为Prototypal Inheritance in JavaScript (JavaScript中的原型式继承)。在这篇文章中,他介绍了一种实现继承的方法:借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。为了达到这个目的,他给出了如下函数。
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
从本质上讲,object()
对传入其中的对象执行了一次浅复制。
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
var person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
var anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
alert(person.friends); //"Shelby,Court,Van,Rob,Barbie"
ECMAScript 5 通过新增Object.create()
方法规范化了原型式继承。这个方法接收两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。在传入一个参数的情况下,Object.create()
与object()
方法的行为相同。Object.create()
方法的第二个参数都会覆盖原型对象上的同名属性。
var person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
var anotherPerson = Object.create(person, {
name: {
value: "Greg"
}
});
alert(anotherPerson.name); //"Greg"
虽然这种方法有缺陷(浅复制等),但如果只是需要简单地操作对象的属性,就可以使用这种方法。
3.3 最理想:寄生组合式继承
3.3.1 寄生式继承
寄生式(parasitic)继承是与原型式继承紧密相关的一种思路,并且同样也是由克罗克福德推而广之的。寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后返回对象。
function createAnother(original) {
var clone = Object.create(original); //通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式来增强这个对象,这是与原型式继承的主要不同
console.log("hi");
};
return clone; // 返回这个对象
}
var person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
var anotherPerson = createAnother(person);
anotherPerson.sayHi(); //"hi"
使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率;这一
点与构造函数模式类似。
3.3.2 寄生组合式继承
先看下面这个例子:
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
};
function SubType(name, age) {
SuperType.call(this, name); // 第二次调用SuperType()
this.age = age;
}
SubType.prototype = new SuperType(); // 第一次调用SuperType()
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
console.log(this.age);
};
var instance = new SubType("Nicholas", 29);
可以发现组合继承的问题:
- 调用两次构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部(创建子类型实例引起)
- 在第一次调用
SuperType
构造函数时,SubType.prototype
会得到两个属性:name 和 colors;再调用一次SuperType
构造函数,这一次又在新对象上创建了实例属性 name 和 colors。于是,这两个属性就屏蔽了原型中的两个同名属性。下图展示了上述过程。
寄生组合式继承其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型(即替换第一次调用构造函数)。寄生组合式继承的基本模式如下所示。
function inheritPrototype(subType, superType) {
var prototype = Object.create(superType.prototype); //创建对象
prototype.constructor = subType; //增强对象,保证了原型链的完整
subType.prototype = prototype; //指定对象
}
修改上例如下:
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
};
function SubType(name, age) {
SuperType.call(this, name);
this.age = age;
}
inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function() {
console.log(this.age);
};
var instance = new SubType("Nicholas", 29);
instance.colors.push("black");
console.log(instance.colors); //"red,blue,green,black"
instance.sayName(); //"Nicholas";
instance.sayAge(); //29
console.log(instance instanceof SubType); //true
console.log(instance instanceof SuperType); //true
原型链图示如下:
参考文献
JavaScript 高级程序设计