Files
cs-note/hhs/REACT/1. 基础篇/03-组件与 Props.md
T

419 lines
15 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [React, Components, Props, Frontend]
create time: 2026-04-29 22:02
---
# 组件与 Props
## 概述
React 应用由一组可复用的组件构成。理解组件的拆分粒度、Props 的类型安全传递,以及组合优于继承的设计哲学,是写出高质量 React 代码的核心。本章从组件的基本形态讲起,深入到 Props 的校验与传递模式,最后介绍组件组合的高级技巧。
> [!tip] 本节学习路线
> 1. 函数组件 vs 类组件 → 为什么函数组件成为主流
> 2. 组件拆分原则 → 如何找到合理的职责边界
> 3. Props 定义与校验 → TypeScript 方式(推荐)和 PropTypes 方式
> 4. Props 传递模式 → 基础透传、回调通信、children 插槽
> 5. Composition 高级模式 → React.memo、forwardRef、受控组件
> 6. Context 初探 → 轻量级跨层级通信
## 函数组件 vs 类组件
```tsx
// ✅ 函数组件(当前推荐写法)
// 输入 props,输出 JSX —— 纯函数的直观映射
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}</h1>;
}
// ⚠️ 类组件(遗留项目常见,新项目中应避免)
// this.props + render() —— 需要额外处理 this 绑定
class Greeting extends React.Component<{ name: string }> {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
```
### 为什么推荐函数组件?
| 对比维度 | 函数组件 + Hooks | 类组件 |
|----------|------------------|--------|
| 心智模型 | 纯函数:输入 props → 输出 JSX | 实例方法 + this 绑定 |
| 逻辑复用 | Custom Hooks(声明式组合) | HOC / Render Props(嵌套地狱)|
| 类型推导 | TS 自动推断完善 | 需要手动泛型参数 |
| 性能开销 | 无实例创建成本 | new 实例 + bind overhead |
| Suspense | 完全支持 | 不支持 |
| Hooks 绑定 | 基于调用顺序 | 依赖 this 上下文 |
> [!note] Hooks 绑定的本质
> useState、useEffect 等 Hook 按调用顺序内部维护一个链表。这就是为什么 Hook **不能写在条件语句或循环中**——顺序一旦改变,状态就会错配。
## 组件拆分原则
> [!question] 思考:一个按钮算一个独立组件,还是把按钮和其外层容器一起写?
>
> 如果按钮只在这一处使用,合并更简洁;如果多个地方共用同一个按钮样式或交互逻辑,就应该拆出来。核心判断标准:**这个组件是否有独立的业务语义?**
好的拆分遵循 **"单一职责" + "合理抽象"**。下面用流程图展示不同层级的职责划分:
```mermaid
graph LR
subgraph P ["Page Level — 页面组装层"]
A["AboutPage<br/>拼接各功能模块"]
end
subgraph F ["Feature Level — 功能模块层"]
B["UserProfile<br/>头像 + 昵称 + 设置入口"]
C["Dashboard<br/>图表 + 数据表格"]
end
subgraph C2 ["Component Level — 通用 UI 层"]
D["Button<br/>按钮变体 & 尺寸"]
E["Modal<br/>弹窗外壳"]
end
subgraph PL ["Primitive Level — 原子层"]
F2["IconButton<br/>图标按钮"]
G["Avatar<br/>头像图片"]
end
A --> B
A --> C
B --> D
B --> G
C --> E
D --> F2
```
### 判断是否该拆分的标准
1. **可复用性** — 同一组 JSX 出现两次以上,考虑抽取为独立组件
2. **可读性** — 单个文件超过 300 行、单个组件超过 80 行,应考虑拆分
3. **测试粒度** — 难以单独测试的巨型组件应拆为小组件
4. **业务语义** — 每个组件应对应一个清晰的业务概念
```tsx
// ❌ 反例:巨型组件,混合了多个不相关的职责
function Dashboard() {
// 200+ 行,混合了 Header、Sidebar、DataTables、Chart...
// 改 Header 要遍历整文件,测试也要 mock 所有子模块
}
// ✅ 正例:拆分为功能块,每个组件职责单一
function Dashboard() {
return (
<>
<DashboardHeader /> {/* 专注头部信息 */}
<aside><Sidebar /></aside> {/* 专注侧边导航 */}
<main>
<DataTable data={tasks} /> {/* 专注数据表格 */}
<Chart data={stats} /> {/* 专注图表展示 */}
</main>
</>
);
}
```
## Props 定义与类型校验
### TypeScript 接口方式(推荐)
```tsx
interface ButtonProps {
label: string; // 必填:按钮文字
variant?: "primary" | "secondary" | "danger"; // 可选:视觉变体(联合类型约束)
size?: "sm" | "md" | "lg"; // 可选:尺寸档位
disabled?: boolean; // 可选:禁用态
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; // 可选:点击回调
children?: React.ReactNode; // 可选:内容插槽
}
// 方式1:解构赋值设默认值(适合简单默认值)
function Button({ label, variant = "primary", onClick }: ButtonProps) {
return <button className={`btn btn-${variant}`} onClick={onClick}>{label}</button>;
}
// 方式2:全部字段显式声明默认值(适合需要覆盖全部可选字段时)
function Button({ label, variant = "primary", size = "md", disabled = false, children }: ButtonProps) {
return <button className={`btn btn-${variant} btn-${size}`} disabled={disabled}>{children}</button>;
}
```
> [!tip] 类型定义的位置建议
> - 小型组件:interface 与组件放在同一文件中
> - 大型组件库:type 导出到独立的 `types.ts`,便于跨文件引用和复用
### PropTypes 方式(JS 项目或过渡期)
```js
import PropTypes from 'prop-types';
Button.propTypes = {
label: PropTypes.string.isRequired, // 必填字符串
variant: PropTypes.oneOf(["primary", "secondary"]), // 枚举限制
onClick: PropTypes.func, // 可选函数
};
```
> [!warning] 不要混用两种校验方式
> TS 提供**编译时检查**,PropTypes 是**运行时兜底**。选其一即可,新项目统一用 TS。混用会导致维护和调试成本翻倍。
## Props 传递模式
### 基础传递
```tsx
// 1. 直接传递字面量值
<Button label="提交" disabled />
// 2. 传递变量或表达式
<Button label={submitText} onClick={handleSubmit} />
```
### 展开透传(Spread Props)
```tsx
// 当一层组件需要将自身 props 原样传给子组件时,避免逐个手写
const inputProps = { placeholder: "请输入", onChange: handleChange };
<Input {...inputProps} /> // ⚡ Spread Operator 将对象解包为独立的 props
// 局部覆盖:父 prop 优先级低于子 prop(后者覆盖前者)
<Input {...inputProps} autoFocus /> // autoFocus 额外传入,其余来自 inputProps
```
### 回调 Prop(子 → 父通信)
```tsx
// 子组件通过回调将数据或事件通知父组件
<Form onSubmit={(data) => console.log("收到表单数据:", data)} />
// 💡 父组件将处理函数作为 prop 传入,子组件在合适时机调用它
```
### Render Item Prop(列表渲染定制)
```tsx
// 父组件告诉子组件:"每个 item 长什么样"
<List
items={items}
renderItem={(item) => <ListItem key={item.id} {...item} />}
/>
// 📌 比完整的 Render Props 模式更轻量的写法
```
## Children 与 Composition
```tsx
// Card 接收 children(任意 JSX 内容),灵活度远超固定 string prop
function Card({ children, header }: { children: React.ReactNode; header: string }) {
return (
<div className="card">
<h3>{header}</h3>
<div className="card-body">{children}</div>
</div>
);
}
// 使用示例:children 可以包含任意合法的 JSX
<Card header="用户信息">
<p>姓名:张三</p>
<p>年龄:25</p>
</Card>
// Fragment —— 不产生额外 DOM 节点,用于包裹兄弟元素
function ListItem({ text, icon }: { text: string; icon: React.ReactNode }) {
return (
<li>
<> {/* ⚡ Fragment 缩写语法 */}
<span className="icon">{icon}</span>
<span>{text}</span>
</>
</li>
);
}
```
> [!question] children 的类型为什么是 React.ReactNode 而不是 JSX.Element?
> - `JSX.Element` 只代表 React 元素(如 `<div />`、`<Comp />`)
> - `React.ReactNode` 范围更广:包含字符串、数字、Fragment、数组、甚至 null/undefined
> - 💡 如果用 `JSX.Element`,当子元素是纯文本或数组时会报错
## React.memo —— 减少不必要的重渲染
```tsx
// memo 对组件做了一层"浅比较"包装:只有 props 变化时才重新渲染
const UserItem = React.memo(function UserItem({ name, age }: { name: string; age: number }) {
// 即使父组件其他 state 变化,只要 name 和 age 不变,就不会重渲染
return <li>{name},{age} 岁</li>;
});
// 自定义比较函数(仅在需要深比较时使用,一般不需要)
const compare = (prev: UserItemProps, next: UserItemProps) => prev.name === next.name && prev.age === next.age;
const UserItemDeep = React.memo(UserItem, compare);
```
> [!tip] memo 的性能权衡
> - 适用于**频繁重渲染但 props 稳定**的叶子组件
> - 过度使用会增加维护成本和内存开销——先用 Profile 工具定位瓶颈,再决定是否加 memo
## forwardRef —— 访问子组件 DOM 节点
```tsx
// 父组件需要通过 ref 操作子组件内部的 DOM(如聚焦输入框)
const FancyInput = forwardRef<HTMLInputElement, { placeholder: string }>(({ placeholder }, ref) => {
return <input ref={ref} placeholder={placeholder} />;
});
// 使用方可以拿到原生 input 的 ref
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // 💡 挂载后自动聚焦
}, []);
return <FancyInput ref={inputRef} placeholder="请输入..." />;
}
```
> [!important] forwardRef 的使用场景
> 不是所有组件都需要 forwardRef。**优先通过 props 解决问题**(可控组件模式)。只在以下场景才需要:
> - 需要暴露 DOM API(聚焦、测量、滚动等)
> - 封装底层 UI 库组件时需要透传 ref
## 受控组件模式(Controlled Components)
```tsx
// 组件的内部 state 由外部 props 控制 —— 单一数据源原则
function ControlledInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<input
value={value} // 🔑 value 由外部决定
onChange={(e) => onChange(e.target.value)} // 🔑 变化时通知外部更新
placeholder="输入内容"
/>
);
}
// 父组件持有唯一真实状态
function SearchPage() {
const [query, setQuery] = useState("");
return (
<>
<ControlledInput value={query} onChange={setQuery} />
<p>搜索关键词:{query || "(空)"}</p>
</>
);
}
```
> [!note] 受控 vs 非受控
> - **受控**:state 在父组件中管理,数据流向清晰(✅ 推荐大多数场景)
> - **非受控**:state 在子组件内部管理,通过 ref 读取(⚡ 适合简单的表单快速实现)
## Context 初探(轻量级全局状态)
Context 是 React 内置的跨组件树通信机制,适合传递那些"多处用到但不应逐层透传"的数据。
```tsx
// 创建 Context,初始值通常设为占位值(实际值由 Provider 注入)
const ThemeContext = createContext<"light" | "dark">("light");
function App() {
const [theme, setTheme] = useState<"light" | "dark">("light");
return (
<ThemeContext.Provider value={theme}>
{/* 其下所有子组件(不限层级)都可以通过 useContext 消费 */}
<Toggle />
<Content />
</ThemeContext.Provider>
);
}
function Toggle() {
const theme = useContext(ThemeContext); // 无需中间层透传
return <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>切换主题</button>;
}
```
> [!warning] Context 的性能陷阱
> - Provider value 如果是**新对象**,每次渲染都是不同的引用 → 所有消费者全部重渲染
> - ✅ 解决:用 `useReducer` 返回稳定的 `{state, dispatch}` 对象,保持引用一致
> - ✅ 解决:拆分 Context,避免一个大 Context 塞入过多数据
## 组件组合的进阶模式
### Compound Components(复合组件)
当一个组件的子项之间存在**隐式共享状态**时,使用复合组件模式:
```tsx
// 手风琴组件:Tab 之间共享 activeIndex,但父组件无需关心
function Accordion({ children }: { children: React.ReactNode }) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
return (
<AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
function AccordionTab({ title, children }: { title: string; children: React.ReactNode }) {
const ctx = useContext(AccordionContext);
// 💡 每个 Tab 自己从 Context 里拿状态,父组件不用逐一传递
const isActive = ctx.activeIndex !== null;
return (
<div onClick={() => ctx.setActiveIndex(ctx.activeIndex)}>
<h3>{title}</h3>
{isActive && <div>{children}</div>}
</div>
);
}
// 对外暴露子组件属性,方便用户使用
Accordion.Tab = AccordionTab;
// 使用:状态在 Tabs 间共享,父组件只需包裹
<Accordion>
<Accordion.Tab title="第一章">...</Accordion.Tab>
<Accordion.Tab title="第二章">...</Accordion.Tab>
</Accordion>
```
> [!question] 什么时候用 compound components?
> 当你的一组子组件需要**彼此感知对方的状态**,又不想让父组件来协调时。典型场景:Tabs/Accordion/SwitchGroup/Table。
### Composition Flow
```mermaid
sequenceDiagram
participant Parent as 父组件
participant Child as 子组件
participant DOM as DOM
Parent->>Child: 传入 props + children(JSX 树)
Note over Parent,Child: Props 描述配置,Children 描述内容
Child->>DOM: 根据 props 渲染特定结构
Child->>Child: 插入 children 到指定位置
Note over Child: 组合 = 配置 + 内容的分离
```
> [!tip] 组合 vs 继承
> React 的设计哲学是 **组合优于继承**:
> - JS 的 class 继承会导致紧耦合和脆弱的基类依赖
> - React 组合让每个组件保持独立,行为通过 props/children/Callbacks 灵活拼装
> - 💡 如果想共享逻辑,优先考虑 Custom Hooks 而非继承
## 关联笔记
- [[REACT/1. 基础篇/01-环境搭建与项目结构]] — React 项目初始化与目录规范
- [[REACT/1. 基础篇/02-JSX 语法]] — JSX 表达式、事件绑定、条件渲染
- [[REACT/1. 基础篇/04-State 与不可变性]] — State 设计原则与更新模式
- [[REACT/2. Hooks 篇/05-核心 Hooks]] — useState/useEffect/useContext/useRef 原理
- [[REACT/2. Hooks 篇/07-自定义 Hooks]] — 逻辑复用与常见 Hook 封装
- [[REACT/4. 进阶篇/11-组件通信模式]] — Props Drill / Context / 状态管理库方案对比
- [[REACT/3. 生态工具篇/09-状态管理]] — Context API vs Zustand vs Redux Toolkit
- [[REACT/5. 工程实践篇/15-性能优化]] — React.memo / 虚拟列表 / Code Splitting