服务端6月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:
```typescript
// app/products/page.tsx
export 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 直接读取 searchParams
### Client Component 中更新 URL 状态
```typescript
'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 传给子组件——最简单的状态传递方式:
```typescript
// app/dashboard/page.tsx
export 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
跨组件共享数据,但不需要跨页面共享:
```typescript
// 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 中注册:
```typescript
// app/layout.tsx
import { 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 状态库:
```bash
npm install zustand
```
```typescript
// store/cart-store.ts
import { 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),
}))
```
```typescript
// 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 持久化到 localStorage
```typescript
import { 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 读取:
```typescript
// 解决 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 简化了这整个流程:
```typescript
// 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')
}
```
```typescript
// app/products/page.tsx
import { 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 时代的思维。
标签
Next.js
next.js是一个基于React的通用JavaScript框架,next.js为React组件模型提供了扩展,支持基于服务器的组件渲染,同时也支持在客户端继续进行渲染 next.js是一个基于React的通用JavaScript框架,next.js为React组件模型提供了扩展,支持基于服务器的组件渲染,同时也支持在客户端继续进行渲染 next.js是一个基于React的通用JavaScript框架,next.js为React组件模型提供了扩展,支持基于服务器的组件渲染,同时也支持在客户端继续进行渲染

服务端6月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`,捕获该路由段及其子组件的运行时错误:
```typescript
// app/dashboard/error.tsx
'use client' // 必须是 Client Component
export 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.tsx`
## global-error.tsx:最后的兜底
当根 layout 崩溃时,`error.tsx` 也渲染不了(因为 layout 本身都挂了)。这时需要 `global-error.tsx`:
```typescript
// 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` 处理可预期的错误:
```typescript
// app/dashboard/page.tsx
export 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 的错误处理
### 基本错误响应
```typescript
// app/api/users/route.ts
import { 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 }
)
}
}
```
### 业务异常封装
```typescript
// lib/errors.ts
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
) {
super(message)
}
}
// app/api/users/route.ts
export 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:
```typescript
// 在 route handler 外层包一个 wrapper
function 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()` 比抛异常更语义化:
```typescript
// app/products/[id]/page.tsx
import { 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 页面:
```typescript
// app/not-found.tsx
export 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 组件做更细粒度的捕获:
```typescript
// 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 更灵活——可以包在单个组件外面,不影响整个页面。
## 错误监控和日志
生产环境中,错误信息不能只在前端打印——需要上报到监控系统:
```typescript
// 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 中的错误上报:
```typescript
// app/dashboard/page.tsx
export 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 + 监控 |
服务端6月5日 21:51
Next.js 缓存全解析:四层缓存机制和失效策略实战Next.js 的缓存机制是它的性能杀手锏,也是最容易让人困惑的部分。一个 `fetch` 请求可能被缓存 5 秒也可能永久缓存,取决于一行配置。搞不清缓存层级,就会出现"数据改了但页面没更新"的诡异问题。
## 四层缓存,各有各的失效机制
```
请求进来
→ 请求记忆(同一渲染周期内的去重)
→ 数据缓存(fetch 结果持久化)
→ 路由缓存(Router Cache,客户端缓存)
→ 完整路由缓存(构建时静态化)
```
从上到下,缓存粒度从细到粗。任何一层命中都不会继续往下查。
## 第一层:请求记忆(Request Memoization)
同一个 Server Component 渲染周期内,多次 `fetch` 同一个 URL 只会真正发一次请求:
```typescript
// 这两个 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,默认行为是**永久缓存**:
```typescript
// 默认:永久缓存(等效 force-cache)
const data = await fetch('https://api.example.com/data')
// 和上面等价
const data = await fetch('https://api.example.com/data', { cache: 'force-cache' })
```
**是的,你没看错——一次 fetch 的结果会被永久缓存,除非你主动让它失效。**
### 控制缓存行为
```typescript
// 不缓存,每次都请求
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?在页面级统一设置:
```typescript
// app/products/page.tsx
export const revalidate = 3600 // 整个页面 1 小时重新验证
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products')
// ...
}
```
页面级 revalidate 会覆盖 fetch 级别的设置。
### 手动失效缓存
```typescript
import { revalidatePath, revalidateTag } from 'next/cache'
// 失效整个路由
revalidatePath('/products')
// 按 tag 失效(更精确)
const data = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
})
// 数据变更时清除 tag
async function updateProduct() {
await db.updateProduct(...)
revalidateTag('products') // 所有带 products tag 的缓存失效
}
```
`revalidateTag` 比 `revalidatePath` 更灵活——一个 tag 可以跨多个页面和组件,清除一次全部失效。
## 第三层:路由缓存(Router Cache)
这是**客户端缓存**,存在浏览器内存中。用户在页面间导航时,Next.js 会缓存已访问路由的 RSC Payload,返回时不需要重新请求服务端。
```typescript
// 在 layout.tsx 中配置
export const experimental = {
staleTimes: {
dynamic: 30, // 动态路由缓存 30 秒
static: 300, // 静态路由缓存 5 分钟
},
}
```
### 路由缓存的失效
- 用户刷新页面 → 缓存失效
- 调用 `router.refresh()` → 缓存失效
- `revalidatePath` / `revalidateTag` → 对应缓存失效
**常见问题**:用户在 A 页面修改了数据,切到 B 页面,再切回 A 页面——看到的还是旧数据。这就是路由缓存在作怪。解决:修改数据后调用 `router.refresh()`。
```typescript
'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 服务器。
```typescript
// 默认行为:构建时静态生成
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'`
```typescript
// 强制动态渲染
export const dynamic = 'force-dynamic'
```
## Server Actions 与缓存失效
Server Actions 是触发缓存失效的最佳位置——数据变更和缓存失效在同一处代码:
```typescript
// 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') // 创建后立刻失效
}
```
## 调试缓存
开发模式和生产模式的缓存行为完全不同——开发模式下几乎所有缓存都被禁用。**缓存问题只在生产环境中复现。**
```bash
# 生产构建
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 没有执行 | 请求记忆去重 | 正常行为,同一周期只请求一次 |
服务端6月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 方式
```typescript
// pages/users.tsx
export async function getServerSideProps() {
const users = await fetchUsers()
return { props: { users } }
}
export default function UsersPage({ users }) {
return <UserList users={users} />
}
```
每个页面需要单独的 `getServerSideProps` 或 `getStaticProps` 函数,数据只能在页面级获取,子组件不能自己获取。
### App Router 方式
```typescript
// app/users/page.tsx
export default async function UsersPage() {
const users = await fetchUsers() // 直接在组件里 await
return <UserList users={users} />
}
```
不需要特殊的 `getXXXProps` 函数——组件本身就是 `async` 函数,直接 `await` 获取数据。子组件也能自己获取:
```typescript
// components/user-stats.tsx
export default async function UserStats() {
const stats = await fetchStats() // 子组件自己获取数据
return <div>活跃用户:{stats.active}</div>
}
```
这是 RSC 的核心优势:**组件级数据获取**,不再需要在页面顶层把所有数据攒齐再一层层传 props。
## 布局系统:从全局到嵌套
### Pages Router:一个布局管所有页面
```typescript
// 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 布局)
```
```typescript
// app/dashboard/layout.tsx
export 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(默认)
```typescript
// app/page.tsx - 自动是 Server Component
export 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(需要显式声明)
```typescript
'use client' // 这行声明是 Client Component
import { 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
```typescript
// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
res.status(200).json({ users: [] })
}
}
```
### App Router
```typescript
// app/api/users/route.ts
import { 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 Router
app/
new-page/
page.tsx # 新页面用 App Router
```
Next.js 同时支持两种 Router。新页面用 App Router,旧页面保持不动,逐步迁移。
### 2. 迁移优先级
1. **API 路由**:最简单,改动最小
2. **纯展示页面**:不需要交互,直接改成 Server Component
3. **有交互的页面**:拆成 Server + Client 组件混合
### 3. 常见迁移坑
- **`useRouter` 不一样了**:Pages Router 用 `next/router`,App Router 用 `next/navigation`
- **`getServerSideProps` 变成组件内 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 没有废弃它的计划。
服务端6月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/about`
### layout.tsx 加载翻译并设置语言
```typescript
// app/[lang]/layout.tsx
import { notFound } from 'next/navigation'
import { dictionaries } from '@/lib/dictionaries'
export const supportedLocales = ['zh', 'en', 'ja'] as const
export 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
```
```json
// dictionaries/zh.json
{
"nav": {
"home": "首页",
"about": "关于",
"contact": "联系我们"
},
"home": {
"title": "欢迎使用我们的产品",
"description": "一站式解决方案"
}
}
```
### 加载翻译的工具函数
```typescript
// lib/dictionaries.ts
const 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 中使用
```typescript
// app/[lang]/page.tsx
import { 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。
## 语言切换组件
```typescript
// 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 标签
搜索引擎需要知道不同语言版本的对应关系:
```typescript
// app/[lang]/layout.tsx 中添加 metadata
export 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` 告诉搜索引擎:无法匹配用户语言时,显示这个版本。通常选英语或主要目标语言。
## 中间件:自动重定向到用户首选语言
```typescript
// middleware.ts
import { 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:
```typescript
// 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 里提供:
```typescript
// app/[lang]/layout.tsx
import { 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 翻译文件不支持插值,需要一个小工具函数:
```typescript
// lib/i18n.ts
export 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
}
```
```json
{
"cart": {
"items": "购物车中有 {count} 件商品",
"item_single": "1 件商品",
"item_plural": "{count} 件商品"
}
}
```
```typescript
const text = interpolate(t.cart.items, { count: 3 }) // "购物车中有 3 件商品"
```
复杂的复数规则(阿拉伯语、俄语等)建议用 `intl-messageformat` 库。
## 常见问题
### 翻译键找不到怎么办
开发时加一个 fallback 机制:
```typescript
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.json
```
### SEO 的 hreflang 和 canonical 同时存在冲突吗
不冲突。`canonical` 指向当前语言版本的规范 URL,`hreflang` 指向其他语言版本。两者配合告诉搜索引擎:这些 URL 是同一个内容的不同语言版本。
## 完整方案清单
| 检查项 | 配置 |
|--------|------|
| 路由结构 | `app/[lang]/` 动态路由 |
| 翻译加载 | 动态 `import()` 按需加载 |
| 语言切换 | 替换 URL 语言段 |
| 自动重定向 | middleware 检测 Accept-Language |
| SEO | generateMetadata 配置 hreflang + canonical |
| Client Component | DictionaryProvider + useDictionary |
| 非法语言 | notFound() 处理 |
| 翻译插值 | interpolate 工具函数 |
服务端6月5日 14:00
Next.js测试策略:服务端组件、API路由、Server Actions和E2ENext.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 倍:
```bash
npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-event
```
### Vitest 配置
```typescript
// vitest.config.ts
import { 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'),
},
},
});
```
```typescript
// vitest.setup.ts
import '@testing-library/jest-dom';
```
## 客户端组件测试
客户端组件(文件顶部有 `'use client'`)的测试和普通 React 组件一样:
```typescript
// 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>
);
}
```
```typescript
// components/__tests__/Counter.test.tsx
import { 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 的组件:
```typescript
// Mock next/navigation
vi.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。
### 方案一:抽取逻辑为纯函数
最佳做法——把业务逻辑从服务端组件里抽出来,单独测试:
```typescript
// lib/filterProducts.ts
export function filterProducts(products: Product[], category: string) {
return products.filter(p => p.category === category);
}
// lib/__tests__/filterProducts.test.ts
import { 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`:
```typescript
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` 等函数。测试时直接调用这些函数:
```typescript
// app/api/users/route.ts
import { 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) });
}
```
```typescript
// __tests__/api/users.test.ts
import { 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 外部 API
API 路由通常要调外部服务。用 MSW 拦截请求:
```typescript
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 路由类似——直接调用函数:
```typescript
// 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 };
}
```
```typescript
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 测试验证完整用户流程——从打开页面到完成操作。
### 基础配置
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
baseURL: 'http://localhost:3000',
use: {
locale: 'zh-CN',
},
});
```
### 页面测试
```typescript
// e2e/home.spec.ts
import { 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` 配置可以自动启动:
```typescript
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% 需要启动浏览器。服务端6月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/settings
```
`page.tsx` 是必须的——没有 page.tsx 的文件夹不构成路由。其他文件(layout、loading、error)都是可选的。
## 动态路由
```tsx
// app/blog/[slug]/page.tsx
export 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 里面:
```tsx
// 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
```tsx
// app/dashboard/loading.tsx
export default function Loading() {
return <div className="animate-pulse">加载中...</div>;
}
```
Next.js 自动用 Suspense 包裹 page.tsx,加载时显示 loading.tsx。不需要手写 useState 管理加载状态。
## error.tsx:错误边界
```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` 函数重新渲染出错的组件。
## 程序化导航
```tsx
'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()`:
```tsx
import { redirect } from 'next/navigation';
export default async function Page() {
const session = await getSession();
if (!session) redirect('/login');
// ...
}
```
`redirect` 在服务端执行,用户看不到中间页面。服务端6月2日 23:15
Next.js SSR、SSG 和 ISR 有什么区别?怎么选?SSR、SSG、ISR 是三种不同的页面渲染策略,区别在于 HTML 什么时候生成。选哪个取决于数据的更新频率和页面的实时性要求。
## 三种策略对比
| 策略 | HTML 生成时机 | 适合场景 | 性能 |
|------|-------------|----------|------|
| SSG | 构建时 | 博客、文档、营销页 | 最快(CDN 缓存) |
| SSR | 每次请求时 | 仪表盘、个人主页 | 中等(服务端计算) |
| ISR | 构建时 + 定时更新 | 商品列表、新闻 | 接近 SSG 的速度 |
## SSG(Static Site Generation)
构建时生成 HTML,部署后不变化。速度最快——CDN 直接返回静态文件,零服务端计算。
```tsx
// 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。数据始终最新,但每次请求都有服务端计算开销。
```tsx
// App Router: 使用动态数据获取自动触发 SSR
async 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 的速度和数据的新鲜度。
```tsx
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 的工作流程:
1. 第一个用户请求 → 返回缓存的静态 HTML(快)
2. 后台检查 revalidate 时间是否到期
3. 到期后重新生成 HTML,替换旧缓存
4. 下一个用户请求 → 返回新生成的 HTML
关键点:用户永远看到的是缓存的页面(快),后台异步更新。最坏情况数据延迟 `revalidate` 秒。
## 怎么选
**选 SSG**:内容几乎不变(文档、博客、营销页)
**选 ISR**:内容定期更新(商品列表、新闻、排行榜),能接受短暂延迟
**选 SSR**:内容必须实时(仪表盘、用户个人页、搜索结果)
**常见错误**:所有页面都用 SSR。大部分页面用 ISR 就够了——1-5 分钟的数据延迟用户感知不到,但性能提升显著。
## App Router 中的 revalidate 策略
```tsx
// 定时重新验证(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)比定时更精准——数据变了立即刷新,没变就不浪费资源。服务端6月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 用特殊函数获取数据:
```tsx
// Pages Router
export async function getServerSideProps() {
const data = await fetchData();
return { props: { data } };
}
export default function Page({ data }) {
return <div>{data}</div>;
}
```
App Router 直接在组件里 async/await:
```tsx
// App Router
export 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` 会冲突)。服务端6月2日 23:13
Next.js 认证怎么做?NextAuth.js 配置 OAuth 和凭证登录实战Next.js 认证最主流的方案是 NextAuth.js(v5 改名 Auth.js)。它处理了 OAuth、JWT、Session 管理等所有细节,30 分钟就能搭好 Google/GitHub 登录。
## 最快上手:NextAuth.js
```bash
npm install next-auth@beta
```
```typescript
// app/api/auth/[...nextauth]/route.ts
import 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;
```
```typescript
// middleware.ts — 保护路由
export { auth as middleware } from './app/api/auth/[...nextauth]/route';
export const config = {
matcher: ['/dashboard/:path*'],
};
```
三步:配置 provider → 创建 API 路由 → 加中间件保护。用户访问 /dashboard 时如果没有登录,自动跳转到 /login。
## 在 Server Component 里获取 Session
```tsx
import { 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
```tsx
'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`:
```tsx
// app/providers.tsx
'use client'
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }) {
return <SessionProvider>{children}</SessionProvider>;
}
```
包在 layout 里后,`useSession` 不再轮询,而是通过 Context 共享 session 数据。
## 自定义登录页
默认登录页太简陋。自定义页面:
```tsx
// app/login/page.tsx
import { 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:
```typescript
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 部署时自动设置,其他平台需要手动配。服务端6月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
```tsx
// 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 时代,客户端获取数据:
```tsx
// Pages Router — 客户端获取
useEffect(() => {
fetch('/api/articles').then(r => r.json()).then(setArticles);
}, []);
```
App Router + RSC,服务端直接获取:
```tsx
// App Router — 服务端获取
async function Page() {
const articles = await db.article.findMany();
return <ArticleList articles={articles} />;
}
```
不需要 API 路由,不需要 loading 状态管理,不需要客户端缓存。服务端拿到数据直接渲染成 HTML。
## 组合模式
Server Component 可以渲染 Client Component,但反过来不行:
```tsx
// Server Component
import SearchBox from './SearchBox'; // Client Component
async function Page() {
const data = await fetchData();
return (
<div>
<SearchBox /> {/* Client Component:交互 */}
<ArticleList data={data} /> {/* Server Component:展示 */}
</div>
);
}
```
关键规则:
- Server Component 可以 import 和渲染 Client Component
- Client Component 不能 import Server Component
- Server Component 可以通过 props 把数据传给 Client Component(必须是可序列化的数据)
## 什么时候用 Client Component
只有这四种情况需要 `'use client'`:
1. 需要交互(onClick、onChange)
2. 需要状态(useState、useReducer)
3. 需要生命周期(useEffect)
4. 需要浏览器 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 更少。服务端6月2日 23:10
Next.js Server Actions 怎么用?表单提交、状态管理和安全验证Server Actions 是 Next.js 的服务端函数——在服务端执行,客户端直接调用,不需要手写 API 路由。底层是 POST 请求 + 加密签名,比传统 fetch + API Route 更简洁。
## 基本用法
```tsx
// 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'); // 刷新缓存
}
```
```tsx
// app/posts/page.tsx
import { 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.1
Content-Type: text/x-component
Next-Action: hashed-action-id
FormData body
```
Next.js 自动给 action 生成加密 ID,客户端调用时带这个 ID。请求到达服务端后,Next.js 根据 ID 找到对应函数执行。所以即使有人猜到 URL,没有正确的 action ID 也无法调用。
## useActionState 管理状态
```tsx
'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 里必须验证:
```typescript
'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 和 revalidateTag
Server Action 修改数据后,需要告诉 Next.js 刷新缓存:
- `revalidatePath('/posts')`:刷新指定路径的缓存
- `revalidateTag('posts')`:刷新所有带 `fetch(..., { next: { tags: ['posts'] } })` 标记的请求缓存
`revalidateTag` 更灵活——一个 tag 可以对应多个页面,改一次全部刷新。
## 安全注意事项
Server Action 虽然有加密 ID 保护,但仍然是公开的 HTTP 端点:
- 必须在 action 内做认证检查(`getServerSession()`)
- 必须验证输入(Zod)
- 不要在 action 里返回敏感信息(返回值会发给客户端)
```typescript
'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。服务端6月2日 23:10
Next.js 中间件怎么用?认证重定向、A/B 测试和 Edge Runtime 限制Next.js 中间件在请求到达页面之前执行,适合做认证检查、路由重写、A/B 测试等。它跑在 Edge Runtime 上,冷启动快但功能有限——不能用 Node.js API。
## 基本用法
在项目根目录创建 `middleware.ts`:
```typescript
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` 很重要——不设的话每个请求(包括静态资源)都经过中间件,拖慢性能。
## 认证重定向
最常见的用例:未登录用户跳转到登录页:
```typescript
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()` 读取:
```typescript
export function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-pathname', request.nextUrl.pathname);
return NextResponse.next({
request: { headers: requestHeaders },
});
}
```
页面里:
```typescript
import { headers } from 'next/headers';
export default function Page() {
const pathname = headers().get('x-pathname');
// ...
}
```
## A/B 测试
根据 Cookie 或随机分配给用户不同版本:
```typescript
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 传递。服务端6月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'`:
```tsx
// 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 格式转换。
```tsx
import Image from 'next/image';
<Image
src="/hero.jpg"
width={1200}
height={600}
alt="描述"
priority // 首屏图片加这个,跳过懒加载
placeholder="blur" // 模糊占位,加载时不会闪白
/>
```
必须填 width 和 height——防止布局偏移(CLS)。`priority` 只给首屏可见图片用,多了反而拖慢 LCP。
外部图片需要配置域名白名单:
```typescript
// next.config.js
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' },
],
},
```
## 3. 字体优化
`next/font` 自动内联字体文件,消除 FOUT(字体闪烁):
```tsx
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` 懒加载:
```tsx
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <div>加载中...</div>,
ssr: false, // 纯客户端组件不需要 SSR
});
```
`ssr: false` 跳过服务端渲染——适合图表、编辑器这类重交互、不需要 SEO 的组件。
## 5. 数据获取策略
App Router 的 `fetch` 默认有缓存:
```tsx
// 缓存(默认)— 适合不常变的数据
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 里:
```tsx
// app/layout.tsx — 只渲染一次
export default function RootLayout({ children }) {
return (
<html>
<body>
<nav>导航栏</nav>
{children} {/* 只有这部分会随路由变化 */}
<footer>页脚</footer>
</body>
</html>
);
}
```
## 7. 分析打包体积
```bash
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,不放 page
- bundle-analyzer 检查大包服务端6月2日 23:07
Next.js 应用有哪些安全风险?环境变量泄露、XSS 和 CSRF 防护实战Next.js 应用的安全风险主要来自三方面:服务端渲染(SSR)泄露敏感数据、API 路由缺乏认证、客户端代码暴露过多信息。逐个堵住就行。
## 1. 环境变量:服务端 vs 客户端
Next.js 的环境变量默认只在服务端可用。以 `NEXT_PUBLIC_` 开头的才会暴露给浏览器。最常见的错误:把数据库密码、API Key 加了 `NEXT_PUBLIC_` 前缀。
```bash
# .env.local — 只在服务端可用
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_...
# .env.local — 暴露给浏览器(谨慎使用)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GA_ID=G-XXXXXXX
```
检查方法:浏览器 F12 > Sources > 搜索你的密钥。如果搜到了,说明 `NEXT_PUBLIC_` 用错了。
## 2. API 路由必须加认证
App Router 的 Route Handlers 默认没有任何认证:
```typescript
// app/api/users/route.ts — 裸奔的 API,任何人都能访问
export async function GET() {
const users = await db.user.findMany();
return Response.json(users);
}
```
加上认证中间件:
```typescript
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 中间件统一拦截,不用每个路由单独写:
```typescript
// middleware.ts
import { 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. 防止 XSS
React 默认转义 HTML,XSS 风险不大。但 `dangerouslySetInnerHTML` 是例外:
```tsx
// 危险:用户输入的 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 防护:
```typescript
// 设置 Cookie 时加 SameSite
cookies().set('session-token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax', // 阻止跨站请求携带 Cookie
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
```
`sameSite: 'lax'` 允许顶层导航携带 Cookie(用户点链接跳转正常),但阻止跨站 POST 请求携带。对大部分应用够用。
## 5. Content Security Policy
CSP 限制页面能加载哪些外部资源,防止恶意脚本注入:
```typescript
// middleware.ts
export 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. 依赖安全审计
```bash
npm audit # 检查已知漏洞
npm audit fix # 自动修复
npx better-npm-audit audit # 更详细的审计
```
定期跑 `npm audit`,高危漏洞必须修。Next.js 自身也有安全更新——保持版本最新。
## 安全检查清单
- 环境变量没有敏感信息泄露到客户端
- API 路由都有认证
- Cookie 设置了 httpOnly + secure + sameSite
- dangerouslySetInnerHTML 用了 DOMPurify
- 有 CSP 头
- npm audit 无高危漏洞服务端5月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 环境变量和数据库要隔离。服务端2月17日 23:32
Next.js 有哪些性能优化技术?Next.js 提供了多种性能优化技术,帮助开发者构建高性能的 Web 应用。以下是 Next.js 的主要性能优化策略:
## 1. 自动代码分割
Next.js 自动将代码分割成小块,只加载当前页面所需的代码。
```javascript
// pages/index.js
import 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` 组件自动优化图片。
```javascript
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` 优化字体加载。
```javascript
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
```javascript
// 使用 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
```javascript
'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. 预加载和预取
```javascript
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` 优化第三方脚本加载。
```javascript
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
```javascript
'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. 虚拟化长列表
```javascript
'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
// 服务器组件默认不发送 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. 使用 Streaming
```javascript
import { 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 缓存
```javascript
// 缓存 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 缓存
```javascript
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. 构建优化
### 分析构建输出
```javascript
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// 其他配置
});
```
### 压缩和优化
```javascript
// next.config.js
module.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
```javascript
// pages/_app.js
import { 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} />;
}
```
## 最佳实践
1. **使用服务器组件**:减少客户端 JavaScript
2. **优化图片**:使用 next/image 组件
3. **懒加载**:延迟加载非关键资源
4. **缓存数据**:使用 ISR 和缓存策略
5. **监控性能**:使用 Web Vitals 监控
6. **分析构建**:定期分析 bundle 大小
7. **使用 CDN**:部署到 Vercel 或其他 CDN
8. **优化字体**:使用 next/font 优化字体加载
通过合理使用这些优化技术,可以显著提升 Next.js 应用的性能和用户体验。服务端2月17日 23:32
Next.js 中有哪些数据获取方法?Next.js 提供了多种数据获取方法,开发者可以根据不同的渲染策略和需求选择合适的方式。以下是 Next.js 中主要的数据获取方法:
## Pages Router 数据获取方法
### 1. getStaticProps
在构建时获取数据,用于静态生成(SSG)。
```javascript
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>;
}
```
**适用场景:**
- 数据在构建时可用
- 页面内容不经常变化
- 需要预渲染以提升 SEO
### 2. getServerSideProps
在每次请求时获取数据,用于服务器端渲染(SSR)。
```javascript
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
用于动态路由的静态生成,定义所有可能的路径。
```javascript
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 获取数据。
```javascript
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 Components
```javascript
async 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 和 Streaming
```javascript
import { 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
```javascript
'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
```javascript
'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
```javascript
'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. 缓存策略
```javascript
// 长期缓存
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. 错误处理
```javascript
export async function getStaticProps() {
try {
const data = await fetchData();
return { props: { data } };
} catch (error) {
return {
notFound: true,
};
}
}
```
### 4. 加载状态
```javascript
// App Router - 使用 loading.js
// app/loading.js
export default function Loading() {
return <div>Loading...</div>;
}
// Pages Router - 使用自定义加载组件
export default function LoadingPage() {
return <div>Loading...</div>;
}
```
### 5. 并行数据获取
```javascript
// 并行获取多个数据
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 },
};
}
```
## 性能优化建议
1. **使用 ISR**:对于需要定期更新的内容,使用 ISR 而不是 SSR
2. **缓存数据**:合理设置缓存时间,减少不必要的请求
3. **并行获取**:使用 Promise.all 并行获取多个数据源
4. **流式渲染**:使用 Suspense 实现流式渲染,提升用户体验
5. **客户端缓存**:使用 SWR 或 React Query 缓存客户端数据
6. **按需重新验证**:使用标签系统按需重新验证数据
通过合理选择和使用这些数据获取方法,可以构建高性能、用户体验良好的 Next.js 应用。服务端2月17日 23:32
Next.js 如何部署到生产环境?Next.js 提供了多种部署选项,开发者可以根据项目需求选择最适合的部署方式。以下是 Next.js 的主要部署选项和最佳实践:
## 1. Vercel(推荐)
Vercel 是 Next.js 的创建者提供的托管平台,提供最佳的 Next.js 部署体验。
### 优点
- 零配置部署
- 自动 HTTPS
- 全球 CDN
- 边缘函数支持
- 预览部署
- 自动优化
### 部署步骤
```bash
# 1. 安装 Vercel CLI
npm i -g vercel
# 2. 登录 Vercel
vercel login
# 3. 部署
vercel
# 4. 生产环境部署
vercel --prod
```
### 配置文件
```javascript
// 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
```dockerfile
# 多阶段构建
FROM node:18-alpine AS base
# 依赖阶段
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 构建阶段
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 运行阶段
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
```
### docker-compose.yml
```yaml
version: '3.8'
services:
nextjs:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
restart: unless-stopped
```
### 构建和运行
```bash
# 构建镜像
docker build -t nextjs-app .
# 运行容器
docker run -p 3000:3000 nextjs-app
# 使用 docker-compose
docker-compose up -d
```
## 3. Node.js 服务器
将 Next.js 应用部署到传统的 Node.js 服务器。
### 使用 PM2
```javascript
// ecosystem.config.js
module.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'
}]
};
```
```bash
# 安装 PM2
npm install -g pm2
# 启动应用
pm2 start ecosystem.config.js
# 查看状态
pm2 status
# 查看日志
pm2 logs
# 重启应用
pm2 restart nextjs-app
```
### 使用 Nginx 反向代理
```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。
### 配置
```javascript
// next.config.js
module.exports = {
output: 'export',
images: {
unoptimized: true
}
};
```
### 构建和部署
```bash
# 构建
npm run build
# 输出在 out/ 目录
# 可以部署到任何静态托管服务,如:
# - GitHub Pages
# - Netlify
# - AWS S3 + CloudFront
# - Firebase Hosting
```
## 5. 云平台部署
### AWS
#### 使用 AWS Amplify
```bash
# 安装 Amplify CLI
npm install -g @aws-amplify/cli
# 初始化
amplify init
# 添加托管
amplify add hosting
# 发布
amplify publish
```
#### 使用 AWS Lambda
```javascript
// app/api/hello/route.js
export const runtime = 'edge';
export async function GET() {
return Response.json({ message: 'Hello from Edge!' });
}
```
### Google Cloud
#### 使用 Cloud Run
```bash
# 构建镜像
gcloud builds submit --tag gcr.io/PROJECT_ID/nextjs-app
# 部署到 Cloud Run
gcloud run deploy nextjs-app \
--image gcr.io/PROJECT_ID/nextjs-app \
--platform managed \
--region us-central1 \
--allow-unauthenticated
```
### Azure
#### 使用 Azure Static Web Apps
```bash
# 安装 Azure CLI
npm install -g @azure/static-web-apps-cli
# 部署
swa deploy ./out --env production
```
## 6. CI/CD 集成
### GitHub Actions
```yaml
name: Deploy to Vercel
on:
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 CI
```yaml
image: node:18
stages:
- test
- build
- deploy
test:
stage: test
script:
- npm ci
- npm test
build:
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 文件
```bash
# .env.local(本地开发)
DATABASE_URL=postgresql://localhost/mydb
API_KEY=your_api_key
# .env.production(生产环境)
DATABASE_URL=postgresql://prod-db-host/mydb
API_KEY=prod_api_key
```
### Vercel 环境变量
```bash
# 使用 Vercel CLI
vercel env add DATABASE_URL production
# 或在 Vercel Dashboard 中设置
```
## 性能优化
### 1. 启用压缩
```javascript
// next.config.js
module.exports = {
compress: true,
};
```
### 2. 优化图片
```javascript
// next.config.js
module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
},
};
```
### 3. 启用 SWC 压缩
```javascript
// next.config.js
module.exports = {
swcMinify: true,
};
```
## 监控和日志
### 使用 Vercel Analytics
```javascript
// pages/_app.js
import { Analytics } from '@vercel/analytics/react';
export default function App({ Component, pageProps }) {
return (
<>
<Component {...pageProps} />
<Analytics />
</>
);
}
```
### 使用 Sentry
```javascript
// sentry.client.config.js
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
});
```
## 部署最佳实践
1. **使用 Vercel**:获得最佳的 Next.js 部署体验
2. **环境变量**:使用 .env 文件管理环境变量
3. **CI/CD**:设置自动部署流程
4. **监控**:使用监控工具跟踪应用性能
5. **备份**:定期备份数据库和重要文件
6. **测试**:部署前运行完整的测试套件
7. **渐进式部署**:使用蓝绿部署或金丝雀发布
8. **文档化**:记录部署流程和配置
通过合理选择部署方式和遵循最佳实践,可以确保 Next.js 应用稳定、高效地运行在生产环境中。服务端2月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
```
### 基本示例
```javascript
// pages/api/hello.js
export 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 方法
```javascript
// pages/api/users/[id].js
export 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`);
}
}
```
## 中间件
### 自定义中间件
```javascript
// lib/middleware.js
export 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();
}
```
### 使用中间件
```javascript
// pages/api/protected.js
import { 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
```javascript
// pages/api/posts/index.js
import { 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
```javascript
// pages/api/users/index.js
import 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
```javascript
// pages/api/auth/[...nextauth].js
import 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 验证
```javascript
// lib/auth.js
import 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' });
}
```
## 文件上传
```javascript
// pages/api/upload.js
import 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)}`
});
});
}
```
## 错误处理
```javascript
// pages/api/error.js
export 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 配置
```javascript
// pages/api/cors.js
export 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' });
}
```
## 最佳实践
1. **使用环境变量**:敏感信息存储在 `.env` 文件中
2. **验证输入**:使用验证库如 Zod 或 Yup
3. **错误处理**:统一错误处理格式
4. **日志记录**:记录请求和错误信息
5. **速率限制**:防止 API 滥用
6. **缓存**:对频繁访问的数据进行缓存
7. **文档化**:使用 Swagger 或 OpenAPI 文档化 API
Next.js API Routes 提供了简单而强大的方式来构建服务器端 API,无需单独的后端服务器,使全栈开发变得更加便捷。