JavaScript 继承的本质:为什么需要继承以及原型继承的真相
本文深入探讨 JavaScript 中继承的本质,对比传统面向对象语言的类继承与 JavaScript 的原型继承。通过分析“天马”案例,阐明继承用于解决类型包含关系而非方法复用,并解释为何在弱类型的 JavaScript 中组合模式往往优于继承。同时梳理了从经典库实现到 ECMAScript 6 语法糖的演变,揭示原型继承解决的是从属关系。
JavaScript 真的需要类(Class)么?
JavaScript 继承的本质:为什么需要继承以及原型继承的真相
首先,我们回顾一下具有类(Class)概念的面向对象语言(例如 Java)的一些基本特性。
父类与子类
父类与子类的包含关系
父类(Superclass)与子类(Subclass)的设计初衷,并非为了解决现实世界中“父亲与儿子”的亲属关系,而是为了解决类之间的包含关系。我们用 Sub 表示“子类”,Sup 表示“父类”,其逻辑关系为:
Sub 是 Sup 的子集
这种区别显而易见:通常情况下,我们可以将子类实例当作父类实例来使用,但在现实生活中,我们绝不会把儿子当作父亲来对待。
换言之,父类和子类的存在,并不是为了解决类之间拥有相同方法或属性的问题。
举个例子
继承设计的局限性:以动物为例
有些人可能会尝试以下做法:
我们需要定义一些动物类,以便在屏幕上创建会移动的动物。然而,这些动物的移动方式各异:有些在空中飞行,有些在地面行走。
因此,我们创建了两个父类:一个是 Fly(飞),一个是 Walk(走):
代码如下:
class Fly {
constructor() {}
}
class Walk {
constructor() {}
} 接着,狮子(以及其它在地面行走的动物)归属于 Walk 类,老鹰(以及其它在天空飞行的动物)归属于 Fly 类:
代码如下:
class Lion extends Walk {
}
class Eagle extends Fly {
} 最后,我们创建 Lion 和 Eagle 类的实例,并调用相应的方法,屏幕上就会显示出狮子和老鹰在移动的效果。
但这可能并非最佳设计。例如,明天老板突然提出需求:需要一种名为天马(Pegasus)的动物,它既能飞行又能行走,时而需要飞行,时而需要行走。
在这种情况下,上述方案将完全失效。
为什么这个设计失败了?
继承是有前提条件的:子类必须能够严格地向上转型(即转换为父类)。
在上述示例中:
我们将狮子(Lion)等同于行走动物(Walk),将老鹰(Eagle)等同于飞行动物(Fly)。
这看起来非常成功,因为子类确实能严格向上转型,但其中隐藏着隐患。
当引入天马(Pegasus)时,我们才意识到:狮子只是“会行走的动物”,老鹰只是“会飞行的动物”。这并不意味着动物一生只能选择飞行或行走,因此既会飞行又会行走的天马便找不到合适的归属。
这个例子有力地证明了,子类和父类的设计目的并非为了解决类间具有相同方法的问题:
一些动物都会行走,需要拥有行走(Walk)这一方法,但这不应通过子类和父类的继承关系来实现。
组合
组合模式:解决方法复用的更优方案
我们可以采用以下方式解决这个问题:
代码如下:
class Lion {
walker = new Walk();
walk() {
return this.walker.walk();
}
}
class Eagle {
flyer = new Fly();
fly() {
return this.flyer.fly();
}
}
class Pegasus {
walker = new Walk();
flyer = new Fly();
walk() {
return this.walker.walk();
}
fly() {
return this.flyer.fly();
}
} 组合(Composition)是指在新类内部创建原有类的对象。因此,组合才是为了解决类间具有相同方法的问题。在这个例子中:
Walk 被视作“会行走的动物应具备的方法集合”,同理 Fly 被视作“会飞行的动物应具备的方法集合”。对于天马(Pegasus),我们只需对 Walk 和 Fly 进行组合即可。
继承的目的
继承的目的与 JavaScript 的弱类型特性
继承并非代码复用的唯一途径,但继承具有其优势:
子类可以向上转型为父类。
这样我们就可以忽略所有子类的差异,将它们视为相同的类进行操作。例如:
我们有方法 fn(A) 和 fn(B),这两个方法实际上相似,我们希望复用它们。
此时,我们可以设立一个父类 C,其中 A 是 C 的子类,B 也是 C 的子类,那么 fn(C) 就可以复用在 A 和 B 身上了。
回到 JavaScript
但回到 JavaScript,我们发现上述例子并不成立。
因为 JavaScript 是弱类型语言,它在执行操作前(由于无需编译)并不关注操作对象的类型是什么。它只在乎执行是否成功,或者是否发生错误。
在这种情况下,继承显得并不必要。同样地,类(Class)的概念也显得多余。
I have been writing JavaScript for 8 years now, and I have never once found need to use an uber function. The super idea is fairly important in the classical pattern, but it appears to be unnecessary in the prototypal and functional patterns. I now see my early attempts to support the classical model in JavaScript as a mistake.
——Douglas Crockford
我编写 JavaScript 代码已经 8 年了,但我从未发现需要使用超类函数(uber function)。超类的概念在经典设计模式中非常重要,但在以原型和函数为基调的模式中并不必要。我现在认为,早期我试图让 JavaScript 支持经典模式是一个错误的决定。
安全环境
当然,你可以手动判断类型,控制参数类型,从而提供一个相对安全的环境。
例如,同为弱类型脚本语言的 PHP,为了模拟强类型面向对象语言的安全环境,不得不这样做:
代码如下:
class ShopProductWriter {
public function write($shopProduct) {
if (!( $shopProduct instanceof CdProduct ) && !( $shopProduct instanceof BookProduct )) {
die("输入错误的类型");
}
// 如果类型正确就执行一些代码
}
} ——PHP Objects, Patterns, and Practice Third Edition . Matt Zandstra
但这仅仅是一个非常丑陋的方案。
经典继承语法糖实现
经典继承语法糖的演变与 ECMAScript 6
不过,经典继承依然是许多人喜欢的方式。因此 YUI、Prototype、Dojo、MooTools 等库都提供了各自的实现方案。
其中较为常见的方案,语法大致如下:
代码如下:
var Person = Class.extend({
init: function(isDancing) {
this.dancing = isDancing;
}
});
var Dancer = Person.extend({
init: function() {
this._super(true);
}
});
var n = new Dancer();
alert(n.dancing); //true 最重要的实现是对 this._super 的处理。其实 extend 函数只是将传入的对象重新组装,变成一个原型对象,并放入新构造函数的 prototype 中。
具体实现请查看参考文献 1。
ECMAScript 6 的经典继承语法糖
由于各库对类的实现方式不同,导致经典继承语法繁多,ECMA 组织对此似乎不太满意。他们试图在 ECMAScript 6 中加入更直观的经典继承语法糖:
代码如下:
class Animal {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}
class Dog extends Animal {
constructor(name) {
super(name);
}
bark() {
console.log("Woof!");
}
} 总结
实际上,JavaScript 中的经典继承并非必不可少。
然而,鉴于许多人喜欢经典继承模型,新版 ECMAScript 6 提供了相关的语法糖。
不过,在中国,前端开发者要广泛使用该语法糖恐怕还是一个遥远的故事……
Quiz2
JavaScript 特有的原型继承
那么,JavaScript 特有的继承方式是怎样的呢?
原型继承
原型继承并非为了解决经典继承中的集合包含关系。实际上,原型继承解决的是从属关系,用数学表达即为:
Sub.prototype ∈ Sup
子级构造函数(子类型)的原型是父级构造函数(父类型)构建的一个实例对象。原型实际上是子类型实例中需要共享的部分:
代码如下:
function Being() {
this.living = true;
}
Being.prototype.walk = function() {
alert("I' m walking");
};
function Dancer() {
this.dancing = true;
}
Dancer.prototype = new Being();
Dancer.prototype.dance = function() {
alert("I'm dancing");
};
var one = new Dancer();
one.walk();
one.dance(); 利用借用、寄生等技术可以产生多种不同的继承效果,但这些技术仅旨在解决原型继承中属性和方法的公用与非公用问题。由于篇幅限制,此处不再展开,有兴趣者可参考《JavaScript 高级程序设计》相关内容。
思考题
1. 文章开头关于天马(Pegasus)的题目,如果在 JavaScript 上应如何设计?例如我们有下列两个类型:
代码如下:
function Walk() {
this.walk = function() {
//walk
};
}
function Fly() {
this.fly = function() {
//fly
};
} JS动态构建复杂嵌套JSON对象的方法
2026-11-05 15:41:52
JS负数取余为什么是负数?详解余数与模运算区别
2026-11-05 13:34:41
JS 解压出现乱码或报错怎么解决?
2026-11-05 12:32:38
JS 中 startsWith、indexOf 与正则判断字符串前缀的对比
2026-11-04 22:19:43
JS解析Excel后怎么将数据转换为JSON格式
2026-11-04 20:58:28
