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)

无脑放最顶层会导致:

正确原则:放在「最近共同父组件」(closest common parent)

状态提升三步法:

  1. 找出所有需要这个 state 的组件(读 或 写)
  2. 找它们最近的共同父组件
  3. state 就放那里,再通过 props 往下分发

本周例子:谁需要 query?

三者最近的共同父 = 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

5. 回调函数(Callback)模式 —— 子组件如何”通知”父组件

子组件不持有状态,只能通过”调用父传下来的函数”来触发父组件更新。 回调函数把函数当成参数传入函数,等待被调用,回调通常不是为了拿返回值,而是为了”让某段逻辑在合适的时机被执行”。

// 父:把 setQuery 作为 prop 传下去
<SearchBar query={query} onQueryChange={setQuery} />

// 子:接收并在事件里调用它
function SearchBar({ query, onQueryChange }) {
  return (
    <input
      value={query}
      onChange={(e) => onQueryChange(e.target.value)} // 把新值"报"给父
    />
  );
}

关键理解点


6. 派生状态(Derived State)—— 筛选千万别改源数据

❌ 大错:筛选后 setState 改原数据

const filtered = photos.filter((p) => p.author.includes(query));
setPhotos(filtered);  // ❌ 灾难

后果:

  1. 原始 photos 被永久覆盖,清空搜索想看全部时,数据已经没了。
  2. 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

空字符串的妙用:空搜索自动显示全部

"任何字符串".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周验收


第5周诊断小测结论(自我复盘)

5 题答对 4 题,概念层面(派生状态 / truthy / setState 触发渲染 / 回调)都通了。 唯一反复犯的点:事件处理传参数,必须用箭头函数包一层。

这是熟练度问题,不是理解问题。多写几次即可成肌肉记忆。