65.9K
CodeProject 正在变化。 阅读更多。
Home

JavaScript – 原型继承 – 图文详解

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2023年11月2日

CPOL

16分钟阅读

viewsIcon

3714

downloadIcon

49

关于JavaScript中“原型继承”的教程

1 引言

从C++、Java、C#等一些现代面向对象语言转到JavaScript的开发者,常常会发现JavaScript“混乱”、晦涩,并且充满了为历史向后兼容而保留的陈旧语言表达式。特别需要指出的一点是,JavaScript中的“对象继承模型”与上述OO语言模型相似,但内部却截然不同。在C++、Java和C#等现代语言中,对象继承模型是“基于类的”,而在JavaScript中,它是“基于实例的”。本文并非一份完整的教程,而是一篇阐述性的文章,它提供了足够多的示例和Chrome DevTools调试器的截图,以解释继承在内部是如何实现的。目标读者是中级及以上JavaScript开发者。

2 理论背景

在给出实际示例之前,需要一些理论背景。我们专注于解释和理解基本概念,更详细的内容请参考相关文献。

2.1 简化定义

以下是一些通俗易懂的解释。

什么是“类继承”? 类继承是一种面向对象编程的范例/方式,通过重用现有类来重用功能/代码。在这里,“类”是创建对象的模板。类是抽象结构,而不是具体实例。使用这种范例的编程语言包括C++、Java和C#。

什么是“原型继承”? 原型继承是一种面向对象编程的范例/方式,通过重用现有对象作为新对象的原型来重用功能/代码。原型对象不是抽象结构,而是具体实例。使用这种范例的编程语言之一是JavaScript。

在“原型继承”中,“原型对象”是什么? 原型对象是一个对象的实例,其状态和行为将在“原型继承”过程中被重用。

哪种更好,“原型继承”还是“类继承”? “原型继承”在20世纪80年代很流行。自20世纪90年代末以来,“类继承”范例变得更加流行。

2.2 JavaScript中的原型继承

以下是一些简化的解释。

JavaScript中的原型继承是如何实现的? JavaScript中的每个对象都有一个特殊的内部隐藏属性 [[Prototype]],它指向另一个对象或为 null

我可以读取/写入 [[Prototype]] 吗? 属性 [[Prototype]] 更像是一个概念,但存在用于读取/写入它的getter/setter,您需要读取/写入getter/setter __proto__

JavaScript中所有对象都有“基类”吗? 嗯,没有“基类”,但有一个“基原型对象”,因为这是一个原型继承范例。有一个对象“Object”,它作为JavaScript中所有新对象的基原型对象。默认情况下,JavaScript中的每个新对象都会通过继承链在深度上递归地指向一个“Object”实例。因此,每个新对象都从“Object”实例继承方法和属性。

JavaScript具有“class”关键字,可以创建新对象。这不是“类继承”吗? ECMA Script 2015 (ES6) 引入了类,它们是JavaScript现有基于原型的继承的语法糖。但其底层仍然是基于原型的继承。

2.3 示例

下面的示例适用于已经有JavaScript经验的开发者。重点在于JavaScript内部如何表示对象,而不是在此教授JavaScript。我们演示了三种不同的对象创建方式:

  1. 字面量语法
  2. 构造函数语法
  3. 类语法

这些不是创建JavaScript对象的唯一方式,但它们为本文提供了足够的区分度。我们使用了Chrome DevTools调试器来查看对象和继承在内部是如何表示的。所有示例的源代码都已附上。示例在Chrome版本116.0.5845.188 (Official Build) (64-bit)上执行。

3 示例01:对象 - 字面量语法

3.1 示例代码

请看以下示例:

//Example 01
//Object - Literal syntax

let person1 = {
    name: "Mark",
    age: 21,
}

<!--
Output
Accessing object property:
person1.name:Mark
Calling method inherited from the prototype:
person1.toString():[object Object]

Accessing object prototype:
person1.__proto__.toString():[object Object]
person1.__proto__.constructor.name:Object

Checking if we are at the root of the inheritance:
person1.__proto__.__proto__:null
    -->

执行屏幕截图:

3.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

3.3 示例注释

  • 在此示例中,对象是使用传统的“字面量语法”创建的。
  • DevTools调试器截图清晰地列出了对象 person1 的所有属性和方法。
  • 即使从语法上不明显,但从执行结果和DevTools中都可以看出,对象 person1 被分配了一个原型对象“Object”。请注意DevTools中的属性 [[Prototype]] Object。
  • 尽管 object person1 本身没有定义方法 .toString(),但它从原型对象“[[Prototype]] Object”继承了该方法,正如执行代码 person1.toString() 所见。
  • 可以看到,使用表达式 person1.__proto__,我们可以直接访问 [[Prototype]] 及其属性和方法。

4 示例02:对象 - 构造函数语法

4.1 示例代码

请看以下示例:

//Example 02
//Object - Constructor function syntax

function Person(name, age) {
    this.name = name;
    this.age = age;
}

let person1 = new Person("Mark", 21);

<!--
Output
Accessing object property:
person1.name:Mark
Calling method inherited from the prototype:
person1.toString():[object Object]

Checking object class:
person1 instanceof Person:true

Accessing object prototype:
person1.__proto__.toString():[object Object]
person1.__proto__.constructor.name:Person

Accessing object prototype’s prototype:
person1.__proto__.__proto__:[object Object]
person1.__proto__.__proto__.constructor.name:Object

Checking if we are at the root of the inheritance:
person1.__proto__.__proto__.__proto__:null
    -->

执行屏幕截图:

4.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

4.3 示例注释

  • 在此示例中,对象是使用“构造函数语法”创建的。
  • DevTools截图清晰地列出了对象 person1 的所有属性和方法。
  • 即使从语法上不明显,但从结果和DevTools中都可以看出,对象 person1 被分配了一个原型对象“Object 请注意DevTools中的属性 [[Prototype]] Object
  • 值得注意的是,我们有2个原型的深度层次。您可以很容易地从DevTools以及通过代码执行 person1.__proto__person1.__proto__.__proto__ 来看到这一点。这很容易解释:当使用“构造函数语法”创建对象时,JavaScript会创建一个原型对象,该对象将作为所有使用相同“构造函数”创建的对象的原型对象,并在该对象中保存对该函数的引用,这可以在DevTools中看到,属性/方法为“constructor: f Person()”。
  • 可以看到,使用表达式 person1.__proto__,我们可以直接访问 [[Prototype]] 及其属性和方法,并通过 person1.__proto__.__proto__ 进一步深入。
  • 尽管对象 person1 本身没有定义方法 .toString(),但它从原型对象“[[Prototype]] Object”继承了该方法,这次是通过2个深度层级,正如执行 person1.toString() 所见。
  • 有人可能会问,这个示例02中的对象 person1 和前面的示例01中的对象 person1 是同一个对象吗?显然,从DevTools可以看出,它们没有相同的内部结构,这取决于对象的创建方式。那么,我们可以说这些对象是相同的吗?嗯,作为程序员,您关心的是对象的“公共接口”,即可以从对象引用 person1 访问的属性和方法。由于这个示例02和前面的示例01中的对象 person1 拥有“几乎相同的”公共接口,因此我们认为它们是相同的。

5 示例03:对象 - 类语法

5.1 示例代码

请看以下示例:

//Example 03
//Object - Class syntax

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
}

let person1 = new Person("Mark", 21);

<!--
Output
Accessing object property:
person1.name:Mark
Calling method inherited from the prototype:
person1.toString():[object Object]

Checking object class:
person1 instanceof Person:true

Accessing object prototype:
person1.__proto__.toString():[object Object]
person1.__proto__.constructor.name:Person

Accessing object prototype’s prototype:
person1.__proto__.__proto__:[object Object]
person1.__proto__.__proto__.constructor.name:Object

Checking if we are at the root of the inheritance:
person1.__proto__.__proto__.__proto__:null
    -->    

执行屏幕截图:

5.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

5.3 示例注释

  • 在此示例中,对象是使用“类语法”创建的。ECMA Script 2015 (ES6) 引入了类,它们是JavaScript现有基于原型的继承的语法糖。
  • DevTools截图清晰地列出了对象 person1 的所有属性和方法。
  • 即使从语法上不明显,但从结果和DevTools中都可以看出,对象 person1 被分配了一个原型对象“Object 请注意DevTools中的属性 [[Prototype]] Object
  • 值得注意的是,我们有2个原型的深度层次。您可以很容易地从DevTools以及通过代码执行 person1.__proto__person1.__proto__.__proto__ 来看到这一点。这很容易解释:当使用“类语法”创建时,JavaScript会创建一个原型对象,该对象将作为所有使用相同“类”创建的对象的原型对象,并在该对象中保存对该函数的引用,这可以在DevTools中看到,属性/方法为“constructor: class Person()”。
  • 可以看到,使用表达式 person1.__proto__,我们可以直接访问 [[Prototype]] 及其属性和方法,并通过 person1.__proto__.__proto__ 进一步深入。
  • 尽管对象 person1 本身没有定义方法 .toString(),但它从原型对象 [[Prototype]] Object 继承了该方法,这次是通过2个深度层级,正如执行代码 person1.toString() 所见。
  • 可以看到,示例03中的对象 person1 和示例02中的对象 person1 拥有几乎相同的内部结构,只是示例02中的属性“constructor: f Person()”现在被属性“constructor: class Person()”替换了。同样,如上所述,由于示例01、示例02和示例03中的对象 person1 拥有几乎相同的“公共接口”,我们认为它们是相同的对象,无论它们是如何创建的。

6 示例04:多个对象 - 字面量语法

6.1 示例代码

请看以下示例:

//Example 04
//Multiple Objects - Literal syntax

let person1 = {
    name: "Mark",
    age: 21,
    toString: function () {
        return `Person ${this.name}, old ${this.age} (ver1)`;
    },
}

let person2 = {
    name: "Novak",
    age: 36,
    toString: function () {
        return `Person ${this.name}, old ${this.age} (ver2)`;
    },
}

person1.city = "Belgrade";
person2.profession = "programmer";

person1.__proto__.country = "Serbia";

<!--
Output
Accessing person1:
person1.name:Mark
person1.city:Belgrade
person1.profession:undefined
person1.country:Serbia
person1.toString():Person Mark, old 21 (ver1)

Accessing person2:
person2.name:Novak
person2.city:undefined
person2.profession:programmer
person2.country:Serbia
person2.toString():Person Novak, old 36 (ver2)
    -->

执行屏幕截图:

6.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

6.3 示例注释

  • 在此示例中,我们使用“字面量语法”创建了两个具有相同 公共 接口的对象。
  • JavaScript的“字面量语法”本身并没有提供任何方式来表明这两个对象“属于同一个类”,事实上JavaScript将它们视为任何两个不同的对象。
  • 即使从语法上不明显,但从结果和DevTools中都可以看出,对象 person1person2 被分配了一个原型对象“Object 请注意DevTools中的属性 [[Prototype]] Object
  • 尽管DevTools截图可能不明显,但这两个对象现在拥有相同的原型 OBJECT INSTANCE “Object”。这对于来自C++、Java、C#等经典的“类继承”语言的程序员来说可能有点奇怪,需要花点心思去适应新的范例。在“类继承”中,每个对象在构造时都会获得自己的基对象实例,而现在情况并非如此。
  • 所以,它们确实有一些共同点,那就是都继承自“Object”。因此,两者都从“Object”继承了相同的属性和方法。
  • 我们为每个对象添加了一些属性。此外,我们还通过表达式 `person1.__proto__.country = "Serbia"` 稍微操作了它们的通用基原型,为其添加了一个属性。
  • 当执行时,该表达式会将一个属性添加到“Object”对象,因为在这种情况下它是原型对象。在实践中,向“Object”添加属性是绝对不应该做的,因为它会向该JavaScript环境中的所有对象添加属性。我们只是想说明这是可能的,并且“Object”在JavaScript中只是另一个对象,与其他任何对象一样。此外,这表明对象 person1person2 都继承自同一个“Object”对象实例。因此,对象 person1person2 现在都可以访问该属性,正如从执行结果中所见。
  • 我们还为对象 person1person2 添加了方法 .toString()。每个对象都有一个不同的方法版本,正如执行结果所示。每个对象现在都有自己的方法 .toString() ,它遮蔽了从“Object”继承的 .toString() 方法。请查看截图,了解DevTools如何表示这一点

7 示例05:多个对象 - 构造函数语法

7.1 示例代码

请看以下示例:

//Example 05
//Multiple Objects - Constructor function syntax

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.toString = function () {
        return `Person ${this.name}, old ${this.age}`;
    }
}

let person1 = new Person("Mark", 21);
let person2 = new Person("Novak", 36);

person1.city = "Belgrade";
person2.profession = "programmer";

person1.__proto__.country = "Serbia";

<!--
Output
Accessing person1:
person1.name:Mark
person1.city:Belgrade
person1.profession:undefined
person1.country:Serbia
person1.toString():Person Mark, old 21

Accessing person2:
person2.name:Novak
person2.city:undefined
person2.profession:programmer
person2.country:Serbia
person2.toString():Person Novak, old 36
    -->
    

执行屏幕截图:

7.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

7.3 示例注释

  • 在此示例中,对象是使用“构造函数语法”创建的。
  • 值得注意的是,我们有2个原型的深度层次。您可以很容易地从DevTools以及通过代码执行 person1.__proto__person1.__proto__.__proto__person2.__proto__person2.__proto__.__proto__ 来看到这一点。这很容易解释:当使用“构造函数语法”创建时,JavaScript会创建一个原型对象,该对象将作为所有使用相同“构造函数”创建的对象的原型对象,并在该对象中保存对该函数的引用,这可以在DevTools中看到,属性/方法为“constructor: f Person()”。
  • 尽管DevTools截图可能不明显,但这两个对象现在都拥有相同的原型链,包含2个对象。这对于来自C++、Java、C#等经典的“类继承”语言的程序员来说可能有点奇怪,需要花点心思去适应新的范例。在“类继承”中,每个对象在构造时都会获得自己的基对象实例,而现在情况并非如此。
  • 我们为每个对象添加了一些属性。此外,我们还通过表达式‘person1.__proto__.country = "Serbia"’稍微操作了它们的通用基原型,为其添加了一个属性。
  • 当执行时,该表达式会将一个属性添加到原型链中的第一个对象,该对象充当所有使用该构造函数创建的对象的原型。此外,这表明对象 person1person2 都继承自相同的“Object”实例。因此,对象 person1person2 现在都可以访问该属性,正如从执行结果中所见。
  • 我们还在构造函数中添加了方法 .toString()。这会将 .ToString() 方法添加到类的原型对象中,正如DevTools所示。每个对象现在都有一个 .toString() 方法,它遮蔽了从“Object”继承的 .toString() 方法。请查看截图,了解DevTools如何表示这一点

8 示例06:多个对象 - 类语法

8.1 示例代码

请看以下示例:

//Example 06
//Multiple Objects - Class syntax

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    toString() {
            return `Person ${this.name}, old ${this.age}`;
    }
}

let person1 = new Person("Mark", 21);
let person2 = new Person("Novak", 36);

person1.city = "Belgrade";
person2.profession = "programmer";

person1.__proto__.country = "Serbia";

<!--
Output
Accessing person1:
person1.name:Mark
person1.city:Belgrade
person1.profession:undefined
person1.country:Serbia
person1.toString():Person Mark, old 21

Accessing person2:
person2.name:Novak
person2.city:undefined
person2.profession:programmer
person2.country:Serbia
person2.toString():Person Novak, old 36
    -->    

执行屏幕截图:

8.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

8.3 示例注释

  • 在此示例中,对象是使用“类语法”创建的。ECMA Script 2015 (ES6) 引入了类,它们是JavaScript现有基于原型的继承的语法糖。
  • 值得注意的是,我们有2个原型的深度层次。您可以很容易地从DevTools以及通过代码执行 person1.__proto__person1.__proto__.__proto__person2.__proto__person2.__proto__.__proto__ 来看到这一点。这很容易解释:当使用“构造函数语法”(此处指类语法)创建时,JavaScript会创建一个原型对象,该对象将作为所有使用相同“类构造函数”创建的对象的原型对象,并在该对象中保存对该函数的引用,这可以在DevTools中看到,属性/方法为“constructor: class Person”。
  • 尽管DevTools截图可能不明显,但这两个对象现在都拥有相同的原型链,包含2个对象。这对于来自C++、Java、C#等经典的“类继承”语言的程序员来说可能有点奇怪,需要花点心思去适应新的范例。在“类继承”中,每个对象在构造时都会获得自己的基对象实例,而现在情况并非如此。
  • 我们为每个对象添加了一些属性。此外,我们还通过表达式‘person1.__proto__.country = "Serbia"’稍微操作了它们的通用基原型,为其添加了一个属性。
  • 当执行时,该表达式会将一个属性添加到原型链中的第一个对象,该对象充当所有使用该构造函数创建的对象的原型。此外,这表明对象 person1person2 都继承自同一个对象实例。因此,对象 person1person2 现在都可以访问该属性,正如从执行结果中所见。
  • 我们还在构造函数中添加了方法 .toString()。这会将 .ToString() 方法添加到类的原型对象中,正如DevTools所示。每个对象现在都有一个 .toString() 方法,它遮蔽了从“Object”继承的 .toString() 方法。请查看截图,了解DevTools如何表示这一点

9 示例07:对象继承 - 字面量语法

9.1 示例代码

请看以下示例:

//Example 07
//Object Inheritance - Literal syntax

let person1 = {
    name: "Mark",
    age: 21,
    toString: function () {
        return `Person ${this.name}, old ${this.age} (ver1)`;
    },
}

let student1 = {
    course: "Computers",
}

student1.__proto__ = person1;

<!--
Output
Accessing object properties and methods:
student1.name:Mark
student1.course:Computers
student1.toString():Person Mark, old 21 (ver1)

Accessing object prototype:
student1.__proto__.toString():Person Mark, old 21 (ver1)
student1.__proto__.constructor.name:Object

Accessing object prototype’s prototype:
student1.__proto__.__proto__.toString():[object Object]
student1.__proto__.__proto__.constructor.name:Object

Checking if we are at the root of the inheritance:
student1.__proto__.__proto__.__proto__:null
    -->    

执行屏幕截图:

9.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

9.3 示例注释

  • 在此示例中,对象是使用传统的“字面量语法”创建的。
  • 我们创建了2个对象,对象 student1 继承自对象 person1。创建继承的关键表达式是“student1.__proto__ = person1;
  • 从代码执行可以看出,对象 student1 继承了对象 person1 的属性和方法。
  • DevTools截图清晰地展示了对象层次结构是如何创建的。
  • 我们还为对象 person1 和对象 student1 添加了方法 .toString(),对象 student1 继承了它。对象 student1 现在拥有自己的方法 .toString(),它遮蔽了从“Object”继承的 .toString() 方法。请查看截图,了解DevTools如何表示这一点。

10 示例08:对象继承 - 构造函数语法

10.1 示例代码

请看以下示例:

//Example 08
//Object Inheritance- Constructor function syntax

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.toString = function () {
        return `Person ${this.name}, old ${this.age}`;
    };
}

let person1 = new Person("Mark", 21);

function Student(course) {
    this.course = course;
}

Student.prototype = person1;

let student1 = new Student("Computers");

<!--
Output
Accessing object properties and methods:
student1.name:Mark
student1.course:Computers
student1.toString():Person Mark, old 21

Checking object class:
student1 instanceof Person:true
student1 instanceof Student:true

Accessing object prototype:
student1.__proto__.toString():Person Mark, old 21
student1.__proto__.constructor.name:Person

Accessing object prototype’s prototype:
student1.__proto__.__proto__.toString():[object Object]
student1.__proto__.__proto__.constructor.name:Person

Accessing object prototype’s prototype’s prototype:
student1.__proto__.__proto__.__proto__.toString():[object Object]
student1.__proto__.__proto__.__proto__.constructor.name:Object

Checking if we are at the root of the inheritance:
student1.__proto__.__proto__.__proto__.__proto__:null
    -->    

执行屏幕截图:

10.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

10.3 示例注释

  • 在此示例中,对象是使用“构造函数语法”创建的。
  • 我们创建了两个对象,对象 student1 继承自对象 person1。创建继承的关键表达式是“Student.prototype = person1;
  • 从代码执行可以看出,对象 student1 继承了对象 person1 的属性和方法。
  • DevTools截图清晰地展示了对象层次结构是如何创建的。
  • 我们还为对象 person1 添加了方法 .toString(),对象 student1 继承了它。对象 student1 现在拥有自己的方法 .toString(),它遮蔽了从“Object”继承的 .toString() 方法。请查看截图,了解DevTools如何表示这一点

11 示例09:对象继承 - 类语法

11.1 示例代码

请看以下示例:

//Example 09
//Object Inheritance - Class syntax

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    toString() {
        return `Person ${this.name}, old ${this.age}`;
    };
}

class Student extends Person {
    constructor(course, name, age) {
        super(name, age);
        this.course = course;
    }
}

let student1 = new Student("Computers", "Mark", 21);

<!--
Output
Accessing object properties and methods:
student1.name:Mark
student1.course:Computers
student1.toString():Person Mark, old 21

Checking object class:
student1 instanceof Person:true
student1 instanceof Student:true

Accessing object prototype:
student1.__proto__.toString():Person undefined, old undefined
student1.__proto__.constructor.name:Student

Accessing object prototype's prototype:
student1.__proto__.__proto__.toString():Person undefined, old undefined
student1.__proto__.__proto__.constructor.name:Person

Accessing object prototype's prototype's prototype:
student1.__proto__.__proto__.__proto__.toString():[object Object]
student1.__proto__.__proto__.__proto__.constructor.name:Object

Checking if we are at the root of the inheritance:
student1.__proto__.__proto__.__proto__.__proto__:null
    -->    

执行屏幕截图:

11.2 DevTools 截图

这是执行期间Chrome DevTools的一些截图。

11.3 示例注释

  • 在此示例中,对象是使用“类语法”创建的。ECMA Script 2015 (ES6) 引入了类,它们是JavaScript现有基于原型的继承的语法糖。
  • 我们创建了两个类,类 Student 继承自类 Person。创建继承的关键表达式是“extends Person
  • 从代码执行可以看出,类 Student 的对象 student1 继承了类 Person 的属性和方法。
  • DevTools截图清晰地展示了对象层次结构是如何创建的。
  • 我们还为类 Person 添加了方法 .toString(),类 Student 的对象 student1 继承了它。对象 student1 现在拥有自己的方法 .toString(),它遮蔽了从“Object”继承的 .toString() 方法。请查看截图,了解DevTools如何表示这一点

12 结论

本文通过一系列示例展示了JavaScript中“原型继承”的工作原理。这绝不是对该主题的详尽阐述,对象可以通过不同的方式创建,关于该主题还有更多JavaScript内容需要涵盖。但是,本文的目标是专注于展示一些基本原则,并概述JavaScript原型继承的工作原理。

13 参考文献

14 历史记录

  • 2023年11月2日:初始版本
© . All rights reserved.