跳转到内容

4.9 组件化开发:像搭积木一样拼页面

一、本节你会做出什么

你会把练习项目拆成一套组件,并且拿到一条判断标准:什么时候该抽组件、什么时候不该。

📷 效果位:左边 components/ 目录树,右边瘦了一半的 page.tsx

4.6 讲了组件是什么(返回 JSX 的函数)。这一节讲怎么组织——因为新手在这件事上有两个相反的翻车方式:

翻车方式 症状
抽得太少 一个 page.tsx 写到 600 行,想改个东西翻半天找不到
抽得太碎 20 个组件文件,每个 5 行,改一处要跳 4 个文件

这一节就是帮你找到中间那条线。


二、开始前你需要

  • 已完成 4.6,做过一次「重复代码 → 组件」的改造
  • 已完成 4.8,练习项目里有三个页面
  • 知道 layout.tsx 和 page.tsx 的分工(4.5)

三、跟着做

第 1 步:什么时候该抽组件——三条判断标准

满足任意一条就该抽。

# 标准 为什么
1 同样的东西出现 2 次以上 4.6 第 1 步那三个痛点:改一处要改 N 处
2 这个文件超过 150 行,读起来找不到东西 你的注意力有限,AI 的也有限
3 这一块有独立的职责,能用一个名字概括 能起出 AttractionCard、SearchBar 这种名字,说明它是个独立的东西

反过来,这些情况不要抽:

不要抽的情况 原因
只用了一次,而且就三五行 抽出去反而要跳文件看
抽出来起不出好名字(只能叫 Block1、Wrapper) 起不出名字说明职责不清,抽了也是乱的
纯粹为了“让文件短一点” 短不是目的,好找才是目的

🔑 最实用的那条是标准 3: 先问自己“这块东西叫什么”。叫得出名字就抽,叫不出就别抽。

第 2 步:三类组件,分开想就不乱了

真实项目里的组件大致分三类。认清类别,你就知道该抽什么、放哪里。

类型 职责 例子 特点
① 布局组件 页面的框架 Header Footer Sidebar 每页都有,通常放在 layout 里
② 展示组件 把数据显示出来 AttractionCard PriceTag Avatar 接 props,自己不管数据从哪来
③ 交互组件 响应用户操作 Button SearchBar Modal 需要 "use client"(4.7 第 5 步)

💡 第 ② 类是重点。好的展示组件有一个特征:它不关心数据从哪来。

AttractionCard 只管“给我名字和价格,我负责显示得好看”。数据是写死的、从接口来的、从数据库来的,它都一样工作。

这叫“组件不关心数据来源” —— 第 5 章用写死的数据,第 9 章换成数据库,卡片组件一行都不用改。

第 3 步:组件放哪里、怎么命名

约定(不是强制,但照着做能省很多沟通):

项目根目录/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── attractions/
│ └── page.tsx
└── components/ ← 👈 组件统一放这里
├── Header.tsx
├── Footer.tsx
├── AttractionCard.tsx
└── ui/ ← 更基础的小零件(可选)
└── Button.tsx
约定 说明
目录叫 components/ 放项目根目录,和 app/ 平级(有 src/ 的话放 src/components/)
文件名用大驼峰 AttractionCard.tsx,和组件名一致——好找
一个文件一个组件 一眼知道去哪个文件改
组件名要具体 AttractionCard ✅ / Card 🤔 / Component1 ❌

🔑 把这几条写进你的规则文件(3.5 讲的 AGENTS.md):

- 组件放 components/ 目录,一个组件一个文件,文件名用大驼峰且与组件名一致
- 组件名要能说清它是什么,不要用 Card / Box / Wrapper 这类泛名

这样 AI 每次抽组件都会放对地方,你不用每次重复交代。

第 4 步:props 怎么设计——传什么,不传什么

这是组件化最需要判断力的地方。

原则:组件需要什么就传什么,不多传。

// 🤔 不太好:把整个对象扔进去
<AttractionCard attraction={item} />
// ✅ 更清晰:明确需要哪几个字段
<AttractionCard name={item.name} price={item.price} image={item.image} />
写法 好处 坏处
传整个对象 写起来省事,字段多时不用列一长串 看组件的调用处,不知道它用了哪些字段
逐个传字段 一眼看出这个组件需要什么数据 字段多了很啰嗦

💡 实用建议:字段少于 5 个就逐个传,多了就传对象。但不管哪种,都要在组件定义处把需要的字段列清楚(4.6 讲的解构写法 ({ name, price }) 就是在干这件事)。

children —— 包裹型组件

还有一种特殊的 props,叫 children。它让组件能“包住别的东西”:

// 定义一个卡片外壳
function Panel({ title, children }) {
return (
<section className="rounded-xl border p-6">
<h2 className="text-xl font-bold mb-3">{title}</h2>
{children} {/* ← 被包住的内容出现在这里 */}
</section>
);
}
// 使用:标签中间的东西就是 children
<Panel title="特色美食">
<p>火锅、串串、钟水饺。</p>
<p>建议避开饭点去排队。</p>
</Panel>

💡 4.7 讲的 layout.tsx 里那个 {children} 就是这个东西——layout 本质上就是一个包裹型组件,页面内容是它的 children。现在这两件事连起来了。

第 5 步:动手——把练习项目组件化

先存档(2.3 规矩一):

终端窗口
git add -A && git commit -m "组件化改造前的存档点"

4.8 里你把导航写在了 layout.tsx 里。现在把它抽出来:

跟 AI 说(按 3.5 的方式,先要方案):

【做什么】把 app/layout.tsx 里的顶部导航抽成 Header 组件,并新增一个 Footer 组件
【放在哪】components/Header.tsx 和 components/Footer.tsx
【什么样】
- Header:内容和现在完全一样(站点名 + 三个导航链接)
- Footer:显示「© 2026 我的练习站」,灰色小字,居中
- layout.tsx 里变成 <Header /> {children} <Footer />
【不要做什么】
- 页面视觉效果不要有任何变化(除了新增的 Footer)
- 不要新增依赖包
【怎么算对】每个页面顶部有导航、底部有页脚,pnpm build 通过
先告诉我你打算建哪些文件、layout.tsx 会变成什么样,我确认后再动手。

② 抽展示组件(AttractionCard)

把 app/attractions/page.tsx 里的列表项抽成 AttractionCard 组件:
1. 放在 components/AttractionCard.tsx;
2. 通过 props 接收 id、name、price;
3. 组件内部负责显示名字、价格(0 元显示"免费")、以及跳转到详情页的链接;
4. 列表页里用 map 渲染,记得加 key;
5. 顺手把卡片做得好看一点:白底、圆角、浅阴影、悬停时阴影加深;
6. 页面功能不变——点击仍然能进详情页。
先给方案。

③ 抽交互组件(一个要 "use client" 的)

新增一个 LikeButton 组件:
1. 放在 components/LikeButton.tsx;
2. 显示一个「想去 N」的按钮,点击 N 加一;
3. 用 useState 记住数字,**记得这个组件需要 "use client"**;
4. 把它放进 AttractionCard,每张卡片都有自己独立的计数。
注意:AttractionCard 本身如果不需要交互,**保持它是服务端组件**,
只让 LikeButton 这一小块是客户端组件。做完告诉我你是怎么处理的。

🔑 最后那个要求值得留意(4.7 提示词 ③ 也提过): 只把真正需要交互的那一小块做成客户端组件,页面其余部分保持服务端渲染。 这是 Next.js 的最佳实践——组件化在这里不只是“代码整齐”,而是有实际性能意义。

验收

终端窗口
git diff --stat # ③ 看改动范围
pnpm build # 4.7 新增的那道关

浏览器里点一遍:导航能跳、卡片能进详情、点赞按钮各自独立计数、控制台无红字。

📷 截图位 1:改造后的 components/ 目录 + 页面效果

终端窗口
git add -A && git commit -m "抽出 Header / Footer / AttractionCard / LikeButton 组件"

第 6 步:别从零造轮子——现成组件库

做按钮、弹窗、下拉菜单、日期选择器这些东西,有大量现成的。

类型 说明
组件库 一整套做好的组件(按钮、表单、弹窗、表格……),装上就能用
常见的 shadcn/ui、Material UI、Ant Design 等
本课立场 第 5 章用不着(内容型网站主要是布局和卡片);第 9 章做后台管理页时值得用

⚠️ 一条提醒:别一上来就让 AI 装组件库。 3.5 讲过「不要擅自新增依赖包」——组件库是比较重的依赖,而且会给项目带来一套你不熟悉的约定。

判断标准:你需要的是“卡片、布局”这类东西 → 自己写(Tailwind 几行就够);你需要的是“带键盘操作和无障碍支持的下拉菜单、可访问的弹窗” → 用现成的,这些自己写很容易出问题。

第 7 步:组件化的收益,自己验证一次

提一个需求,感受组件化到底值不值:

给所有景点卡片加一个"标签"显示(比如"必去""免费""文化"),
标签是小圆角色块,显示在景点名下方。
数据里加上 tag 字段。

观察 git diff --stat:

  • 组件化之前:要改列表页里三段重复代码
  • 组件化之后:改 AttractionCard.tsx 一个文件(加上数据里补字段)

🔑 这就是组件化的全部意义: 改一处,所有用到的地方都变。

4.6 让你体会过一次(加按钮),这里再验证一次——这两次体感会让你在第 5 章主动要求 AI 抽组件,而不是等它写出 600 行的 page.tsx。


四、完成的标志是

  • 我知道该抽组件的三条标准(重复 2 次以上 / 文件超 150 行 / 能起出好名字)
  • 我知道起不出名字就不该抽
  • 我说得出三类组件:布局 / 展示 / 交互
  • 我知道好的展示组件不关心数据从哪来
  • 我知道组件放 components/、文件名用大驼峰、一个文件一个组件
  • 我把这几条命名约定写进了规则文件
  • 我知道 props 该传具体字段还是整个对象的权衡
  • 我知道 children 是“被包住的内容”,并且明白 layout.tsx 就是个包裹型组件
  • 我抽出了 Header / Footer(布局类)
  • 我抽出了 AttractionCard(展示类),页面功能不变
  • 我抽出了 LikeButton(交互类),只有它加了 "use client"
  • pnpm build 通过
  • 我用“加标签”的需求验证了一次组件化的收益(只改一个文件)
  • 我知道什么时候该用现成组件库、什么时候自己写

五、卡住了看这里

Q:抽完组件,页面报错 Element type is invalid。 八成是导入/导出没对上。组件文件用 export default function Header(),引用处就该 import Header from "@/components/Header";如果用的是 export function Header()(没有 default),引用时要写 import { Header } from ...。把报错给 AI,一句话能修。

Q:报错说找不到模块 @/components/Header。 @/ 是指向项目根(或 src/)的别名。检查:① 文件路径和大小写是否完全一致(Mac 不区分大小写、部署的 Linux 区分——这是“本地好的上线就坏”的一个经典原因);② 文件扩展名是 .tsx 而不是 .ts。

Q:抽完组件,样式全没了。 Tailwind 的 class 可能在复制过程中漏了,或者外层包裹结构变了导致布局失效。用 git diff 对比,看那个元素的 class 是不是变短了。拿不准就 git restore . 重来一次,并在需求里强调「视觉效果必须完全不变」。

Q:LikeButton 加了 "use client",但报错说父组件也要加。 通常是你在服务端组件里把函数当 props 传给了客户端组件。两个解法:① 让那个交互组件自己管自己的状态,不要从父组件传函数进去(第 5 步的做法);② 父组件也加 "use client"(但会牺牲服务端渲染)。优先选 ①。

Q:我不知道该把某一块抽成组件还是留在页面里。 用标准 3:试着给它起个名字。 起得出就抽,起不出就留着。还拿不准就用本节提示词 ①,让 AI 给建议——但最终决定权在你,因为你知道以后要往哪个方向改。

Q:AI 一次抽了 15 个组件,我看不过来。 这是“抽得太碎”。回滚(git restore .),重新提需求时加上边界:「只抽出重复出现 2 次以上的部分,单次使用且少于 20 行的不要抽」。3.5 讲的「说清不要做什么」在这里特别有效。


六、本节提示词

① 组件化方案评估(动手前先问):

这是我的页面文件:
【@文件名】
请帮我评估组件化方案:
1. 哪些部分**应该**抽成组件?按「重复出现 / 文件过长 / 职责独立」说明理由;
2. 哪些部分**不该**抽?为什么;
3. 每个建议抽出的组件,给出:组件名、文件路径、需要哪些 props、属于布局/展示/交互哪一类;
4. 哪些组件需要 `"use client"`,哪些不需要;
5. 建议的改造顺序。
**先不要动代码**,我确认方案后再说。

② 执行抽组件(确认方案后用):

按刚才确认的方案抽组件。要求:
1. 组件放 components/ 目录,一个组件一个文件,文件名大驼峰且与组件名一致;
2. **页面视觉效果和功能必须完全不变**;
3. props 用解构写法,让人一眼看出这个组件需要什么数据;
4. 只有真正需要交互的组件加 `"use client"`,其余保持服务端组件;
5. map 渲染的列表记得加 key;
6. 不要新增任何依赖包。
改完告诉我:新建了哪些文件、原文件行数的变化、以及我该怎么验证效果没变。

③ 组件代码体检(项目做大之后定期跑一次):

请检查我项目里 components/ 目录下的组件:
1. 有没有命名不清的组件(叫 Card / Box / Wrapper 这类泛名)?建议改成什么;
2. 有没有组件接收了用不到的 props?
3. 有没有组件过大、应该再拆分的?
4. 有没有重复的组件(两个组件干了几乎一样的事)?
5. 有没有不必要地加了 `"use client"` 的组件(其实不需要交互)?
逐条给结果,指出具体文件。**先不要改**,我确认后再动手。

上一节 ← 4.8 Next.js 核心:路由与页面跳转 下一节 → 4.10 浏览器开发者工具:定位问题的第一现场