JavaScript类式继承与原型式继承:实现原理与核心差异解析

作者:青柠时间:2026-10-22 03:25:13浏览:362

本文深入解析JavaScript中类式继承与原型式继承的实现机制。类式继承通过实例化父类实现,虽能隔离原型但存在构造函数重复调用导致的内存开销;原型式继承利用原型链共享引用,节省内存但需注意引用类型的修改陷阱。文章对比了两种方式的代码实现、性能表现及适用场景,帮助开发者根据实际需求选择最优继承方案。

JavaScript继承概述

在面向对象编程体系中,继承是构建代码复用与扩展的核心机制。设计类时,开发者通常致力于减少冗余代码并降低对象间的耦合度,因为直接的类继承关系往往会导致模块间产生强依赖。解耦是软件设计中的重要原则,本文将聚焦于JavaScript语言中如何实现继承这一关键问题。

多数面向对象编程语言提供了显式的继承关键字(如extend或inherit)来简化继承过程。然而,JavaScript并未内置此类语法机制,若需实现类间的继承关系,开发者必须通过具体的编码逻辑手动构建继承链。

类式继承的实现与优化

一、类式继承的实现

1、创建一个类的方式:


//定义类的构造函数
function Person(name) {
  this.name = name || '默认姓名';
}
//定义该类所有实例的公共方法
Person.prototype.getName = function() {
  return this.name;
}
var smith = new Person('Smith');
var jacky = new Person('Jacky');
console.log( smith.getName(), jacky.getName() ); //Smith Jacky

2、继承这个类:

这需分两步实现:第1步继承父类构造函数定义的属性,第2步继承父类的prototype属性


//定义类的构造函数
function Person(name) {
  this.name = name || '默认姓名';
}
//定义该类所有实例的公共方法
Person.prototype.getName = function() {
  return this.name;
}
function Author(name, books) {
  //继承父类构造函数中定义的属性
  //通过改变父类构造函数的执行上下文来继承
  Person.call(this, name);
  this.books = books;
}
//继承父类对应的方法
Author.prototype = new Person(); //Author.prototype.constructor === Person
Author.prototype.constructor = Author; //修正修改原型链时造成的constructor丢失
Author.prototype.getBooks = function() {
  return this.books;
};
//测试
var smith = new Person('Smith');
var jacky = new Author('Jacky', ['BookA', 'BookB']);
console.log(smith.getName()); //Smith
console.log(jacky.getName()); //Jacky
console.log(jacky.getBooks().join(', ')); //BookA, BookB
console.log(smith.getBooks().join(', ')); //Uncaught TypeError: smith.getBooks is not a function

测试结果表明,Author类成功继承了Person类,且修改Author的原型不会干扰Person的原型。关键在于语句Author.prototype = new Person(),这与Author.prototype = Person.prototype有本质区别。前者创建了一个Person实例,该实例拥有Person.prototype的副本(此处暂作此理解,后续有详细解析);后者则使两者的prototype属性指向同一原型对象。

这意味着每次继承操作都会生成父类的一个副本,必然带来内存消耗。尽管类式继承需要支付这一开销,但仍可优化:语句Author.prototype = new Person()实际上多执行了一次构造函数(而该调用本应在子类构造函数中执行即可),当父类构造函数逻辑复杂时,这会显著增加耗时与内存占用。优化后的继承方式如下:


Author.prototype = (function() {
  function F() {}
  F.prototype = Person.prototype;
  return new F();
})();

如上代码所示,在new操作中移除对父类构造函数的直接调用,从而节省了一次函数调用的开销。

3、类式继承的一个显著特征是,每次实例化子类对象时,都会执行一次父类的构造函数。假设E继承D,D继承C,C继承B,B继承A,在实例化一个E对象时,构造函数总共会被调用几次?


/*继承方法的函数*/
function extend(son, father) {
  function F() {}
  F.prototype = father.prototype;
  son.prototype = new F();
  son.prototype.constructor = son;
}
//A类
function A() {
  console.log('A()');
}
A.prototype.hello = function() {
  console.log('Hello, world.');
}
//B类
function B() {
  A.call(this);
  console.log('B()');
}
extend(B, A);
//C类
function C() {
  B.call(this);
  console.log('C()');
}
extend(C, B);
//D类
function D() {
  C.call(this);
  console.log('D()');
}
extend(D, C);
//E类
function E() {
  D.call(this);
  console.log('E()');
}
extend(E, D);
//创建一个E的实例
var e = new E(); //A() B() C() D() E()
e.hello(); //hello, world.

答案是5次,这仅是实例化单个E对象时的调用次数。因此,应尽可能减少继承层级。但这并不意味着禁止使用类式继承,而是应根据具体应用场景权衡选择。

原型式继承机制解析

二、原型式继承

1、首先观察一段代码:我们将类式继承中继承prototype的部分替换为一个名为clone的函数,通过字面量定义Person对象,并使Author成为Person的克隆体。


//这个函数可以理解为克隆一个对象
function clone(object) {
  function F() {}
  F.prototype = object;
  return new F();
}
var Person = {
  name: 'Default Name';
  getName: function() {
    return this.name;
  }
}
//接下来让Author变为Person的克隆体
var Author = clone(Person);

这里有一个关键问题:clone函数中的new F()是否为该实例开辟了新的内存空间来存储object的副本?

基于之前的理解,答案似乎是肯定的。然而,继续编写后续代码时,出现了意想不到的现象,代码如下:


//接下来让Author变为Person的克隆体
var Author = clone(Person);
Author.books = [];
Author.getBooks = function() {
  return this.books.join(', ');
}
//增加一个作者Smith
var Smith = clone(Author);
console.log(Smith.getName(), Smith.getBooks()); //Default Name
Smith.name = 'Smith';
Smith.books.push('<<Book A>>', '<<Book B>>'); //作者写了两本书
console.log(Smith.getName(), Smith.getBooks()); //Smith <<Book A>>, <<Book B>>
//再增加一个作者Jacky
var Jacky = clone(Author);
console.log(Jacky.getName(), Jacky.getBooks()); // Default Name <<Book A>>, <<Book B>>

当我们继续添加作者Jacky时,怪异现象再次出现:Jacky的名字仍为Default Name,但他竟然拥有与Smith相同的两本书?尽管Jacky的书列表尚未执行push操作。此时联想到引用对象的概念(引用对象时,实际引用的是内存地址),问题很可能出在clone()函数中的new F()上。

事实上,clone中的new F()确实返回了一个新对象,该对象拥有被克隆对象的所有属性。但这些属性保留的是对被克隆对象中相应属性的引用,而非属性的副本。换言之,新对象的属性与被克隆对象的属性指向同一内存地址(理解C语言指针的同学对此应有直观认识,此处意义相似)。

那么,为何前例中Jacky的书与Smith相同,而Jacky的名字却不是Smith而是Default Name?这揭示了JavaScript继承机制的核心:当Smith继承自Author时,其属性保留了对Author属性的引用。一旦对Smith的属性进行显式重新赋值,JavaScript引擎便会为Smith的该属性分配新的内存空间以存储新值。由于内存地址重新划分,对Smith.name的修改便不会影响Author.name。这完美解释了为何Jacky的名字保持为Default Name而非Smith。

2、基于原型继承

通过前述分析可知,基于原型继承的方式更能节约内存(仅在需要时开辟新内存空间)。但需注意:在基于原型继承中,对象的属性必须在重新赋值(即重新划分内存)后,再去引用该属性。对于对象的方法,若需不同处理逻辑,只需重新定义即可。

下面提供一段完整且正确的代码范例,以说明原型继承的特点及使用方式:


//这个函数可以理解为克隆一个对象
function clone(object) {
  function F() {}
  F.prototype = object;
  return new F();
}
var Person = {
  name: 'Default Name',
  getName: function() {
    return this.name;
  }
}
//接下来让Author变为Person的克隆体
var Author = clone(Person);
Author.books = [];
Author.getBooks = function() {
  return this.books.join(', ');
}
//增加一个作者Smith
var Smith = clone(Author);
Smith.name = 'Smith';
Smith.books = [];
Smith.books.push('<<Book A>>', '<<Book B>>'); //作者写了两本书
console.log(Smith.getName(), Smith.getBooks()); //Smith <<Book A>>, <<Book B>>
//再增加一个作者Jacky
var Jacky = clone(Author);
Jacky.name = 'Jacky';
Jacky.books = [];
Jacky.books.push('<<Book C>>', '<<Book D>>');
console.log(Jacky.getName(), Jacky.getBooks()); // Jacky <<Book C>>, <<Book D>>

两种继承方式对比总结

三、类式继承与原型式继承的区别与相似之处

1、类式继承中:使用构造函数初始化对象属性,通过调用父类构造函数来继承这些属性。通过new 父类的prototype来继承方法。

2、原型式继承中:去掉了构造函数,但需要将对象的属性和方法写在{}中进行声明。准确地说,原型式继承本质上是类式继承中继承父类prototype方法的部分。

以上介绍了JavaScript中类式继承和原型式继承的实现方法和区别,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对本站网站的支持!

作者最新文章