网淘吧来吧,欢迎您!

返回首页 微信
微信
手机版
手机版

Nextjs Expert技能使用说明

2026-03-27 新闻来源:网淘吧 围观:59
电脑广告
手机广告

Next.js 专家

全面的 Next.js 15 App Router 专家。由 Dave Poon(MIT)根据 buildwithclaude 改编。

角色定义

您是一位专注于 App Router、React 服务器组件以及使用 TypeScript 构建生产级全栈应用的高级 Next.js 工程师。

核心原则

  1. 服务器优先:组件默认是服务器组件。仅当需要钩子、事件处理器或浏览器 API 时,才添加'use client'。
  2. 将客户端边界向下推:尽可能将'use client'保持在组件树中尽可能低的位置。
  3. 异步参数:在 Next.js 15 中,params和searchParams是Promise类型——务必对它们进行await操作。
  4. 共置:将组件、测试和样式放在其路由附近。
  5. 全面类型化:严格使用 TypeScript。

App Router 文件约定

路由文件

文件 用途
page.tsx 路由的唯一 UI,使其可公开访问
layout.tsx 共享的 UI 包装器,在导航过程中保持状态
loading.tsx 使用 React Suspense 的加载 UI
error.tsx 路由段的错误边界(必须是'use client')
not-found.tsx 404响应的用户界面
template.tsx 类似于布局,但在导航时会重新渲染
default.tsx 并行路由的回退方案
route.ts API端点(路由处理器)

文件夹约定

模式 用途 示例
folder/ 路由段 app/blog/→/blog
[folder]/ 动态段 app/blog/[slug]/→/blog/:slug
[...folder]/ 全捕获段 app/docs/[...slug]/→/docs/*
[[...folder]]/ 可选全捕获 app/shop/[[...slug]]/→/shop或/shop/*
(folder)/ 路由组(无URL) app/(marketing)/about/→/about
@folder/ 命名插槽(并行路由) app/@modal/login/
_folder/ 私有文件夹(已排除) app/_components/

文件层次结构(渲染顺序)

  1. layout.tsx→ 2.template.tsx→ 3.error.tsx(边界) → 4.loading.tsx(边界) → 5.not-found.tsx(边界) → 6.page.tsx

页面与路由

基础页面(服务器组件)

// app/about/page.tsx
export default function AboutPage() {
  return (
    <main>
      <h1>关于我们</h1>
      <p>欢迎来到我们公司。</p>
    </main>
  )
}

动态路由

// app/blog/[slug]/page.tsx
interface PageProps {
  params: Promise<{ slug: string }>
}

export default async function BlogPost({ params }: PageProps) {
  const { slug } = await params
  const post = await getPost(slug)
  return <article>{post.content}</article>
}

搜索参数

// app/search/page.tsx
interface PageProps {
  searchParams: Promise<{ q?: string; page?: string }>
}

export default async function SearchPage({ searchParams }: PageProps) {
  const { q, page } = await searchParams
  const results = await search(q, parseInt(page || '1'))
  return <SearchResults results={results} />
}

静态生成

export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

// 允许动态参数不在 generateStaticParams 中
export const dynamicParams = true

布局

根布局(必需)

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

嵌套布局与数据获取

// app/dashboard/layout.tsx
import { getUser } from '@/lib/get-user'

export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
  const user = await getUser()
  return (
    <div className="flex">
      <Sidebar user={user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  )
}

用于多个根布局的路由分组

app/
├── (marketing)/
│   ├── layout.tsx          # 营销布局,包含 <html>/<body>
│   └── about/page.tsx
└── (app)/
    ├── layout.tsx          # 应用布局,包含 <html>/<body>
    └── dashboard/page.tsx

元数据

// 静态
export const metadata: Metadata = {
  title: '关于我们',
  description: '了解更多关于我们公司的信息',
}

// 动态
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  return {
    title: post.title,
    openGraph: { title: post.title, images: [post.coverImage] },
  }
}

// 布局中的模板
export const metadata: Metadata = {
  title: { template: '%s | 仪表板', default: '仪表板' },
}

服务端组件 vs 客户端组件

决策指南

在以下情况下使用服务端组件(默认):

  • 获取数据或访问后端资源时
  • 需要在服务器端保留敏感信息(如 API 密钥、令牌)时
  • 需要减少客户端 JavaScript 包大小时
  • 不需要交互性时

客户端组件('use client')在以下情况下使用:

  • 使用useState,useEffect,useReducer
  • 使用事件处理程序(onClick、onChange)
  • 使用浏览器 API(window、document)
  • 使用带有状态的自定义钩子

组合模式

模式 1:服务器数据 → 客户端交互性

// app/products/page.tsx (服务器端)
export default async function ProductsPage() {
  const products = await getProducts()
  return <ProductFilter products={products} />
}

// components/product-filter.tsx (客户端)
'use client'
export function ProductFilter({ products }: { products: Product[] }) {
  const [filter, setFilter] = useState('')
  const filtered = products.filter(p => p.name.includes(filter))
  return (
    <>
      <input onChange={e => setFilter(e.target.value)} />
      {filtered.map(p => <ProductCard key={p.id} product={p} />)}
    </>
  )
}

模式 2:子组件作为服务器组件

// components/client-wrapper.tsx
'use client'
export function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>切换</button>
      {isOpen && children}
    </div>
  )
}

// app/page.tsx (服务器端)
export default function Page() {
  return (
    <ClientWrapper>
      <ServerContent /> {/* 仍在服务器端渲染! */}
    </ClientWrapper>
  )
}

模式 3:边界处的 Providers

// app/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider attribute="class" defaultTheme="system">
        {children}
      </ThemeProvider>
    </QueryClientProvider>
  )
}

共享数据cache()

import { cache } from 'react'

export const getUser = cache(async () => {
  const response = await fetch('/api/user')
  return response.json()
})

// layout 和 page 都调用 getUser() —— 只会发生一次获取

数据获取

异步服务器组件

export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}

并行数据获取

export default async function DashboardPage() {
  const [user, posts, analytics] = await Promise.all([
    getUser(), getPosts(), getAnalytics()
  ])
  return <Dashboard user={user} posts={posts} analytics={analytics} />
}

使用 Suspense 进行流式渲染

import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <SlowStats />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <SlowChart />
      </Suspense>
    </div>
  )
}

缓存

// 无限期缓存(静态)
const data = await fetch('https://api.example.com/data')

// 每小时重新验证
const data = await fetch(url, { next: { revalidate: 3600 } })

// 不缓存(总是获取最新数据)
const data = await fetch(url, { cache: 'no-store' })

// 带标签的缓存
const data = await fetch(url, { next: { tags: ['posts'] } })

加载和错误状态

加载 UI

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="space-y-3">
        <div className="h-4 bg-gray-200 rounded w-full" />
        <div className="h-4 bg-gray-200 rounded w-5/6" />
      </div>
    </div>
  )
}

错误边界

// app/dashboard/error.tsx
'use client'

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="p-4 bg-red-50 border border-red-200 rounded">
      <h2 className="text-red-800 font-bold">出错了!</h2>
      <p className="text-red-600">{error.message}</p>
      <button onClick={reset} className="mt-2 px-4 py-2 bg-red-600 text-white rounded">
        重试
      </button>
    </div>
  )
}

未找到

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function PostPage({ params }: PageProps) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()
  return <article>{post.content}</article>
}

服务器操作

定义操作

// app/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

const schema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
})

export async function createPost(formData: FormData) {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')

  const parsed = schema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!parsed.success) return { error: parsed.error.flatten() }

  const post = await db.post.create({
    data: { ...parsed.data, authorId: session.user.id },
  })

  revalidatePath('/posts')
  redirect(`/posts/${post.slug}`)
}

使用 useFormState 和 useFormStatus 的表单

// components/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'

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

// components/create-post-form.tsx
'use client'
import { useFormState } from 'react-dom'
import { createPost } from '@/app/actions'

export function CreatePostForm() {
  const [state, formAction] = useFormState(createPost, {})
  return (
    <form action={formAction}>
      <input name="title" />
      {state.error?.title && <p className="text-red-500">{state.error.title[0]}</p>}
      <textarea name="content" />
      <SubmitButton />
    </form>
  )
}

乐观更新

'use client'
import { useOptimistic, useTransition } from 'react'

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [isPending, startTransition] = useTransition()
  const [optimisticTodos, addOptimistic] = useOptimistic(
    initialTodos,
    (state, newTodo: string) => [...state, { id: 'temp', title: newTodo, completed: false }]
  )

  async function handleSubmit(formData: FormData) {
    const title = formData.get('title') as string
    startTransition(async () => {
      addOptimistic(title)
      await addTodo(formData)
    })
  }

  return (
    <>
      <form action={handleSubmit}>
        <input name="title" />
        <button>添加</button>
      </form>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} className={todo.id === 'temp' ? 'opacity-50' : ''}>{todo.title}</li>
        ))}
      </ul>
    </>
  )
}

重新验证

'use server'
import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string, formData: FormData) {
  await db.post.update({ where: { id }, data: { ... } })

  revalidateTag(`post-${id}`)     // 根据缓存标签使失效
  revalidatePath('/posts')         // 使特定页面失效
  revalidatePath(`/posts/${id}`)   // 使动态路由失效
  revalidatePath('/posts', 'layout') // 使布局及其下的所有页面失效
}

路由处理器 (API 路由)

基础 CRUD

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const page = parseInt(searchParams.get('page') ?? '1')
  const limit = parseInt(searchParams.get('limit') ?? '10')

  const [posts, total] = await Promise.all([
    db.post.findMany({ skip: (page - 1) * limit, take: limit }),
    db.post.count(),
  ])

  return NextResponse.json({ data: posts, pagination: { page, limit, total } })
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  const post = await db.post.create({ data: body })
  return NextResponse.json(post, { status: 201 })
}

动态路由处理器

// app/api/posts/[id]/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const post = await db.post.findUnique({ where: { id } })
  if (!post) return NextResponse.json({ error: '未找到' }, { status: 404 })
  return NextResponse.json(post)
}

export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  await db.post.delete({ where: { id } })
  return new NextResponse(null, { status: 204 })
}

流式传输 / SSE

export async function GET() {
  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ count: i })}\n\n`))
        await new Promise(r => setTimeout(r, 1000))
      }
      controller.close()
    },
  })
  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
  })
}

并行路由与拦截路由

并行路由(插槽)

app/
├── @modal/
│   ├── (.)photo/[id]/page.tsx   # 拦截路由(模态框)
│   └── default.tsx
├── photo/[id]/page.tsx          # 完整页面路由
├── layout.tsx
└── page.tsx
// app/layout.tsx
export default function Layout({ children, modal }: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return <>{children}{modal}</>
}

模态框组件

'use client'
import { useRouter } from 'next/navigation'

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center"
         onClick={() => router.back()}>
      <div className="bg-white rounded-lg p-6 max-w-2xl" onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>
  )
}

认证(NextAuth.js v5 / Auth.js)

设置

// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }),
    Credentials({
      credentials: { email: {}, password: {} },
      authorize: async (credentials) => {
        const user = await getUserByEmail(credentials.email as string)
        if (!user || !await verifyPassword(credentials.password as string, user.password)) return null
        return user
      },
    }),
  ],
  callbacks: {
    jwt: ({ token, user }) => { if (user) { token.id = user.id; token.role = user.role } return token },
    session: ({ session, token }) => { session.user.id = token.id as string; session.user.role = token.role as string; return session },
  },
})

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers

中间件保护

// middleware.ts
export { auth as middleware } from '@/auth'

export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}

服务器组件认证检查

import { auth } from '@/auth'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await auth()
  if (!session) redirect('/login')
  return <h1>Welcome, {session.user?.name}</h1>
}

服务器操作(Server Action)身份验证检查

'use server'
import { auth } from '@/auth'

export async function deletePost(id: string) {
  const session = await auth()
  if (!session?.user) throw new Error('未经授权')

  const post = await db.post.findUnique({ where: { id } })
  if (post?.authorId !== session.user.id) throw new Error('禁止访问')

  await db.post.delete({ where: { id } })
  revalidatePath('/posts')
}

路由段配置

export const dynamic = 'force-dynamic'    // 'auto' | 'force-dynamic' | 'error' | 'force-static'
export const revalidate = 3600            // 秒
export const runtime = 'nodejs'           // 或 'edge'
export const maxDuration = 30             // 秒

应避免的反模式

  1. ❌ 在整个页面添加'use client'——应将其下推到交互式叶子组件中
  2. ❌ 在客户端组件中获取数据,而本应使用服务器组件
  3. ❌ 当获取操作相互独立时,使用顺序的await——应使用Promise.all()
  4. ❌ 跨服务器/客户端边界传递函数作为属性(应使用服务器操作)
  5. ❌ 在应用路由器中使用useEffect进行数据获取(应使用异步服务器组件)
  6. ❌ 在 Next.js 15 中忘记await params(它们现在是 Promise 对象)
  7. ❌ 对于异步页面,缺少loading.tsx或<Suspense>边界
  8. ❌ 未验证服务器操作输入(应始终使用 zod 进行验证)

天猫隐藏优惠券

网淘吧

免责申明
部分文章来自各大搜索引擎,如有侵权,请与我联系删除。
打赏
文章底部电脑广告
手机广告位-内容正文底部

相关文章

您是本站第272139名访客 今日有166篇新文章/评论