JavaScript 继承机制与原型链详解

作者:半夏

时间:2026-10-23

浏览:611

本文深入解析JavaScript基于原型的继承机制,详细阐述prototype、constructor与this关键字的工作原理。通过构造函数模拟类、原型链链接及继承实现中的常见问题,结合经典库实现方案,帮助开发者掌握JS面向对象编程的核心概念与最佳实践。

文章目录

面向对象与基于对象

绝大多数开发人员都具备面向对象语言(如C++、C#、Java)的开发经验。在传统面向对象范式中,类和实例是两个核心概念:类定义了一类事物的公共行为与方法,而实例则是类的具体实现。此外,封装、继承和多态被视为面向对象编程的三大支柱。

然而在JavaScript中,上述特性似乎并不直接存在。JavaScript并非纯粹的面向对象语言,而是一种基于对象的语言。其显著特征在于万物皆对象,包括字符串、数组、日期、数字,甚至函数也是对象。例如:

    // 定义一个函数 - add
function add(a, b) {
add.invokeTimes++;
return a + b;
}
// 因为函数本身也是对象,这里为函数add定义一个属性,用来记录此函数被调用的次数
add.invokeTimes = 0;

add(1 + 1);
add(2 + 3);
console.log(add.invokeTimes); // 2

 

模拟JavaScript中类和继承

在面向对象语言中,我们使用类来创建自定义对象。既然JavaScript中所有事物都是对象,那么该如何创建自定义对象呢?

这便引入了原型(prototype)的概念。我们可以将prototype视为一种模板,新创建的自定义对象都是该模板的一个拷贝(实际上并非拷贝,而是内部存在一个不可见的链接,给人以拷贝的错觉)。

以下示例展示了如何通过prototype创建自定义对象:

    // 构造函数
function Person(name, sex) {
this.name = name;
this.sex = sex;
}
// 定义Person的原型,原型中的属性可以被自定义对象引用
Person.prototype = {
getName: function() {
return this.name;
},
getSex: function() {
return this.sex;
}
}

在此例中,函数Person被称为构造函数,即用于创建自定义对象的函数。由此可见,JavaScript通过构造函数结合原型的方式,模拟实现了类的功能。创建自定义对象(即实例化类)的代码如下:

    var zhang = new Person("ZhangSan", "man");
console.log(zhang.getName()); // "ZhangSan"

var chun = new Person("ChunHua", "woman");
console.log(chun.getName()); // "ChunHua"

当执行代码var zhang = new Person("ZhangSan", "man")时,内部实际上执行了以下步骤:

  • 创建一个空白对象(new Object())。
  • 将Person.prototype中的属性(键值对)拷贝到该空对象中(尽管前文提到内部实现是隐藏链接,但此处表现为属性复制的效果)。
  • 将该对象通过this关键字传递给构造函数并执行构造函数。
  • 将该对象赋值给变量zhang。

 

为了证明prototype模板并非被拷贝到实例化对象中,而是采用链接方式,请参考以下代码:

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

var zhang = new Person("ZhangSan", "man");
console.log(zhang.age); // 20
// 覆盖prototype中的age属性
zhang.age = 19;
console.log(zhang.age); // 19
delete zhang.age;
// 在删除实例属性age后,此属性值又从prototype中获取
console.log(zhang.age); // 20

这种在JavaScript内部实现的隐藏prototype链接,是JavaScript运行的基础,也是模拟实现继承的关键所在。

 

如何在JavaScript中实现简单的继承?以下示例将创建一个雇员类Employee,它从Person继承了原型prototype中的所有属性。

    function Employee(name, sex, employeeID) {
this.name = name;
this.sex = sex;
this.employeeID = employeeID;
}
// 将Employee的原型指向Person的一个实例
// 因为Person的实例可以调用Person原型中的方法, 所以Employee的实例也可以调用Person原型中的所有属性。
Employee.prototype = new Person();
Employee.prototype.getEmployeeID = function() {
return this.employeeID;
};

var zhang = new Employee("ZhangSan", "man", "1234");
console.log(zhang.getName()); // "ZhangSan

 

上述关于继承的实现较为粗糙,且存在诸多问题:

  • 在创建Employee构造函数和原型(后文简称类)时,便对Person进行了实例化,这种做法并不恰当。
  • Employee的构造函数无法调用父类Person的构造函数,导致在Employee构造函数中对name和sex属性进行重复赋值。
  • Employee中的函数会覆盖Person中的同名函数,缺乏重载机制(此问题与前一条性质相同)。
  • 创建JavaScript类的语法过于零散,不如C#/Java中的语法优雅。
  • 实现中存在constructor属性指向错误的问题,这将在第二篇文章中讨论。

我们将在第三章完善这个例子。

 

JavaScript继承的实现

鉴于JavaScript本身缺乏完整的类和继承实现,且手工实现方式存在诸多缺陷,网上已涌现出多种解决方案:

  • Douglas Crockford - Prototypal Inheritance in JavaScript
  • Douglas Crockford - Classical Inheritance in JavaScript
  • John Resig - Simple JavaScript Inheritance
  • Dean Edwards - A Base Class for JavaScript Inheritance
  • Prototype
  • Mootools
  • Extjs

本系列文章将逐一深入分析这些实现,最终旨在深入理解JavaScript中类和继承的实现方式。

 

下一章我们将介绍类实现中的相关知识,包括this、constructor、prototype等。


本章将重点介绍JavaScript中几个关键属性(this、constructor、prototype),这些属性对于理解JavaScript类和继承的实现至关重要。

this

this表示当前对象。若在全局作用域使用this,则指代当前页面对象window;若在函数中使用this,其指代对象取决于运行时该函数被调用的上下文。我们还可以使用apply和call两个全局方法来改变函数中this的具体指向。

首先看一个在全局作用域使用this的示例:

  <script type="text/javascript">
   console.log(this === window); // true
   console.log(window.alert === this.alert); // true
   console.log(this.parseInt("021", 10)); // 10
  </script>
  

 

函数中的this是在运行时决定的,而非函数定义时,如下所示:

  // 定义一个全局函数
  function foo() {
   console.log(this.fruit);
  }
  // 定义一个全局变量,等价于window.fruit = "apple";
  var fruit = "apple";
  // 此时函数foo中this指向window对象
  // 这种调用方式和window.foo();是完全等价的
  foo(); // "apple"

  // 自定义一个对象,并将此对象的属性foo指向全局函数foo
  var pack = {
   fruit: "orange",
   foo: foo
  };
  // 此时函数foo中this指向window.pack对象
  pack.foo(); // "orange"
  

 

全局函数apply和call可用于改变函数中this的指向,示例如下:

  // 定义一个全局函数
  function foo() {
   console.log(this.fruit);
  }
  
  // 定义一个全局变量
  var fruit = "apple";
  // 自定义一个对象
  var pack = {
   fruit: "orange"
  };
  
  // 等价于window.foo();
  foo.apply(window); // "apple"
  // 此时foo中的this === pack
  foo.apply(pack); // "orange"
  

注:apply和call两个函数的作用相同,唯一区别在于参数定义不同。

 

由于在JavaScript中函数也是对象,因此可以看到如下有趣的示例:

  // 定义一个全局函数
  function foo() {
   if (this === window) {
    console.log("this is window.");
   }
  }
  
  // 函数foo也是对象,所以可以定义foo的属性boo为一个函数
  foo.boo = function() {
   if (this === foo) {
    console.log("this is foo.");
   } else if (this === window) {
    console.log("this is window.");
   }
  };
  // 等价于window.foo();
  foo(); // this is window.
  
  // 可以看到函数中this的指向调用函数的对象
  foo.boo(); // this is foo.
  
  // 使用apply改变函数中this的指向
  foo.boo.apply(window); // this is window.
  

 

prototype

我们在第一章中已使用prototype模拟类和继承的实现。prototype本质上仍是一个JavaScript对象,且每个函数都有一个默认的prototype属性。若该函数用于创建自定义对象的场景,则称该函数为构造函数。例如以下简单场景:

  // 构造函数
  function Person(name) {
   this.name = name;
  }
  // 定义Person的原型,原型中的属性可以被自定义对象引用
  Person.prototype = {
   getName: function() {
    return this.name;
   }
  }
  var zhang = new Person("ZhangSan");
  console.log(zhang.getName()); // "ZhangSan"
  

作为类比,我们考虑JavaScript中的数据类型——字符串(String)、数字(Number)、数组(Array)、对象(Object)、日期(Date)等。我们有理由相信,在JavaScript内部,这些类型均作为构造函数来实现,例如:

  // 定义数组的构造函数,作为JavaScript的一种预定义类型
  function Array() {
   // ...
  }
  
  // 初始化数组的实例
  var arr1 = new Array(1, 56, 34, 12);
  // 但是,我们更倾向于如下的语法定义:
  var arr2 = [1, 56, 34, 12];
  

同时,对数组操作的许多方法(如concat、join、push)也应在prototype属性中定义。


实际上,JavaScript所有固有数据类型都具有只读的prototype属性(这是合理的:若修改这些类型的prototype属性,预定义的方法将消失),但我们可以向其中添加自己的扩展方法。

  // 向JavaScript固有类型Array扩展一个获取最小值的方法
  Array.prototype.min = function() {
   var min = this[0];
   for (var i = 1; i < this.length; i++) {
    if (this[i] < min) {
     min = this[i];
    }
   }
   return min;
  };
  
  // 在任意Array的实例上调用min方法
  console.log([1, 56, 34, 12].min()); // 1
  

注意:这里有一个陷阱,向Array的原型中添加扩展方法后,当使用for-in循环数组时,该扩展方法也会被循环出来。


以下代码说明了这一点(假设已向Array的原型中扩展了min方法):

  var arr = [1, 56, 34, 12];
  var total = 0;
  for (var i in arr) {
   total += parseInt(arr[i], 10);
  }
  console.log(total); // NaN
  
  

解决方法也很简单:

  var arr = [1, 56, 34, 12];
  var total = 0;
  for (var i in arr) {
   if (arr.hasOwnProperty(i)) {
    total += parseInt(arr[i], 10);
   }
  }
  console.log(total); // 103
  

 

constructor

constructor始终指向创建当前对象的构造函数。例如以下示例:

  // 等价于 var foo = new Array(1, 56, 34, 12);
  var arr = [1, 56, 34, 12];
  console.log(arr.constructor === Array); // true
  // 等价于 var foo = new Function();
  var Foo = function() { };
  console.log(Foo.constructor === Function); // true
  // 由构造函数实例化一个obj对象
  var obj = new Foo();
  console.log(obj.constructor === Foo); // true
  
  // 将上面两段代码合起来,就得到下面的结论
  console.log(obj.constructor.constructor === Function); // true
  

 

但当constructor遇到prototype时,有趣的事情便发生了。我们知道每个函数都有一个默认的属性prototype,而该prototype的constructor默认指向该函数。如下例所示:

  function Person(name) {
   this.name = name;
  };
  Person.prototype.getName = function() {
   return this.name;
  };
  var p = new Person("ZhangSan");
  
  console.log(p.constructor === Person); // true
  console.log(Person.prototype.constructor === Person); // true
  // 将上两行代码合并就得到如下结果
  console.log(p.constructor.prototype.constructor === Person); // true
  

然而,当我们重新定义函数的prototype时(注意:此处是覆盖而非修改),constructor的行为便显得奇怪,如下示例:

  function Person(name) {
   this.name = name;
  };
  Person.prototype = {
   getName: function() {
    return this.name;
   }
  };
  var p = new Person("ZhangSan");
  console.log(p.constructor === Person); // false
  console.log(Person.prototype.constructor === Person); // false
  console.log(p.constructor.prototype.constructor === Person); // false
  

这是为什么呢?


原因在于,覆盖Person.prototype时,等价于执行以下代码操作:

  Person.prototype = new Object({
   getName: function() {
    return this.name;
   }
  });
  

而constructor始终指向创建自身的构造函数,因此此时Person.prototype.constructor === Object,即:

  function Person(name) {
   this.name = name;
  };
  Person.prototype = {
   getName: function() {
    return this.name;
   }
  };
  var p = new Person("ZhangSan");
  console.log(p.constructor === Object); // true
  console.log(Person.prototype.constructor === Object); // true
  console.log(p.constructor.prototype.constructor === Object); // true
  

如何修正这种问题?方法也很简单,重新覆盖Person.prototype.constructor即可:

  function Person(name) {
   this.name = name;
  };
  Person.prototype = new Object({
   getName: function() {
    return this.name;
   }
  });
  Person.prototype.constructor = Person;
  var p = new Person("ZhangSan");
  console.log(p.constructor === Person); // true
  console.log(Person.prototype.constructor === Person); // true
  console.log(p.constructor.prototype.constructor === Person); // true
  

 


下一章我们将对第一章提到的Person-Employee类和继承的实现进行完善。

posted @

2009-07-08 16:30

三生石上 阅读(512) |

评论 (1) | 编辑
JavaScript继承详解(一)

面向对象与基于对象

绝大多数开发人员都具备面向对象语言(如C++、C#、Java)的开发经验。在传统面向对象范式中,类和实例是两个核心概念:类定义了一类事物的公共行为与方法,而实例则是类的具体实现。此外,封装、继承和多态被视为面向对象编程的三大支柱。

然而在JavaScript中,上述特性似乎并不直接存在。JavaScript并非纯粹的面向对象语言,而是一种基于对象的语言。其显著特征在于万物皆对象,包括字符串、数组、日期、数字,甚至函数也是对象。例如:

  // 定义一个函数 - add
function add(a, b) {
add.invokeTimes++;
return a + b;
}
// 因为函数本身也是对象,这里为函数add定义一个属性,用来记录此函数被调用的次数
add.invokeTimes = 0;

add(1 + 1);
add(2 + 3);
console.log(add.invokeTimes); // 2

 

模拟JavaScript中类和继承

在面向对象语言中,我们使用类来创建自定义对象。既然JavaScript中所有事物都是对象,那么该如何创建自定义对象呢?

这便引入了原型(prototype)的概念。我们可以将prototype视为一种模板,新创建的自定义对象都是该模板的一个拷贝(实际上并非拷贝,而是内部存在一个不可见的链接,给人以拷贝的错觉)。

以下示例展示了如何通过prototype创建自定义对象:

  // 构造函数
function Person(name, sex) {
this.name = name;
this.sex = sex;
}
// 定义Person的原型,原型中的属性可以被自定义对象引用
Person.prototype = {
getName: function() {
return this.name;
},
getSex: function() {
return this.sex;
}
}

在此例中,函数Person被称为构造函数,即用于创建自定义对象的函数。由此可见,JavaScript通过构造函数结合原型的方式,模拟实现了类的功能。创建自定义对象(即实例化类)的代码如下:

  var zhang = new Person("ZhangSan", "man");
console.log(zhang.getName()); // "ZhangSan"

var chun = new Person("ChunHua", "woman");
console.log(chun.getName()); // "ChunHua"

当执行代码var zhang = new Person("ZhangSan", "man")时,内部实际上执行了以下步骤:

  • 创建一个空白对象(new Object())。
  • 将Person.prototype中的属性(键值对)拷贝到该空对象中(尽管前文提到内部实现是隐藏链接,但此处表现为属性复制的效果)。
  • 将该对象通过this关键字传递给构造函数并执行构造函数。
  • 将该对象赋值给变量zhang。

 

为了证明prototype模板并非被拷贝到实例化对象中,而是采用链接方式,请参考以下代码:

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

var zhang = new Person("ZhangSan", "man");
console.log(zhang.age); // 20
// 覆盖prototype中的age属性
zhang.age = 19;
console.log(zhang.age); // 19
delete zhang.age;
// 在删除实例属性age后,此属性值又从prototype中获取
console.log(zhang.age); // 20

这种在JavaScript内部实现的隐藏prototype链接,是JavaScript运行的基础,也是模拟实现继承的关键所在。

 

如何在JavaScript中实现简单的继承?以下示例将创建一个雇员类Employee,它从Person继承了原型prototype中的所有属性。

  function Employee(name, sex, employeeID) {
this.name = name;
this.sex = sex;
this.employeeID = employeeID;
}
// 将Employee的原型指向Person的一个实例
// 因为Person的实例可以调用Person原型中的方法, 所以Employee的实例也可以调用Person原型中的所有属性。
Employee.prototype = new Person();
Employee.prototype.getEmployeeID = function() {
return this.employeeID;
};

var zhang = new Employee("ZhangSan", "man", "1234");
console.log(zhang.getName()); // "ZhangSan

 

上述关于继承的实现较为粗糙,且存在诸多问题:

  • 在创建Employee构造函数和原型(后文简称类)时,便对Person进行了实例化,这种做法并不恰当。
  • Employee的构造函数无法调用父类Person的构造函数,导致在Employee构造函数中对name和sex属性进行重复赋值。
  • Employee中的函数会覆盖Person中的同名函数,缺乏重载机制(此问题与前一条性质相同)。
  • 创建JavaScript类的语法过于零散,不如C#/Java中的语法优雅。
  • 实现中存在constructor属性指向错误的问题,这将在第二篇文章中讨论。

我们将在第三章完善这个例子。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章