Files
cs-note/hhs/REACT/1. 基础篇/03-组件与 Props.md
T
2026-05-24 11:42:38 +08:00

419 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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