跳到主要内容

链接和导航

在 Next.js 中,路由默认在服务器上渲染。这通常意味着客户端必须等待服务器响应才能显示新路由。Next.js 内置了预取流式渲染客户端过渡功能,确保导航保持快速和响应。

本指南解释了 Next.js 中导航的工作原理,以及如何为动态路由慢速网络优化导航。

导航的工作原理

要理解 Next.js 中导航的工作原理,需要熟悉以下概念:

服务端渲染

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

根据何时发生,有两种类型的服务端渲染:

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

服务端渲染的权衡是客户端必须等待服务器响应才能显示新路由。Next.js 通过预取用户可能访问的路由并执行客户端过渡来解决这种延迟。

提示: 初始访问时也会生成 HTML。

预取

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

<Link> 组件进入用户的视口时,Next.js 会自动预取链接的路由。

app/layout.tsx
import Link from 'next/link'

export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<nav>
{/* 当链接被悬停或进入视口时预取 */}
<Link href="/blog">Blog</Link>
{/* 不预取 */}
<a href="/contact">Contact</a>
</nav>
{children}
</body>
</html>
)
}

预取路由的多少取决于它是静态还是动态:

  • 静态路由: 完整路由被预取。
  • 动态路由: 跳过预取,或者如果存在 loading.tsx 则部分预取路由。

通过跳过或部分预取动态路由,Next.js 避免了为用户可能永远不会访问的路由在服务器上进行不必要的工作。但是,在导航前等待服务器响应可能会让用户感觉应用程序没有响应。

没有流式渲染的服务端渲染没有流式渲染的服务端渲染

为了改善动态路由的导航体验,你可以使用流式渲染

流式渲染

流式渲染允许服务器在动态路由的部分内容准备好时立即发送给客户端,而不是等待整个路由渲染完成。这意味着用户能更快看到内容,即使页面的某些部分仍在加载。

对于动态路由,这意味着它们可以部分预取。也就是说,共享布局和加载骨架屏可以提前请求。

带流式渲染的服务端渲染工作原理带流式渲染的服务端渲染工作原理

要使用流式渲染,在路由文件夹中创建 loading.tsx

loading.js 特殊文件loading.js 特殊文件
app/dashboard/loading.tsx
export default function Loading() {
// 添加在路由加载时显示的备用 UI。
return <LoadingSkeleton />
}

在幕后,Next.js 会自动将 page.tsx 内容包装在 <Suspense> 边界中。当路由加载时会显示预取的备用 UI,一旦准备好就会替换为实际内容。

提示: 你也可以使用 <Suspense> 为嵌套组件创建加载 UI。

loading.tsx 的好处:

  • 为用户提供即时导航和视觉反馈。
  • 共享布局保持交互性,导航可中断。
  • 改善核心 Web 指标:TTFBFCPTTI

为了进一步改善导航体验,Next.js 使用 <Link> 组件执行客户端过渡

客户端过渡

传统上,导航到服务端渲染的页面会触发完整的页面加载。这会清除状态、重置滚动位置并阻止交互性。

Next.js 使用 <Link> 组件通过客户端过渡避免这种情况。它不是重新加载页面,而是通过以下方式动态更新内容:

  • 保持任何共享布局和 UI。
  • 用预取的加载状态或新页面(如果可用)替换当前页面。

客户端过渡使服务端渲染的应用程序感觉像客户端渲染的应用程序。当与预取流式渲染结合时,它实现了快速过渡,即使是动态路由也是如此。

什么会导致过渡变慢?

这些 Next.js 优化使导航快速和响应。但是,在某些条件下,过渡仍然可能感觉很慢。以下是一些常见原因以及如何改善用户体验:

没有 loading.tsx 的动态路由

当导航到动态路由时,客户端必须等待服务器响应才能显示结果。这可能会让用户感觉应用程序没有响应。

我们建议为动态路由添加 loading.tsx 以启用部分预取、触发即时导航,并在路由渲染时显示加载 UI。

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

提示: 在开发模式下,你可以使用 Next.js Devtools 来识别路由是静态还是动态。有关更多信息,请参阅 devIndicators

没有 generateStaticParams 的动态段

如果动态段可以被预渲染但由于缺少 generateStaticParams 而没有预渲染,路由将在请求时回退到动态渲染。

通过添加 generateStaticParams 确保路由在构建时静态生成:

app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())

return posts.map((post) => ({
slug: post.slug,
}))
}

export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
// ...
}

慢速网络

在慢速或不稳定的网络上,预取可能在用户点击链接之前无法完成。这可能影响静态和动态路由。在这些情况下,loading.js 备用可能不会立即出现,因为它还没有被预取。

为了改善感知性能,你可以使用 useLinkStatus 钩子 在过渡进行时向用户显示内联视觉反馈(如链接上的旋转器或文本闪烁)。

app/ui/loading-indicator.tsx
'use client'

import { useLinkStatus } from 'next/link'

export default function LoadingIndicator() {
const { pending } = useLinkStatus()
return pending ? (
<div role="status" aria-label="Loading" className="spinner" />
) : null
}

你可以通过添加初始动画延迟(例如 100ms)并以不可见状态开始动画(例如 opacity: 0)来"防抖"加载指示器。这意味着只有在导航时间超过指定延迟时才会显示加载指示器。

.spinner {
/* ... */
opacity: 0;
animation:
fadeIn 500ms 100ms forwards,
rotate 1s linear infinite;
}

@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes rotate {
to {
transform: rotate(360deg);
}
}

提示: 你可以使用其他视觉反馈模式,如进度条。查看示例这里

禁用预取

你可以通过在 <Link> 组件上将 prefetch 属性设置为 false 来选择退出预取。这在渲染大量链接列表(例如无限滚动表格)时避免不必要的资源使用很有用。

<Link prefetch={false} href="/blog">
Blog
</Link>

但是,禁用预取会带来权衡:

  • 静态路由只有在用户点击链接时才会被获取。
  • 动态路由需要先在服务器上渲染,然后客户端才能导航到它。

为了在不完全禁用预取的情况下减少资源使用,你可以只在悬停时预取。这限制了预取到用户更可能访问的路由,而不是视口中的所有链接。

app/ui/hover-prefetch-link.tsx
'use client'

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

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> 是一个客户端组件,必须在水合后才能预取路由。在初始访问时,大型 JavaScript 包可能会延迟水合,导致预取无法立即开始。

React 通过选择性水合来缓解这个问题,你可以通过以下方式进一步改善:

示例

原生 History API

Next.js 允许你使用原生 window.history.pushStatewindow.history.replaceState 方法来更新浏览器的历史堆栈,而无需重新加载页面。

pushStatereplaceState 调用集成到 Next.js 路由器中,允许你与 usePathnameuseSearchParams 同步。

window.history.pushState

使用它向浏览器的历史堆栈添加新条目。用户可以导航回之前的状态。例如,对产品列表进行排序:

app/ui/sort-products.tsx
'use client'

import { useSearchParams } from 'next/navigation'

export default 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

使用它替换浏览器历史堆栈上的当前条目。用户无法导航回之前的状态。例如,切换应用程序的语言环境:

app/ui/locale-switcher.tsx
'use client'

import { usePathname } from 'next/navigation'

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

function switchLocale(locale: string) {
// 例如 '/en/about' 或 '/fr/contact'
const newPath = `/${locale}${pathname}`
window.history.replaceState(null, '', newPath)
}

return (
<>
<button onClick={() => switchLocale('en')}>English</button>
<button onClick={() => switchLocale('fr')}>French</button>
</>
)
}