楠渡余生楠渡余生
首页
笔记
作品集
留言板
关于
GitHub
CSDN
首页
笔记
作品集
留言板
关于
GitHub
CSDN
  • 前端开发

    • React Server Components(RSC)学习笔记

      • React Server Components(RSC)学习笔记
    • 全栈框架学习笔记

      • 全栈框架学习笔记
    • jQuery 学习笔记

      • jQuery 学习笔记
    • React 学习笔记

      • React 学习笔记
    • AJAX 学习笔记

      • AJAX 学习笔记
    • Axios 完整学习笔记

      • Axios 完整学习笔记
    • CSS 属性速查手册

      • CSS 属性速查手册
    • HTML5 与 CSS 综合学习笔记

      • HTML5 与 CSS 综合学习笔记
    • JavaScript 学习笔记

      • JavaScript 学习笔记
    • Promise 学习笔记

      • Promise 学习笔记
    • Tailwind CSS 完整笔记

      • Tailwind CSS 完整笔记
    • TypeScript 快速上手

      • TypeScript 快速上手
    • Vue3 学习笔记

      • Vue3 学习笔记
  • 元框架与全栈路由

    • Next.js App Router 最佳实践

      • Next.js App Router 最佳实践
    • 跨域与服务端组件数据预取

      • 跨域与服务端组件数据预取
  • 现代数据流与安全

    • Prisma Schema 全栈类型生成

      • Prisma Schema 全栈类型生成
    • Supabase RLS 行级安全策略

      • Supabase RLS 行级安全策略
  • 商业化与支付闭环

    • SaaS 订阅制用户表结构设计

      • SaaS 订阅制用户表结构设计
    • Stripe Webhook 接入避坑指南

      • Stripe Webhook 接入避坑指南
  • 零运维与边缘计算

    • Cloudflare 基础防护与 CDN

      • Cloudflare 基础防护与 CDN
    • Vercel 自动化部署与环境变量

      • Vercel 自动化部署与环境变量
  • AI 赋能与集成

    • Vercel AI SDK 流式输出实战

      • Vercel AI SDK 流式输出实战
  • 增长、监控与运营

    • Resend 事务性邮件模板

      • Resend 事务性邮件模板
    • Sentry 前端异常捕获与报警

      • Sentry 前端异常捕获与报警
  • Node.js 深入学习

    • MongoDB 常用命令速查表

      • MongoDB 常用命令速查表
    • Node.js + MongoDB 生产级最佳实践指南

      • Node.js + MongoDB 生产级最佳实践指南
    • Node.js Express 框架

      • Node.js Express 框架
    • Node.js HTTP 模块

      • Node.js HTTP 模块
    • Node.js NPM 包管理

      • Node.js NPM 包管理
    • Node.js 文件系统模块

      • Node.js 文件系统模块
    • Node.js 模块化设计

      • Node.js 模块化设计
  • 后端开发

    • Express 基本使用

      • Express 基本使用
    • Node.js 学习笔记

      • Node.js 学习笔记
    • SpringBoot 完整学习笔记

      • SpringBoot 完整学习笔记
  • 开发工具

    • Windows + WSL + Docker 踩坑与通关指南

      • Windows + WSL + Docker 踩坑与通关指南
    • GitHub 新手完全指南

      • GitHub 新手完全指南
    • 个人博客搭建指南

      • 个人博客搭建指南

跨域与服务端组件数据预取

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。
最后更新: 2026/6/13 21:40
贡献者: 52nnnn, Claude Opus 4.7