⚠️ Alpha内测版本警告:此为早期内部构建版本,尚不完整且可能存在错误,欢迎大家提Issue反馈问题或建议
Skip to content

7.1 一个接口不够用了

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


小明的电影详情页难题

小明的"个人豆瓣"从第六章延续过来。数据库设计好了——moviesdirectorstagsmovie_tagsratings 五张表,关系理清了,约束也加了。按照 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。"

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 和平均评分。"


同一份数据,两种返回方式。嵌套结构一次拿全,扁平结构需要二次请求。

嵌套结构一次请求,数据完整
{
  "id": 1,
  "title": "星际穿越",
  "director": {
    "id": 5,
    "name": "诺兰"
  }
} 
扁平结构轻量返回,按需获取
{
  "id": 1,
  "title": "星际穿越",
  "director_id": 5
} 
// 需要再请求 /api/directors/5
嵌套:减少请求次数,但响应体更大。适合数据量小、关联紧密的场景。
扁平:响应轻量,客户端按需拼装。适合列表页、移动端等带宽敏感场景。

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 页。前端据此渲染页码栏,并在最后一页禁用"下一页"按钮。

游标分页的响应略有不同

游标分页不返回 totaltotalPages(因为计算总数本身就是一次全表扫描,很慢),而是返回一个 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 条。这样既灵活又不会破坏已有的调用方式。

搜索和筛选是两回事

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

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

跟 AI 说:

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


小明的接口进化之路

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

阶段接口问题
V1GET /api/movies → 返回全部 500 条页面卡死
V2GET /api/movies?page=1&limit=20 → 分页返回找不到想看的电影
V3GET /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)排序字段是否限制了允许的值(防止用户传入任意列名)?"


让 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 时自动加载,帮你避免常见的性能陷阱。


下一步

接口能查能筛了,但上线后会遇到新问题——有人提交空数据、重复点击、服务器突然 500。去 当接口出了问题 看看怎么应对。