跨域与服务端组件数据预取
1. 先理解跨域问题在哪里发生
跨域是浏览器安全策略,不是服务端之间的问题。
浏览器 fetch https://api.example.com -> 受 CORS 限制
Next.js Server Component fetch https://api.example.com -> 不受浏览器 CORS 限制
Next.js Route Handler fetch 后端服务 -> 不受浏览器 CORS 限制
真实项目建议:
- 浏览器直接请求第三方 API:需要 CORS。
- 浏览器请求自己站点
/api/*:通常不跨域。 - Server Component 直接请求后端:适合隐藏密钥、聚合数据。
- Route Handler 作为 BFF:适合统一鉴权、限流、错误格式。
2. Server Component 数据预取
Server Component 可以在服务端直接获取数据,减少客户端瀑布流。
// app/products/page.tsx
import { ProductList } from './ProductList'
type Product = {
id: string
name: string
price: number
stock: number
}
async function getProducts(): Promise<Product[]> {
try {
const res = await fetch(`${process.env.INTERNAL_API_URL}/products`, {
headers: {
Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`,
},
next: { revalidate: 120, tags: ['products'] },
})
if (!res.ok) throw new Error(`Products API failed: ${res.status}`)
const data = await res.json() as unknown
if (!Array.isArray(data)) return []
return data.filter((item): item is Product => {
return Boolean(
item &&
typeof item === 'object' &&
'id' in item &&
'name' in item &&
'price' in item &&
'stock' in item,
)
})
} catch (error) {
console.error('[getProducts]', error)
return []
}
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<main>
<h1>商品列表</h1>
<ProductList products={products} />
</main>
)
}
// app/products/ProductList.tsx
'use client'
type Product = {
id: string
name: string
price: number
stock: number
}
type Props = {
products: Product[]
}
export function ProductList({ products }: Props) {
if (products.length === 0) {
return <p>暂无商品</p>
}
return (
<ul>
{products.map(product => (
<li key={product.id}>
{product.name} - ¥{product.price} - 库存 {product.stock}
</li>
))}
</ul>
)
}
3. Fetch 缓存机制
Next.js App Router 中服务端 fetch 常见模式:
// 默认可能参与缓存,适合公开数据
await fetch(url)
// 永远不缓存,适合登录态、订单、余额
await fetch(url, { cache: 'no-store' })
// 按时间重新验证,适合商品、文章、配置
await fetch(url, { next: { revalidate: 300 } })
// 打标签,后续按标签失效
await fetch(url, { next: { tags: ['products'] } })
4. 按标签失效缓存
// app/admin/products/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export type UpdateProductState = {
ok: boolean
message: string
}
export async function updateProductPrice(
_prevState: UpdateProductState,
formData: FormData,
): Promise<UpdateProductState> {
const id = String(formData.get('id') || '')
const priceValue = Number(formData.get('price'))
if (!id) return { ok: false, message: '缺少商品 ID' }
if (!Number.isFinite(priceValue) || priceValue < 0) {
return { ok: false, message: '价格不合法' }
}
try {
const res = await fetch(`${process.env.INTERNAL_API_URL}/products/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`,
},
body: JSON.stringify({ price: priceValue }),
cache: 'no-store',
})
if (!res.ok) throw new Error(`Update product failed: ${res.status}`)
revalidateTag('products')
return { ok: true, message: '商品价格已更新' }
} catch (error) {
console.error('[updateProductPrice]', error)
return { ok: false, message: '更新失败,请稍后重试' }
}
}
5. Route Handler 作为 BFF 解决跨域与鉴权
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (!token) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
try {
const res = await fetch(`${process.env.INTERNAL_API_URL}/products`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
return NextResponse.json(
{ error: '获取商品失败', detail: data?.message ?? null },
{ status: res.status },
)
}
return NextResponse.json({ products: Array.isArray(data) ? data : [] })
} catch (error) {
console.error('[GET /api/products]', error)
return NextResponse.json({ error: '服务暂时不可用' }, { status: 500 })
}
}
// app/products/ClientProducts.tsx
'use client'
import { useEffect, useState } from 'react'
type Product = {
id: string
name: string
}
export function ClientProducts() {
const [products, setProducts] = useState<Product[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const controller = new AbortController()
async function load() {
try {
const res = await fetch('/api/products', { signal: controller.signal })
const data = await res.json().catch(() => null) as { products?: Product[]; error?: string } | null
if (!res.ok) throw new Error(data?.error || '请求失败')
setProducts(Array.isArray(data?.products) ? data.products : [])
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
console.error('[ClientProducts]', error)
setError(error instanceof Error ? error.message : '加载失败')
} finally {
setLoading(false)
}
}
load()
return () => controller.abort()
}, [])
if (loading) return <p>加载中...</p>
if (error) return <p role="alert">{error}</p>
return (
<ul>
{products.map(product => <li key={product.id}>{product.name}</li>)}
</ul>
)
}
6. CORS 的正确配置
如果确实要让浏览器跨域请求你的 API,需要白名单,不要直接 *。
// app/api/public-data/route.ts
import { NextRequest, NextResponse } from 'next/server'
const allowedOrigins = new Set([
'https://example.com',
'https://www.example.com',
])
function corsHeaders(origin: string | null) {
const allowOrigin = origin && allowedOrigins.has(origin) ? origin : 'https://example.com'
return {
'Access-Control-Allow-Origin': allowOrigin,
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Vary': 'Origin',
}
}
export async function OPTIONS(request: NextRequest) {
return new NextResponse(null, {
status: 204,
headers: corsHeaders(request.headers.get('origin')),
})
}
export async function GET(request: NextRequest) {
try {
return NextResponse.json(
{ ok: true, time: new Date().toISOString() },
{ headers: corsHeaders(request.headers.get('origin')) },
)
} catch (error) {
console.error('[GET /api/public-data]', error)
return NextResponse.json(
{ error: '服务暂时不可用' },
{ status: 500, headers: corsHeaders(request.headers.get('origin')) },
)
}
}
7. 真实业务坑点
7.1 把所有请求都 no-store
这样会让所有页面都变成动态渲染,失去缓存优势。
建议:
- 登录态、订单、余额:
no-store - 商品、文章、公开配置:
revalidate - 静态文案:默认缓存或 SSG
7.2 在 Client Component 暴露密钥
NEXT_PUBLIC_* 会进入浏览器,不能放私钥。
可以公开:NEXT_PUBLIC_SITE_URL
不能公开:STRIPE_SECRET_KEY、DATABASE_URL、OPENAI_API_KEY
7.3 依赖 CORS 解决所有问题
如果请求需要私钥或复杂鉴权,应该让浏览器请求你的 /api/*,再由服务端调用真实后端。
7.4 缓存失效不明确
修改数据后如果不 revalidateTag 或 revalidatePath,用户可能看到旧数据。
8. 生产取舍
- Server Component 预取:首屏快、SEO 好,但要小心缓存策略。
- Client fetch:交互灵活,但首屏慢、SEO 弱。
- Route Handler BFF:统一鉴权和错误处理,但多一层服务调用。
- CORS 放开:适合公开 API,不适合带私钥的业务 API。
