首页 / React 学习笔记 / 23

REACT · Vol.IV · LESSON 23 · 组件设计与工程化

组合优于继承:Java 设计原则的前端回响

进阶核心#组件#设计

一条你背过的原则,先在 Java 里翻过车

卷三到上一站正式收官:状态分了类(第 17 篇)、选了容器(第 18~20 篇)、收服了表单(第 21 篇)、钉进了磁盘(第 22 篇)。卷四「组件设计与工程化」开篇,第一课不讲新 API,讲一条你八成在《Effective Java》里背过的原则——组合优于继承(Favor composition over inheritance)。因为 React 把这条原则执行到了极致,而理解「为什么极致」,恰好要从 Java 翻过的车看起。

先复现一次经典车祸。模板方法模式,基类定流程、子类填坑:

OrderService.java · 继承式扩展:三个月后的定时炸弹
// 模板方法:基类定死流程,子类填坑
public abstract class BaseOrderService {
    public final Order place(Order order) {
        applyDiscount(order);   // 步骤 1:折扣
        applyShipping(order);   // 步骤 2:运费
        return save(order);     // 步骤 3:落库
    }
    protected abstract void applyDiscount(Order order);
}

public class VipOrderService extends BaseOrderService {
    @Override
    protected void applyDiscount(Order order) {
        // 心照不宣地依赖了父类「折扣一定发生在运费之前」
        order.setPrice(order.getPrice().multiply(new BigDecimal("0.8")));
    }
}

三个月后,有人为了优化运费计算,把基类里两步对调了位置。编译照样绿,测试也许照样过——直到财务发现 VIP 订单的价格全算错了。这就是脆弱基类问题(fragile base class):子类看不见、却依赖着父类的实现细节,父类改一行,子孙满堂陪葬。再加上「为一点差异就 extends」导致的继承层次腐烂——五层之后没人说得清 order 到底走了哪个覆写——Java 社区早就用血泪换来了那条原则。

有意思的是,Spring 自己也在逃离继承:WebSecurityConfigurerAdapter(继承式安全配置)在 5.7 被弃用,官方改推声明一个 SecurityFilterChain Bean 的组合式写法;HandlerInterceptorAdapter 同样退场,直接实现接口即可。框架的进化方向,就是答案。

后端类比:白盒复用 vs 黑盒复用

继承是白盒复用:子类看得见父类内部,复用越深,耦合越死,父类一动全塔震动。组合是黑盒复用:只依赖对方暴露的接口,内部实现随便换。你在 Java 里学的这条设计原则,React 不是「借鉴」,而是把它设成了默认值——后面四站讲的就是「默认值」长什么样。

React 的答案:压根没有继承这回事

React 组件之间不能继承。函数组件连类都没有,extends 无从谈起;class 组件时代的 extends React.Component 只是接入框架的仪式,官方从未鼓励业务组件之间互相继承,如今更是全面转向函数组件。想复用一个组件?不是继承它,而是把它装进另一个组件里用

第一个组合手段:children 包裹

Card.tsx · 最小形态的组合:泛化容器
import type { ReactNode } from "react";

// Card 不知道、也不关心 children 里是什么——黑盒
function Card({ children }: { children: ReactNode }) {
  return (
    <div className="card">
      <div className="card-body">{children}</div>
    </div>
  );
}

// 使用处:装什么都行,复用的是「结构 + 样式」
<Card>
  <h3>订单已支付</h3>
  <p>预计 3 天内送达</p>
</Card>

Card 复用的是卡片的外壳(边框、内边距、阴影),内容作为参数传入。它不继承任何业务组件,也不被任何业务组件继承——内容与容器之间只隔着 props 这一层薄薄的契约。试着用继承表达这张卡片?你会得到 BaseCardTextCardImageCardTextImageCard……而 children 一个参数就终结了讨论。

children 的本质是什么?你其实早就会:依赖注入。Card 在参数表里声明「我需要一个内容」,调用方注入具体内容——和构造器注入一个协作对象是同一个思想,只不过注入的不是 Bean,而是一段待渲染的 UI。children 还有更多玩法(函数式 children、插槽复合组件),第 25 篇展开。
后端类比:注入协作对象,而不是继承它

订单服务需要发短信,你的选择从来不是 extends SmsService 去偷它的方法,而是 @Autowired 注入一个 SmsSender 接口。children 就是这个思路在 UI 层的样子:容器声明需要的「坑位」,调用方填内容——黑盒、可替换、零父子耦合。

多插槽与「props 传组件」

children 只有一个口。页面骨架通常需要三个口:顶栏、侧边栏、主内容。解法顺理成章——插槽做成 props

PageLayout.tsx · 多插槽布局:骨架是容器,内容是参数
import type { ReactNode } from "react";

interface PageLayoutProps {
  top: ReactNode;      // 插槽 1:顶栏
  sidebar: ReactNode;  // 插槽 2:侧边栏
  children: ReactNode; // 插槽 3:主内容
}

function PageLayout({ top, sidebar, children }: PageLayoutProps) {
  return (
    <div className="page">
      <div className="page-top">{top}</div>
      <div className="page-main">
        <aside>{sidebar}</aside>
        <main>{children}</main>
      </div>
    </div>
  );
}

// 调用方自由拼装,Layout 对内容零感知
<PageLayout top={<TopNav />} sidebar={<CategoryTree />}>
  <ProductList />
</PageLayout>

注意 props 里放的是 <TopNav /> 这种已经创建好的元素(element),而不只是数据。传元素还是传组件类型(比如收一个 Comp: ComponentType 再在内部渲染 <Comp />)各有场景,后者的关键是:变量必须大写开头才会被 JSX 当组件渲染——这正是第 2 篇讲过的 JSX 规则在组合场景的直接应用。

继承:塔越垒越高 第 5 层… VipOrderService BaseOrderService extends extends 改基类一步,全塔震动 组合:骨架装内容 top 插槽 sidebar 想换就换 children 随便装什么 内容是参数,不是子类
图 1同一个「页面骨架」需求:左边继承塔牵一发动全身,右边一个布局组件收插槽
后端类比:模板方法模式的正解

BasePage 定骨架、子类填坑的模板方法,在 React 里就是一个收 ReactNode props 的布局组件:骨架是「结构复用」,坑位是「参数」。没有父子耦合,想换顶栏换顶栏,想换内容换内容——你要的「定流程、留扩展点」,插槽全部给了,且不用付继承的税。

组合爆炸:四个按钮类的教训与一个 Button 的解法

组合的第三种手法出现在一个经典困境里。做按钮组件库,起步很顺:PrimaryButtonDangerButton 各继承自 Button。新需求来了:提交时要 loading 态——于是 LoadingButtonLoadingPrimaryButtonLoadingDangerButton……再加一个 size 维度,类数量直接翻倍。继承是乘法:每加一个正交维度,子类数量乘以 2。Java 仓库里那些 AbstractPrimaryLoadingIconButton 就这么来的。

React 的解法:只留一个 Button,把维度全部变成 props。

Button.tsx · 类矩阵 → 参数组合:乘法变加法
import type { ReactNode } from "react";

interface ButtonProps {
  variant?: "primary" | "danger"; // 颜色维度
  loading?: boolean;              // 状态维度
  children: ReactNode;
}

function Button({ variant = "primary", loading = false, children }: ButtonProps) {
  return (
    <button className={"btn btn-" + variant} disabled={loading}>
      {loading ? "处理中…" : children}
    </button>
  );
}

// 4 种组合,0 个子类:维度正交,各管各的
<Button variant="primary" loading>保存</Button>
<Button variant="danger">删除</Button>

类的数量是 1,表达力是全组合。新增维度就是加一个 prop,不动任何已有代码——对脆弱基类问题釜底抽薪:压根没有基类。原来 2×2×2 的类矩阵,塌缩成一个接收三个参数的函数。

后端类比:把「分类」变成「参数」

继承是先分类再实例化(IS-A),组合是直接描述特征(HAS-A)。就像你用 @ConditionalOnProperty 一个配置类切遍所有环境,而不是给每个环境复制一个子工程;又像把嵌套的 if-else 换成策略表——维度正交化之后,复杂度从指数级掉回线性。

那什么时候该收「元素」(children/插槽)而不是收 props?判断标准是可枚举性:variant、loading 这种有限可枚举的变化,收 props;不可枚举的变化(卡片里到底装什么内容只有调用方知道),退回 children。两个工具不冲突,Button 里照样可以放 children。

三层复用观:逻辑、结构、样式各找各妈

把前四站和卷二的知识拼起来,React 生态其实把「复用」拆成了三个正交的层,每层有专属工具:

你想复用什么正解反模式
逻辑:取数、防抖、计时、订阅自定义 Hook(第 13 篇)搞个基类放 protected 方法
结构:布局、卡片、模态框骨架组合:children / 插槽 props模板方法式的组件基类
样式:颜色、间距、圆角CSS / class / 设计 token用组件继承链层层传递样式

补两个历史坐标:HOC 与 render props(第 26 篇)本质也是组合,只是 Hooks 出现之前复用逻辑的过渡方案——读老代码遇到时不必慌;组件拆到什么粒度、边界划在哪,第 27 篇接着谈。

归结成一句话:《Effective Java》说组合优于继承,是提醒你「能不用就不用」;React 更进一步——几乎没有继承,一切复用皆组合。你在 Java 里好不容易养成的「少用继承」直觉,在 React 里是默认设置,连纠结的机会都省了。

核心要点
  • 继承的三宗病:脆弱基类(子类依赖父类实现细节)、层次腐烂、组合爆炸;Spring 自己都在弃用继承式扩展点(WebSecurityConfigurerAdapter)。
  • React 组件之间没有继承:复用结构靠 children 包裹、多插槽 props、props 传组件(变量大写开头才当组件渲染)。
  • 正交维度收成参数(variant / loading),别造类矩阵;不可枚举的内容才收 children。
  • 三层复用观:逻辑 → 自定义 Hook,结构 → 组合,样式 → CSS;HOC / render props 是历史过渡形态。

本章回顾

卷四开篇立了总纲:组件复用的一切手段——children、插槽、收组件的 props、自定义 Hook——都是「组合」这个 Java 老原则的具体化。但组合出来的组件有个绕不开的接头问题:input 里的值到底听 React 的,还是听 DOM 的?第 21 篇 register 用非受控埋下的伏笔,下一篇第 24 篇「受控与非受控组件」正式回收。

Comments · 评论