大多数时候, JavaScript 原型让刚开始学习 JavaScript 的人困惑——尤其是有 C++ 或者 Java 背景的人。
在 JavaScript 中,相较于 C++ 和 Java,继承有一些不同的作用。JavaScript 继承是众所周知的 “原型继承”。
当你在 JavaScript 中遇到 类
时,事情就变得有点困难了。新的语法 class
看起来像 C++ 或者 Java,但实际上,它们的作用是不同的。
这篇文章中,我们将尝试理解 JavaScript 中的“原型继承”。我们也会看看新的语法 class
并且尝试理解它真正是什么。现在开始吧。
首先,我们用老派 JavaScript 函数和原型开始。
理解 prototype 的需要
如果你曾经跟 JavaScript 的数组,对象或者字符串打交道,你应该注意过默认地很多可用的方法。
举个例子:
var arr = [1,2,3,4];
arr.reverse(); // returns [4,3,2,1]
var obj = {id: 1, value: "Some value"};
obj.hasOwnProperty('id'); // returns true
var str = "Hello World";
str.indexOf('W'); // returns 6
复制代码
你曾经好奇过这些方法是哪里来的吗?你自定并未定义过它们。
你可以像这样定义自己的方法吗?你或许说可以像这样做:
var arr = [1,2,3,4];
arr.test = function() {
return 'Hi';
}
arr.test(); // will return 'Hi'
复制代码
这是有效的,但是只对叫 arr
的变量有效。我们用另一个变量 arr2
调用 arr2.test()
将会跑出一个错误:“TypeError:arr2.test is not function”。
那么如何处理这些方法才能让每一个 array/string/object 的实例变得可用?你能创建自己的方法用同样的行为吗?答案是肯定的。你需要用正确的方法处理。要这样做,JavaScript 的原型就出现了。
首先看看那些方法来自哪里。考虑下面的代码:
var arr1 = [1,2,3,4];
var arr2 = Array(1,2,3,4);
复制代码
我们用两种不同的方式创建数组:arr1
使用数组字面量和 arr2
使用 Array
构造函数。它们两者是相等的,有一些不同,但不是这篇文章的问题。
现在看看构造函数 Array
——在 JavaScript 中是预定义的构造函数。如果你打开 Chrome 开发这工具,然后在控制台输入 console.log(Array.prototype)
输入回车,你会看见下面这些内容:
在这里你可以看到所有的我们好奇的方法。这里就是我们得到函数方法的地方。自己试试 String.prototype
和 Object.prototype
。
我们创建一个自己的简单构造函数:
var foo = function(name) {
this.myName = name;
this.tellMyName = function() {
console.log(this.myName);
}
}
var fooObj1 = new foo('James');
fooObj1.tellMyName(); // will print James
var fooObj2 = new foo('Mike');
fooObj2.tellMyName(); // will print Mike
复制代码
你能找出上面代码的基本问题吗?问题在于我们在上述处理中浪费了内容。注意这个方法 tellMyName
,在 foo
的实例中,每一个都是一样的。每次我们创建一个 foo
实例方法 tellMyName
,都占用一部分系统内存。如果 tellName
对所有实例都是一样的,它最好保留在一个地方,并且所有我们的实例都来自这个地方。我们看看如何实现:
var foo = function(name) {
this.myName = name;
}
foo.prototype.tellMyName = function() {
console.log(this.myName);
}
var fooObj1 = new foo('James');
fooObj1.tellMyName(); // will print James
var fooObj2 = new foo('Mike');
fooObj2.tellMyName(); // will print Mike
复制代码
来比较下上面和之前的实现。在上面的实现中,如果你 console.dir()
这个实例,会得到以下内容:
注意实例的属性只有 myName
。 tellMyName
是定义在 __prototype__
之下。之后我们再讨论 __prototype__
。更要注意的是,两个实例的 tellMyName
是相等的。如果它们的引用相同,在 JavaScript 中函数比较就相等。这说明 tellMyName
在多个实例中没有消耗额外的空间。
我们看看之前的例子:
注意这次 tellMyName
定义为实例的属性。它不再 __proto__
下面。同样的,注意这次比较函数等价的结果是 false。这是因为他们在不同的内存位置,并且他们的引用也不相同。
我希望你现在理解了 __prototype
的必要性。
所有的 就JavaScript 函数都有一个 prototype
属性,是一个 object 类型。你可以在 prototype
下面定义自己的属性。当你使用函数作为构造函数时,所有的实例将会继承来自 object 的 prototype
。
现在我们来看看上面的 __prototype__
。 __prototype__
是原型对象的简单引用,实例继承了原型对象。听起来很复杂?实际上一点都不。我们看一个可见的例子。
考虑下面代码。我们已经创建了一个数组,通过数组字面量来创建的,它的属性来自于 Array.prototype
。
var arr = [1, 2, 3, 4];
复制代码
上面我刚刚提到:“__prototype__
是原型对象的简单引用,实例继承了原型对象。”所以,arr.__prototype__
应该和 Array.prototype
是相同的。来证明看看:
我们不应当用 __proto__
访问原型对象。根据 MDN 的参考,__proto__
是非常不推荐的,并且不是在所以浏览器都支持。正确的方法如下:
var arr = [1, 2, 3, 4];
var prototypeOfArr = Object.getPrototypeOf(arr);
prototypeOfArr === Array.prototype;
prototypeOfArr === arr.__proto__;
复制代码
上面代码片段展示了 __proto__
和 Ojbect.getPrototypeof
返回的东西一样。
现在来休息一下。喝点咖啡,试试上面的例子。等你准备好了,我们再继续。
原型链和继承
在上面第二张图中,注意到在第一个 __proto__
对象里有另一个 __proto__
了吗?如果没有回去看看第二张图。我们现在讨论它的实际意义。这就是著名的原型链。
在 JavaScript 中,我们通过原型链实现继承。
考虑下面的例子:我们都理解术语“机车”。公共汽车可以被叫做看做机车。小汽车也能被当做机车。公共汽车,小汽车和摩托车都有共同的属性,这就是为什么他们能被称作机车。举个例子,它们可以从一个地方移动到另一个地方。它们有轮子,有喇叭等等。
当然,公共汽车,小汽车和摩托有不同的类型,比如 Mercedes, BMW,Honda 等等。
上面的图表中,公共汽车从机车集成了一些属性。Mercedes Benz 从公共汽车继承了一些属性。类似的还有汽车和摩托车。
我们在 JavaScript 中建立这种关系。
首先,为了简单的缘故我们假定一些观点:
- 公共汽车有 6 个轮子
- 公共汽车,小汽车,摩托的加速和刹车不相同,所有的公共汽车,小汽车和摩托都一样。
- 所有的机车都能鸣笛。
function Vehicle(vehicleType) { // 机车构造
this.vehicleType = vehicleType;
}
Vehicle.prototype.blowHorn = function () {
console.log('Honk! Honk! Honk!'); // 所有的机车可以鸣笛
}
function Bus(make) { // 公共汽车构造
Vehicle.call(this, "Bus");
this.make = make
}
Bus.prototype = Object.create(Vehicle.prototype); // 使公共汽车继承来自机车的属性
Bus.prototype.noOfWheels = 6; // 假设所有的公共汽车有 6 个轮子
Bus.prototype.accelerator = function() {
console.log('Accelerating Bus'); // 公共汽车加速
}
Bus.prototype.brake = function() {
console.log('Braking Bus'); // 公共汽车减速
}
function Car(make) {
Vehicle.call(this, "Car");
this.make = make;
}
Car.prototype = Object.create(Vehicle.prototype);
Car.prototype.noOfWheels = 4;
Car.prototype.accelerator = function() {
console.log('Accelerating Car');
}
Car.prototype.brake = function() {
console.log('Braking Car');
}
function MotorBike(make) {
Vehicle.call(this, "MotorBike");
this.make = make;
}
MotorBike.prototype = Object.create(Vehicle.prototype);
MotorBike.prototype.noOfWheels = 2;
MotorBike.prototype.accelerator = function() {
console.log('Accelerating MotorBike');
}
MotorBike.prototype.brake = function() {
console.log('Braking MotorBike');
}
var myBus = new Bus('Mercedes');
var myCar = new Car('BMW');
var myMotorBike = new MotorBike('Honda');
复制代码
来解释一下上述代码:
我们有个 Vehicle
构造器,它是机车类型。所有的机车都能鸣笛,在 Vehicle
原型上有个 blowHorn
属性。
作为 Bus
,是一种机车,从 Vehicle
对象里继承属性。
我们假设所有的公共汽车有 6 个轮子,同时有加速和刹车程序。所以我们在 Bus
原型上定义有 noOfWheels
,accelerator
和 brake
属性。
小汽车和摩托车类似。
来 Chrome 开发者工具的 console 里面执行代码。
执行以后,我们得到 3 个对象 myBus
, myCar
和 myMotorBike
。
输入 console.dir(mybus)
,按下回车。点击三角形图标展开内容,你会看到如下:
在 myBus
之下,我们有 make
和 vehicleType
属性。注意 Bus
的原型的 __proto__
的值。它的原型的所有属性这里是可见的:accelerator
,brake
,noOfWheels
。
现在我们看看第一个 __proto__
对象。这个随想有另一个 __proto__
作为它的属性。
在这之下,我们有 blowHorn
和 constructor
属性。
Bus.prototype = Object.create(Vehicle.prototype);
复制代码
记着这行代码吗? Object.create(Vehicle.prototype)
将会创建一个空对象,这个对象的原型是 Vehicle.prototype
。我们设置这个对象作为 Bus
的原型。对于 Vehicle.prototype
我们没有特殊定义任何原型,所以默认地继承来自 Object.prototype
。
我们来看看下面的魔法:
我们可以访问 make
属性作为 myBus
自己的属性。 我们可以访问 brake
属性,从 myBus
的原型中。 我们可以访问 blowHorn
属性 从 myBus
的原型的原型。 我们可以访问 hasOwnProperty
属性 从 myBus
的原型的原型的原型。:)
这叫做原型链。无论何时在 JavaScript 中我们访问一个对象的原型时,它首先检查是否这个属性在对象内部。如果不在就检查它的原型对象。如果在,就会得到这个原型值。否则,它会检查属性是否存在原型的原型上,如果也不在,那么检查原型的原型的原型,一直如此下去。
那么这种方式将会检查多久?如果属性在任何一个位置被发现或者任何位置上 __proto__
的值是 null
或者 undefined
的时候就停止。接着会抛出一个错误,告诉你这个查找的值不存在。
这是在 JavaScript 中通过原型链的帮助,继承是如何运作的。
随便试试上面的例子,用 myCar
和 myMotorBike
。
正如我们知道的,JavaScript 中一切都是对象。在每个实例中你都能找到它,原型链结束于 Object.prototype
。
如果你通过 Objec.create(null)
创建一个对象,上面的规则就是个例外了。
var obj = Object.create(null)
复制代码
上面的 obj
代码,将是一个空对象,,没有任何原型。
更多关于 Object.create
的信息,请查阅 MDN。
你能改变一个已经存在的对象的原型吗?答案显而易见,通过 Object.setPrototypeOf()
就可以。具体信息参考 MDN。
想知道一个属性是否是对象自己的属性?你已经知道如何这么做了。 Object.hasOwnProperty
将会告诉你,是否这个属性来自对象自己或者来自它的原型链。具体信息参考 MDN。
注意 __proto__
也作为 [[prototype]]
引用。
现在休息一下。我们将继续最后一部分内容。
理解 JavaScript 的类
根据 MDN:
JavaScript 类,发布于 ECMAScript 2015,是一种语法糖,覆盖了 JavaScript存在的基于原型的继承。对于 JavaScript 而言,类语法没有引进新的面向对象继承。
JavaScript 中的类提供更好的语法去实现我们在上面做的事情,这种语法要更清晰。我们来一睹为快。
class Myclass {
constructor(name) {
this.name = name;
}
tellMyName() {
console.log(this.name)
}
}
const myObj = new Myclass("John");
复制代码
constructor
方法是一种特殊的方法。无论何时,你创建了类的实例,它会自动执行。这类里只可能有一个constructor
。
你在类里定义的方法将会移动到原型对象上。
如果你想在实例里有一些属性你可以在构造器上定义它,正如我们做的那样 this.name = name
。
来看看我们的 myObj
。
注意我们在实例内部有一个 name
属性,同时在原型上有一个 tellMyName
方法。
考虑如下代码:
class Myclass {
constructor(firstName) {
this.name = firstName;
}
tellMyName() {
console.log(this.name)
}
lastName = "lewis";
}
const myObj = new Myclass("John");
复制代码
我们看看输出:
lastname
移动到了实例而不是原型。只有方法,那些你声明在类体里的方法会移动到原型。尽管这有点意外。
考虑如下代码:
class Myclass {
constructor(firstName) {
this.name = firstName;
}
tellMyName = () => {
console.log(this.name)
}
lastName = "lewis";
}
const myObj = new Myclass("John");
复制代码
输出:
注意 tellMyName
现在是一个箭头函数,同时它被移动到了实例而不是原型。所以记着箭头函数会总是移动到实例,请小心使用它们。
我们来看看静态类属性:
class Myclass {
static welcome() {
console.log("Hello World");
}
}
Myclass.welcome();
const myObj = new Myclass();
myObj.welcome();
复制代码
输出:
静态属性是你可以不用创建类的实例就能访问的。另一方面,实例也不能访问类的静态属性。
那么静态属性是一个只在类中的新的概念,并且不在旧 JavaScript 中的吗?不,旧的JavaScript也支持。旧的JavaScript这样实现静态类:
function Myclass() {
}
Myclass.welcome = function() {
console.log("Hello World");
复制代码
现在看看如何在类中实现继承:
class Vehicle {
constructor(type) {
this.vehicleType= type;
}
blowHorn() {
console.log("Honk! Honk! Honk!");
}
}
class Bus extends Vehicle {
constructor(make) {
super("Bus");
this.make = make;
}
accelerator() {
console.log('Accelerating Bus');
}
brake() {
console.log('Braking Bus');
}
}
Bus.prototype.noOfWheels = 6;
const myBus = new Bus("Mercedes");
复制代码
我们继承其他类使用 extends
关键字。
super()
将会简单地执行父类的构造器。如果你从其他类继承,同时在子类使用构造器,那么必须在子类的构造器中调用 super()
,以免抛出错误。
我们已经知道在类体中,如果定义了不是常规函数的任何属性,它将被移动到实例中而不是原型链。我们我们在 Bus.prototype
上定义 noOfWheel
。
在类体中,如果你想去执行父类方法,你可以使用 super.parentClassMethod()
。
输出:
https://cdn-images-1.medium.com/max/800/1*62igbvXqzZZBvlH7_jNPBw.png
复制代码
上面的内容看起来跟我们的第七张图很像。
总结
那么你是否应该使用新的类语法或者旧的构造器语法呢?我觉得没有一定的答案。它取决于你的场景。
这篇文章中,类的部分我已经证明了你可以用原型的方式继承类。关于 JavaScript 类有更多的东西需要了解,但是超过了本文的范围。看看 MDN 上关于类的文档。或者以后我会写一篇关于类的文章。
如果这篇文章帮到了你,那么点个赞我会很感激。
感谢阅读 :)