接手祖传代码后,我终于理解了"组合优于继承"

前言

不管是看些博客,文章还是啥,经常会看到"组合优于继承"这句话,心里想的是:啥玩意儿?继承多好用啊,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(); // 💥 抛错了

你看,企鹅确实是鸟,但它不会飞。如果用继承,要么:

  1. 在子类里覆盖方法抛错(但这违反了里氏替换原则)
  2. 把父类拆成更细的类(但这又导致类爆炸)

这就是所谓的"脆弱基类问题"(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()的完整逻辑,你得:

  1. 看Dog类的bark方法
  2. 往上翻Mammal的构造函数(找energy定义)
  3. 再往上翻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. 代码不重复,每个能力只定义一次
  2. 能力可以随意组合,想要什么组装什么
  3. 新增职业超简单,改一下能力列表就行

什么时候该用继承

说了这么多组合的好处,难道继承就完全没用了吗?

也不是。继承适合这些场景:

场景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的类型系统天然支持组合,用起来比继承舒服多了。

总结

"组合优于继承"不是说继承一无是处,而是说:

  1. 优先考虑组合:大部分场景下,组合更灵活、更好维护
  2. 继承也有用:严格的is-a关系、需要多态、框架要求等场景下继承更合适
  3. 两者结合:实际项目中往往是组合 + 继承混用

记住一个原则:如果你在纠结该不该用继承,那大概率应该用组合

最后用一句话概括:

  • 继承是"你是什么"(you are what you are)
  • 组合是"你能做什么"(you are what you do)

世界是由能力组成的,不是由血统组成的。代码也一样。


参考文档:

相关推荐
文阿花5 分钟前
Echarts实现自定旋转3D饼状图
javascript·3d·echarts·饼状图
meilindehuzi_a39 分钟前
深入理解 JavaScript 的同步与异步机制:从单线程设计到 Promise 核心应用
开发语言·javascript·ecmascript
如烟花的信页41 分钟前
加速乐cookie逆向分析
javascript·爬虫·python·js逆向
永远的WEB小白1 小时前
css改变svg图标的颜色
前端·javascript·css
ikoala1 小时前
Codex 不得不装的 12 个插件,都在这了
前端·javascript·后端
赵庆明老师2 小时前
JS检查提交的文件是否合规
开发语言·前端·javascript
颂love2 小时前
Vue的两大生态以及组件通信
前端·javascript·vue.js·typescript
光影少年2 小时前
js单线程,为什在node环境下的js可以处理高并发请求?
前端·javascript·掘金·金石计划
moMo3 小时前
# JavaScript 的“等等我”:聊聊同步与异步
javascript
Cobyte3 小时前
19.Vue Vapor 的实现原理原来这么简单
前端·javascript·vue.js