← FC Coder · HomePhase 02 · Lesson 21 · 60 min
Lesson21
Phase Two · Fantasy Builder · Matchday 21 · Coach Picks the Eleven

阵容板 baby
教练手里的大战术板

Today's 3 Jobs · 今天这三件事
  1. 01
    PlayerCard 收两个新 props · isSelected + onToggle
    卡不再自己记 · 父级告诉它
  2. 02
    新建 app/fantasy/page.tsx · useState<Player[]>([])
    教练手里那张大战术板
  3. 03
    🌟 凑齐 11 人 · 左右两边同步金边
    选你最爱的 11 人

第 19 课每张卡内部 `useState(isSelected)` —— 信息散在 22 个小脑子里 · 教练拼不出 11 人。今天把 isSelected 升到父组件 fantasy/page.tsx。`useState<Player[]>([])` 在最顶 · 22 张卡都看同一份板 · 左右两边自动同步。Phase 2 的分水岭:状态提升。

Concept · 第 1 个新概念

状态提升 = 教练手里的大战术板

11 人数据放父组件 · 22 张卡只是被画的对象。

Chalk Board · 19 课 vs 21 课
// 父组件 = 教练
const [squad, setSquad] = useState<Player[]>([]);

// 子组件 = 卡(不再 useState)
<PlayerCard isSelected={squad.some(s => s.name === p.name)}
onToggle={() => addPlayer(p)} />
19 课
每张卡 useState(false) · 22 个小记忆
21 课
父级 useState<Player[]>([]) · 1 份总板
好处
知道凑了几个 · 挡得住 12 · 能发家人群

球员卡只问教练「我画上了吗」(isSelected prop)+ 告诉教练「我被点了」(onToggle prop)。

Concept · 第 2 个新概念

数组 state · 不能 push · 拿新板

加 = [...squad, p] · 删 = .filter · 改 = .map · 清空 = []

Chalk Board · 数组 state 三条规矩
// 直接改 · React 看不见
squad.push(haaland) ❌

// 拿新板告诉 React
setSquad([...squad, haaland]) ✅
❌ 错
squad.push(haaland)
✅ 对
setSquad([...squad, haaland])
setSquad(squad.filter(p => p.name !== name))

...(三个点)= 展开 / spread · 把数组里所有东西一个一个铺开。新数组 = 旧的所有 + 新这个

Roster · 今天 3 个新工具

state lift · 数组 state · spread

抄着用 · Step 8 看到左右同步金边那一刻是今天的「哇」。

  1. 01
    isSelected ↑ onToggle ↑
    状态提升 —— 卡内部 useState 拿掉 · isSelected 变 prop。
    22 张卡看同一份 squad 数组 · 教练手里那张大战术板。
  2. 02
    useState<Player[]>([])
    数组 state —— 一组数据当成 state。
    初值 `[]` 空数组。类型 `Player[]` 告诉 TS 每一项是什么。
  3. 03
    setSquad([...squad, p])
    拿新板 · 不能 push —— 三个点 = 旧的全部铺开 + 新加一个。
    删用 .filter · 改用 .map。不能 `squad.push` —— React 看不见。
Sandbox · 在课程页里先玩一下

迷你阵容板 · 5 人上限 · 12 个可选

和 /fantasy 同一个逻辑 · 上限改 5 让你快速看「满了」拦人。

迷你阵容板

0 / 5

可选 · 11

哈兰德

ST · 评分 91

挪威 · 曼城

福登

LW · 评分 87

英国 · 曼城

罗德里

CM · 评分 90

西班牙 · 曼城

德布劳内

CM · 评分 88

比利时 · 曼城

阿坎吉

CB · 评分 84

瑞士 · 曼城

埃德森

GK · 评分 88

巴西 · 曼城

多库

LW · 评分 82

比利时 · 曼城

贝尔纳多

CM · 评分 86

葡萄牙 · 曼城

阿克

CB · 评分 81

荷兰 · 曼城

迪亚斯

CB · 评分 85

葡萄牙 · 曼城

格瓦迪奥尔

CB · 评分 84

克罗地亚 · 曼城

阵容

左边点 5 个进来。点右边的卡 = 踢出阵容。

提示:state 在 FormationSandbox 顶部的 useState<Player[]> · errors / isComplete 全部派生(无 useState)。

Half 2 · 在屏幕上

改 PlayerCard · 写 fantasy · 凑 11 人

做完一步就点 ✓。Step 8 左右同步金边那一下是今天的「哇」。

01改 PlayerCard · 拿掉小记忆
01Min

Cursor + pnpm dev

老三件套。打开 components/player-card/index.tsx。
02Min

PlayerCard:删 useState · 加 isSelected? + onToggle? 两个可选 prop

function PlayerCard({ player, isSelected = false, onToggle }: { player: Player; isSelected?: boolean; onToggle?: () => void }) · <article onClick={onToggle}>。保存。
03Min

回 lesson 19 sandbox · 卡现在点不动 = 预期

因为没传 onToggle。第 22 课改 1 行就好。今天不修(一次只动一课)。
02新建 app/fantasy/page.tsx · 教练拿板
04Min

新建 app/fantasy/page.tsx · 骨架先跑起来

右键 app/ → New Folder → fantasy · 里面 New File → page.tsx · "use client" + useState<Player[]>([]) + <main><h1>梦幻阵容板</h1><p>你的阵容:{squad.length} / 11</p></main>。访问 /fantasy 看到 0/11。
05Min

写 addPlayer / removePlayer 两个工具函数

addPlayer:满 11 不收 · 重复不收 · 不然 setSquad([...squad, player])。removePlayer:setSquad(squad.filter(p => p.name !== name))。保存。
06Min

渲染左栏:22 张可选卡

<section><h2>可选球员({players.length})</h2>{players.map(p => <PlayerCard key={p.name} player={p} isSelected={squad.some(s => s.name === p.name)} onToggle={() => addPlayer(p)} />)}</section>。看到 22 张卡。
07Min

渲染右栏:阵容 + 空态文案

<section><h2>我的阵容({squad.length}/11)</h2>{squad.length === 0 ? <p>还没选 ...</p> : squad.map(p => <PlayerCard ... onToggle={() => removePlayer(p.name)} />)}</section>
03🌟 联动 · 凑 11 人
08Min

🌟 点哈兰德 —— 左右同步金边

左边那张哈兰德变金边 + ✅ 已选 · 右边出现同一张 · 标题 1/11。这是今天的「哇」—— 同一个 squad 算出两边的 isSelected。
09Min

测两条规则:不重复 · 不超 11

点同一个 2 次:右边不重复(addPlayer 里 .some 挡)。凑到 11/11 再点第 12 个:静悄悄不收(addPlayer 第一行 return)。第 22 课让它大声说话。
10Min

点右边卡 · 把人踢出阵容

右边的 onToggle 是 removePlayer · 点一下他从板上消失 · 左边那张同步去掉金边。
04看代码 · 截图
11Min

回 Cursor 看代码:全文 useState 只有 1 个

fantasy/page.tsx 顶部那个 useState<Player[]>([]) 是唯一 state · 22 张卡没有自己的 state。这就是「状态提升」最纯的形态。
12Min

📸 截图战利品

Mac Shift + Cmd + 4 框「11/11」+ 右边 11 张你最爱的。存 2026-XX-XX-我的第一个阵容.png · Phase 2 第六张战利品。
Half Time · 中场 · 讲给爸爸听

4 题 · 重点:为什么卡不再 useState

第 1 题答得出 = 状态提升真懂了。

01为什么 PlayerCard 今天不再有自己的 useState?Hint ↓

信息分散在 22 个小脑子里 · 教练拼不出全局 —— 11 人数据要在一个地方。

02squad 这个数组放在哪个组件里?为什么不放 PlayerCard 里?Hint ↓

父组件 fantasy/page.tsx —— 22 张卡都要看同一份名单。教练手里那张大战术板。

03为什么不能 squad.push(player) · 必须 setSquad([...squad, player])?Hint ↓

直接改 React 不知道。和 19 课 count = count + 1 一个道理 · 必须通过 setter 通知。比喻:不在原板上画 · 拿一张新板。

04凑到 11 个再点第 12 个为什么没反应?Hint ↓

addPlayer 第一行 if 拦了。教练第一条规则:满了不收。第 22 课会让规则大声说话(红字提示)。

Player Rating · 本课温度计

给「教练拿板」打个分

说真话。爸爸会看到。

今天难度Difficulty
0
今天开心Fun
0
Final Whistle · 终场哨

教练手里的板第一次画上 11 个名字 —— 你做了一个真的小 app。

还有 12 步没打勾。Step 8 看到左右同步金边那一下今天就过 —— 剩下改天补。