REACT · Vol.IV · LESSON 25 · 组件设计与工程化
children 与复合组件模式
children 的本质:JSX 是对象,包裹内容就是传参
第 23 篇说「组合优于继承」,其最基本的形态你天天在用——组件包裹内容:
import { type ReactNode } from "react";
// 使用方:标签体里塞了两个元素
<Card>
<h3>月度账单</h3>
<p>共 42 笔支出</p>
</Card>
// Card 内部:children 就是个普通 prop,类型 ReactNode
function Card({ children }: { children: ReactNode }) {
return <div className="card">{children}</div>;
}
看起来像「HTML 标签体」,其实是彻头彻尾的传参。回忆第 2 篇的结论:JSX 只是函数调用的语法糖,编译产物是普通 JS 对象。把上面那段 JSX 编译开,一切豁然开朗:
createElement(Card, null,
createElement("h3", null, "月度账单"),
createElement("p", null, "共 42 笔支出")
);
// 这两个「元素对象」被打包,塞进 props.children 传给 Card
所以 props.children 没有魔法:它就是调用组件函数时多传的一个参数,值是「元素对象」(或字符串、数组等可渲染物)。Card 拿到对象后决定它渲染在哪——放哪、渲染几次、要不要渲染,全由 Card 说了算。类型 ReactNode 涵盖 children 的全部合法形态:元素、字符串、数字、数组、undefined、布尔表达式 {count > 0 && ...}。
你用 Thymeleaf 做后台页时写过 layout.html:页头页脚固定,中间留一个 th:replace 占位,各页面把自己的内容片段「填」进去。Card 的 children 就是这个占位——区别只在填充物不是 HTML 字符串,而是一棵随 state 自主更新的组件树。
多插槽:children 只有一个,多的就用 props 传 JSX
children 只有一份。卡片还想自定义标题区、底部操作区,就多开几个「插槽」——用 props 传 JSX:
import { type ReactNode } from "react";
interface ArticleCardProps {
title: ReactNode; // 头部插槽:注意类型是 ReactNode,不是 string
footer?: ReactNode; // 底部插槽:可选
children: ReactNode; // 正文插槽:约定俗成的名字
}
function ArticleCard({ title, footer, children }: ArticleCardProps) {
return (
<article className="card">
<header>{title}</header>
<div className="body">{children}</div>
{footer && <footer>{footer}</footer>}
</article>
);
}
// 使用方:传进去的是 JSX 对象,不是字符串——插槽是「活的」
<ArticleCard
title={<h3>{post.title}</h3>}
footer={<button onClick={like}>点赞</button>}
>
<p>{post.body}</p>
</ArticleCard>
两个容易忽略的点:
- 插槽类型必须写
ReactNode,别写string。写成 string,调用方只能传文字;ReactNode 才允许塞按钮、嵌套组件——插槽的价值全在这。这也是第 28 篇 TypeScript 与 React 的日常配合。 - JSX 能出现在属性值里和标签体里,是同一个原因:都是「往函数参数里放对象」。传参没有禁区。
children 相当于方法的「主参数」,title/footer 相当于命名参数——Java Builder 里 .title(...).footer(...) 那串链式调用。页面布局里 header / content / footer 三个 fragment 占位,映射到组件上就是这三个插槽:Sitemesh 装饰器、Spring 的 layout decorate,思路同构。
复合组件:Tabs.Item 为什么一个 prop 都不用传
插槽解决「往组件里塞内容」,复合组件(Compound Components)解决另一个问题:一组子组件如何共享父组件的状态,而使用方浑然不觉。先看想要的使用姿势——Ant Design、Element UI 这类组件库的标准长相:
const [tab, setTab] = useState("orders");
<Tabs active={tab} onChange={setTab}>
<Tabs.Item label="订单">订单列表……</Tabs.Item>
<Tabs.Item label="商品">商品列表……</Tabs.Item>
</Tabs>
盯着 Tabs.Item 看一会儿,会发现两件「不可能」的事:
- Item 没传 index,它怎么知道自己排第几、该不该高亮?
- Item 没传 active、没传 onClick,点它一下整个 Tabs 怎么切页?
答案是:这些信息由 Tabs 通过 Context(第 15 篇)悄悄下发——Tabs 是 Provider,持有激活态;Item 是消费者,useContext 一拿。使用方感知不到这条内部通道,这就是「隐式注册」:Item 只要出现在 Tabs 里,身份和依赖自动就位。Select/Select.Option、Accordion/Accordion.Item 全是同一套路。对比不这么做的写法:
<TabItem label="订单" index={0}
active={tab === "orders"} onClick={() => setTab("orders")}>
订单列表……
</TabItem>
// 每个 Item 都要重复这一整套;插一个新 Tab,后面的 index 全要手改
Item 不需要手动去 registry 登记——「出现在 Tabs 标签体内」这个动作本身就是注册,像 @Component 类只要落在扫描路径下就进了 ApplicationContext,激活态这类依赖由容器注入,类本身零感知。巧的是,React 的 Context 和 Spring 的 Context 连名字都一样:都是「依赖的来源」。组件树就是前端版的应用上下文。
顺带交代一个更老的做法:Hooks 普及前,组件库常用 React.Children.map 遍历 children,再用 cloneElement 克隆子元素、强行注入 active 等 props。能跑,但有个致命局限:Item 必须是 children 的直接子节点——中间包一层 <div> 或加个条件渲染,props 就注不进去了,还逼着使用方给每个 Item 配 key(第 7 篇)。Context 天然穿透任意嵌套,现代组件库基本都改用了它;这两个 API 认识即可,不必再写。
实战:手写一个迷你 Tabs
轮到你了。下面这个迷你 Tabs 可以直接抄进项目跑,重点看三处:Context 只在文件内流转、Item 激活态来自 useContext、最后用 Object.assign 静态挂载:
import {
createContext, useContext, useState,
type ReactNode,
} from "react";
// 1. 内部通道:不导出,是 Tabs 和 Item 的「私房话」
interface TabsContextValue {
active: string;
setActive: (key: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
// 2. Item:不接收任何状态 props,激活态从 Context 拿
function TabItem({ label, children }: { label: string; children: ReactNode }) {
const ctx = useContext(TabsContext);
// 没包在 Tabs 里就明确报错——兜住使用方的误用
if (!ctx) throw new Error("Tabs.Item 必须用在 Tabs 内部");
const isActive = ctx.active === label;
return (
<>
<button
className={isActive ? "tab active" : "tab"}
onClick={() => ctx.setActive(label)}
>
{label}
</button>
{isActive && <div className="panel">{children}</div>}
</>
);
}
// 3. Tabs:唯一持状态的人,通过 Provider 下发
// (初始为空 = 都不选;想默认选中,加 defaultActive prop 即可)
function Tabs({ children }: { children: ReactNode }) {
const [active, setActive] = useState("");
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 4. 静态挂载:让 <Tabs.Item /> 成立,TS 自动推断出完整类型
export default Object.assign(Tabs, { Item: TabItem });
用起来就是第 3 站的目标 API:
<Tabs>
<Tabs.Item label="订单">订单列表</Tabs.Item>
<Tabs.Item label="商品">商品列表</Tabs.Item>
</Tabs>
三个实现细节值得停留:
- 激活判据为什么用
label不用 index?label 天然唯一且稳定,增删 Tab 不会错位;用 index 则每次增删都可能改变语义——和第 7 篇「列表 key 不用 index」同一个道理。 - TS 静态挂载还有种手动写法,老项目里更常见——先声明交叉类型再赋值:
type TabsComponent = typeof Tabs & { Item: typeof TabItem };
const TabsWithItem = Tabs as TabsComponent;
TabsWithItem.Item = TabItem;
export default TabsWithItem;
- Provider 的 value 每次渲染都是新对象,Item 会跟着重渲染——不过 Tabs 本来就只在 active 变化时才渲染,量级可接受;真要较真,等第 37 篇 memo 上场。
复合组件的使用体验像 Builder:Tabs 定骨架,Tabs.Item 链式往里填内容。运行时的分工像模板方法:父组件(抽象类)定死「状态与调度」的骨架流程,子组件(实现类)只填「自己那块长什么样」的空。区别是 Java 靠继承建立这层关系,React 靠「包裹 + Context」,没有继承的耦合——正是第 23 篇「组合优于继承」的组件库实践版。
何时别用:模式是给高频复用准备的
复合组件不是越多越好。收益 = 「子项数量 × 使用次数」——Tabs、Select、Accordion、Menu 这类全站复用、内部又共享状态的组件群才赚得回成本。对着下表自查:
| 场景 | 建议 | 理由 |
|---|---|---|
| Card、Modal 等简单包裹 | 直接 children,顶多加一两个 JSX 插槽 | 没有共享状态,模式是杀鸡牛刀 |
| Tabs / Select / Accordion / Radio.Group | 复合组件 + Context | 子项共享父状态,且全站高频复用 |
| 一次性的页面区块 | 普通组件 + props | 读代码要跳文件,抽象成本不划算 |
| 对外发布的组件库 | 复合组件 + Context(补齐无障碍属性) | API 面子稳定、里子自由,行业标准答案 |
判断标准与第 23 篇一脉相承:抽象是要还的债。小组件硬上复合组件,读一个功能得在三个定义之间来回跳——为「看起来像个组件库」付出理解成本,不值。
- children 就是
props.children:JSX 是对象,包裹内容 = 多传一个参数,类型 ReactNode。 - 多插槽用 JSX props(title/footer),插槽类型写 ReactNode 才「活」。
- 复合组件 = 父组件 Context 下发状态 + 子组件 useContext 隐式消费,使用方零感知。
- 静态挂载
Object.assign(Tabs, { Item: TabItem })让Tabs.Item成立。 - React.Children.map + cloneElement 是老方案:怕嵌套、要 key,认识即可。
- 模式为高频复用服务,一次性的小组件别硬上。
本章回顾
这一篇把「组合」推到了组件库级别:children 是传参,插槽是多传几个参数,复合组件是让子组件们经 Context 隐式共享父状态。至此你见过的都是把对象传进组件。Hooks 出现之前,人们还发明过把函数乃至整个组件当参数传的复用方式:render props 与高阶组件——它们今天大多退居二线,但老项目里遍地都是。下一篇第 26 篇,带你读懂这些「化石」。
Comments · 评论