怎么又忽然想起写 JavaScript 原型继承这个基础的内容呢?最近在写一个小程序,要从底层实现继承,突然发现我对这么基础知识的记忆模糊起来。于是迅速找文章来读,才又重新拾起,现在想把相关的内容通过图形化的方式记录在自己的博客上,以免过一段时间又忘了。
构造函数
在 JavaScript 中并不存在真正的构造函数,一个普通的函数和 new
操作符一同使用时即可充当构造函数(为了模拟面向对象中真正的类,充当构造函数的函数名一般都是大写字母开头):
function Car() { }
上例中 Car
就是一个函数。这个函数有一个 prototype
属性,可以在该属性上添加方法:
Car.prototype.move = function () { console.log('Hi, I am moving!'); };
通过 new
操作符即可创建一个 Car
的实例(这是概念上的实例,JavaScript 当中它仅仅是一个对象):
var myCar = new Car();
并且可以调用 move
方法:
myCar.move(); // Hi, I am moving!
我们都知道 move
方法并不是位于 myCar
这个对象上,而是在 myCar
对象的原型链上。myCar
对象有个 __proto__
属性,该属性指向 Car.prototype
。当调用 myCar.move()
时,JavaScript 引擎首先看方法是否存在于 myCar
对象,如果不存在则再看是否存在于 myCar.__proto__
上,由于 myCar.__proto__
与 Car.prototype
指向同一个对象,由此便可以调用到 move()
方法。下面的图描述了这样的关系:
通过以下代码也可以看出这个关联关系:
console.log(myCar.__proto__ === Car.prototype); // true
注意,当我们说通过原型链查找属性时,实际上是通过对象的内部的 [[Prototype]]
属性进行的,在目前浏览器和 NodeJS 环境中,可以通过 __proto__
查看。而原型这个词又和对象的 prototype
属性名字一样,容易造成混淆。要记住表达原型链的属性并不是 prototype
而是内部的 [[Prototype]]
属性。
在 JavaScript 中,还有一个 instanceof
操作符,通过它可以知道某个对象是否是某个构造函数的实例:
myCar instanceof Car; // true
当调用 instanceof
操作符时,JavaScript 引擎会判断 myCar
的 __proto__
是否与 Car.prototype
指向同一个对象,如果是则返回 true
。
原型继承
接下来我们看如何通过原型的方式实现面向对象的继承:
function Sedan() { Car.call(this); }
插入解释一下什么是 Sedan:
A sedan is a car with seats for four or more people, a fixed roof, and a boot that is separate from the part of the car that you sit in.
总之,Sedan 就是某一种 Car,因此可以借用它来表达一个继承关系。
在 Sedan
构造函数中,通过 Car.call(this)
实现了在子类的构造函数中调用父类构造函数的方法。接下来我们修改 Sedan.prototype
对象为一个 Car
的实例:
Sedan.prototype = new Car();
这么做是为了让 Sedan.prototype
对象的 __proto__
属性指向 Car.prototype
,从而实现原型链继承。
接下来可以实例化一个 Sedan
对象,并调用 move
方法:
var mySedan = new Sedan(); mySedan.move();
下图描述了继承关系:
再通过代码验证一下:
console.log(mySedan.__proto__ === Sedan.prototype); // true console.log(Sedan.prototype.__proto__ === Car.prototype); // true
Car.prototype
对象还有一个 constructor
属性,它指向 Car
本身:
Car.prototype.constructor === Car; // true
显然在第一个例子中,下面的结果是 true
:
myCar.constructor === Car; // true
通过原型链(__proto__
属性),访问 myCar.constructor
的时候实际上就是访问了原型对象的 constructor
属性,也就是 Car.prototype.constructor
。
但是你会发现 mySedan
的 constructor
也成为 Car
了。显然是因为 mySedan
原型链被修改到了那个 Car
的实例(即 Sedan.prototype
)上,顺着原型链就会最终访问 Car.prototype.constructor
。因此在实际使用中,要把修改的 constructor
改回来:
var originalConstructor = Sedan.prototype.constructor; Sedan.prototype = new Car(); Sedan.prototype.constructor = originalConstructor;
最终的关系图如下所示:
一些改进
在上面 Sedan
继承 Car
的示例中,我们让 Sedan.prototype
指向了一个 Car
实例,这可能会产生一些副作用:为了实现继承,我们不必要的实例化了一个 Car
实例,假设 Car
的构造函数当中有一些网络请求甚至与服务有数据读写交互,那么轻则导致不必要的浪费,重则会导致一些 bug 出现。
有解决办法吗?答案是有的,方法为:创建一个临时的空构造函数,并将这个函数对象的 prototype
指向需要继承的 prototype
上,代码如下:
function F() { } F.prototype = Car.prototype; Sedan.prototype = new F();
关系图如下所示:
这样我们避免了实例化 Car
的同时,也保证了 Sedan
实例的 __proto__
指向正确。
使用 ES5 方法
在上面的例子中,我们通过一个临时函数 F
来实现原型继承,在 ES5 中有个方法可以实现同样的事情,这就是 Object.create()
方法,该方法可以创建一个新对象,并把对象的原型链指向一个指定的对象上,像这样:
Sedan.prototype = Object.create(Car.prototype);
简洁吧,一句话就搞定了。
不过为了关联原型链,我们还是创建了一个新对象赋值给 Sedan.prototype
,在 ES6 中有个方法可以直接修改原型链关系:Object.setPrototypeOf
使用 ES6 方法
我们用 ES6 的这个方法替换 Object.create()
。
Object.setPrototypeOf(Sedan.prototype, Car.prototype);
使用 ES6 的 class 语法糖
ES6 还提供了更像面向对象风格的语法糖,实现起来是这样的:
class Car { constructor() { } move() { console.log('Hi, I am moving!'); } } class Sedan extends Car { constructor() { super(); } } var mySedan = new Sedan(); mySedan.move(); // Hi, I am moving!
这样 JavaScript 看上去更加像一门真正的面向对象语言了,但语法糖就是语法糖,本质上还是通过对象的原型链来继承,只不过写法上再也不会看到那些杂乱的 prototype
对象了。
留言