JavaScript是一门面向对象的设计语言,在JS里除了null
和undefined
,其余一切皆为对象。其中Array/Function/Date/RegExp
是Object对象的特殊实例实现,Boolean/Number/String
也都有对应的基本包装类型的对象(具有内置的方法)。传统语言是依靠class
类来完成面向对象的继承和多态等特性,而JS使用原型链和构造器来实现继承,依靠参数arguments.length
来实现多态。并且在ES6里也引入了class
关键字来实现类。
接下来我们来聊一下JS的原型链、继承和类。
函数与对象的关系
有时我们会好奇为什么能给一个函数添加属性,函数难道不应该就是一个执行过程的作用域吗?
var name = 'Leon';
function Person(name) {
this.name = name;
this.sayName = function() {
alert(this.name);
}
}
Person.age = 10;
console.log(Person.age); // 10
console.log(Person);
/* 输出函数体:
ƒ Person(name) {
this.name = name;
}
*/
我们能够给函数赋一个属性值,当我们输出这个函数时这个属性却无影无踪了,这到底是怎么回事,这个属性又保存在哪里了呢?
其实,在JS里,函数就是一个对象,这些属性自然就跟对象的属性一样被保存起来,函数名称指向这个对象的存储空间。
函数调用过程没查到资料,个人理解为:这个对象内部拥有一个内部属性[[function]]
保存有该函数体的字符串形式,当使用()
来调用的时候,就会实时对其进行动态解析和执行,如同eval()
一样。
上图是JS的具体内存分配方式,JS中分为值类型和引用类型,值类型的数据大小固定,我们将其分配在栈里,直接保存其数据。而引用类型是对象,会动态的增删属性,大小不固定,我们把它分配到内存堆里,并用一个指针指向这片地址,也就是Person其实保存的是一个指向这片地址的指针。这里的Person对象是个函数实例,所以拥有特殊的内部对象[[function]]
用于调用。同时它也拥有内部属性arguments/this/name
,因为不相关,这里我们没有绘出,而展示了我们为其添加的属性age。
函数与原型的关系
同时在JS里,我们创建的每一个函数都有一个prototype
(原型)属性,这个属性是一个指针,指向一个用于包含该对象所有实例的共享属性和方法的对象。而这个对象同时包含一个指针指向这个这个函数,这个指针就是constructor
,这个函数也被成为构造函数。这样我们就完成了构造函数和原型对象的双向引用。
而上面的代码实质也就是当我们创建了Person构造函数之后,同步开辟了一片空间创建了一个对象作为Person的原型对象,可以通过Person.prototype
来访问这个对象,也可以通过Person.prototype.constructor
来访问Person该构造函数。通过构造函数我们可以往实例对象里添加属性,如上面的例子里的name
属性和sayName()
方法。我们也可以通过prototype
来添加原型属性,如:
Person.prototype.name = 'Nicholas';
Person.prototype.age = 24;
Person.prototype.sayAge = function () {
alert(this.age);
};
这些原型对象为实例赋予了默认值,现在我们可以看到它们的关系是:
要注意属性和原型属性不是同一个东西,也并不保存在同一个空间里:
Person.age; // 10
Person.prototype.age; // 24
原型和实例的关系
现在有了构造函数和原型对象,那我们接下来new
一个实例出来,这样才能真正体现面向对象编程的思想,也就是继承
:
var person1 = new Person('Lee');
var person2 = new Person('Lucy');
我们新建了两个实例person1和person2,这些实例的内部都会包含一个指向其构造函数的原型对象的指针(内部属性),这个指针叫[[Prototype]]
,在ES5的标准上没有规定访问这个属性,但是大部分浏览器实现了__proto__
的属性来访问它,成为了实际的通用属性,于是在ES6的附录里写进了该属性。__proto__
前后的双下划线说明其本质上是一个内部属性,而不是对外访问的API,因此官方建议新的代码应当避免使用该属性,转而使用Object.setPrototypeOf()
(写操作)、Object.getPrototypeOf()
(读操作)、Object.create()
(生成操作)代替。
这里的prototype我们称为显示原型
,__proto__我们称为隐式原型
。
同时由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在各个浏览器和 JavaScript 引擎上都是一个很慢的操作。其在更改继承的性能上的影响是微妙而又广泛的,这不仅仅限于 obj.__proto__ = ...
语句上的时间花费,而且可能会延伸到任何代码,那些可以访问任何[[Prototype]]已被更改的对象的代码。如果你关心性能,你应该避免设置一个对象的 [[Prototype]]。相反,你应该使用 Object.create()
来创建带有你想要的[[Prototype]]的新对象。
此时它们的关系是(为了清晰,忽略函数属性的指向,用(function)
代指):
在这里我们可以看到两个实例指向了同一个原型对象,而在new的过程中调用了Person()方法,对每个实例分别初始化了name属性和sayName方法,属性值分别被保存,而方法作为引用对象也指向了不同的内存空间。
我们可以用几种方法来验证实例的原型指针到底指向的是不是构造函数的原型对象:
person1.__proto__ === Person.prototype // true
Person.prototype.isPrototypeOf(person1); // true
Object.getPrototypeOf(person2) === Person.prototype; // true
person1 instanceof Person; // true
原型链
现在我们访问实例person1的属性和方法了:
person1.name; // Lee
person1.age; // 24
person1.toString(); // [object Object]
想下这个问题,我们的name值来自于person1的属性,那么age值来自于哪?toString( )方法又在哪定义的呢?
这就是我们要说的原型链,原型链是实现继承的主要方法,其思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。如果我们让一个原型对象等于另一个类型的实例,那么该原型对象就会包含一个指向另一个原型的指针,而如果另一个原型对象又是另一个原型的实例,那么上述关系依然成立,层层递进,就构成了实例与原型的链条,这就是原型链的概念
。
上面代码的name来自于自身属性,age来自于原型属性,toString( )方法来自于Person原型对象的原型Object。当我们访问一个实例属性的时候,如果没有找到,我们就会继续搜索实例的原型,如果还没有找到,就递归搜索原型链直到原型链末端。我们可以来验证一下原型链的关系:
Person.prototype.__proto__ === Object.prototype // true
同时让我们更加深入的验证一些东西:
Person.__proto__ === Function.prototype // true
Function.prototype.__proto__ === Object.prototype // true
我们会发现Person是Function对象的实例,Function是Object对象的实例,Person原型是Object对象的实例。这证明了我们开篇的观点:JavaScript是一门面向对象的设计语言,在JS里除了null和undefined,其余一切皆为对象
。
下面祭出我们的原型链图:
根据我们上面讲述的关于prototype/constructor/__proto__
的内容,我相信你可以完全看懂这张图的内容。需要注意两点:
- 构造函数和对象原型一一对应,他们与实例一起作为三要素构成了三面这幅图。最左侧是实例,中间是构造函数,最右侧是对象原型。
- 最最右侧的null告诉我们:
Object.prototype.__proto__ = null
,也就是Object.prototype是JS中一切对象的根源。其余的对象继承于它,并拥有自己的方法和属性。
继承
原型链继承
通过原型链我们已经实现了对象的继承,我们具体的实现下:
function Super(name) {
this.name = name;
this.colors = ['red', 'blue'];
};
function Sub(age) {
this.age = age;
}
Sub.prototype = new Super('Lee');
var instance = new Sub(20);
instance.name; // Lee
instance.age; // 20
我们通过让Sub类的原型指向Super类的实例,实现了继承,可以在instance上访问name和colors属性。但是,其最大的问题来自于共享数据,如果实例1修改了colors属性,那么实例2的colors属性也会变化。另外,此时我们在子类上并不能传递父类的参数,限制性很大。
构造函数继承
为了解决对象引用的问题,我们调用构造函数来实现继承,保证每个实例拥有相同的父类属性,但值之间互不影响。实质
function Super(name) {
this.name = name;
this.colors = ['red', 'blue'];
this.sayName = function() {
return this.name;
}
}
function Sub() {
Super.call(this, 'Nicholas');
}
var instance1 = new Sub();
var instance2 = new Sub();
instance1.colors.push('black');
instance1.colors; // ['red', 'blue', 'black']
instance2.colors; // ['red', 'blue']
此时我们通过改变父类构造函数的作用域就解决了引用对象的问题,同时我们也可以向父类传递参数了。但是,只用构造函数就很难在定义方法时复用,现在我们创建所有实例时都要声明一个sayName()的方法,而且此时,子类中看不到父类的方法。
组合继承
为了复用方法,我们使用组合继承的方式,即利用构造函数继承属性,利用原型链继承方法,融合它们的优点,避免缺陷,成为JS中最常用的继承。
function Super(name) {
this.name = name;
this.colors = ['red', 'blue'];
};
function Sub(name, age) {
// 第二次调用
Super.call(this, name);
this.age = age;
}
Super.prototype.sayName = function () {
return this.name;
};
// 第一次调用
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
Sub.prototype.sayAge = function () {
return this.age;
}
var instance = new Sub('lee', 40);
instance.sayName(); // lee
instance.sayAge(); // 40
这时我们全局只有一个函数,不用再给每一个实例新建一个,并且每个实例拥有相同的属性,达到了我们想要的继承。此时instanceof和isPrototypeOf()也能够识别继承创建的对象。
但是依然有一个不理想的地方是,我们会调用两次父类的构造函数,第一次在Sub的原型上设置了name和colors属性,此时name的值是undefined;第二次调用在Sub的实例上新建了name和colors属性,而这个实例属性会屏蔽原型的同名属性。所以这种继承会出现两组属性,这并不是理想的方式,我们试图来解决这个问题。
原型式继承
我们先来看一个后面会用到的继承,它根据已有的对象创建一个新对象。
function create(obj) {
function F(){};
F.prototype = obj;
return new F();
}
var person = {
name: 'Nicholas',
friends: ['Lee', 'Luvy']
};
var anotherPerson = create(person);
anotherPerson.name; // Nicholas
anotherPerson.friends.push('Rob');
person.friends; // ['Lee', 'Luvy', 'Rob']
也就是说我们根据一个对象作为原型,直接生成了一个新的对象,其中的引用对象依然共用,但你同时也可以给其赋予新的属性。
ES5规范化了这个原型继承,新增了Object.create()
方法,接收两个参数,第一个为原型对象,第二个为要混合进新对象的属性,格式与Object.defineProperties()
相同。
Object.create(null, {name: {value: 'Greg', enumerable: true}});
寄生组合式继承
function Super(name) {
this.name = name;
this.colors = ['red', 'blue'];
};
function Sub(name, age) {
Super.call(this, name);
this.age = age;
}
Super.prototype.sayName = function () {
return this.name;
};
// 我们封装其继承过程
function inheritPrototype(Sub, Super) {
// 以该对象为原型创建一个新对象
var prototype = Object.create(Super.prototype);
prototype.constructor = Sub;
Sub.prototype = prototype;
}
inheritPrototype(Sub, Super);
Sub.prototype.sayAge = function () {
return this.age;
}
var instance = new Sub('lee', 40);
instance.sayName(); // lee
instance.sayAge(); // 40
这种方式只调用了一次父类构造函数,只在子类上创建一次对象,同时保持原型链,还可以使用instanceof和isPrototypeOf()
来判断原型,是我们最理想的继承方式。
Class类
ES6引进了class
关键字,用于创建类,这里的类是作为ES5构造函数和原型对象的语法糖
存在的,其功能大部分都可以被ES5实现,不过在语言层面上ES6也提供了部分支持。新的写法不过让对象原型看起来更加清晰,更像面向对象的语法而已。
我们先看一个具体的class写法:
//定义类
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
var point = new Point(10, 10);
我们看到其中的constructor方法就是之前的构造函数,this就是之前的原型对象,toString()就是定义在原型上的方法,只能使用new关键字来新建实例。语法差别在于我们不需要function关键字和逗号分割符。其中,所有的方法都直接定义在原型上,注意所有的方法都不可枚举。类的内部使用严格模式,并且不存在变量提升,其中的this指向类的实例。
new是从构造函数生成实例的命令。ES6 为new命令引入了一个new.target属性,该属性一般用在构造函数之中,返回new命令作用于的那个构造函数。如果构造函数不是通过new命令调用的,new.target会返回undefined,因此这个属性可以用来确定构造函数是怎么调用的。
类存在静态方法,使用static
关键字表示,其只能类和继承的子类来进行调用,不能被实例调用,也就是不能被实例继承,所以我们称它为静态方法。类不存在内部方法和内部属性。
class Foo {
static classMethod() {
return 'hello';
}
}
Foo.classMethod() // 'hello'
var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function
类通过extends
关键字来实现继承,在继承的子类的构造函数里我们使用super
关键字来表示对父类构造函数的引用;在静态方法里,super指向父类;在其它函数体内,super表示对父类原型属性的引用。其中super必须在子类的构造函数体内调用一次,因为我们需要调用时来绑定子类的元素对象,否则会报错。
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y); // 调用父类的constructor(x, y)
this.color = color;
}
toString() {
return this.color + ' ' + super.toString(); // 调用父类的toString()
}
}
参考资料
- 阮一峰 ES6 - class: http://es6.ruanyifeng.com/#do...
- MDN文档 - Object.create(): https://developer.mozilla.org...
- 深入理解原型对象和继承: https://github.com/norfish/bl...
- 知乎 prototype和__proto__的区别: https://www.zhihu.com/questio...
- Javascript高级程序设计: 第四章(变量、作用域和内存问题)、第五章(引用类型)、第六章(面向对象的程序设计)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。