在基于类的语言中,对象是类的实例,并且类可以从另一个类继承。JavaScript是一门基于原型的语言,这意味着对象直接从其他对象继承。
JavaScript提供了一套极为丰富的代码重用模式,能够实现继承的方法有很多。在了解这些方法之前,首先要对"对象"这个概念具备一定认知
对象
在JS里,万物皆对象。而__proto__是每个对象都有的属性。
__proto__属性都是由一个对象指向一个对象,它的作用是当访问一个对象的属性不存在时,就会去它的__proto__属性所指向的那个对象里找,如果仍然不存在这个属性,则继续寻找,直到顶端为null时结束,这样形成的一条链即所谓的原型链。
let a={name:'haha'};
let b={};
b.__proto__=a;
console.log(b.name);//haha
//b虽然没有name属性,但通过原型链找到了a的name属性
在js中,有多种生成对象的方式。不同方式生成的对象其proto指向也不同,下面给出三种最常用的生成对象的方式以及各自的proto指向
var a={};
var result = a.__proto__===Object.prototype;
console.log(result);//true
//通过字面量形式创建的对象,其proto默认指向Object()函数的prototype
function A(){};
var a=new A();
var result=a.__proto__===A.prototype;
console.log(result);//true
//通过构造函数创建的对象,其proto指向该构造函数的prototype
var a1={}
var a2=Object.create(a1);
var result = a2.__proto__===a1;
console.log(result);//true
//对象的proto其实可以自由指向其他对象
实例和原型
在对js中的’对象’具备一定的认知之后,接下来需要了解的就是实例和原型的概念了,不论ES5还是ES6,都有原型属性和实例属性的概念,但是ES5的表述更加直观,因此我们首先以ES5为例,简要介绍什么是原型属性,什么是实例属性
//构造方法中定义的变量或方法,均属于实例属性
function A() {
this.x = 10;
this.printX = function () {
console.log(this.x);
}
}
//prototype定义的变量或方法,均属于原型属性,原型属性存储在prototype当中
A.prototype.y = -10;
A.prototype.printY = function () {
console.log(this.y);
}
let a = new A();
//对于实例属性,每一个创建的实例对象都独立开辟一块内存空间用于保存实例属性的变量和方法
console.log(Object.getOwnPropertyNames(a));//[ 'x', 'printX' ]
//对于原型属性,每一个创建的实例对象都共享原型属性的变量和方法
console.log(Object.getOwnPropertyNames(A.prototype));//[ 'constructor', 'y', 'printY' ]
a.printX();//10
a.printY();//-10
//由于原型属性的共享特性,当修改原型属性时,所有实例都会受到影响
let aa = new A();
aa.__proto__.y = -20;
a.printY();//-20
ES6加入了类的概念,反而模糊了实例和原型的界限,但这种区别依旧存在
class A{
//构造函数里设置的属性是实例属性,这一点和ES5中的构造方法类似
constructor() {
this.aaa = 'aaa';
}
//普通的方法被视为原型属性,会被存储在类A的prototype中
foo() {
console.log('foo');
}
//箭头函数事实上是属于实例属性,通过类A生成的实例都会包含该箭头函数
bar = () => {
}
}
class B extends A{
constructor() {
super();
}
}
class C extends B{
constructor() {
super();
}
fun() {
super.foo();
}
}
let a = new A();
let b = new B();
let c = new C();
//class A 中自定义的原型属性仅包含 foo
console.log(Object.getOwnPropertyNames(A.prototype));//[ 'constructor', 'foo' ]
//由class A 产生的实例,既包含了构造函数中定义的属性 aaa,又包含了箭头函数 bar
console.log(Object.getOwnPropertyNames(a));//[ 'bar', 'aaa' ]
//class B虽然继承了A,但需要注意的是,A中的原型属性并未给到B,若B的实例需要调用 foo 方法时,需要通过原型链去逐个查找
console.log(Object.getOwnPropertyNames(B.prototype));//[ 'constructor' ]
//显然,通过继承,B获取了A的实例属性,通过其实例b包含bar和aaa便可证明这一点
console.log(Object.getOwnPropertyNames(b));//[ 'bar', 'aaa' ]
//继承并不影响我们在子类当中新添加原型属性
console.log(Object.getOwnPropertyNames(C.prototype));//[ 'constructor', 'fun' ]
//fun属于class C的原型属性,因此class C的实例中不会包含fun
console.log(Object.getOwnPropertyNames(c));//[ 'bar', 'aaa' ]
//实例c中并未包含foo方法,但依然可以实现调用,就是通过原型链调用了class A中的foo方法
c.foo();//foo
//除此之外,实例c通过调用fun方法,同样可以达到相同的目的,这是因为fun中使用了super关键字,背后的原理同样是通过原型链逐层查找,最终在class A的原型中找到了foo方法
c.fun();//foo
//若我们将foo方法定义为箭头函数,c.fun()将会报错,这是因为箭头函数会被视为实例属性,而原型链搜索的路径为原型属性,这也就解释了为什么super调用的父类函数不能是箭头函数
箭头函数
这里对箭头函数做个简单介绍,重点对比箭头函数和绑定this的区别
class A{
constructor() {
this.x = 100;
this.bar = this.bar.bind(this);//通过绑定this,bar方法也留在了实例当中
}
foo() {
}
bar() {
}
temp = () => {
}
}
let a = new A();
console.log(Object.getOwnPropertyNames(A.prototype));//[ 'constructor', 'foo', 'bar' ]
console.log(Object.getOwnPropertyNames(a));//[ 'temp', 'x', 'bar' ]
在上面的例子中,class A里面一共定义了3个方法,其中foo和bar被放在了原型属性中,而箭头函数temp被视为实例属性。这里我们对bar方法进行了绑定this的操作,通过日志可以发现,bar方法和temp一样,也存在于实例a当中,换句话说,绑定this的操作使得bar方法能够同时存在于原型属性和实例属性当中
下面举一个绑定this的具体使用环境
class A {
constructor() {
this.x = 100;
// this.printX = this.printX.bind(this);
}
foo() {
let temp = {
x: 6,
print: this.printX
}
temp.print();
}
bar() {
this.printX();
}
printX() {
console.log(this.x);
}
}
let a = new A();
a.printX();// 100
a.bar();// 100
a.foo();// 6
通过上面的例子可以看出,当foo方法没有进行绑定this时,其this的指向会和我们所预期的不一致。这里使用绑定this或箭头函数都可以解决这个问题。
继承的实现
其实上面的内容已经对继承有了部分介绍,这里重点比较ES5和ES6在继承使用上面的差别
- ES5和ES6的差别
ES6中采用了大家更容易理解的class来实现继承,但实际上class只是构造函数的语法糖而已,class本身也是一个函数
class Person{};
let result = typeof Person ==='function';
console.log(result);//true
ES5中继承的实现(方式之一:原型链继承)
function Father(){
this.name='father';
}
function Child(){
this.id='001';
}
Child.prototype=new Father();
let child=new Child();
console.log(child.name);//father
console.log(child.id);//001
如果把构造函数比做类的话,Child类通过将prototype属性设置为Father类的实例来实现继承。
ES6中继承的实现
class Father{
constructor(){
this.name='father';
}
}
class Child extends Father{
constructor(){
super();
this.id='001';
}
}
let child=new Child();
console.log(child.name);//father
console.log(child.id);//001
ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。
- ES5中继承的若干实现方式
原型链继承(同上)
function Father(name){
this.name=name;
this.printName=function(){
console.log(this.name);
}
}
Father.prototype.age=40;
function Child(){
this.name='child';
}
Child.prototype=new Father('father');//建立了原型链
let result = Child.prototype.__proto__===Father.prototype;
console.log(result);//true
let child = new Child();
console.log(child);//{name: "child"}
console.log(child.age);//40
console.log(child instanceof Father);//true
console.log(child instanceof Child);//true
//instanceof 运算符用来检测构造函数的prototype是否存在于实例的原型链上。
console.log(child.__proto__);//{ name: 'father', printName: [Function] }
子类的prototype由于传入了父类的实例,使得子类实例继承了父类的全部属性(构造函数属性和prototype中的属性),如果子类实例child想要添加新的属性或者重写原有属性,则需要在子类构造函数Child中定义。
缺点:1、新实例无法向父类构造函数传参。
2、继承单一,没有实现多继承。
3、原型上的属性是共享的,一个实例修改了原型属性,另一个实例的原型属性也会被修改
let child2 = new Child();
child.__proto__.age=10;
console.log(child2.age);//10
构造函数继承
function Father(name){
this.name=name;
this.printName=function(){
console.log(this.name);
}
}
Father.prototype.age=40;
function Child(){
Father.call(this);
this.name='child';
}
let child = new Child();
console.log(child);//Child { name: 'child', printName: [Function] }
console.log(child.age);//undefined
console.log(child instanceof Father);//false
console.log(child instanceof Child);//true
将父类构造函数中的属性直接添加给子类的构造函数,实现了多继承,当生成子类实例时,该实例包含了所有父类构造函数中定义的全部属性
缺点:1、只能继承父类构造函数的属性,父类的原型属性无法获取(因为从子类prototype到父类prototype的原型链不存在。
2、无法实现父类构造函数的复用,每次创建子类实例都要重新调用父类的构造函数。这导致每个新实例都有父类构造函数的属性副本,比较臃肿。
组合继承
function Father(name){
this.name=name;
this.printName=function(){
console.log(this.name);
}
}
Father.prototype.age=40;
function Child(name){
Father.call(this,name);//第一次调用父类构造函数
}
//和上面的例子相比,仅仅添加了下面这行代码,利用原型链建立了子类和父类的链接
Child.prototype=new Father('father');//第二次调用父类构造函数
let child = new Child('child');
console.log(child);//{ name: 'child', printName: [Function] }
console.log(child.age);//40
console.log(child instanceof Father);//true
console.log(child instanceof Child);//true
组合继承结合了前面两种模式的特点,但是调用了两次父类的构造函数,造成了内存上的损耗
寄生组合继承
function Father(name){
this.name=name;
this.printName=function(){
console.log(this.name);
}
}
Father.prototype.age=40;
Father.prototype.printAge=function(){
console.log(this.age);
}
//获取父类原型属性
function content(obj){
function Foo(){};
Foo.prototype=obj;
return new Foo();
}
let father=content(Father.prototype)
//获取父类构造函数属性
function Child(){
Father.call(this,'child');
}
Child.prototype=father;
Child.prototype.constructor=Child;//修复构造函数
let child=new Child();
console.log(child);//{ name: 'child', printName: [Function] }
console.log(child.age);//40
console.log(child instanceof Father);//true
console.log(child instanceof Child);//true
通过寄生方式,砍掉父类的实例属性,这样,在调用两次父类的构造的时候,就不会初始化两次实例方法和属性,避免了组合继承的缺点