理解 JavaScript 中的原型继承

171
我是JavaScript OOP的新手。您能解释一下以下代码块之间的区别吗?我测试了两个代码块,它们都可以工作。哪一个是最佳实践以及为什么?
第一个代码块:

function Car(name){
    this.Name = name;
}

Car.prototype.Drive = function(){
    console.log("My name is " + this.Name + " and I'm driving.");
}

SuperCar.prototype = new Car();
SuperCar.prototype.constructor = SuperCar;

function SuperCar(name){
    Car.call(this, name);
}

SuperCar.prototype.Fly = function(){
    console.log("My name is " + this.Name + " and I'm flying!");
}

var myCar = new Car("Car");
myCar.Drive();

var mySuperCar = new SuperCar("SuperCar");
mySuperCar.Drive();
mySuperCar.Fly();

第二个区块:

function Car(name){
    this.Name = name;
    this.Drive = function(){ 
        console.log("My name is " + this.Name + " and I'm driving.");
    }
}

SuperCar.prototype = new Car();

function SuperCar(name){
    Car.call(this, name);
    this.Fly = function(){
        console.log("My name is " + this.Name + " and I'm flying!");
    }
}

var myCar = new Car("Car");
myCar.Drive();

var mySuperCar = new SuperCar("SuperCar");
mySuperCar.Drive();
mySuperCar.Fly();

为什么作者使用 prototype 添加了 Drive 和 Fly 方法,而没有在 Car 类中声明它们为 this.Drive 方法,在 SuperCar 类中声明为 this.Fly 方法?
为什么需要将 SuperCar.prototype.constructor 设置回 SuperCar?当设置 prototype 时是否会覆盖 constructor 属性?我把这行代码注释掉后没有任何变化。
为什么在 SuperCar 构造函数中调用 Car.call(this, name)?如果我这样做,Car 的属性和方法不是会被 "继承" 吗?
var myCar = new Car("Car");

3
更现代的方式是使用Object.create()而不是new:http://ncombo.wordpress.com/2013/07/11/javascript-inheritance-done-right/ - Jon
2
只是提醒一下,这个例子可能会让人感到困惑:在这种情况下,“super”指的是一辆飞行汽车,而在面向对象编程中,“super”通常指父原型/类。 - mikemaccana
2
我认为让JavaScript的原型继承难以理解的部分是它将其与构造函数混合在一起。如果我们移除构造函数和类的概念,只剩下对象,那么每个对象要么从空(null)继承,要么从另一个对象继承。这样会更容易理解,而这正是Object.create()所做的。 - ming_codes
可能是JavaScript .prototype如何工作?的重复问题。 - Liam
6个回答

140

为了补充Norbert Hartl的答案, SuperCar.prototype.constructor是不必要的,但有些人使用它作为获取对象(在这种情况下是SuperCar对象)构造函数的一种便捷方式。

仅从第一个示例中可以看出,Car.call(this, name)位于SuperCar构造函数中,因为当您执行以下操作时:

var mySuperCar = new SuperCar("SuperCar");

这就是 JavaScript 的工作方式:
  1. 实例化一个新的空对象。
  2. 将新对象的内部原型设置为 Car。
  3. 运行 SuperCar 构造函数。
  4. 返回完成的对象并将其设置在 mySuperCar 中。
请注意,JavaScript 没有为您调用 Car。由于原型的特性,任何您未为 SuperCar 自己设置的属性或方法都将在 Car 中查找。有时这很好,例如 SuperCar 没有 Drive 方法,但它可以共享 Car 的 Drive 方法,因此所有 SuperCar 将使用相同的 Drive 方法。其他时候,您不想共享,比如每个 SuperCar 都有自己的 Name。那么如何设置每个 SuperCar 的名称为其自己的内容呢?您可以在 SuperCar 构造函数中设置 this.Name:
function SuperCar(name){
    this.Name = name;
}

这样做是可以的,但是等一下。我们在Car构造函数中难道不是已经做了同样的事情吗?不想重复自己。既然Car已经设置了名称,那就直接调用它。

function SuperCar(name){
    this = Car(name);
}

抱歉,您永远不希望更改特殊的"this"对象引用。还记得那4个步骤吗?紧握JavaScript给你的对象,因为这是保持SuperCar对象和Car之间宝贵内部原型链接的唯一方法。那么我们如何设置Name,既不重复自己,又不丢弃JavaScript花费了很多特殊的努力为我们准备的新鲜SuperCar对象呢?
两件事情。一: "this"的含义是灵活的。二:Car是一个函数。可以调用Car,而不是使用原始的、新鲜的实例化对象,而是使用SuperCar对象。这给我们提供了最终解决方案,这是问题中第一个示例的一部分。
function SuperCar(name){
    Car.call(this, name);
}

作为一个函数,Car允许使用函数的call方法进行调用,这会将Car内部的this更改为我们正在构建的SuperCar实例。Presto!现在每个SuperCar都有自己的Name属性。
总之,在SuperCar构造函数中使用Car.call(this, name)可以为每个新的SuperCar对象提供独特的Name属性,但不会重复已经存在于Car中的代码。
一旦理解原型,它们就不会很可怕,但它们与传统的类/继承面向对象编程模型并不相似。我写了一篇关于JavaScript中原型概念的文章。它是为使用JavaScript的游戏引擎编写的,但它是由Firefox使用的同一JavaScript引擎,因此应该都相关。

4
如果您想在旧引擎中模拟Object.getPrototypeOf,则需要"SuperCar.prototype.constructor"。 - Raynos
3
“Javascript中的原型概念”当前的链接已经失效了。我是否可以在其他地方阅读到这篇文章? - shockawave123
由于原型的特性,任何你没有为SuperCar设置的属性或方法都将在Car中查找。我不明白派生对象如何从基对象获取方法和属性,如果基对象构造函数没有被调用。是派生对象原型查看基对象并添加派生对象没有的所有内容吗?还是基对象原型具有基对象的方法和属性? - Caio Gomes

82
两种方法的区别在于,在第一种例子中,Drive()只存在一次,而在第二种方法中,Drive()会每个实例都存在(每次你执行new Car()函数drive()都会被重新创建)。或者说,第一个方法使用原型存储函数,而第二个方法使用构造函数。查找函数的顺序是构造函数和原型。因此,在查找Drive()时,无论它是否在构造函数中或原型中,它都会找到。使用原型更有效,因为通常你只需要针对每种类型的函数调用一次。
在javascript中,new关键字会自动设置构造函数的原型。如果你正在重写原型,那么你必须手动设置构造函数。
javascript中的继承没有类似于super的方法。因此,如果你有一个子类,唯一调用超类构造函数的机会就是根据其名称进行调用。

8
Norbert,需要注意的是你的第一个例子基本上是Douglas Crockford所谓的伪经典继承。有一些要注意的事情:
  1. 您将调用Car构造函数两次,一次来自SuperCar.prototype = new Car()行,另一次来自“构造函数窃取”行Car.call(this...您可以创建一个帮助方法来继承原型,这样您的Car构造函数只需要运行一次,使设置更加高效。
  2. SuperCar.prototype.constructor = SuperCar行将允许您使用instanceof来标识构造函数。有些人需要这样做,其他人则避免使用instanceof
  3. 在超级(例如Car)上定义引用变量,如:var arr = ['one','two']将被所有实例共享。这意味着inst1.arr.push['three'],inst2.arr.push['four']等将显示为所有实例!基本上,静态行为可能不是您想要的。
  4. 您的第二个块在构造函数中定义了fly方法。这意味着每次调用它时,都会创建一个“方法对象”。最好使用原型进行方法!但是,如果您愿意,可以将其保留在构造函数中-您只需要进行保护,以便仅实际初始化原型文字一次(伪):if (SuperCar.prototype.myMethod!= 'function')...然后定义您的原型文字。
  5. “为什么要调用Car.call(this,name)....”:我没有时间仔细查看您的代码,所以我可能是错误的,但通常这是为了使每个实例都可以保持自己的状态,以修复我上面描述的原型链中的“静态”行为问题。
最后,我想提到我有几个TDD JavaScript Inheritance代码示例在这里工作:TDD JavaScript Inheritance Code and Essay我希望能得到您的反馈,因为我希望改进它并保持其开源。目标是帮助经典程序员快速掌握JavaScript,并补充学习Crockford和Zakas书籍。

1
我之前遇到过第一点。我发现如果不使用new关键字,而只是使用Superclass.call(this)(所以SuperCar = { Car.call(this); }; SuperCar.prototype = Car;),这似乎可以工作。有没有不这样做的原因?(对于necro的道歉) - obie
作为一名来自经典面向对象编程语言的开发者,我总是觉得需要创建一个“新”的实例来填充原型链这一点有些奇怪。 - superjos
不错的书。你应该注意到 Github 页面顶部的链接已经失效,当然,它可以在目录中找到。 - John Powell

1
function abc() {
}

为函数abc创建的原型方法和属性

abc.prototype.testProperty = 'Hi, I am prototype property';
abc.prototype.testMethod = function() { 
   alert('Hi i am prototype method')
}

创建函数abc的新实例。
var objx = new abc();

console.log(objx.testProperty); // will display Hi, I am prototype property
objx.testMethod();// alert Hi i am prototype method

var objy = new abc();

console.log(objy.testProperty); //will display Hi, I am prototype property
objy.testProperty = Hi, I am over-ridden prototype property

console.log(objy.testProperty); //will display Hi, I am over-ridden prototype property

http://astutejs.blogspot.in/2015/10/javascript-prototype-is-easy.html


1

我不是100%确定,但我相信区别在于第二个例子仅将Car类的内容复制到SuperCar对象中,而第一个例子将SuperCar原型链接到Car类,使得对Car类的运行时更改也会影响到SuperCar类。


0

这里有几个问题:

请问您能解释一下以下代码块之间的区别吗?我测试过两个代码块都可以工作。

第一个只创建了一个 Drive 函数,而第二个则在 myCarmySuperCar 上分别创建了两个 Drive 函数。

这是一段代码,当执行第一个或第二个代码块时,会得到不同的结果:

myCar.Fly === mySuperCar.Fly // true only in the first case
Object.keys(myCar).includes("Fly") // true only in the second case
Object.keys(Car.prototype).length === 0 // true only in the second case

什么是最佳实践,为什么?作者为什么要使用原型添加DriveFly方法,但不将它们声明为Car类中的this.Drive方法和SuperCar类中的this.Fly
在原型上定义方法是更好的做法,因为:
  • 每个方法只被定义一次
  • 每个方法也可用于创建实例而不执行构造函数的情况(当调用Object.create(Car.prototype)时);
  • 您可以检查在实例的原型链中的哪个级别定义了某个方法。
为什么SuperCar.prototype.constructor需要设置回SuperCar?当设置prototype时,是否覆盖了constructor属性?我注释掉这行代码,但没有任何变化。

constructor 属性在设置 prototype 时不会被覆盖。但是 new Car() 的构造函数是 Car,所以如果你将 new Car() 设置为 SuperCar.prototype,那么显然 SuperCar.prototype.constructor 就是 Car

只要不重新分配给 prototype,就存在一个不变性:Constructor.prototype.constructor === Constructor。例如,对于 Car,这是正确的:Car.prototype.constructor === Car,但对于 ArrayObjectString 等同样适用。

但是,如果将不同的对象重新分配给 prototype,则该不变性将被破坏。通常这不是问题(正如您已经注意到的那样),但最好恢复它,因为它回答了这个问题:“创建新实例时使用哪个构造函数来使用此原型对象?”一些代码可能会进行这种检查并依赖它。有关此类情况,请参见 "为什么需要设置原型构造函数?"

Why call Car.call(this, name); in SuperCar constructor? Won't properties and methods of Car be 'inherited' when I do

var myCar = new Car("Car");
如果您不执行Car.call(this, name);,那么您的SuperCar实例将没有名称属性。当然,您可以决定只执行this.name = name;,这只是复制在Car构造函数中的代码,但在更复杂的情况下,这样的代码重复是不好的做法。
SuperCar构造函数中调用new Car(name)是没有帮助的,因为那会创建另一个对象,而您真正需要的是扩展this对象。通过不使用new(而是使用call),您实际上告诉Car函数不要作为构造函数运行(即不要创建新对象),而是使用您传递给它的对象。
时代已经改变
在现代JavaScript版本中,您可以使用super(name)代替Car.call(this, name)
function SuperCar(name) {
    super(name);
}

今天,您也可以使用class语法,并将问题中的第一个代码块编写如下:

class Car {
    constructor(name) {
        this.name = name;
    }
    drive() {
        console.log(`My name is ${this.name} and I'm driving.`);
    }
}

class SuperCar extends Car {
    constructor(name) {
        super(name);
    }
    fly() {
        console.log(`My name is ${this.name} and I'm flying!`);
    }
}

const myCar = new Car("Car");
myCar.drive();

const mySuperCar = new SuperCar("SuperCar");
mySuperCar.drive();
mySuperCar.fly();

注意,您甚至不必提及“prototype”属性即可实现该目标。 “class ... extends”语法还负责设置“prototype.constructor”属性,就像您问题的第一个块一样。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接