Vue 基础体系 · 第 66/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Nuxt Server Routes:Nitro、API、运行时配置、缓存和部署
Nuxt 的服务端能力不是“在 Vue 页面旁边放几个接口文件”这么简单。一个请求进入 Nuxt 后,可能经过 Nitro 的路由匹配、服务端中间件、API Handler、运行时配置读取、缓存层和部署适配器,最后才生成 HTTP 响应。
理解这条链路,至少需要区分四个概念:
- Server Routes:Nuxt 项目中由文件系统声明的服务端路由。
- Nitro:Nuxt 使用的服务端运行时和构建系统,负责路由、打包、适配部署平台等。
- API Routes:通常位于
server/api下、用于提供数据接口的 Server Routes。 - 运行时配置与缓存:服务端代码如何读取部署时配置,以及响应在哪一层被复用。
本文示例基于 Nuxt 3、Vue 3、Composition API、TypeScript 和现代 Vite 工具链。Nuxt 和 Nitro 的部分配置会随版本变化,使用时应以当前项目生成的类型和官方文档为准。
一、请求进入 Nuxt 后发生了什么
Nuxt 的服务端请求可以抽象为以下流程:
sequenceDiagram
participant C as Client
participant N as Nitro Server
participant M as Server Middleware
participant R as Route Matcher
participant H as Event Handler
participant D as Data Source
C->>N: HTTP request
N->>M: 执行 server/middleware
M-->>N: 放行或直接返回错误
N->>R: 根据 URL 和 HTTP Method 匹配
R->>H: 调用 Server/API Handler
H->>D: 查询数据库或外部服务
D-->>H: 返回数据
H-->>N: Response
N-->>C: HTTP response
这里的关键对象是 事件对象 event。它不是浏览器事件,而是 H3/Nitro 服务端请求上下文,通常包含:
- 请求方法、URL、请求头;
- 路由参数;
- Cookie;
- 请求体读取能力;
- 响应头和响应状态设置能力;
- 当前请求范围内的上下文数据。
一个最小的服务端路由如下:
// server/api/health.get.ts
export default defineEventHandler(() => {
return {
ok: true,
service: 'web',
}
})
启动开发服务器:
npm run dev
请求:
curl http://localhost:3000/api/health
预期响应:
{
"ok": true,
"service": "web"
}
server/api/health.get.ts 中各部分的含义是:
server/:Nuxt 服务端目录;api/:API 路由目录;health:路由名称;.get:只匹配GET方法;.ts:TypeScript 实现;defineEventHandler:把函数声明为 Nitro/H3 可调用的事件处理器。
对应路径是 /api/health。server/api 下的文件默认带 /api 前缀,而 server/routes 下的文件不带这个前缀:
// server/routes/health.get.ts
export default defineEventHandler(() => {
return { ok: true }
})
该文件对应 /health,而不是 /api/health。
1. HTTP 方法和文件名
可以通过文件名后缀约束方法:
server/api/users.get.ts -> GET /api/users
server/api/users.post.ts -> POST /api/users
server/api/users.delete.ts -> DELETE /api/users
server/api/users.ts -> 多数情况下可处理多个方法
如果一个资源需要分别处理不同方法,推荐使用方法后缀:
// server/api/users.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string }>(event)
if (!body.name || body.name.trim().length < 2) {
throw createError({
statusCode: 400,
statusMessage: 'name must contain at least 2 characters',
})
}
return {
id: crypto.randomUUID(),
name: body.name.trim(),
}
})
请求:
curl -X POST http://localhost:3000/api/users \
-H 'content-type: application/json' \
-d '{"name":"Ada"}'
预期结果类似:
{
"id": "生成的 UUID",
"name": "Ada"
}
readBody 会解析请求体。这里必须提供 content-type: application/json,否则不同运行时或客户端的解析行为可能不符合预期。生产接口还应限制请求体大小、校验字段类型和长度,并避免直接信任客户端传来的 ID、角色或金额。
2. 动态路由和查询参数
动态文件名会生成路由参数:
// server/api/users/[id].get.ts
export default defineEventHandler((event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Missing user id',
})
}
return {
id,
name: 'Example User',
}
})
请求 /api/users/42 时,id 为字符串 "42"。即使业务上它代表数字,也不能假设框架会自动转换:
const numericId = Number(id)
if (!Number.isInteger(numericId) || numericId <= 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid user id',
})
}
查询参数使用 getQuery:
// server/api/search.get.ts
export default defineEventHandler((event) => {
const query = getQuery(event)
const keyword = typeof query.q === 'string' ? query.q.trim() : ''
return {
keyword,
page: query.page ?? '1',
}
})
请求:
curl 'http://localhost:3000/api/search?q=nuxt&page=2'
路径参数和查询参数有不同语义:
/api/users/42中的42是资源路径的一部分;/api/users?page=2中的page是查询条件;- 查询参数可以重复或具有数组形式,解析后不一定是单个字符串,因此应进行类型收窄。
二、Nitro 到底负责什么
Nitro 是 Nuxt 的服务端引擎。它不等同于某一个 HTTP 业务接口,也不等同于 Node.js。它主要负责:
- 收集
server/目录中的路由和中间件; - 构建服务端代码;
- 提供基于 H3 的请求处理模型;
- 处理运行时配置;
- 支持缓存、预渲染和路由规则;
- 将应用适配为 Node、Serverless、边缘运行时或静态输出等目标。
Nuxt 页面服务端渲染和 server/api 接口通常共享同一个 Nitro 服务,但它们不是同一种路由:
- 页面路由通常负责返回 HTML 或 Nuxt SSR 响应;
- API 路由通常返回 JSON、文件、重定向或错误响应;
- 两者都可以使用服务端运行时能力,但缓存、认证和响应格式可能不同。
1. event 是请求级状态,不是全局状态
可以把请求处理器看成函数:
其中:
Request是当前 HTTP 请求;RuntimeConfig是当前进程可用的部署配置;ExternalState是数据库、缓存、文件系统或外部服务;Response是最终 HTTP 响应。
event 只表示当前请求,不应被保存到模块级变量中:
// 错误示例
let currentEvent: unknown
export default defineEventHandler((event) => {
currentEvent = event
return { ok: true }
})
多个请求可能并发执行。把请求对象存入全局变量会造成请求之间的数据覆盖、用户信息串读或内存泄漏。需要跨请求共享的数据,应使用明确的外部存储或只读进程级配置;需要当前请求范围的数据,应放在 event.context 中。
例如,认证中间件可以把用户信息挂到当前请求上下文:
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'session')
if (!token) {
return
}
// 实际项目中应验证签名或查询会话存储
event.context.user = {
id: 'user-1',
role: 'user',
}
})
路由处理器再读取:
// server/api/me.get.ts
export default defineEventHandler((event) => {
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
return user
})
这只是展示数据流。生产环境不能仅凭 Cookie 中存在一个字符串就认为用户已认证,必须验证会话签名、令牌有效期和撤销状态。
2. 中间件、路由和错误的关系
server/middleware 中的处理器会在服务端路由匹配后、具体处理器执行前参与请求流程。它常用于:
- 记录请求;
- 注入请求上下文;
- 做统一认证;
- 设置通用响应头。
如果中间件抛出错误,后续路由不会执行:
export default defineEventHandler((event) => {
const internalKey = getHeader(event, 'x-internal-key')
if (internalKey !== process.env.INTERNAL_KEY) {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
})
}
})
但不应把所有逻辑都塞进全局中间件。只需要保护某个接口时,在具体路由中检查更容易理解,也能避免健康检查、静态资源或公开接口被误拦截。
统一错误应使用 createError:
throw createError({
statusCode: 404,
statusMessage: 'User not found',
data: {
code: 'USER_NOT_FOUND',
},
})
不要把数据库异常、令牌内容或堆栈直接返回给客户端。服务端日志可以记录完整错误,客户端响应应只包含可公开的信息。
三、Nuxt API 与客户端调用
客户端调用 Nuxt API 通常使用 $fetch:
<script setup lang="ts">
const { data, error, pending } = await useFetch('/api/health')
</script>
<template>
<p v-if="pending">Loading...</p>
<p v-else-if="error">Request failed</p>
<pre v-else>{{ data }}</pre>
</template>
useFetch 是 Nuxt 对数据获取、SSR 和响应式状态的封装。服务端渲染时,它可以参与 Nuxt 的数据传输流程,避免浏览器再次无意义地重复获取同一份 SSR 数据。
直接使用 $fetch 更适合事件处理器或明确的一次性请求:
<script setup lang="ts">
async function createUser() {
const result = await $fetch('/api/users', {
method: 'POST',
body: {
name: 'Ada',
},
})
console.log(result)
}
</script>
需要区分两类请求:
// 服务器内部调用 API
await $fetch('/api/users')
// 服务端直接调用业务函数
await userService.list()
在服务端渲染期间,直接调用同一个 Nuxt API 可能导致一次额外的 HTTP 风格处理。对于复杂系统,通常更适合把核心业务抽到普通 TypeScript 服务模块中,让 API Handler 和页面数据加载共同调用它:
// server/services/userService.ts
export async function listUsers() {
return [
{ id: '1', name: 'Ada' },
{ id: '2', name: 'Grace' },
]
}
// server/api/users.get.ts
import { listUsers } from '../services/userService'
export default defineEventHandler(() => {
return listUsers()
})
这样可以避免把“内部模块调用”和“HTTP API 调用”混为一谈。HTTP 层负责认证、参数解析和响应状态;业务模块负责领域逻辑。
四、运行时配置:构建时配置和部署时配置不是一回事
Nuxt 配置中常见两类值:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
databaseUrl: '',
apiSecret: '',
public: {
apiBase: '/api',
appName: 'Demo',
},
},
})
runtimeConfig 的规则是:
runtimeConfig顶层值只应在服务端访问;runtimeConfig.public中的值会暴露给客户端;- 密钥、数据库地址、签名密钥等不能放入
public; - 配置默认值会参与构建,但生产值可以通过符合规则的环境变量覆盖。
服务端读取配置:
// server/api/config-check.get.ts
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event)
return {
hasDatabaseUrl: Boolean(config.databaseUrl),
apiBase: config.public.apiBase,
}
})
客户端读取公开配置:
<script setup lang="ts">
const config = useRuntimeConfig()
console.log(config.public.apiBase)
</script>
不要这样做:
export default defineNuxtConfig({
runtimeConfig: {
public: {
apiSecret: 'secret-value',
},
},
})
因为 public 配置会进入客户端可访问的数据。即使代码中没有主动显示它,浏览器仍可能通过构建产物、Nuxt payload 或运行时配置看到它。
1. 环境变量覆盖
Nuxt 对运行时配置的环境变量覆盖通常使用 NUXT_ 前缀,并根据配置层级映射。例如:
NUXT_DATABASE_URL='postgres://user:pass@db/app'
NUXT_API_SECRET='production-secret'
NUXT_PUBLIC_API_BASE='https://example.com/api'
部署时:
NUXT_DATABASE_URL='postgres://user:pass@db/app' \
NUXT_API_SECRET='production-secret' \
node .output/server/index.mjs
这里有两个容易混淆的事实:
nuxt.config.ts本身会在构建阶段执行;runtimeConfig的设计目标是让服务端在运行阶段读取部署配置。
因此不应把生产密钥写死在仓库,也不能把普通的 process.env.SOME_KEY 使用方式等同于 Nuxt 运行时配置。对于需要被 useRuntimeConfig() 正确覆盖的字段,应按 Nuxt 约定设置 NUXT_... 环境变量。
运行时配置并不是安全边界。服务端代码仍可能错误地把私密值返回:
// 错误示例
export default defineEventHandler(() => {
return useRuntimeConfig()
})
应只返回明确允许公开的字段:
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event)
return {
appName: config.public.appName,
}
})
五、缓存:缓存的不是“函数”,而是带条件的响应
缓存的核心条件是:对于同一个缓存键,后续请求可以接受之前保存的结果。
形式化地说,若响应函数为:
其中:
- 是请求键,例如路径、查询参数和必要的请求头;
- 是外部状态,例如数据库内容;
- 是响应。
当缓存只使用 作为键时,必须满足:
或者至少在缓存有效期内,业务允许把不同状态下的结果视为相同。若响应还依赖用户身份 U,则缓存键必须包含用户维度:
否则用户 A 的响应可能被用户 B 读取。这是服务端缓存最严重的错误之一。
1. 路由级缓存规则
可以在 nuxt.config.ts 中声明路由规则:
export default defineNuxtConfig({
routeRules: {
'/blog/**': {
swr: 3600,
},
'/api/health': {
cache: {
maxAge: 10,
},
},
},
})
这里的语义需要区分:
swr: 3600表示允许使用 stale-while-revalidate 语义,具体实现由 Nitro 和部署平台决定;cache.maxAge: 10表示缓存有效时间为 10 秒;- 不同 Nitro/Nuxt 版本对路由规则、平台适配和缓存后端的支持可能不同;
- 路由规则不是数据库级缓存,也不是自动生成 CDN 缓存控制策略的绝对保证。
静态博客页面通常可以容忍短时间旧数据,而用户账户、购物车、权限和支付状态通常不能使用共享缓存。尤其要避免对带有 Cookie、Authorization 或用户私有数据的接口随意配置公共缓存。
2. Handler 级缓存
对于可以缓存的服务端处理器,可以使用 Nitro 提供的缓存 Handler 能力。具体导入方式和可用选项可能随 Nitro 版本变化,典型形式如下:
// server/api/catalog.get.ts
export default defineCachedEventHandler(async () => {
const response = await fetch('https://example.com/catalog.json')
if (!response.ok) {
throw createError({
statusCode: 502,
statusMessage: 'Catalog upstream failed',
})
}
return response.json()
}, {
maxAge: 300,
})
这段代码的前提是:目录数据允许在 300 秒内被多个请求共享。若上游请求失败,不能把失败结果误当成成功数据缓存;同时还需要考虑上游超时、缓存击穿和缓存失效后的并发请求。
如果响应依赖查询参数,必须确认缓存键会区分不同 URL。例如:
/api/search?q=nuxt
/api/search?q=vue
不能共享同一个没有查询参数区分的缓存键。对于依赖认证信息的接口,除非明确设计了按用户隔离的缓存,否则应关闭共享缓存。
3. 缓存失效比缓存写入更难
假设商品列表缓存为:
管理员更新数据库状态 后,如果没有失效动作,缓存仍是:
此时数据库已经是新状态,但客户端继续得到旧结果。常见处理方式有:
- 缩短
maxAge,接受最终一致性; - 更新数据后删除对应缓存;
- 使用版本化缓存键,例如
catalog:v2; - 对必须即时一致的接口不使用共享缓存。
不同部署平台的缓存存储和失效 API 并不完全一致,不能假设本地开发环境清空内存缓存的方式在 Serverless 或边缘环境同样有效。
六、SSR、预渲染、ISR 与 API 缓存的区别
Nuxt 中至少存在三种“结果被复用”的场景:
1. SSR
SSR 是请求到达时在服务端生成 HTML:
每次请求都可能重新执行页面数据获取和组件渲染。SSR 不等于页面被缓存,也不等于 API 响应被缓存。
2. 预渲染
预渲染在构建阶段生成页面文件。构建时无法访问生产环境才存在的数据库状态,适合文档、营销页和内容快照。
如果项目使用纯静态输出,例如:
npx nuxt generate
则输出主要是静态文件。此时不能把 server/api 当成一个持续运行的 Node API 服务来使用;部署平台只会提供生成出来的静态资源,除非另行部署 API 后端或使用平台函数能力。
3. ISR/SWR
ISR 或 SWR 是在预生成或首次请求后,在一段时间内复用页面结果,并在过期后重新生成。它解决的是页面或路由响应的更新频率问题,不等同于数据库缓存,也不等同于浏览器缓存。
因此以下三个问题必须分开回答:
- 页面 HTML 是否由服务端生成?
- API Handler 是否缓存响应?
- 浏览器或 CDN 是否缓存 HTTP 响应?
修改 server/api/users.get.ts 的缓存规则,不会自动改变页面 HTML 的 SSR 行为;修改页面的 swr 规则,也不代表页面内部调用的所有 API 都获得相同缓存策略。
七、一个完整的 API 示例:配置、校验、错误和客户端调用
下面实现一个读取公开配置、接收 POST 数据并返回明确错误的接口。
// server/api/profile.post.ts
import { z } from 'zod'
const profileSchema = z.object({
displayName: z.string().trim().min(2).max(40),
})
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const body = await readBody<unknown>(event)
const result = profileSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid request body',
data: {
code: 'INVALID_BODY',
},
})
}
const upstream = await $fetch<{ id: string }>(
`${config.public.apiBase}/profiles`,
{
method: 'POST',
body: result.data,
headers: {
'x-service-token': config.apiSecret,
},
},
)
return {
id: upstream.id,
displayName: result.data.displayName,
}
})
前置条件:
npm install zod
这个处理器的执行顺序是:
- 从
useRuntimeConfig(event)读取服务端配置; - 用
readBody读取未知类型的请求体; - 用 schema 验证,而不是把客户端输入直接断言成可信类型;
- 验证失败时返回
400; - 使用私有的
apiSecret调用上游服务; - 只返回业务允许公开的结果。
客户端调用:
await $fetch('/api/profile', {
method: 'POST',
body: {
displayName: 'Ada Lovelace',
},
})
请求体不符合约束时,服务端应返回 HTTP 400。上游失败时,$fetch 会抛出异常,Nuxt 会将其转换为错误响应;生产代码还可以捕获异常并映射为不泄露上游细节的 502 Bad Gateway。
不要把 TypeScript 类型断言当成运行时校验:
const body = await readBody<{
displayName: string
}>(event)
这只告诉 TypeScript“假设它是这个类型”,不会检查客户端是否真的发送了字符串。网络边界必须使用运行时校验。
八、部署:构建产物、Node、Serverless 和静态输出
1. Node 部署
普通 Node 部署通常执行:
npm run build
node .output/server/index.mjs
构建成功后,Nuxt/Nitro 会生成 .output 目录。index.mjs 是 Node 服务器入口,实际端口通常由 PORT 环境变量控制:
PORT=8080 node .output/server/index.mjs
验证:
curl -i http://127.0.0.1:8080/api/health
应看到类似:
HTTP/1.1 200 OK
content-type: application/json
并得到健康检查 JSON。
生产部署时需要验证:
- 构建阶段是否成功;
.output是否被完整复制到运行镜像;PORT是否与平台分配的端口一致;NUXT_...环境变量是否在运行阶段存在;- 反向代理是否正确转发
Host、协议和客户端 IP; - 健康检查是否访问一个无数据库依赖或依赖明确的接口。
2. Serverless 和边缘运行时
Nitro 支持不同部署预设。可以通过环境变量选择预设,具体名称取决于当前 Nitro 版本和目标平台:
NITRO_PRESET=<target> npm run build
不同运行时的约束并不相同:
- Node 通常允许长期进程、连接池和本地临时文件;
- Serverless 实例可能冷启动、随时销毁,并发请求不一定落到同一实例;
- 边缘运行时可能不支持完整 Node API、原生模块或传统 TCP 数据库连接;
- 本地内存缓存可能只在单个实例内有效;
- 多实例之间不能依赖模块级变量同步状态。
因此,下面这种“进程内计数器”不能作为生产全局计数:
let count = 0
export default defineEventHandler(() => {
count += 1
return { count }
})
在单个本地进程中它会递增;部署到多实例环境后,每个实例都有自己的 count,重启后也会归零。需要一致计数时,应使用 Redis、数据库或平台提供的共享存储。
3. 静态输出的边界
如果部署目标只有静态文件服务器:
npx nuxt generate
则服务端路由不会作为一个常驻 Nitro HTTP 服务运行。以下设计会失败:
- 浏览器请求
/api/orders,期待 Nuxt 在静态服务器上访问数据库; - 在
server/api中读取仅存在于服务器环境的密钥; - 依赖每次请求执行的认证中间件;
- 依赖服务端缓存和实时数据库状态。
静态站点仍可以调用外部 API,但该 API 必须由另一个持续运行的后端、Serverless Function 或边缘函数提供。
九、生产故障的诊断路径
1. 页面正常,API 404
先确认路径前缀:
server/api/foo.get.ts -> /api/foo
server/routes/foo.get.ts -> /foo
然后确认构建产物是否包含该路由,以及部署方式是否是静态输出。如果使用 nuxt generate,服务端 API 没有常驻进程是最常见原因之一。
2. 本地有值,生产配置为空
检查三个层面:
printenv | grep '^NUXT_'
然后确认:
- 环境变量是否注入到运行容器,而不是只注入构建容器;
- 名称是否与
runtimeConfig层级匹配; - 私有字段是否误放到
public; - 是否在服务端使用了
useRuntimeConfig(event); - 平台是否在启动命令执行前设置变量。
如果配置只在构建阶段存在,而运行时容器没有该变量,使用运行时配置的代码可能在生产请求中得到空值或默认值。
3. 用户看到别人的数据
优先检查缓存,而不是先怀疑 Vue 响应式系统:
- 是否给带 Cookie 或 Authorization 的接口设置了共享缓存;
- 缓存键是否忽略了查询参数;
- 是否缓存了用户私有页面;
- CDN 是否缓存了没有正确
Cache-Control的响应; - 服务端是否把用户状态放进了模块级变量。
如果响应依赖用户身份,应默认视为不可共享,除非已经设计并验证了按用户隔离的缓存键和失效策略。
4. 本地正常,Serverless 超时
常见原因包括:
- 在每个请求中重复建立数据库连接;
- 依赖本地文件持久化;
- 使用了不受目标运行时支持的 Node API;
- 上游请求没有超时;
- 把冷启动和网络延迟误判为缓存问题。
应先记录请求总耗时、上游耗时和数据库耗时,再决定是连接管理、运行时兼容性还是外部服务故障。
十、几个容易混淆的结论
Server Routes 不等于 API Routes。 server/api 是 Server Routes 的一个约定目录;server/routes、动态路由和服务端中间件也属于 Nitro 服务端能力。
Nitro 不等于数据库。 Nitro 负责服务端运行时和部署适配,不会自动提供数据持久化、事务或跨实例状态。
运行时配置不等于机密管理系统。 它可以避免把值硬编码进业务代码,但密钥仍应由容器密钥、云平台 Secret 或专门的密钥系统注入,并且不能进入 public 或响应正文。
SSR 不等于缓存。 SSR 描述生成 HTML 的位置;缓存描述结果是否被复用。两者可以独立配置。
TypeScript 类型不等于输入验证。 编译器无法验证真实 HTTP 请求体,边界数据仍需要运行时 schema 校验。
本地成功不等于部署可用。 本地通常是单进程、长生命周期、完整 Node 环境;生产可能是多实例、Serverless 或边缘运行时。缓存、连接、文件和全局变量的语义都会因此改变。
Nuxt Server Routes 的核心不是文件名本身,而是把“请求如何进入系统、由哪个 Handler 处理、读取哪些运行时配置、响应是否可缓存、最终在哪种运行时执行”完整连接起来。只有沿着这条因果链检查,才能正确判断一个接口是路由问题、配置问题、缓存问题,还是部署模型本身不匹配。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Nuxt 路由与布局:文件约定、中间件、错误页和导航
- 下一篇:Vue SSR 水合诊断:不一致来源、客户端边界和调试方法
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论