React 基础版(三):组件
目录
第一部分:组件是什么?—— 乐高积木的启示
1.1 一个生活化的类比
打开一盒乐高积木,里面有长方块、正方块、车轮、窗户、门。每一块积木都有自己固定的形状和功能。单独看只是一个小零件,但把它们拼在一起,就能搭出城堡、飞船或汽车。
更神奇的是 —— 同一块积木可以在多个场景反复使用。一块红色长方块,今天做墙壁,明天做船底,后天做车底盘。你不需要重新造这块积木,直接从盒子里拿出来就行。
React 组件和乐高积木的设计理念一模一样:
| 乐高积木 | React 组件 |
|---|---|
| 一块有固定形状的积木 | 一个有固定功能的 UI 模块 |
| 积木的尺寸和颜色 | 组件的 props(配置参数) |
| 同一块积木反复使用 | 同一个组件在多个页面复用 |
| 多块积木拼在一起 | 多个组件嵌套组合成页面 |
| 换一块积木不影响其他 | 修改一个组件不影响其他组件 |
// 就像从乐高盒子里拿出"窗户"和"门"两块积木
<Window color="blue" size="medium" />
<Door color="brown" width="double" />
// 拼在一起就组成了一面墙
<div className="house-wall">
<Window color="blue" size="medium" />
<Door color="brown" width="double" />
</div>
一句话总结:组件是 UI 的基本构建块。每个组件是一个独立、可复用的"积木块",有自己的外观(JSX)和行为(逻辑),可以像搭积木一样拼成完整页面。
1.2 组件的三大核心特性
特性一:复用性(Reusability)—— 一次定义,到处使用
就像刻了一枚"审批通过"的印章,之后任何人拿着它就能盖章,不需要每次手写。
// 定义一次 Button 组件(刻一枚"印章")
function Button({ text, type }) {
return <button className={`btn btn-${type}`}>{text}</button>;
}
// 到处使用(到处盖章)
function App() {
return (
<div>
<Button text="保存" type="primary" /> {/* 蓝色主按钮 */}
<Button text="取消" type="default" /> {/* 灰色默认按钮 */}
<Button text="删除" type="danger" /> {/* 红色危险按钮 */}
</div>
);
}
复用的好处:改一处 Button 样式,所有页面自动生效;新增按钮类型只改一处。
特性二:独立性(Independence)—— 内部变化不影响外部
每个组件像一间带隔音墙的录音棚。A 棚里唱歌跑调,B 棚完全听不到。
function Counter() {
const [count, setCount] = useState(0); // Counter 自己的"私有财产"
return (
<div>
<p>计数:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
// 页面上放三个 Counter,它们是三个独立实例
function App() {
return (
<div>
<Counter /> {/* 这个 count 是自己的 */}
<Counter /> {/* 这个也是自己的,互不影响 */}
<Counter /> {/* 同上 */}
</div>
);
}
// 点击第一个按钮,只有第一个数字会变 —— 这就是"独立性"
特性三:可组合性(Composability)—— 小积木拼出大城堡
像写论文:词语 → 句子 → 段落 → 完整章节。
// 第一层:原子组件(词语)
function Avatar({ src }) {
return <img src={src} />;
}
function UserName({ name }) {
return <span>{name}</span>;
}
// 第二层:组合组件(句子)
function UserCard({ user }) {
return (
<div>
<Avatar src={user.avatar} />
<UserName name={user.name} />
</div>
);
}
// 第三层:页面组件(章节)
function UserListPage() {
const users = [
{ id: 1, name: "小明" },
{ id: 2, name: "小红" },
];
return (
<div>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
React 核心理念:组合优于继承。不需要通过继承来扩展功能,而是将小组件组合成更大的组件。
1.3 函数组件 vs 类组件 —— 为什么现在都用函数组件?
React 历史上有两种组件写法。这里是直观对比:
| 类组件(旧方式) | 函数组件(现代推荐) | |
|---|---|---|
| 类比 | 手工作坊(复杂的模具和流程) | 3D 打印机(数字模型一键打印) |
| 状态管理 | this.state + this.setState |
useState 一行搞定 |
| 生命周期 | 多个方法(componentDidMount 等) | useEffect 统一处理 |
| 代码量 | 多(constructor、render、this 绑定) | 少(一个函数即可) |
| 学习门槛 | 需要理解 this 绑定 |
会写 JS 函数就能上手 |
| Hooks 支持 | 不支持 | 全面支持 |
// ❌ 类组件(了解即可,不要求掌握)
class Welcome extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.handleClick = this.handleClick.bind(this); // 必须手动绑定 this!
}
handleClick() {
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<div>
<h1>你好,{this.props.name}</h1>
<p>计数:{this.state.count}</p>
<button onClick={this.handleClick}>+1</button>
</div>
);
}
}
// ✅ 函数组件(现代推荐写法)
function Welcome({ name }) {
const [count, setCount] = useState(0); // 一行搞定状态
return (
<div>
<h1>你好,{name}</h1>
<p>计数:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
本书约定:后续全部使用函数组件 + Hooks。遇到老项目中的类组件,能读懂即可。
第二部分:组件的定义 —— 从零搭建一个组件
2.1 最简组件:Hello, World
// 第1行:导入 React(React 17+ 新 JSX 转换可省略,保留最安全)
import React from "react";
// 第2-12行:定义一个函数组件
// ┌── function 关键字
// │ ┌── 组件名(首字母必须大写!)
// │ │
function HelloWorld() {
// │
// └── 函数体:在这里写组件的逻辑
// return 后面是 JSX —— 描述组件"长什么样"
return (
<div>
{/* JSX 中的注释要这样写 */}
<h1>Hello, World!</h1>
<p>这是我的第一个 React 组件</p>
</div>
);
}
// 最后一行:导出组件,让其他文件可以 import 使用
export default HelloWorld;
逐行拆解:
| 代码 | 作用 | 必须遵守的规则 |
|---|---|---|
import React from "react" |
导入 React 核心库 | 使用 JSX 的文件都需要(React 17+ 可省略) |
function HelloWorld() |
声明函数组件 | 首字母必须大写,否则被当成普通 HTML 标签 |
return (...) |
返回 UI 描述(JSX) | 多行 JSX 必须用 () 包裹,否则 return 后换行会变成空返回 |
<div>...</div> |
组件的 HTML 结构 | JSX 必须有且仅有一个根节点 |
{/* 注释 */} |
JSX 中的注释 | 不能用 // 或 /* */(在 JSX 里会被当成文本渲染) |
export default |
默认导出 | 每个文件通常只导出一个默认组件 |
使用这个组件
import HelloWorld from "./components/HelloWorld"; // 引入
function App() {
return (
<div>
<HelloWorld /> {/* 像使用 HTML 标签一样用! */}
<HelloWorld /> {/* 可以用多次 */}
</div>
);
}
2.2 Props:给组件传入"配置项"
类比:外卖订单
你点外卖时要告诉商家:口味(香辣/原味)、数量(1份/2份)、加不加可乐。这些"配置项"就是 props。
同一个组件 + 不同的 props = 不同的渲染结果 —— 这就是组件复用的核心。
基础用法
// props 是一个对象,装着调用者传来的所有数据
function Welcome(props) {
// 比如 <Welcome name="小明" visitCount={1} />
// props = { name: "小明", visitCount: 1 }
return (
<div>
<h1>你好,{props.name}!</h1>
<p>欢迎你第 {props.visitCount} 次访问。</p>
</div>
);
}
// 使用
function App() {
return (
<div>
<Welcome name="小明" visitCount={1} />
<Welcome name="小红" visitCount={5} />
<Welcome name="小刚" visitCount={99} />
</div>
);
}
// 渲染结果:
// 你好,小明!欢迎你第 1 次访问。
// 你好,小红!欢迎你第 5 次访问。
// 你好,小刚!欢迎你第 99 次访问。
进阶:用解构语法简化
// 写法一(基础):用 props 对象
function Welcome(props) {
return <h1>你好,{props.name}!</h1>;
}
// 写法二(推荐):在参数位置直接解构
function Welcome({ name, visitCount }) {
// └── 相当于 const { name, visitCount } = props;
return (
<div>
<h1>你好,{name}!</h1>
<p>第 {visitCount} 次访问</p>
</div>
);
}
Props 的铁律:只读!不能修改!
function Welcome({ name }) {
// ❌ 绝对不能这样!props 是只读的!
// name = "李四";
// ✅ 如果需要转换,存到新变量
const upperName = name.toUpperCase();
return <h1>你好,{upperName}!</h1>;
}
口诀:Props 是数据的单行道,只能从父组件流向子组件,不能逆行向上。
2.3 PropTypes:给 Props 加"门卫"
JS 是动态类型语言 —— 你可以给 name 传字符串、数字、甚至函数,JS 都不会报错。但类型错误是 Bug 的第一大来源。
PropTypes 就像门卫:你告诉它"name 必须是字符串",有人传数字时它会在控制台发出警告。
import React from "react";
import PropTypes from "prop-types"; // React 19 + Vite 自带,无需额外安装
// 第一步:定义组件
function UserCard({ name, age, isAdmin, hobbies, onDelete }) {
return (
<div>
<h2>{name}</h2>
<p>年龄:{age} 岁</p>
<p>身份:{isAdmin ? "管理员" : "普通用户"}</p>
<p>爱好:{hobbies.join("、")}</p>
<button onClick={onDelete}>删除</button>
</div>
);
}
// 第二步:挂载 propTypes —— 告诉"门卫"规则
UserCard.propTypes = {
name: PropTypes.string.isRequired, // 字符串 + 必填
age: PropTypes.number.isRequired, // 数字 + 必填
isAdmin: PropTypes.bool, // 布尔值,可选
hobbies: PropTypes.array, // 数组,可选
onDelete: PropTypes.func, // 函数,可选
};
// 第三步:设置默认值
UserCard.defaultProps = {
isAdmin: false,
hobbies: [],
};
export default UserCard;
// 传错类型时,控制台会警告(不阻止运行,只在开发环境提示)
<UserCard name={123} age="十八岁" />
// Warning: Failed prop type: Invalid prop `name` of type `number`
// supplied to `UserCard`, expected `string`.
PropTypes 常用类型速查:
| 写法 | 含义 |
|---|---|
PropTypes.string |
字符串 |
PropTypes.number |
数字 |
PropTypes.bool |
布尔值 |
PropTypes.array |
数组 |
PropTypes.object |
对象 |
PropTypes.func |
函数 |
PropTypes.node |
可渲染的内容 |
PropTypes.element |
一个 React 元素 |
PropTypes.oneOf(["a","b"]) |
只能是列表中的某一项 |
PropTypes.arrayOf(PropTypes.number) |
由数字组成的数组 |
PropTypes.shape({ name: ... }) |
对象包含特定结构的属性 |
加 .isRequired |
该 prop 必填 |
2.4 useState:让组件拥有"记忆"
到目前组件还是"静态"的——传入什么就渲染什么,不会自己变化。真实应用中组件需要"活"起来:点击按钮计数器 +1,输入框打字显示内容,切换开关改变主题。
这种会随用户操作而变化的数据,在 React 中叫做 state(状态)。
类比:白板上的便利贴
白板上贴着一张便利贴,上面写了数字 0。
- 错误做法:直接把便利贴上的
0擦掉写成1—— 白板不知道变化了 - 正确做法:按"更新"按钮(调用 setState),React 换一张写有
1的新便利贴贴上去,并刷新白板显示
基本语法
import React, { useState } from "react"; // 第一步:导入 useState
function Counter() {
// 第二步:调用 useState
// ┌── 状态变量名 ┌── 初始值(可以是数字、字符串、对象、数组)
// │ │
// │ ┌── 更新函数(命名惯例 = set + 变量名首字母大写)
// │ │
const [count, setCount] = useState(0);
// │ │ │
// │ │ └── useState(初始值):返回 [当前值, 更新函数]
// │ └── 第二个元素:更新状态的函数,调用它才会触发重新渲染
// └── 第一个元素:当前的状态值
return (
<div>
<p>你点击了 {count} 次</p>
{/* 第三步:通过 setXxx 更新状态 */}
<button onClick={() => setCount(count + 1)}>点我 +1</button>
<button onClick={() => setCount(0)}>归零</button>
</div>
);
}
// 每次点击按钮 → setCount(新值) → React 重新渲染 → 页面数字更新
逐行详解
const [count, setCount] = useState(0);
// └─────────────────┘ 这叫"数组解构赋值"
// useState(0) 返回 [0, function]
// count = 返回数组的第0个元素(当前值)
// setCount = 返回数组的第1个元素(更新函数)
// 等价于:
// const stateArray = useState(0);
// const count = stateArray[0];
// const setCount = stateArray[1];
// ❌ 不能直接改 count
// count = count + 1; 这样写不会触发重新渲染!
// ✅ 必须通过 setCount
// setCount(count + 1); 正确做法
三个关键规则
规则一:只能在函数组件或自定义 Hook 中调用
// ❌ 错误:在普通函数中使用
function normalFunction() {
const [value, setValue] = useState(0); // 报错!
}
// ✅ 正确:在组件函数中使用
function MyComponent() {
const [value, setValue] = useState(0); // 没问题
return <div>{value}</div>;
}
规则二:必须在函数体顶层调用,不能在条件/循环中
// ❌ 错误:useState 在 if 里面
function Bad({ isAdmin }) {
if (isAdmin) {
const [data, setData] = useState(null); // 报错!
}
}
// ❌ 错误:useState 在循环里
function Bad() {
for (let i = 0; i < 5; i++) {
const [val, setVal] = useState(i); // 报错!
}
}
// ✅ 正确:始终在顶层
function Good({ isAdmin }) {
const [data, setData] = useState(null); // 第1个Hook,始终在这里
useEffect(() => {
if (isAdmin) fetchData().then(setData); // 条件逻辑放 Hook 里面
}, [isAdmin]);
return <div>...</div>;
}
为什么? React 用"编号"管理状态:第1个 useState 是 #1,第2个是 #2……如果某个 useState 有时存在有时不存在(因为在 if 里),编号就乱了,React 不知道哪个状态对应哪个数据。
规则三:setState 是"异步"的 —— 不要立刻读刚更新的值
function Demo() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // ❌ 打印的还是 0!不是 1!
// setCount 只是"安排"了更新,更新不会立刻发生
};
// ✅ 如果需要基于上一次的值更新,用函数式更新
const handleClickSafe = () => {
setCount((prevCount) => {
// prevCount 是 React 保证的最新值
return prevCount + 1;
});
};
return <button onClick={handleClick}>count = {count}</button>;
}
处理对象和数组 —— 不可变更新
function UserProfile() {
const [user, setUser] = useState({
name: "小明",
age: 18,
hobbies: ["篮球", "编程"],
});
const handleBirthday = () => {
// ❌ 错误:直接改原对象(引用地址没变,React 检测不到)
// user.age = user.age + 1; setUser(user);
// ✅ 正确:创建新对象
setUser({ ...user, age: user.age + 1 });
};
const addHobby = (hobby) => {
// ❌ 错误:user.hobbies.push(hobby); setUser(user);
// ✅ 正确:创建新数组
setUser({ ...user, hobbies: [...user.hobbies, hobby] });
};
return (
<div>
<p>姓名:{user.name}</p>
<p>年龄:{user.age} 岁</p>
<p>爱好:{user.hobbies.join("、")}</p>
<button onClick={handleBirthday}>过生日 +1岁</button>
</div>
);
}
核心原则:React 通过
===引用比较判断状态是否变化。直接修改对象属性不改变引用,React 认为"没变",跳过渲染。必须创建新对象/新数组。
2.5 useEffect:让组件学会"做事"
什么是副作用?
纯渲染:接收 props 和 state,返回 JSX。同样的输入永远得到同样的输出。
副作用:超越了"纯渲染"的操作:
- 网络请求(从服务器获取数据)
- 操作 DOM(手动改页面元素)
- 定时器(
setTimeout、setInterval) - 事件订阅(监听窗口大小、键盘按键)
- 读写浏览器存储(
localStorage)
类比:智能管家
useEffect 就像一个智能管家:
- 你进房间(组件挂载)→ 管家开灯、拉窗帘、调空调
- 你出房间(组件卸载)→ 管家关灯、关空调、锁门
- 房间东西变了(依赖项变化)→ 管家重新调整环境
基本语法
import React, { useState, useEffect } from "react";
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// 参数1:副作用函数 —— 组件渲染后执行
// 参数2:依赖数组 —— 控制何时重新执行(下面这个是空数组)
console.log("定时器已启动");
const timerId = setInterval(() => {
setSeconds((prev) => prev + 1);
}, 1000);
// 返回一个"清理函数" —— 组件卸载时 React 自动调用
return () => {
console.log("定时器已清除");
clearInterval(timerId); // 清除定时器,防止内存泄漏
};
}, []);
// └── 空数组 = "只在组件首次挂载时执行一次"
return <p>你在这个页面停留了 {seconds} 秒</p>;
}
依赖数组的三种情况(核心!)
// 情况一:不传依赖数组 —— 每次渲染都执行(⚠️ 容易死循环)
useEffect(() => {
console.log("每次渲染都执行");
});
// 情况二:传空数组 [] —— 只在首次挂载时执行一次
useEffect(() => {
console.log("只执行一次");
// 适合:初始化请求、事件绑定
}, []);
// 情况三:传有值的数组 [a, b] —— 首次 + 依赖项变化时执行
useEffect(() => {
document.title = `你点击了 ${count} 次`;
}, [count]); // count 变化 → 重新执行
完整示例:网络请求
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUsers() {
try {
setLoading(true);
const response = await fetch(
"https://jsonplaceholder.typicode.com/users",
);
if (!response.ok) throw new Error("网络请求失败");
const data = await response.json();
setUsers(data);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUsers();
}, []); // 只在首次挂载时执行
if (loading) return <div>加载中...</div>;
if (error) return <div>出错了:{error}</div>;
return (
<div>
<h2>用户列表(来自网络请求)</h2>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
</div>
);
}
2.6 完整示例:待办事项组件
下面是一个融合了 props + useState + useEffect + PropTypes 的完整组件:
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
// ==================== TodoItem 组件 ====================
// 作用:渲染单条待办事项,支持勾选完成、双击编辑、删除
// props:
// id - 事项的唯一标识
// text - 事项的文字内容
// completed - 是否已完成
// onToggle - 勾选/取消勾选时的回调
// onDelete - 删除时的回调
// onEdit - 编辑完成时的回调
// ======================================================
function TodoItem({ id, text, completed, onToggle, onDelete, onEdit }) {
// 状态1:是否处于编辑模式
const [isEditing, setIsEditing] = useState(false);
// 状态2:编辑时临时保存的文字
const [editText, setEditText] = useState(text);
// 副作用:当外部 text 变化时,同步更新编辑文字
useEffect(() => {
setEditText(text);
}, [text]);
// 保存编辑
const handleSave = () => {
const trimmed = editText.trim();
if (trimmed === "") {
onDelete(id); // 空文字 → 删除
} else if (trimmed !== text) {
onEdit(id, trimmed); // 有变化 → 通知父组件
}
setIsEditing(false);
};
// 取消编辑
const handleCancel = () => {
setEditText(text); // 恢复原文字
setIsEditing(false);
};
// 键盘:回车保存,Esc 取消
const handleKeyDown = (e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel();
};
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderBottom: "1px solid #eee",
backgroundColor: completed ? "#f0f0f0" : "transparent",
}}
>
{/* 完成状态复选框 */}
<input
type="checkbox"
checked={completed}
onChange={() => onToggle(id)}
/>
{/* 编辑模式 / 普通模式切换 */}
{isEditing ? (
<>
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleKeyDown}
style={{ flex: 1 }}
autoFocus
/>
<button onClick={handleSave}>保存</button>
<button onClick={handleCancel}>取消</button>
</>
) : (
<>
<span
style={{
flex: 1,
textDecoration: completed ? "line-through" : "none",
cursor: "pointer",
}}
onDoubleClick={() => setIsEditing(true)}
>
{text}
</span>
<button onClick={() => onDelete(id)} style={{ color: "red" }}>
删除
</button>
</>
)}
</div>
);
}
// PropTypes 类型校验
TodoItem.propTypes = {
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
completed: PropTypes.bool,
onToggle: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func,
};
// 默认值
TodoItem.defaultProps = {
completed: false,
};
export default TodoItem;
第三部分:组件的使用 —— 完整操作流程
3.1 组件的引入与导出
导入自己写的组件(相对路径)
import Header from "./components/Header"; // 从子文件夹导入
import Sidebar from "./Sidebar"; // 从当前目录导入
import Button from "../common/Button"; // 从上级目录导入
导入第三方库(包名路径)
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
默认导出 vs 命名导出
// ===== 定义文件:MyComponents.jsx =====
export default function Header() {
// 默认导出 —— 一个文件只有一个
return <header>网站头部</header>;
}
export function Footer() {
// 命名导出 —— 可以有多个
return <footer>网站底部</footer>;
}
export function Sidebar() {
return <aside>侧边栏</aside>;
}
// ===== 使用文件:App.jsx =====
import Header from "./MyComponents"; // 默认导出:不需要花括号
import { Footer, Sidebar } from "./MyComponents"; // 命名导出:必须花括号 + 精确匹配名字
import Header, { Footer, Sidebar } from "./MyComponents"; // 同时导入
3.2 Props 的三种传值方式
方式一:基础类型(字符串、数字、布尔)
<UserCard
name="小明" // ✅ 字符串:直接用引号
age={18} // ✅ 数字:必须用 {},写成 age="18" 就是字符串了!
isVip={true} // ✅ 布尔:必须用 {}
score={99.5} // ✅ 小数:也是数字类型,用 {}
/>
方式二:对象类型
function App() {
const userInfo = {
name: "小红",
age: 22,
address: { city: "北京", district: "朝阳区" },
};
return (
<div>
{/* 传入整个对象 */}
<ProfileCard user={userInfo} />
{/* 展开运算符:把对象属性展开为独立 props */}
<ProfileCard {...userInfo} />
{/* 等价于 <ProfileCard name="小红" age={22} address={{city:"北京",...}} /> */}
{/* 直接写对象字面量 */}
<Table config={{ columns: 3, bordered: true }} />
</div>
);
}
方式三:函数类型 —— 子组件"向上通知"父组件的标准方式
// ===== 父组件 =====
function App() {
const handleDelete = (userId) => {
console.log("要删除的用户 ID:", userId);
// 执行删除逻辑...
};
return <UserList onDelete={handleDelete} />; // 把函数作为 props 传入
}
// ===== 子组件 =====
function UserList({ onDelete }) {
return (
<ul>
<li>
小明
<button onClick={() => onDelete(1)}>删除</button>
{/* 子组件不直接改数据,而是"打电话通知"父组件 */}
</li>
</ul>
);
}
关键理解:React 数据流是单向的(父→子),子组件不能直接改父组件的数据。但子组件可以调用父组件传来的回调函数来"上报"变化,让父组件自己改。
3.3 组件的嵌套组合
children:特殊的"插槽" prop
// 定义容器组件 —— children 自动包含被包裹的内容
function Card({ title, children }) {
return (
<div style={{ border: "1px solid #ccc", borderRadius: 8, padding: 16 }}>
<h2>{title}</h2>
<div>{children}</div> {/* 渲染被包裹的内容 */}
</div>
);
}
// 使用
function App() {
return (
<div>
<Card title="个人信息">
<p>姓名:小明</p>
<p>年龄:18 岁</p>
<button>编辑资料</button>
</Card>
<Card title="最新动态">
<ul>
<li>小明发布了一篇文章</li>
<li>小红点了一个赞</li>
</ul>
</Card>
</div>
);
}
3.4 实战案例一:个人信息卡片
效果:可复用的用户信息卡片,展示头像、姓名、年龄和简介。不同 props 展示不同用户。
import React from "react";
import PropTypes from "prop-types";
function UserCard({ avatar, name, age, bio }) {
return (
<div
style={{
width: 300,
border: "1px solid #e0e0e0",
borderRadius: 12,
padding: 20,
textAlign: "center",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
margin: 10,
}}
>
<img
src={avatar}
alt={`${name} 的头像`}
style={{
width: 80,
height: 80,
borderRadius: "50%",
objectFit: "cover",
}}
/>
<h3 style={{ margin: "12px 0 4px" }}>{name}</h3>
<p style={{ color: "#888", fontSize: 14 }}>{age} 岁</p>
{/* 有 bio 才显示,没有就不渲染 */}
{bio && (
<p style={{ marginTop: 12, fontSize: 14, color: "#555" }}>{bio}</p>
)}
</div>
);
}
UserCard.propTypes = {
avatar: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
age: PropTypes.number.isRequired,
bio: PropTypes.string, // 可选
};
export default UserCard;
// 使用示例
import UserCard from "./components/UserCard";
function App() {
return (
<div style={{ display: "flex", flexWrap: "wrap" }}>
<UserCard
avatar="https://i.pravatar.cc/150?img=1"
name="小明"
age={18}
bio="热爱编程的00后,喜欢篮球和吉他"
/>
<UserCard
avatar="https://i.pravatar.cc/150?img=5"
name="小红"
age={22}
bio="设计师一枚,对一切美的事物感兴趣"
/>
<UserCard
avatar="https://i.pravatar.cc/150?img=3"
name="小刚"
age={27}
// 故意不传 bio —— 简介区域不会显示
/>
</div>
);
}
| 要点 | 说明 |
|---|---|
| props 解构 | { avatar, name, age, bio } 直接从参数取出 |
| 条件渲染 | {bio && <p>...</p>} —— 有 bio 才显示 |
| PropTypes | bio 没加 isRequired,不传也不会警告 |
| 组件复用 | 同一个 UserCard,三组不同 props,三种不同卡片 |
3.5 实战案例二:点赞计数器
效果:带点赞/踩和重置的计数器 + 操作日志。展示 useState 核心用法。
import React, { useState } from "react";
function LikeCounter() {
const [count, setCount] = useState(0); // 状态1:点赞数
const [logs, setLogs] = useState([]); // 状态2:操作日志
const handleLike = () => {
setCount((prev) => prev + 1); // 函数式更新
setLogs((prev) => [...prev, "赞 +1"]); // 不可变追加
};
const handleDislike = () => {
setCount((prev) => prev - 1);
setLogs((prev) => [...prev, "踩 -1"]);
};
const handleReset = () => {
setCount(0);
setLogs([]);
};
return (
<div
style={{
width: 300,
padding: 20,
border: "1px solid #ddd",
borderRadius: 12,
textAlign: "center",
}}
>
<div style={{ fontSize: 48, fontWeight: "bold", margin: "16px 0" }}>
{count}
</div>
<p style={{ color: "#888" }}>
{count > 0 ? "正向" : count < 0 ? "负向" : "当前为零"}
</p>
<div style={{ display: "flex", gap: 8, justifyContent: "center" }}>
<button onClick={handleLike}>赞 +1</button>
<button onClick={handleDislike}>踩 -1</button>
<button onClick={handleReset} style={{ color: "#999" }}>
重置
</button>
</div>
{logs.length > 0 && (
<div
style={{
marginTop: 16,
textAlign: "left",
fontSize: 13,
color: "#666",
}}
>
<p style={{ fontWeight: "bold" }}>操作日志:</p>
{logs.map((log, i) => (
<div key={i}>
{i + 1}. {log}
</div>
))}
</div>
)}
</div>
);
}
export default LikeCounter;
| 要点 | 说明 |
|---|---|
| 多个 useState | 一个组件可以有多个状态,各自独立管理 |
| 函数式更新 | setCount((prev) => prev + 1) 基于最新值更新 |
| 不可变数组 | [...prev, "赞 +1"] 创建新数组而非 push |
| React 自动批处理 | 同时调多个 setState,React 会合并为一次渲染 |
3.6 实战案例三:搜索过滤列表
效果:输入关键词实时过滤商品列表。综合运用 useState + useEffect + 受控输入。
import React, { useState, useEffect } from "react";
const ALL_PRODUCTS = [
{ id: 1, name: "MacBook Pro", category: "电脑", price: 12999 },
{ id: 2, name: "iPhone 15", category: "手机", price: 6999 },
{ id: 3, name: "AirPods Pro", category: "耳机", price: 1899 },
{ id: 4, name: "iPad Air", category: "平板", price: 4799 },
{ id: 5, name: "Apple Watch", category: "手表", price: 2999 },
{ id: 6, name: "Mac Mini", category: "电脑", price: 4499 },
{ id: 7, name: "iPhone SE", category: "手机", price: 3499 },
{ id: 8, name: "Magic Keyboard", category: "配件", price: 899 },
];
function ProductSearch() {
const [searchTerm, setSearchTerm] = useState(""); // 搜索词
const [filtered, setFiltered] = useState(ALL_PRODUCTS); // 筛选结果
// 搜索词变化 → 重新筛选
useEffect(() => {
const term = searchTerm.toLowerCase().trim();
if (term === "") {
setFiltered(ALL_PRODUCTS); // 空搜索 → 显示全部
} else {
setFiltered(
ALL_PRODUCTS.filter(
(p) =>
p.name.toLowerCase().includes(term) ||
p.category.toLowerCase().includes(term),
),
);
}
}, [searchTerm]);
const formatPrice = (price) => "¥" + price.toLocaleString("zh-CN");
return (
<div style={{ maxWidth: 500, margin: "0 auto", padding: 20 }}>
<h2>商品搜索</h2>
<input
type="text"
placeholder="输入商品名称或类别..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: "100%",
padding: "10px 12px",
fontSize: 16,
border: "1px solid #ccc",
borderRadius: 8,
marginBottom: 16,
boxSizing: "border-box",
}}
/>
<p style={{ color: "#888", fontSize: 14 }}>
共 {filtered.length} 件商品
{searchTerm && `(搜索:"${searchTerm}")`}
</p>
{filtered.length === 0 ? (
<p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}>
没有找到匹配的商品
</p>
) : (
filtered.map((product) => (
<div
key={product.id}
style={{
display: "flex",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid #f0f0f0",
}}
>
<div>
<span style={{ fontWeight: "bold" }}>{product.name}</span>
<span
style={{
marginLeft: 8,
fontSize: 12,
color: "#fff",
background: "#1890ff",
padding: "2px 6px",
borderRadius: 4,
}}
>
{product.category}
</span>
</div>
<span style={{ color: "#ff4d4f", fontWeight: "bold" }}>
{formatPrice(product.price)}
</span>
</div>
))
)}
</div>
);
}
export default ProductSearch;
| 要点 | 说明 |
|---|---|
| 受控组件 | value={searchTerm} + onChange 实现输入框与状态双向同步 |
| useEffect 依赖 | [searchTerm] —— 搜索词变了才重新筛选 |
| filter 不修改原数组 | ALL_PRODUCTS.filter(...) 返回新数组 |
| 空列表友好提示 | filtered.length === 0 时显示提示文字 |
| key 用唯一 ID | key={product.id} 而非 index |
第四部分:注意事项 —— 10 个高频错误
错误 1:组件名首字母没有大写
// ❌ 错误:首字母小写 → React 把它当普通 HTML 标签
function myButton() {
return <button>点击</button>;
}
// 使用:<myButton /> → 不会渲染!
// ✅ 正确:首字母必须大写
function MyButton() {
return <button>点击</button>;
}
原因:React 靠首字母大小写区分"组件"和"HTML 标签"。小写 = 原生标签,大写 = 你的组件。
错误 2:JSX 返回多个并列元素(没有唯一根节点)
// ❌ 错误:两个并列元素
function App() {
return (
<h1>标题</h1>
<p>段落</p>
);
}
// 报错:Adjacent JSX elements must be wrapped in an enclosing tag.
// ✅ 正确:用 <div> 或 <>...</> 包裹
function App() {
return (
<>
<h1>标题</h1>
<p>段落</p>
</>
);
}
原因:JSX 本质是
React.createElement()调用,return只能返回一个表达式。
错误 3:多行 JSX 的 return 后面没加括号
// ❌ 错误:return 后换行 → JS 自动插入分号 → return;(什么都没返回)
function App() {
return;
<div>
<h1>标题</h1>
</div>;
}
// 结果:页面一片空白!
// ✅ 正确:return 后紧跟 (
function App() {
return (
<div>
<h1>标题</h1>
</div>
);
}
原因:JS 的自动分号插入(ASI)机制会在
return后换行时补分号,变成return;。
错误 4:直接修改 props
// ❌ 错误:直接改 props
function Greeting({ name }) {
name = name.toUpperCase(); // 试图修改 props!
return <h1>你好,{name}</h1>;
}
// ✅ 正确:存到新变量
function Greeting({ name }) {
const upperName = name.toUpperCase(); // 新变量,不动 props
return <h1>你好,{upperName}</h1>;
}
原因:React 数据流是单向的。修改 props 会破坏数据流,导致不可预测的 Bug。
错误 5:useState / useEffect 在条件或循环中调用
// ❌ 错误:Hook 在 if 语句里
function Bad({ isAdmin }) {
if (isAdmin) {
const [data, setData] = useState(null); // 报错!
}
}
// ✅ 正确:Hook 始终在顶层
function Good({ isAdmin }) {
const [data, setData] = useState(null); // 始终在这里
useEffect(() => {
if (isAdmin) fetchData().then(setData); // 条件逻辑放里面
}, [isAdmin]);
return <div>...</div>;
}
原因:React 依赖 Hook 的调用顺序来管理状态。顺序乱了,状态就对应错了。
错误 6:直接修改 state(push、直接赋值属性)
// ❌ 错误:直接 push(引用没变,React 检测不到)
function BadList() {
const [items, setItems] = useState(["苹果", "香蕉"]);
const addItem = () => {
items.push("橘子"); // 改了原数组!
setItems(items); // 引用地址没变 → React 认为没变化 → 不渲染!
};
// 点击按钮 → 页面没有变化!
}
// ✅ 正确:创建新数组
function GoodList() {
const [items, setItems] = useState(["苹果", "香蕉"]);
const addItem = () => {
setItems([...items, "橘子"]); // 新数组,新引用 → React 检测到变化
};
// 点击按钮 → 页面出现"橘子" ✅
}
原因:React 用
===比较来判断状态是否变化。push 不改变引用地址。
错误 7:useEffect 忘了写依赖数组
// ❌ 错误:没有依赖数组 → 每次渲染都执行
function Bad() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(count);
}); // ← 没有 []!每次渲染都执行!
// 如果在里面 setCount,会死循环!
return <button onClick={() => setCount(count + 1)}>+1</button>;
}
// ✅ 正确:加上依赖数组
function Good() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(count);
}, [count]); // ← 有依赖数组,只在 count 变化时执行
return <button onClick={() => setCount(count + 1)}>+1</button>;
}
原因:不传依赖数组 = "每次渲染都执行",容易造成死循环。
错误 8:useEffect 中没清理副作用(事件、定时器泄漏)
// ❌ 错误:加了事件监听,没移除 → 内存泄漏
function Bad() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
// ⚠️ 没有返回清理函数!旧的监听器一直在!
}, [width]);
return <div>{width}px</div>;
}
// ✅ 正确:返回清理函数
function Good() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize); // 清理!
};
}, []); // 空数组:只绑定一次
return <div>{width}px</div>;
}
原因:事件、定时器等像"只管开灯不管关灯",不清理会导致内存泄漏和重复执行。
错误 9:在组件函数体中(渲染阶段)执行副作用
// ❌ 错误:在函数体中直接发请求 → 每次渲染都发 → 死循环
function Bad({ userId }) {
fetch(`/api/user/${userId}`) // ⚠️ 渲染阶段执行副作用
.then((res) => res.json())
.then((data) => {
/* setState → 触发渲染 → 又 fetch → 死循环 */
});
localStorage.setItem("lastVisit", Date.now()); // ⚠️ 也是副作用
return <div>{userId}</div>;
}
// ✅ 正确:副作用全放 useEffect
function Good({ userId }) {
const [userData, setUserData] = useState(null);
useEffect(() => {
fetch(`/api/user/${userId}`)
.then((res) => res.json())
.then(setUserData);
localStorage.setItem("lastVisit", Date.now());
}, [userId]);
return <div>{userId}</div>;
}
原因:渲染阶段应该是纯函数——只返回 JSX,不产生副作用。副作用必须放
useEffect。
错误 10:useEffect 依赖数组"撒谎"(遗漏依赖)
// ❌ 错误:effect 里用了 count,但依赖数组是空的(闭包陷阱)
function Bad() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`当前计数:${count}`); // 用了 count
}, []); // ⚠️ 依赖数组"撒谎"了 —— count 永远是闭包里的旧值 0!
return <button onClick={() => setCount(count + 1)}>+1</button>;
}
// ✅ 正确:诚实声明所有依赖
function Good() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`当前计数:${count}`);
}, [count]); // ✅ 诚实声明:我依赖 count
return <button onClick={() => setCount(count + 1)}>+1</button>;
}
原因:依赖数组是给 React 的"诚信声明"。漏写依赖会导致 effect 里的值永远是旧的(闭包陷阱)。
十大错误速查总表
| # | 错误现象 | 核心原因 | 正确做法 |
|---|---|---|---|
| 1 | 组件名首字母小写 | React 无法区分组件和 HTML 标签 | 首字母必须大写 |
| 2 | JSX 返回多个并列元素 | 必须有唯一根节点 | 用 <div> 或 <>...</> 包裹 |
| 3 | 多行 return 不用括号 | ASI 自动插入分号 | return ( 开头 |
| 4 | 直接修改 props | 破坏单向数据流 | 复制到新变量 |
| 5 | Hook 放在 if/for 里 | 破坏 Hook 调用顺序 | Hook 始终在函数体顶层 |
| 6 | 直接 push/修改 state | 引用没变,React 检测不到 | 创建新对象/数组(展开运算符) |
| 7 | useEffect 不写依赖数组 | 每次渲染都执行,容易死循环 | 按需写 [] 或 [dep1, dep2] |
| 8 | useEffect 不返回清理函数 | 事件/定时器泄漏 | return () => { cleanup } |
| 9 | 函数体中执行副作用 | 渲染阶段应是纯函数 | 副作用全放 useEffect |
| 10 | 依赖数组遗漏实际使用的变量 | 闭包陷阱,拿到的永远是旧值 | 诚实声明所有依赖 |
第五部分:配套练习
5.1 课堂练习:构建"心愿单"组件
请创建一个名为 WishList 的函数组件,满足以下要求:
- 初始数据:初始化 3 个示例心愿(如"学一门乐器"、"去西藏旅行"、"每天阅读 30 分钟")
- 添加心愿:输入框 + "添加"按钮,输入文字后加入列表。空文字不添加(alert 提示)
- 完成标记:每个心愿前有复选框,勾选后显示删除线效果
- 删除功能:已完成的心愿旁出现"删除"按钮,点击移除
- 统计信息:底部显示"共 X 个心愿,已完成 Y 个"
提示:
- 心愿数据结构:
{ id: number, text: string, completed: boolean } - 新心愿 id 可用
Date.now()生成 - 用
...展开运算符更新数组,不用push
扩展挑战(选做):
- 增加"一键清空"按钮
- 用
localStorage持久化,刷新页面后数据不丢失
5.2 随堂自测(3 道选择题)
第 1 题:关于 props,以下说法正确的是?
A. 子组件可以直接修改 props 来实现数据回传
B. props 是只读的,但可以通过调用父组件传入的回调函数来"通知"父组件更新数据
C. props 只能传递字符串
D. props 和 state 是完全一样的东西
点击查看答案
正确答案:B
- A 错:props 只读(见错误 4)
- B 对:这是 React 单向数据流的标准模式
- C 错:props 可传任何 JS 类型
- D 错:props 是外部传入(只读),state 是内部管理(可更新)
第 2 题:以下代码的 useEffect 何时执行?
function UserPage({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
return <div>{user?.name}</div>;
}
A. 只在首次渲染时执行一次
B. 每次渲染都执行
C. 首次渲染时执行,且 userId 变化时重新执行
D. 组件卸载时执行
点击查看答案
正确答案:C
- 依赖数组是
[userId],所以首次挂载后执行,之后每次 userId 变化时重新执行 - A 对应
[],B 对应不传数组,D 对应清理函数
第 3 题:关于 useState,哪一项是错误的?
A. useState 返回 [当前值, 更新函数]
B. 调用 setCount(count + 1) 后,count 会立刻变成新值
C. 基于旧值更新时推荐 setCount((prev) => prev + 1)
D. 一个组件可以多次调用 useState
点击查看答案
正确答案:B
- B 错误:
setCount是异步的,调用后立即读count拿到的还是旧值(见 2.4 规则三) - A 对:正是数组解构的写法
- C 对:函数式更新避免闭包陷阱
- D 对:可以有多个独立的状态
学习建议:学完本文后,打开你的 React 项目,亲手创建 3~5 个组件练手。重点体验"props 从上传下"和"state 管理内部变化"两种数据流动方式的区别。
遇到问题时,先对照第四部分的十大错误速查表 —— 你的问题大概率就在其中。
当你能够熟练地拆分组件、理解 props 与 state 的区别、正确使用 useEffect 处理副作用时,恭喜你 —— 你已经掌握了 React 最核心的能力。
评论区