前端阅读 05月28日 06:28
如何在 Astro 中创建和使用 API 路由?如何处理请求和响应?
Astro 的 API 路由(Server Endpoints)允许你在项目中创建服务端接口,处理 HTTP 请求并返回响应。这是 Astro 构建全栈应用的核心能力之一,面试中常考请求处理方式、SSR/SSG 模式差异、类型安全等知识点。API 路由的基本原理API 路由文件放在 src/pages/ 目录下,文件路径即接口路径。与页面组件不同,API 路由文件使用 .ts 或 .js 扩展名,导出的是 HTTP 方法函数而非 Astro 组件。Astro 使用 Web 标准的 Request 和 Response 对象,与 Cloudflare Workers、Deno 等运行时保持一致,这意味着你不需要学习 Express 那样的 req/res 专属 API,掌握 Fetch API 标准即可上手。关键前提:API 路由需要服务端渲染(SSR)模式才能在请求时动态执行。如果你的项目是纯静态站点(SSG),API 路由只会在构建时执行一次。需要在 astro.config.mjs 中配置适配器:import { defineConfig } from 'astro/config';import node from '@astrojs/node';export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }),});创建第一个 API 路由使用 APIRoute 类型可以获得完整的类型提示,这是推荐的做法:// src/pages/api/hello.tsimport type { APIRoute } from 'astro';export const GET: APIRoute = async ({ request }) => { return new Response( JSON.stringify({ message: 'Hello, World!', timestamp: Date.now() }), { status: 200, headers: { 'Content-Type': 'application/json' }, } );};访问 /api/hello 即可得到 JSON 响应。APIRoute 类型会自动推断 params、request、cookies 等参数的类型,避免手写类型声明。支持哪些 HTTP 方法每个 API 路由文件可以导出多个 HTTP 方法函数,Astro 根据请求方法自动路由到对应函数:// src/pages/api/users.tsimport type { APIRoute } from 'astro';export const GET: APIRoute = async ({ request, url }) => { const users = await fetchUsers(); return new Response(JSON.stringify(users), { headers: { 'Content-Type': 'application/json' }, });};export const POST: APIRoute = async ({ request }) => { const body = await request.json(); const newUser = await createUser(body); return new Response(JSON.stringify(newUser), { status: 201, headers: { 'Content-Type': 'application/json' }, });};export const DELETE: APIRoute = async ({ request }) => { const body = await request.json(); await deleteUser(body.id); return new Response(null, { status: 204 });};支持的导出函数名包括 GET、POST、PUT、PATCH、DELETE、OPTIONS 和 ALL。ALL 函数会在请求方法没有对应导出函数时被调用,适合做兜底处理或方法校验。动态路由参数使用方括号语法定义动态路由参数,与页面路由的规则一致:// src/pages/api/users/[id].tsimport type { APIRoute } from 'astro';export const GET: APIRoute = async ({ params }) => { const { id } = params; const user = await fetchUserById(id); if (!user) { return new Response( JSON.stringify({ error: 'User not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } } ); } return new Response(JSON.stringify(user), { headers: { 'Content-Type': 'application/json' }, });};如果需要捕获多个路径段,使用剩余参数语法 [...path].ts,params.path 会得到完整的路径数组。静态模式下,动态路由必须导出 getStaticPaths() 来预生成路径。请求处理:获取请求体、查询参数和请求头API 路由函数接收一个上下文对象,从中可以提取请求的所有信息:// src/pages/api/search.tsimport type { APIRoute } from 'astro';export const POST: APIRoute = async ({ request, url, cookies }) => { try { // 请求体:根据 Content-Type 选择解析方式 const body = await request.json(); // JSON 请求体 // const formData = await request.formData(); // 表单数据 // const text = await request.text(); // 纯文本 // 查询参数 const limit = parseInt(url.searchParams.get('limit') || '10'); const page = parseInt(url.searchParams.get('page') || '1'); // 请求头 const authHeader = request.headers.get('Authorization'); const contentType = request.headers.get('Content-Type'); // Cookie const sessionToken = cookies.get('session')?.value; const results = await search(body.query, { limit, page }); return new Response(JSON.stringify({ results, page, limit }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300', }, }); } catch (error) { return new Response( JSON.stringify({ error: 'Invalid request body' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); }};注意 request.json() 只能调用一次,因为 Request.body 是 ReadableStream,消费后不可重读。如果需要多次读取,先 clone() 再解析。响应构建:状态码、头信息和重定向Astro 返回的是标准 Response 对象,你可以完全控制状态码、头信息和响应体:// 成功响应return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', },});// 创建资源return new Response(JSON.stringify(newItem), { status: 201, headers: { 'Location': `/api/items/${newItem.id}` },});// 重定向return Response.redirect(new URL('/api/new-path', request.url), 301);// 无内容return new Response(null, { status: 204 });面试中容易被问到:Astro 4+ 使用的是原生 Response 构造函数,不再返回 Astro 自定义的响应对象。如果你看到教程中使用 ({ body, status }) 的写法,那是 Astro 3 及更早版本的旧语法,已经废弃。身份验证与授权API 路由中实现鉴权通常从请求头或 Cookie 中提取凭证:// src/pages/api/admin/stats.tsimport type { APIRoute } from 'astro';export const GET: APIRoute = async ({ request, cookies }) => { // 方式一:Bearer Token const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) { return new Response( JSON.stringify({ error: 'Missing authorization token' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } const user = await verifyToken(token); if (!user) { return new Response( JSON.stringify({ error: 'Invalid or expired token' }), { status: 403, headers: { 'Content-Type': 'application/json' } } ); } // 方式二:Session Cookie(配合中间件更方便) const sessionId = cookies.get('session_id')?.value; const stats = await fetchAdminStats(user.id); return new Response(JSON.stringify(stats), { headers: { 'Content-Type': 'application/json' }, });};更推荐的做法是将鉴权逻辑提取到中间件(middleware)中,避免每个路由重复编写。中间件在 API 路由执行前运行,可以在 locals 上挂载用户信息:// src/middleware.tsimport { defineMiddleware } from 'astro:middleware';export const onRequest = defineMiddleware(async (context, next) => { const token = context.request.headers.get('Authorization')?.replace('Bearer ', ''); if (token) { const user = await verifyToken(token); if (user) { context.locals.user = user; } } return next();});在 API 路由中直接读取 context.locals.user 即可判断身份。错误处理策略推荐封装统一的错误处理工具,让 API 路由保持简洁:// src/lib/api-error.tsexport class ApiError extends Error { constructor( public statusCode: number, message: string, public code?: string ) { super(message); this.name = 'ApiError'; }}export function handleApiError(error: unknown): Response { if (error instanceof ApiError) { return new Response( JSON.stringify({ error: error.message, code: error.code }), { status: error.statusCode, headers: { 'Content-Type': 'application/json' } } ); } console.error('Unexpected error:', error); return new Response( JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' } } );}在路由中使用:// src/pages/api/data.tsimport type { APIRoute } from 'astro';import { ApiError, handleApiError } from '../../lib/api-error';export const GET: APIRoute = async ({ params }) => { try { const data = await fetchData(params.id); if (!data) throw new ApiError(404, 'Data not found', 'NOT_FOUND'); return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { return handleApiError(error); }};CORS 跨域配置如果你的 API 需要被其他域名的前端调用,必须处理 CORS。可以通过 OPTIONS 预检和响应头来解决:// src/pages/api/public-data.tsimport type { APIRoute } from 'astro';const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400',};export const OPTIONS: APIRoute = async () => { return new Response(null, { status: 204, headers: corsHeaders });};export const GET: APIRoute = async ({ request }) => { const data = await fetchPublicData(); return new Response(JSON.stringify(data), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, });};更优雅的做法是在中间件中统一添加 CORS 头,避免每个路由重复定义。API 路由与 Astro Actions 的区别Astro 4.9+ 引入了 Actions,这是处理服务端逻辑的新方式。面试中经常考察两者的适用场景:API 路由适合:对外提供 REST 接口,供第三方或前端 SPA 调用需要处理多种 HTTP 方法的场景Webhook 回调接收需要自定义响应格式(非 JSON)的场景Actions 适合:表单提交和数据变更需要输入验证(Zod schema)和类型安全的场景渐进增强需求——即使 JavaScript 禁用也能工作组件内部的服务端调用// Actions 示例:带验证的表单处理import { defineAction } from 'astro:actions';import { z } from 'astro:schema';export const server = { createPost: defineAction({ input: z.object({ title: z.string().min(1).max(200), content: z.string().min(1), }), handler: async (input) => { const post = await db.post.create({ data: input }); return post; }, }),};面试要点:Actions 底层仍基于 API 路由实现,但它封装了验证、序列化和错误处理,适合大多数表单交互场景。如果你不需要 REST 语义或对外暴露接口,优先用 Actions。文件上传处理处理文件上传需要从 formData 中提取文件对象,并进行类型和大小校验:// src/pages/api/upload.tsimport type { APIRoute } from 'astro';export const POST: APIRoute = async ({ request }) => { try { const formData = await request.formData(); const file = formData.get('file') as File | null; if (!file) { return new Response( JSON.stringify({ error: 'No file provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { return new Response( JSON.stringify({ error: 'Unsupported file type' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } const maxSize = 5 * 1024 * 1024; // 5MB if (file.size > maxSize) { return new Response( JSON.stringify({ error: 'File exceeds 5MB limit' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } const url = await uploadToStorage(file); return new Response(JSON.stringify({ url }), { status: 201, headers: { 'Content-Type': 'application/json' }, }); } catch (error) { return new Response( JSON.stringify({ error: 'Upload failed' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); }};大文件上传建议使用流式处理(request.body 是 ReadableStream),避免将整个文件加载到内存。数据库集成与分页查询API 路由连接数据库时,分页是最常见的需求之一:// src/pages/api/posts.tsimport type { APIRoute } from 'astro';import { db } from '../../lib/db';export const GET: APIRoute = async ({ url }) => { const page = Math.max(1, parseInt(url.searchParams.get('page') || '1')); const limit = Math.min(50, Math.max(1, parseInt(url.searchParams.get('limit') || '10'))); const offset = (page - 1) * limit; const [posts, total] = await Promise.all([ db.post.findMany({ take: limit, skip: offset, orderBy: { createdAt: 'desc' }, }), db.post.count(), ]); return new Response( JSON.stringify({ posts, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }), { headers: { 'Content-Type': 'application/json' } } );};注意对 page 和 limit 做了边界处理,防止负数或过大值导致的异常查询。SSG 模式下的 API 路由静态站点生成模式下,API 路由在构建时执行,产出的 JSON 文件会被当作静态资源。这意味着动态路由需要通过 getStaticPaths() 声明所有可能的路径:// src/pages/api/tags/[tag].tsimport type { APIRoute } from 'astro';export async function getStaticPaths() { const tags = await fetchAllTags(); return tags.map(tag => ({ params: { tag } }));}export const GET: APIRoute = async ({ params }) => { const posts = await fetchPostsByTag(params.tag); return new Response(JSON.stringify(posts), { headers: { 'Content-Type': 'application/json' }, });};如果需要运行时动态响应,必须将路由标记为按需渲染:export const prerender = false;面试常问:SSG 模式的 API 路由本质上是构建时的数据预生成,适合数据不频繁变化的场景;SSR 模式才是真正的服务端接口,适合实时数据。搞混这两种模式是常见的错误。实战中的常见问题请求体解析失败怎么办? request.json() 在非法 JSON 时会抛异常,必须用 try/catch 包裹。同理 request.formData() 在非表单请求时也会报错。如何实现速率限制? Astro 本身不提供速率限制,需要自行实现或使用中间件。简单的做法是基于 IP 和时间窗口做计数:// src/lib/rate-limit.tsconst requests = new Map<string, { count: number; resetAt: number }>();export function rateLimit(ip: string, limit = 100, windowMs = 60000): boolean { const now = Date.now(); const record = requests.get(ip); if (!record || now > record.resetAt) { requests.set(ip, { count: 1, resetAt: now + windowMs }); return true; } record.count++; return record.count <= limit;}生产环境建议用 Redis 存储计数,避免内存泄漏和分布式场景下的不一致问题。如何做输入验证? 除了 Actions 内置的 Zod 验证,API 路由中也可以直接用 Zod:import { z } from 'zod';const CreatePostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), tags: z.array(z.string()).optional(),});export const POST: APIRoute = async ({ request }) => { const body = await request.json(); const result = CreatePostSchema.safeParse(body); if (!result.success) { return new Response( JSON.stringify({ error: 'Validation failed', details: result.error.flatten() }), { status: 422, headers: { 'Content-Type': 'application/json' } } ); } const post = await createPost(result.data); return new Response(JSON.stringify(post), { status: 201, headers: { 'Content-Type': 'application/json' }, });};这样做的好处是类型从验证结果中推断,不需要手动声明 body 的类型。掌握 Astro API 路由的关键在于理解它是基于 Web 标准的请求响应模型,与 Express 等框架的专有 API 不同。核心知识点包括:SSR/SSG 模式选择、APIRoute 类型标注、中间件集成鉴权、Actions 与 API 路由的适用场景区分,以及输入验证和错误处理的最佳实践。