JavaScript – 原型继承 – 图文详解
关于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。我们演示了三种不同的对象创建方式:
- 字面量语法
- 构造函数语法
- 类语法
这些不是创建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中都可以看出,对象
person1
和person2
被分配了一个原型对象“Object
”。 请注意DevTools中的属性[[Prototype]] Object
。 - 尽管DevTools截图可能不明显,但这两个对象现在拥有相同的原型
OBJECT INSTANCE “Object”
。这对于来自C++、Java、C#等经典的“类继承”语言的程序员来说可能有点奇怪,需要花点心思去适应新的范例。在“类继承”中,每个对象在构造时都会获得自己的基对象实例,而现在情况并非如此。 - 所以,它们确实有一些共同点,那就是都继承自“
Object
”。因此,两者都从“Object
”继承了相同的属性和方法。 - 我们为每个对象添加了一些属性。此外,我们还通过表达式 `
person1.__proto__.country = "Serbia
"` 稍微操作了它们的通用基原型,为其添加了一个属性。 - 当执行时,该表达式会将一个属性添加到“
Object
”对象,因为在这种情况下它是原型对象。在实践中,向“Object
”添加属性是绝对不应该做的,因为它会向该JavaScript环境中的所有对象添加属性。我们只是想说明这是可能的,并且“Object
”在JavaScript中只是另一个对象,与其他任何对象一样。此外,这表明对象person1
和person2
都继承自同一个“Object
”对象实例。因此,对象person1
和person2
现在都可以访问该属性,正如从执行结果中所见。 - 我们还为对象
person1
和person2
添加了方法.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"
’稍微操作了它们的通用基原型,为其添加了一个属性。 - 当执行时,该表达式会将一个属性添加到原型链中的第一个对象,该对象充当所有使用该构造函数创建的对象的原型。此外,这表明对象
person1
和person2
都继承自相同的“Object”实例。因此,对象person1
和person2
现在都可以访问该属性,正如从执行结果中所见。 - 我们还在构造函数中添加了方法
.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"
’稍微操作了它们的通用基原型,为其添加了一个属性。 - 当执行时,该表达式会将一个属性添加到原型链中的第一个对象,该对象充当所有使用该构造函数创建的对象的原型。此外,这表明对象
person1
和person2
都继承自同一个对象实例。因此,对象person1
和person2
现在都可以访问该属性,正如从执行结果中所见。 - 我们还在构造函数中添加了方法
.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 参考文献
- [1] https://en.wikipedia.org/wiki/Prototype-based_programming
- [2] https://en.wikipedia.org/wiki/Class-based_programming
- [3] https://javascript.js.cn/
- [4] David Flanagan, JavaScript: The Definitive Guide, Seventh Edition, O’Reilly 2020
14 历史记录
- 2023年11月2日:初始版本