JavaScript是一门面向对象的设计语言,在JS里除了null
和undefined
,其他一切皆为对象。其中Array/Function/Date/RegExp
是Object对象的特殊实例实现,Boolean/Number/String
也都有对应的基本包装类型的对象(具备内置的方法)。传统语言是依靠class
类来完成面向对象的继承和多态等特性,而JS使用原型链和构造器来实现继承,依靠参数arguments.length
来实现多态。而且在ES6里也引入了class
关键字来实现类。
接下来咱们来聊一下JS的原型链、继承和类。git
有时咱们会好奇为何能给一个函数添加属性,函数难道不该该就是一个执行过程的做用域吗?es6
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; } */
咱们可以给函数赋一个属性值,当咱们输出这个函数时这个属性却无影无踪了,这究竟是怎么回事,这个属性又保存在哪里了呢?github
其实,在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); };
这些原型对象为实例赋予了默认值,如今咱们能够看到它们的关系是:优化
要注意属性和原型属性不是同一个东西,也并不保存在同一个空间里:this
Person.age; // 10 Person.prototype.age; // 24
如今有了构造函数和原型对象,那咱们接下来new
一个实例出来,这样才能真正体现面向对象编程的思想,也就是继承
:spa
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__
的内容,我相信你能够彻底看懂这张图的内容。须要注意两点:
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()
来判断原型,是咱们最理想的继承方式。
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() } }