组合优于继承:JavaScript 中的组合式编程实践

组合优于继承:JavaScript 中的组合式编程实践 – 从乐高积木到代码艺术

各位观众,各位码农,各位未来的编程艺术家们,晚上好!我是你们的老朋友,今天咱们聊点儿高雅的——组合式编程。

等等,别急着走!我知道,一听到“组合式编程”这几个字,有些人可能已经开始打哈欠了。觉得这又是哪个学术派大佬提出的高深理论,跟实际开发八竿子打不着。

错!大错特错!

组合式编程,其实比你想象的更贴近生活,更实用,而且,毫不夸张地说,它能让你的代码更优雅,更易维护,甚至……更有趣!😎

开场白:继承的甜蜜陷阱与组合的崛起

我们先来聊聊“继承”。

想象一下,你想要设计一个“鸟”的类。你会怎么做?

class Animal {
  constructor(name) {
    this.name = name;
  }
  eat() {
    console.log(`${this.name} is eating.`);
  }
}

class Bird extends Animal {
  constructor(name, canFly) {
    super(name);
    this.canFly = canFly;
  }
  fly() {
    if (this.canFly) {
      console.log(`${this.name} is flying!`);
    } else {
      console.log(`${this.name} can't fly.`);
    }
  }
}

const robin = new Bird("Robin", true);
robin.eat(); // Robin is eating.
robin.fly(); // Robin is flying!

看起来很棒,对吧?继承,就像家族血脉,让子类“鸟”自动拥有了父类“动物”的特性,比如“吃”。这感觉就像拥有了一张无限额度的信用卡,啥也不用干,就能继承一堆功能。

但是,等等!事情并没有那么简单。

如果我们要添加一个“企鹅”类呢?企鹅也是鸟,但它不能飞。

class Penguin extends Bird {
  constructor(name) {
    super(name, false); // 企鹅不能飞
  }
}

const pingu = new Penguin("Pingu");
pingu.eat(); // Pingu is eating.
pingu.fly(); // Pingu can't fly.

现在看起来还可以,但是如果你的鸟类家族越来越庞大,种类越来越多,比如加上“鸵鸟”、“蜂鸟”、“鹦鹉”……情况会变得越来越复杂。

问题来了:

  1. 脆弱的基类: 你的“Animal”类或“Bird”类,一旦发生改变,会影响到所有子类。就像多米诺骨牌一样,一倒全倒。
  2. 代码膨胀: 为了适应所有子类的特殊需求,你的父类可能会变得越来越臃肿,维护成本越来越高。
  3. 菱形继承问题: 在某些编程语言中(JavaScript 虽然没有直接的菱形继承,但可以通过原型链模拟),可能会出现菱形继承问题,导致代码逻辑混乱。
  4. 不必要的继承: 有些子类可能并不需要父类的所有特性,但仍然被迫继承,造成资源浪费。就像你只想吃一碗米饭,却被强行塞了一桌子菜。
  5. 紧耦合: 继承关系往往会导致父类和子类之间的高度耦合,降低了代码的灵活性和可重用性。

总而言之,继承就像是一把双刃剑,用得好,能简化代码,提高效率;用不好,就会让你陷入泥潭,难以自拔。

那么,有没有更好的方法呢?

答案是肯定的!这就是我们今天要讲的——组合

组合的魅力:像乐高积木一样搭建代码

组合,顾名思义,就是把不同的部分组合在一起,形成一个整体。就像乐高积木一样,你可以用不同的积木块,搭建出各种各样的模型,无论是汽车、飞机,还是城堡,都可以通过不同的组合来实现。

组合式编程的核心思想是:

  • 优先使用组合,而不是继承。
  • 将复杂的功能分解成小的、可重用的模块。
  • 通过组合这些模块,构建出更复杂的功能。

我们用一个例子来说明:

假设我们需要设计一个“会飞的鸭子”。

使用继承:

class Animal {
  constructor(name) {
    this.name = name;
  }
  eat() {
    console.log(`${this.name} is eating.`);
  }
}

class Bird extends Animal {
  constructor(name) {
    super(name);
  }
  fly() {
    console.log(`${this.name} is flying!`);
  }
}

class Duck extends Bird {
  constructor(name) {
    super(name);
  }
  quack() {
    console.log("Quack!");
  }
}

class FlyingDuck extends Duck {
  constructor(name) {
    super(name);
  }
}

const flyingDuck = new FlyingDuck("Flying Duck");
flyingDuck.eat(); // Flying Duck is eating.
flyingDuck.fly(); // Flying Duck is flying!
flyingDuck.quack(); // Quack!

虽然可以实现,但明显可以看到继承层级很深,逻辑复杂。如果再来一个不会飞的鸭子呢?又需要再创建一个新的类。

使用组合:

const canEat = (state) => ({
  eat: () => console.log(`${state.name} is eating.`)
});

const canFly = (state) => ({
  fly: () => console.log(`${state.name} is flying!`)
});

const canQuack = (state) => ({
  quack: () => console.log("Quack!")
});

const createDuck = (name, canFlyBool) => {
  let state = {
    name: name,
    canFly: canFlyBool
  }
  return Object.assign({}, canEat(state), canQuack(state), canFlyBool ? canFly(state) : {});
}

const flyingDuck = createDuck("Flying Duck", true);
flyingDuck.eat(); // Flying Duck is eating.
flyingDuck.fly(); // Flying Duck is flying!
flyingDuck.quack(); // Quack!

const duck = createDuck("Duck", false);
duck.eat(); // Duck is eating.
//duck.fly(); // 报错,因为 Duck 没有 fly 方法
duck.quack(); // Quack!

代码解读:

  1. 定义小的、可重用的模块: 我们定义了 canEatcanFlycanQuack 三个函数,分别负责“吃”、“飞”和“叫”的功能。这些函数接收一个 state 对象,并返回一个包含对应方法的对象。
  2. 使用 Object.assign 进行组合: createDuck 函数接收一个 namecanFlyBool 参数,创建一个 state 对象,然后使用 Object.assigncanEatcanQuackcanFly(如果 canFlyBooltrue)的方法合并到 state 对象中。

组合的优势:

  1. 灵活性: 你可以根据需要,自由地组合不同的模块,创建出各种各样的对象。
  2. 可重用性: 小的模块可以被多个对象复用,减少代码冗余。
  3. 低耦合: 模块之间相互独立,修改一个模块不会影响到其他模块。
  4. 易于测试: 小的模块更容易进行单元测试。
  5. 可读性: 代码结构清晰,易于理解和维护。

你可以这样理解:

  • 继承: 像流水线生产,一旦确定了流程,就很难改变。
  • 组合: 像乐高积木,可以根据需要,灵活地搭建各种模型。

组合模式的常见技巧与模式

在 JavaScript 中,实现组合式编程有很多种方式,这里介绍几种常见的技巧和模式:

1. Mixins

Mixins 是一种将多个对象的方法和属性合并到另一个对象中的技术。它可以让你在不同的对象之间共享代码,而无需使用继承。

const barker = (state) => ({
  bark: () => console.log(`Woof, I am ${state.name}`)
});

const driver = (state) => ({
  drive: () => console.log(`Vroom, I am driving ${state.name}`)
});

const Person = (name) => {
  let state = {
    name: name
  };

  return Object.assign({}, state, barker(state), driver(state));
}

const jane = Person("Jane");
jane.bark(); // Woof, I am Jane
jane.drive(); // Vroom, I am driving Jane

在这个例子中,barkerdriver 都是 Mixins。它们接收一个 state 对象,并返回一个包含对应方法的对象。Person 函数使用 Object.assignbarkerdriver 的方法合并到 state 对象中。

2. 函数组合

函数组合是一种将多个函数组合成一个新函数的技术。它可以让你将复杂的功能分解成小的、可重用的函数,然后将这些函数组合在一起,实现更复杂的功能. 就像搭积木,先有小的积木块,再组合成大的模型。

const add = (x) => x + 1;
const multiply = (x) => x * 2;

const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x);

const addAndMultiply = compose(multiply, add);

console.log(addAndMultiply(1)); // (1 + 1) * 2 = 4

在这个例子中,compose 函数接收多个函数作为参数,并返回一个新的函数。这个新函数接收一个参数 x,然后使用 reduceRight 从右到左依次调用传入的函数,最终返回结果。

3. 高阶组件 (Higher-Order Components – HOC)

高阶组件是一种接收一个组件作为参数,并返回一个新的组件的技术。它常用于 React 中,用于在组件之间共享逻辑。 你可以理解成一个工厂,接收一个原材料,加工后输出一个新的产品。

import React from 'react';

const withLogger = (WrappedComponent) => {
  return class extends React.Component {
    componentDidMount() {
      console.log('Component mounted!');
    }

    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
};

const MyComponent = () => {
  return <div>Hello, world!</div>;
};

const MyComponentWithLogger = withLogger(MyComponent);

export default MyComponentWithLogger;

在这个例子中,withLogger 是一个高阶组件。它接收一个组件 WrappedComponent 作为参数,并返回一个新的组件。这个新组件在 componentDidMount 生命周期钩子中打印一条日志,然后渲染 WrappedComponent

4. Render Props

Render Props 是一种使用一个 prop 来传递渲染逻辑的技术。它允许你在组件之间共享 UI 逻辑,而无需使用继承或高阶组件。 你可以理解成一个插槽,你可以在插槽中自定义渲染内容。

import React from 'react';

class Mouse extends React.Component {
  constructor(props) {
    super(props);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove = (event) => {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
        {this.props.render(this.state)}
      </div>
    );
  }
}

const MyComponent = () => {
  return (
    <Mouse render={mouse => (
      <p>The mouse position is ({mouse.x}, {mouse.y})</p>
    )}/>
  );
};

export default MyComponent;

在这个例子中,Mouse 组件接收一个 render prop,这个 prop 是一个函数,它接收当前鼠标的位置作为参数,并返回一个 React 元素。MyComponent 使用 Mouse 组件,并将一个函数作为 render prop 传递给它。

组合 vs 继承:一个表格概览

为了更清晰地对比组合和继承,我们用一个表格来总结它们的优缺点:

特性 继承 组合
灵活性 较低,继承关系固定,难以改变 较高,可以根据需要灵活地组合不同的模块
可重用性 较低,子类只能重用父类的代码,难以在不同的类之间共享代码 较高,小的模块可以被多个对象复用,减少代码冗余
耦合性 较高,父类和子类之间高度耦合,修改父类可能会影响到子类 较低,模块之间相互独立,修改一个模块不会影响到其他模块
测试性 较低,继承关系复杂,难以进行单元测试 较高,小的模块更容易进行单元测试
可读性 较低,继承层级过深可能会导致代码难以理解和维护 较高,代码结构清晰,易于理解和维护
适用场景 当类之间存在明显的“is-a”关系,且父类的行为和属性对子类都有意义时 当需要更大的灵活性和可重用性,且类之间不存在明显的“is-a”关系时
复杂度 随着继承层级的加深,复杂度会迅速增加 复杂度相对稳定,不会随着模块数量的增加而显著增加
口头禅 "我是…的子类","我继承了…" "我包含了…","我组合了…"
比喻 家族血脉,流水线生产 乐高积木,拼图游戏
表情 😫 (当继承关系变得复杂时) 😎 (当使用组合式编程时)

总结:拥抱组合,开启代码艺术之旅

各位观众,各位码农,各位未来的编程艺术家们,今天我们一起探讨了组合式编程的魅力。希望通过今天的分享,大家能够对组合式编程有更深入的理解,并在实际开发中尝试使用它。

记住,组合不是万能的,继承也不是一无是处。关键在于根据具体的场景,选择最合适的编程范式。

我的建议是:

  • 优先考虑组合。
  • 将复杂的功能分解成小的、可重用的模块。
  • 拥抱变化,灵活地组合这些模块。

相信通过不断地实践和探索,你也能像乐高大师一样,用代码搭建出属于自己的艺术作品! 🚀

最后,祝大家编程愉快,bug 越来越少,代码越来越优雅! 谢谢大家! 🙌

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注