模块一 · 起步与渲染
组件与组合:props、children 与拆分边界
组件是 React 的最小复用单位。这一节讲清 props 的约束、children 与具名插槽的用法、组合优于继承的落地方式,以及组件拆分与重挂载的边界。
读完这一节,你能做到
- 用类型把 props 写成契约,而不是
any。 - 用
children与具名插槽实现结构复用,不再传 HTML 字符串。 - 判断一个组件该拆还是该合。
- 避免"组件定义在渲染里"这类隐蔽的重挂载问题。
一、组件就是一个函数
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收窄接口。
九、常见坑
- 把 props 复制到 state,后续 props 变化时界面卡在旧值。
&&左边是数字,渲染出0。- 组件定义在渲染函数里,导致整棵子树重挂载。
children类型写JSX.Element,传字符串或数组时类型报错;应该用ReactNode。- 用字符串传 HTML,既丢失 React 能力又有 XSS 风险。
- 复合组件里 Context 的 value 每次新建对象,所有子项重渲染。
- 解构 props 后忘记把
className合并,导致外部样式失效。
十、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个
Card组件,支持title、children、footer,并用它改造一段重复的 JSX。 - 写一个
Tabs复合组件,要求 Context value 用useMemo,并用 Profiler 验证切换时子项是否重渲染。 - 找出一个 props 超过 7 个的组件,尝试用 children 或配置数组收敛。
- 故意在渲染里定义子组件,观察输入框失焦现象,再把它提到顶层修复。
② 让 AI 写同一题
把第 2 题交给 AI,补一句 React 19 + TS,Context value 必须稳定。
③ 对比要盯的点
- Context value 它用
useMemo了吗?没用的话,父组件每次渲染都会让所有 Tab 重新渲染。 - 它把
activeKey塞进了每个子项的 props 吗?塞了就退化成传参组件,不再是复合组件。 Tabs.Panel在Tabs之外使用时,它是报错还是静默拿到undefined?
④ 一句话取舍
你背下了“Context value 要 memo”,但它真的省了渲染吗?用 Profiler 数出前后次数,说不出数字就不算优化。
十一、自测
- props 为什么必须只读?
children与具名插槽的区别是什么?- 为什么组合优于继承?
- 组件定义在渲染里会发生什么?
ReactNode与JSX.Element的区别是什么?
参考答案要点:数据流单向可追踪;children 是一段内容,具名插槽是多个命名区域;函数组件没有继承,组合更灵活;每次都是新类型,整树重挂载;ReactNode 包含字符串、数组、null 等。
附:props 设计检查清单
组件难用,通常不是实现问题,而是 props 没设计好。下面七条可以作为评审标准:
| 检查项 | 反例 | 更好的做法 |
|---|---|---|
| 不传“模式开关” | isEdit、isDetail、isCreate | 拆成三个场景组件或传 mode 枚举 |
| 不传内部实现细节 | iconSize、labelWidth | 由样式类或 children 控制 |
| 布尔命名用肯定式 | notDisabled | disabled |
| 回调命名表达意图 | handleClick | onSubmit、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 没有插槽)。约定一旦统一,读代码时能靠名字判断意图。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★☆ React 组件复用:组合和继承怎么选?
- ★★★★☆ HOC、Render Props 和复合组件分别适合什么场景?
- ★★★★☆ 复合组件怎么设计?Context 要注意什么?
提示:读完可以顺手做几道对应题目,检验是否真的理解