419 lines
15 KiB
Markdown
419 lines
15 KiB
Markdown
---
|
||
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
|