State与Props —— 让组件活起来
如果把组件比作一个演员,Props 就是导演给他的剧本台词,State 就是他自己的情绪记忆。剧本决定了他要说什么,情绪决定了他怎么表达。两者配合,一个”活”的角色才能立在舞台上。
📋 开篇自测:你已经知道多少?
- Props 和 State 的核心区别是什么?哪个可以被组件自身修改?
- 为什么连续调用两次
setCount(count + 1)结果只加了 1,而不是 2?- 子组件想把数据传给父组件,在 React 中应该怎么做?
一、Props —— 组件的”入参”
1.1 理解 Props
你一定写过函数:
function greet(name) {
return `你好,${name}!`;
}
greet("小明"); // "你好,小明!"
name 是函数的参数,调用时从外部传入。React 组件的 Props 就是同样的道理——它是父组件传给子组件的数据通道。
// 定义一个 UserCard 组件,接收 props
function UserCard(props) {
return (
<div className="user-card">
<h2>{props.name}</h2>
<p>年龄:{props.age}</p>
<p>职业:{props.job}</p>
</div>
);
}
// 父组件使用时传入数据
function App() {
return (
<div>
<UserCard name="小明" age={25} job="前端工程师" />
<UserCard name="小红" age={28} job="产品经理" />
</div>
);
}
同一个 UserCard 组件,传入不同的 Props,渲染出不同的内容。这就是组件复用的基础。
1.2 解构 Props
每次写 props.xxx 很繁琐,实际开发中我们几乎都用解构写法:
function UserCard({ name, age, job }) {
return (
<div className="user-card">
<h2>{name}</h2>
<p>年龄:{age}</p>
<p>职业:{job}</p>
</div>
);
}
代码更简洁,而且一眼就能看出这个组件需要哪些数据。
1.3 默认值与 children
当某个 Props 没有传入时,可以设置默认值:
function Button({ text = "点击", type = "primary", children }) {
return (
<button className={`btn btn-${type}`}>
{children || text}
</button>
);
}
// 三种使用方式
<Button /> // 显示"点击"
<Button text="提交" type="danger" /> // 显示"提交",红色样式
<Button>自定义内容</Button> // 显示"自定义内容"
children 是一个特殊的 Props,它代表组件标签之间包裹的内容。在上面的例子中,<Button>自定义内容</Button> 里的”自定义内容”就是 children。
1.4 Props 的核心规则:只读
Props 是只读的。 子组件不能修改从父组件接收到的 Props。
function UserCard({ name }) {
// 错误!不要这样做
// name = "张三";
return <h2>{name}</h2>;
}
为什么?因为 Props 代表的是父组件的意志。如果子组件能随意修改 Props,数据流就乱了——你不知道一个值到底是谁改的、什么时候改的。React 的单向数据流设计就是为了避免这种混乱。
🤔 想一想 Props 只读这个规则,和 JavaScript 中”纯函数”的概念有什么关系?纯函数的特征之一是不修改传入的参数。React 组件本质上就是一个接收 Props、返回 JSX 的纯函数。
二、Props 的类型检查
当项目规模变大、组件数量增多时,一个常见的问题是:传错了 Props 类型,但没有任何报错,直到运行时页面显示异常才发现。
2.1 TypeScript 方式(推荐)
在 TypeScript 项目中,用 interface 或 type 定义 Props 类型是最佳实践:
interface UserCardProps {
name: string;
age: number;
job: string;
avatar?: string; // 可选属性
onFollow?: () => void; // 可选的回调函数
}
function UserCard({ name, age, job, avatar, onFollow }: UserCardProps) {
return (
<div className="user-card">
{avatar && <img src={avatar} alt={name} />}
<h2>{name}</h2>
<p>年龄:{age}岁</p>
<p>职业:{job}</p>
{onFollow && <button onClick={onFollow}>关注</button>}
</div>
);
}
这样做的好处:编辑器会在你写代码时实时提示哪些 Props 是必传的、类型是什么,传错了立刻红线报错,不用等到运行时才发现。
2.2 PropTypes 方式(了解即可)
在非 TypeScript 的项目中,React 提供了 prop-types 库做运行时类型检查:
import PropTypes from 'prop-types';
function UserCard({ name, age, job }) {
return (
<div>
<h2>{name}</h2>
<p>{age}岁 · {job}</p>
</div>
);
}
UserCard.propTypes = {
name: PropTypes.string.isRequired,
age: PropTypes.number.isRequired,
job: PropTypes.string.isRequired,
};
PropTypes 只在开发环境下生效,生产构建会自动移除。但它的检查发生在运行时,不如 TypeScript 在编译时就拦截问题。新项目强烈建议直接使用 TypeScript。
三、State —— 组件的”记忆”
Props 是外部传入的,组件自身不能改。那如果组件需要记住一些”自己的东西”——比如用户点了几次按钮、输入框里写了什么、下拉菜单是展开还是收起——怎么办?
这就是 State 的用武之地。State 是组件的私有记忆,由组件自身管理,改变时会触发重新渲染。
3.1 没有 State 的困境
先看一个”错误示范”,理解为什么需要 State:
function Counter() {
let count = 0;
function handleClick() {
count = count + 1;
console.log(count); // 值确实变了:1, 2, 3...
}
return (
<div>
<p>计数:{count}</p>
<button onClick={handleClick}>+1</button>
</div>
);
}
点击按钮后,console.log 里的 count 确实在增加,但页面上始终显示 0。为什么?
因为普通的局部变量改变不会通知 React 去重新渲染。React 不知道这个变量变了,所以界面不会更新。这就像你在脑子里默默改了一个数字,但嘴巴没有说出来——外界当然看不到变化。
3.2 useState:正确的姿势
React 提供了 useState Hook 来声明状态变量:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<div>
<p>计数:{count}</p>
<button onClick={handleClick}>+1</button>
</div>
);
}
useState(0) 做了两件事:
- 声明一个状态变量
count,初始值为0 - 返回一个更新函数
setCount,调用它可以修改count的值并触发组件重新渲染
[count, setCount] 是数组解构语法——useState 返回一个长度为 2 的数组,第一项是当前值,第二项是更新函数。名字可以随意取,但约定俗成使用 [xxx, setXxx] 的命名方式。
四、useState 深入 —— 更新的学问
4.1 直接赋值更新
最简单的用法,直接传入新值:
const [name, setName] = useState("小明");
const [age, setAge] = useState(25);
const [todos, setTodos] = useState([]);
setName("小红"); // 字符串
setAge(26); // 数字
setTodos(["吃饭", "睡觉"]); // 数组
4.2 函数式更新(重要)
当新的 State 需要基于旧的 State 计算时,应该使用函数式更新:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// 方式一:直接赋值(在某些场景下有问题)
// setCount(count + 1);
// 方式二:函数式更新(推荐)
setCount(prev => prev + 1);
}
return <button onClick={handleClick}>计数:{count}</button>;
}
为什么推荐函数式更新? 看一个经典的坑:
function handleClick() {
setCount(count + 1); // count 此时是 0
setCount(count + 1); // count 此时还是 0!
setCount(count + 1); // count 此时还是 0!
}
// 点击后 count 变成 1,不是 3
在同一次事件处理中,count 的值是固定的(闭包特性)。三次 setCount(0 + 1) 其实都在设置 count = 1。
使用函数式更新就没有这个问题:
function handleClick() {
setCount(prev => prev + 1); // 0 → 1
setCount(prev => prev + 1); // 1 → 2
setCount(prev => prev + 1); // 2 → 3
}
// 点击后 count 正确变成 3
函数式更新的参数 prev 始终是最新的 State 值,不受闭包影响。
一条实用规则:只要新值依赖旧值,就用函数式更新。
4.3 更新对象和数组
State 可以是任何类型。但更新对象和数组时,必须创建新的引用,不能直接修改原对象:
const [user, setUser] = useState({ name: "小明", age: 25 });
// 错误:直接修改原对象,React 检测不到变化
// user.age = 26;
// setUser(user);
// 正确:创建新对象(展开运算符)
setUser({ ...user, age: 26 });
// 正确:使用函数式更新
setUser(prev => ({ ...prev, age: prev.age + 1 }));
数组同理:
const [items, setItems] = useState(["苹果", "香蕉"]);
// 添加元素
setItems(prev => [...prev, "橙子"]);
// 删除元素(过滤掉索引为 1 的)
setItems(prev => prev.filter((_, index) => index !== 1));
// 修改元素
setItems(prev => prev.map((item, index) =>
index === 0 ? "大苹果" : item
));
React 通过比较引用(===)来判断 State 是否变化。如果你修改原对象再调用 setState,引用没有改变,React 就认为”没变”,不会重新渲染。
🤔 想一想 为什么 React 要求用”不可变”的方式更新 State?直接修改原对象不是更省内存吗?提示:想想 React 如何判断”需不需要重新渲染”——用
===比较两个对象,如果是同一个引用,即使内部属性变了也会被认为”没变”。不可变更新虽然创建了新对象,但让”变没变”的判断变得又快又准。
4.4 惰性初始化
如果初始 State 需要经过计算得出,可以传入一个函数,避免每次渲染都重复计算:
// 每次渲染都会执行 computeExpensiveValue(),即使只用一次
const [data, setData] = useState(computeExpensiveValue());
// 只在首次渲染时执行一次
const [data, setData] = useState(() => computeExpensiveValue());
常见场景:从 localStorage 读取初始值。
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') || 'light';
});
五、State vs Props —— 什么时候用哪个?
State 和 Props 都是数据,但角色完全不同。用一张表对比:
| 维度 | Props | State |
|---|---|---|
| 谁来提供 | 父组件传入 | 组件自身声明 |
| 可否修改 | 只读,不可修改 | 可以通过 setter 更新 |
| 触发渲染 | 父组件重新传入新 Props 时 | 调用 setState 时 |
| 类比 | 函数的参数 | 函数内部的局部变量 |
| 用途 | 配置组件行为、传递数据 | 记录交互状态、管理动态数据 |
判断原则:
- 这个数据是从外部传进来的吗? → Props
- 这个数据会因为用户操作而改变吗? → State
- 这个数据能从已有的 Props 或 State 计算出来吗? → 都不用,直接在渲染时计算
举个例子:一个商品卡片组件。
function ProductCard({ name, price, discount }) {
// name, price, discount 是外部传入的 → Props
// 是否收藏是用户行为决定的 → State
const [isFavorite, setIsFavorite] = useState(false);
// 折后价可以从 Props 计算出来 → 不需要额外的 State
const finalPrice = price * (1 - discount);
return (
<div>
<h3>{name}</h3>
<p>原价:¥{price} | 折后:¥{finalPrice.toFixed(2)}</p>
<button onClick={() => setIsFavorite(prev => !prev)}>
{isFavorite ? "❤️ 已收藏" : "🤍 收藏"}
</button>
</div>
);
}
finalPrice 完全可以从 price 和 discount 算出来,不需要单独存一个 State。能算出来的值,就不要存成 State——这是减少 bug 的一条重要原则。
六、单向数据流 —— React 的哲学
在 React 中,数据像瀑布一样从上往下流动:
App(数据源)
↓ props
Header(展示标题)
↓ props
TodoList(展示列表)
↓ props
TodoItem(展示单条)
这就是单向数据流(One-Way Data Flow):
- 父组件通过 Props 把数据传给子组件
- 子组件不能反过来修改父组件的数据
- 数据只沿一个方向流动:从上到下
为什么要这样设计?想象一个 10 层楼的组件树,如果任何一层都能随意修改任何一层的数据,出了 bug 你得从上到下、从左到右排查所有可能——就像一团纠缠在一起的毛线球。
单向数据流让数据的来源和去向一目了然。某个数据出了问题,只要沿着组件树往上找”谁传给我的”就能定位。
七、组件通信模式
单向数据流规定了数据从上往下流。但实际开发中,不可能所有通信都是父传子。React 提供了几种模式来解决不同方向的通信需求。
7.1 父传子:Props
这是最基本的模式,前面已经充分演示。数据和回调函数都可以通过 Props 传递。
function Parent() {
const [message, setMessage] = useState("你好");
return <Child text={message} />;
}
function Child({ text }) {
return <p>{text}</p>;
}
7.2 子传父:回调函数
子组件不能直接修改父组件的数据,但父组件可以把一个”回调函数”通过 Props 传下去。子组件调用这个函数,就等于间接通知了父组件。
function Parent() {
const [score, setScore] = useState(0);
// 把"加分"的能力通过 Props 传给子组件
return (
<div>
<p>总分:{score}</p>
<Child onScore={(points) => setScore(prev => prev + points)} />
</div>
);
}
function Child({ onScore }) {
return (
<div>
<button onClick={() => onScore(10)}>答对了 +10</button>
<button onClick={() => onScore(-5)}>答错了 -5</button>
</div>
);
}
数据依然是从上到下流动(父组件的 score 通过 Props 传下去展示),操作指令从下到上传递(子组件通过调用 onScore 通知父组件)。这并没有违反单向数据流——回调函数只是在”请求”父组件更新自己的 State。
7.3 兄弟通信:状态提升
两个平级的兄弟组件需要共享数据怎么办?把共享的 State 提升到它们共同的父组件中管理。
function App() {
// 状态提升:温度数据由父组件统一管理
const [celsius, setCelsius] = useState(0);
return (
<div>
<CelsiusInput
value={celsius}
onChange={(val) => setCelsius(val)}
/>
<FahrenheitDisplay celsius={celsius} />
</div>
);
}
function CelsiusInput({ value, onChange }) {
return (
<label>
摄氏温度:
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
);
}
function FahrenheitDisplay({ celsius }) {
const fahrenheit = (celsius * 9) / 5 + 32;
return <p>华氏温度:{fahrenheit.toFixed(1)}°F</p>;
}
CelsiusInput 和 FahrenheitDisplay 是兄弟组件。它们不能直接对话,但通过父组件 App 中转:输入组件改变父组件的 State,父组件把最新值传给展示组件。
7.4 受控组件与非受控组件
在上面的温度输入框示例中,<input> 的 value 由 React 的 State 控制——用户输入触发 onChange,onChange 更新 State,State 变化导致 input 的 value 更新。这种模式叫做受控组件。
与之对应的是非受控组件——不用 State 控制表单值,而是用 defaultValue 设置初始值,需要时通过 ref 读取 DOM 的当前值:
import { useRef } from 'react';
function UncontrolledForm() {
const inputRef = useRef(null);
function handleSubmit() {
// 需要值的时候,直接从 DOM 读取
alert('输入的内容:' + inputRef.current.value);
}
return (
<div>
<input ref={inputRef} defaultValue="初始值" />
<button onClick={handleSubmit}>提交</button>
</div>
);
}
什么时候用哪个? 大部分场景用受控组件——因为 React State 中始终保存着最新值,方便做验证、联动、条件渲染。非受控组件适合不需要实时追踪值的场景(比如文件上传的 <input type="file">)。
🤔 想一想 受控组件中,如果你只设置了
value但没有onChange处理函数,输入框会怎样?试试看:你会发现无论怎么打字,输入框的内容都不会变——因为 React 的 State 没有更新,value始终是初始值。这就是”受控”的含义:控制权完全在 State 手中。
八、实战:搭建待办事项列表
把本章学到的所有知识串起来,构建一个完整的 TodoList 应用。
8.1 功能需求
- 输入框添加新待办
- 点击切换完成/未完成状态
- 删除待办
- 显示统计信息(总数、已完成数)
8.2 完整代码
import { useState } from 'react';
// ---- 子组件:单条待办 ----
function TodoItem({ todo, onToggle, onDelete }) {
return (
<li style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 0',
borderBottom: '1px solid #eee',
}}>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
<span style={{
flex: 1,
textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#999' : '#333',
}}>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
style={{
background: 'none',
border: '1px solid #ddd',
borderRadius: '4px',
padding: '2px 8px',
cursor: 'pointer',
color: '#e74c3c',
}}
>
删除
</button>
</li>
);
}
// ---- 子组件:统计栏 ----
function TodoStats({ total, doneCount }) {
return (
<div style={{
padding: '12px 0',
color: '#666',
fontSize: '14px',
}}>
共 {total} 项,已完成 {doneCount} 项,未完成 {total - doneCount} 项
</div>
);
}
// ---- 子组件:输入栏 ----
function TodoInput({ onAdd }) {
const [text, setText] = useState('');
function handleSubmit(e) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
onAdd(trimmed);
setText('');
}
return (
<form onSubmit={handleSubmit} style={{
display: 'flex',
gap: '8px',
marginBottom: '16px',
}}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="添加新待办..."
style={{
flex: 1,
padding: '8px 12px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '16px',
}}
/>
<button type="submit" style={{
padding: '8px 20px',
background: '#3498db',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px',
}}>
添加
</button>
</form>
);
}
// ---- 主组件:TodoApp ----
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: '学习 React Props', done: true },
{ id: 2, text: '学习 React State', done: false },
{ id: 3, text: '完成 TodoList 实战', done: false },
]);
// 添加待办(子传父:TodoInput 通过 onAdd 回调通知父组件)
function handleAdd(text) {
setTodos(prev => [
...prev,
{ id: Date.now(), text, done: false },
]);
}
// 切换完成状态
function handleToggle(id) {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}
// 删除待办
function handleDelete(id) {
setTodos(prev => prev.filter(todo => todo.id !== id));
}
// 统计数据:从 State 计算得出,不需要额外的 State
const doneCount = todos.filter(todo => todo.done).length;
return (
<div style={{
maxWidth: '500px',
margin: '40px auto',
padding: '20px',
fontFamily: '-apple-system, sans-serif',
}}>
<h1 style={{ fontSize: '24px', marginBottom: '16px' }}>
待办事项
</h1>
{/* 父传子:传入 onAdd 回调 */}
<TodoInput onAdd={handleAdd} />
{/* 父传子:传入数据和回调 */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</ul>
{/* 父传子:传入计算得到的统计数据 */}
<TodoStats total={todos.length} doneCount={doneCount} />
</div>
);
}
export default TodoApp;
8.3 知识点回顾
这个 TodoList 虽然不大,但涵盖了本章的所有核心知识:
Props 相关:
TodoItem通过 Props 接收todo数据、onToggle和onDelete回调TodoStats通过 Props 接收计算好的统计数据TodoInput通过 Props 接收onAdd回调
State 相关:
TodoApp用useState管理 todos 数组TodoInput用useState管理输入框文本(受控组件)- 所有 State 更新都使用函数式更新 + 不可变方式
组件通信:
- 父传子:
TodoApp→TodoItem(传数据和回调) - 子传父:
TodoInput→TodoApp(通过onAdd回调传递新待办文本) - 兄弟通信:
TodoInput和TodoItem通过父组件TodoApp的状态提升间接通信
派生数据:
doneCount从todos计算得出,没有额外存成 State
📝 掌握度自测
-
以下关于 Props 的说法,哪一项是错误的?
- A) Props 是从父组件传递给子组件的数据
- B) 子组件可以通过
this.props.xxx = newValue修改 Props - C) Props 可以传递函数
- D)
children是一个特殊的 Props
-
以下代码点击按钮后,页面显示的数字是多少?
const [count, setCount] = useState(0); function handleClick() { setCount(count + 5); setCount(count + 5); }- A) 0
- B) 5
- C) 10
- D) 报错
-
子组件想通知父组件”用户点击了删除按钮”,正确的做法是:
- A) 子组件直接修改父组件的 State
- B) 父组件把一个回调函数通过 Props 传给子组件,子组件调用它
- C) 子组件通过
window.postMessage发送消息 - D) 子组件使用
document.dispatchEvent触发自定义事件
-
以下哪种更新对象 State 的方式是正确的?
- A)
user.name = "新名字"; setUser(user); - B)
setUser({ ...user, name: "新名字" }); - C)
setUser(Object.assign(user, { name: "新名字" })); - D)
setUser((prev) => { prev.name = "新名字"; return prev; });
- A)
-
关于受控组件,以下描述正确的是:
- A) 受控组件的值由 DOM 自身管理
- B) 受控组件使用
defaultValue设置初始值 - C) 受控组件的值由 React State 控制,通过
onChange更新 - D) 受控组件不需要事件处理函数
💡 自我评估
- 答对5题:State 与 Props 已经融会贯通,可以自信地构建交互式组件了!
- 答对3-4题:核心概念已经掌握,再回顾一下函数式更新和不可变更新的细节。
- 答对0-2题:建议动手把 TodoList 从零敲一遍。理解这些概念最好的方式不是读,而是写代码、犯错误、修bug。
参考答案: 1-B, 2-B, 3-B, 4-B, 5-C
购买课程解锁全部内容
从组件到架构:12 章系统掌握现代 React
¥29.90