REACT · Vol.IV · LESSON 23 · 组件设计与工程化
组合优于继承:Java 设计原则的前端回响
一条你背过的原则,先在 Java 里翻过车
卷三到上一站正式收官:状态分了类(第 17 篇)、选了容器(第 18~20 篇)、收服了表单(第 21 篇)、钉进了磁盘(第 22 篇)。卷四「组件设计与工程化」开篇,第一课不讲新 API,讲一条你八成在《Effective Java》里背过的原则——组合优于继承(Favor composition over inheritance)。因为 React 把这条原则执行到了极致,而理解「为什么极致」,恰好要从 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 同样退场,直接实现接口即可。框架的进化方向,就是答案。
继承是白盒复用:子类看得见父类内部,复用越深,耦合越死,父类一动全塔震动。组合是黑盒复用:只依赖对方暴露的接口,内部实现随便换。你在 Java 里学的这条设计原则,React 不是「借鉴」,而是把它设成了默认值——后面四站讲的就是「默认值」长什么样。
React 的答案:压根没有继承这回事
React 组件之间不能继承。函数组件连类都没有,extends 无从谈起;class 组件时代的 extends React.Component 只是接入框架的仪式,官方从未鼓励业务组件之间互相继承,如今更是全面转向函数组件。想复用一个组件?不是继承它,而是把它装进另一个组件里用。
第一个组合手段:children 包裹。
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 这一层薄薄的契约。试着用继承表达这张卡片?你会得到 BaseCard、TextCard、ImageCard、TextImageCard……而 children 一个参数就终结了讨论。
订单服务需要发短信,你的选择从来不是 extends SmsService 去偷它的方法,而是 @Autowired 注入一个 SmsSender 接口。children 就是这个思路在 UI 层的样子:容器声明需要的「坑位」,调用方填内容——黑盒、可替换、零父子耦合。
多插槽与「props 传组件」
children 只有一个口。页面骨架通常需要三个口:顶栏、侧边栏、主内容。解法顺理成章——插槽做成 props:
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 规则在组合场景的直接应用。
BasePage 定骨架、子类填坑的模板方法,在 React 里就是一个收 ReactNode props 的布局组件:骨架是「结构复用」,坑位是「参数」。没有父子耦合,想换顶栏换顶栏,想换内容换内容——你要的「定流程、留扩展点」,插槽全部给了,且不用付继承的税。
组合爆炸:四个按钮类的教训与一个 Button 的解法
组合的第三种手法出现在一个经典困境里。做按钮组件库,起步很顺:PrimaryButton、DangerButton 各继承自 Button。新需求来了:提交时要 loading 态——于是 LoadingButton、LoadingPrimaryButton、LoadingDangerButton……再加一个 size 维度,类数量直接翻倍。继承是乘法:每加一个正交维度,子类数量乘以 2。Java 仓库里那些 AbstractPrimaryLoadingIconButton 就这么来的。
React 的解法:只留一个 Button,把维度全部变成 props。
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 换成策略表——维度正交化之后,复杂度从指数级掉回线性。
三层复用观:逻辑、结构、样式各找各妈
把前四站和卷二的知识拼起来,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 · 评论