理解JS原型链到实现继承
JS是通过原型链的方式能够像一些基于类的语言(如Java、C++)一样实现了类的功能,并且也可以通过原型链进行继承。但是,由于原型链的灵活,在ES5的语法中有许多方式能够实现继承,可能你有过和我一样的迷惑,到底哪一种继承才是最好的,或者哪一种继承能够有更高的效率。因此,有必要先研究一下原型链到底是什么,从而总结出最好的继承方案。
原型链是啥子
专业的话来说就是:
每个实例对象( object )都有一个私有属性(称之为
__proto__
)指向它的构造函数的原型对象(prototype)。该原型对象也有一个自己的原型对象(__proto__
) ,层层向上直到一个对象的原型对象为null
。根据定义,null
没有原型,并作为这个原型链中的最后一个环节。
也许你和我一样,没得看懂,那再看一遍。
还没看懂,好,行,那先在浏览器里面试一下子。打开控制台,复制下面常用的继承方法的代码,按回车。
function A() {
this.a = 1;
}
function B() {
A.call(this);
this.a = 2;
this.b = 3;
}
B.prototype = new A();
B.prototype.constructor = B;
var b = new B();
console.log(b);
展开输出的对象,排除一些不常用的方法,我们能看到像以下的形式。
{
B: {
a: 2,
b: 3,
__proto__: {
// A
a: 1,
__proto__: {
// Object
}
}
}
}
如果你学过数据结构这个时候你可能会想到这不就跟链表很像嘛,一个链表拥有自己的值以外,还有一个指针指向下一个链表节点,这样一直指下去,直到指向空指针。从输出的内容来看,可以看到B
的__proto__
指向A
,然后A
的__proto__
指向Object
,最后Object
没有__proto__
,可以看作指向null
。这就可以看作类中B
继承于A
,A
继承于Object
,Object
继承于null
。
如果你已经忘记了链表是什么,或者没有学过数据结构,那我用一个简单的图来说明一下,看完图应该就能够理解什么是原型链了。
当明白原型链之后另一个问题就是它是如何运作的。如果你足够细心,你会发现我在上面的代码中B
类里将a
设置成了2
,接着在控制台中输入以下代码。
b.a
// 结果:2
b.toString() // 调用Object的方法,这个虽然是方法,但是不影响理解
// 结果: "[object Object]"
b.c // 访问一个不存在的值
// 结果:undefined
// 查找方法 {a: 2, b: 3} ------> {a: 1} ------> {toString: function()} ------> null
首先,在展开的b对象中的a
为2
,而b
指向A
的__proto__
中的a
为1
,然而a
输出的结果为2
而不是1
,可以看到JS在原型链中有两个相同的a
时忽略了A
中的a
。再看第二个代码,调用了Object
类中的toString()
方法,也能够调用,这说明如果B
中没有这个方法就会去B
的__proto__
中找,如果还是没找到,则会继续通过__proto__
中的__proto__
去找,一直往下找下去。第三行代码访问了一个不存在的属性,最终返回了undefined
,这就说明将原型链找完了没找到则会返回未定义,这样就说明了原型链的工作原理。简言之,当访问属性时,从原型链头向尾找,找到了第一个就不找了,找不到就拉倒了。
如何继承
如果使用ES6语法,那可以很容易的实现继承,如下所示。不过这种方法只是提供了class
语法糖,JS的类还是通过原型链的方式实现的。
class B extends A {
constructor() {
super();
this.a = 2;
this.b = 3;
}
// 一些方法
}
然而,ES5实现类的继承便有非常多的方式。首先理解prototype
属性,这个属性相当于在类通过new
新建实例后,将prototype
复制到这个实例的__proto__
中。那么在继承上,只需要将父类的prototype
的内容复制到子类的prototype
中,这样在新建对象时能够形成一个原型链。
通过这样的原理,可以使用上述的继承方案,使用B.prototype = new A()
的方法,将A
中的原型链完整的复制到B
的prototype
中,最终能够实现继承。然而,这种方法有一个缺点,即在调用B.prototype = new A()
时,相当于执行了一次A
类的构造方案,而B
的构造方法也调用过A
的构造方法,最终相当于A
的构造方法调用了两次。然而这并不是问题的所在,假如发生如下的情况。
// 表示一个坐标
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.clone = function() {
return new Point(this.x, this.y);
}
// 图形基类
function Shape(point) {
this.position = point.clone();
}
// 圆
function Circle(point) {
Shape.call(this, point);
}
Circle.prototype = new Shape();
Circle.prototype.constructor = Circle;
代码有点长,需要有点耐心看完。这几行代码乍一看好像没什么问题,然而当运行时,却报错。原因就出在Circle.prototype = new Shape();
这句代码中。对于我们个人想法而言,每一个图形必须要一个位置字段,因此构造函数需要输入一个Point
对象,然后对这个对象克隆,作为图形的位置。然而这行代码中new Shape();
中并没有提供Point
对象的参数,从而Shape
的构造函数执行了undefined.clone()
,从而造成了错误。因此不得不要在构造函数中判断有没有Point
参数,做出不同的处理。并且,还会存在一些问题,比如原型链中,每一个父类都会执行构造函数,都会生成一些同样的无法直接访问到的属性了,这点之后再讨论。
那么,能不能对这点进行一些改进呢,是可以的。首先,先了解一下Object.create()
方法,这个方法是使用现有的对象,创建该对象的__proto__
,那么如果使用该方法,不久可以不用使用new
构造原型链,从而不会再次执行父类的构造方法了,那来测试一下。将上述的例子中的Circle.prototype = new Shape()
改为Circle.prototype = Object.create(Shape.prototype)
,再重新运行代码,发现没有报错了,在使用中程序也能正确运行。
接下来,将第一个例子通过该方法进行继承,将B.prototype = new A();
改为B.prototype = Object.create(A.prototype)
,运行,将输出的结果展开,排除一些不常用的方法,我们可以看到以下形式。
{
B: {
a: 2,
b: 3,
__proto__: {
// A
__proto__: {
// Object
}
}
}
}
细心的你肯定发现了,指向A
的__proto__
中的a
属性消失了,与之前输出的有细微区别,这就说明没有执行A
的构造函数,并且也没有创建无法直接访问到了的属性了。
这个时候就会有人问了,你这个原型链和前一种方法产生的原型链少了构造函数中的属性,那还能说明B
是A
的子类吗?当然是,我们在控制台中进行一些测试。
b instanceof A;
// 结果:true
b instanceof B;
// 结果:true
b instanceof Object;
// 结果:true
结果表明B
确实是A
也是Object
的子类,因为instanceof
方法是检测构造函数的 prototype
属性是否出现在某个实例对象的原型链上,它只检查prototype
,而不检查某个对象自己的属性。
现在,最后要纠结一下为什么要重新设置一下constructor
,你可以把这行代码注释掉重新运行以下代码,你会发现程序还是如期运行,而且也能够正常使用。所以写不写这行代码正常不会产生任何问题,在我理解中只要不用一些奇淫方法(比如获取某个对象实例的构造函数然后新建一个对象),不写也可以,不过还是建议这句还是写一下。
至于其他的方法也很多,可以上网搜索一下其他的方式,然后自己分析一下其他方式会不会更好。
最后,如果觉得这样写还是有点麻烦,又害怕使用ES6语法会产生兼容性问题,那么可以编写一个简单的继承方法,如下。
function Class(desc) {
var initialize;
// initialize 代表是构造函数
// 如果没有填构造函数,创建一个构造函数
if (desc.initialize) {
initialize = desc.initialize;
} else {
initialize = function() {
desc.extends.call(this);
}
}
// 如果有继承,则进行继承操作
if (desc.extends) {
initialize.prototype = Object.create(desc.extends.prototype);
initialize.prototype.constructor = initialize;
}
// 除去extends和initialize以外,其他的作为函数定义,将函数定义添加到该类中
for (var k in desc) {
if (k == 'extends' || k == 'initialize')
continue;
initialize.prototype[k] = desc[k];
}
// 返回类
return initialize;
}
// 使用
var Point = Class({
// 这里填写方法名,这样构造函数名就是Point而不是initialize
// 不过不写也不会影响使用
initialize: function Point(x, y) {
this.x = x;
this.y = y;
},
clone: function() {
return new Point(this.x, this.y);
}
});
var Shape = Class({
initialize: function Shape(point) {
this.position = point.clone();
},
showPosition: function() {
console.log(
'{ x = ' +
this.position.x + ', ' +
this.position.y + ' }');
}
});
var Circle = Class({
extends: Shape,
initialize: function Circle(point) {
Shape.call(this, point);
}
});
// 创建对象,调用方法
var circle = new Circle(new Point(100, 200));
console.log(circle);
circle.showPosition();
什么,你还要使用Object.defineProperty()
定义属性,再实现一些get
、set
方法?这些不难,可以自己去思考,正好能够帮助理解继承链。
这样写方便了创建类和继承类,只不过可能在IDE中也许就不会有代码提示了。
性能问题
理解工作原理后,那么对于性能而言,排在原型链越前方的属性和方法,访问的越快,越在后面的属性和方法,访问的速度会越慢,虽然这个访问速度的时间差很小,但是在原型链很长或者访问频率很高的情况下性能便也许会大打折扣。因此,如果需要非常高的效率的情况下,可以进行一些优化方法。
- 减短原型链,可以使用在类中创建对象的方式代替继承。
- 将常访问的方法放在链前,可以重写常用的方法。
- 如果在不考虑是否子父类的情况下,将需要的父类以及父父类方法和变量直接复制到该类中,然后不进行继承。
- 在判断对象是否拥有某属性时,使用
hasOwnProperty()
方法,而不是判断该值是否为undefined
。
最后
希望大家能够在本篇文章中有所收获,然后,博主玻璃心,如有错误,请轻点喷。