前言
不管是看些博客,文章还是啥,经常会看到"组合优于继承"这句话,心里想的是:啥玩意儿?继承多好用啊,extends一下就完事了,还能复用代码,多香。也没去详细想过相关的问题。
直到有一天,我接手了一个"祖传"项目,看到了这样的代码:
javascript
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {}
class GuideDog extends Dog {}
class ServiceDog extends GuideDog {}
class TherapyDog extends ServiceDog {}
// 我:???这是什么套娃现场
然后产品经理说:我们要加个会飞的狗。
我:......
这时候才明白,原来继承不是万能的。今天就来聊聊为什么"组合优于继承",以及什么时候该用继承,什么时候该用组合。
继承的问题到底在哪
问题1:类层次太脆弱
继承最大的问题是:你的子类和父类绑死了。
javascript
class Bird {
constructor(name) {
this.name = name;
}
fly() {
return `${this.name} is flying`;
}
layEggs() {
return `${this.name} laid eggs`;
}
}
class Penguin extends Bird {
// 问题来了:企鹅不会飞
fly() {
throw new Error('Penguins cannot fly!');
}
}
const penguin = new Penguin('波波');
penguin.fly(); // 💥 抛错了
你看,企鹅确实是鸟,但它不会飞。如果用继承,要么:
- 在子类里覆盖方法抛错(但这违反了里氏替换原则)
- 把父类拆成更细的类(但这又导致类爆炸)
这就是所谓的"脆弱基类问题"(Fragile Base Class Problem)。父类改一点东西,所有子类都可能挂。
问题2:多继承的困境
JavaScript只支持单继承,但现实世界很多东西需要多种能力:
javascript
// 我想要一个会飞又会游泳的鸭子
class Duck extends Bird, Fish { // ❌ 语法错误,JS不支持多继承
// ...
}
有人说,那我用Mixin啊:
javascript
class Duck extends Bird {}
Object.assign(Duck.prototype, Swimmable, Diveable);
但这又引入了新问题:
- Mixin的方法可能冲突
- 没有类型检查
- 原型链变得混乱
问题3:逻辑散落在继承链中
javascript
class Animal {
constructor(name) {
this.name = name;
}
eat() {
this.energy += 10; // 等等,energy在哪定义的?
}
}
class Mammal extends Animal {
constructor(name) {
super(name);
this.energy = 100; // 原来在这
}
}
class Dog extends Mammal {
bark() {
if (this.energy < 20) { // 要理解这个逻辑,得翻三层继承链
return 'Too tired to bark';
}
this.energy -= 5;
return 'Woof!';
}
}
要理解Dog.bark()的完整逻辑,你得:
- 看Dog类的bark方法
- 往上翻Mammal的构造函数(找energy定义)
- 再往上翻Animal的eat方法(看energy怎么变化)
这就是"知识散落"问题。继承链一长,代码就像侦探小说,到处找线索。
组合是什么
组合的核心思想很简单:把功能拆成独立的小块,需要什么就组装什么。
就像搭乐高:
- 继承:买了个"城堡套装",想改成"太空站",得把塔楼锯掉,很麻烦
- 组合:买了一堆积木,想搭啥搭啥
最简单的组合例子
javascript
// 把能力定义成独立的对象
const canFly = {
fly() {
return `${this.name} is flying at ${this.flySpeed} km/h`;
}
};
const canSwim = {
swim() {
return `${this.name} is swimming at ${this.swimSpeed} km/h`;
}
};
const canWalk = {
walk() {
return `${this.name} is walking at ${this.walkSpeed} km/h`;
}
};
// 鸭子:会飞、会游、会走
function createDuck(name) {
const duck = {
name,
flySpeed: 50,
swimSpeed: 5,
walkSpeed: 3
};
return Object.assign(duck, canFly, canSwim, canWalk);
}
// 企鹅:只会游、会走
function createPenguin(name) {
const penguin = {
name,
swimSpeed: 30,
walkSpeed: 2
};
return Object.assign(penguin, canSwim, canWalk);
}
const duck = createDuck('唐老鸭');
console.log(duck.fly()); // ✅ 唐老鸭 is flying at 50 km/h
console.log(duck.swim()); // ✅ 唐老鸭 is swimming at 5 km/h
const penguin = createPenguin('波波');
console.log(penguin.swim()); // ✅ 波波 is swimming at 30 km/h
console.log(penguin.fly); // ✅ undefined - 企鹅本来就不会飞
看到区别了吗?
- 用继承:企鹅继承了Bird,但得覆盖fly方法抛错
- 用组合:企鹅压根不组装fly能力,自然就不会飞
组合的几种实现方式
方式1:工厂函数 + Object.assign
这是最简单的方式,上面已经展示过了。优点是简单直接,缺点是没有类型提示。
方式2:类组合(Class Composition)
如果你喜欢class语法,可以这样:
javascript
class Animal {
constructor(name) {
this.name = name;
}
}
// 把能力定义成装饰函数
function withFlying(Base) {
return class extends Base {
fly() {
return `${this.name} is flying`;
}
};
}
function withSwimming(Base) {
return class extends Base {
swim() {
return `${this.name} is swimming`;
}
};
}
// 组装能力
class Duck extends withSwimming(withFlying(Animal)) {
constructor(name) {
super(name);
}
}
const duck = new Duck('唐老鸭');
duck.fly(); // ✅
duck.swim(); // ✅
duck instanceof Animal; // ✅ true
这种方式的好处是:
- 保留了class的语法
- instanceof检查仍然有效
- IDE能提供代码补全
方式3:依赖注入
更高级的做法是把能力注入进去:
javascript
class Bird {
constructor(name, abilities = []) {
this.name = name;
// 注入能力
abilities.forEach(ability => {
ability.attachTo(this);
});
}
}
// 定义能力模块
const flyingAbility = {
attachTo(target) {
target.flySpeed = 50;
target.fly = function() {
return `${this.name} is flying at ${this.flySpeed} km/h`;
};
}
};
const swimmingAbility = {
attachTo(target) {
target.swimSpeed = 10;
target.swim = function() {
return `${this.name} is swimming at ${this.swimSpeed} km/h`;
};
}
};
// 使用
const duck = new Bird('唐老鸭', [flyingAbility, swimmingAbility]);
const penguin = new Bird('波波', [swimmingAbility]);
duck.fly(); // ✅
penguin.fly; // ✅ undefined
这种方式更灵活,能力模块可以有自己的初始化逻辑。
实战案例:重构继承代码
来看个真实场景。假设我们要做一个游戏角色系统。
用继承实现(一团糟)
javascript
class Character {
constructor(name, hp, mp) {
this.name = name;
this.hp = hp;
this.mp = mp;
}
attack() {
return `${this.name} attacks!`;
}
}
class Mage extends Character {
castSpell(spellName) {
if (this.mp < 10) return 'Not enough MP!';
this.mp -= 10;
return `${this.name} casts ${spellName}!`;
}
}
class Warrior extends Character {
useSkill(skillName) {
if (this.hp < 20) return 'Too injured!';
this.hp -= 20;
return `${this.name} uses ${skillName}!`;
}
}
// 现在产品说:我们要加个"魔剑士",既能施法又能用战技
class SpellBlade extends Mage { // 继承法师
useSkill(skillName) { // 复制粘贴战士的代码
if (this.hp < 20) return 'Too injured!';
this.hp -= 20;
return `${this.name} uses ${skillName}!`;
}
}
// 问题:
// 1. SpellBlade复制了Warrior的代码(违反DRY原则:DRY - Don't Repeat Yourself(不要重复自己))
// 2. 如果Warrior的useSkill改了,SpellBlade也得改
// 3. 继承Mage还是Warrior?这是个哲学问题
用组合重构(清爽)
javascript
// 把能力拆成独立模块
const combatAbility = {
attack() {
return `${this.name} attacks for ${this.attackPower} damage!`;
}
};
const magicAbility = {
castSpell(spellName) {
if (this.mp < 10) return 'Not enough MP!';
this.mp -= 10;
return `${this.name} casts ${spellName}!`;
}
};
const skillAbility = {
useSkill(skillName) {
if (this.hp < 20) return 'Too injured!';
this.hp -= 20;
return `${this.name} uses ${skillName}!`;
}
};
// 工厂函数
function createCharacter(name, hp, mp, abilities) {
const character = {
name,
hp,
mp,
attackPower: 50
};
return Object.assign(character, ...abilities);
}
// 创建不同职业
const mage = createCharacter('甘道夫', 100, 200, [combatAbility, magicAbility]);
const warrior = createCharacter('阿拉贡', 200, 50, [combatAbility, skillAbility]);
const spellBlade = createCharacter('塞尔达', 150, 150, [combatAbility, magicAbility, skillAbility]);
mage.castSpell('火球术'); // ✅
warrior.useSkill('狂战士之怒'); // ✅
spellBlade.castSpell('雷电术'); // ✅
spellBlade.useSkill('剑气斩'); // ✅
用组合的好处:
- 代码不重复,每个能力只定义一次
- 能力可以随意组合,想要什么组装什么
- 新增职业超简单,改一下能力列表就行
什么时候该用继承
说了这么多组合的好处,难道继承就完全没用了吗?
也不是。继承适合这些场景:
场景1:严格的"is-a"关系
javascript
class Error {
constructor(message) {
this.message = message;
}
}
class TypeError extends Error {
constructor(message) {
super(message);
this.name = 'TypeError';
}
}
class ReferenceError extends Error {
constructor(message) {
super(message);
this.name = 'ReferenceError';
}
}
TypeError 确实是 Error的一种,这种关系很稳定,不会变。用继承没毛病。
场景2:需要利用多态
javascript
class Shape {
getArea() {
throw new Error('子类必须实现getArea方法');
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
getArea() {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
// 利用多态
function calculateTotalArea(shapes) {
return shapes.reduce((sum, shape) => sum + shape.getArea(), 0);
}
const shapes = [
new Circle(5),
new Rectangle(4, 6),
new Circle(3)
];
calculateTotalArea(shapes); // 统一调用getArea,不用管具体类型
这种情况继承的优势很明显:所有形状都有getArea方法,可以统一处理。
场景3:框架要求
javascript
// React组件必须继承React.Component
class MyComponent extends React.Component {
render() {
return <div>Hello</div>;
}
}
// 虽然现在有了函数组件,但类组件时代就是这样
框架规定了继承,那就没办法,得用。
组合和继承的混合使用
实际项目中,往往是组合和继承混着用。
javascript
// 用继承表达"is-a"关系
class Animal {
constructor(name, energy) {
this.name = name;
this.energy = energy;
}
eat() {
this.energy += 10;
}
}
// 用组合添加能力
const canFly = {
fly() {
if (this.energy < 10) return 'Too tired to fly';
this.energy -= 10;
return `${this.name} is flying`;
}
};
const canSwim = {
swim() {
if (this.energy < 5) return 'Too tired to swim';
this.energy -= 5;
return `${this.name} is swimming`;
}
};
// 鸭子是动物(继承),会飞会游(组合)
class Duck extends Animal {
constructor(name) {
super(name, 100);
Object.assign(this, canFly, canSwim);
}
}
const duck = new Duck('唐老鸭');
duck.eat(); // 继承自Animal
duck.fly(); // 组合的能力
duck.swim(); // 组合的能力
这样既保留了继承的多态性,又有了组合的灵活性。
TypeScript中的组合
在TS里,组合的类型安全性更好:
javascript
// 定义能力接口
interface Flyable {
flySpeed: number;
fly(): string;
}
interface Swimmable {
swimSpeed: number;
swim(): string;
}
// 组合类型
type Duck = {
name: string;
} & Flyable & Swimmable;
function createDuck(name: string): Duck {
return {
name,
flySpeed: 50,
swimSpeed: 10,
fly() {
return `${this.name} is flying`;
},
swim() {
return `${this.name} is swimming`;
}
};
}
const duck = createDuck('唐老鸭');
duck.fly(); // ✅ 类型安全
duck.walk(); // ❌ 编译错误:Duck没有walk方法
TS的类型系统天然支持组合,用起来比继承舒服多了。
总结
"组合优于继承"不是说继承一无是处,而是说:
- 优先考虑组合:大部分场景下,组合更灵活、更好维护
- 继承也有用:严格的is-a关系、需要多态、框架要求等场景下继承更合适
- 两者结合:实际项目中往往是组合 + 继承混用
记住一个原则:如果你在纠结该不该用继承,那大概率应该用组合。
最后用一句话概括:
- 继承是"你是什么"(you are what you are)
- 组合是"你能做什么"(you are what you do)
世界是由能力组成的,不是由血统组成的。代码也一样。
参考文档: