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

本页介绍了 Next.js 中的导航工作原理以及如何针对动态路由和慢速网络对其进行优化

导航的工作原理

服务端渲染

在 Next.js 中,Layouts and Pages 和 React 服务端组件是默认的。在初始和后续导航中,服务端组件的 Payload 在发送到客户端之前在服务端上生成

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

  • 静态渲染(或预渲染) 发生在构建时或增量静态再生期间,并且结果被缓存
  • 动态渲染在请求时发生,以响应客户端请求

服务端渲染的弊端在于,客户端必须等待服务器响应才能显示新路由。Next.js 通过预加载用户可能访问的路由并执行客户端转换来解决此延迟问题

需要了解的是:初次访问时也会生成 HTML

预取 (Prefetching)

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

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

// app/layout.tsx
import Link from 'next/link'
 
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <nav>
          {/* 当进入视口时进行 prefetching */}
          <Link href="/blog">Blog</Link>
          {/* 没有 prefetching */}
          <a href="/contact">Contact</a>
        </nav>
        {children}
      </body>
    </html>
  )
}

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

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

通过跳过或部分预取动态路由,Next.js 避免了服务器对用户可能永远不会访问的路由进行不必要的处理。但是,在导航之前等待服务器响应可能会给用户留下应用没有响应的印象

为了改善动态路线的导航体验,您可以使用流式传输

流式传输

流式传输允许服务端在动态路由的部分内容准备就绪后立即将其发送到客户端,而无需等待整个路由渲染完成。这意味着即使页面的部分内容仍在加载,用户也能更快地看到内容

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

要使用流式传输,请在路由文件夹中创建 loading.tsx

// app/dashboard/loading.tsx
export default function Loading() {
  // 添加将在路线加载时显示的 fallback UI
  return <LoadingSkeleton />
}

在后台,Next.js 会自动将 page.tsx 内容包裹在 <Suspense> 边界内。预获取的 fallback UI 会在路由加载时显示,并在加载完成后替换为实际内容

温馨提示:您还可以使用 <Suspense> 为嵌套组件创建加载 UI

loading.tsx 的好处:

  • 为用户提供即时导航和视觉反馈
  • 共享布局保持交互性并且导航可中断
  • 改进的核心 Web 指标:TTFBFCP 和 TTI(详见前端性能指标

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

客户端转换

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

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

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

客户端过渡使服务器渲染的应用感觉像客户端渲染的应用。当与预取和流式传输配合使用时,即使对于动态路由,它也能实现快速过渡

什么会导致转变缓慢?

这些 Next.js 优化使导航更加快速且响应迅速。然而,在某些情况下,过渡仍然会感觉缓慢。以下是一些常见原因以及如何改善用户体验:

动态路由没有 loading.tsx

当导航到动态路由时,客户端必须等待服务器响应才能显示结果。这会给用户一种应用程序没有响应的印象

我们建议将 loading.tsx 添加到动态路由以启用部分预取,触发即时导航,并在路由呈现时显示加载 UI

动态段没有 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 钩子在转换过程中向用户显示内联视觉反馈 (inline visual feedback),如加载指示器 (spinners) 或在链接上的文本闪烁 (text glimmers on the link)

// 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
}

您可以通过添加初始动画延迟(例如 100 毫秒)并将动画设置为不可见(例如 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> 是一个客户端组件,必须先进行 hydrated 才能预取路由。首次访问时,较大的 JS 包可能会延迟 hydrated,导致无法立即开始预取

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

原生 History API

Next.js 允许你使用原生的 window.history.pushState 和 window.history.replaceState 无需重新加载页面即可更新浏览器历史记录堆栈的方法

pushState 和 replaceState 调用集成到 Next.js 路由器中,允许您与 usePathname 和 useSearchParams 同步

  • window.history.pushState 来向浏览器的历史记录堆栈添加新条目。用户可以导航回之前的状态
  • window.history.replaceState 来替换浏览器历史记录堆栈中的当前条目。用户无法返回到之前的状态