面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 06月5日 22:01

Next.js 状态管理该用哪个方案?Server Component 到 Zustand 怎么选

Next.js App Router 引入了 Server Components 后,状态管理的思路和纯 SPA 完全不同。很多数据根本不需要客户端状态——服务端直接获取、渲染、返回 HTML,客户端零 JS 开销。只有真正需要交互的数据才用客户端状态管理。先问:这个状态真的需要客户端管理吗?| 场景 | 方案 | 需要 JS 吗 ||------|------|-----------|| 页面初始数据 | Server Component 直接 fetch | 不需要 || 用户个人信息 | Server Component + cookies | 不需要 || 表单输入值 | useState | 需要 || 弹窗开关 | useState | 需要 || 跨页面共享的购物车 | URL 参数 / Cookie / 外部状态库 | 看场景 |原则:能用 Server Component 解决的不用客户端状态。每多一个客户端状态,就多一份 JS 发送到浏览器。第一选择:URL 状态搜索关键词、分页页码、筛选条件——这些状态天然属于 URL:// app/products/page.tsxexport default async function ProductsPage({ searchParams,}: { searchParams: { q?: string; page?: string }}) { const products = await searchProducts({ query: searchParams.q, page: Number(searchParams.page) || 1, }) return <ProductList products={products} />}URL 状态的优势:可分享:用户复制链接给别人,状态不丢浏览器后退/前进天然支持SEO 友好:搜索引擎能抓到分页和筛选状态不需要 JS:Server Component 直接读取 searchParamsClient Component 中更新 URL 状态'use client'import { useSearchParams, useRouter } from 'next/navigation'function SearchBar() { const searchParams = useSearchParams() const router = useRouter() function handleSearch(query: string) { const params = new URLSearchParams(searchParams) params.set('q', query) params.delete('page') // 搜索时重置分页 router.push(`/products?${params.toString()}`) } return <input defaultValue={searchParams.get('q') || ''} onChange={(e) => handleSearch(e.target.value)} />}第二选择:Server Component 传递 props父组件获取数据,通过 props 传给子组件——最简单的状态传递方式:// app/dashboard/page.tsxexport default async function DashboardPage() { const [stats, recentOrders] = await Promise.all([ fetchStats(), fetchRecentOrders(), ]) return ( <div> <StatsCard stats={stats} /> <RecentOrders orders={recentOrders} /> </div> )}Promise.all 并行获取,不需要任何状态库。子组件拿到的是渲染好的数据,不需要 loading 状态。第三选择:React Context跨组件共享数据,但不需要跨页面共享:// providers/theme-provider.tsx'use client'import { createContext, useContext, useState } from 'react'type Theme = 'light' | 'dark'const ThemeContext = createContext<{ theme: Theme toggleTheme: () => void}>({ theme: 'light', toggleTheme: () => {} })export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<Theme>('light') return ( <ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'), }}> {children} </ThemeContext.Provider> )}export function useTheme() { return useContext(ThemeContext)}在根 layout 中注册:// app/layout.tsximport { ThemeProvider } from '@/providers/theme-provider'export default function RootLayout({ children }) { return ( <html> <body> <ThemeProvider>{children}</ThemeProvider> </body> </html> )}注意:Context Provider 必须是 Client Component,包在 Server Component 的 layout 里使用。这会导致 Provider 下面的所有子组件在客户端渲染——所以只放必要的 Provider,不要把整个应用包在一个巨大的 Provider 树里。第四选择:轻量外部状态库当 Context 不够用时(跨页面、需要 devtools、需要中间件),用 Zustand——最小最轻的 React 状态库:npm install zustand// store/cart-store.tsimport { create } from 'zustand'interface CartItem { id: string name: string price: number quantity: number}interface CartStore { items: CartItem[] addItem: (item: Omit<CartItem, 'quantity'>) => void removeItem: (id: string) => void clearCart: () => void total: () => number}export const useCartStore = create<CartStore>((set, get) => ({ items: [], addItem: (item) => set((state) => { const existing = state.items.find(i => i.id === item.id) if (existing) { return { items: state.items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i ), } } return { items: [...state.items, { ...item, quantity: 1 }] } }), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id), })), clearCart: () => set({ items: [] }), total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),}))// components/cart-button.tsx'use client'import { useCartStore } from '@/store/cart-store'export function CartButton() { const items = useCartStore((s) => s.items) const total = useCartStore((s) => s.total) return ( <button>购物车 ({items.length}) ¥{total()}</button> )}Zustand 的优势:不需要 Provider 包裹,任何 Client Component 里直接 useCartStore()。Zustand 持久化到 localStorageimport { create } from 'zustand'import { persist } from 'zustand/middleware'export const useCartStore = create( persist<CartStore>( (set, get) => ({ // ... store 定义 }), { name: 'cart-storage', // localStorage key } ))刷新页面后购物车数据不丢失。但注意 SSR hydration 不匹配的问题——首次渲染用空状态,hydration 后从 localStorage 读取:// 解决 hydration 不匹配function CartButton() { const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) if (!mounted) return <button>购物车</button> const count = useCartStore((s) => s.items.length) return <button>购物车 ({count})</button>}Server Actions 替代表单状态传统方式:表单输入 → 客户端状态 → 提交 → API 调用。Server Actions 简化了这整个流程:// app/products/actions.ts'use server'import { revalidateTag } from 'next/cache'export async function createProduct(formData: FormData) { const name = formData.get('name') as string const price = Number(formData.get('price')) await db.product.create({ data: { name, price } }) revalidateTag('products')}// app/products/page.tsximport { createProduct } from './actions'export function AddProductForm() { return ( <form action={createProduct}> <input name="name" required /> <input name="price" type="number" required /> <button type="submit">添加</button> </form> )}不需要 useState 管理表单值,不需要 fetch 调 API,提交后自动重新验证缓存。这是 App Router 推荐的表单处理方式。状态管理选择流程需要跨页面共享吗?├── 不需要 → 页面级用 Server Component,组件级用 useState└── 需要 → 能放 URL 吗? ├── 能 → URL 参数(searchParams) └── 不能 → 需要持久化吗? ├── 不需要 → React Context └── 需要 → Zustand + persist各方案对比| 方案 | 跨页面 | 持久化 | 需要 JS | 复杂度 ||------|--------|--------|---------|--------|| Server Component | 否 | - | 不需要 | 最低 || URL 参数 | 是 | 是 | 最少 | 低 || useState | 否 | 否 | 需要 | 低 || React Context | 是(同一 Provider 树) | 否 | 需要 | 中 || Zustand | 是 | 可选 | 需要 | 中 || Redux | 是 | 可选 | 需要 | 高 |Next.js 项目中,80% 的状态可以用 Server Component + URL 参数解决。只有真正需要在客户端管理交互状态时,才用 Context 或 Zustand。不要上来就装 Redux——那是 SPA 时代的思维。
服务端阅读 06月5日 21:52

Next.js 错误处理全指南:error.tsx、global-error 和 API 错误分层方案

Next.js 应用出错时,用户看到的不能是一个白屏或一堆报错代码。App Router 提供了分层错误处理机制——从组件级到全局级,每一层都有专门的文件处理。搞清这些层级,就能让错误发生时用户仍然能看到有意义的提示,而不是整个应用崩溃。错误处理层级组件内 try/catch → 处理可预期的业务错误 ↓ 未捕获error.tsx → 路由级 Error Boundary ↓ 仍未捕获global-error.tsx → 全局兜底(根 layout 也崩了)从内到外,每一层兜住上一层没处理的错误。error.tsx:路由级错误边界App Router 中,每个路由段可以有一个 error.tsx,捕获该路由段及其子组件的运行时错误:// app/dashboard/error.tsx'use client' // 必须是 Client Componentexport default function DashboardError({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { return ( <div className="error-container"> <h2>出错了</h2> <p>{error.message}</p> <button onClick={reset}>重试</button> </div> )}关键点:error.tsx 必须是 Client Component('use client')——Error Boundary 是客户端概念reset 函数重新执行渲染该路由段的尝试——适合临时性错误(网络波动)error.digest 是 Next.js 生成的错误哈希,可以在日志中追踪error.tsx 捕获什么子组件的运行时错误子组件中 Server Component 的数据获取失败子路由的 page.tsx 抛出的错误error.tsx 不捕获什么同级 layout.tsx 的错误——layout 在 error boundary 外面loading.tsx 不会影响 error.tsx根 app/layout.tsx 的错误——需要 global-error.tsxglobal-error.tsx:最后的兜底当根 layout 崩溃时,error.tsx 也渲染不了(因为 layout 本身都挂了)。这时需要 global-error.tsx:// app/global-error.tsx'use client'export default function GlobalError({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { return ( <html> <body> <h2>应用发生了严重错误</h2> <button onClick={reset}>重新加载</button> </body> </html> )}global-error.tsx 必须自带 <html> 和 <body>——因为根 layout 已经崩溃,什么都复用不了。这个文件应该尽量简单,不要引入任何可能也出错的组件。Server Component 中的错误处理Server Component 里用 try/catch 处理可预期的错误:// app/dashboard/page.tsxexport default async function DashboardPage() { let stats try { stats = await fetchDashboardStats() } catch { stats = { activeUsers: 0, revenue: 0 } // 降级数据 } return ( <div> <h1>仪表盘</h1> <StatsCard data={stats} /> </div> )}可预期的错误(API 超时、数据不存在)用 try/catch 降级处理,不让它冒泡到 error.tsx。只有真正无法恢复的错误才让 Error Boundary 接管。API Route 的错误处理基本错误响应// app/api/users/route.tsimport { NextResponse } from 'next/server'export async function GET(request: Request) { try { const users = await fetchUsers() return NextResponse.json(users) } catch (error) { console.error('Failed to fetch users:', error) return NextResponse.json( { error: '获取用户列表失败' }, { status: 500 } ) }}业务异常封装// lib/errors.tsexport class AppError extends Error { constructor( public statusCode: number, public code: string, message: string, ) { super(message) }}// app/api/users/route.tsexport async function POST(request: Request) { const body = await request.json() if (!body.email) { throw new AppError(400, 'MISSING_EMAIL', '邮箱不能为空') } if (await isEmailTaken(body.email)) { throw new AppError(409, 'EMAIL_TAKEN', '邮箱已被注册') } const user = await createUser(body) return NextResponse.json(user, { status: 201 })}全局错误中间件在 middleware 或单独的 error handler 中统一处理 AppError:// 在 route handler 外层包一个 wrapperfunction withErrorHandler(handler: Function) { return async (...args: any[]) => { try { return await handler(...args) } catch (error) { if (error instanceof AppError) { return NextResponse.json( { error: error.code, message: error.message }, { status: error.statusCode } ) } console.error('Unexpected error:', error) return NextResponse.json( { error: 'INTERNAL_ERROR', message: '服务器内部错误' }, { status: 500 } ) } }}// 使用export const POST = withErrorHandler(async (request: Request) => { const body = await request.json() // ... 业务逻辑,直接 throw AppError})notFound:比 Error Boundary 更优雅的"错误""找不到"不是错误,是一种正常状态。用 notFound() 比抛异常更语义化:// app/products/[id]/page.tsximport { notFound } from 'next/navigation'export default async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id) if (!product) { notFound() // 触发 not-found.tsx,不触发 error.tsx } return <ProductDetail product={product} />}自定义 not-found 页面:// app/not-found.tsxexport default function NotFound() { return ( <div className="not-found"> <h2>页面不存在</h2> <a href="/">返回首页</a> </div> )}notFound() 的好处:它不会在日志中记录为错误,不会触发 Error Boundary,返回 404 状态码——语义更准确。Client Component 中的错误处理Client Component 里可以用 React 的 Error Boundary class 组件做更细粒度的捕获:// components/error-boundary.tsx'use client'import { Component } from 'react'type Props = { fallback: React.ReactNode children: React.ReactNode}export class ErrorBoundary extends Component<Props, { hasError: boolean }> { state = { hasError: false } static getDerivedStateFromError() { return { hasError: true } } render() { if (this.state.hasError) { return this.props.fallback } return this.props.children }}// 使用<ErrorBoundary fallback={<p>加载失败</p>}> <Chart data={data} /></ErrorBoundary>这比 error.tsx 更灵活——可以包在单个组件外面,不影响整个页面。错误监控和日志生产环境中,错误信息不能只在前端打印——需要上报到监控系统:// app/error.tsx'use client'import { useEffect } from 'react'export default function Error({ error, reset }: { error: Error; reset: () => void }) { useEffect(() => { // 上报错误到监控系统 reportError(error) }, [error]) return ( <div> <h2>出了点问题</h2> <button onClick={reset}>重试</button> </div> )}Server Component 中的错误上报:// app/dashboard/page.tsxexport default async function DashboardPage() { try { const data = await fetchData() return <Dashboard data={data} /> } catch (error) { reportError(error) // 上报 throw error // 让 error.tsx 处理 UI }}错误处理策略速查| 错误类型 | 处理方式 | 文件位置 ||---------|---------|---------|| 数据获取失败 | try/catch 降级 | Server Component 内 || 路由级运行时错误 | error.tsx | app/[segment]/error.tsx || 根 layout 崩溃 | global-error.tsx | app/global-error.tsx || 页面不存在 | notFound() + not-found.tsx | app/not-found.tsx || API 错误 | try/catch + 状态码 | route.ts 内 || 单组件错误 | ErrorBoundary 包裹 | 任意 Client Component || 未预期错误 | 上报监控 + 兜底 UI | error.tsx + 监控 |
服务端阅读 06月5日 21:51

Next.js 缓存全解析:四层缓存机制和失效策略实战

Next.js 的缓存机制是它的性能杀手锏,也是最容易让人困惑的部分。一个 fetch 请求可能被缓存 5 秒也可能永久缓存,取决于一行配置。搞不清缓存层级,就会出现"数据改了但页面没更新"的诡异问题。四层缓存,各有各的失效机制请求进来 → 请求记忆(同一渲染周期内的去重) → 数据缓存(fetch 结果持久化) → 路由缓存(Router Cache,客户端缓存) → 完整路由缓存(构建时静态化)从上到下,缓存粒度从细到粗。任何一层命中都不会继续往下查。第一层:请求记忆(Request Memoization)同一个 Server Component 渲染周期内,多次 fetch 同一个 URL 只会真正发一次请求:// 这两个 fetch 在同一个渲染周期只会发一次网络请求async function Layout() { const user = await fetch('/api/user') // 真正请求 // ...}async function Page() { const user = await fetch('/api/user') // 命中记忆,不请求 // ...}这是 React 的自动去重机制,不需要你做任何配置。只在 Server Component 的单次渲染内有效——渲染完成后记忆清除,下次请求重新获取。第二层:数据缓存(Data Cache)这是最容易出问题的缓存层。Next.js 扩展了 fetch API,默认行为是永久缓存:// 默认:永久缓存(等效 force-cache)const data = await fetch('https://api.example.com/data')// 和上面等价const data = await fetch('https://api.example.com/data', { cache: 'force-cache' })是的,你没看错——一次 fetch 的结果会被永久缓存,除非你主动让它失效。控制缓存行为// 不缓存,每次都请求const data = await fetch('https://api.example.com/data', { cache: 'no-store' })// 缓存 60 秒后重新验证const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 }})三种策略对比| 策略 | 配置 | 行为 | 适用场景 ||------|------|------|---------|| 静态 | cache: 'force-cache' | 构建时获取,永久缓存 | 不变的数据(配置、字典) || 动态 | cache: 'no-store' | 每次请求都获取 | 实时数据(用户信息、支付) || ISR | next: { revalidate: 60 } | 缓存 60 秒,到期后台重新验证 | 经常更新但不需实时的数据 |ISR 的工作原理revalidate: 60 不是到期立刻更新——而是到期后第一个请求仍然返回缓存旧数据,同时后台触发重新验证。下一个请求就能拿到新数据。这叫 stale-while-revalidate,用户永远不会等到过期数据的重新获取。按路由段配置 revalidate不想在每个 fetch 里写 revalidate?在页面级统一设置:// app/products/page.tsxexport const revalidate = 3600 // 整个页面 1 小时重新验证export default async function ProductsPage() { const products = await fetch('https://api.example.com/products') // ...}页面级 revalidate 会覆盖 fetch 级别的设置。手动失效缓存import { revalidatePath, revalidateTag } from 'next/cache'// 失效整个路由revalidatePath('/products')// 按 tag 失效(更精确)const data = await fetch('https://api.example.com/products', { next: { tags: ['products'] }})// 数据变更时清除 tagasync function updateProduct() { await db.updateProduct(...) revalidateTag('products') // 所有带 products tag 的缓存失效}revalidateTag 比 revalidatePath 更灵活——一个 tag 可以跨多个页面和组件,清除一次全部失效。第三层:路由缓存(Router Cache)这是客户端缓存,存在浏览器内存中。用户在页面间导航时,Next.js 会缓存已访问路由的 RSC Payload,返回时不需要重新请求服务端。// 在 layout.tsx 中配置export const experimental = { staleTimes: { dynamic: 30, // 动态路由缓存 30 秒 static: 300, // 静态路由缓存 5 分钟 },}路由缓存的失效用户刷新页面 → 缓存失效调用 router.refresh() → 缓存失效revalidatePath / revalidateTag → 对应缓存失效常见问题:用户在 A 页面修改了数据,切到 B 页面,再切回 A 页面——看到的还是旧数据。这就是路由缓存在作怪。解决:修改数据后调用 router.refresh()。'use client'import { useRouter } from 'next/navigation'function UpdateButton() { const router = useRouter() async function handleClick() { await updateData() router.refresh() // 刷新当前路由,清除客户端缓存 } return <button onClick={handleClick}>更新</button>}第四层:完整路由缓存(Full Route Cache)构建时,Next.js 会把静态路由的 RSC Payload 和 HTML 都生成好,部署后直接从 CDN 返回——根本不走 Node.js 服务器。// 默认行为:构建时静态生成export default async function Page() { const data = await fetch('https://api.example.com/static-data') // force-cache return <div>{data}</div>}什么时候路由变成动态的以下任一条件满足,路由就不会被静态生成:fetch 使用了 cache: 'no-store'fetch 使用了 next: { revalidate: 0 }使用了 cookies()、headers() 等动态 API页面导出了 export const dynamic = 'force-dynamic'// 强制动态渲染export const dynamic = 'force-dynamic'Server Actions 与缓存失效Server Actions 是触发缓存失效的最佳位置——数据变更和缓存失效在同一处代码:// app/products/actions.ts'use server'import { revalidateTag } from 'next/cache'export async function createProduct(formData: FormData) { await db.product.create({ data: { name: formData.get('name') } }) revalidateTag('products') // 创建后立刻失效}调试缓存开发模式和生产模式的缓存行为完全不同——开发模式下几乎所有缓存都被禁用。缓存问题只在生产环境中复现。# 生产构建npm run build && npm start# 查看每个路由的渲染策略# 构建输出会显示:# ○ /products (静态)# ● /products/[id] (动态)# ƒ /api/products (动态)符号含义:○ 静态生成,● 服务端渲染,ƒ 动态路由。缓存策略速查| 数据特征 | fetch 配置 | 路由类型 ||---------|-----------|---------|| 不变数据 | cache: 'force-cache' | 静态 || 偶尔更新 | next: { revalidate: 3600, tags: ['xxx'] } | ISR || 频繁更新 | next: { revalidate: 60 } | ISR || 实时数据 | cache: 'no-store' | 动态 || 用户相关 | cookies() + cache: 'no-store' | 动态 || 问题 | 原因 | 解决 ||------|------|------|| 数据更新了页面没变 | 数据缓存未失效 | revalidateTag / revalidatePath || 返回上一页看到旧数据 | 路由缓存 | router.refresh() || 开发环境正常生产不对 | 开发模式禁用缓存 | 生产构建调试 || fetch 没有执行 | 请求记忆去重 | 正常行为,同一周期只请求一次 |
服务端阅读 06月5日 21:49

Next.js App Router vs Pages Router:核心区别和渐进式迁移指南

Next.js 13 引入的 App Router 不是 Pages Router 的替代品——它是一套全新的架构,基于 React Server Components,改变了数据获取、渲染和路由的整个思维方式。迁移不是改个目录名那么简单。核心区别一表看懂| 维度 | Pages Router | App Router ||------|-------------|------------|| 目录 | pages/ | app/ || 路由文件 | pages/about.js | app/about/page.tsx || API 路由 | pages/api/users.js | app/api/users/route.ts || 布局 | _app.js + 唯一 layout | 嵌套 layout(每个目录可以有) || 数据获取 | getServerSideProps / getStaticProps | 组件内直接 async/await || 渲染模型 | 客户端组件为主 | 服务端组件为主(RSC) || 状态 | 天然客户端 | 服务端组件无状态 |数据获取:最大的变化Pages Router 方式// pages/users.tsxexport async function getServerSideProps() { const users = await fetchUsers() return { props: { users } }}export default function UsersPage({ users }) { return <UserList users={users} />}每个页面需要单独的 getServerSideProps 或 getStaticProps 函数,数据只能在页面级获取,子组件不能自己获取。App Router 方式// app/users/page.tsxexport default async function UsersPage() { const users = await fetchUsers() // 直接在组件里 await return <UserList users={users} />}不需要特殊的 getXXXProps 函数——组件本身就是 async 函数,直接 await 获取数据。子组件也能自己获取:// components/user-stats.tsxexport default async function UserStats() { const stats = await fetchStats() // 子组件自己获取数据 return <div>活跃用户:{stats.active}</div>}这是 RSC 的核心优势:组件级数据获取,不再需要在页面顶层把所有数据攒齐再一层层传 props。布局系统:从全局到嵌套Pages Router:一个布局管所有页面// pages/_app.tsx - 全局唯一的布局export default function App({ Component, pageProps }) { return ( <Layout> <Component {...pageProps} /> </Layout> )}所有页面共享同一个 _app.js。想做"某些页面有侧边栏,某些没有"很别扭——要靠条件判断或把布局塞进每个页面。App Router:嵌套布局,每个目录可以有app/ layout.tsx # 根布局(必须有 html + body) page.tsx # 首页 dashboard/ layout.tsx # dashboard 专属布局(侧边栏) page.tsx # dashboard 首页 settings/ page.tsx # settings 页(继承 dashboard 布局)// app/dashboard/layout.tsxexport default function DashboardLayout({ children }) { return ( <div className="flex"> <Sidebar /> <main className="flex-1">{children}</main> </div> )}/dashboard/settings 会渲染三层布局:根 layout → dashboard layout → settings page。切换 settings 页面时,dashboard layout 不会重新渲染——侧边栏状态保留。这是 App Router 布局的核心:嵌套且持久化。Pages Router 做不到这一点——每次路由切换都重新挂载整个页面。Server Components vs Client Components这是迁移时最容易搞混的概念:Server Components(默认)// app/page.tsx - 自动是 Server Componentexport default async function Page() { const data = await fetch('https://api.example.com/data') return <div>{data.title}</div>}在服务端渲染,不会发送 JS 到客户端可以直接访问数据库、文件系统不能用 useState、useEffect、浏览器 API不能绑定事件处理函数(onClick 等)Client Components(需要显式声明)'use client' // 这行声明是 Client Componentimport { useState } from 'react'export default function Counter() { const [count, setCount] = useState(0) return <button onClick={() => setCount(count + 1)}>{count}</button>}在客户端渲染(可以 hydration)可以用所有 React Hooks可以绑定事件、使用浏览器 API不能直接 await 异步数据选择原则| 需要 | 选择 ||------|------|| 获取数据 | Server Component || 访问后端资源 | Server Component || useState/useEffect | Client Component || 事件处理(onClick) | Client Component || 浏览器 API(window) | Client Component |默认用 Server Component,只在需要交互时才用 Client Component。 这和 Pages Router 的思维完全相反——Pages Router 默认全是客户端组件。API 路由的变化Pages Router// pages/api/users.tsimport type { NextApiRequest, NextApiResponse } from 'next'export default function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === 'GET') { res.status(200).json({ users: [] }) }}App Router// app/api/users/route.tsimport { NextResponse } from 'next/server'export async function GET() { const users = await fetchUsers() return NextResponse.json({ users })}export async function POST(request: Request) { const body = await request.json() const user = await createUser(body) return NextResponse.json(user, { status: 201 })}变化:从单个 handler 函数 + if (req.method) 拆成独立的方法函数。每个 HTTP 方法一个导出函数,更清晰。迁移策略不要一次性全迁移——两个 Router 可以共存:1. 渐进式迁移pages/ old-page.tsx # 还在用 Pages Routerapp/ new-page/ page.tsx # 新页面用 App RouterNext.js 同时支持两种 Router。新页面用 App Router,旧页面保持不动,逐步迁移。2. 迁移优先级API 路由:最简单,改动最小纯展示页面:不需要交互,直接改成 Server Component有交互的页面:拆成 Server + Client 组件混合3. 常见迁移坑useRouter 不一样了:Pages Router 用 next/router,App Router 用 next/navigationgetServerSideProps 变成组件内 async:不再需要序列化/反序列化 props_app.js 里的 Provider:移到 app/layout.tsx,但要包在 Client Component 里图片组件:next/image API 有变化,注意 fill 属性替代了 layout="fill"什么时候还该用 Pages Router不是所有项目都需要迁移:老项目稳定运行:没有性能问题,不需要 RSC,不必迁移团队不熟悉 RSC:App Router 的思维模式完全不同,迁移成本不只是改代码重度依赖客户端状态:如果你的页面几乎全是客户端交互,RSC 的优势体现不出来App Router 的核心价值是服务端渲染优先 + 嵌套布局。如果你的项目确实受益于这两点,值得迁移;否则 Pages Router 继续用就好——Next.js 没有废弃它的计划。
服务端阅读 06月5日 21:48

Next.js App Router 国际化实战:路由、翻译、SEO 和语言切换

多语言网站不是翻译几个字符串就完事——路由怎么设计、翻译文件怎么组织、SEO 的 hreflang 怎么配、切换语言时状态怎么保持,每个环节都有坑。Next.js App Router 提供了灵活的路由机制,但国际化方案需要自己搭。这篇给出一个完整的实战方案。路由结构设计App Router 推荐用动态路由段 [lang] 承载语言前缀:app/ [lang]/ layout.tsx # 语言布局(加载翻译、设置 lang 属性) page.tsx # 首页 about/ page.tsx # 关于页 layout.tsx # 根布局URL 效果:/zh/about、/en/about、/ja/aboutlayout.tsx 加载翻译并设置语言// app/[lang]/layout.tsximport { notFound } from 'next/navigation'import { dictionaries } from '@/lib/dictionaries'export const supportedLocales = ['zh', 'en', 'ja'] as constexport type Locale = typeof supportedLocales[number]export function generateStaticParams() { return supportedLocales.map((lang) => ({ lang }))}export default async function LangLayout({ children, params: { lang },}: { children: React.ReactNode params: { lang: string }}) { if (!supportedLocales.includes(lang as Locale)) { notFound() } return ( <html lang={lang}> <body>{children}</body> </html> )}generateStaticParams 让 Next.js 在构建时为每种语言生成静态页面。notFound() 处理非法语言前缀。翻译文件组织按语言分文件dictionaries/ zh.json en.json ja.json// dictionaries/zh.json{ "nav": { "home": "首页", "about": "关于", "contact": "联系我们" }, "home": { "title": "欢迎使用我们的产品", "description": "一站式解决方案" }}加载翻译的工具函数// lib/dictionaries.tsconst dictionaries = { zh: () => import('@/dictionaries/zh.json').then((m) => m.default), en: () => import('@/dictionaries/en.json').then((m) => m.default), ja: () => import('@/dictionaries/ja.json').then((m) => m.default),}export type Dictionary = Awaited<ReturnType<typeof dictionaries.zh>>export async function getDictionary(lang: string): Promise<Dictionary> { if (!(lang in dictionaries)) { return dictionaries.zh() // 回退到默认语言 } return dictionaries[lang]()}用动态 import() 按需加载——用户访问 /en/about 时只加载 en.json,不会把所有语言的翻译都打到一个包里。在 Server Component 中使用// app/[lang]/page.tsximport { getDictionary } from '@/lib/dictionaries'export default async function Home({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return ( <main> <h1>{t.home.title}</h1> <p>{t.home.description}</p> </main> )}Server Component 里直接用 await,不需要状态管理。翻译在服务端完成,客户端拿到的就是渲染好的 HTML。语言切换组件// components/lang-switcher.tsx'use client'import { usePathname, useRouter } from 'next/navigation'import { supportedLocales, type Locale } from '@/app/[lang]/layout'const localeNames: Record<Locale, string> = { zh: '中文', en: 'English', ja: '日本語',}export function LangSwitcher({ currentLang }: { currentLang: Locale }) { const pathname = usePathname() const router = useRouter() function switchLang(newLang: Locale) { // 替换 URL 中的语言段 const segments = pathname.split('/') segments[1] = newLang router.push(segments.join('/')) } return ( <select value={currentLang} onChange={(e) => switchLang(e.target.value as Locale)}> {supportedLocales.map((locale) => ( <option key={locale} value={locale}>{localeNames[locale]}</option> ))} </select> )}切换语言就是替换 URL 的第一段——/zh/about 变 /en/about。Next.js 会重新加载对应语言的页面。SEO:hreflang 标签搜索引擎需要知道不同语言版本的对应关系:// app/[lang]/layout.tsx 中添加 metadataexport async function generateMetadata({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return { title: t.home.title, alternates: { canonical: `https://example.com/${lang}`, languages: { 'zh': 'https://example.com/zh', 'en': 'https://example.com/en', 'ja': 'https://example.com/ja', 'x-default': 'https://example.com/en', // 默认语言 }, }, }}x-default 告诉搜索引擎:无法匹配用户语言时,显示这个版本。通常选英语或主要目标语言。中间件:自动重定向到用户首选语言// middleware.tsimport { NextRequest, NextResponse } from 'next/server'import { supportedLocales } from '@/app/[lang]/layout'function getLocale(request: NextRequest): string { const acceptLanguage = request.headers.get('accept-language') if (!acceptLanguage) return 'zh' // 解析 Accept-Language 头,匹配支持的语言 const preferred = acceptLanguage .split(',') .map((lang) => lang.split(';')[0].trim().substring(0, 2).toLowerCase()) return preferred.find((lang) => supportedLocales.includes(lang as any)) || 'zh'}export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // 已有语言前缀,不处理 if (supportedLocales.some((locale) => pathname.startsWith(`/${locale}`))) { return } // 根目录访问,重定向到首选语言 const locale = getLocale(request) request.nextUrl.pathname = `/${locale}${pathname}` return NextResponse.redirect(request.nextUrl)}export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'],}用户首次访问 example.com/about 时,中间件检测到 URL 没有语言前缀,根据浏览器 Accept-Language 重定向到 /en/about 或 /zh/about。Client Component 中使用翻译Client Component 不能直接 await 翻译,需要通过 props 传入或用 Context:// providers/dictionary-provider.tsx'use client'import { createContext, useContext } from 'react'import type { Dictionary } from '@/lib/dictionaries'const DictionaryContext = createContext<Dictionary | null>(null)export function DictionaryProvider({ dictionary, children,}: { dictionary: Dictionary children: React.ReactNode}) { return ( <DictionaryContext.Provider value={dictionary}> {children} </DictionaryContext.Provider> )}export function useDictionary() { const dictionary = useContext(DictionaryContext) if (!dictionary) throw new Error('useDictionary must be used within DictionaryProvider') return dictionary}在 layout 里提供:// app/[lang]/layout.tsximport { DictionaryProvider } from '@/providers/dictionary-provider'export default async function LangLayout({ children, params: { lang } }) { const dictionary = await getDictionary(lang) return ( <DictionaryProvider dictionary={dictionary}> {children} </DictionaryProvider> )}Client Component 里直接 useDictionary() 取翻译,不需要每次 prop drilling。翻译中的插值和复数纯 JSON 翻译文件不支持插值,需要一个小工具函数:// lib/i18n.tsexport function interpolate(template: string, params: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`))}// 复数处理(简单版)export function pluralize(count: number, singular: string, plural: string): string { return count === 1 ? singular : plural}{ "cart": { "items": "购物车中有 {count} 件商品", "item_single": "1 件商品", "item_plural": "{count} 件商品" }}const text = interpolate(t.cart.items, { count: 3 }) // "购物车中有 3 件商品"复杂的复数规则(阿拉伯语、俄语等)建议用 intl-messageformat 库。常见问题翻译键找不到怎么办开发时加一个 fallback 机制:export function t(dictionary: Dictionary, path: string): string { const keys = path.split('.') let result: any = dictionary for (const key of keys) { result = result?.[key] } return result ?? path // 找不到翻译时返回键名,而不是报错}翻译文件太大怎么办按页面拆分翻译文件,按需加载:dictionaries/ zh/ common.json home.json about.json en/ common.json home.json about.jsonSEO 的 hreflang 和 canonical 同时存在冲突吗不冲突。canonical 指向当前语言版本的规范 URL,hreflang 指向其他语言版本。两者配合告诉搜索引擎:这些 URL 是同一个内容的不同语言版本。完整方案清单| 检查项 | 配置 ||--------|------|| 路由结构 | app/[lang]/ 动态路由 || 翻译加载 | 动态 import() 按需加载 || 语言切换 | 替换 URL 语言段 || 自动重定向 | middleware 检测 Accept-Language || SEO | generateMetadata 配置 hreflang + canonical || Client Component | DictionaryProvider + useDictionary || 非法语言 | notFound() 处理 || 翻译插值 | interpolate 工具函数 |
服务端阅读 06月5日 14:00

Next.js测试策略:服务端组件、API路由、Server Actions和E2E

Next.js 应用的测试比纯 React 复杂——它有服务端组件、客户端组件、API 路由、Server Actions、还有 SSR/SSG 渲染模式。照搬 React 的测试方法会踩很多坑。这篇文章按组件类型分类,把每类该测什么、用什么工具、怎么 Mock 讲清楚。测试工具选择| 工具 | 用途 | 替代方案 ||------|------|----------|| Jest + React Testing Library | 组件单元测试 | Vitest(更快,配置类似) || MSW (Mock Service Worker) | Mock API 请求 | nock(只 Node 环境) || Playwright | E2E 测试 | Cypress || @testing-library/user-event | 模拟用户交互 | fireEvent(不如 user-event 贴近真实) |Next.js 项目推荐用 Vitest 代替 Jest——Vite 生态集成更好,速度快 3-5 倍:npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-eventVitest 配置// vitest.config.tsimport { defineConfig } from 'vitest/config';import react from '@vitejs/plugin-react';import path from 'path';export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['**/*.{test,spec}.{js,jsx,ts,tsx}'], }, resolve: { alias: { '@': path.resolve(__dirname, './src'), }, },});// vitest.setup.tsimport '@testing-library/jest-dom';客户端组件测试客户端组件(文件顶部有 'use client')的测试和普通 React 组件一样:// components/Counter.tsx'use client';import { useState } from 'react';export function Counter() { const [count, setCount] = useState(0); return ( <div> <span data-testid="count">{count}</span> <button onClick={() => setCount(c => c + 1)}>+1</button> </div> );}// components/__tests__/Counter.test.tsximport { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { Counter } from '../Counter';describe('Counter', () => { it('increments count on click', async () => { const user = userEvent.setup(); render(<Counter />); expect(screen.getByTestId('count')).toHaveTextContent('0'); await user.click(screen.getByRole('button', { name: '+1' })); expect(screen.getByTestId('count')).toHaveTextContent('1'); });});Mock hooks测试依赖 useSearchParams 等 Next.js hooks 的组件:// Mock next/navigationvi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams('tab=settings'), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => '/dashboard',}));Next.js 的 hooks 在测试环境里不工作——它们依赖 Next.js 的路由上下文。Mock 是唯一的方式。服务端组件测试服务端组件(默认,没有 'use client')不能直接用 React Testing Library 渲染——它们是异步函数,返回的不是标准 JSX。方案一:抽取逻辑为纯函数最佳做法——把业务逻辑从服务端组件里抽出来,单独测试:// lib/filterProducts.tsexport function filterProducts(products: Product[], category: string) { return products.filter(p => p.category === category);}// lib/__tests__/filterProducts.test.tsimport { filterProducts } from '../filterProducts';describe('filterProducts', () => { it('filters by category', () => { const products = [ { id: 1, name: 'Widget', category: 'tools' }, { id: 2, name: 'Book', category: 'books' }, ]; expect(filterProducts(products, 'tools')).toHaveLength(1); });});服务端组件本身只是数据获取 + 渲染的组合,逻辑都在纯函数里——纯函数好测、快、不依赖任何框架。方案二:测试渲染输出如果必须测服务端组件的渲染结果,用 renderToString:import { renderToString } from 'react-dom/server';import { ProductList } from '../components/ProductList';it('renders product names', async () => { const products = [{ id: 1, name: 'Widget' }]; const html = renderToString(await ProductList({ products })); expect(html).toContain('Widget');});这种方式比较粗糙——只能断言 HTML 字符串包含什么,不能用 screen.getByRole 等 DOM 查询 API。适合快速验证组件不会报错、渲染了关键内容。API 路由测试Next.js App Router 的 API 路由是 route.ts 文件,导出 GET、POST 等函数。测试时直接调用这些函数:// app/api/users/route.tsimport { NextResponse } from 'next/server';export async function GET(request: Request) { const { searchParams } = new URL(request.url); const page = searchParams.get('page') || '1'; const users = await fetchUsers(Number(page)); return NextResponse.json({ users, page: Number(page) });}// __tests__/api/users.test.tsimport { GET } from '@/app/api/users/route';// Mock 数据获取vi.mock('@/lib/data', () => ({ fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),}));describe('GET /api/users', () => { it('returns users with page number', async () => { const request = new Request('http://localhost/api/users?page=2'); const response = await GET(request); const data = await response.json(); expect(response.status).toBe(200); expect(data.page).toBe(2); expect(data.users).toHaveLength(1); });});直接构造 Request 对象传入——不需要启动服务器,测试跑在 Node 环境里,速度极快。Mock 外部 APIAPI 路由通常要调外部服务。用 MSW 拦截请求:import { setupServer } from 'msw/node';import { http, HttpResponse } from 'msw';const server = setupServer( http.get('https://api.example.com/users', () => { return HttpResponse.json([{ id: 1, name: 'Mocked User' }]); }),);beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());MSW 在 Node 层面拦截 HTTP 请求——不需要改业务代码,测试完自动恢复。Server Actions 测试Server Actions 是 Next.js 13+ 的服务端函数,在客户端通过 useServer 调用。测试方式和 API 路由类似——直接调用函数:// app/actions/createPost.ts'use server';export async function createPost(formData: FormData) { const title = formData.get('title') as string; if (!title || title.length < 3) { return { error: '标题至少 3 个字符' }; } await db.post.create({ data: { title } }); return { success: true };}import { createPost } from '@/app/actions/createPost';vi.mock('@/lib/db', () => ({ db: { post: { create: vi.fn().mockResolvedValue({ id: 1 }) } },}));describe('createPost', () => { it('rejects short titles', async () => { const formData = new FormData(); formData.set('title', 'ab'); const result = await createPost(formData); expect(result.error).toBeDefined(); }); it('creates post with valid title', async () => { const formData = new FormData(); formData.set('title', 'My Post'); const result = await createPost(formData); expect(result.success).toBe(true); });});E2E 测试(Playwright)单元测试验证组件逻辑,E2E 测试验证完整用户流程——从打开页面到完成操作。基础配置// playwright.config.tsimport { defineConfig } from '@playwright/test';export default defineConfig({ testDir: './e2e', baseURL: 'http://localhost:3000', use: { locale: 'zh-CN', },});页面测试// e2e/home.spec.tsimport { test, expect } from '@playwright/test';test('homepage shows products', async ({ page }) => { await page.goto('/'); await expect(page.getByText('热门商品')).toBeVisible(); await expect(page.getByTestId('product-card')).toHaveCount(10);});test('can add product to cart', async ({ page }) => { await page.goto('/'); await page.getByTestId('product-card').first().getByRole('button', { name: '加入购物车' }).click(); await expect(page.getByTestId('cart-count')).toHaveText('1');});E2E 测试需要 Next.js 服务器在跑。Playwright 的 webServer 配置可以自动启动:export default defineConfig({ webServer: { command: 'npm run dev', port: 3000, reuseExistingServer: !process.env.CI, },});测试策略总结| 层级 | 测试什么 | 工具 | 占比 ||------|----------|------|------|| 纯函数/工具 | 业务逻辑、数据转换 | Vitest | 40% || 客户端组件 | 交互、状态、渲染 | RTL + Vitest | 25% || API 路由/Actions | 请求处理、验证、错误 | Vitest + MSW | 20% || E2E | 关键用户流程 | Playwright | 15% |服务端组件不直接测——逻辑抽到纯函数,渲染验证留给 E2E。这样 85% 的测试跑在 Vitest 里(< 100ms/个),只有 15% 需要启动浏览器。
服务端阅读 06月2日 23:16

Next.js App Router 怎么定义路由?动态路由、布局嵌套和 loading 详解

Next.js App Router 用文件系统定义路由——文件夹结构就是 URL 结构。每个路由三要素:page.tsx(页面)、layout.tsx(布局)、loading.tsx(加载状态)。基本路由映射app/├── page.tsx → /├── about/│ └── page.tsx → /about├── blog/│ ├── page.tsx → /blog│ └── [slug]/│ └── page.tsx → /blog/:slug(动态路由)├── dashboard/│ ├── layout.tsx → dashboard 共享布局│ ├── page.tsx → /dashboard│ └── settings/│ └── page.tsx → /dashboard/settingspage.tsx 是必须的——没有 page.tsx 的文件夹不构成路由。其他文件(layout、loading、error)都是可选的。动态路由// app/blog/[slug]/page.tsxexport default function BlogPost({ params }: { params: { slug: string } }) { return <h1>文章: {params.slug}</h1>;}访问 /blog/hello-world 时,params.slug = "hello-world"。Catch-all 路由用 [...slug]:匹配 /shop/clothes/shirts 这样的多级路径,params.slug = ["clothes", "shirts"]。路由组用 (groupName) 创建不反映在 URL 中的分组:app/├── (marketing)/│ ├── about/page.tsx → /about(URL 里没有 marketing)│ └── pricing/page.tsx → /pricing├── (shop)/│ ├── products/page.tsx → /products│ └── cart/page.tsx → /cart路由组的用途:给一组页面共享 layout 而不影响 URL。比如 (marketing) 组用营销页布局,(shop) 组用商店布局。布局嵌套layout.tsx 会嵌套——子路由的 layout 包在父 layout 里面:// app/layout.tsx — 根布局(必有,包含 html/body)export default function RootLayout({ children }) { return ( <html> <body> <nav>全局导航</nav> {children} </body> </html> );}// app/dashboard/layout.tsx — dashboard 布局export default function DashboardLayout({ children }) { return ( <div className="flex"> <aside>侧边栏</aside> <main>{children}</main> </div> );}从 /dashboard 切换到 /dashboard/settings 时,根布局和 dashboard 布局都不会重新渲染。只有 children 对应的 page.tsx 更新。loading.tsx:自动 Suspense// app/dashboard/loading.tsxexport default function Loading() { return <div className="animate-pulse">加载中...</div>;}Next.js 自动用 Suspense 包裹 page.tsx,加载时显示 loading.tsx。不需要手写 useState 管理加载状态。error.tsx:错误边界// app/error.tsx — 必须是 Client Component'use client'export default function Error({ error, reset }) { return ( <div> <p>出错了: {error.message}</p> <button onClick={reset}>重试</button> </div> );}error.tsx 捕获子组件的运行时错误,显示错误界面。reset 函数重新渲染出错的组件。程序化导航'use client'import { useRouter } from 'next/navigation';function LoginButton() { const router = useRouter(); return <button onClick={() => router.push('/dashboard')}>登录</button>;}router.push() 客户端跳转,router.replace() 替换当前历史记录(不能回退),router.back() 返回上一页。Server Component 里用 redirect():import { redirect } from 'next/navigation';export default async function Page() { const session = await getSession(); if (!session) redirect('/login'); // ...}redirect 在服务端执行,用户看不到中间页面。
服务端阅读 06月2日 23:15

Next.js SSR、SSG 和 ISR 有什么区别?怎么选?

SSR、SSG、ISR 是三种不同的页面渲染策略,区别在于 HTML 什么时候生成。选哪个取决于数据的更新频率和页面的实时性要求。三种策略对比| 策略 | HTML 生成时机 | 适合场景 | 性能 ||------|-------------|----------|------|| SSG | 构建时 | 博客、文档、营销页 | 最快(CDN 缓存) || SSR | 每次请求时 | 仪表盘、个人主页 | 中等(服务端计算) || ISR | 构建时 + 定时更新 | 商品列表、新闻 | 接近 SSG 的速度 |SSG(Static Site Generation)构建时生成 HTML,部署后不变化。速度最快——CDN 直接返回静态文件,零服务端计算。// App Router 默认就是 SSG// 没有 dynamic 数据获取的页面自动静态生成export default function AboutPage() { return <h1>关于我们</h1>;}// 带数据的 SSG:构建时获取async function BlogList() { const posts = await db.post.findMany(); // 构建时执行 return posts.map(p => <article key={p.id}>{p.title}</article>);}局限:数据变化后需要重新构建部署。适合不常变的内容。SSR(Server-Side Rendering)每次请求时生成 HTML。数据始终最新,但每次请求都有服务端计算开销。// App Router: 使用动态数据获取自动触发 SSRasync function Dashboard() { const stats = await fetch('https://api.example.com/stats', { cache: 'no-store' // 不缓存,每次请求重新获取 }).then(r => r.json()); return <div>{stats.users} 用户</div>;}cache: 'no-store' 告诉 Next.js 这个请求不能缓存,必须每次执行。适合实时数据。ISR(Incremental Static Regeneration)SSG 的升级版——静态生成 HTML,但后台定时重新生成。兼具 SSG 的速度和数据的新鲜度。async function ProductList() { const products = await fetch('https://api.example.com/products', { next: { revalidate: 3600 } // 每 3600 秒(1 小时)重新验证 }).then(r => r.json()); return products.map(p => <div key={p.id}>{p.name}</div>);}ISR 的工作流程:第一个用户请求 → 返回缓存的静态 HTML(快)后台检查 revalidate 时间是否到期到期后重新生成 HTML,替换旧缓存下一个用户请求 → 返回新生成的 HTML关键点:用户永远看到的是缓存的页面(快),后台异步更新。最坏情况数据延迟 revalidate 秒。怎么选选 SSG:内容几乎不变(文档、博客、营销页)选 ISR:内容定期更新(商品列表、新闻、排行榜),能接受短暂延迟选 SSR:内容必须实时(仪表盘、用户个人页、搜索结果)常见错误:所有页面都用 SSR。大部分页面用 ISR 就够了——1-5 分钟的数据延迟用户感知不到,但性能提升显著。App Router 中的 revalidate 策略// 定时重新验证(ISR)fetch(url, { next: { revalidate: 60 } }); // 60 秒// 按需重新验证:修改数据后手动触发import { revalidateTag, revalidatePath } from 'next/cache';// 在 Server Action 里触发async function updateProduct(formData: FormData) { await db.product.update({ ... }); revalidateTag('products'); // 刷新所有 products 标记的缓存 revalidatePath('/products'); // 刷新 /products 页面}按需重新验证(On-Demand Revalidation)比定时更精准——数据变了立即刷新,没变就不浪费资源。
服务端阅读 06月2日 23:14

Next.js Pages Router 和 App Router 有什么区别?该不该迁移?

Pages Router 是 Next.js 的原始路由系统,App Router 是 13+ 引入的新系统。核心区别:App Router 基于 React Server Components,默认在服务端渲染;Pages Router 默认在客户端渲染。新项目用 App Router,老项目不急迁移。架构对比| 维度 | Pages Router | App Router ||------|-------------|------------|| 目录 | pages/ | app/ || 默认渲染 | 客户端 | 服务端(RSC) || 数据获取 | getServerSideProps / getStaticProps | async 组件 + fetch || 布局 | _app.tsx + 全局 Layout | 嵌套 layout.tsx || 路由 | 文件即路由 | 文件夹 + page.tsx || API | pages/api/ | app/ + route.ts || Loading | 手动管理 | loading.tsx 自动 |数据获取的变化Pages Router 用特殊函数获取数据:// Pages Routerexport async function getServerSideProps() { const data = await fetchData(); return { props: { data } };}export default function Page({ data }) { return <div>{data}</div>;}App Router 直接在组件里 async/await:// App Routerexport default async function Page() { const data = await fetchData(); // 服务端直接执行 return <div>{data}</div>;}App Router 的方式更直观——不需要记特殊函数名,数据获取就是普通的函数调用。布局系统Pages Router 的布局是全局的(_app.tsx),切换页面时整个布局重新渲染。App Router 支持嵌套布局——每个目录可以有 layout.tsx,子路由切换时父布局不重新渲染:app/├── layout.tsx # 根布局(导航栏、页脚)— 永远不重新渲染├── dashboard/│ ├── layout.tsx # 仪表盘布局(侧边栏)— 路由切换不重新渲染│ ├── page.tsx # /dashboard│ └── settings/│ └── page.tsx # /dashboard/settings用户从 /dashboard 切换到 /dashboard/settings 时,根布局和仪表盘布局都保持不变,只有 settings 的 page.tsx 重新渲染。这在 Pages Router 里做不到。何时迁移不急迁移的情况:项目稳定运行,没有性能问题团队不熟悉 RSC,迁移风险高大量自定义 _document.tsx / _app.tsx 逻辑应该迁移的情况:需要更好的首屏性能(RSC 显著减少 JS 体积)嵌套布局能解决当前的布局闪烁问题项目刚开始或处于早期迁移可以渐进式——两个路由可以共存。先把新页面用 App Router 写,老页面逐步迁移。两者可以共存pages/├── index.tsx # Pages Router 处理├── about.tsx # Pages Router 处理app/├── dashboard/│ └── page.tsx # App Router 处理Next.js 13+ 同时支持两套路由,同一个项目里渐进迁移。但要注意:同一路径不能两边都定义(pages/dashboard.tsx 和 app/dashboard/page.tsx 会冲突)。
服务端阅读 06月2日 23:13

Next.js 认证怎么做?NextAuth.js 配置 OAuth 和凭证登录实战

Next.js 认证最主流的方案是 NextAuth.js(v5 改名 Auth.js)。它处理了 OAuth、JWT、Session 管理等所有细节,30 分钟就能搭好 Google/GitHub 登录。最快上手:NextAuth.jsnpm install next-auth@beta// app/api/auth/[...nextauth]/route.tsimport NextAuth from 'next-auth';import GitHub from 'next-auth/providers/github';export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, }), ], pages: { signIn: '/login', // 自定义登录页 },});export const { GET, POST } = handlers;// middleware.ts — 保护路由export { auth as middleware } from './app/api/auth/[...nextauth]/route';export const config = { matcher: ['/dashboard/:path*'],};三步:配置 provider → 创建 API 路由 → 加中间件保护。用户访问 /dashboard 时如果没有登录,自动跳转到 /login。在 Server Component 里获取 Sessionimport { auth } from './app/api/auth/[...nextauth]/route';export default async function Dashboard() { const session = await auth(); if (!session) { redirect('/login'); } return <h1>欢迎, {session.user.name}</h1>;}auth() 在服务端获取 session,不需要客户端 JavaScript。这是 RSC 的优势——认证逻辑完全在服务端,客户端零开销。在 Client Component 里获取 Session'use client'import { useSession } from 'next-auth/react';export default function Profile() { const { data: session, status } = useSession(); if (status === 'loading') return <p>加载中...</p>; if (!session) return <p>请先登录</p>; return <p>{session.user.name}</p>;}useSession 通过轮询 /api/auth/session 获取 session。更高效的方式是用 SessionProvider:// app/providers.tsx'use client'import { SessionProvider } from 'next-auth/react';export function Providers({ children }) { return <SessionProvider>{children}</SessionProvider>;}包在 layout 里后,useSession 不再轮询,而是通过 Context 共享 session 数据。自定义登录页默认登录页太简陋。自定义页面:// app/login/page.tsximport { signIn } from '@/auth';export default function LoginPage() { return ( <form action={async () => { 'use server' await signIn('github'); }}> <button type="submit">用 GitHub 登录</button> </form> );}用 Server Action 触发 signIn,比客户端 signIn() 更简洁。凭证登录(用户名密码)OAuth 不够时,加 Credentials provider:import Credentials from 'next-auth/providers/credentials';import bcrypt from 'bcryptjs';export const { handlers, auth } = NextAuth({ providers: [ Credentials({ credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, }, async authorize(credentials) { const user = await db.user.findUnique({ where: { email: credentials.email }, }); if (!user) return null; const valid = await bcrypt.compare(credentials.password, user.passwordHash); if (!valid) return null; return { id: user.id, name: user.name, email: user.email }; }, }), ],});authorize 返回 null 表示认证失败,返回 user 对象表示成功。密码必须用 bcrypt 哈希,不要存明文。常见问题Session 过期后页面不刷新:用 SessionProvider 的 refetchInterval 定时刷新:<SessionProvider refetchInterval={300}> 每 5 分钟检查一次。OAuth 回调 404:确保回调 URL 在 OAuth provider 里配置正确。GitHub 在 Settings > Developer > OAuth Apps 里配。部署后 Cookie 不工作:NEXTAUTH_URL 环境变量必须设为生产域名。Vercel 部署时自动设置,其他平台需要手动配。
服务端阅读 06月2日 23:11

React Server Components 是什么?和 Client Components 怎么配合?

React Server Components(RSC)是只在服务端渲染的组件——它们的代码不会发送到浏览器。这是 React 架构的根本变化:组件不再默认跑在客户端,而是默认跑在服务端。RSC 解决什么问题传统 React 应用把所有组件代码打包成 JS 发给浏览器。一个列表页可能有 200KB 的 JS,但大部分是数据获取和渲染逻辑,用户交互只占一小部分。用户要等 JS 下载、解析、执行完才能看到页面。RSC 的解决方案:数据获取和渲染在服务端完成,只把 HTML 和少量交互代码发给浏览器。结果:更快的首屏、更小的 JS 包、更简单的数据获取。Server Component vs Client Component// Server Component(默认)— 不发 JS 给浏览器async function ArticleList() { const articles = await db.article.findMany(); // 直接查数据库 return ( <ul> {articles.map(a => <li key={a.id}>{a.title}</li>)} </ul> );}// Client Component — JS 会发给浏览器'use client'import { useState } from 'react';function SearchBox() { const [query, setQuery] = useState(''); // 需要状态 return <input value={query} onChange={e => setQuery(e.target.value)} />;}判断标准很简单:需要 useState、useEffect、onClick 等 React hooks/事件的就是 Client Component,否则就是 Server Component。数据获取方式的变化Pages Router 时代,客户端获取数据:// Pages Router — 客户端获取useEffect(() => { fetch('/api/articles').then(r => r.json()).then(setArticles);}, []);App Router + RSC,服务端直接获取:// App Router — 服务端获取async function Page() { const articles = await db.article.findMany(); return <ArticleList articles={articles} />;}不需要 API 路由,不需要 loading 状态管理,不需要客户端缓存。服务端拿到数据直接渲染成 HTML。组合模式Server Component 可以渲染 Client Component,但反过来不行:// Server Componentimport SearchBox from './SearchBox'; // Client Componentasync function Page() { const data = await fetchData(); return ( <div> <SearchBox /> {/* Client Component:交互 */} <ArticleList data={data} /> {/* Server Component:展示 */} </div> );}关键规则:Server Component 可以 import 和渲染 Client ComponentClient Component 不能 import Server ComponentServer Component 可以通过 props 把数据传给 Client Component(必须是可序列化的数据)什么时候用 Client Component只有这四种情况需要 'use client':需要交互(onClick、onChange)需要状态(useState、useReducer)需要生命周期(useEffect)需要浏览器 API(window、localStorage)其他都用 Server Component。一个常见错误:因为不熟悉 RSC 而给所有组件加 'use client'——这样 App Router 就退化成了 Pages Router,失去了 RSC 的性能优势。RSC 的局限Server Component 不能用 hooks(useState、useEffect)Server Component 不能用浏览器 API(window、document)Server Component 传给 Client Component 的 props 必须可序列化(不能传函数、类实例)调试更难——错误堆栈跨服务端和客户端RSC 仍然在快速演进,API 可能在未来版本变化。但方向是明确的:服务端渲染更多,客户端 JS 更少。
服务端阅读 06月2日 23:10

Next.js Server Actions 怎么用?表单提交、状态管理和安全验证

Server Actions 是 Next.js 的服务端函数——在服务端执行,客户端直接调用,不需要手写 API 路由。底层是 POST 请求 + 加密签名,比传统 fetch + API Route 更简洁。基本用法// app/actions.ts'use server'export async function createPost(formData: FormData) { const title = formData.get('title') as string; const content = formData.get('content') as string; await db.post.create({ data: { title, content } }); revalidatePath('/posts'); // 刷新缓存}// app/posts/page.tsximport { createPost } from './actions';export default function NewPost() { return ( <form action={createPost}> <input name="title" /> <textarea name="content" /> <button type="submit">发布</button> </form> );}没有 API 路由,没有 fetch,没有 JSON 序列化。表单提交直接触发服务端函数。Server Action 的本质Server Action 编译后变成一个 POST 请求:POST /_next/data/... HTTP/1.1Content-Type: text/x-componentNext-Action: hashed-action-idFormData bodyNext.js 自动给 action 生成加密 ID,客户端调用时带这个 ID。请求到达服务端后,Next.js 根据 ID 找到对应函数执行。所以即使有人猜到 URL,没有正确的 action ID 也无法调用。useActionState 管理状态'use client'import { useActionState } from 'react';import { createPost } from './actions';export default function NewPost() { const [state, formAction, isPending] = useActionState( async (prevState, formData) => { const result = await createPost(formData); return result; }, null ); return ( <form action={formAction}> <input name="title" /> <button type="submit" disabled={isPending}> {isPending ? '提交中...' : '发布'} </button> {state?.error && <p>{state.error}</p>} </form> );}isPending 在请求期间为 true,可以显示加载状态。state 保存上一次 action 的返回值。输入验证永远不要信任客户端数据。Server Action 里必须验证:'use server'import { z } from 'zod';const schema = z.object({ title: z.string().min(1).max(200), content: z.string().min(10),});export async function createPost(formData: FormData) { const parsed = schema.safeParse({ title: formData.get('title'), content: formData.get('content'), }); if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors }; } await db.post.create({ data: parsed.data }); revalidatePath('/posts');}Zod 验证比手写 if-else 更可靠,错误信息也结构化。revalidatePath 和 revalidateTagServer Action 修改数据后,需要告诉 Next.js 刷新缓存:revalidatePath('/posts'):刷新指定路径的缓存revalidateTag('posts'):刷新所有带 fetch(..., { next: { tags: ['posts'] } }) 标记的请求缓存revalidateTag 更灵活——一个 tag 可以对应多个页面,改一次全部刷新。安全注意事项Server Action 虽然有加密 ID 保护,但仍然是公开的 HTTP 端点:必须在 action 内做认证检查(getServerSession())必须验证输入(Zod)不要在 action 里返回敏感信息(返回值会发给客户端)'use server'export async function deleteAccount(formData: FormData) { const session = await getServerSession(); if (!session) throw new Error('Unauthorized'); // ...}和 API Route 的区别Server Action:适合表单提交、数据变更,自动处理 loading/error 状态API Route:适合第三方回调(Webhook)、文件上传、需要自定义响应头简单场景用 Server Action 更简洁。需要精细控制 HTTP 响应时用 API Route。
服务端阅读 06月2日 23:10

Next.js 中间件怎么用?认证重定向、A/B 测试和 Edge Runtime 限制

Next.js 中间件在请求到达页面之前执行,适合做认证检查、路由重写、A/B 测试等。它跑在 Edge Runtime 上,冷启动快但功能有限——不能用 Node.js API。基本用法在项目根目录创建 middleware.ts:import { NextResponse } from 'next/server';import type { NextRequest } from 'next/server';export function middleware(request: NextRequest) { // 请求到达页面之前执行 console.log(request.nextUrl.pathname); return NextResponse.next(); // 放行,继续处理请求}// 限制中间件只对匹配的路径生效export const config = { matcher: ['/dashboard/:path*', '/api/:path*'],};matcher 很重要——不设的话每个请求(包括静态资源)都经过中间件,拖慢性能。认证重定向最常见的用例:未登录用户跳转到登录页:export function middleware(request: NextRequest) { const token = request.cookies.get('session-token'); if (!token) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname); return NextResponse.redirect(loginUrl); } return NextResponse.next();}export const config = { matcher: ['/dashboard/:path*'],};修改请求头中间件可以给请求加 header,页面里用 headers() 读取:export function middleware(request: NextRequest) { const requestHeaders = new Headers(request.headers); requestHeaders.set('x-pathname', request.nextUrl.pathname); return NextResponse.next({ request: { headers: requestHeaders }, });}页面里:import { headers } from 'next/headers';export default function Page() { const pathname = headers().get('x-pathname'); // ...}A/B 测试根据 Cookie 或随机分配给用户不同版本:export function middleware(request: NextRequest) { let variant = request.cookies.get('ab-variant')?.value; if (!variant) { variant = Math.random() > 0.5 ? 'A' : 'B'; } const response = NextResponse.next(); response.cookies.set('ab-variant', variant); // 重写到不同页面 if (variant === 'B' && request.nextUrl.pathname === '/pricing') { return NextResponse.rewrite(new URL('/pricing-b', request.url)); } return response;}NextResponse.rewrite 在服务端切换到另一个页面,用户看到的 URL 不变。限制:Edge Runtime中间件跑在 Edge Runtime,不是 Node.js。这意味着:不能用 fs、path、crypto(Node.js 内置模块)不能用 prisma、mongoose(数据库客户端)不能用 jsonwebtoken(需要 crypto)执行时间限制 30 秒(Vercel 上是 25 秒)简单的 token 验证、Cookie 操作没问题。复杂的认证逻辑(查数据库验证 token)不应该放中间件——放在页面组件或 API 路由里。常见问题中间件死循环:中间件重定向到 /login,但 /login 也匹配了 matcher。解决:matcher 排除 /login,或在中间件里判断路径。cookies 在 Server Component 里读不到:中间件 response.cookies.set() 设置的 Cookie 在同一请求的 Server Component 里读不到——因为中间件的 response 还没返回给浏览器。需要通过 request headers 传递。
服务端阅读 06月2日 23:08

Next.js 性能怎么优化?Server Components、图片和缓存策略实战

Next.js 性能优化从三个方向入手:减少客户端 JavaScript 体积、加快页面加载速度、优化数据获取策略。App Router 的 Server Components 天然比 Pages Router 快——大部分代码不发给浏览器。1. Server Components 优先App Router 默认所有组件都是 Server Component。只在需要交互(useState、useEffect、onClick)时才加 'use client':// Server Component(默认)— 不发 JS 给浏览器export default function Page() { return ( <div> <h1>标题</h1> {/* 静态内容 → Server Component */} <SearchBox /> {/* 需要交互 → Client Component */} <ArticleList articles={articles} /> {/* 纯展示 → Server Component */} </div> );}常见错误:整个页面都标 'use client'。正确做法是把交互部分抽成小的 Client Component,外层保持 Server Component。2. 图片优化next/image 自动做三件事:懒加载、按设备尺寸返回合适分辨率、WebP 格式转换。import Image from 'next/image';<Image src="/hero.jpg" width={1200} height={600} alt="描述" priority // 首屏图片加这个,跳过懒加载 placeholder="blur" // 模糊占位,加载时不会闪白/>必须填 width 和 height——防止布局偏移(CLS)。priority 只给首屏可见图片用,多了反而拖慢 LCP。外部图片需要配置域名白名单:// next.config.jsimages: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com' }, ],},3. 字体优化next/font 自动内联字体文件,消除 FOUT(字体闪烁):import { Inter } from 'next/font/google';const inter = Inter({ subsets: ['latin'] });export default function Layout({ children }) { return <body className={inter.className}>{children}</body>;}不要用 CSS @import 加载 Google Fonts——它会阻塞渲染。next/font 在构建时下载字体文件,零网络请求。4. 动态导入减少首屏 JS非首屏需要的组件用 dynamic 懒加载:import dynamic from 'next/dynamic';const HeavyChart = dynamic(() => import('./HeavyChart'), { loading: () => <div>加载中...</div>, ssr: false, // 纯客户端组件不需要 SSR});ssr: false 跳过服务端渲染——适合图表、编辑器这类重交互、不需要 SEO 的组件。5. 数据获取策略App Router 的 fetch 默认有缓存:// 缓存(默认)— 适合不常变的数据const data = await fetch('https://api.example.com/data');// 不缓存 — 每次请求都重新获取const data = await fetch('https://api.example.com/data', { cache: 'no-store'});// 定时重新验证 — 适合有一定延迟容忍的数据const data = await fetch('https://api.example.com/data', { next: { revalidate: 3600 } // 1 小时后重新验证});ISR(Incremental Static Regeneration)是 Next.js 的杀手锏:静态页面生成后定时更新,不需要每次请求都渲染。revalidate 的时间根据数据变化频率设置——新闻列表 60 秒,配置数据 3600 秒。6. Layout 防止重复渲染App Router 的 layout.tsx 在导航时不会重新渲染。把不会变的 UI(导航栏、页脚)放在 layout 里:// app/layout.tsx — 只渲染一次export default function RootLayout({ children }) { return ( <html> <body> <nav>导航栏</nav> {children} {/* 只有这部分会随路由变化 */} <footer>页脚</footer> </body> </html> );}7. 分析打包体积npx @next/bundle-analyzer生成可视化报告,找出最大的包。常见问题:整个 lodash 被 import(改用 lodash-es 的按需导入)、moment.js 太大(改用 dayjs)、客户端不必要的包。性能优化检查清单Server Component 优先,'use client' 只在需要交互时用图片用 next/image + width/height + priority(首屏)字体用 next/font,不用 CSS @import非首屏组件 dynamic 导入fetch 设置合理的 cache/revalidate 策略静态 UI 放 layout,不放 pagebundle-analyzer 检查大包
服务端阅读 06月2日 23:07

Next.js 应用有哪些安全风险?环境变量泄露、XSS 和 CSRF 防护实战

Next.js 应用的安全风险主要来自三方面:服务端渲染(SSR)泄露敏感数据、API 路由缺乏认证、客户端代码暴露过多信息。逐个堵住就行。1. 环境变量:服务端 vs 客户端Next.js 的环境变量默认只在服务端可用。以 NEXT_PUBLIC_ 开头的才会暴露给浏览器。最常见的错误:把数据库密码、API Key 加了 NEXT_PUBLIC_ 前缀。# .env.local — 只在服务端可用DATABASE_URL=postgresql://...STRIPE_SECRET_KEY=sk_live_...# .env.local — 暴露给浏览器(谨慎使用)NEXT_PUBLIC_API_URL=https://api.example.comNEXT_PUBLIC_GA_ID=G-XXXXXXX检查方法:浏览器 F12 > Sources > 搜索你的密钥。如果搜到了,说明 NEXT_PUBLIC_ 用错了。2. API 路由必须加认证App Router 的 Route Handlers 默认没有任何认证:// app/api/users/route.ts — 裸奔的 API,任何人都能访问export async function GET() { const users = await db.user.findMany(); return Response.json(users);}加上认证中间件:import { getServerSession } from 'next-auth';export async function GET(request: Request) { const session = await getServerSession(); if (!session) { return new Response('Unauthorized', { status: 401 }); } const users = await db.user.findMany(); return Response.json(users);}更高效的方式:用 Next.js 中间件统一拦截,不用每个路由单独写:// middleware.tsimport { NextResponse } from 'next/server';import type { NextRequest } from 'next/server';export function middleware(request: NextRequest) { const token = request.cookies.get('session-token'); if (!token && request.nextUrl.pathname.startsWith('/api/')) { return new Response('Unauthorized', { status: 401 }); } return NextResponse.next();}export const config = { matcher: '/api/:path*',};3. 防止 XSSReact 默认转义 HTML,XSS 风险不大。但 dangerouslySetInnerHTML 是例外:// 危险:用户输入的 HTML 直接渲染<div dangerouslySetInnerHTML={{ __html: userContent }} />// 安全:先用 DOMPurify 清洗import DOMPurify from 'isomorphic-dompurify';<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />另一个 XSS 来源:URL 参数直接插入页面。Next.js 的 useSearchParams() 读取的值如果不转义就渲染,可能被注入。4. CSRF 防护SameSite Cookie 是最简单的 CSRF 防护:// 设置 Cookie 时加 SameSitecookies().set('session-token', token, { httpOnly: true, secure: true, sameSite: 'lax', // 阻止跨站请求携带 Cookie path: '/', maxAge: 60 * 60 * 24 * 7,});sameSite: 'lax' 允许顶层导航携带 Cookie(用户点链接跳转正常),但阻止跨站 POST 请求携带。对大部分应用够用。5. Content Security PolicyCSP 限制页面能加载哪些外部资源,防止恶意脚本注入:// middleware.tsexport function middleware(request: NextRequest) { const response = NextResponse.next(); response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-eval' https://cdn.example.com; style-src 'self' 'unsafe-inline';" ); return response;}CSP 配置比较繁琐,建议用 next-safe 库简化。先从 default-src 'self' 开始,逐步放宽需要的域名。6. 依赖安全审计npm audit # 检查已知漏洞npm audit fix # 自动修复npx better-npm-audit audit # 更详细的审计定期跑 npm audit,高危漏洞必须修。Next.js 自身也有安全更新——保持版本最新。安全检查清单环境变量没有敏感信息泄露到客户端API 路由都有认证Cookie 设置了 httpOnly + secure + sameSitedangerouslySetInnerHTML 用了 DOMPurify有 CSP 头npm audit 无高危漏洞
服务端阅读 05月30日 20:38

为什么 Next.js 部署到 Vercel 通常更省心?

Next.js 部署到 Vercel 省心,核心原因是框架能力和平台能力基本对齐。Vercel 能自动识别 Next.js 项目,处理构建命令、路由、静态资源、图片优化、ISR 缓存、API Routes 和中间件。开发者把代码推到 Git 后,就能得到 Production 和 Preview 两套部署链路。追问Vercel 是 Next.js 的唯一最佳选择吗?不是唯一,但通常是体验最顺的选择,尤其是使用 App Router、ISR、图片优化和预览部署时。其他平台也能部署,只是可能要手动适配。ISR 在 Vercel 上有什么优势?Vercel 能把 ISR 的缓存、后台再生成和 CDN 分发串起来。需要注意 revalidate 时间不能乱设,太短会增加回源压力。API Routes 都应该放在 Vercel 吗?轻量接口、鉴权回调和页面数据聚合适合;长任务、复杂事务、文件处理和高频数据库写入不一定适合。Preview Deployment 的价值是什么?每个 PR 都能生成独立预览地址,改 UI、验 SEO、看接口环境更快。坑在于 Preview 环境变量和数据库要隔离。
服务端阅读 02月17日 23:32

Next.js 有哪些性能优化技术?

Next.js 提供了多种性能优化技术,帮助开发者构建高性能的 Web 应用。以下是 Next.js 的主要性能优化策略:1. 自动代码分割Next.js 自动将代码分割成小块,只加载当前页面所需的代码。// pages/index.jsimport dynamic from 'next/dynamic';// 动态导入组件const DynamicComponent = dynamic(() => import('../components/HeavyComponent'), { loading: () => <p>Loading...</p>, ssr: false // 禁用服务器端渲染});export default function Home() { return ( <div> <h1>Home Page</h1> <DynamicComponent /> </div> );}2. 图片优化使用 next/image 组件自动优化图片。import Image from 'next/image';export default function ImageExample() { return ( <Image src="/hero.jpg" alt="Hero image" width={800} height={600} priority // 首屏图片使用优先加载 placeholder="blur" // 模糊占位符 blurDataURL="data:image/jpeg;base64,..." /> );}图片优化特性:自动选择最佳格式(WebP、AVIF)响应式图片懒加载避免布局偏移3. 字体优化使用 next/font 优化字体加载。import { Inter } from 'next/font/google';const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter',});export default function RootLayout({ children }) { return ( <html lang="en" className={inter.variable}> <body>{children}</body> </html> );}4. 数据获取优化使用缓存和 ISR// 使用 fetch 的缓存选项async function Page() { const data = await fetch('https://api.example.com/data', { next: { revalidate: 60, // ISR:每 60 秒重新验证 tags: ['data'] // 标签用于按需重新验证 } }).then(r => r.json()); return <div>{data.content}</div>;}使用 React Query 或 SWR'use client';import useSWR from 'swr';const fetcher = (url) => fetch(url).then(r => r.json());export default function DataComponent() { const { data, error, isLoading } = useSWR('/api/data', fetcher, { revalidateOnFocus: false, dedupingInterval: 60000, }); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error</div>; return <div>{data.content}</div>;}5. 预加载和预取import Link from 'next/link';export default function Navigation() { return ( <nav> <Link href="/about" prefetch={true}> About </Link> <Link href="/contact" prefetch={false}> Contact </Link> </nav> );}6. 脚本优化使用 next/script 优化第三方脚本加载。import Script from 'next/script';export default function Page() { return ( <> <Script src="https://www.googletagmanager.com/gtag/js" strategy="afterInteractive" /> <Script id="google-analytics" strategy="afterInteractive"> {` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'GA_MEASUREMENT_ID'); `} </Script> </> );}脚本加载策略:beforeInteractive:在页面交互前加载afterInteractive:在页面可交互后立即加载lazyOnload:在浏览器空闲时加载7. 使用 React.memo 和 useMemo'use client';import { memo, useMemo } from 'react';const ExpensiveComponent = memo(function ExpensiveComponent({ data }) { const processedData = useMemo(() => { return data.map(item => ({ ...item, computed: expensiveCalculation(item) })); }, [data]); return <div>{/* 渲染处理后的数据 */}</div>;});8. 虚拟化长列表'use client';import { useVirtualizer } from '@tanstack/react-virtual';export default function VirtualList({ items }) { const parentRef = useRef(); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50, }); return ( <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}> <div style={{ height: `${virtualizer.getTotalSize()}px` }}> {virtualizer.getVirtualItems().map(virtualItem => ( <div key={virtualItem.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)`, }} > {items[virtualItem.index]} </div> ))} </div> </div> );}9. 服务器组件优化// 服务器组件默认不发送 JavaScript 到客户端async function ServerComponent() { const data = await fetchData(); return ( <div> <h1>{data.title}</h1> <p>{data.content}</p> </div> );}// 只在需要交互的地方使用客户端组件'use client';function InteractiveComponent() { const [count, setCount] = useState(0); return <button onClick={() => setCount(c => c + 1)}>{count}</button>;}10. 使用 Streamingimport { Suspense } from 'react';async function SlowComponent() { const data = await slowFetch(); return <div>{data}</div>;}export default function Page() { return ( <div> <h1>Page Title</h1> <Suspense fallback={<div>Loading...</div>}> <SlowComponent /> </Suspense> </div> );}11. 缓存策略使用 Next.js 缓存// 缓存 API 响应export async function getStaticProps() { const data = await fetch('https://api.example.com/data', { cache: 'force-cache', // 或 'no-store', 'no-cache' }).then(r => r.json()); return { props: { data }, revalidate: 3600, // 1 小时 };}使用 Redis 缓存import { Redis } from '@upstash/redis';const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN,});export async function getCachedData(key) { const cached = await redis.get(key); if (cached) return JSON.parse(cached); const data = await fetchData(); await redis.set(key, JSON.stringify(data), { ex: 3600 }); return data;}12. 构建优化分析构建输出// next.config.jsconst withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true',});module.exports = withBundleAnalyzer({ // 其他配置});压缩和优化// next.config.jsmodule.exports = { compress: true, swcMinify: true, productionBrowserSourceMaps: false, // 优化图片 images: { formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], },};性能监控使用 Web Vitals// pages/_app.jsimport { useReportWebVitals } from 'next/web-vitals';export function reportWebVitals(metric) { // 发送到分析服务 console.log(metric); // 或发送到 Google Analytics // gtag('event', metric.name, { value: metric.value });}export default function App({ Component, pageProps }) { useReportWebVitals(reportWebVitals); return <Component {...pageProps} />;}最佳实践使用服务器组件:减少客户端 JavaScript优化图片:使用 next/image 组件懒加载:延迟加载非关键资源缓存数据:使用 ISR 和缓存策略监控性能:使用 Web Vitals 监控分析构建:定期分析 bundle 大小使用 CDN:部署到 Vercel 或其他 CDN优化字体:使用 next/font 优化字体加载通过合理使用这些优化技术,可以显著提升 Next.js 应用的性能和用户体验。
服务端阅读 02月17日 23:32

Next.js 中有哪些数据获取方法?

Next.js 提供了多种数据获取方法,开发者可以根据不同的渲染策略和需求选择合适的方式。以下是 Next.js 中主要的数据获取方法:Pages Router 数据获取方法1. getStaticProps在构建时获取数据,用于静态生成(SSG)。export async function getStaticProps(context) { const data = await fetch('https://api.example.com/data').then(r => r.json()); return { props: { data }, revalidate: 60, // 可选:ISR,每 60 秒重新生成 notFound: false, // 可选:返回 404 页面 redirect: { destination: '/login', permanent: false }, // 可选:重定向 };}export default function Page({ data }) { return <div>{data.content}</div>;}适用场景:数据在构建时可用页面内容不经常变化需要预渲染以提升 SEO2. getServerSideProps在每次请求时获取数据,用于服务器端渲染(SSR)。export async function getServerSideProps(context) { const { req, res, query, params } = context; // 可以访问请求和响应对象 const token = req.cookies.token; const data = await fetch('https://api.example.com/data', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()); return { props: { data }, // 不支持 revalidate };}export default function Page({ data }) { return <div>{data.content}</div>;}适用场景:数据在请求时才能获取需要访问请求/响应对象内容频繁变化3. getStaticPaths用于动态路由的静态生成,定义所有可能的路径。export async function getStaticPaths() { const posts = await getAllPosts(); return { paths: posts.map(post => ({ params: { slug: post.slug } })), fallback: false, // 或 'blocking' 或 true };}export async function getStaticProps({ params }) { const post = await getPostBySlug(params.slug); return { props: { post }, };}export default function BlogPost({ post }) { return <div>{post.title}</div>;}fallback 选项:false:只返回预生成的路径,其他路径返回 404'blocking':服务器渲染新路径,等待完成后返回true:立即返回静态页面,后台生成新路径App Router 数据获取方法1. 服务器组件中的 fetch在服务器组件中直接使用 fetch 获取数据。async function Page() { const data = await fetch('https://api.example.com/data', { cache: 'force-cache', // 或 'no-store', 'no-cache', 'default' next: { revalidate: 60, // ISR tags: ['data'] // 用于按需重新验证 } }).then(r => r.json()); return <div>{data.content}</div>;}cache 选项:force-cache:强制使用缓存(默认)no-store:不使用缓存no-cache:每次验证缓存default:使用默认缓存策略2. 使用 React Server Componentsasync function BlogList() { const posts = await fetch('https://api.example.com/posts', { next: { revalidate: 3600 } }).then(r => r.json()); return ( <div> {posts.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> );}3. 使用 Suspense 和 Streamingimport { Suspense } from 'react';async function SlowComponent() { const data = await fetch('https://api.example.com/slow', { next: { revalidate: 60 } }).then(r => r.json()); return <div>{data.content}</div>;}export default function Page() { return ( <div> <h1>Page Title</h1> <Suspense fallback={<div>Loading...</div>}> <SlowComponent /> </Suspense> </div> );}客户端数据获取1. 使用 useEffect'use client';import { useState, useEffect } from 'react';export default function ClientDataComponent() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetch('/api/data') .then(res => res.json()) .then(data => { setData(data); setLoading(false); }); }, []); if (loading) return <div>Loading...</div>; return <div>{data.content}</div>;}2. 使用 SWR'use client';import useSWR from 'swr';const fetcher = (url) => fetch(url).then(res => res.json());export default function SWRComponent() { const { data, error, isLoading } = useSWR('/api/data', fetcher, { revalidateOnFocus: false, revalidateOnReconnect: false, dedupingInterval: 60000, }); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error</div>; return <div>{data.content}</div>;}3. 使用 React Query'use client';import { useQuery } from '@tanstack/react-query';async function fetchData() { const res = await fetch('/api/data'); return res.json();}export default function ReactQueryComponent() { const { data, error, isLoading } = useQuery({ queryKey: ['data'], queryFn: fetchData, staleTime: 60000, cacheTime: 300000, }); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error</div>; return <div>{data.content}</div>;}数据获取最佳实践1. 选择合适的方法| 场景 | 推荐方法 ||------|----------|| 静态内容,构建时可用 | getStaticProps / SSG || 动态内容,需要实时数据 | getServerSideProps / SSR || 需要用户交互 | 客户端数据获取 || SEO 重要,内容变化不频繁 | SSG + ISR || 需要访问请求/响应对象 | getServerSideProps |2. 缓存策略// 长期缓存fetch('/api/data', { cache: 'force-cache', next: { revalidate: 3600 } });// 短期缓存fetch('/api/data', { cache: 'no-store' });// 按需重新验证fetch('/api/data', { next: { tags: ['data'] } });// 在 API 路由中重新验证import { revalidateTag } from 'next/cache';export async function POST() { revalidateTag('data'); return Response.json({ revalidated: true });}3. 错误处理export async function getStaticProps() { try { const data = await fetchData(); return { props: { data } }; } catch (error) { return { notFound: true, }; }}4. 加载状态// App Router - 使用 loading.js// app/loading.jsexport default function Loading() { return <div>Loading...</div>;}// Pages Router - 使用自定义加载组件export default function LoadingPage() { return <div>Loading...</div>;}5. 并行数据获取// 并行获取多个数据export async function getStaticProps() { const [posts, users, comments] = await Promise.all([ fetch('/api/posts').then(r => r.json()), fetch('/api/users').then(r => r.json()), fetch('/api/comments').then(r => r.json()), ]); return { props: { posts, users, comments }, };}性能优化建议使用 ISR:对于需要定期更新的内容,使用 ISR 而不是 SSR缓存数据:合理设置缓存时间,减少不必要的请求并行获取:使用 Promise.all 并行获取多个数据源流式渲染:使用 Suspense 实现流式渲染,提升用户体验客户端缓存:使用 SWR 或 React Query 缓存客户端数据按需重新验证:使用标签系统按需重新验证数据通过合理选择和使用这些数据获取方法,可以构建高性能、用户体验良好的 Next.js 应用。
服务端阅读 02月17日 23:32

Next.js 如何部署到生产环境?

Next.js 提供了多种部署选项,开发者可以根据项目需求选择最适合的部署方式。以下是 Next.js 的主要部署选项和最佳实践:1. Vercel(推荐)Vercel 是 Next.js 的创建者提供的托管平台,提供最佳的 Next.js 部署体验。优点零配置部署自动 HTTPS全球 CDN边缘函数支持预览部署自动优化部署步骤# 1. 安装 Vercel CLInpm i -g vercel# 2. 登录 Vercelvercel login# 3. 部署vercel# 4. 生产环境部署vercel --prod配置文件// vercel.json{ "buildCommand": "npm run build", "outputDirectory": ".next", "framework": "nextjs", "regions": ["iad1"], "functions": { "app/api/**/*.js": { "maxDuration": 30 } }, "headers": [ { "source": "/(.*)", "headers": [ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "X-Frame-Options", "value": "DENY" } ] } ]}2. 自托管(Docker)使用 Docker 容器化 Next.js 应用,部署到任何支持 Docker 的平台。Dockerfile# 多阶段构建FROM node:18-alpine AS base# 依赖阶段FROM base AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci# 构建阶段FROM base AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build# 运行阶段FROM base AS runnerWORKDIR /appENV NODE_ENV productionRUN addgroup --system --gid 1001 nodejsRUN adduser --system --uid 1001 nextjsCOPY --from=builder /app/public ./publicCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/staticUSER nextjsEXPOSE 3000ENV PORT 3000ENV HOSTNAME "0.0.0.0"CMD ["node", "server.js"]docker-compose.ymlversion: '3.8'services: nextjs: build: . ports: - "3000:3000" environment: - NODE_ENV=production - DATABASE_URL=${DATABASE_URL} restart: unless-stopped构建和运行# 构建镜像docker build -t nextjs-app .# 运行容器docker run -p 3000:3000 nextjs-app# 使用 docker-composedocker-compose up -d3. Node.js 服务器将 Next.js 应用部署到传统的 Node.js 服务器。使用 PM2// ecosystem.config.jsmodule.exports = { apps: [{ name: 'nextjs-app', script: 'node_modules/next/dist/bin/next', args: 'start -p 3000', instances: 'max', exec_mode: 'cluster', env: { NODE_ENV: 'production', PORT: 3000 }, error_file: './logs/err.log', out_file: './logs/out.log', log_date_format: 'YYYY-MM-DD HH:mm:ss Z' }]};# 安装 PM2npm install -g pm2# 启动应用pm2 start ecosystem.config.js# 查看状态pm2 status# 查看日志pm2 logs# 重启应用pm2 restart nextjs-app使用 Nginx 反向代理server { listen 80; server_name example.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}4. 静态导出对于不需要服务器端功能的简单应用,可以导出为静态 HTML。配置// next.config.jsmodule.exports = { output: 'export', images: { unoptimized: true }};构建和部署# 构建npm run build# 输出在 out/ 目录# 可以部署到任何静态托管服务,如:# - GitHub Pages# - Netlify# - AWS S3 + CloudFront# - Firebase Hosting5. 云平台部署AWS使用 AWS Amplify# 安装 Amplify CLInpm install -g @aws-amplify/cli# 初始化amplify init# 添加托管amplify add hosting# 发布amplify publish使用 AWS Lambda// app/api/hello/route.jsexport const runtime = 'edge';export async function GET() { return Response.json({ message: 'Hello from Edge!' });}Google Cloud使用 Cloud Run# 构建镜像gcloud builds submit --tag gcr.io/PROJECT_ID/nextjs-app# 部署到 Cloud Rungcloud run deploy nextjs-app \ --image gcr.io/PROJECT_ID/nextjs-app \ --platform managed \ --region us-central1 \ --allow-unauthenticatedAzure使用 Azure Static Web Apps# 安装 Azure CLInpm install -g @azure/static-web-apps-cli# 部署swa deploy ./out --env production6. CI/CD 集成GitHub Actionsname: Deploy to Vercelon: push: branches: [main]jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Build run: npm run build - name: Deploy to Vercel uses: amondnet/vercel-action@v20 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.ORG_ID }} vercel-project-id: ${{ secrets.PROJECT_ID }} vercel-args: '--prod'GitLab CIimage: node:18stages: - test - build - deploytest: stage: test script: - npm ci - npm testbuild: stage: build script: - npm ci - npm run build artifacts: paths: - .next/deploy: stage: deploy script: - npm install -g vercel - vercel --prod --token=$VERCEL_TOKEN only: - main环境变量管理.env 文件# .env.local(本地开发)DATABASE_URL=postgresql://localhost/mydbAPI_KEY=your_api_key# .env.production(生产环境)DATABASE_URL=postgresql://prod-db-host/mydbAPI_KEY=prod_api_keyVercel 环境变量# 使用 Vercel CLIvercel env add DATABASE_URL production# 或在 Vercel Dashboard 中设置性能优化1. 启用压缩// next.config.jsmodule.exports = { compress: true,};2. 优化图片// next.config.jsmodule.exports = { images: { formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920], },};3. 启用 SWC 压缩// next.config.jsmodule.exports = { swcMinify: true,};监控和日志使用 Vercel Analytics// pages/_app.jsimport { Analytics } from '@vercel/analytics/react';export default function App({ Component, pageProps }) { return ( <> <Component {...pageProps} /> <Analytics /> </> );}使用 Sentry// sentry.client.config.jsimport * as Sentry from '@sentry/nextjs';Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 1.0,});部署最佳实践使用 Vercel:获得最佳的 Next.js 部署体验环境变量:使用 .env 文件管理环境变量CI/CD:设置自动部署流程监控:使用监控工具跟踪应用性能备份:定期备份数据库和重要文件测试:部署前运行完整的测试套件渐进式部署:使用蓝绿部署或金丝雀发布文档化:记录部署流程和配置通过合理选择部署方式和遵循最佳实践,可以确保 Next.js 应用稳定、高效地运行在生产环境中。
服务端阅读 02月17日 23:31

Next.js 的 API Routes 是如何工作的?

Next.js 提供了强大的 API Routes 功能,允许开发者创建 API 端点来处理服务器端逻辑。API Routes 可以处理数据库查询、身份验证、表单提交等服务器端操作。API Routes 基础创建 API Route在 pages/api 目录下创建文件,每个文件都会成为一个 API 端点。pages/ api/ hello.js → /api/hello users/ index.js → /api/users [id].js → /api/users/123基本示例// pages/api/hello.jsexport default function handler(req, res) { res.status(200).json({ message: 'Hello from Next.js API!' });}请求和响应对象请求对象 (req)req 对象包含以下属性:req.method:HTTP 方法(GET、POST、PUT、DELETE 等)req.query:查询参数req.body:请求体(POST、PUT 等)req.headers:请求头req.cookies:Cookie响应对象 (res)res 对象提供以下方法:res.status(code):设置状态码res.json(data):发送 JSON 响应res.send(data):发送响应res.redirect(url):重定向res.setHeader(name, value):设置响应头处理不同的 HTTP 方法// pages/api/users/[id].jsexport default async function handler(req, res) { const { id } = req.query; switch (req.method) { case 'GET': const user = await getUserById(id); res.status(200).json(user); break; case 'PUT': const updatedUser = await updateUser(id, req.body); res.status(200).json(updatedUser); break; case 'DELETE': await deleteUser(id); res.status(204).end(); break; default: res.setHeader('Allow', ['GET', 'PUT', 'DELETE']); res.status(405).end(`Method ${req.method} Not Allowed`); }}中间件自定义中间件// lib/middleware.jsexport function authMiddleware(req, res, next) { const token = req.headers.authorization; if (!token) { return res.status(401).json({ error: 'Unauthorized' }); } // 验证 token const user = verifyToken(token); if (!user) { return res.status(401).json({ error: 'Invalid token' }); } req.user = user; next();}使用中间件// pages/api/protected.jsimport { authMiddleware } from '@/lib/middleware';export default function handler(req, res) { // 受保护的路由 res.status(200).json({ user: req.user });}// 应用中间件export const config = { api: { bodyParser: false, externalResolver: true, },};// 在实际使用中,需要手动调用中间件数据库集成使用 Prisma// pages/api/posts/index.jsimport { PrismaClient } from '@prisma/client';const prisma = new PrismaClient();export default async function handler(req, res) { if (req.method === 'GET') { const posts = await prisma.post.findMany(); res.status(200).json(posts); } else if (req.method === 'POST') { const post = await prisma.post.create({ data: req.body, }); res.status(201).json(post); } else { res.status(405).end(); }}使用 MongoDB// pages/api/users/index.jsimport clientPromise from '@/lib/mongodb';export default async function handler(req, res) { const client = await clientPromise; const db = client.db(); if (req.method === 'GET') { const users = await db.collection('users').find({}).toArray(); res.status(200).json(users); } else if (req.method === 'POST') { const result = await db.collection('users').insertOne(req.body); res.status(201).json(result); } else { res.status(405).end(); }}身份验证使用 NextAuth.js// pages/api/auth/[...nextauth].jsimport NextAuth from 'next-auth';import Providers from 'next-auth/providers';export default NextAuth({ providers: [ Providers.Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), Providers.Credentials({ name: 'Credentials', credentials: { username: { label: "Username", type: "text" }, password: { label: "Password", type: "password" } }, authorize: async (credentials) => { // 验证用户 const user = await authenticate(credentials); if (user) { return user; } return null; } }), ], database: process.env.DATABASE_URL,});JWT 验证// lib/auth.jsimport jwt from 'jsonwebtoken';export function verifyToken(token) { try { return jwt.verify(token, process.env.JWT_SECRET); } catch (error) { return null; }}export function createToken(payload) { return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '1d' });}文件上传// pages/api/upload.jsimport formidable from 'formidable';import fs from 'fs';import path from 'path';export const config = { api: { bodyParser: false, },};export default async function handler(req, res) { if (req.method !== 'POST') { return res.status(405).end(); } const form = formidable({ uploadDir: path.join(process.cwd(), '/public/uploads'), keepExtensions: true, }); form.parse(req, (err, fields, files) => { if (err) { return res.status(500).json({ error: 'File upload failed' }); } const file = files.file[0]; res.status(200).json({ url: `/uploads/${path.basename(file.filepath)}` }); });}错误处理// pages/api/error.jsexport default function handler(req, res) { try { // 业务逻辑 const data = processData(req.body); res.status(200).json(data); } catch (error) { console.error('API Error:', error); if (error.name === 'ValidationError') { res.status(400).json({ error: error.message }); } else if (error.name === 'UnauthorizedError') { res.status(401).json({ error: 'Unauthorized' }); } else { res.status(500).json({ error: 'Internal server error' }); } }}CORS 配置// pages/api/cors.jsexport default function handler(req, res) { res.setHeader('Access-Control-Allow-Credentials', true); res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS,PATCH,DELETE,POST,PUT'); res.setHeader( 'Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' ); if (req.method === 'OPTIONS') { res.status(200).end(); return; } res.status(200).json({ message: 'CORS enabled' });}最佳实践使用环境变量:敏感信息存储在 .env 文件中验证输入:使用验证库如 Zod 或 Yup错误处理:统一错误处理格式日志记录:记录请求和错误信息速率限制:防止 API 滥用缓存:对频繁访问的数据进行缓存文档化:使用 Swagger 或 OpenAPI 文档化 APINext.js API Routes 提供了简单而强大的方式来构建服务器端 API,无需单独的后端服务器,使全栈开发变得更加便捷。