Next.js

链接与导航

深入理解 Next.js 的导航系统,掌握 Link 组件、useRouter 钩子、预取优化和客户端过渡的最佳实践。

概述

Next.js 提供了强大的导航系统,通过内置的预取、流式传输和客户端过渡确保导航快速且响应迅速。

导航工作原理

服务器渲染

Next.js 中的布局和页面默认是 React 服务器组件。在初始和后续导航时,服务器组件负载在服务器上生成后发送到客户端。

两种服务器渲染类型:

  • 静态渲染(预渲染):在构建时或重新验证期间发生,结果被缓存
  • 动态渲染:在请求时响应客户端请求发生

预取

预取是在用户导航到新路由之前在后台加载路由的过程。这使应用中的路由导航感觉即时,因为当用户点击链接时,渲染下一个路由的数据已经在客户端可用。

Next.js 在使用 <Link> 组件时自动预取进入用户视口的路由。

流式传输

流式传输允许在服务器组件准备就绪时逐步渲染它们。工作被分成块,并在准备就绪时流式传输到客户端。这使用户可以在整个内容完成渲染之前看到页面的部分内容。

客户端过渡

当导航到新路由时,Next.js 执行客户端过渡而不是完整的页面重新加载。这意味着:

  • 浏览器不会重新加载页面
  • 只有更改的路由段会重新渲染
  • 共享布局保持挂载和交互
  • React 状态被保留

<Link> 是扩展 HTML <a> 元素的 React 组件,提供预取和客户端导航。这是 Next.js 中路由间导航的主要方式。

基础用法

import Link from 'next/link'

export default function Page() {
  return <Link href="/dashboard">仪表板</Link>
}

动态路由

import Link from 'next/link'

export default function PostList({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  )
}

对象形式的 href

import Link from 'next/link'

export default function Page() {
  return (
    <Link
      href={{
        pathname: '/about',
        query: { name: 'test' },
      }}
    >
      关于
    </Link>
  )
}

href(必需)

要导航到的路径或 URL。

// 字符串形式
<Link href="/dashboard">仪表板</Link>

// 对象形式
<Link href={{ pathname: '/blog', query: { id: '1' } }}>博客</Link>

replace

替换浏览器历史记录栈中的当前条目,而不是添加新条目。默认为 false

<Link href="/dashboard" replace>
  仪表板
</Link>

scroll

导航后是否滚动到页面顶部。默认为 true

<Link href="/dashboard" scroll={false}>
  仪表板
</Link>

prefetch

控制链接的预取行为。

// 自动预取(默认)
<Link href="/dashboard">仪表板</Link>

// 禁用预取
<Link href="/dashboard" prefetch={false}>
  仪表板
</Link>

// 显式启用预取
<Link href="/dashboard" prefetch={true}>
  仪表板</Link>

预取行为:

  • 静态路由:预取完整的路由
  • 动态路由:预取到第一个 loading.js 文件的部分路由
  • prefetch={false}:仅在悬停时预取
  • prefetch={true}null:预取完整路由

onNavigate

导航开始时调用的回调函数。

'use client'

import Link from 'next/link'

export default function Page() {
  return (
    <Link
      href="/dashboard"
      onNavigate={(e) => {
        console.log('导航到:', e.href)
      }}
    >
      仪表板
    </Link>
  )
}

传递其他属性

可以将 <a> 标签属性传递给 <Link>

<Link href="/about" className="text-blue-500" target="_blank">
  关于
</Link>

活动链接

使用 usePathname() 确定链接是否活动。

'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'

export function NavLinks() {
  const pathname = usePathname()

  return (
    <nav>
      <Link
        className={pathname === '/' ? 'active' : ''}
        href="/"
      >
        首页
      </Link>
      <Link
        className={pathname === '/about' ? 'active' : ''}
        href="/about"
      >
        关于
      </Link>
    </nav>
  )
}

滚动到特定 ID

<Link href="/dashboard#settings">设置</Link>

禁用滚动恢复

<Link href="/dashboard" scroll={false}>
  仪表板
</Link>

useRouter 钩子

useRouter 钩子允许在客户端组件中以编程方式更改路由。

建议:除非有特定需求使用 useRouter,否则使用 <Link> 组件进行导航。

基础用法

'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
  const router = useRouter()

  return (
    <button onClick={() => router.push('/dashboard')}>
      前往仪表板
    </button>
  )
}

router.push()

执行客户端导航到提供的路由。在浏览器历史记录栈中添加新条目。

'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
  const router = useRouter()

  return (
    <button onClick={() => router.push('/dashboard')}>
      仪表板
    </button>
  )
}

禁用滚动到顶部:

router.push('/dashboard', { scroll: false })

router.replace()

执行客户端导航到提供的路由,但不在浏览器历史记录栈中添加新条目。

'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
  const router = useRouter()

  return (
    <button onClick={() => router.replace('/dashboard')}>
      仪表板
    </button>
  )
}

router.refresh()

刷新当前路由。向服务器发出新请求,重新获取数据请求,并重新渲染服务器组件。

'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
  const router = useRouter()

  return (
    <button onClick={() => router.refresh()}>
      刷新
    </button>
  )
}

客户端会合并更新的 React 服务器组件负载,而不会丢失未受影响的客户端 React 状态(如 useState)或浏览器状态(如滚动位置)。

router.prefetch()

预取提供的路由以实现更快的客户端过渡。

'use client'

import { useRouter } from 'next/navigation'
import { useEffect } from 'react'

export default function Page() {
  const router = useRouter()

  useEffect(() => {
    router.prefetch('/dashboard')
  }, [router])

  return <button onClick={() => router.push('/dashboard')}>仪表板</button>
}

带失效回调:

router.prefetch('/dashboard', {
  onInvalidate: () => {
    console.log('预取的数据已失效')
  },
})

router.back()

导航回浏览器历史记录栈中的上一个路由。

'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
  const router = useRouter()

  return (
    <button onClick={() => router.back()}>
      返回
    </button>
  )
}

router.forward()

导航到浏览器历史记录栈中的下一个页面。

'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
  const router = useRouter()

  return (
    <button onClick={() => router.forward()}>
      前进
    </button>
  )
}

预取详解

预取使应用中不同路由之间的导航感觉即时。Next.js 根据应用代码中使用的链接智能地默认预取。

自动预取

Next.js 在使用 <Link> 组件时自动预取进入用户视口的路由。

import Link from 'next/link'

export default function Page() {
  return (
    <div>
      {/* 这些链接会在进入视口时自动预取 */}
      <Link href="/about">关于</Link>
      <Link href="/blog">博客</Link>
    </div>
  )
}

静态 vs 动态路由预取

静态页面动态页面
预取内容完整路由到第一个 loading.js 的部分路由
缓存时间5 分钟30 秒

手动预取

使用 router.prefetch() 手动预取路由。

'use client'

import { useRouter } from 'next/navigation'
import { useEffect } from 'react'

export default function Page() {
  const router = useRouter()

  useEffect(() => {
    // 组件挂载时预取
    router.prefetch('/dashboard')
  }, [router])

  return <button onClick={() => router.push('/dashboard')}>仪表板</button>
}

悬停触发预取

仅在用户悬停时预取,减少资源使用。

'use client'

import Link from 'next/link'
import { useState } from 'react'

export function HoverPrefetchLink({
  href,
  children,
}: {
  href: string
  children: React.ReactNode
}) {
  const [active, setActive] = useState(false)

  return (
    <Link
      href={href}
      prefetch={active ? null : false}
      onMouseEnter={() => setActive(true)}
    >
      {children}
    </Link>
  )
}

禁用预取

对于大量链接列表,可以禁用预取以避免不必要的资源使用。

<Link href={`/blog/${post.id}`} prefetch={false}>
  {post.title}
</Link>

重定向

redirect 函数

redirect 函数允许将用户重定向到另一个 URL。可以在服务器组件、路由处理程序和服务器操作中调用。

import { redirect } from 'next/navigation'

export default async function Page() {
  const user = await getUser()

  if (!user) {
    redirect('/login')
  }

  return <div>欢迎,{user.name}</div>
}

在服务器操作中:

'use server'

import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const post = await savePost(formData)
  redirect(`/blog/${post.slug}`)
}
  • redirect 默认返回 307(临时重定向)状态码
  • 在服务器操作中返回 303(查看其他)
  • 可以接受绝对 URL 并重定向到外部链接

permanentRedirect 函数

permanentRedirect 函数允许永久重定向用户到另一个 URL。

import { permanentRedirect } from 'next/navigation'

export default async function Page() {
  const user = await getUser()

  if (user.username !== user.canonicalUsername) {
    permanentRedirect(`/users/${user.canonicalUsername}`)
  }

  return <div>用户资料</div>
}
  • permanentRedirect 默认返回 308(永久重定向)状态码
  • 常用于更改实体的规范 URL 后
  • 可以接受绝对 URL 并重定向到外部链接

使用原生 History API

window.history.pushState

更新浏览器历史记录栈而不重新加载页面。

'use client'

import { useSearchParams } from 'next/navigation'

export function SortProducts() {
  const searchParams = useSearchParams()

  function updateSorting(sortOrder: string) {
    const params = new URLSearchParams(searchParams.toString())
    params.set('sort', sortOrder)
    window.history.pushState(null, '', `?${params.toString()}`)
  }

  return (
    <>
      <button onClick={() => updateSorting('asc')}>升序</button>
      <button onClick={() => updateSorting('desc')}>降序</button>
    </>
  )
}

window.history.replaceState

替换浏览器历史记录栈中的当前条目。用户无法导航回上一个状态。

'use client'

import { usePathname } from 'next/navigation'

export function LocaleSwitcher() {
  const pathname = usePathname()

  function switchLocale(locale: string) {
    const newPath = `/${locale}${pathname}`
    window.history.replaceState(null, '', newPath)
  }

  return (
    <>
      <button onClick={() => switchLocale('en')}>English</button>
      <button onClick={() => switchLocale('zh')}>中文</button>
    </>
  )
}

导航优化

动态路由优化

对于没有 loading.js 的动态路由,可以添加加载状态以改善用户体验。

// app/blog/[slug]/loading.tsx
export default function Loading() {
  return <div>加载中...</div>
}

慢网络优化

使用 loading.js 和流式传输为慢网络提供即时反馈。

// 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="h-64 bg-gray-200 rounded" />
    </div>
  )
}

实际应用示例

导航菜单

'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

const links = [
  { href: '/', label: '首页' },
  { href: '/about', label: '关于' },
  { href: '/blog', label: '博客' },
  { href: '/contact', label: '联系' },
]

export function Navigation() {
  const pathname = usePathname()

  return (
    <nav className="flex gap-4">
      {links.map((link) => (
        <Link
          key={link.href}
          href={link.href}
          className={
            pathname === link.href
              ? 'text-blue-600 font-bold'
              : 'text-gray-600 hover:text-gray-900'
          }
        >
          {link.label}
        </Link>
      ))}
    </nav>
  )
}

面包屑导航

'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

export function Breadcrumbs() {
  const pathname = usePathname()
  const segments = pathname.split('/').filter(Boolean)

  return (
    <nav className="flex gap-2">
      <Link href="/">首页</Link>
      {segments.map((segment, index) => {
        const href = `/${segments.slice(0, index + 1).join('/')}`
        const isLast = index === segments.length - 1

        return (
          <span key={href} className="flex gap-2">
            <span>/</span>
            {isLast ? (
              <span className="font-bold">{segment}</span>
            ) : (
              <Link href={href}>{segment}</Link>
            )}
          </span>
        )
      })}
    </nav>
  )
}

分页

'use client'

import Link from 'next/link'
import { useSearchParams } from 'next/navigation'

export function Pagination({ totalPages }: { totalPages: number }) {
  const searchParams = useSearchParams()
  const currentPage = Number(searchParams.get('page')) || 1

  return (
    <div className="flex gap-2">
      {currentPage > 1 && (
        <Link href={`?page=${currentPage - 1}`}>上一页</Link>
      )}
      {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
        <Link
          key={page}
          href={`?page=${page}`}
          className={page === currentPage ? 'font-bold' : ''}
        >
          {page}
        </Link>
      ))}
      {currentPage < totalPages && (
        <Link href={`?page=${currentPage + 1}`}>下一页</Link>
      )}
    </div>
  )
}

搜索功能

'use client'

import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'

export function SearchBar() {
  const router = useRouter()
  const searchParams = useSearchParams()
  const [query, setQuery] = useState(searchParams.get('q') || '')

  function handleSearch(e: React.FormEvent) {
    e.preventDefault()
    const params = new URLSearchParams(searchParams.toString())
    if (query) {
      params.set('q', query)
    } else {
      params.delete('q')
    }
    router.push(`/search?${params.toString()}`)
  }

  return (
    <form onSubmit={handleSearch}>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
      />
      <button type="submit">搜索</button>
    </form>
  )
}

模态框导航

'use client'

import { useRouter } from 'next/navigation'
import { useEffect } from 'react'

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter()

  useEffect(() => {
    // 按 ESC 键关闭
    function handleEscape(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        router.back()
      }
    }
    window.addEventListener('keydown', handleEscape)
    return () => window.removeEventListener('keydown', handleEscape)
  }, [router])

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center">
      <div className="bg-white p-6 rounded-lg max-w-2xl w-full">
        <button
          onClick={() => router.back()}
          className="float-right text-gray-500"
        >

        </button>
        {children}
      </div>
    </div>
  )
}

最佳实践

对于声明式导航,始终使用 <Link> 组件。

// 推荐
<Link href="/dashboard">仪表板</Link>

// 避免
<a href="/dashboard">仪表板</a>

2. 仅在必要时使用 useRouter

仅在需要编程式导航时使用 useRouter

// 适用场景:表单提交后导航
async function handleSubmit() {
  await saveData()
  router.push('/success')
}

// 不必要:可以使用 Link
<button onClick={() => router.push('/about')}>关于</button>

3. 合理使用预取

对于大量链接,考虑禁用或延迟预取。

// 大量链接列表
{posts.map((post) => (
  <Link href={`/blog/${post.id}`} prefetch={false}>
    {post.title}
  </Link>
))}

// 或使用悬停预取
<HoverPrefetchLink href={`/blog/${post.id}`}>
  {post.title}
</HoverPrefetchLink>

4. 添加加载状态

为动态路由添加 loading.js 以改善用户体验。

// app/blog/[slug]/loading.tsx
export default function Loading() {
  return <Skeleton />
}

5. 使用活动链接样式

为当前页面的链接添加视觉反馈。

const pathname = usePathname()

<Link
  href="/about"
  className={pathname === '/about' ? 'active' : ''}
>
  关于
</Link>

总结

Next.js 的导航系统提供了强大而优化的路由体验:

  • Link 组件:主要的导航方式,提供自动预取和客户端过渡
  • useRouter 钩子:用于编程式导航和高级用例
  • 预取:自动或手动预取路由以实现即时导航
  • 重定向:使用 redirectpermanentRedirect 进行服务器端重定向
  • 优化:通过流式传输、加载状态和智能预取优化导航体验

理解并正确使用这些功能,可以构建快速、响应迅速的 Next.js 应用。

在 GitHub 上编辑

上次更新于