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

前言

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

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


参考文档:

相关推荐
C.果栗子2 小时前
Blob格式的PDF文件调用打印,浏览器文件打印(兼容)
前端·javascript·pdf
San30.4 小时前
从代码规范到 AI Agent:现代前端开发的智能化演进
javascript·人工智能·代码规范
廾匸6404 小时前
语义化标签
前端·javascript·html
汪汪队立大功1235 小时前
selenium中执行javascript,是否等价于在浏览器console位置执行
javascript·selenium·测试工具
soda_yo7 小时前
搞不懂作用域链?这篇文章让你一眼秒懂!
javascript·面试
apollo_qwe7 小时前
Set 和 Map常用场景代码片段
javascript
Hilaku8 小时前
我为什么说全栈正在杀死前端?
前端·javascript·后端
程序猿_极客8 小时前
【期末网页设计作业】HTML+CSS+JS 旅行社网站、旅游主题设计与实现(附源码)
javascript·css·html·课程设计·期末网页设计
用户283209679378 小时前
为什么我的页面布局总是乱糟糟?可能是浮动和BFC在作怪!
javascript