Next.js 14 App Router路由架构与Server Actions服务端数据处理实战

Next.js 14的App Router引入了基于React Server Components的路由系统,配合Server Actions实现了前后端一体化的数据交互模式。Server Actions允许在服务端组件中直接定义处理函数,前端调用时自动通过POST请求传输,无需手写API路由。这种模式简化了全栈开发流程,同时保持了类型安全和SEO优势。

App Router目录结构与路由约定

App Router使用文件系统路由,app目录下的文件夹结构直接映射为URL路径:

app/
├── layout.tsx          # 根布局,所有页面共享
├── page.tsx            # 首页 /
├── loading.tsx         # 全局加载UI
├── error.tsx           # 全局错误边界
├── not-found.tsx       # 404页面
├── globals.css         # 全局样式
├── (marketing)/       # 路由组,不影响URL
│   ├── about/
│   │   └── page.tsx   # /about
│   └── pricing/
│       └── page.tsx   # /pricing
├── dashboard/
│   ├── layout.tsx     # dashboard专属布局
│   ├── page.tsx       # /dashboard
│   ├── settings/
│   │   └── page.tsx   # /dashboard/settings
│   └── users/
│       ├── page.tsx   # /dashboard/users
│       └── [id]/      # 动态路由
│           └── page.tsx # /dashboard/users/123
├── api/
│   └── webhook/
│       └── route.ts   # API路由(传统模式)
└── sitemap.ts         # 动态站点地图

关键约定:每个路由文件夹内的page.tsx是路由的UI入口,layout.tsx定义该层级的共享布局。路由组用()包裹,路径不显示在URL中,用于组织代码结构。动态路由用[]包裹参数名。

Server Component与Client Component划分策略

App Router默认所有组件都是Server Component,仅在需要交互时才添加use client指令。合理划分两者边界直接影响性能:

// app/dashboard/users/page.tsx
// Server Component - 默认在服务端渲染
import { db } from '@/lib/database'
import { UserTable } from './UserTable'
import { Pagination } from './Pagination'

interface PageProps {
  searchParams: { page?: string; q?: string }
}

// 服务端获取数据,直接访问数据库
async function getUsers(page: number, query: string) {
  const pageSize = 20
  const offset = (page - 1) * pageSize
  
  const users = await db.user.findMany({
    where: query ? {
      OR: [
        { name: { contains: query, mode: 'insensitive' } },
        { email: { contains: query, mode: 'insensitive' } }
      ]
    } : undefined,
    select: { id: true, name: true, email: true, role: true, createdAt: true },
    skip: offset,
    take: pageSize,
    orderBy: { createdAt: 'desc' }
  })
  
  const total = await db.user.count({
    where: query ? {
      OR: [
        { name: { contains: query, mode: 'insensitive' } },
        { email: { contains: query, mode: 'insensitive' } }
      ]
    } : undefined
  })
  
  return { users, total, totalPages: Math.ceil(total / pageSize) }
}

export default async function UsersPage({ searchParams }: PageProps) {
  const page = parseInt(searchParams.page || '1')
  const query = searchParams.q || ''
  const { users, totalPages } = await getUsers(page, query)
  
  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">用户管理</h1>
      <UserTable users={users} />
      <Pagination currentPage={page} totalPages={totalPages} />
    </div>
  )
}
// app/dashboard/users/UserTable.tsx
'use client'  // 标记为Client Component

import { useState } from 'react'
import { deleteUserAction } from './actions'

export function UserTable({ users }: { users: User[] }) {
  const [selected, setSelected] = useState<string[]>([])
  const [deleting, setDeleting] = useState(false)
  
  const handleDelete = async () => {
    setDeleting(true)
    // 调用Server Action,无需手写API
    await deleteUserAction(selected)
    setDeleting(false)
    setSelected([])
    window.location.reload()
  }
  
  return (
    <div>
      <table>
        <thead>
          <tr>
            <th><input type="checkbox" /></th>
            <th>姓名</th>
            <th>邮箱</th>
            <th>角色</th>
          </tr>
        </thead>
        <tbody>
          {users.map(user => (
            <tr key={user.id}>
              <td>
                <input 
                  type="checkbox" 
                  checked={selected.includes(user.id)}
                  onChange={(e) => {
                    if (e.target.checked) {
                      setSelected([...selected, user.id])
                    } else {
                      setSelected(selected.filter(id => id !== user.id))
                    }
                  }}
                />
              </td>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.role}</td>
            </tr>
          ))}
        </tbody>
      </table>
      {selected.length > 0 && (
        <button onClick={handleDelete} disabled={deleting}>
          {deleting ? '删除中...' : `删除选中(${selected.length})`}
        </button>
      )}
    </div>
  )
}

Server Actions表单处理与数据验证

Server Actions用use server指令标记,可以在服务端组件中内联定义或单独文件导出:

// app/dashboard/users/actions.ts
'use server'

import { db } from '@/lib/database'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

// Zod schema验证
const UserSchema = z.object({
  name: z.string().min(2, '姓名至少2个字符').max(50),
  email: z.string().email('邮箱格式不正确'),
  role: z.enum(['admin', 'editor', 'viewer']),
})

export async function createUserAction(formData: FormData) {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    role: formData.get('role'),
  }
  
  const result = UserSchema.safeParse(raw)
  if (!result.success) {
    return { 
      success: false, 
      errors: result.error.flatten().fieldErrors 
    }
  }
  
  try {
    const user = await db.user.create({
      data: result.data
    })
    revalidatePath('/dashboard/users')
    return { success: true, user }
  } catch (error) {
    return { success: false, errors: { _form: ['创建用户失败'] } }
  }
}

export async function deleteUserAction(ids: string[]) {
  if (ids.length === 0) return
  try {
    await db.user.deleteMany({
      where: { id: { in: ids } }
    })
    revalidatePath('/dashboard/users')
    return { success: true }
  } catch (error) {
    return { success: false, error: '批量删除失败' }
  }
}

// 使用revalidateTag实现精细化的缓存控制
export async function updateUserProfileAction(formData: FormData) {
  const userId = formData.get('userId') as string
  
  await db.user.update({
    where: { id: userId },
    data: {
      name: formData.get('name'),
      bio: formData.get('bio'),
    }
  })
  
  revalidateTag(`user-${userId}`)
  revalidatePath('/dashboard/users')
}

Server Actions与progressive enhancement表单

Server Actions支持progressive enhancement,即使JavaScript未加载,表单也能通过传统POST提交工作:

// app/dashboard/users/create/page.tsx
import { createUserAction } from '../actions'
import { useFormState, useFormStatus } from 'react-dom'

export default function CreateUserPage() {
  const [state, formAction] = useFormState(createUserAction, {
    success: false,
    errors: {}
  })
  
  return (
    <form action={formAction}>
      <div>
        <label>姓名</label>
        <input type="text" name="name" required />
        {state.errors?.name && (
          <span className="error">{state.errors.name[0]}</span>
        )}
      </div>
      
      <div>
        <label>邮箱</label>
        <input type="email" name="email" required />
        {state.errors?.email && (
          <span className="error">{state.errors.email[0]}</span>
        )}
      </div>
      
      <div>
        <label>角色</label>
        <select name="role">
          <option value="viewer">查看者</option>
          <option value="editor">编辑者</option>
          <option value="admin">管理员</option>
        </select>
      </div>
      
      <SubmitButton />
      
      {state.success && (
        <p className="success">用户创建成功</p>
      )}
    </form>
  )
}

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '创建用户'}
    </button>
  )
}

关键点:useFormState替代了传统的useState+onSubmit模式,表单action直接绑定Server Action。useFormStatus提供提交状态,用于禁用按钮和显示loading。revalidatePath在Action执行后自动刷新对应路由的Server Component缓存数据。

安全注意事项:Server Actions本质是HTTP POST请求,任何能访问到该URL的客户端都可以调用。需要在Action内部做权限校验,不能依赖前端的按钮禁用来阻止未授权操作。使用Next.js中间件或Action内的Session检查确保访问控制。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/nextjs14approuter-lu-you-jia-gou-yu-serveractions-fu-wu/

(0)
小编小编
上一篇 17小时前
下一篇 17小时前

相关推荐