首页 / React 学习笔记 / 25

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

children 与复合组件模式

进阶#组件#模式

children 的本质:JSX 是对象,包裹内容就是传参

第 23 篇说「组合优于继承」,其最基本的形态你天天在用——组件包裹内容:

Card.tsx · 最常见的包裹写法
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 编译开,一切豁然开朗:

编译产物.js · 标签体 = 从第三个参数起的位置参数
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:

ArticleCard.tsx · 三个插槽:title / children / footer
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,思路同构。

传「JSX 对象」和传「函数」是两个次元的插槽:传 JSX,内容在调用方渲染时就定稿成对象;传函数,内容延迟到子组件调用时才生成,还能拿到子组件递来的状态。后者叫 render props,第 26 篇的主角——先把对象版插槽用扎实。

复合组件:Tabs.Item 为什么一个 prop 都不用传

插槽解决「往组件里塞内容」,复合组件(Compound Components)解决另一个问题:一组子组件如何共享父组件的状态,而使用方浑然不觉。先看想要的使用姿势——Ant Design、Element UI 这类组件库的标准长相:

用法.tsx · 目标 API:Item 不带任何状态 props
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 全是同一套路。对比不这么做的写法:

显式版.tsx · 反例:每个使用处都得自己接线
<TabItem label="订单" index={0}
  active={tab === "orders"} onClick={() => setTab("orders")}>
  订单列表……
</TabItem>
// 每个 Item 都要重复这一整套;插一个新 Tab,后面的 index 全要手改
后端类比:@Component 组件扫描

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 认识即可,不必再写。

拆开看,复合组件没有发明任何新东西:状态提升(第 14 篇)让 Tabs 持有激活态,Context(第 15 篇)让激活态跨层可达,children 传参让 Item 进得了门。所谓模式,就是三件旧武器的固定连招。

实战:手写一个迷你 Tabs

轮到你了。下面这个迷你 Tabs 可以直接抄进项目跑,重点看三处:Context 只在文件内流转、Item 激活态来自 useContext、最后用 Object.assign 静态挂载:

Tabs.tsx · 复合组件三件套:Context + Item + 静态挂载
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:

App.tsx · 调用方视角:干净得像在写配置
<Tabs>
  <Tabs.Item label="订单">订单列表</Tabs.Item>
  <Tabs.Item label="商品">商品列表</Tabs.Item>
</Tabs>

三个实现细节值得停留:

  • 激活判据为什么用 label 不用 index?label 天然唯一且稳定,增删 Tab 不会错位;用 index 则每次增删都可能改变语义——和第 7 篇「列表 key 不用 index」同一个道理。
  • TS 静态挂载还有种手动写法,老项目里更常见——先声明交叉类型再赋值:
Tabs.tsx · 静态挂载的手动写法
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 与模板方法

复合组件的使用体验像 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 篇一脉相承:抽象是要还的债。小组件硬上复合组件,读一个功能得在三个定义之间来回跳——为「看起来像个组件库」付出理解成本,不值。

隐式注册:状态在父,子组件从 Context 领 Tabs(Provider) useState: active = "orders" Context 下发 {active, setActive} Tabs.Item 订单 useContext → 激活,高亮 Tabs.Item 商品 useContext → 未激活
图 1复合组件的隐式注册:Tabs 经 Context 下发激活态,Tabs.Item 零 props 接线。
核心要点
  • 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 · 评论