导航与路由过渡

在 Next.js 中,导航不仅仅是跳转页面——它涉及 Link 预加载、useRouter 编程式导航、usePathname/useSearchParams 状态同步、以及 View Transitions 动画。本章逐一讲清这些导航 API 的使用场景、底层行为和最佳实践。

1.1 基础使用

import Link from 'next/link'
 
export function Navigation() {
  return (
    <nav>
      <Link href="/chat">对话</Link>
      <Link href="/knowledge">知识库</Link>
      <Link href="/settings">设置</Link>
    </nav>
  )
}

&lt;Link&gt; 的核心行为:

  • 渲染为 &lt;a&gt; 标签(SEO 友好)
  • 客户端导航(不刷新页面,布局状态保持)
  • 自动预加载(Link 进入视口时预加载目标页面数据)

1.2 动态链接

// 动态路由
<Link href={`/chat/${chatId}`}>查看对话</Link>
 
// 带查询参数
<Link href={{ pathname: '/chat', query: { tab: 'history' } }}>
  历史记录
</Link>
 
// 带 Hash
<Link href="/docs#installation">安装章节</Link>

1.3 Prefetch 预加载控制

// 默认:预加载静态部分(布局 + loading 组件)
<Link href="/settings">设置</Link>
 
// 禁用预加载(节省带宽,适用于大量链接的列表)
<Link href={`/chat/${id}`} prefetch={false}>
  {title}
</Link>
 
// 完整预加载(包括动态数据,适用于高频访问链接)
<Link href="/chat" prefetch={true}>对话</Link>

1.4 活跃链接样式

Next.js 没有内置 activeClassName,需要结合 usePathname 实现:

'use client'
 
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
 
const navItems = [
  { href: '/chat', label: '对话', icon: MessageSquare },
  { href: '/knowledge', label: '知识库', icon: BookOpen },
  { href: '/settings', label: '设置', icon: Settings },
]
 
export function SidebarNav() {
  const pathname = usePathname()
 
  return (
    <nav className="space-y-1">
      {navItems.map((item) => (
        <Link
          key={item.href}
          href={item.href}
          className={cn(
            'flex items-center gap-3 rounded-lg px-3 py-2 text-sm',
            pathname.startsWith(item.href)
              ? 'bg-primary/10 text-primary font-medium'
              : 'text-muted-foreground hover:bg-accent',
          )}
        >
          <item.icon className="h-4 w-4" />
          {item.label}
        </Link>
      ))}
    </nav>
  )
}
场景使用原因
站内导航&lt;Link&gt;客户端导航 + 预加载
外部链接&lt;a&gt;需要完整页面加载
下载文件&lt;a download&gt;Link 不支持 download
邮件链接&lt;a href="mailto:"&gt;非 HTTP 协议
新标签页&lt;Link target="_blank"&gt;Link 也支持 target

2. useRouter:编程式导航

2.1 基础使用

'use client'
 
import { useRouter } from 'next/navigation'
 
export function ChatActions({ chatId }: { chatId: string }) {
  const router = useRouter()
 
  async function handleDelete() {
    await deleteChat(chatId)
    router.push('/chat') // 导航到对话列表
  }
 
  return <Button onClick={handleDelete}>删除对话</Button>
}

2.2 所有导航方法

const router = useRouter()
 
// 导航到指定路径
router.push('/chat')
 
// 替换当前历史记录(不能后退)
router.replace('/chat')
 
// 后退
router.back()
 
// 前进
router.forward()
 
// 刷新当前页面的 Server Component 数据(不刷新整个页面)
router.refresh()
 
// 预加载指定路径
router.prefetch('/settings')

2.3 router.refresh() 的妙用

router.refresh()重新执行当前路由的 Server Components(重新获取数据),但不刷新整个页面(Client Component 状态保持):

'use client'
 
import { useRouter } from 'next/navigation'
 
export function SendMessageButton({ chatId }: { chatId: string }) {
  const router = useRouter()
 
  async function handleSend(message: string) {
    await sendMessage(chatId, message) // Server Action
    router.refresh() // 重新获取消息列表(Server Component 数据刷新)
    // Client Component 的输入框状态保持!
  }
 
  return <Button onClick={() => handleSend('Hello')}>发送</Button>
}
场景推荐
静态导航链接&lt;Link&gt; — 语义化、SEO、预加载
表单提交后跳转router.push() — 编程式
删除后返回列表router.replace() — 不保留历史
数据更新后刷新router.refresh() — 只刷新 Server 数据
条件性导航router.push() — 根据逻辑决定去哪

3. 路由状态 Hooks

3.1 usePathname

获取当前 URL 的路径部分:

'use client'
 
import { usePathname } from 'next/navigation'
 
export function Breadcrumb() {
  const pathname = usePathname()
  // pathname = "/chat/abc/messages"
 
  const segments = pathname.split('/').filter(Boolean)
  // segments = ["chat", "abc", "messages"]
 
  return (
    <nav className="flex gap-2 text-sm text-muted-foreground">
      {segments.map((segment, index) => (
        <span key={index}>
          {index > 0 && ' / '}
          {segment}
        </span>
      ))}
    </nav>
  )
}

3.2 useSearchParams

获取和操作 URL 查询参数:

'use client'
 
import { useSearchParams, usePathname, useRouter } from 'next/navigation'
 
export function ChatFilter() {
  const searchParams = useSearchParams()
  const pathname = usePathname()
  const router = useRouter()
 
  const currentModel = searchParams.get('model') ?? 'all'
 
  function setFilter(model: string) {
    const params = new URLSearchParams(searchParams.toString())
    if (model === 'all') {
      params.delete('model')
    } else {
      params.set('model', model)
    }
    router.push(`${pathname}?${params.toString()}`)
  }
 
  return (
    <div className="flex gap-2">
      {['all', 'gpt-4o', 'claude-3.5'].map((model) => (
        <Button
          key={model}
          variant={currentModel === model ? 'default' : 'outline'}
          onClick={() => setFilter(model)}
        >
          {model}
        </Button>
      ))}
    </div>
  )
}

3.3 useParams

获取动态路由参数(客户端版的 params):

'use client'
 
import { useParams } from 'next/navigation'
 
export function ChatHeader() {
  const params = useParams<{ id: string }>()
  // params.id = "abc"
 
  return <h1>对话 {params.id}</h1>
}

3.4 Server Component 中获取路由信息

Server Component 不能使用 Hooks。路由信息通过 props 传入:

// Server Component — 通过 props 获取
export default async function ChatPage({
  params,
  searchParams,
}: {
  params: Promise<{ id: string }>
  searchParams: Promise<{ tab?: string }>
}) {
  const { id } = await params
  const { tab } = await searchParams
  // ...
}

也可以使用 next/headers

import { headers } from 'next/headers'
 
export default async function Page() {
  const headersList = await headers()
  const referer = headersList.get('referer')
  // ...
}

4. 导航事件与加载状态

4.1 useTransition 实现导航加载状态

'use client'
 
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
 
export function NavigationButton({ href, children }: { href: string; children: React.ReactNode }) {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()
 
  return (
    <button
      onClick={() => startTransition(() => router.push(href))}
      disabled={isPending}
      className={isPending ? 'opacity-50' : ''}
    >
      {isPending ? '加载中...' : children}
    </button>
  )
}

4.2 全局导航进度条

使用 next-nprogress-bar 或自定义实现:

// components/navigation-progress.tsx
'use client'
 
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import NProgress from 'nprogress'
 
export function NavigationProgress() {
  const pathname = usePathname()
 
  useEffect(() => {
    NProgress.done()
  }, [pathname])
 
  return null
}

更推荐使用 loading.tsx——它是 Next.js 内置的、基于 Suspense 的加载状态方案,不需要额外库。

5. View Transitions API

5.1 原生 View Transitions

Next.js 16 实验性支持浏览器原生 View Transitions API:

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    viewTransition: true,
  },
}
/* globals.css */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-out;
}
 
::view-transition-new(root) {
  animation: fade-in 0.2s ease-in;
}
 
@keyframes fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}
 
@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

5.2 基于 Framer Motion 的页面过渡

更成熟的方案——使用 Framer Motion:

// app/(dashboard)/template.tsx
'use client'
 
import { motion } from 'framer-motion'
 
export default function Template({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 10 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.2, ease: 'easeOut' }}
    >
      {children}
    </motion.div>
  )
}

注意:template.tsx 在每次导航时重新挂载(不同于 layout.tsx),这正是页面过渡动画所需要的。

6. 常见导航模式

6.1 登录后跳回原页面

// middleware.ts — 保存 callbackUrl
if (!isLoggedIn) {
  const loginUrl = new URL('/login', request.url)
  loginUrl.searchParams.set('callbackUrl', pathname)
  return NextResponse.redirect(loginUrl)
}
 
// app/(auth)/login/page.tsx — 登录成功后跳回
'use client'
 
export function LoginForm() {
  const searchParams = useSearchParams()
  const callbackUrl = searchParams.get('callbackUrl') ?? '/chat'
  const router = useRouter()
 
  async function onSubmit(data: LoginData) {
    const result = await signIn('credentials', { ...data, redirect: false })
    if (result?.ok) {
      router.push(callbackUrl)
    }
  }
}

6.2 表单提交后导航

'use client'
 
import { useRouter } from 'next/navigation'
import { createChat } from './actions'
 
export function NewChatForm() {
  const router = useRouter()
 
  async function handleSubmit(formData: FormData) {
    const result = await createChat(formData)
    if (result.data) {
      router.push(`/chat/${result.data.id}`)
    }
  }
 
  return (
    <form action={handleSubmit}>
      <input name="title" required />
      <button type="submit">创建对话</button>
    </form>
  )
}

6.3 URL 状态管理

将 UI 状态同步到 URL,实现可分享、可书签的状态:

'use client'
 
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { useCallback } from 'react'
 
export function useQueryState(key: string, defaultValue: string = '') {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
 
  const value = searchParams.get(key) ?? defaultValue
 
  const setValue = useCallback(
    (newValue: string) => {
      const params = new URLSearchParams(searchParams.toString())
      if (newValue === defaultValue) {
        params.delete(key)
      } else {
        params.set(key, newValue)
      }
      const queryString = params.toString()
      router.push(queryString ? `${pathname}?${queryString}` : pathname)
    },
    [key, defaultValue, pathname, searchParams, router],
  )
 
  return [value, setValue] as const
}
 
// 使用
function ChatPage() {
  const [tab, setTab] = useQueryState('tab', 'messages')
  // URL: /chat/abc?tab=settings
  // tab = "settings"
}

本章小结

  • Link 组件:声明式导航,自动预加载,配合 usePathname 实现活跃链接样式
  • useRouter:编程式导航,push/replace/back/refresh 四个核心方法
  • 路由状态 HooksusePathname(路径)、useSearchParams(查询参数)、useParams(动态参数)
  • router.refresh():只刷新 Server Component 数据,Client 状态保持——AI 对话场景的关键
  • View Transitions:原生 API 实验性支持 + Framer Motion template.tsx 方案更成熟
  • URL 状态管理:将 UI 状态同步到 URL,实现可分享、可书签的体验

下一章讲路由高级模式。