--- 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 扁平:两种数据组织方式 假设前端需要电影信息和导演信息。后端可以用两种方式返回: ### 嵌套结构 把关联数据"嵌"在主对象里,一次返回所有信息:
嵌套响应长什么样?展开看看 ```json { "id": 1, "title": "千与千寻", "year": 2001, "director": { "id": 5, "name": "宫崎骏", "nationality": "日本" }, "tags": ["动画", "奇幻", "冒险"], "rating": { "average": 9.4, "count": 2156 } } ``` 前端直接用 `movie.director.name` 就能拿到导演名字,用 `movie.rating.average` 就能拿到评分,不用再发第二个、第三个请求。所有数据一次到位。
### 扁平结构 只返回关联数据的 ID,前端需要的话自己再查:
扁平响应长什么样?展开看看 ```json { "id": 1, "title": "千与千寻", "year": 2001, "directorId": 5, "tagIds": [1, 3, 7], "averageRating": 9.4 } ``` 前端拿到 `directorId: 5` 后,如果要显示导演名字,还得再调 `GET /api/directors/5`。如果要显示标签名字,还得拿着 `tagIds` 去查标签表。
### 怎么选? 这不是非此即彼的选择,而是看场景: | 场景 | 推荐结构 | 理由 | |------|---------|------| | 电影详情页 | 嵌套 | 页面需要展示完整信息,一次查完省得前端多跑几趟 | | 电影列表页 | 扁平(或轻度嵌套) | 列表只需要标题、年份、海报,带上完整导演信息和所有标签是浪费带宽 | | 管理后台的表格 | 扁平 | 表格每行只显示关键字段,点击某行再加载详情 | | 搜索结果 | 扁平 + 少量嵌套 | 搜索结果需要显示标题和评分,但不需要完整的导演履历 | 一个实用的判断标准:**前端拿到数据后,还需不需要再发请求才能渲染页面?** 如果需要,说明你的接口返回的数据不够,应该嵌套更多关联数据。如果前端拿到数据就能直接渲染,说明刚刚好。 小明把电影详情接口改成了嵌套结构,一个请求返回所有信息。页面从"零件拼装"变成了"一次成型",加载速度快了,体验也好了。 跟 AI 说的时候,直接描述你的需求就行: > "电影详情接口需要同时返回导演信息、标签列表和平均评分,用嵌套结构,一个请求返回所有数据。电影列表接口只返回标题、年份、海报 URL 和平均评分。" --- ## 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 条。响应里带上总数和总页数,方便前端显示页码导航。" ### 分页响应该带什么 光返回数据不够,前端还需要知道"一共多少条""当前第几页""还有没有下一页"——否则前端不知道该显示几个页码按钮,也不知道"下一页"按钮该不该灰掉。 一个好的分页响应长这样:
分页响应示例 ```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 页。前端据此渲染页码栏,并在最后一页禁用"下一页"按钮。
::: 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) 看看怎么应对。 :::