Skip to content

Nuxt Server Routes与BFF接口边界重构

这次整理的对象主要来自下面这些本地素材:

  • 11Vue学习/组件化思维/Nuxt数据获取与渲染模式重构.md
  • 11Vue学习/组件化思维/Nuxt中间件与插件注入边界重构.md
  • 11Vue学习/组件化思维/Nuxt生命周期与副作用时机重构.md
  • 11Vue学习/组件化思维/认证请求与路由权限链路重构.md
  • 11Vue学习/Nuxt4/核心概念/2Lifecycle.md

和前面的案例一样,这一轮不直接修改任何独立项目,而是把其中最值得复用的“Nuxt Server Routes 与 BFF 接口边界”思路整理成一篇案例文档。

这类场景最容易被低估的地方,不是 server/api 会不会写,而是“页面、服务端接口、外部后端和运行时上下文到底谁应该承担哪一层转换”。如果这个问题没有先想清楚,项目后面通常会逐渐出现这些症状:

  • 页面虽然改成了 useFetch('/api/**'),但接口层只是把后端原样透传回来
  • 页面开始理解 token、header、provider 字段和多后端聚合细节
  • server/api 里既做鉴权、又做聚合、又做页面定制、还顺手塞很多格式转换
  • 同一套后端返回结构被多个页面直接消费,结果页面和后端一起耦合住
  • 页面数据获取虽然进了 Nuxt 链路,但接口层仍然没有形成稳定的 BFF 语义

这类场景真正的复杂度中心

Nuxt Server Routes 场景里,真正的复杂度中心不是“接口放在服务端了没有”,而是“服务端接口到底是在表达页面语义,还是继续暴露底层后端细节”。

从当前素材和已有案例里,可以明确看到至少四条关键边界:

  • 页面消费层和后端原始返回结构不是一回事
  • BFF 聚合层和底层服务调用层不是一回事
  • 请求级上下文准备和接口级业务组装不是一回事
  • 页面数据获取时机和服务端数据拼装职责必须同时设计

如果这些边界不先拆开,项目很快就会出现一些典型问题:

  • 页面为了渲染一个列表,要理解多个下游接口的结构差异
  • 鉴权、租户、分页、容错和映射逻辑散在页面和接口里重复出现
  • 一个 provider 升级字段,多个页面跟着一起改
  • server/api 看起来很多,但其实没有形成稳定的页面级契约

所以这类重构的重点不是把请求“挪到服务端”,而是先回答:

  • 页面真正需要的稳定数据契约是什么
  • 哪些拼装逻辑应该收口在 BFF 层
  • 哪些逻辑属于请求上下文,哪些属于接口级业务组装
  • 下游后端、第三方服务和页面展示层应该怎样解耦

推荐的重构边界

更适合长期维护的 Nuxt 项目,通常会把这类链路拆成下面几层:

  • 页面消费层:只关心页面需要的数据结构、加载状态和交互动作
  • BFF 接口层:位于 server/api/**,负责表达页面级或前端级契约
  • 服务组装层:位于 server/services/**server/utils/**,负责调用下游系统并聚合结果
  • 请求上下文层:位于 server/middleware/**,负责准备 token、租户、用户上下文和请求日志
  • 下游适配层:负责对接外部 API、内部服务或数据库,不把原始结构直接泄露给页面

如果换成更具体的理解,大概可以这样映射:

  • pages/orders/[id].vue:只消费订单详情页面需要的数据
  • server/api/orders/[id].ts:输出订单详情页的稳定契约
  • server/services/orders.ts:负责聚合订单、支付、物流等多个下游结果
  • server/middleware/auth.ts:负责准备当前请求的身份上下文
  • server/utils/providers/order-client.ts:负责调用具体后端或第三方服务

这里最重要的原则是:

  • 页面不要直接理解下游接口形状
  • BFF 层不要退化成原始透传层
  • 请求上下文层不要混进页面定制逻辑
  • 下游适配层不要反向定义页面契约

不要让页面直接消费后端原始结构

很多 Nuxt 项目第一次把数据获取迁进 useAsyncDatauseFetch 时,最常见的写法是“页面直接请求后端或请求一个几乎原样透传的 /api 接口”。

下面这种写法最容易在项目初期出现:

ts
const route = useRoute()

const { data } = await useAsyncData(
  () => `order:${route.params.id}`,
  () => $fetch(`https://backend.example.com/orders/${route.params.id}`, {
    headers: {
      Authorization: `Bearer ${token.value}`,
      'x-tenant-id': tenantId.value,
    },
  }),
)

问题在于:

  • 页面开始理解 token 和租户头部
  • 页面开始绑定具体后端地址和字段结构
  • 一旦后端聚合方式变化,页面就会跟着一起改

更稳的做法,是让页面只消费本地 BFF 接口:

ts
const route = useRoute()

const { data: order } = await useAsyncData(
  () => `order:${route.params.id}`,
  () => $fetch(`/api/orders/${route.params.id}`),
)
ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  return await getOrderDetailForPage(event, id)
})

这样页面得到的就是“当前页面需要的稳定契约”,而不是继续背着后端细节前进。

BFF 适合表达页面语义,不适合沦为透传层

Nuxt Server Routes 最容易被做浅的一点,就是接口虽然进了 server/api,但它只是把下游结果直接返回给页面。

ts
export default defineEventHandler(async () => {
  return await orderProvider.listRawOrders()
})

这种写法短期看似简单,但长期通常会带来三个问题:

  • 页面和 provider 字段结构直接耦合
  • 同一个 raw response 被多个页面各自转换
  • BFF 层失去了作为前端契约层的价值

更稳的做法,是让接口围绕页面目标组织响应:

ts
export default defineEventHandler(async (event) => {
  const result = await listOrdersForDashboard(event)

  return {
    items: result.items.map((item) => ({
      id: item.id,
      title: item.displayTitle,
      amountText: item.amountText,
      status: item.status,
      actions: item.actions,
    })),
    summary: result.summary,
  }
})

这里最关键的变化不是“返回字段改了”,而是:

  • 接口开始围绕页面目标表达契约
  • 字段映射和适配逻辑不再散在页面里
  • 下游 provider 变化时,页面不一定要跟着变化

请求上下文准备和业务组装要分成两层

Nuxt 项目里还有一个常见混乱点:同一个 server/api 文件里既读 cookie、又校验身份、又查租户、又调多个后端、又做页面映射。

这样写久了以后,接口文件会越来越大,复用边界也会越来越差。

更稳的方式,是把“请求上下文准备”和“接口级业务组装”分开:

  • server/middleware/** 负责准备当前请求上下文
  • server/api/** 负责声明接口契约和调用组装层
  • server/services/** 负责聚合多个下游结果
ts
export function requireRequestUser(event: H3Event) {
  const user = event.context.user
  if (!user) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
  }
  return user
}
ts
export async function getOrderDetailForPage(event: H3Event, id: string) {
  const user = requireRequestUser(event)
  const [order, shipment] = await Promise.all([
    orderProvider.getOrder(user.id, id),
    shipmentProvider.getShipment(id),
  ])

  return mapOrderDetailToPage(order, shipment)
}

这样拆完以后,接口文件就能重新回到“声明契约与编排调用”的职责上,而不是继续承载全部复杂度。

页面数据获取时机和服务端接口语义要站在同一层

BFF 边界还有一个常见误区:页面用的是 useAsyncData,服务端接口却还是底层资源接口思维。结果就是:

  • 页面拿的是 SSR 友好的数据链路
  • 接口返回的却不是页面友好的结构
  • 数据获取时机和接口语义没有真正对齐

更稳的设计应该是:

  • 页面用 useAsyncDatauseFetch 获取“页面契约”
  • server/api 输出和页面渲染直接对齐的数据结构
  • 真正的 provider 差异、聚合逻辑和容错策略留在服务层内部
ts
const route = useRoute()

const { data: articlePage } = await useAsyncData(
  () => `article-page:${route.params.slug}`,
  () => $fetch(`/api/article-pages/${route.params.slug}`),
)

这里背后的关键点是:

  • 页面数据获取关注的是页面渲染目标
  • 服务端接口关注的是页面契约输出
  • 下游资源差异不再泄露到页面消费层

这样 M5-2 里强调的数据获取链路,才能和这次的接口边界真正协同起来。

一套更稳的 Nuxt Server Routes 重构顺序

如果以后再遇到“页面请求已经走本地接口,但接口层还是越来越乱”的情况,可以先按下面顺序检查:

  1. 先列出页面当前真正需要的稳定契约,而不是直接看后端返回结构
  2. 再区分请求上下文准备、BFF 契约输出和下游服务适配三层责任
  3. 把 token、租户、会话、日志这些请求级逻辑从接口文件里抽出去
  4. 把 provider 原始结构和页面映射逻辑收口到服务组装层
  5. 让页面只请求本地 server/api 暴露的稳定契约
  6. 最后再检查接口是否仍然在透传底层细节,还是已经表达页面语义

如果这 6 步做完,Nuxt Server Routes 问题通常会从“只是把请求搬到服务端”变成“前端真正拥有了稳定的接口边界”,整个页面层和下游后端之间也会轻很多。

这篇案例最后沉淀出的核心方法

这轮最重要的不是再多写几条 /api 路由,而是沉淀出一条可复用的 Nuxt 接口边界重构思路:

  • 先区分页面消费层、BFF 接口层、服务组装层和下游适配层
  • 再让 server/api 只表达页面级契约,而不是继续透传原始结构
  • 再把请求上下文准备和业务组装拆开
  • 最后让页面的数据获取时机和接口输出语义站在同一层

这样以后面对多后端聚合、权限上下文、页面定制接口、provider 字段变动和服务端适配链路这些场景时,就能先按边界拆职责,而不是继续把页面、接口和后端绑成一团。

共 20 个模块,1301 篇 Markdown 文档。