---
title: "7.1 一个接口不够用了"
description: "关联查询、分页、过滤排序——当数据量和需求增长时，一个简单的 CRUD 接口该怎么进化"
chapter: "第七章"
---

# 7.1 一个接口不够用了

> **本节目标**：理解当应用从"能跑"走向"能用"时，接口设计会遇到哪些真实问题，以及该怎么跟 AI 描述这些需求。

---

## 小明的电影详情页难题

小明的"个人豆瓣"从第六章延续过来。数据库设计好了——`movies`、`directors`、`tags`、`movie_tags`、`ratings` 五张表，关系理清了，约束也加了。按照 7.0 学到的 CRUD 模式，他也给电影应用跑通了增删改查——添加电影、查看列表、编辑信息、删除记录，一切正常。

小明很开心，觉得后端这块差不多了，开始做真正的页面。

第一个页面是电影详情页。他打开豆瓣看了看，一个电影详情页上要显示：电影名、年份、海报、导演姓名、演员列表、用户评分、标签、简介。他的数据库里，这些信息分散在四五张表里。

最直觉的做法是什么？前端发五个请求：

1. `GET /api/movies/1` → 拿电影基本信息
2. `GET /api/directors/5` → 拿导演信息
3. `GET /api/movies/1/tags` → 拿标签列表
4. `GET /api/movies/1/ratings` → 拿评分
5. `GET /api/movies/1/actors` → 拿演员列表

能跑。但小明打开页面一看，体验很糟糕——页面先出了电影标题，过了半秒导演名字才冒出来，又过了一会儿标签才显示，评分最后才加载完。用户看到的是一个"跳来跳去"的页面，像是零件一个个往上拼。

更要命的是，五个请求意味着五次网络往返。每次往返至少几十毫秒，加上服务器处理时间，用户要等好几百毫秒才能看到完整页面。如果用户网络不好（比如在地铁里），某个请求超时了，页面就会缺一块——有标题没导演，有评分没标签。

老师傅看了一眼说："先搞清楚一件事——你这个详情页是怎么渲染的？"

### 先问自己：这个页面需要 API 吗？

这是一个很多新手会忽略的问题。在 Next.js 里，页面组件（`page.tsx`）本身就运行在服务器上，可以**直接查数据库**，根本不需要绕一圈走 API。

小明的电影详情页如果是一个 Server Component（默认就是），它可以直接在组件里调用 Drizzle 查询，把五张表的数据一次查好，渲染成 HTML 发给浏览器。整个过程没有任何"前端发请求→后端返回"的网络往返——数据在服务器上就拿到了。

那 API Route 是给谁用的？两种场景：

- **前端需要动态交互时**——比如用户点了"收藏"按钮，前端需要告诉后端"我要收藏这部电影"。这种用户触发的写操作，需要通过某种方式调用后端逻辑
- **外部消费者**——比如老王想用小明的数据做小程序，他需要一个可以调用的 HTTP 接口

小明的详情页属于"打开页面就展示数据"，不需要用户交互触发。所以老师傅说："详情页直接在 Server Component 里查数据库就行，不用写 API Route。"

::: tip Server Action：不用写接口的写操作
你可能会发现，AI 生成的"收藏""点赞""提交表单"这类功能，代码里并没有 `app/api/xxx/route.ts` 文件。取而代之的是一个带 `'use server'` 标记的函数——这就是 **Server Action**。

Server Action 让前端可以直接调用一个运行在服务器上的函数，不需要手动定义 API 路由、不需要写 `fetch` 请求。对于"用户点按钮→后端处理→返回结果"这类简单的写操作，它比 API Route 更简洁。

你不需要指定用哪种方式。加载了 `next-best-practices` Skill 的 Claude Code 会自动判断——页面展示用 Server Component 直接查库，简单的写操作用 Server Action，需要给外部调用的接口用 Route Handler。看到代码里没有 `route.ts` 不用觉得奇怪，AI 可能选了更合适的方式。
:::

不过，小明的电影列表页有筛选、排序、翻页——这些是用户交互触发的，前端需要根据用户操作动态请求不同的数据。这种场景就需要 API 了。

所以接下来讨论的分页、过滤、排序，都是针对**需要 API 的场景**——前端根据用户操作动态请求数据。

但即使是 API 场景，"一个页面发五个请求"的问题依然存在。比如小明后来给电影详情页加了"收藏"和"评论"功能，详情页变成了客户端交互页面，需要通过 API 获取数据。这时候，数据结构该怎么组织？

---

## 嵌套 vs 扁平：两种数据组织方式

假设前端需要电影信息和导演信息。后端可以用两种方式返回：

### 嵌套结构

把关联数据"嵌"在主对象里，一次返回所有信息：

<details>
<summary>嵌套响应长什么样？展开看看</summary>

```json
{
  "id": 1,
  "title": "千与千寻",
  "year": 2001,
  "director": {
    "id": 5,
    "name": "宫崎骏",
    "nationality": "日本"
  },
  "tags": ["动画", "奇幻", "冒险"],
  "rating": {
    "average": 9.4,
    "count": 2156
  }
}
```

前端直接用 `movie.director.name` 就能拿到导演名字，用 `movie.rating.average` 就能拿到评分，不用再发第二个、第三个请求。所有数据一次到位。

</details>

### 扁平结构

只返回关联数据的 ID，前端需要的话自己再查：

<details>
<summary>扁平响应长什么样？展开看看</summary>

```json
{
  "id": 1,
  "title": "千与千寻",
  "year": 2001,
  "directorId": 5,
  "tagIds": [1, 3, 7],
  "averageRating": 9.4
}
```

前端拿到 `directorId: 5` 后，如果要显示导演名字，还得再调 `GET /api/directors/5`。如果要显示标签名字，还得拿着 `tagIds` 去查标签表。

</details>

### 怎么选？

这不是非此即彼的选择，而是看场景：

| 场景 | 推荐结构 | 理由 |
|------|---------|------|
| 电影详情页 | 嵌套 | 页面需要展示完整信息，一次查完省得前端多跑几趟 |
| 电影列表页 | 扁平（或轻度嵌套） | 列表只需要标题、年份、海报，带上完整导演信息和所有标签是浪费带宽 |
| 管理后台的表格 | 扁平 | 表格每行只显示关键字段，点击某行再加载详情 |
| 搜索结果 | 扁平 + 少量嵌套 | 搜索结果需要显示标题和评分，但不需要完整的导演履历 |

一个实用的判断标准：**前端拿到数据后，还需不需要再发请求才能渲染页面？** 如果需要，说明你的接口返回的数据不够，应该嵌套更多关联数据。如果前端拿到数据就能直接渲染，说明刚刚好。

小明把电影详情接口改成了嵌套结构，一个请求返回所有信息。页面从"零件拼装"变成了"一次成型"，加载速度快了，体验也好了。

跟 AI 说的时候，直接描述你的需求就行：

> "电影详情接口需要同时返回导演信息、标签列表和平均评分，用嵌套结构，一个请求返回所有数据。电影列表接口只返回标题、年份、海报 URL 和平均评分。"

---

<ApiEvolution />

## 500 条记录一次全返回，页面卡死了

详情页的问题解决了，小明开始做首页的电影列表。

一开始只有十几部电影，`GET /api/movies` 返回一个数组，前端循环渲染，毫秒级完成，体验丝滑。

然后小明花了一个周末，把自己看过的所有电影都录了进去——500 多部。再打开首页，浏览器转了好几秒才显示出来。他打开开发者工具一看：接口返回了一个巨大的 JSON，500 多条记录，光数据就有好几百 KB。前端要把 500 个电影卡片全部渲染出来，DOM 节点几千个，浏览器直接卡住了。

这就像你去图书馆借书，跟管理员说"把你们所有的书都搬出来给我看看"。管理员搬了一下午，你面前堆了几万本书，你根本看不过来。正常人的做法是："先给我看科幻类的，一次看 20 本，看完了再拿下一批。"

这就是**分页**。

### 偏移分页：翻书模式

最常见的分页方式是**偏移分页（offset/limit）**——告诉后端"跳过前面 N 条，给我接下来的 M 条"。

请求：`GET /api/movies?page=3&limit=20`

意思是：第 3 页，每页 20 条。后端会跳过前 40 条（第 1、2 页），返回第 41-60 条。

这种方式的好处是**简单直观**。前端可以直接显示页码导航："第 1 页、第 2 页、第 3 页……"，用户可以点击任意页码跳转。你在淘宝、京东搜索商品时看到的底部页码栏，用的就是这种分页。

坏处是**翻到后面会变慢**。数据库执行 `OFFSET 10000 LIMIT 20` 时，要先扫描前面 10000 条记录（虽然不返回），然后才取出你要的 20 条。就像在图书馆里找第 500 本书——管理员要从第 1 本开始数，数到第 500 本，然后把后面 20 本递给你。每次都从头数，页数越深越慢。

这个问题在第六章 6.3 节讲 OFFSET 分页陷阱时已经提过。对于小明的 500 部电影来说，偏移分页完全够用——性能问题要到几万条数据以上才会明显。

### 游标分页：书签模式

另一种方式是**游标分页（cursor-based pagination）**——不用页码，而是用"书签"。

请求：`GET /api/movies?cursor=eyJpZCI6NDB9&limit=20`

意思是：从上次返回的最后一条记录之后，再给我 20 条。`cursor` 是一个编码后的标记，指向上一页最后一条记录的位置。

这就像在图书馆里放了一个书签。下次来的时候，直接从书签的位置开始往后取，不需要从头数。无论你翻到第几页，速度都一样快——因为数据库直接从书签位置开始读，不需要扫描前面的记录。

坏处是**不能跳页**。你只能"下一页、下一页"地往后翻，不能直接跳到第 50 页。这就是为什么社交媒体的 feed（微博、朋友圈、抖音）都用无限滚动而不是页码——它们用的就是游标分页。

### 怎么选？

| 场景 | 推荐方式 | 理由 |
|------|---------|------|
| 后台管理表格 | 偏移分页 | 需要页码导航，数据量通常可控 |
| 商品搜索结果 | 偏移分页 | 用户习惯翻页，需要跳到指定页 |
| 社交 feed / 评论列表 | 游标分页 | 无限滚动，数据量大，不需要跳页 |
| 数据量 < 1 万条 | 偏移分页 | 简单够用，性能不是问题 |
| 数据量 > 10 万条 | 游标分页 | 偏移分页会越翻越慢 |

小明的电影库只有几百部，偏移分页绰绰有余。

跟 AI 说：

> "电影列表接口加上分页，用 offset/limit 方式，默认每页 20 条。响应里带上总数和总页数，方便前端显示页码导航。"

### 分页响应该带什么

光返回数据不够，前端还需要知道"一共多少条""当前第几页""还有没有下一页"——否则前端不知道该显示几个页码按钮，也不知道"下一页"按钮该不该灰掉。

一个好的分页响应长这样：

<details>
<summary>分页响应示例</summary>

```json
{
  "success": true,
  "data": [
    { "id": 1, "title": "千与千寻", "year": 2001, "rating": 9.4 },
    { "id": 2, "title": "龙猫", "year": 1988, "rating": 9.2 },
    { "id": 3, "title": "天空之城", "year": 1986, "rating": 9.1 }
  ],
  "meta": {
    "total": 500,
    "page": 1,
    "limit": 20,
    "totalPages": 25
  }
}
```

`meta` 里的信息让前端知道：总共 500 条数据，当前是第 1 页，每页 20 条，一共 25 页。前端据此渲染页码栏，并在最后一页禁用"下一页"按钮。

</details>

::: tip 游标分页的响应略有不同
游标分页不返回 `total` 和 `totalPages`（因为计算总数本身就是一次全表扫描，很慢），而是返回一个 `nextCursor`。前端拿着这个 cursor 请求下一页，如果 `nextCursor` 为空，说明没有更多数据了。
:::

---

## 想按标签筛选、按评分排序

分页搞定了，小明又有了新需求。

他的朋友来试用，说："你这个电影列表能不能只看动画片？我不想在 500 部电影里一页一页翻着找。"另一个朋友说："能不能按评分从高到低排？我想看你评分最高的电影。"

小明纠结了：是给每种筛选条件都建一个新接口？`GET /api/movies/animation` 返回动画片，`GET /api/movies/top-rated` 返回高分电影？那如果又要按标签筛选又要按评分排序呢？再建一个 `GET /api/movies/animation/top-rated`？排列组合下来，接口数量会爆炸。

老师傅说："不用建新接口。一个列表接口，用查询参数组合就行。"

### 查询参数的组合艺术

`GET /api/movies?tag=动画&sort=rating&order=desc&page=1&limit=20`

这一个请求就表达了："给我标签是'动画'的电影，按评分从高到低排，第 1 页，每页 20 条。"

查询参数的好处是**可以自由组合**，就像乐高积木：

- 只想排序不想筛选？`GET /api/movies?sort=rating&order=desc`
- 只想筛选不想排序？`GET /api/movies?tag=动画`
- 想同时按多个标签筛选？`GET /api/movies?tag=动画&tag=日本`
- 什么都不传？`GET /api/movies` 返回默认排序的全部数据（带分页）

接口只有一个，前端根据用户的操作拼不同的参数就行。用户在筛选栏选了"动画"，前端加上 `tag=动画`；用户点了"按评分排序"，前端加上 `sort=rating&order=desc`。接口代码不用改，所有组合都自动支持。

### 常见的查询参数设计

| 参数 | 用途 | 示例 |
|------|------|------|
| `page` / `limit` | 分页 | `?page=2&limit=20` |
| `sort` / `order` | 排序 | `?sort=rating&order=desc` |
| `tag` / `genre` | 按分类筛选 | `?tag=科幻` |
| `year` | 按年份筛选 | `?year=2024` 或 `?yearFrom=2020&yearTo=2024` |
| `q` / `search` | 关键词搜索 | `?q=千与千寻` |
| `minRating` | 最低评分 | `?minRating=8` |

这些参数都应该是**可选的**。不传就用默认值——默认不筛选、默认按创建时间倒序、默认第 1 页每页 20 条。这样既灵活又不会破坏已有的调用方式。

::: warning 搜索和筛选是两回事
**筛选（Filter）** 是精确匹配——"标签等于动画"、"年份等于 2024"。**搜索（Search）** 是模糊匹配——"标题里包含'千与千寻'"。两者可以组合使用，但实现方式不同。筛选用数据库的 WHERE 条件就行，搜索可能需要全文索引。

对于小明的电影库，简单的 `LIKE '%关键词%'` 搜索就够了。如果数据量大到几十万条，可能需要 PostgreSQL 的全文搜索功能——但那是后面的事，先跑起来再优化。
:::

跟 AI 说：

> "电影列表接口支持以下可选查询参数：tag（按标签过滤）、year（按年份过滤）、q（按标题搜索）、sort（排序字段，支持 rating/year/createdAt）、order（asc 或 desc）。所有参数都是可选的，不传就返回默认排序的全部数据。"

---

## 小明的接口进化之路

回顾一下小明的电影列表接口是怎么一步步进化的：

| 阶段 | 接口 | 问题 |
|------|------|------|
| V1 | `GET /api/movies` → 返回全部 500 条 | 页面卡死 |
| V2 | `GET /api/movies?page=1&limit=20` → 分页返回 | 找不到想看的电影 |
| V3 | `GET /api/movies?tag=动画&sort=rating&order=desc&page=1&limit=20` | 够用了！ |

每一次进化都是被真实需求推动的——不是小明提前设计好的，而是用着用着发现不够用，然后加功能。这也是 API 设计的常态：先做最简单的版本，遇到问题再迭代。不需要一开始就设计一个"完美"的接口。

---

## 跟 AI 沟通的 Prompt 模板

把上面学到的概念组合起来，你可以用一段话描述一个完整的接口需求：

**从零设计接口时：**

> "帮我设计电影列表和详情两个接口。列表接口支持分页（offset/limit，默认每页 20 条）、按标签过滤、按评分或年份排序，响应里带上总数和总页数。详情接口返回电影信息，嵌套导演信息、标签列表和平均评分。统一用 `{ success, data, error }` 格式返回。"

**给已有接口加功能时：**

> "现有的 `GET /api/movies` 只返回全部数据，帮我加上分页和过滤功能。分页用 query 参数 page 和 limit，过滤支持按 tag 和 year 筛选，排序支持 sort 和 order 参数。所有新参数都是可选的——不传这些参数时行为跟之前一样，保持向后兼容。"

**让 AI 自查时：**

> "检查一下现有的电影列表接口，有没有以下问题：1）是否支持分页？2）查询参数是否都做了类型校验（比如 page 必须是正整数）？3）排序字段是否限制了允许的值（防止用户传入任意列名）？"

---

::: tip 让 AI 自动优化性能
当你的 API 代码越来越复杂时，可能会不知不觉引入性能问题——比如数据瀑布流（一个请求等另一个请求完成）、不必要的重渲染、Bundle 过大等。

推荐加载这两个 Skills，让 AI 在写代码时自动遵循最佳实践：

- **`vercel-react-best-practices`** - React/Next.js 性能优化（消除瀑布流、Bundle 优化、重渲染优化）
- **`next-best-practices`** - Next.js 文件约定、RSC 边界、异步 API、路由处理、元数据

这两个 Skills 通常已经内置在 Claude Code 中。它们会在你使用 React 或 Next.js 时自动加载，帮你避免常见的性能陷阱。
:::

---

::: info 下一步
接口能查能筛了，但上线后会遇到新问题——有人提交空数据、重复点击、服务器突然 500。去 [当接口出了问题](./02-when-things-go-wrong.md) 看看怎么应对。
:::
