React 第5周笔记 组件拆分+状态提升
路线图阶段:第一阶段(React 基础)第5周 项目:给相册加搜索/筛选栏(按 author 筛选) 核心:学会”把大组件拆小”+“决定共享状态住在哪” 验收目标:拿到设计稿,能先画组件树,再写代码
1. 为什么要拆分组件
一个组件包揽所有事(取数据 + 状态 + 渲染),功能一多就变成几百行的”意大利面代码”,没法读、没法维护、没法复用。
专业做法:拆成小组件,每个只负责一件事(单一职责)。
类比设计:就像设计系统里,你不会把整个页面做成一个巨型组件,而是拆成 Button / Card / Input 等小组件,再组合起来。React 同理。
2. 组件树(Component Tree)—— 像设计系统一样思考 UI
拿到界面,先在脑子里(或纸上)拆出层级结构。这是 design engineer 的核心技能,也是你的设计背景优势。
本周相册的组件树:
Gallery (管数据 photos/loading/error + 协调)
├─ SearchBar (搜索框 —— 用户输入)
├─ ResultsLabel (显示当前关键词,可选)
└─ PhotoGrid / 图片网格 (渲染图片)
└─ PhotoCard × N (单张卡片)
每个组件只管自己那块:SearchBar 只管输入,PhotoCard 只管显示一张图。
⚠️ 组件要定义在顶层、互相平级,不要把一个组件定义在另一个组件函数内部。 嵌套定义会导致父组件每次重渲染都”重新创建”子组件,引发状态丢失和性能问题。 区分两个动作:定义(顶层写
function SearchBar(){}) vs 使用(父组件 return 里写<SearchBar />)。
3. 状态提升(Lifting State Up)—— 本周最核心
问题:共享状态该住在哪个组件?
多个组件都要用同一份数据(如搜索关键词 query),它该存在哪?
错误直觉:放根组件(App)
无脑放最顶层会导致:
- 逐层传递(prop drilling):状态穿过一堆不需要它的中间组件才能到达目标。
- 根组件变”上帝组件”:什么都管,几百行,没法维护。
正确原则:放在「最近共同父组件」(closest common parent)
状态提升三步法:
- 找出所有需要这个 state 的组件(读 或 写)
- 找它们最近的共同父组件
- state 就放那里,再通过 props 往下分发
本周例子:谁需要 query?
- SearchBar —— 写(用户打字改它)
- ResultsLabel —— 读(显示关键词)
- PhotoGrid —— 读(按它筛选)
三者最近的共同父 = Gallery(不是 App)。所以 query 放 Gallery。
而且 Gallery 本来就管着 photos,query 放这儿正好和数据待一起。
4. 数据流铁律:数据下行,事件上报
Data flows down (props), events flow up (callbacks).
Gallery (持有 query state)
│
├─ query + setQuery ──props──▶ SearchBar
│ (SearchBar 输入时调用 setQuery,把新值"报上来")
│
└─ 筛选后的 photos ──props──▶ PhotoGrid
- 数据下行:父把值通过 props 传给子(
query={query})。 - 事件上报:父把”改值的函数”传给子,子在适当时机调用它,把变化”报”回父。
5. 回调函数(Callback)模式 —— 子组件如何”通知”父组件
子组件不持有状态,只能通过”调用父传下来的函数”来触发父组件更新。 回调函数把函数当成参数传入函数,等待被调用,回调通常不是为了拿返回值,而是为了”让某段逻辑在合适的时机被执行”。
// 父:把 setQuery 作为 prop 传下去
<SearchBar query={query} onQueryChange={setQuery} />
// 子:接收并在事件里调用它
function SearchBar({ query, onQueryChange }) {
return (
<input
value={query}
onChange={(e) => onQueryChange(e.target.value)} // 把新值"报"给父
/>
);
}
关键理解点
- prop 名 ≠ 传进去的值:
onQueryChange是子组件起的”代号”,setQuery是真正传进去的函数。调用onQueryChange("k")实际就是setQuery("k")。 - 调用回调不为返回值,只为触发动作(副作用):
onQueryChange(...)返回 undefined,你要的是”它去触发父组件更新”,不是它的返回值。 - 不需要额外动作时,直接传
setQuery最干净;若想更新前做别的事(如记日志),才自己包一个 handler 函数传下去。
6. 派生状态(Derived State)—— 筛选千万别改源数据
❌ 大错:筛选后 setState 改原数据
const filtered = photos.filter((p) => p.author.includes(query));
setPhotos(filtered); // ❌ 灾难
后果:
- 原始 photos 被永久覆盖,清空搜索想看全部时,数据已经没了。
- setPhotos 触发重渲染 → 又筛选 → 又 setPhotos → 死循环(第4周那个坑)。
✅ 正确:渲染时当场算,不存 state
const filteredPhotos = photos.filter((p) => p.author.includes(query));
// 直接用 filteredPhotos 渲染,全程不碰 photos
数据流:
photos(原始,不变) ┐
├─→ filteredPhotos(每次渲染当场算)─→ 渲染
query(变) ┘
核心原则:原始数据是”数据源(source of truth)“,绝不因筛选/显示需要去改它。 能从现有 state 算出来的值 = 派生状态,直接算,不要再开一个 state。 两份数据 = 两份要手动同步的负担 = bug 温床。
7. 本周用到的 JS 小知识
字符串 .includes():包含匹配
photo.author.includes(query) // author 里"包含"query 就算匹配
"Kevin".includes("kev") // 注意:大小写敏感,"kev" 匹配不到大写 K
- 对比
===(完全相等):author === query要求一字不差,搜索框不该用。 - 进阶:两边都
.toLowerCase()可忽略大小写。
空字符串的妙用:空搜索自动显示全部
"任何字符串".includes("") // 永远 true
所以 query 为空时,filter 全部通过 → 自动显示全部,不用额外写 if。
数组/对象永远是 truthy(高频坑)
Boolean([]) // true ← 空数组也是 true!
Boolean({}) // true ← 空对象也是 true!
判断数组”空不空”必须用 .length,不能直接拿数组当布尔值:
filteredPhotos.length === 0 // ✅ 判断空
filteredPhotos ? ... : ... // ❌ 永远走第一个分支
8. 空状态(Empty State)—— 设计师的本能,用代码实现
列表为空时,不该是一片空白,要给用户明确反馈(你做 UI 时熟悉的 empty state)。
{filteredPhotos.length === 0 ? (
<p>没有找到匹配的照片</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "12px" }}>
{filteredPhotos.map((photo) => (
<div key={photo.id}>
<img src={`https://picsum.photos/id/${photo.id}/300/200`} alt={photo.author}
style={{ width: "100%", display: "block", borderRadius: "8px" }} />
</div>
))}
</div>
)}
整个三元用 {} 包进 JSX,两个分支各用 () 包住内容。
完整代码:带搜索的相册
import { useState, useEffect } from "react";
function Gallery() {
const [photos, setPhotos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [query, setQuery] = useState("");
useEffect(() => {
async function loadPhotos() {
try {
const res = await fetch("https://picsum.photos/v2/list?page=1&limit=30");
const data = await res.json();
setPhotos(data);
setLoading(false);
} catch (err) {
setError(err.message);
setLoading(false);
}
}
loadPhotos();
}, []);
if (loading) return <p>加载中...</p>;
if (error) return <p>出错了:{error}</p>;
// derived state —— compute, don't store
const filteredPhotos = photos.filter((photo) => photo.author.includes(query));
return (
<>
<SearchBar query={query} onQueryChange={setQuery} />
{filteredPhotos.length === 0 ? (
<p>没有找到匹配的照片</p>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "12px" }}>
{filteredPhotos.map((photo) => (
<div key={photo.id}>
<img
src={`https://picsum.photos/id/${photo.id}/300/200`}
alt={photo.author}
style={{ width: "100%", display: "block", borderRadius: "8px" }}
/>
</div>
))}
</div>
)}
</>
);
}
function SearchBar({ query, onQueryChange }) {
return (
<input
type="text"
placeholder="搜索..."
value={query}
onChange={(e) => onQueryChange(e.target.value)}
/>
);
}
function App() {
return <Gallery />;
}
export default App;
第5周验收
- 能把一个界面拆成组件树,说清父子关系
- 懂状态提升:共享状态放「最近共同父组件」,不是无脑放根
- 懂数据下行 / 事件上报,会用回调让子组件通知父组件
- 懂派生状态:筛选结果当场算,不改源数据、不开新 state
第5周诊断小测结论(自我复盘)
5 题答对 4 题,概念层面(派生状态 / truthy / setState 触发渲染 / 回调)都通了。 唯一反复犯的点:事件处理传参数,必须用箭头函数包一层。
- ❌
onClick={onToggle(!isOn)}(渲染时立刻执行) - ✅
onClick={() => onToggle(!isOn)}(点击时才执行) - 口诀:「事件处理要传参,箭头包裹慢慢谈」——看到要传参,先写
() =>。
这是熟练度问题,不是理解问题。多写几次即可成肌肉记忆。