知

模块一 · 起步与渲染

第 2 / 18 节19 分钟阅读型

组件与组合:props、children 与拆分边界

组件是 React 的最小复用单位。这一节讲清 props 的约束、children 与具名插槽的用法、组合优于继承的落地方式,以及组件拆分与重挂载的边界。

读完这一节,你能做到

  1. 用类型把 props 写成契约,而不是 any。
  2. 用 children 与具名插槽实现结构复用,不再传 HTML 字符串。
  3. 判断一个组件该拆还是该合。
  4. 避免"组件定义在渲染里"这类隐蔽的重挂载问题。

一、组件就是一个函数

type ButtonProps = {
  label: string;
  disabled?: boolean;
  onClick?: () => void;
};

export function Button({ label, disabled = false, onClick }: ButtonProps) {
  return (
    <button disabled={disabled} onClick={onClick}>
      {label}
    </button>
  );
}

三条硬规则:

  • props 只读。子组件不能改 props.label,要改就让父组件通过回调改。
  • 组件必须纯。同样的 props 必须渲染出同样的结果,不能在函数体里发请求、写 localStorage、改外部变量。
  • 输入决定输出。如果一个组件的行为依赖"父组件有没有先执行某段逻辑",它的边界就划错了。

二、props 的四种常见形态

形态写法场景
必填{ title: string }组件无法工作的核心输入
可选 + 默认值{ size?: "sm" | "lg" } + 解构默认值有合理默认的样式参数
children{ children: ReactNode }内容由调用方决定
透传ComponentProps<"button">包装原生元素/第三方组件
import type { ComponentProps, ReactNode } from "react";

type CardProps = {
  title: string;
  children: ReactNode;
  footer?: ReactNode;
};

// 包装原生 button,保留全部原生属性与类型
type IconButtonProps = ComponentProps<"button"> & { icon: ReactNode };

export function IconButton({ icon, children, ...rest }: IconButtonProps) {
  return (
    <button {...rest}>
      {icon}
      {children}
    </button>
  );
}

...rest 透传是组件库的基本功:不逐个列 onFocus、aria-*、data-*,而是把未消费的属性整体交给底层元素。注意 rest 里不要包含已被解构的 className、style,否则会被覆盖。

三、children 与具名插槽

React 没有 slot 语法,但有三种等价做法:

需求做法
一段内容children
多个固定区域用多个 ReactNode props(header、footer)
需要子组件内部数据传函数(render prop)
function Panel({ header, children, footer }: PanelProps) {
  return (
    <section className="panel">
      <header>{header}</header>
      <main>{children}</main>
      <footer>{footer}</footer>
    </section>
  );
}

// 调用
<Panel header={<h3>订单</h3>} footer={<Pager />}>
  <OrderList />
</Panel>;

用元素而不是字符串:header="<h3>订单</h3>" 会原样显示标签文本,还会带来 XSS 风险;传 JSX 元素才能被 React 正常渲染。

需要子组件数据时(等价于 Vue 的作用域插槽):

function List<T>({
  items,
  render,
}: {
  items: T[];
  render: (item: T) => ReactNode;
}) {
  return (
    <ul>
      {items.map((item, i) => (
        <li key={i}>{render(item)}</li>
      ))}
    </ul>
  );
}

<List items={orders} render={(o) => <span>{o.no}</span>} />;

注意这里的 key 用了下标,只适合静态列表;真实列表应由调用方提供 id,或让 render 返回带 key 的元素。

四、组合优于继承

类组件时代用继承复用,函数组件时代只有组合。三种落地方式:

方式结构适用
children 组合外层只负责布局,内容由调用方给卡片、弹窗、页面框架
复合组件Tabs + Tabs.Item,共享 Context一组强关联组件
配置驱动传 columns、fields 数组表格、表单这类高度模板化的场景

复合组件的最小实现:

const TabsCtx = createContext<{
  active: string;
  setActive: (k: string) => void;
} | null>(null);

function Tabs({
  children,
  defaultValue,
}: {
  children: ReactNode;
  defaultValue: string;
}) {
  const [active, setActive] = useState(defaultValue);
  const value = useMemo(() => ({ active, setActive }), [active]);
  return <TabsCtx.Provider value={value}>{children}</TabsCtx.Provider>;
}

function TabItem({ name, children }: { name: string; children: ReactNode }) {
  const ctx = useContext(TabsCtx);
  if (!ctx) throw new Error("TabItem 必须在 Tabs 内使用");
  return ctx.active === name ? <div>{children}</div> : null;
}

Tabs.Item = TabItem;

要点:Context 的 value 必须 useMemo,否则每次渲染都创建新对象,所有消费者跟着重渲染。

五、条件渲染与列表的两个陷阱

陷阱一:&& 渲染数字。

{
  items.length && <List />;
} // 长度为 0 时页面显示 "0"
{
  items.length > 0 && <List />;
} // 正确

陷阱二:列表 key。

{
  items.map((item) => <Row key={item.id} item={item} />);
}

key 用下标,在插入/删除时会让状态错位:展开状态、输入内容、滚动位置都会跑到别的行上。key 只在同级唯一,不需要全局唯一。

六、组件拆分的三个维度

维度问题结论
职责是否只做一件事多个职责就拆
变更频率是否与周围同时变化节奏不同就隔开
复用是否会在别处出现会复用就抽

反例:为了"看起来组件化",把一行文字抽成组件,结果 props 比 JSX 还长。

正例:把"取数 + loading + 错误 + 空态"收进一个自定义 Hook,视图只负责渲染四种状态。

两个信号说明边界划错了:

  • 拆出去的组件仍要读父组件的五个状态。
  • 一个组件向外抛了五个以上回调。

七、最隐蔽的 bug:组件定义在渲染里

function Page() {
  // 每次渲染都创建一个新组件类型
  const Row = ({ text }: { text: string }) => <div>{text}</div>;
  return (
    <>
      {items.map((i) => (
        <Row key={i.id} text={i.text} />
      ))}
    </>
  );
}

每次 Page 渲染,Row 都是一个新的函数引用,React 认为类型变了,于是卸载重建整棵子树:输入框失焦、动画重放、state 清空。

修法只有一条:组件定义提到模块顶层。需要闭包数据时,用 props 传进去,而不是在渲染里定义组件。

同理,<Component /> 与 {renderComponent()} 也不同:前者是元素,后者是调用函数,后者会绕过 hooks 规则。

八、ref 与命令式操作

// React 19:ref 可以直接作为 prop
function Input({ ref, ...rest }: ComponentProps<"input">) {
  return <input ref={ref} {...rest} />;
}

React 19 之前需要 forwardRef,19 起函数组件可以直接接收 ref prop。使用边界:

  • 只暴露必要的方法,不要让父组件随意操作内部 DOM。
  • 能用 props 表达的状态,不要用 ref 方法。
  • 需要暴露多个方法时,用 useImperativeHandle 收窄接口。

九、常见坑

  1. 把 props 复制到 state,后续 props 变化时界面卡在旧值。
  2. && 左边是数字,渲染出 0。
  3. 组件定义在渲染函数里,导致整棵子树重挂载。
  4. children 类型写 JSX.Element,传字符串或数组时类型报错;应该用 ReactNode。
  5. 用字符串传 HTML,既丢失 React 能力又有 XSS 风险。
  6. 复合组件里 Context 的 value 每次新建对象,所有子项重渲染。
  7. 解构 props 后忘记把 className 合并,导致外部样式失效。

十、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一个 Card 组件,支持 title、children、footer,并用它改造一段重复的 JSX。
  2. 写一个 Tabs 复合组件,要求 Context value 用 useMemo,并用 Profiler 验证切换时子项是否重渲染。
  3. 找出一个 props 超过 7 个的组件,尝试用 children 或配置数组收敛。
  4. 故意在渲染里定义子组件,观察输入框失焦现象,再把它提到顶层修复。

② 让 AI 写同一题

把第 2 题交给 AI,补一句 React 19 + TS,Context value 必须稳定。

③ 对比要盯的点

  • Context value 它用 useMemo 了吗?没用的话,父组件每次渲染都会让所有 Tab 重新渲染。
  • 它把 activeKey 塞进了每个子项的 props 吗?塞了就退化成传参组件,不再是复合组件。
  • Tabs.Panel 在 Tabs 之外使用时,它是报错还是静默拿到 undefined?

④ 一句话取舍

你背下了“Context value 要 memo”,但它真的省了渲染吗?用 Profiler 数出前后次数,说不出数字就不算优化。

十一、自测

  1. props 为什么必须只读?
  2. children 与具名插槽的区别是什么?
  3. 为什么组合优于继承?
  4. 组件定义在渲染里会发生什么?
  5. ReactNode 与 JSX.Element 的区别是什么?

参考答案要点:数据流单向可追踪;children 是一段内容,具名插槽是多个命名区域;函数组件没有继承,组合更灵活;每次都是新类型,整树重挂载;ReactNode 包含字符串、数组、null 等。

附:props 设计检查清单

组件难用,通常不是实现问题,而是 props 没设计好。下面七条可以作为评审标准:

检查项反例更好的做法
不传“模式开关”isEdit、isDetail、isCreate拆成三个场景组件或传 mode 枚举
不传内部实现细节iconSize、labelWidth由样式类或 children 控制
布尔命名用肯定式notDisableddisabled
回调命名表达意图handleClickonSubmit、onRemove
受控与非受控不混用同时传 value 与 defaultValue二选一,或明确“仅初始值”
不用 props 传 HTML 字符串content="<b>x</b>"传 ReactNode
透传用 ComponentProps逐个列 20 个原生属性ComponentProps<"button"> + ...rest

“模式开关”是最常见的坏味道。一个组件里同时出现 isEdit、isDetail、readonly 三个布尔,实际组合出八种状态,其中至少三种没意义。把它们收敛成一个 mode: "create" | "edit" | "view",非法组合就从类型层面消失了。

另一个容易忽略的点是默认值的方向。默认值应该指向“安全且常见”的行为:按钮默认 type="button"(避免误提交)、选择器默认不选中、弹窗默认不自动聚焦。默认值选错,每个调用方都得写一遍覆盖,那就不是默认值了。

最后是命名一致性:onXxx 表示事件回调,renderXxx 表示渲染函数,xxxSlot 不要再出现(React 没有插槽)。约定一旦统一,读代码时能靠名字判断意图。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解