React(五):Props 组件通信的桥梁
目录
第一部分:Props 核心概念 —— 快递包裹的启示
1.1 生活化类比:快递包裹
想象你在网上买了一件衣服。商家打包时:
- 包裹上贴着快递单,上面写着:收货人(你)、商品名称(T恤)、颜色(白色)、尺码(L号)
- 你收到包裹后可以打开使用,但你不能修改快递单上的信息——那是商家发货时确定的
- 如果商家发错了颜色,你不能自己在快递单上改,只能联系商家重新发货
React 中的 Props 就是这个快递包裹:
| 快递场景 | React Props 场景 |
|---|---|
| 商家(发货方) | 父组件(数据的源头,负责"打包"并传递) |
| 快递单上的信息 | Props 对象(装着所有配置数据的容器) |
| 收货人(你) | 子组件(接收数据的一方) |
| 收到包裹后使用衣服 | 子组件用 props 渲染 UI |
| 不能改快递单 | Props 是只读的,子组件不能修改 |
| 联系商家重新发货 | 子组件通过回调函数通知父组件更新数据 |
// 就像商家(父组件)给你(子组件)寄了一件指定颜色和尺码的 T 恤
<ClothingItem
name="纯棉T恤" // 相当于快递单上的"商品名称"
color="白色" // 相当于快递单上的"颜色"
size="L" // 相当于快递单上的"尺码"
/>
一句话总结:Props(Properties 的缩写)是父组件传递给子组件的数据"包裹",子组件只能读取、使用,不能拆开修改里面的内容。 它是 React 单向数据流的物理载体。
1.2 Props 是什么?(白话版)
用最直白的话说:Props 就是组件函数的参数。
// 你写了一个普通函数,它接收参数
function sayHello(userName) {
console.log("你好," + userName);
}
sayHello("小明"); // 输出:你好,小明
// React 函数组件就是这样的函数,只不过参数叫做 props
function SayHello(props) {
// props 是一个对象,装着调用者传来的所有"参数"
return <h1>你好,{props.userName}!</h1>;
}
// 使用时就像给函数传参一样:
<SayHello userName="小明" /> {/* 渲染结果:你好,小明!*/}
| 普通 JavaScript 函数 | React 函数组件 |
|---|---|
function fn(a, b) |
function Comp(props) |
fn(1, 2) 调用传参 |
<Comp a={1} b={2} /> |
| 参数在函数体内使用 | props 在 JSX 中使用 |
| 参数是只读的局部变量 | props 是只读的 |
1.3 Props 在 React 数据流中的位置
React 的核心设计原则是单向数据流:
父组件(数据的拥有者)
│
│ 通过 Props 向下传递
▼
子组件(数据的使用者)
│
│ 通过 Props 继续向下传递
▼
孙子组件(数据的最终使用者)
- 数据只能从父流向子,不能反过来(就像水往低处流)
- 如果子组件需要"改变"父组件的数据,必须通过回调函数(父组件把一个函数作为 props 传给子组件,子组件调用这个函数"上报"变化)
口诀:数据向下流,事件向上冒。Props 是单向高速公路,只能从上往下开。
第二部分:Props 的类型定义
在 React 中定义 Props 的类型,主要有两大流派:
- TypeScript 类型注解:编译时检查,写在代码里,IDE 有智能提示(现代项目首选)
- PropTypes 运行时校验:运行时检查,不阻止运行只打印警告,兼容纯 JS 项目
下面分别覆盖两种组件(函数组件、类组件)在 TS 下的写法,以及纯 JS 下 PropTypes 的完整用法。
2.1 函数组件 TypeScript 类型定义
2.1.1 基础类型 —— 最常用的场景
// 01-ts-basic.tsx —— 可直接复制运行
// ============================================================
// 第一步:用 interface 或 type 定义 Props 的"形状"(Shape)
// interface:接口,可以理解为"一份合同",规定 props 必须长什么样
// ============================================================
interface UserCardProps {
// 属性名 类型注解 含义
name: string; // 姓名,字符串类型
age: number; // 年龄,数字类型
isVip: boolean; // 是否为 VIP,布尔类型
}
// ============================================================
// 第二步:在函数参数后标注类型
// ============================================================
function UserCard({ name, age, isVip }: UserCardProps) {
// 解构取值的同时获得完整的类型提示
return (
<div style={{ border: "1px solid #ccc", padding: 16, borderRadius: 8 }}>
<h2>{name}</h2>
{/* 鼠标悬停在 name 上,IDE 会显示:string */}
<p>年龄:{age} 岁</p>
{/* 鼠标悬停在 age 上,IDE 会显示:number */}
<p>身份:{isVip ? "VIP 会员" : "普通用户"}</p>
{/* isVip 是 boolean,三元表达式类型安全 */}
</div>
);
}
// ============================================================
// 第三步:使用组件时传错类型 → TypeScript 直接标红报错
// ============================================================
function App() {
return (
<div>
{/* ✅ 正确:类型全部匹配 */}
<UserCard name="小明" age={18} isVip={true} />
{/* ❌ 错误:age 传了字符串,TS 编译报错 */}
{/* <UserCard name="小明" age="十八岁" isVip={true} /> */}
{/* ❌ 错误:缺少必填属性 name,TS 编译报错 */}
{/* <UserCard age={18} isVip={true} /> */}
</div>
);
}
export default App;
白话版:
interface相当于你跟 TypeScript 签了一份合同——"我的组件需要三个东西:name(字符串)、age(数字)、isVip(布尔),缺一不可,类型不对也不行。" TypeScript 就像一个严格的合同审核员,传错就标红。
2.1.2 可选类型 —— 有些 Props 可以不传
// 02-ts-optional.tsx —— 可直接复制运行
// ============================================================
// 在属性名后加 ? 表示"这个属性可以不传"
// ============================================================
interface ButtonProps {
text: string; // 必填:按钮上显示的文字
size?: "small" | "medium" | "large"; // 可选:按钮尺寸,只能选这三者之一
// └── ? 表示"可以不传,传了就必须是这三个值之一"
disabled?: boolean; // 可选:是否禁用,不传默认为 false
onClick?: () => void; // 可选:点击回调函数
// └── void 表示"这个函数没有返回值"
}
function Button({
text,
size = "medium",
disabled = false,
onClick,
}: ButtonProps) {
// └── 解构时给默认值(TypeScript 也能识别)
// 根据 size 计算实际像素值
const sizeMap = {
small: { padding: "6px 12px", fontSize: 12 },
medium: { padding: "10px 20px", fontSize: 16 },
large: { padding: "14px 28px", fontSize: 20 },
};
return (
<button
style={{
...sizeMap[size], // size 永远有值,因为有默认值兜底
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
}}
disabled={disabled} // disabled 是 HTML button 的原生属性
onClick={onClick} // onClick 可能为 undefined,但 button 的 onClick 接受 undefined
>
{text}
</button>
);
}
// ============================================================
// 使用演示:可选属性可传可不传
// ============================================================
function App() {
return (
<div style={{ display: "flex", gap: 10, padding: 20 }}>
{/* ✅ 只传必填的 text,其余用默认值 */}
<Button text="默认按钮" />
{/* ✅ 传递部分可选属性 */}
<Button text="大号按钮" size="large" />
{/* ✅ 传递全部属性 */}
<Button
text="不可点击"
size="small"
disabled={true}
onClick={() => alert("这个按钮不可点击")}
/>
</div>
);
}
export default App;
白话版:
?就是"选填"标记。就像填表——姓名是必填(不带?),手机号是选填(带?)。选填项不写也没事,TypeScript 不会报警告。
2.1.3 联合类型 —— 限定可选值的范围
// 03-ts-union.tsx —— 可直接复制运行
// ============================================================
// 联合类型:用 | 分隔多个可能的类型
// 作用:比 string 更精确,防止传入意料之外的值
// ============================================================
// 定义状态类型 —— 只能是这三个字符串之一
type StatusType = "success" | "warning" | "error";
// └── type 关键字:给类型起一个别名(类似给手机号存一个"联系人"名字)
// 定义按钮变体类型
type VariantType = "primary" | "danger" | "default" | "text";
interface StatusBadgeProps {
status: StatusType; // 必填,值必须是 "success" | "warning" | "error"
variant?: VariantType; // 可选,值必须是四个变体之一
message: string; // 必填的提示文字
}
function StatusBadge({
status,
variant = "default",
message,
}: StatusBadgeProps) {
// 根据 status 精确匹配颜色(不需要 default 兜底,因为 TS 保证不会传错)
const colorMap: Record<StatusType, string> = {
// Record<Key类型, Value类型>:创建一个"键值对字典"的类型
success: "#4CAF50", // 绿色
warning: "#FF9800", // 橙色
error: "#F44336", // 红色
};
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 16px",
backgroundColor: colorMap[status],
color: "white",
borderRadius: 6,
fontSize: 14,
}}
>
{/* 不同状态显示不同图标 */}
<span>
{status === "success" ? "✓" : status === "warning" ? "⚠" : "✕"}
</span>
<span>{message}</span>
{variant !== "default" && (
<span style={{ fontSize: 11, opacity: 0.8 }}>({variant})</span>
)}
</div>
);
}
function App() {
return (
<div
style={{ display: "flex", flexDirection: "column", gap: 10, padding: 20 }}
>
<StatusBadge status="success" message="数据保存成功" />
<StatusBadge status="warning" message="磁盘空间不足" variant="danger" />
<StatusBadge status="error" message="网络连接失败" variant="primary" />
{/* ❌ 编译报错:"loading" 不在 StatusType 的范围内 */}
{/* <StatusBadge status="loading" message="加载中..." /> */}
</div>
);
}
export default App;
白话版:
type StatusType = "success" | "warning" | "error"意思是"StatusType 这个类型只能取这三个值,其他的都不行"。就像自助餐厅只有三种饮料(可乐、雪碧、芬达),你点不了橙汁——TypeScript 会直接拦住你。
2.1.4 复杂引用类型 —— 对象、数组、函数的类型定义
// 04-ts-complex.tsx —— 可直接复制运行
// ============================================================
// 实际开发中最常见的 props 类型:对象、数组、函数、children
// ============================================================
// ---------- 1. 定义嵌套对象的类型 ----------
interface Address {
city: string; // 城市
district: string; // 区
detail?: string; // 详细地址(可选)
}
// ---------- 2. 定义数组元素的类型 ----------
interface Hobby {
id: number; // 唯一ID
name: string; // 爱好名称
level: "beginner" | "intermediate" | "advanced"; // 熟练程度
}
// ---------- 3. 定义组件的 Props ----------
interface UserProfileProps {
// ----- 对象类型 -----
user: {
name: string;
age: number;
address: Address; // 嵌套对象,引用上面定义的 Address 类型
};
// ----- 数组类型 -----
hobbies: Hobby[]; // Hobby 类型的数组,等价于 Array<Hobby>
// ----- 函数类型(回调) -----
onFollow: (userId: number) => void;
// └── 参数列表 └── 返回值类型(void = 无返回值)
onDelete: (userId: number, reason: string) => Promise<void>;
// └── Promise<void> = 返回一个 Promise
// ----- children 类型 -----
children?: React.ReactNode;
// └── ReactNode 是 React 中最宽泛的"可渲染内容"类型
// 可以是 string、number、JSX 元素、数组、null、undefined 等
}
function UserProfile({
user,
hobbies,
onFollow,
onDelete,
children,
}: UserProfileProps) {
// 计算用户等级(根据年龄)
const userLevel = user.age >= 60 ? "老年" : user.age >= 30 ? "中年" : "青年";
return (
<div
style={{
border: "1px solid #e0e0e0",
borderRadius: 12,
padding: 24,
maxWidth: 400,
}}
>
{/* 头部:用户基础信息 */}
<div style={{ marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>{user.name}</h2>
<p style={{ color: "#888", margin: "4px 0" }}>
{user.age} 岁 · {userLevel} · {user.address.city}{" "}
{user.address.district}
</p>
{user.address.detail && (
<p style={{ color: "#aaa", fontSize: 13, margin: 0 }}>
{user.address.detail}
</p>
)}
</div>
{/* 爱好列表 */}
<div style={{ marginBottom: 16 }}>
<h4 style={{ margin: "0 0 8px" }}>爱好标签:</h4>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{hobbies.map((hobby) => (
<span
key={hobby.id} // key 必须在 map 的最外层元素上
style={{
padding: "4px 10px",
borderRadius: 12,
fontSize: 13,
backgroundColor:
hobby.level === "advanced"
? "#e8f5e9"
: hobby.level === "intermediate"
? "#fff3e0"
: "#f3e5f5",
color:
hobby.level === "advanced"
? "#2e7d32"
: hobby.level === "intermediate"
? "#e65100"
: "#6a1b9a",
}}
>
{hobby.name}
</span>
))}
</div>
</div>
{/* children 插槽区域 */}
{children && (
<div
style={{
marginBottom: 16,
padding: 12,
backgroundColor: "#f9f9f9",
borderRadius: 8,
}}
>
{children}
</div>
)}
{/* 操作按钮 */}
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => onFollow(1)}
style={{ padding: "8px 20px", cursor: "pointer" }}
>
关注
</button>
<button
onClick={() => onDelete(1, "不再感兴趣")}
style={{
padding: "8px 20px",
cursor: "pointer",
color: "#f44336",
border: "1px solid #f44336",
background: "transparent",
}}
>
删除
</button>
</div>
</div>
);
}
function App() {
// 模拟用户数据(实际开发中通常来自 API 请求)
const sampleUser = {
name: "小明",
age: 25,
address: {
city: "北京",
district: "朝阳区",
detail: "望京SOHO T3 12层",
},
};
const sampleHobbies: Hobby[] = [
// 显式标注数组类型,获得智能提示
{ id: 1, name: "React 编程", level: "advanced" },
{ id: 2, name: "篮球", level: "intermediate" },
{ id: 3, name: "摄影", level: "beginner" },
];
return (
<UserProfile
user={sampleUser}
hobbies={sampleHobbies}
onFollow={(id) => console.log("关注了用户", id)}
onDelete={async (id, reason) => {
// async 函数自动返回 Promise
console.log("删除了用户", id, "原因:", reason);
}}
>
{/* 这段 JSX 会通过 children prop 传给 UserProfile */}
<p style={{ margin: 0, fontSize: 14, color: "#666" }}>
💬 "热爱生活,热爱代码。"
</p>
</UserProfile>
);
}
export default App;
白话版:
React.ReactNode是 React 里"什么都能渲染的东西"的统一名字,包括文字、数字、HTML标签、其他组件等。() => void表示"这个函数不需要返回任何东西,调它就是为了执行一个操作"。() => Promise<void>表示"这个函数做的是异步操作(比如发网络请求)"。
2.1.5 综合示例 —— 一个完整组件所需的全部 TS Props 技巧
// 05-ts-complete.tsx —— 可直接复制运行,覆盖所有常见 Props 类型场景
import React, { useState } from "react";
// ==================== 类型定义区 ====================
// 表格列配置的类型
interface Column {
key: string; // 列的 key(对应数据中的字段名)
title: string; // 列标题
width?: number; // 列宽(可选)
sortable?: boolean; // 是否可排序(可选)
render?: (value: unknown, record: Record<string, unknown>) => React.ReactNode;
// └── unknown 表示"我不知道是什么类型,但比 any 安全"
// └── Record<string, unknown> = { [key: string]: unknown }
// 即"键是字符串、值类型未知的对象"
}
// 分页配置的类型
interface Pagination {
current: number; // 当前页码
pageSize: number; // 每页条数
total: number; // 总条数
onChange: (page: number) => void; // 翻页回调
}
// 组件的 Props
interface DataTableProps {
// --- 必填 ---
columns: Column[]; // 列配置数组
dataSource: Record<string, unknown>[]; // 数据源(对象数组)
// --- 可选 ---
rowKey?: string; // 行唯一标识的字段名,默认 "id"
loading?: boolean; // 加载状态
emptyText?: string; // 空数据提示文字
pagination?: Pagination; // 分页配置(整个对象可选)
}
// ==================== 组件实现 ====================
function DataTable({
columns,
dataSource,
rowKey = "id",
loading = false,
emptyText = "暂无数据",
pagination,
}: DataTableProps) {
// 当前排序状态
const [sortKey, setSortKey] = useState<string | null>(null);
// └── useState 的泛型:string | null 联合类型
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// └── 泛型限定只能是这两个字符串
// 排序逻辑
const sortedData = [...dataSource].sort((a, b) => {
if (!sortKey) return 0;
const aVal = a[sortKey];
const bVal = b[sortKey];
if (typeof aVal === "number" && typeof bVal === "number") {
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
}
return 0;
});
// 处理表头点击排序
const handleSort = (key: string) => {
if (sortKey === key) {
// 同一列 → 切换正序/倒序
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
// 不同列 → 重置为正序
setSortKey(key);
setSortOrder("asc");
}
};
// ===== 加载态 =====
if (loading) {
return <div style={{ textAlign: "center", padding: 40 }}>加载中...</div>;
}
// ===== 空数据态 =====
if (dataSource.length === 0) {
return (
<div style={{ textAlign: "center", padding: 40, color: "#999" }}>
{emptyText}
</div>
);
}
// ===== 正常数据态 =====
return (
<div>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
{/* 表头 */}
<thead>
<tr style={{ backgroundColor: "#f5f5f5" }}>
{columns.map((col) => (
<th
key={col.key}
style={{
padding: "12px 16px",
textAlign: "left",
borderBottom: "2px solid #e0e0e0",
cursor: col.sortable ? "pointer" : "default",
width: col.width,
}}
onClick={() => col.sortable && handleSort(col.key)}
>
{col.title}
{col.sortable && sortKey === col.key && (
<span style={{ marginLeft: 4 }}>
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
</th>
))}
</tr>
</thead>
{/* 表体 */}
<tbody>
{sortedData.map((record) => (
<tr
key={String(record[rowKey])}
style={{ borderBottom: "1px solid #f0f0f0" }}
>
{/* 将 rowKey 转为字符串,因为 key 需要 string | number */}
{columns.map((col) => (
<td key={col.key} style={{ padding: "12px 16px" }}>
{/* 有自定义 render 就用 render,没有就直接显示原始值 */}
{col.render
? col.render(record[col.key], record)
: String(record[col.key] ?? "-")}
{/* └── ?? 是"空值合并运算符"
record[col.key] 为 null 或 undefined 时显示 "-" */}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* 分页 */}
{pagination && (
<div
style={{
display: "flex",
justifyContent: "flex-end",
padding: 16,
gap: 8,
}}
>
<span style={{ color: "#666" }}>
共 {pagination.total} 条,第 {pagination.current}/
{Math.ceil(pagination.total / pagination.pageSize)} 页
</span>
<button
disabled={pagination.current <= 1}
onClick={() => pagination.onChange(pagination.current - 1)}
>
上一页
</button>
<button
disabled={
pagination.current >=
Math.ceil(pagination.total / pagination.pageSize)
}
onClick={() => pagination.onChange(pagination.current + 1)}
>
下一页
</button>
</div>
)}
</div>
);
}
// ==================== 使用示例 ====================
function App() {
const columns: Column[] = [
{ key: "name", title: "姓名", sortable: true },
{ key: "age", title: "年龄", width: 80, sortable: true },
{ key: "email", title: "邮箱" },
{
key: "status",
title: "状态",
// 自定义渲染:将原始值转换为带颜色的标签
render: (value) => {
// value 类型为 unknown,但你可以根据业务逻辑安全使用
const status = value as string; // 类型断言:告诉 TS"我知道它是 string"
const colorMap: Record<string, string> = {
active: "#4CAF50",
inactive: "#9E9E9E",
pending: "#FF9800",
};
return (
<span
style={{
padding: "2px 8px",
borderRadius: 4,
backgroundColor: colorMap[status] || "#e0e0e0",
color: "white",
fontSize: 13,
}}
>
{status}
</span>
);
},
},
];
const data: Record<string, unknown>[] = [
{
id: 1,
name: "小明",
age: 25,
email: "xiaoming@example.com",
status: "active",
},
{
id: 2,
name: "小红",
age: 30,
email: "xiaohong@example.com",
status: "pending",
},
{
id: 3,
name: "小刚",
age: 28,
email: "xiaogang@example.com",
status: "inactive",
},
];
const [page, setPage] = useState(1);
return (
<DataTable
columns={columns}
dataSource={data}
loading={false}
pagination={{
current: page,
pageSize: 2,
total: 3,
onChange: (p) => setPage(p),
}}
/>
);
}
export default App;
2.2 类组件 TypeScript 类型定义
虽然现在主流是函数组件,但维护老项目时仍会遇到类组件。以下是类组件 Props 的 TS 写法:
// 06-ts-class.tsx —— 类组件 TypeScript Props 定义
import React, { Component } from "react";
// ============================================================
// 第一步:定义 Props 和 State 的类型(和函数组件一样)
// ============================================================
interface ClassCardProps {
title: string; // 必填:卡片标题
content: string; // 必填:卡片内容
variant?: "default" | "outlined" | "elevated"; // 可选:卡片样式变体
onClose?: () => void; // 可选:关闭回调
}
// State(状态)也需要定义类型
interface ClassCardState {
isVisible: boolean; // 是否可见
clickCount: number; // 点击次数
}
// ============================================================
// 第二步:Component<Props类型, State类型>
// 泛型参数1 = Props 的类型
// 泛型参数2 = State 的类型
// ============================================================
class ClassCard extends Component<ClassCardProps, ClassCardState> {
// └── 两个泛型参数
// ----- 构造函数:初始化 state -----
constructor(props: ClassCardProps) {
super(props); // 类组件中必须先调用 super(props)
this.state = {
isVisible: true,
clickCount: 0,
};
}
// ----- 事件处理方法(需要用箭头函数绑定 this 或手动 bind)-----
handleClose = () => {
// 箭头函数自动绑定 this,不需要 constructor 里 bind
this.setState({ isVisible: false }); // 更新 state
this.props.onClose?.(); // 调用父组件传来的回调
// └── ?. 是"可选链操作符":onClose 存在才调用,不存在就跳过
};
handleClick = () => {
this.setState((prevState) => ({
// 函数式更新,prevState 保证是最新值
clickCount: prevState.clickCount + 1,
}));
};
// ----- render 方法:必须实现,返回 JSX -----
render() {
// 从 props 和 state 中解构取值
const { title, content, variant = "default" } = this.props;
// └── 类组件中 props 挂在 this 上,不是函数的参数
const { isVisible, clickCount } = this.state;
// 根据 variant 计算样式
const variantStyle: Record<string, React.CSSProperties> = {
// React.CSSProperties 是 style 属性的内置类型
default: {
border: "1px solid #ccc",
boxShadow: "none",
},
outlined: {
border: "2px solid #1976D2",
boxShadow: "none",
},
elevated: {
border: "none",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
};
if (!isVisible) {
// 不可见时返回 null(不渲染任何内容)
return null;
}
return (
<div
style={{
padding: 20,
borderRadius: 8,
maxWidth: 400,
...variantStyle[variant],
}}
>
{/* 关闭按钮 */}
{this.props.onClose && (
// 如果有 onClose 回调才显示关闭按钮
<button
onClick={this.handleClose}
style={{
float: "right",
cursor: "pointer",
border: "none",
background: "none",
}}
>
✕
</button>
)}
<h3 style={{ margin: "0 0 8px" }}>{title}</h3>
<p style={{ color: "#555", margin: 0 }}>{content}</p>
{/* 计数器 */}
<button onClick={this.handleClick} style={{ marginTop: 12 }}>
点击次数:{clickCount}
</button>
</div>
);
}
}
// ============================================================
// 类组件一样可以设置 defaultProps
// ============================================================
// ClassCard.defaultProps = {
// variant: "default",
// };
export default ClassCard;
白话版:类组件就像一个"模具车间"——
constructor是初始化设备(设置 state),render是生产产品(返回 JSX),this.props是外部送来的原材料,this.state是车间内部的库存。
2.3 纯 JS 环境下的 PropTypes 校验
如果项目不使用 TypeScript,React 提供了 PropTypes 来做运行时的类型校验。它在开发环境中检测到类型不匹配时,会在浏览器控制台打印警告。
2.3.1 安装与导入
# React 19 + Vite 环境通常已内置,无需额外安装
# 如果没有,运行:
npm install prop-types
import PropTypes from "prop-types";
2.3.2 全部常用校验规则速查
// 07-proptypes-all.jsx —— PropTypes 全部常用校验规则一览
import PropTypes from "prop-types";
function DemoComponent({
name,
age,
isAdmin,
hobbies,
address,
onSave,
status,
scores,
config,
avatar,
children,
}) {
// 组件逻辑省略...
return <div>{/* ... */}</div>;
}
// ============================================================
// 挂载 propTypes 对象 —— 定义每个 prop 的校验规则
// ============================================================
DemoComponent.propTypes = {
// ---------- 基础类型 ----------
name: PropTypes.string, // 字符串
age: PropTypes.number, // 数字(包括整数和小数)
isAdmin: PropTypes.bool, // 布尔值(true / false)
// ---------- 引用类型 ----------
hobbies: PropTypes.array, // 数组(不关心数组里是什么)
address: PropTypes.object, // 对象(不关心对象里有什么属性)
onSave: PropTypes.func, // 函数
// ---------- React 专用类型 ----------
avatar: PropTypes.element, // 一个 React 元素(如 <img />、<div />)
children: PropTypes.node, // 可渲染的内容(文字、元素、数组、数字等,最宽泛)
// ---------- 枚举类型(限定可选值范围) ----------
status: PropTypes.oneOf(["active", "inactive", "pending"]),
// 只能是这三个字符串之一,传其他值 → 警告
// ---------- 数组元素类型校验 ----------
scores: PropTypes.arrayOf(PropTypes.number),
// 必须是数组,且数组里每个元素都是数字
// ---------- 对象结构校验 ----------
config: PropTypes.shape({
// shape:对象必须包含指定的属性
title: PropTypes.string.isRequired, // 嵌套属性也可以加 isRequired
width: PropTypes.number,
height: PropTypes.number,
}),
// ---------- 必填(加 .isRequired) ----------
name: PropTypes.string.isRequired, // name 必须传,且必须是字符串
};
// ============================================================
// 设置默认值(可选属性没有传值时使用)
// ============================================================
DemoComponent.defaultProps = {
isAdmin: false, // 不传时默认为 false
hobbies: [], // 不传时默认为空数组
status: "active", // 不传时默认为 "active"
};
export default DemoComponent;
2.3.3 全部 PropTypes 校验规则速查表
| 写法 | 含义 | 示例合格值 | 示例不合格值 |
|---|---|---|---|
PropTypes.string |
字符串 | "hello" |
123、true |
PropTypes.number |
数字 | 123、3.14 |
"123" |
PropTypes.bool |
布尔值 | true、false |
0、"true" |
PropTypes.array |
数组 | [1,2,3]、["a","b"] |
{a:1} |
PropTypes.object |
对象 | {a:1}、new Date() |
null、[1,2] |
PropTypes.func |
函数 | () => {}、function(){} |
"fn" |
PropTypes.symbol |
Symbol 类型 | Symbol("id") |
"symbol" |
PropTypes.node |
任何可渲染的内容 | "hello"、123、<div/>、[<p/>, <span/>] |
— |
PropTypes.element |
一个 React 元素 | <div/>、<MyComp/> |
"hello" |
PropTypes.elementType |
一个 React 组件类型(构造函数) | MyComponent、"div" |
<MyComponent/> |
PropTypes.instanceOf(Date) |
某个类的实例 | new Date() |
{} |
PropTypes.oneOf(["a", "b"]) |
只能是列表中的某一项 | "a"、"b" |
"c" |
PropTypes.oneOfType([PropTypes.string, PropTypes.number]) |
多种类型中的某一种 | "hello"、123 |
true |
PropTypes.arrayOf(PropTypes.number) |
由指定类型元素组成的数组 | [1, 2, 3] |
["1", "2"] |
PropTypes.objectOf(PropTypes.number) |
值都是指定类型的对象 | {a: 1, b: 2} |
{a: "1"} |
PropTypes.shape({name: PropTypes.string}) |
对象必须符合指定的结构 | {name: "小明", age: 18} |
{age: 18} |
PropTypes.exact({name: PropTypes.string}) |
对象必须精确匹配结构(不能有多余属性) | {name: "小明"} |
{name:"小明",age:18} |
加 .isRequired |
该 prop 必填(可链式调用) | — | 不传该 prop |
白话版:
PropTypes就像门卫检查出入证——每人进门前亮出证件(props),门卫按规则(propTypes)核对。证件类型不对或缺少必填项,门卫就在大喇叭(控制台)里喊一声"You 不对劲!"——但不拦你进门(不阻止运行),只在开发环境提醒。
2.4 两种校验方式的对比与选择建议
| 维度 | TypeScript 类型注解 | PropTypes 运行时校验 |
|---|---|---|
| 检查时机 | 编译时(写代码时就标红) | 运行时(浏览器控制台打印警告) |
| IDE 支持 | 优秀的自动补全、悬停提示、跳转定义 | 基本无 |
| 能否阻止 Bug | 能(编译不通过无法运行) | 不能(只警告,不阻止运行) |
| 学习成本 | 中(需要学习 TS 语法) | 低(API 简单、直观) |
| 灵活性 | 极高(泛型、联合类型、条件类型) | 有限(仅支持预定义的校验规则) |
| 适用场景 | 新项目、中大型项目、团队协作 | 纯 JS 老项目、简单原型、教学演示 |
| 维护性 | 强(类型即文档,重构有保障) | 弱(容易过时、忘记同步更新) |
推荐策略:新项目一律用 TypeScript,老项目逐步迁移。 PropTypes 可以作为 TypeScript 的补充在纯 JS 代码中使用,但不应取代 TS。
第三部分:Props 的使用全流程
3.1 父组件传值:五种数据类型的传递
// 08-parent-to-child.tsx —— 可直接复制运行
// 演示父组件向子组件传递各种类型的数据
import React from "react";
// ==================== 子组件:展示所有类型的 props ====================
interface ShowcaseProps {
// --- 基础类型 ---
title: string; // 字符串
count: number; // 数字
isPublished: boolean; // 布尔
// --- 引用类型 ---
tags: string[]; // 字符串数组
author: {
// 对象
name: string;
avatar: string;
};
onSave: (title: string) => void; // 函数(回调)
}
function Showcase({
title,
count,
isPublished,
tags,
author,
onSave,
}: ShowcaseProps) {
return (
<div
style={{
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
maxWidth: 400,
}}
>
<h3>{title}</h3>
<p>阅读量:{count}</p>
<p>状态:{isPublished ? "已发布" : "草稿"}</p>
{/* 数组的渲染 */}
<div>
标签:
{tags.map((tag, index) => (
<span
key={index}
style={{
marginLeft: 4,
padding: "2px 8px",
background: "#e3f2fd",
borderRadius: 4,
fontSize: 12,
}}
>
{tag}
</span>
))}
</div>
{/* 对象属性的使用 */}
<div style={{ marginTop: 8 }}>
作者:
<img
src={author.avatar}
alt={author.name}
style={{
width: 24,
height: 24,
borderRadius: "50%",
verticalAlign: "middle",
}}
/>
<span style={{ marginLeft: 4 }}>{author.name}</span>
</div>
{/* 调用函数类型的 prop */}
<button onClick={() => onSave(title)} style={{ marginTop: 8 }}>
保存文章
</button>
</div>
);
}
// ==================== 父组件:准备数据并传递 ====================
function App() {
// 模拟从数据库或 API 获取的数据
const articleData = {
title: "React Props 完全指南",
count: 1280,
isPublished: true,
tags: ["React", "TypeScript", "前端"],
author: {
name: "小明老师",
avatar: "https://i.pravatar.cc/150?img=3",
},
};
// 回调函数:处理子组件的"上报"
const handleSave = (title: string) => {
alert(`文章《${title}》已保存!`);
};
return (
<div style={{ padding: 20 }}>
{/* ===== 方式一:逐属性传递(最常见) ===== */}
<Showcase
title={articleData.title} // 字符串:直接传
count={articleData.count} // 数字:必须用 {} 包裹
isPublished={articleData.isPublished} // 布尔:必须用 {} 包裹
tags={articleData.tags} // 数组:用 {} 包裹
author={articleData.author} // 对象:用 {} 包裹
onSave={handleSave} // 函数:用 {} 包裹(不要加 (),加括号就变成调用后传返回值了!)
/>
<hr style={{ margin: "20px 0" }} />
{/* ===== 方式二:展开运算符批量传递 ===== */}
<Showcase {...articleData} onSave={handleSave} />
{/* └── ...articleData 等价于逐条写出每个属性 */}
{/* 注意:onSave 不在 articleData 里,需要单独传 */}
</div>
);
}
export default App;
传值方式速查
| 数据类型 | 传递语法 | 错误写法 | 说明 |
|---|---|---|---|
| 字符串 | name="小明" |
— | 唯一不需要 {} 的类型 |
| 数字 | age={18} |
age="18"(变成字符串了!) |
必须用 {},否则被当成字符串 |
| 布尔 | isVip={true} |
isVip="true"(变成字符串了!) |
必须用 {} |
| JS 表达式 | count={a + b} |
— | {} 里可以写任何 JS 表达式 |
| 数组 | list={[1, 2, 3]} |
list="[1,2,3]" |
必须用 {},双花括号不是数组字面量 |
| 对象 | user={{ name: "小明" }} |
— | 外层 {} 是 JSX,内层 {} 是对象 |
| 函数 | onClick={handleClick} |
onClick={handleClick()} |
不要加 (),加了就是调用后的返回值 |
| 展开运算符 | <Comp {...obj} /> |
— | 把对象的所有属性展开为独立的 prop |
3.2 子组件接收值:四种接收方式
// 09-receive-props.tsx —— 子组件接收 Props 的四种方式
// ===== 方式一:接收整个 props 对象(不推荐,但要知道) =====
function WelcomeV1(props) {
// props 是一个对象,装着所有传入的属性
return <h1>你好,{props.name}!</h1>;
// └── 每次用都要写 props.xxx,很繁琐
}
// ===== 方式二:参数位置直接解构(推荐⭐) =====
function WelcomeV2({ name, age }) {
// 在函数签名的参数位置就解构,相当于:
// const { name, age } = props;
return (
<div>
<h1>你好,{name}!</h1>
<p>年龄:{age} 岁</p>
</div>
);
}
// ===== 方式三:解构 + 默认值(推荐⭐⭐) =====
function WelcomeV3({ name, age = 18, isVip = false }) {
// └── 默认值:父组件不传时使用
return (
<div>
<h1>你好,{name}!</h1>
<p>年龄:{age} 岁</p>
<p>{isVip ? "VIP 用户" : "普通用户"}</p>
</div>
);
}
// ===== 方式四:剩余参数收集(rest props) =====
function WelcomeV4({ name, ...restProps }) {
// 解构出 name,剩余的所有属性打包进 restProps 对象
console.log("剩余属性:", restProps);
// 比如 <WelcomeV4 name="小明" age={18} city="北京" />
// restProps = { age: 18, city: "北京" }
return (
<div>
<h1>你好,{name}!</h1>
{/* 把剩余属性直接展开传给原生 HTML 元素 */}
<div {...restProps}>其他信息区域</div>
</div>
);
}
3.3 children prop:插槽式的组件组合
children 是 React 中最特殊、最常用的 prop。它不需要显式传递——写在组件标签之间的所有内容,自动成为 children。
// 10-children-prop.tsx —— 可直接复制运行
// children 的四种经典用法
// ===== 用法一:基础容器组件 =====
interface CardProps {
title: string; // 卡片标题
children?: React.ReactNode; // 卡片内容(写在标签之间)
}
function Card({ title, children }: CardProps) {
return (
<div
style={{
border: "1px solid #e0e0e0",
borderRadius: 12,
overflow: "hidden",
}}
>
{/* 标题栏 */}
<div
style={{
padding: "12px 16px",
backgroundColor: "#f5f5f5",
fontWeight: "bold",
}}
>
{title}
</div>
{/* 内容区 —— children 在这里渲染 */}
<div style={{ padding: 16 }}>{children}</div>
</div>
);
}
// ===== 用法二:多插槽(多个 children 区域) =====
interface LayoutProps {
header?: React.ReactNode; // 头部插槽
sidebar?: React.ReactNode; // 侧边栏插槽
children?: React.ReactNode; // 主内容区(默认 children)
footer?: React.ReactNode; // 底部插槽
}
function Layout({ header, sidebar, children, footer }: LayoutProps) {
return (
<div
style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}
>
{/* 头部 */}
{header && (
<header
style={{ padding: 16, backgroundColor: "#1976D2", color: "white" }}
>
{header}
</header>
)}
<div style={{ display: "flex", flex: 1 }}>
{/* 侧边栏 */}
{sidebar && (
<aside
style={{
width: 200,
padding: 16,
backgroundColor: "#f5f5f5",
borderRight: "1px solid #e0e0e0",
}}
>
{sidebar}
</aside>
)}
{/* 主内容 */}
<main style={{ flex: 1, padding: 16 }}>{children}</main>
</div>
{/* 底部 */}
{footer && (
<footer
style={{
padding: 16,
backgroundColor: "#f5f5f5",
borderTop: "1px solid #e0e0e0",
textAlign: "center",
}}
>
{footer}
</footer>
)}
</div>
);
}
// ===== 用法三:对 children 进行遍历/克隆操作 =====
interface ButtonGroupProps {
children: React.ReactNode;
direction?: "row" | "column"; // 排列方向
}
function ButtonGroup({ children, direction = "row" }: ButtonGroupProps) {
return (
<div style={{ display: "flex", flexDirection: direction, gap: 8 }}>
{/* React.Children.map:安全地遍历 children(即使只有一个子元素也能正确处理) */}
{React.Children.map(children, (child, index) => {
// 给每个子元素包裹一层间距 + 添加序号标记
return (
<div
key={index}
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
<span style={{ fontSize: 10, color: "#999" }}>{index + 1}</span>
{child}
</div>
);
})}
</div>
);
}
// ===== 用法四:children 作为函数(Render Props 模式,进阶用法) =====
interface ToggleProps {
children: (isOn: boolean, toggle: () => void) => React.ReactNode;
// └── children 是一个函数!接收状态和操作方法,返回 UI
}
function Toggle({ children }: ToggleProps) {
const [isOn, setIsOn] = React.useState(false);
const toggle = () => setIsOn((prev) => !prev);
// 调用 children 函数,把状态和控制权交给父组件
return <>{children(isOn, toggle)}</>;
}
// ==================== 综合使用示例 ====================
function App() {
return (
<div style={{ padding: 20, maxWidth: 600, margin: "0 auto" }}>
{/* 用法一:基础容器 */}
<Card title="📌 用户协议">
<p>欢迎使用本产品。请仔细阅读以下条款...</p>
<p>使用本产品即表示您同意以上条款。</p>
</Card>
<div style={{ height: 20 }} />
{/* 用法二:多插槽布局 */}
<Layout
header={<nav>网站 Logo | 导航1 | 导航2</nav>}
sidebar={
<ul>
<li>菜单项 1</li>
<li>菜单项 2</li>
<li>菜单项 3</li>
</ul>
}
footer="© 2026 我的网站 · 版权所有"
>
<h2>欢迎来到主内容区</h2>
<p>这里是页面的主要内容。</p>
</Layout>
<div style={{ height: 20 }} />
{/* 用法三:遍历 children */}
<ButtonGroup>
<button>按钮A</button>
<button>按钮B</button>
<button>按钮C</button>
</ButtonGroup>
<div style={{ height: 20 }} />
{/* 用法四:children 作为函数 */}
<Toggle>
{(isOn, toggle) => (
<div
onClick={toggle}
style={{
display: "inline-block",
padding: "8px 24px",
borderRadius: 20,
cursor: "pointer",
backgroundColor: isOn ? "#4CAF50" : "#ccc",
color: "white",
userSelect: "none",
transition: "background-color 0.3s",
}}
>
{isOn ? "开" : "关"}
</div>
)}
</Toggle>
</div>
);
}
export default App;
白话版:
children就是"标签里的东西"。就像夹心饼干——<Card>和</Card>是两片饼干,中间夹的 p 标签、按钮等就是 children(夹心)。
3.4 Props 解构赋值最佳实践
// 11-destructure-best-practice.tsx —— Props 解构的六大最佳实践
// ===== 实践一:参数位置解构 + 默认值(最推荐) =====
// ✅ 好处:一眼看出需要哪些 props,默认值就近声明
function UserInfo({
name,
age = 0, // 数字默认值
isAdmin = false, // 布尔默认值
hobbies = [], // 数组默认值(注意:不能用 null!)
avatar = "https://i.pravatar.cc/150?img=1", // 字符串默认值
}) {
return (
<div>
<img src={avatar} alt={name} />
<h3>
{name}({age} 岁)
</h3>
</div>
);
}
// ===== 实践二:嵌套对象的深层解构 =====
// ✅ 适合需要取出嵌套对象中多个属性的场景
function AddressCard({
user: {
name,
address: { city, district }, // 深层解构:直接拿到 city 和 district
},
}: {
user: {
name: string;
address: { city: string; district: string };
};
}) {
return (
<div>
<p>姓名:{name}</p>
<p>
地址:{city} {district}
</p>
</div>
);
}
// ===== 实践三:重命名解构(alias) =====
// ✅ 适合 props 名与组件内部变量名冲突时
function FormField({
name: fieldName, // 重命名:props 叫 name,组件里用 fieldName(避免和 HTML name 属性混淆)
value: fieldValue, // 重命名:props 叫 value,组件里用 fieldValue
}) {
return <input name={fieldName} defaultValue={fieldValue} />;
}
// ===== 实践四:剩余参数 rest props(传给原生元素时特别有用) =====
// ✅ 自己处理特定的 prop,剩余的透传给底层 HTML 元素
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
// └── 继承 HTML input 元素的所有原生属性类型
label: string; // 额外增加的自定义 prop
}
function LabeledInput({ label, id, ...inputProps }: InputProps) {
// └── 收集剩余所有属性(className, placeholder, value, onChange 等)
const uniqueId = id || label; // 如果没有传 id,就用 label 作为 id
return (
<div style={{ marginBottom: 12 }}>
<label htmlFor={uniqueId} style={{ display: "block", marginBottom: 4 }}>
{label}
</label>
<input
id={uniqueId}
{...inputProps} // 把所有剩余属性展开传给原生 input
style={{
padding: "8px 12px",
border: "1px solid #ccc",
borderRadius: 4,
width: "100%",
}}
/>
</div>
);
}
// ===== 实践五:Props 解构 + TypeScript 类型 =====
interface ProductCardProps {
product: {
id: number;
name: string;
price: number;
inStock: boolean;
};
onBuy: (productId: number) => void;
}
function ProductCard({
product: { id, name, price, inStock },
onBuy,
}: ProductCardProps) {
// └── 解构 + 重命名同时进行
return (
<div>
<h4>{name}</h4>
<p>¥{price}</p>
<button disabled={!inStock} onClick={() => onBuy(id)}>
{inStock ? "立即购买" : "缺货"}
</button>
</div>
);
}
// ===== 实践六:不要解构你不用的 props =====
// ❌ 坏习惯:解构了一大堆,只用了其中一两个
function BadExample({ name, age, city, hobby, job, ...rest }) {
// 只有 name 被用了,其他的解构都是浪费
return <h1>{name}</h1>;
}
// ✅ 好习惯:只解构你需要的
function GoodExample(props: { name: string }) {
// 或者直接 { name }
return <h1>{props.name}</h1>;
}
解构最佳实践速查表
| 场景 | 推荐写法 | 说明 |
|---|---|---|
| 基础解构 + 默认值 | { name, age = 18 } |
默认值就近声明,一目了然 |
| 深层嵌套解构 | { user: { name, address: { city } } } |
直接拿到深层属性 |
| 重命名 alias | { name: userName } |
避免变量名冲突 |
| 剩余参数(rest) | { label, ...inputProps } |
剩余的透传给原生元素 |
| 解构 + 类型注解 | { name }: { name: string } |
解构的同时标注类型 |
| 解构 + TS interface | { name }: UserProps |
interface 定义在外,解构干净 |
| 不要过度解构 | 只解构你真正用到的属性 | 减少不必要的变量声明 |
第四部分:注意事项与常见误区
4.1 Props 只读性 —— 铁律不可破
Props 是 React 中绝对不能修改的数据。这个规则不是建议,而是硬性规定——React 内部依赖 props 的不可变性来保证数据流的可预测性。
为什么不能修改?
父组件的 state
│
│ 传给子组件(通过 props)
▼
子组件的 props ← 这个值是父组件"借"给子组件看的
│
│ 如果子组件改了 props...
▼
父组件的 state 不会跟着变!数据不一致,Bug 的温床。
错误 vs 正确 对比代码
// 12-immutable-props.tsx —— Props 只读性:错误 vs 正确对比
// ===== 场景:子组件收到一个用户对象,想要显示大写的名字 =====
// ❌ 错误做法一:直接修改 props 对象的属性
function UserBad1({ user }: { user: { name: string } }) {
user.name = user.name.toUpperCase(); // ❌ 直接改 props!
// TypeScript 模式下此处会报编译错误
return <h1>{user.name}</h1>;
}
// ❌ 错误做法二:试图重新给解构出的变量赋值
function UserBad2({ name }: { name: string }) {
name = name.toUpperCase(); // ❌ 虽然 JS 允许,但这改了局部变量,不是改 props
// 问题是:这会让代码阅读者以为 props 被修改了
return <h1>{name}</h1>;
}
// ✅ 正确做法一:创建新的局部变量
function UserGood1({ user }: { user: { name: string } }) {
const displayName = user.name.toUpperCase(); // ✅ 新变量,不动 props
return <h1>{displayName}</h1>;
}
// ✅ 正确做法二:在 JSX 中直接计算(适合简单变换)
function UserGood2({ user }: { user: { name: string } }) {
return <h1>{user.name.toUpperCase()}</h1>; // ✅ 只是读取,没有修改
}
// ✅ 正确做法三:用 useMemo 缓存计算结果(适合复杂计算)
function UserGood3({
user,
}: {
user: { firstName: string; lastName: string };
}) {
const fullName = React.useMemo(
() => `${user.lastName} ${user.firstName}`.toUpperCase(),
// └── useMemo:只在依赖变化时重新计算
[user.firstName, user.lastName], // 依赖数组
);
return <h1>{fullName}</h1>;
}
// ✅ 正确做法四:需要"修改"数据时,调用父组件传来的回调函数
function UserGood4({
user,
onUpdateName, // 父组件传来的回调函数
}: {
user: { id: number; name: string };
onUpdateName: (id: number, newName: string) => void;
}) {
const handleRename = () => {
const newName = prompt("请输入新名字:", user.name);
if (newName && newName !== user.name) {
onUpdateName(user.id, newName); // ✅ 通知父组件去改,自己不碰数据
}
};
return (
<div>
<span>{user.name}</span>
<button onClick={handleRename}>改名</button>
</div>
);
}
口诀:Props 是父组件的数据副本,子组件只有"查看权",没有"修改权"。要改数据,请呼叫父组件(回调函数)。
4.2 父组件更新 → 子组件重渲染机制
这是 React 最核心的行为之一,理解它才能写出高性能的组件。
触发子组件重渲染的三种情况
// 13-rerender-mechanism.tsx —— 子组件重渲染触发机制
import React, { useState } from "react";
// ===== 子组件 =====
function Child({ name, age }: { name: string; age: number }) {
console.log(`Child 渲染了:name=${name}, age=${age}`);
// 每次渲染都会打印,方便观察
return (
<div>
{name} - {age} 岁
</div>
);
}
// ===== 父组件 =====
function App() {
const [count, setCount] = useState(0); // 父组件自己的 state
const [userName, setUserName] = useState("小明"); // 传给子组件的 state
return (
<div>
{/* 情况一:父组件自身的 state 变化 → 父组件重渲染 → 子组件也重渲染 */}
<p>父组件计数器:{count}</p>
<button onClick={() => setCount(count + 1)}>count +1</button>
{/* 情况二:传给子组件的 props 变化 → 子组件重渲染 */}
<p>用户名:{userName}</p>
<button
onClick={() => setUserName(userName === "小明" ? "小红" : "小明")}
>
切换用户
</button>
<hr />
<Child name={userName} age={25} />
<hr />
{/* 情况三:父组件 state 变化,但子组件的 props 没变 → 子组件仍然会重渲染! */}
{/* 这就是为什么需要 React.memo 来优化 */}
<Child name="固定的名字" age={30} />
{/* 点击 count+1 按钮时,这个 Child 也会重渲染,即使它的 props(name、age)完全没变! */}
</div>
);
}
export default App;
重渲染规则总结
| 触发条件 | 子组件是否重渲染 | 说明 |
|---|---|---|
| 父组件的 state 变化(如 setState) | ✅ 会 | 父组件渲染 → 默认所有子组件都渲染 |
| 传给子组件的 props 值变化 | ✅ 会 | 父组件用新值调用子组件 |
| 父组件 state 变化,但子组件 props 不变 | ✅ 会(默认行为) | 这是 React 默认行为,需用 React.memo 优化 |
| 子组件自己的 state 变化 | ✅ 会 | 自身状态变化触发重渲染 |
如何避免不必要的重渲染
// 14-memo-optimization.tsx —— 使用 React.memo 阻止不必要的重渲染
import React, { useState } from "react";
// ===== 用 React.memo 包裹子组件 =====
// React.memo 的作用:如果 props 没变,就跳过重渲染(复用上次的结果)
const MemoizedChild = React.memo(function Child({ name }: { name: string }) {
console.log("MemoizedChild 渲染:", name);
// 只有 name 变化时才会打印
return <div>姓名:{name}</div>;
});
// 也可以写成这样:
// function Child({ name }: { name: string }) { ... }
// const MemoizedChild = React.memo(Child);
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState("小明");
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>count +1</button>
{/* 点击这个按钮 → 父组件重渲染 → 但 MemoizedChild 的 props(name)没变 → 不重渲染! */}
<button onClick={() => setName(name === "小明" ? "小红" : "小明")}>
切换名字
</button>
{/* 点击这个按钮 → name 变了 → MemoizedChild 重渲染 */}
<hr />
<MemoizedChild name={name} />
{/* 另一个实例:name 固定不变 → 永远不重渲染 */}
<MemoizedChild name="固定的名字" />
</div>
);
}
export default App;
白话版:
React.memo就像一个精明的管家——每次父组件说"大家重新装修!",管家就会检查子组件的"装修材料清单"(props)有没有变。没变就不折腾,省时省力。
4.3 默认 Props 的正确设置方式
方式对比
// 15-default-props.tsx —— 默认 Props 的四种设置方式
// ===== 方式一:解构时给默认值(推荐⭐⭐⭐,最常用) =====
function Greeting({
name,
greeting = "你好",
}: {
name: string;
greeting?: string;
}) {
// └── 直接在解构位置赋值
return (
<p>
{greeting},{name}!
</p>
);
}
// ===== 方式二:函数体中用 || 或 ?? 设置默认值 =====
function GreetingV2({ name, greeting }: { name: string; greeting?: string }) {
const finalGreeting = greeting ?? "你好";
// └── ?? 空值合并:只有 null/undefined 时才用默认值
return (
<p>
{finalGreeting},{name}!
</p>
);
}
// ===== 方式三:defaultProps 静态属性(老方式,兼容类组件) =====
function GreetingV3({ name, greeting }: { name: string; greeting?: string }) {
return (
<p>
{greeting},{name}!
</p>
);
}
GreetingV3.defaultProps = {
greeting: "你好", // 不传 greeting 时使用
};
// ===== 方式四:TypeScript 中的 defaultProps(推荐用方式一替代) =====
interface GreetingProps {
name: string;
greeting?: string;
}
function GreetingV4({ name, greeting = "你好" }: GreetingProps) {
// 直接在解构时给默认值,TS 能正确推断 greeting 不是 undefined
return (
<p>
{greeting},{name}!
</p>
);
}
推荐:一律使用方式一(解构时给默认值)。它是现代 React 的标准做法,TypeScript 完美支持,代码最简洁。
4.4 新手六大典型误区
误区一:Props 命名使用了 HTML 保留属性名却含义不同
// ❌ 错误:自定义的 className prop 和 HTML 的 className 属性混了
function Box({ className }) {
// 你以为 className 是你自定义的逻辑分类,实际上它被当成了 CSS 类名
return <div className={className}>内容</div>;
}
// 别人使用时:
<Box className="用户管理模块" />;
// → 渲染成 <div class="用户管理模块"> ← 这根本不是合法的 CSS 类名!
// ✅ 正确:自定义 prop 取一个不会冲突的名字
function Box({ category }) {
// 用 category 表示"业务分类",className 留给 CSS 样式
return (
<div data-category={category}>
{/* data-* 属性是 HTML 标准中专门用来存自定义数据的 */}
内容
</div>
);
}
<Box category="用户管理模块" />;
// → 渲染成 <div data-category="用户管理模块"> ✅
容易踩坑的 prop 名:
className、style、id、key、ref、children。这些在 React 中有特殊含义,不要用作自定义业务属性名。
误区二:类型不匹配 —— 字符串当数字传
// ❌ 错误:数字类型的 prop 传了字符串
function AgeDisplay({ age }: { age: number }) {
return <p>年龄:{age + 1} 岁</p>;
// age 是字符串 "18" → "18" + 1 = "181"(JS 的字符串拼接!)
}
// 错误调用:
<AgeDisplay age="18" />
// └── 这就是传了字符串!"18" 不是 18
// ✅ 正确:数字必须用 {} 包裹
<AgeDisplay age={18} />
// └── {18} → JS 表达式 → 数字 18 → 18 + 1 = 19 ✅
记忆口诀:引号包起来 = 字符串,花括号包起来 = JS 表达式。数字、布尔、对象、数组、函数——统统放花括号!
误区三:过度传值 —— 把整个大对象一股脑传给子组件
// ❌ 错误:把整个用户对象(含几十个字段)传给只展示姓名的子组件
function UserNameDisplay({ user }: { user: User }) {
// User 类型可能有 30+ 个字段(id, name, email, phone, address, ...)
return <span>{user.name}</span>;
// 只用了 user.name,但整个 user 对象都传进来了
}
// 问题:
// 1. 子组件依赖了整个 User 类型 → 耦合度高
// 2. user 中任何一个字段变化(如 user.lastLoginTime)→ 子组件都重渲染
// 3. 别人看代码不知道这个组件到底需要哪些数据
// ✅ 正确:只传真正需要的字段
function UserNameDisplay({ name }: { name: string }) {
return <span>{name}</span>;
}
// 调用:
<UserNameDisplay name={user.name} />
原则:组件需要什么就传什么,只传最小必要数据。 Props 越少,组件越通用,越容易维护。
误区四:props 和 state 概念混淆
// ❌ 错误:把 props 当成 state 用
function Counter({ initialCount }: { initialCount: number }) {
// 想记录当前计数,于是想"更新" props
initialCount = initialCount + 1; // ❌ 试图修改 props
// 或者想用 props 驱动内部状态但每次都重置:
const [count, setCount] = useState(initialCount);
// 问题:父组件传不同的 initialCount 时,count 不会自动同步
// 需要用 useEffect 监听 + key 重置,很麻烦
return <p>{count}</p>;
}
// ✅ 正确:分清"外部配置"和"内部状态"
// 场景1:计数完全由父组件管理(受控模式)
function Counter({ count, onIncrement }: { count: number; onIncrement: () => void }) {
return (
<div>
<span>{count}</span>
<button onClick={onIncrement}>+1</button>
</div>
);
}
// 场景2:计数由子组件自己管理,但 props 提供一个初始值
function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
}
| Props(外部来的) | State(自己管的) | |
|---|---|---|
| 来源 | 父组件传入 | 组件内部通过 useState 创建 |
| 可变性 | 只读,不能改 | 可读写(通过 setState) |
| 作用 | 配置组件、传递数据 | 管理组件内部的变化 |
| 变化触发 | 父组件重渲染时可能变化 | 调用 setState 时变化 |
| 类比 | 遥控器的"频道按钮"(固定) | 电视机"当前频道"(会变) |
误区五:在子组件中不小心"修改"了引用类型的 props
// ❌ 错误:直接操作了 props 中的数组
function TodoList({ items, onUpdate }: { items: { text: string; done: boolean }[]; onUpdate: (items: any[]) => void }) {
const handleMarkAllDone = () => {
// ❌ 遍历并直接修改了 props 中的对象属性!
items.forEach((item) => {
item.done = true; // props 中对象的属性被改了!
});
onUpdate(items); // 引用没变,父组件可能检测不到
};
return (
<div>
<button onClick={handleMarkAllDone}>全部完成</button>
{items.map((item, i) => (
<div key={i}>{item.text}</div>
))}
</div>
);
}
// ✅ 正确:创建全新的数组和对象
function TodoListGood({ items }: { items: { text: string; done: boolean }[] }) {
const handleMarkAllDone = () => {
// ✅ 创建新数组,每个元素也是新对象
const newItems = items.map((item) => ({
...item, // 浅拷贝原对象的所有属性
done: true, // 覆盖 done 属性
}));
// 通过回调通知父组件,而不是直接改
// onUpdate(newItems);
};
return (
<div>
<button onClick={handleMarkAllDone}>全部完成</button>
{items.map((item, i) => (
<div key={i}>{item.text}</div>
))}
</div>
);
}
核心原则:永远不要直接修改 props 中的对象属性或数组元素。需要"修改"时,创建全新的副本再通过回调通知父组件。
误区六:函数作为 props 传递时用了 () 调用
// ❌ 错误:传函数时加了括号 → 传的是函数的返回值,不是函数本身
function App() {
const handleClick = () => alert("点击了!");
return (
<div>
{/* ❌ 页面加载时就弹窗了!因为 handleClick() 在渲染时就被调用了 */}
<button onClick={handleClick()}>点我</button>
{/* 这个 onClick 收到的值是 undefined(handleClick 没有返回值) */}
{/* ✅ 正确一:传函数引用(函数本身) */}
<button onClick={handleClick}>点我</button>
{/* ✅ 正确二:需要传参时,用箭头函数包裹 */}
<button onClick={() => handleClick("参数1")}>点我带参</button>
</div>
);
}
// 记忆技巧:
// onClick={handleClick} → 把函数本身交给 React,等点击时 React 调用它
// onClick={handleClick()} → 在渲染时立即调用函数,把返回值交给 React(通常不是你想要的)
// onClick={() => handleClick("x")} → 传一个箭头函数,等点击时才会执行 handleClick("x")
口诀:传函数时不加括号,要传参就包箭头。(唯一的例外是在 useEffect 的清理函数中需要调用,但那不是 props。)
第五部分:实战练习
5.1 练习一:商品展示卡片组件
题目描述
创建一个名为 ProductCard 的组件,用于展示商品信息。要求如下:
- 基础展示:显示商品图片、名称、价格、库存状态
- 条件渲染:有折扣时显示原价(删除线)和折扣价,无折扣时只显示正常价格
- 交互功能:有"加入购物车"按钮,点击后调用父组件传入的回调函数
- 缺货处理:库存为 0 时按钮显示"缺货"且不可点击
- TypeScript 类型:为所有 props 定义完整的 TypeScript 类型
Props 清单:
| Prop 名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| name | string | 是 | 商品名称 |
| image | string | 是 | 商品图片 URL |
| price | number | 是 | 商品价格(元) |
| discount | number | 否 | 折扣比例(0~1) |
| stock | number | 否 | 库存数量,默认 0 |
| onAddToCart | (productName: string) => void | 否 | 加入购物车回调 |
点击查看参考答案
// exercise-01-answer.tsx —— 商品展示卡片 参考答案
import React from "react";
// ========== 第一步:定义 Props 类型 ==========
interface ProductCardProps {
name: string; // 商品名称(必填)
image: string; // 商品图片 URL(必填)
price: number; // 商品价格(必填)
discount?: number; // 折扣比例,0~1 之间(可选,如 0.8 表示 8 折)
stock?: number; // 库存数量(可选,默认 0)
onAddToCart?: (productName: string) => void; // 加入购物车回调(可选)
}
// ========== 第二步:实现组件 ==========
function ProductCard({
name,
image,
price,
discount, // 可选,可以不传
stock = 0, // 默认值为 0
onAddToCart, // 可选的回调函数
}: ProductCardProps) {
// ----- 计算展示价格 -----
const hasDiscount = discount !== undefined && discount > 0 && discount < 1;
// 有折扣的条件:discount 存在 且 大于 0 且 小于 1
const finalPrice = hasDiscount ? price * discount : price;
// 折后价 = 原价 × 折扣(如 100 × 0.8 = 80)
// ----- 缺货判断 -----
const isOutOfStock = stock <= 0;
// 库存小于等于 0 视为缺货
// ----- 加入购物车处理 -----
const handleAddToCart = () => {
if (isOutOfStock) return; // 缺货时什么都不做(防御性编程)
onAddToCart?.(name); // 可选链调用:onAddToCart 存在才调用
};
return (
<div
style={{
width: 260,
border: "1px solid #e0e0e0",
borderRadius: 12,
overflow: "hidden", // 让图片圆角生效
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
transition: "box-shadow 0.3s",
backgroundColor: "#fff",
}}
// 鼠标悬停时阴影加深(用内联样式无法实现 hover,这里仅做静态展示)
>
{/* ===== 商品图片 ===== */}
<div style={{ position: "relative" }}>
<img
src={image}
alt={name} // alt 属性:图片加载失败时显示的文字,也用于无障碍
style={{
width: "100%", // 宽度撑满容器
height: 180,
objectFit: "cover", // 保持比例裁剪,填满固定高度
}}
/>
{/* 折扣标签:有折扣时显示 */}
{hasDiscount && (
<span
style={{
position: "absolute", // 绝对定位,相对于父容器
top: 8,
left: 8,
padding: "4px 10px",
backgroundColor: "#f44336",
color: "white",
borderRadius: 4,
fontSize: 13,
fontWeight: "bold",
}}
>
{Math.round(discount * 10)}折
{/* Math.round():四舍五入取整,0.85 → 9 折 */}
</span>
)}
{/* 缺货遮罩 */}
{isOutOfStock && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundColor: "rgba(0,0,0,0.5)", // 半透明黑色遮罩
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 18,
fontWeight: "bold",
}}
>
已售罄
</div>
)}
</div>
{/* ===== 商品信息 ===== */}
<div style={{ padding: 16 }}>
{/* 商品名称 */}
<h3
style={{
margin: "0 0 8px",
fontSize: 16,
// 文字超出时显示省略号
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={name} // 鼠标悬停显示完整名称
>
{name}
</h3>
{/* 价格区域 */}
<div
style={{
marginBottom: 12,
display: "flex",
alignItems: "baseline",
gap: 8,
}}
>
{/* 当前价格(折后价或原价) */}
<span style={{ fontSize: 22, fontWeight: "bold", color: "#f44336" }}>
¥{finalPrice.toFixed(2)}
{/* toFixed(2):保留两位小数,80 → "80.00" */}
</span>
{/* 有折扣时显示原价(带删除线) */}
{hasDiscount && (
<span
style={{
fontSize: 14,
color: "#999",
textDecoration: "line-through", // 删除线
}}
>
¥{price.toFixed(2)}
</span>
)}
</div>
{/* 库存信息 */}
<p
style={{
margin: "0 0 12px",
fontSize: 13,
color: isOutOfStock ? "#f44336" : "#4CAF50",
}}
>
{isOutOfStock ? "暂时缺货" : `库存:${stock} 件`}
</p>
{/* 加入购物车按钮 */}
<button
onClick={handleAddToCart}
disabled={isOutOfStock} // 缺货时按钮不可点击
style={{
width: "100%", // 撑满容器
padding: "10px 0",
border: "none",
borderRadius: 8,
backgroundColor: isOutOfStock ? "#ccc" : "#1976D2",
color: "white",
fontSize: 15,
fontWeight: "bold",
cursor: isOutOfStock ? "not-allowed" : "pointer",
transition: "background-color 0.2s",
}}
>
{isOutOfStock ? "缺货" : "加入购物车"}
</button>
</div>
</div>
);
}
// ========== 第三步:使用示例 ==========
function ExerciseApp() {
const handleAddToCart = (productName: string) => {
alert(`已将《${productName}》加入购物车!`);
};
return (
<div style={{ display: "flex", gap: 20, padding: 20, flexWrap: "wrap" }}>
{/* 正常商品:有折扣、有库存 */}
<ProductCard
name="React 从入门到精通"
image="https://picsum.photos/seed/reactbook/400/300"
price={79.9}
discount={0.7} // 7 折
stock={50}
onAddToCart={handleAddToCart}
/>
{/* 无折扣商品 */}
<ProductCard
name="JavaScript 高级程序设计"
image="https://picsum.photos/seed/jsbook/400/300"
price={99.0}
stock={10}
onAddToCart={handleAddToCart}
/>
{/* 缺货商品:不传 discount,stock 为 0 */}
<ProductCard
name="TypeScript 实战指南(缺货)"
image="https://picsum.photos/seed/tsbook/400/300"
price={69.0}
/>
</div>
);
}
export default ExerciseApp;
要点解析:
| 知识点 | 代码位置 | 说明 |
|---|---|---|
| 可选 props 默认值 | stock = 0 |
解构时直接给默认值 |
| 条件渲染 | {hasDiscount && ...} |
有折扣才显示折扣标签和原价 |
| 可选链调用 | onAddToCart?.(name) |
回调函数可能存在也可能不存在 |
| 计算值 | finalPrice |
折后价 = 原价 × 折扣 |
| 禁用状态 | disabled={isOutOfStock} |
缺货时按钮不可点击 |
| 缺货视觉反馈 | 半透明遮罩 + "售罄" | 让用户一眼看出该商品不可购买 |
5.2 练习二:可配置的评分组件
题目描述
创建一个名为 StarRating 的评分组件,可以用于商品评分、电影评分等场景。
- 星级展示:用 ★ 和 ☆ 展示评分(满分 5 星),已评分部分显示实心星,未评分显示空心星
- 只读模式:当不传
onChange时,组件为只读模式(不可点击) - 可交互模式:当传了
onChange时,点击星星可以修改评分(半星可选支持) - 自定义最大星级:通过
maxStarsprop 控制最多几颗星(默认 5) - 自定义颜色和大小:支持通过 props 自定义星星颜色和大小
Props 清单:
| Prop 名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| value | number | 是 | — | 当前评分(0 ~ maxStars) |
| maxStars | number | 否 | 5 | 最大星级数 |
| size | number | 否 | 24 | 星星大小(px) |
| color | string | 否 | "#FFB400" | 星星颜色 |
| onChange | (newValue: number) => void | 否 | undefined | 评分改变回调(只读不传) |
| readOnly | boolean | 否 | false | 是否只读 |
点击查看参考答案
// exercise-02-answer.tsx —— 可配置评分组件 参考答案
import React, { useState } from "react";
// ========== 第一步:定义 Props 类型 ==========
interface StarRatingProps {
value: number; // 当前评分(必填)
maxStars?: number; // 最大星级数(可选,默认 5)
size?: number; // 星星的像素大小(可选,默认 24)
color?: string; // 星星的填充颜色(可选,默认金色)
onChange?: (newValue: number) => void; // 评分改变时的回调(不传 = 只读模式)
readOnly?: boolean; // 显式的只读标记(可选,默认 false)
}
// ========== 第二步:实现组件 ==========
function StarRating({
value,
maxStars = 5,
size = 24,
color = "#FFB400",
onChange, // 可选,不传就是只读
readOnly = false,
}: StarRatingProps) {
// ----- 悬停预览状态(仅在可交互模式下使用) -----
const [hoverValue, setHoverValue] = useState<number>(0);
// 鼠标悬停时临时显示的星级,鼠标移开后清空
// ----- 判断是否可交互 -----
const isInteractive = !readOnly && onChange !== undefined;
// 两个条件同时满足才能交互:(1) 不是只读模式 (2) 有 onChange 回调
// ----- 实际显示的值:悬停时用悬停值,否则用实际值 -----
const displayValue = hoverValue > 0 ? hoverValue : value;
// ----- 点击星星 -----
const handleClick = (starIndex: number) => {
if (!isInteractive) return; // 不可交互时直接返回
// starIndex 从 1 开始:点击第 1 颗星 → starIndex = 1
// 如果点击的是当前已有的最高星级,则取消(归零)
const newValue = starIndex === value ? 0 : starIndex;
onChange?.(newValue); // 可选链调用
};
// ----- 鼠标进入星星(悬停预览) -----
const handleMouseEnter = (starIndex: number) => {
if (!isInteractive) return;
setHoverValue(starIndex); // 记录当前悬停到第几颗星
};
// ----- 鼠标离开整个组件 -----
const handleMouseLeave = () => {
if (!isInteractive) return;
setHoverValue(0); // 清空悬停值,恢复显示实际评分
};
// ===== 渲染所有星星 =====
return (
<div
style={{
display: "inline-flex", // 星星排成一行
gap: 2, // 星星之间的间距
cursor: isInteractive ? "pointer" : "default", // 可交互时鼠标变成手型
}}
onMouseLeave={handleMouseLeave} // 鼠标离开区域时清除悬停
role="radiogroup" // 无障碍:告诉屏幕阅读器这是一组单选按钮
aria-label={`评分:${value} / ${maxStars}`} // 无障碍标签
>
{/*
Array.from({ length: maxStars }) 创建一个长度为 maxStars 的数组
例如 maxStars = 5 → [undefined, undefined, undefined, undefined, undefined]
然后 map 遍历生成每个星星
*/}
{Array.from({ length: maxStars }).map((_, index) => {
const starIndex = index + 1; // 星星编号从 1 开始(不是从 0)
// ----- 判断当前星星的填充状态 -----
const isFilled = starIndex <= Math.floor(displayValue);
// Math.floor(3.5) = 3 → 前 3 颗星满星
const isHalf =
!isFilled &&
starIndex === Math.ceil(displayValue) &&
displayValue % 1 !== 0;
// 半星条件:不是满星 + 是向上取整的那颗 + 实际值有小数部分
// 例如 value = 3.5 → starIndex = 4 时满足:不是满星 + 等于 Math.ceil(3.5)=4 + 3.5%1=0.5≠0
return (
<span
key={starIndex} // React 列表渲染必备的 key
onClick={() => handleClick(starIndex)}
onMouseEnter={() => handleMouseEnter(starIndex)}
style={{
fontSize: size, // 用字体大小控制星星尺寸
lineHeight: 1,
color: isFilled || isHalf ? color : "#e0e0e0", // 有颜色或灰色
transition: "transform 0.1s, color 0.2s", // 过渡动画
// 悬停时星星稍微放大
...(isInteractive
? {
transform:
hoverValue >= starIndex ? "scale(1.15)" : "scale(1)",
}
: {}),
}}
aria-label={`${starIndex} 星`} // 无障碍标签
>
{isFilled ? "★" : isHalf ? "⯪" : "☆"}
{/* 满星显示 ★(实心),半星显示 ⯪,空星显示 ☆(空心) */}
{/*
补充:更精确的半星实现可以用两个 span 叠加:
外层是空星(灰色),内层是半颗实星(用 overflow:hidden 裁剪宽度)
这里用 Unicode 字符 ⯪ 简化实现
*/}
</span>
);
})}
{/* 评分数字(可选展示) */}
<span
style={{
marginLeft: 8,
fontSize: size * 0.7,
color: "#666",
lineHeight: `${size}px`,
}}
>
{value.toFixed(1)}
{/* toFixed(1):保留一位小数,3.5 → "3.5",4 → "4.0" */}
</span>
</div>
);
}
// ========== 第三步:使用示例 ==========
function ExerciseApp() {
// ----- 可交互评分的状态 -----
const [movieRating, setMovieRating] = useState(4);
const [productRating, setProductRating] = useState(3.5);
return (
<div style={{ padding: 20, maxWidth: 500, margin: "0 auto" }}>
<h2>评分组件演示</h2>
{/* 只读模式:不传 onChange */}
<div style={{ marginBottom: 20 }}>
<h4>1. 只读模式(不传 onChange)</h4>
<p>《React 完全指南》评分:</p>
<StarRating value={4.5} />
{/* 不传 onChange → 不可点击,仅展示 */}
</div>
<hr />
{/* 可交互模式:传了 onChange */}
<div style={{ marginBottom: 20 }}>
<h4>2. 交互模式(传 onChange)</h4>
<p>电影评分(点击星星修改):</p>
<StarRating
value={movieRating}
onChange={(newVal) => setMovieRating(newVal)}
maxStars={10} // 10 星制!
color="#E91E63" // 粉色星星
size={28} // 更大的星星
/>
<p style={{ marginTop: 4, fontSize: 13, color: "#888" }}>
当前评分:{movieRating} / 10
</p>
</div>
<hr />
{/* 商品评分:5 星制,默认金色 */}
<div style={{ marginBottom: 20 }}>
<h4>3. 商品评分(5 星制,含半星)</h4>
<StarRating
value={productRating}
onChange={(newVal) => setProductRating(newVal)}
/>
<p style={{ marginTop: 4, fontSize: 13, color: "#888" }}>
当前评分:{productRating} / 5
</p>
</div>
<hr />
{/* 显式只读模式 */}
<div>
<h4>4. 显式只读(readOnly={true})</h4>
<StarRating value={3} readOnly={true} color="#9E9E9E" />
<p style={{ marginTop: 4, fontSize: 13, color: "#888" }}>
灰色 + 只读,适合展示历史评分
</p>
</div>
</div>
);
}
export default ExerciseApp;
要点解析:
| 知识点 | 代码位置 | 说明 |
|---|---|---|
| 可选 props 默认值 | maxStars = 5、size = 24 等 |
解构时直接给默认值 |
| 只读/交互双模式 | isInteractive 判断 |
不传 onChange → 纯展示;传了 → 可点击 |
| 悬停预览 | hoverValue + onMouseEnter |
鼠标划过时临时显示高亮,离开后恢复 |
| 半星逻辑 | isHalf 计算 |
通过取余运算判断小数部分 |
Array.from 技巧 |
Array.from({ length: maxStars }) |
创建指定长度的数组用于 map 渲染 |
| 无障碍(a11y) | role="radiogroup"、aria-label |
让屏幕阅读器可以理解评分组件的含义 |
| 可选链调用 | onChange?.(newValue) |
回调函数不存在时安全跳过 |
| 点击当前星取消评分 | starIndex === value ? 0 : starIndex |
点击已评分星 → 清空评分 |
全文总结:Props 是 React 组件通信的基石。它就像快递包裹上的快递单——父组件填写数据,子组件接收并使用,但不能修改。掌握 Props 的类型定义(TypeScript 接口 / PropTypes 校验)、传递与接收方式(解构、children)、以及核心规则(只读性、默认值、单向数据流),你就掌握了 React 中最重要的组件通信能力。
再配合文中指出的六大典型误区及其修正方案,你可以避免新手期 90% 以上的 Props 相关 Bug。现在,打开你的编辑器,动手写几个组件,让 Props 在组件之间流动起来吧!
下一个话题预告:当你掌握了 Props(外部数据)后,接下来要学习组件内部的数据管理——State(状态)与事件处理,让组件真正"活"起来。
版权声明:本教程所有代码示例均为教学目的编写,可自由使用、修改和分发。
评论区