第 4 章 对象
- 前言
- 4.1 对象的属性和访问方式
- 4.1.1 对象的属性
- (1)数据属性
- (2)访问器属性
- 4.1.2 属性的访问方式
- (1)使用`.`访问属性
- (2)使用`[]`访问属性
- (3)两者的不同点
- 4.2 创建对象
- (1)基于Object()构造函数
- (2)基于对象字面量
- (3)基于工厂方法模式
- (4)基于构造函数模式
- (5)基于原型对象的模式
- (6)构造函数和原型混合的模式
- (7)基于动态原型模式
- 4.3 对象拷贝
- 4.3.1 对象浅拷贝
- (1)简单的引用复制
- (2)ES6的Obeject.assign()函数
- 4.3.2 对象深拷贝
- (1)JSON序列化和反序列化
- (2)自定义实现深拷贝
- (3)jQuery实现——$.clone()函数和 $.extend()函数
- 4.4 原型对象
- 4.4.1 原型对象、构造函数、实例之间的关系
- (1)原型对象、构造函数和实例之间的关系
- (2)实例的属性读取顺序
- (3)重写原型对象
- 4.4.2 原型链
- (1)原型链的特点
- (2)属性区分
- (3)内置构造函数
- (4)__proto__属性
- 4.5 继承
- 4.5.1 原型链继承
- (1)原型链继承的优点
- (2)原型链继承的缺点
- 4.5.2 构造继承
- (1)构造继承的优点
- (2)构造继承的缺点
- 4.5.3 复制继承
- (1)复制继承的优点
- (2)复制继承的缺点
- 4.5.4 组合继承
- (1)组合继承的优点
- (2)组合继承的缺点
- 4.5.5 寄生组合继承
- 4.6 instanceof运算符
- 4.6.1 instanceof运算符的常规用法
- 4.6.2 instanceof运算符用于继承判断
- 4.6.3 instanceof运算符的复杂用法
- 4.6.4 instanceof运算符的复杂用法的详细处理过程
- (1)Object instanceof Object
- (2)Function instanceof Function
- (3)Foo instanceof Foo
- (4)String instanceof String
前言
本章是第四章对象相关的内容。
JavaScript虽然是一门弱类型语言,但它同样是一门面向对象的语言,严格来说它是一门基于原型的面向对象的语言。
在学完后,希望掌握下面知识点:
- 对象的属性和访问方式
- 创建对象
- 对象拷贝
- 原型对象
- 继承
- instanceof 运算符
4.1 对象的属性和访问方式
4.1.1 对象的属性
对象:无序属性的集合,其属性值可以包含基本类型值、对象或者函数等。
对象其实就是一组键值对的组合,键表示的是属性名称,值表示的是属性的值。
对象的属性可以分成两种:
- 数据属性
- 访问器属性
(1)数据属性
数据属性有 4 个描述其行为的特性,而且都是内部值,所以将其放在两对方括号中:
[[Configurable]]
:表示属性能否删除而重新定义,或者是否可以修改为访问器属 性,默认值为true
[[Enumerable]]
:表示属性是否可枚举,可枚举的属性能够通过for...in
循环返回, 默认值为true
[[Writable]]
:表示属性值能否被修改,默认值为true
[[Value]]
:表示属性的真实值,属性的读取和写入均通过此属性完成,默认值为undefined
比如下面,定义了一个包含name属性的对象person,name属性的[[Configurable]]、[[Enumerable]]、[[Writable]]特性值都为true,[[Value]]特性值为’kingx’
var person = {
name: 'kingx'
};
如果需要修改数据属性默认的特性,必须要使用Object.defineProperty()
函数:
Object.defineProperty(target,property,{
configurable: true,
enumerable: false,
writable: false,
value: 'kingx'
});
target
:表示目标对象property
:表示将要修改的属性- 第三个参数是一个描述符对象,描述符对象的属性必须为configurable、enumerable、writable、value,以分别对应4个特性值,可以同时设置其中一个或多个值
(2)访问器属性
访问器属性同样有 4 个特性:
[[Configurable]]
:表示属性能否删除而重新定义,或者是否可以修改为访问器属性,默认值为true
[[Enumerable]]
:表示属性是否可枚举,可枚举的属性能够通过for…in循环返回,默认值为true
[[Get]]
:在读取属性值时调用的函数(一般称为getter()
函数),负责返回有效的值,默认值为undefined
[[Set]]
:在写入属性值时调用的函数(一般称为setter()
函数),负责处理数据,默认值为undefined
如果需要修改访问器属性默认的特性,则必须使用Object.defineProperty()
函数
var person = {
_age: 10
};
Object.defineProperty(person, "age", {
get: function(){
return this._age;
},
set: function(newValue) {
if (newValue > 10) {
this._age = newValue;
console.log('设置成功');
}
}
});
console.log(person.age); // 10
person.age = 9; //没超过10,所以设置失败
console.log(person.age); // 10
person.age = 19; // 设置成功
console.log(person.age); // 19
4.1.2 属性的访问方式
对象属性的访问方式有两种,第一种是使用点操作符.
,第二种是使用中括号操作符[]
(1)使用.
访问属性
语法为:
ObjectName.propertyName
ObjectName
:对象名称propertyName
:属性名称
比如上面的 person.name,就是访问 person 对象的 name 属性值
(2)使用[]
访问属性
语法为:
ObjectName[propertyName]
比如person['name']
(3)两者的不同点
- 点操作符是静态的,只能是一个以属性名称命名的简单描述符,而且无法修 改;中括号操作符是动态的,可以传递字符串或者变量,并且支持在运行时修改。例如:
var obj = {};
obj.name = '张三';
var myName = 'name';
console.log(obj.myName); // undefined,访问不到对应的属性
console.log(obj[myName]); // 张三
- 点操作符不能以数字作为属性名,而中括号操作符可以。例如:
var obj={};
obj.1=1; // 抛出异常,Unexpected number
obj[2]=2;
console.log(obj.1); // 抛出异常,missing ) after argument list
console.log(obj[2]); // 2
- 如果属性名中包含会导致语法错误的字符,或者属性名中含有关键字或者保 留字,可以使用方括号操作符,而不能使用点操作符。例如:
var person = {};
person['first name'] ='kingx';
console.log(person['first name']); // kingx
console.log(person.first name);//抛出异常,missing ) after argument list
4.2 创建对象
最简单的基于Object()构造函数和基于对象字面量 2 种方法简单易懂,但在处理需要同时创建若干个属性名相同,而只是属性值不同的对象时会产生很多冗余代码。因此并不推荐用这 2 种方法批量处理。
除了这两种,还额外介绍了其他 5 种方法。
其中第 6 种构造函数和原型混合的模式是最常见的创建自定义类型对象的方式。
(1)基于Object()构造函数
通过Object对象的构造函数生成一个实例,然后给它增加需要的各种属性。
// Object()构造函数生成实例
var person = new Object();
// 为实例新增各种属性
person.name = 'kingx';
person.age = 11;
person.getName = function (){
return this.name;
};
person.address = {
name: '北京市',
code: '100000'
};
(2)基于对象字面量
对象字面量本身就是一系列键值对的组合,每个属性之间通过逗号分隔。
var person = {
name: 'kingx',
age: 11,
getName: function () {
return this.name;
},
address: {
name: '北京市',
code: '100000'
}
};
(3)基于工厂方法模式
工厂方法模式是一种比较重要的设计模式,用于创建对象,旨在抽象出创建对象和属性赋值的过程,只对外暴露出需要设置的属性值。
// 工厂方法,对外暴露接收的name、age、address属性值
function createPerson(name, age, address) {
// 内部通过Object()构造函数生成一个对象,并添加各种属性
var o = new Object();
o.name = name;
o.age = age;
o.address = address;
o.getName = function () {
return this.name;
};
// 返回创建的对象
return o;
}
var person = createPerson('kingx', 11, {
name: '北京市',
code: '100000'
});
使用工厂方法可以减少很多重复的代码,但是创建的所有实例都是Object类型,无法更进一步区分具体的类型。
(4)基于构造函数模式
构造函数是通过this为对象添加属性的,属性值类型可以为基本类型、对象或者函数,然后通过new操作符创建对象的实例。
function Person(name, age, address) {
this.name = name;
this.age = age;
this.address = address;
this.getName = function () {
return this.name;
};
}
var person = new Person('kingx', 11, {
name: '北京市',
code: '100000'
});
console.log(person instanceof Person); // true
使用构造函数创建的对象可以确定其所属类型,解决了方法3存在的问题。但是使用构造函数创建的对象存在一个问题,即相同实例的函数是不一样的。
var person = new Person('kingx', 11, {
name: '北京市',
code: '100000'
});
var person2 = new Person('kingx', 11, {
name: '北京市',
code: '100000'
});
console.log(person.getName === person2.getName); // false
也就是说每一个实例的函数都会占一定的内存空间,这是没必要的。
(5)基于原型对象的模式
基于原型对象的模式是将所有的函数和属性都封装在对象的prototype属性上。
// 定义函数
function Person() {}
// 通过prototype属性增加属性和函数
Person.prototype.name = 'kingx';
Person.prototype.age = 11;
Person.prototype.address = {
name: '北京市',
code: '100000'
};
Person.prototype.getName = function () {
return this.name;
};
// 生成两个实例
var person = new Person();
var person2 = new Person();
console.log(person.name === person2.name); // true
console.log(person.getName === person2.getName); // true
不同的实例会共享原型上的属性和参数,也就解决了(4)的问题。
但是也因此,如果改变其中一个实例的属性或值,其他的实例对应也都会发生变化。所以基于原型对象的模式很少单独使用
(6)构造函数和原型混合的模式
构造函数和原型混合的模式是目前最常见的创建自定义类型对象的方式。
构造函数中用于定义实例的属性,原型对象中用于定义实例共享的属性和函数。
通过构造函数传递参数,每个实例都能拥有自己的属性值,同时实例还能共享函数的引 用,最大限度地节省了内存空间。结合了两种方式的长处。
// 构造函数中定义实例的属性
function Person(name, age, address) {
this.name = name;
this.age = age;
this.address = address;
}
// 原型中添加实例共享的函数
Person.prototype.getName = function() {
return this.name;
};
// 生成两个实例
var person = new Person('kingx', 11, {
name:'beijing',
code:'100000'
});
var person2 = new Person('kingx2', 12, {
name: '上海市',
code: '200000'
});
// 输出实例初始的name属性值
console.log(person.name); // kingx
console.log(person2.name); // kingx2
// 改变一个实例的属性值
person.address.name = '广州市';
person.address.code = '510000';
// 不影响另一个实例的属性值
console.log(person2.address.name); // 上海市
// 不同的实例共享相同的函数,因此在比较时是相等的
console.log(person.getName === person2.getName); // true
// 改变一个实例的属性,函数仍然能正常执行
person2.name = 'kingx3';
console.log(person.getName()); // kingx
console.log(person2.getName()); // kingx3
(7)基于动态原型模式
动态原型模式是将原型对象放在构造函数内部,通过变量进行控制,只在第一次生成实例的时候进行原型的设置。
动态原型的模式相当于懒汉模式,只在生成实例时设置原型对象,但是功能与构造函数和原型混合模式是相同的。
// 动态原型模式
function Person(name, age, address) {
this.name = name;
this.age = age;
this.address = address;
// 如果Person对象中_initialized 为undefined,则表明还没有为Person的原型对象添加函数
if (typeof Person._initialized === "undefined") {
Person.prototype.getName = function () {
return this.name;
};
Person._initialized = true;
}
}
4.3 对象拷贝
根据复制后的变量与原始变量值的影响情况,拷贝可以分为浅拷贝和深拷贝两种方式。
针对不同的数据类型,浅拷贝和深拷贝会有不同的表现,主要表现于基本数据类型和引用数据类型在内存中存储的值不同。
对于基本数据类型的值,变量存储的是值本身,存放在栈内存的简单数据段中,可以直接进行访问。
对于引用类型的值,变量存储的是值在内存中的地址,地址指向内存中的某个位置。 如果有多个变量同时指向同一个内存地址,则其中一个变量对值进行修改时,会影响到其他的变量。
基本数据类型不管是浅拷贝还是深拷贝都是对值本身的拷贝,对拷贝后值的修改不会影响到原始值。
引用数据类型如果执行的是浅拷贝,对拷贝后值的修改会影响到原始值;如果执行的是深拷贝,则拷贝的对象和原始对象相互独立,不会彼此影响。
4.3.1 对象浅拷贝
浅拷贝只拷贝对象最外层的属性。如果对象存在更深层的属性,则不会进行处理,这就会导致拷贝对象和原始对象的深层属性仍然指向同一块内存。
有 2 种实现浅拷贝的方法:
(1)简单的引用复制
也就是遍历对象最外层的所有属性,直接将属性值复制到另一个变量中。
/**
* JavaScript实现对象浅克隆——引用复制
*/
function shallowClone(origin) {
var result = {};
// 遍历最外层属性
for (var key in origin) {
// 判断是否是对象自身的属性
if (origin.hasOwnProperty(key)) {
result[key] = origin[key];
}
}
return result;
}
(2)ES6的Obeject.assign()函数
ES6 中,Object 对象新增了一个assign()
函数,用于将源对象的可枚举属性复制到目标对象中。
var origin = {
a: 1,
b: [2, 3, 4],
c: {
d: 'name'
}
};
// 通过Object.assign()函数克隆对象
var result = Object.assign({}, origin);
console.log(origin); // { a: 1, b: [ 2, 3, 4 ], c: { d: 'name' } }
console.log(result); // { a: 1, b: [ 2, 3, 4 ], c: { d: 'name' } }
4.3.2 对象深拷贝
目前有多个类库提供了标准的深拷贝实现方法。
(1)JSON序列化和反序列化
如果一个对象中的全部属性都是可以序列化的,那么我们可以先使用JSON.stringify()函数将原始对象序列化为字符串,再使用JSON.parse()函数将字符串反序列化为一个对象,这样得到的对象就是深克隆后的对象。
var origin = {
a: 1,
b: [2, 3, 4],
c: {
d: 'name'
}
};
// 先反序列化为字符串,再序列化为对象,得到深克隆后的对象
var result = JSON.parse(JSON.stringify(origin));
console.log(origin); // { a: 1, b: [ 2, 3, 4 ], c: { d: 'name' } }
console.log(result); // { a: 1, b: [ 2, 3, 4 ], c: { d: 'name' } }
这种方法能够解决大部分JSON类型对象的深克隆问题,但是对于以下几个问题不能很好地解决:
- 无法实现对函数、RegExp等特殊对象的克隆
- 对象的constructor会被抛弃,所有的构造函数会指向Object,原型链关系断裂
- 对象中如果存在循环引用,会抛出异常
为了解决以上几个问题,我们需要自定义实现深拷贝,对不同的数据类型进行特殊处理,也就是下一种方法
(2)自定义实现深拷贝
这个是实现深拷贝最好的方式,刚才提到的问题全部可以解决。
在自定义实现深拷贝时,需要针对不同的数据类型做针对性的处理,因此我们会先实现判断数据类型的函数,并将所有函数封装在一个辅助类对象中,这里用“_”表示(类似于underscore类库对外暴露的对象)
/**
* 类型判断
*/
(function (_) {
// 列举出可能存在的数据类型
var types = 'Array Object String Date RegExp Function Boolean Number Null Undefined'.split(' ');
function type() {
// 通过调用toString()函数,从索引为8时截取字符串,得到数据类型的值
return Object.prototype.toString.call(this).slice(8, -1);
}
for (var i = types.length; i--;) {
_['is' + types[i]] = (function (self) {
return function (elem) {
return type.call(elem) === self;
};
})(types[i]);
}
return _;
})(_ = {});
执行上面的代码后,_
对象便具有了isArray()
函数、isObject()
函数等一系列判断数据类型的函数。然后再调用_.isArray(param)
函数判断param
是否是数组类型、调用 _.isObject(param)
函数判断param
是否是对象类型。
然后就是对深拷贝的代码实现,具体看注释。
(这块还没有太理解,之后还需要再看看)
/**
* 深拷贝实现方案
* @param source 待拷贝的对象
* @returns {*} 返回拷贝后的对象
*/
function deepClone(source) {
// 维护两个储存循环引用的数组
var parents = [];
var children = [];
// 用于获得正则表达式的修饰符,/igm
function getRegExp(reg) {
var result = '';
if (reg.ignoreCase) {
result += 'i';
}
if (reg.global) {
result += 'g';
}
if (reg.multiline) {
result += 'm';
}
return result;
}
// 便于递归的_clone()函数
function _clone(parent) {
if (parent === null)
return null;
if (typeof parent !== 'object')
return parent;
var child, proto;
// 对数组做特殊处理
if (_.isArray(parent)) {
child = [];
} else if (_.isRegExp(parent)) {
// 对正则对象做特殊处理
child = new RegExp(parent.source, getRegExp(parent));
if (parent.lastIndex)
child.lastIndex = parent.lastIndex;
} else if (_.isDate(parent)) {
// 对Date对象做特殊处理
child = new Date(parent.getTime());
} else {
// 处理对象原型
proto = Object.getPrototypeOf(parent);
// 利用Object.create切断原型链
child = Object.create(proto);
}
// 处理循环引用
var index = parents.indexOf(parent);
if (index !== -1) {
// 如果父数组存在本对象,说明之前已经被引用过,直接返回此对象
return children[index];
}
// 没有引用过,则添加至parents和children数组中
parents.push(parent);
children.push(child);
// 遍历对象属性
for (var prop in parent) {
if (parent.hasOwnProperty(prop)) {
// 递归处理
child[prop] = _clone(parent[prop]);
}
}
return child;
}
return _clone(source);
}
(3)jQuery实现——$.clone()函数和 $.extend()函数
在jQuery中提供了一个$.clone()
函数,但是它是用于复制DOM对象的。真正用于实现拷贝的函数是$.extend()
。
使用$.extend()
函数可以实现函数与正则表达式等类型的克隆,还能保持拷贝对象的原型链关系,解决了深拷贝中存在的3个问题中的前两个,但是却无法解决循环引用的问题。
由于现在更多使用Vue或React等,因此具体方法就不记录在这里了,只是放在这提醒一下。如果后续有需要再单独学习。
4.4 原型对象
通过构造函数创建实例会导致函数在不同实例中重复创建。
可以通过prototype
属性解决这个问题。每一个函数在创建时都会被赋予一个prototype
属性,它指向函数的原型对象,这个对象可以包含所有实例共享的属性和函数。因此在使用prototype
属性后,就可以将实例共享的属性和函数抽离出构造函数,将它们添加在prototype
属性中。
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayName = function () {
console.log(this.name);
}; // 不同实例中的sayName属性是相等的
4.4.1 原型对象、构造函数、实例之间的关系
(1)原型对象、构造函数和实例之间的关系
每个函数在创建时都会被赋予一个prototype
属性,默认情况下,所有的原型对象都会增加一个constructor
属性,指向prototype
属性所在的函数,即构造函数。
当通过new
操作符调用构造函数创建一个实例时,实例具有一个__proto__
属性,指向构造函数的原型对象,因此__proto__
属性相当于连接实例与构造函数的原型对象的桥梁。
下面的例子为构造函数的原型对象添加了 4 个属性,同时生成了 2 个实例:
function Person(){}
Person.prototype.name = 'Nicholas';
Person.prototype.age = 29;
Person.prototype.job = 'Software Engineer';
Person.prototype.sayName = function(){
console.log(this.name);
};
var person1 = new Person();
var person2 = new Person();
以这个 Person 构造函数为例,来看一下构造函数、原型对象和实例之间的关系
构造函数Person有个prototype
属性,指向的是Person的原型对象。在原型对象中有constructor
属性和另外4个原型对象上的属性,其中constructor
属性指向构造函数本身。
通过new操作符创建的两个实例person1和person2,都具有一个__proto__
属性 (图中的[[Prototype]]
即__proto__
属性),指向的是Person的原型对象。
(2)实例的属性读取顺序
当我们通过对象的实例读取某个属性时,是有一个搜索过程的:
- 先在实例本身寻找指定属性,如果找到就直接返回该属性的值
- 如果实例本身没找到,那就继续沿着原型对象寻找,如果在原型对象中找到了该属性,就返回该属性的值
(3)重写原型对象
在之前的代码中,每次为原型对象添加一个属性或者函数时,都需要手动写上Person.prototype,这是一种冗余的写法。我们可以将所有需要绑定在原型对象上的属性写成一个对象字面量的形式,并赋值给prototype
属性,需要注意的是要增加一个constructor
属性指向该构造函数:
function Person() {}
Person.prototype = {
constructor: Person, // 重要
name: 'Nicholas',
age: 29,
job: 'Software Engineer',
sayName: function () {
console.log(this.name);
}
};
将一个对象字面量赋给prototype
属性的方式实际是重写了原型对象,等同于切断了构造函数和最初原型之间的关系。因此有一点需要注意的是,如果仍然想使用constructor
属性做后续处理,则应该在对象字面量中增加一个constructor
属性,指向构造函数本身,否则原型的constructor
属性会指向Object类型的构造函数,从而导致constructor
属性与构造函数的脱离。
function Person() {}
Person.prototype = {
name: 'Nicholas',
sayName: function () {
console.log(this.name);
}
};
Person.prototype.constructor === Object; // true
Person.prototype.constructor === Person; // false
由于重写原型对象会切断构造函数和最初原型之间的关系,因此会带来一个隐患,那就是如果在重写原型对象之前,已经生成了对象的实例,则该实例将无法访问到新的原型对象中的函数。只有在重写后生成的实例才能访问到。
因此如果想要重写原型对象,需要保证不要在重写完成之前生成对象的实例,否则会出现异常。
4.4.2 原型链
对象的每个实例都具有一个__proto__
属性,指向的是构造函数的原型对象,而原型对象同样存在一个__proto__
属性指向上一级构造函数的原型对象,就这样层层往上,直到最上层某个原型对象为null
。
在JavaScript中几乎所有的对象都具有__proto__
属性,由__proto__
属性连接而成的 链路构成了JavaScript的原型链,原型链的顶端是Object.prototype
,它的__proto__
属 性为null
。
这个图可以分3部分理解:
第一部分是自定义的Foo()函数,Foo()函数的prototype属性指向Foo.prototype对象,通过Foo()构造函数生成实例f1、f2,它们的__proto__
属性指向Foo.prototype对象。而Foo()函数本身可以作为Function对象的实例,因此Foo.__proto__指向Function.prototype。
第二部分与Object()构造函数有关,Object()构造函数本身也是Function类型,因此Object.__proto__指向Function.prototype,通过Object()构造函数生成的实例o1、o2,它们的__proto__
属性指向Object.prototype对象。
第三部分与Function()构造函数有关,Function.__proto__指向Function.prototype,而Function.prototype为一个对象,它的__proto__
属性指向Object.prototype对象。
这样所有的对象通过__proto__
属性向上寻找都是一定会追溯到Object.prototype
的。
关于原型链有一些需要注意的知识点:
(1)原型链的特点
- 由于原型链的存在,属性查找的过程不再是只查找自身的原型对象,而是会沿着整个原型链一直向上,直到追溯到Object.prototype。如果Object.prototype上也找不到该属性,则返回“undefined”。如果期间在实例本身或者某个原型对象上找到了该属性,则会直接返回结果,因此会存在属性覆盖的问题。
- 由于属性查找会经历整个原型链,因此查找的链路越长,对性能的影响越大。
(2)属性区分
可以使用hasOwnProperty()
函数区分属性是实例自身的还是从原型链中继承的,如果是true
说明是实例属性,false
代表是原型对象上的属性。
在使用for...in
运算符遍历对象的属性时,一般可以配合hasOwnProperty()
函数一起使用,检测是否是对象自身的属性,然后做后续处理。
for (var prop in person) {
if (person.hasOwnProperty(prop)) {
// do something
}
}
(3)内置构造函数
JavaScript中有一些特定的内置构造函数,如String()构造函数、Number()构造函 数、Array()构造函数、Object()构造函数等。
它们本身的__proto__
属性都统一指向Function.prototype
String._ _proto_ _ === Function.prototype; // true
Number._ _proto_ _ === Function.prototype; // true
Array._ _proto_ _ === Function.prototype; // true
Date._ _proto_ _ === Function.prototype; // true
Object._ _proto_ _ === Function.prototype; // true
Function._ _proto_ _ === Function.prototype; // true
(4)__proto__属性
以下面举例:
Function.prototype.a = 'a'; Object.prototype.b = 'b';
function Person() {}
var p = new Person();
console.log('p.a:', p.a); console.log('p.b:', p.b);
上面的代码需要输出实例p的a属性和b属性的值,所以我们需要先了解实例p的属性查找过程。属性的查找是根据__proto__
属性沿着原型链来完成的,因此我们需要先梳理出实例p的原型链:
// 实例p直接原型
p._ _proto_ _ = Person.prototype;
// Person原型对象的原型
Person.prototype._ _proto_ _ = Object.prototype;
因此实例输出p的属性时,最终会找到Object.prototype中去,所以结果是
p.a; // undefined
p.b; // b
4.5 继承
虽然JavaScript并不是一门面向对象的语言,不直接具备继承的特性,但是我们可以通过某些方式间接实现继承,从而能利用继承的优势,增强代码复用性与扩展性。在不影响父类对象实现的情况下,使得子类对象具有父类对象的特性;同时还能在不影响父类对象行为的情况下扩展子类对象独有的特性。
本节会介绍 5 种 JavaScript 实现继承的方式。
首先先提前定义一个父类 Animal 并增加属性、实例函数和原型函数:
// 定义一个父类
Animal function Animal(name) {
// 属性
this.type = 'Animal';
this.name = name || '动物';
// 实例函数
this.sleep = function () {
console.log(this.name + '正在睡觉!');
}
}
// 原型函数
Animal.prototype.eat = function (food) {
console.log(this.name + '正在吃:' + food);
};
4.5.1 原型链继承
原型链继承的主要思想:重写子类的prototype
属性,将其指向父类的实例。
// 父类
function Animal(age) {
// 属性
this.name = 'Animal';
this.age = age;
// 实例函数
this.sleep = function () {
return this.name + '正在睡觉!'; }
}
// 父类原型函数
Animal.prototype.eat = function (food) {
return this.name + '正在吃:' + food;
};
// 子类Cat
function Cat(name) {
this.name = name;
}
// 原型继承
Cat.prototype = new Animal();
// 很关键的一句,将Cat的构造函数指向自身
Cat.prototype.constructor = Cat;
var cat = new Cat('加菲猫');
console.log(cat.type); // Animal
console.log(cat.name); // 加菲猫
console.log(cat.sleep()); // 加菲猫正在睡觉!
console.log(cat.eat('猫粮')); // 加菲猫正在吃:猫粮
- 子类Cat中没有增加type属性,因此会直接继承父类Animal的type属性,输出字符串"Animal"
- 子类Cat中增加了name属性,因此生成实例时会覆盖父类的name属性,输出"加菲猫"而不是"动物"
- 因为Cat的
prototype
属性指向了Animal类型的实例,因此在生成实例cat时,会继承实例函数和原型函数,在调用sleep()函数和eat()函数时,this指向了实例cat,从而输出“加菲猫正在睡觉!”和“加菲猫正在吃:猫粮”
需要注意这一句:
Cat.prototype.constructor = Cat;
这是因为如果不将Cat原型对象的constructor属性指向自身的构造函数的话,那将会指向父类Animal的构造函数。
所以在设置了子类的prototype属性后,需要将其constructor属性指向自身构造函数。
(1)原型链继承的优点
- 简单,易于实现:只需要设置子类的prototype属性为父类的实例即可,实现起来简单
- 继承关系纯粹:生成的实例既是子类的实例,也是父类的实例
- 可通过子类直接访问父类原型链属性和函数
(2)原型链继承的缺点
- 子类的所有实例将共享父类的属性:如果有引用数据类型的值,那么改变子类某个实例的属性值,会影响到其他实例的属性值
- 在创建子类实例时,无法向父类的构造函数传递参数:在通过new操作符创建子类的实例时,会调用子类的构造函数,而在子类的构造函数中并没有设置与父类的关联,从而导致无法向父类的构造函数传递参数
- 无法实现多继承:由于子类Cat的prototype属性只能设置为一个值,如果同时设置为多个值的话,后面的值会覆盖前面的值,导致Cat只能继承一个父类,而无法实现多继承
- 为子类增加原型对象上的属性和函数时,必须放在new Animal()函数之后,因为原型函数重写的问题,之前的设置会消失
4.5.2 构造继承
构造继承的主要思想:在子类的构造函数中通过call()
函数改变this
的指向,调用父类的构造函数,从而能将父类的实例的属性和函数绑定到子类的this
上。
// 父类
function Animal(age) {
// 属性
this.name = 'Animal';
this.age = age;
// 实例函数
this.sleep = function () {
return this.name + '正在睡觉!'; }
}
// 父类原型函数
Animal.prototype.eat = function (food) {
return this.name + '正在吃:' + food;
};
// 子类
function Cat(name) {
// 核心,通过call()函数实现Animal的实例的属性和函数的继承
Animal.call(this);
this.name = name || 'tom';
}
// 生成子类的实例
var cat = new Cat('tony');
// 可以正常调用父类实例函数
console.log(cat.sleep()); // tony正在睡觉!
// 不能调用父类原型函数
console.log(cat.eat()); // TypeError: cat.eat is not a function
子类可以正常调用父类的实例函数,而无法调用父类原型对象上的函数。
(1)构造继承的优点
- 可解决子类实例共享父类属性的问:
call()
函数实际是改变了父类Animal构造函数中this
的指向,调用后this
指向了子类 Cat,相当于将父类的type、age和sleep等属性和函数直接绑定到了子类的this
中,成了子类实例的属性和函数,因此生成的子类实例中是各自拥有自己的type、age和sleep属性和函数,不会相互影响 - 创建子类的实例时,可以向父类传递参数:在call()函数中,我们可以传递参数,这个时候参数是传递给父类的,我们就可以对 父类的属性进行设置,同时由子类继承下来
function Cat(name, parentAge) {
// 在子类生成实例时,传递参数给call()函数,间接地传递给父类,然后被子类继承
Animal.call(this, parentAge);
this.name = name || 'tom';
}
// 生成子类实例
var cat = new Cat('tony', 11);
console.log(cat.age); // 11,因为子类继承了父类的age属性
- 可以实现多继承: 在子类的构造函数中,可以通过多次调用
call()
函数来继承多个父对象,每调用一次call()
函数就会将父类的实例的属性和函数绑定到子类的this中
(2)构造继承的缺点
- 实例只是子类的实例,并不是父类的实例: 因为我们并未通过原型对象将子类与父类进行串联,所以生成的实例与父类并没有关系,这样就失去了继承的意义
- 只能继承父类实例的属性和函数,并不能继承原型对象上的属性和函数
- 无法复用父类的实例函数:由于父类的实例函数将通过call()函数绑定到子类的this中,因此子类生成的每个实例都会拥有父类实例函数的引用,这会造成不必要的内存消耗,影响性能
4.5.3 复制继承
复制继承的主要思想:首先生成父类的实例,然后通过for...in
遍历父类实例的属性和函数,并将其依次设置为子类实例的属性和函数或者原型对象上的属性和函数。
// 父类
function Animal(parentAge) {
// 实例属性
this.name = 'Animal';
this.age = parentAge;
// 实例函数
this.sleep = function () {
return this.name + '正在睡觉!';
}
}
// 原型函数
Animal.prototype.eat = function (food) {
return this.name + '正在吃:' + food; };
// 子类
function Cat(name, age) {
var animal = new Animal(age);
// 父类的属性和函数,全部添加至子类中
for (var key in animal) {
// 实例属性和函数
if (animal.hasOwnProperty(key)) {
this[key] = animal[key];
} else {
// 原型对象上的属性和函数
Cat.prototype[key] = animal[key];
}
}
// 子类自身的属性
this.name = name;
}
// 子类自身原型函数
Cat.prototype.eat = function (food) {
return this.name + '正在吃:' + food;
};
var cat = new Cat('tony', 12); console.log(cat.age); // 12
console.log(cat.sleep()); // tony正在睡觉!
console.log(cat.eat('猫粮')); // tony正在吃:猫粮
在子类的构造函数中,对父类实例的所有属性进行for...in
遍历,如果animal.hasOwnProperty(key)返回 true
,则表示是实例的属性和函数,则直接绑定到子类的this
上,成为子类实例的属性和函数;如果animal.hasOwnProperty(key)返回false
,则表示是原型对象上的属性和函数,则将其添加至子类的prototype属性上,成为子类的原型对象上的属性和函数
(1)复制继承的优点
- 支持多继承:只需要在子类的构造函数中生成多个父类的实例,然后通过相同的
for...in
处理即 - 能同时继承实例的属性和函数与原型对象上的属性和函数
- 可以向父类构造函数中传递值
(2)复制继承的缺点
- 父类的所有属性都需要复制,消耗内存
- 实例只是子类的实例,并不是父类的实例:实际上我们只是通过遍历父类的属性和函数并将其复制至子类上,并没有通过原型对象串联起父类和子类,因此子类的实例不是父类的实例
4.5.4 组合继承
组合继承的主要思想:组合了构造继承和原型继承两种方法,一方面在子类的构造函数中通过call()
函数调用父类的构造函数,将父类的实例的属性和函数绑定到子类的this
中;另一方面,通过改变子类的prototype
属性,继承父类的原型对象上的属性和函数。
// 父类
function Animal(parentAge) {
// 实例属性
this.name = 'Animal';
this.age = parentAge;
// 实例函数
this.sleep = function () {
return this.name + '正在睡觉!';
};
this.feature = ['fat', 'thin', 'tall'];
}
// 原型函数
Animal.prototype.eat = function (food) {
return this.name + '正在吃:' + food;
};
// 子类
function Cat(name) {
// 通过构造函数继承实例的属性和函数
Animal.call(this);
this.name = name;
}
// 通过原型继承原型对象上的属性和函数
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;
var cat = new Cat('tony');
console.log(cat.name); // tony
console.log(cat.sleep()); // tony正在睡觉!
console.log(cat.eat('猫粮')); // tony正在吃:猫粮
(1)组合继承的优点
- 既能继承父类实例的属性和函数,又能继承原型对象上的属性和函数
- 既是子类的实例,又是父类的实例
- 不存在引用属性共享的问题:因为在子类的构造函数中已经将父类的实例属性指向了子类的this,所以即使后面将父类的实例属性绑定到子类的prototype属性中,也会因为构造函数作用域优先级比原型链优先级高,所以不会出现引用属性共享的问题
- 可以向父类的构造函数中传递参数:通过call()函数可以向父类的构造函数中传递参数
(2)组合继承的缺点
组合继承的缺点为:父类的实例属性会绑定两次。
在子类的构造函数中,通过call()
函数调用了一次父类的构造函数;在改写子类的 prototype
属性、生成父类的实例时调用了一次父类的构造函数。
通过两次调用,父类实例的属性和函数会进行两次绑定,一次会绑定到子类的构造函数的this中,即实例属性和函数,另一次会绑定到子类的prototype属性中,即原型对象上的属性和函数,但是实例属性优先级会比原型对象上的属性优先级高,因此实例属性会覆盖原型对象上的属性。
4.5.5 寄生组合继承
其实组合继承方案已经足够好,但是仍然可以对于存在的缺点进行优化:在进行子类的prototype
属性的设置时,可以去掉父类实例的属性和函数。
// 子类
function Cat(name) {
// 继承父类的实例属性和函数
Animal.call(this);
this.name = name;
}
// 立即执行函数
(function () {
// 设置任意函数Super()
var Super = function () {};
// 关键语句,Super()函数的原型指向父类Animal的原型,去掉父类的实例属性
Super.prototype = Animal.prototype;
Cat.prototype = new Super();
Cat.prototype.constructor = Cat;
})();
最关键的一句在于
Super.prototype = Animal.prototype;
只取父类Animal的prototype属性,过滤掉Animal的实例属性,从而避免了父类的实例属性绑定两次。
一般使用组合继承就已经足够,但是寄生组合继承是实现继承最完美的一种。
4.6 instanceof运算符
在之前判断一个变量的类型时,使用了typeof
运算符。但是存在一个问题,对于任何引用数据类型的值都会返回“object”,从而无法判断对象的具体类型。
因此引入了instanceof
运算符:
target instanceof constructor
上面的代码表示的是构造函数constructor()的prototype属性是否出现在target对象的原型链中,说得通俗一点就是,target对象是不是构造函数constructor()的实例。
4.6.1 instanceof运算符的常规用法
例子1:原生数据类型的包装类型
var stringObject = new String('hello world');
stringObject instanceof String; // true
例子2:Function类型
function Foo() {}
var foo = new Foo();
foo instanceof Foo; //true
4.6.2 instanceof运算符用于继承判断
instanceof
很重要的一点就是可以在继承关系中,判断一个实例对象是否属于它的父类。
// 定义构造函数
function C(){}
function D(){}
var o = new C();
o instanceof C; //true
o instanceof D; //false
o instanceof Object; //true,因为Object.prototype属性在o的原型链上
D.prototype = new C(); // 通过将D()构造函数的prototype属性指向C()构造函数的一个实例可以产生继承关系
var o2 = new D();
o2 instanceof D; // true
o2 instanceof C; // true,因为通过继承关系,C.prototype出现在o2的原型链上
4.6.3 instanceof运算符的复杂用法
从上面看起来instanceof
运算符比较简单,但其实会有很多复杂情况:
Object instanceof Object; //true
Function instanceof Function; //true
Number instanceof Number; //false
String instanceof String; //false
Function instanceof Object; //true
Foo instanceof Function; //true
Foo instanceof Foo; //false
会发现 Object() 构造函数和 Function() 构造函数在使用instanceof
运算符处理自身的时候会返回true
;而 Number() 构造函数和 String() 构造函数在使用instanceof
运算符处理自身的时候返回false
。
以下是一段对instanceof运算符实现原理比较经典的JavaScript代码解释:
/**
* instanceof 运算符实现原理 * @param L 表示左表达式
* @param R 表示右表达式
* @returns {boolean}
*/
function instance_of(L, R) {
var O = R.prototype; // 取 R 的显示原型
L = L._ _proto_ _; // 取 L 的隐式原型
while (true) {
if (L === null)
return false;
if (O === L) // 这里是重点:当 O 严格等于 L 时,返回“true”
return true;
L = L._ _proto_ _; // 如果不相等则重新取L的隐式原型
}
}
对这段代码的理解如下:
- 获取右表达式R的prototype属性为O,左表达式的__proto__隐式原型为L
- 首先判断左表达式__proto__隐式原型L是否为空,如果为空,则直接返false。实际上只有Object.prototype.__proto__属性为null,即到了原型链的最顶层。
- 然后判断O与L是否严格相等,需要注意的是只有在严格相等的时候,才返回 true
- 如果不相等,则递归L的__proto__属性,直到L为null或者O===L,得到最终结果
4.6.4 instanceof运算符的复杂用法的详细处理过程
(1)Object instanceof Object
// 将左、右侧值进行赋值
ObjectL = Object, ObjectR = Object;
// 根据原理获取对应值
L = ObjectL._ _proto_ _ = Function.prototype;
R = ObjectR.prototype;
// 执行第一次判断
L != R;
// 继续寻找L._ _pro_ _
L = L._ _proto_ _ = Function.prototype._ _proto_ _ = Object.prototype; // 执行第二次判断
L === R; // true
(2)Function instanceof Function
// 将左、右侧值进行赋值
FunctionL = Function, FunctionR = Function;
// 根据原理获取对应值
L = FunctionL._ _proto_ _ = Function.prototype;
R = FunctionR.prototype = Function.prototype; // 执行第一次判断成功,返回“true”
L === R; // true
(3)Foo instanceof Foo
// 将左、右侧值进行赋值
FooL = Foo, FooR = Foo;
// 根据原理获取对应值
L = FooL._ _proto_ _ = Function.prototype;
R = FooR.prototype = Foo.prototype;
// 第一次判断失败,返回“false”
L !== R;
// 继续寻找L._ _proto_ _
L = L._ _proto_ _ = Function.prototype._ _proto_ _ = Object.prototype; // 第二次判断失败,返回“false”
L !== R;
// 继续寻找L._ _proto_ _
L = L._ _proto_ _ = Object.prototype._ _proto_ _ = null;
// L为null,返回“false”
L === null;
因此Foo instanceof Foo返回false
(4)String instanceof String
// 将左、右侧值进行赋值
StringL = String, StringR = String;
// 根据原理获取对应值
L = StringL._ _proto_ _ = Function.prototype;
R = StringR.prototype = String.prototype;
// 第一次判断失败,返回“false”
L !== R;
// 继续寻找L._ _proto_ _
L = L._ _proto_ _ = Function.prototype._ _proto_ _ = Object.prototype; // 第二次判断失败,返回“false”
L !== R;
// 继续寻找L._ _proto_ _
L = L._ _proto_ _ = Object.prototype._ _proto_ _ = null; // L为null,返回“false”
L === null;
因此String instanceof String返回false