如何使用 Next.js 构建单页应用 (SPA)

Next.js 全面支持构建单页应用 (Single-Page Applications, SPA)。

这包括通过预获取实现快速路由切换、客户端数据获取、使用浏览器 API、集成第三方客户端库、创建静态路由等功能。

如果您已有现成的 SPA 应用,可以无需大幅改动代码即可迁移至 Next.js。之后还能根据需要逐步添加服务端功能。

什么是单页应用?

SPA 的定义各有不同。我们将"严格 SPA"定义为:

  • 客户端渲染 (CSR):应用通过单个 HTML 文件(如 index.html)提供服务。所有路由、页面切换和数据获取都由浏览器中的 JavaScript 处理。
  • 无整页刷新:不同于为每个路由请求新文档,客户端 JavaScript 会操作当前页面的 DOM 并按需获取数据。

严格 SPA 通常需要加载大量 JavaScript 才能使页面具备交互性。此外,客户端数据瀑布流管理也颇具挑战。使用 Next.js 构建 SPA 可以解决这些问题。

为何选择 Next.js 构建 SPA?

Next.js 能自动对 JavaScript 包进行代码分割,并为不同路由生成多个 HTML 入口点。这避免了在客户端加载不必要的 JavaScript 代码,减小包体积并加速页面加载。

next/link 组件会自动预获取路由,在保持严格 SPA 快速页面切换优势的同时,还能将应用路由状态持久化到 URL 以便链接和分享。

Next.js 可以从静态站点甚至完全客户端渲染的严格 SPA 起步。随着项目发展,您可以按需逐步添加更多服务端功能(如 React 服务端组件服务端操作等)。

示例

让我们探讨构建 SPA 的常见模式及 Next.js 的解决方案。

在 Context Provider 中使用 React 的 use

推荐在父组件(或布局)中获取数据,返回 Promise,然后在客户端组件中使用 React 的 use hook 解包值。

Next.js 可以在服务端提前开始数据获取。本例中即应用的入口点——根布局。服务端能立即开始向客户端流式传输响应。

通过将数据获取"提升"到根布局,Next.js 会在应用其他组件执行前先在服务端启动指定请求。这消除了客户端瀑布流问题,避免了多次客户端与服务端往返。由于服务端通常更靠近(理想情况下与数据库同处一地),还能显著提升性能。

例如,更新根布局调用 Promise 但不使用 await

import { UserProvider } from './user-provider'
import { getUser } from './user' // 某个服务端函数

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  let userPromise = getUser() // 不要使用 await

  return (
    <html lang="en">
      <body>
        <UserProvider userPromise={userPromise}>{children}</UserProvider>
      </body>
    </html>
  )
}

虽然您可以延迟并传递单个 Promise 作为属性给客户端组件,但我们通常将此模式与 React 上下文提供者配合使用。这样能通过自定义 React Hook 更便捷地从客户端组件访问。

可以将 Promise 传递给 React 上下文提供者:

'use client';

import { createContext, useContext, ReactNode } from 'react';

type User = any;
type UserContextType = {
  userPromise: Promise<User | null>;
};

const UserContext = createContext<UserContextType | null>(null);

export function useUser(): UserContextType {
  let context = useContext(UserContext);
  if (context === null) {
    throw new Error('useUser必须用在UserProvider内');
  }
  return context;
}

export function UserProvider({
  children,
  userPromise
}: {
  children: ReactNode;
  userPromise: Promise<User | null>;
}) {
  return (
    <UserContext.Provider value={{ userPromise }}>
      {children}
    </UserContext.Provider>
  );
}

最后,可在任何客户端组件中调用 useUser() 自定义 hook 并解包 Promise:

'use client'

import { use } from 'react'
import { useUser } from './user-provider'

export function Profile() {
  const { userPromise } = useUser()
  const user = use(userPromise)

  return '...'
}

消费 Promise 的组件(如上例中的 Profile)将被暂停。这实现了部分水合。在 JavaScript 加载完成前,您就能看到流式传输和预渲染的 HTML。

使用 SWR 构建 SPA

SWR 是一个流行的 React 数据获取库。

使用 SWR 2.3.0(及 React 19+)版本,您可以在现有基于 SWR 的客户端数据获取代码旁逐步采用服务端功能。这是上述 use() 模式的抽象。意味着您可以在客户端和服务端之间移动数据获取,或同时使用两者:

  • 纯客户端useSWR(key, fetcher)
  • 纯服务端useSWR(key) + RSC 提供的数据
  • 混合模式useSWR(key, fetcher) + RSC 提供的数据

例如,用 <SWRConfig>fallback 包装应用:

import { SWRConfig } from 'swr'
import { getUser } from './user' // 某个服务端函数

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <SWRConfig
      value={{
        fallback: {
          // 此处不 await getUser()
          // 只有读取该数据的组件会暂停
          '/api/user': getUser(),
        },
      }}
    >
      {children}
    </SWRConfig>
  )
}

由于这是服务端组件,getUser() 可以安全读取 cookies、headers 或与数据库通信。无需单独的 API 路由。<SWRConfig> 下的客户端组件可以使用相同键调用 useSWR() 获取用户数据。使用 useSWR 的组件代码无需任何改动即可兼容现有客户端获取方案。

'use client'

import useSWR from 'swr'

export function Profile() {
  const fetcher = (url) => fetch(url).then((res) => res.json())
  // 您熟悉的相同 SWR 模式
  const { data, error } = useSWR('/api/user', fetcher)

  return '...'
}

fallback 数据可被预渲染并包含在初始 HTML 响应中,然后立即被子组件通过 useSWR 读取。SWR 的轮询、重新验证和缓存仍仅在客户端运行,因此保留了 SPA 所需的所有交互性。

由于初始 fallback 数据由 Next.js 自动处理,您现在可以删除之前检查 data 是否为 undefined 的条件逻辑。数据加载时,最近的 <Suspense> 边界将被暂停。

SWRRSCRSC + SWR
SSR 数据Cross IconCheck IconCheck Icon
SSR 流式传输Cross IconCheck IconCheck Icon
请求去重Check IconCheck IconCheck Icon
客户端功能Check IconCross IconCheck Icon

使用 React Query 构建 SPA

您可以在客户端和服务端同时使用 React Query 与 Next.js。这样既能构建严格 SPA,也能利用 Next.js 的服务端功能配合 React Query。

详见 React Query 文档

仅在浏览器渲染组件

客户端组件在 next build 期间会进行预渲染。如果想禁用客户端组件的预渲染,仅在浏览器环境中加载,可以使用 next/dynamic

import dynamic from 'next/dynamic'

const ClientOnlyComponent = dynamic(() => import('./component'), {
  ssr: false,
})

这对于依赖 windowdocument 等浏览器 API 的第三方库很有用。您也可以添加 useEffect 检查这些 API 是否存在,若不存在则返回 null 或预渲染的加载状态。

客户端浅路由

如果从 Create React AppVite 等严格 SPA 迁移,可能已有通过浅路由更新 URL 状态的代码。这对于不使用默认 Next.js 文件系统路由的应用视图间手动切换很有用。

Next.js 允许使用原生 window.history.pushStatewindow.history.replaceState 方法更新浏览器历史栈而不重载页面。

pushStatereplaceState 调用会集成到 Next.js 路由器中,可与 usePathnameuseSearchParams 同步。

'use client'

import { useSearchParams } from 'next/navigation'

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

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

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

import { useSearchParams } from 'next/navigation'

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

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

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

了解更多关于 Next.js 中路由和导航的工作原理。

在客户端组件中使用服务端操作

您可以在仍使用客户端组件的同时逐步采用服务端操作。这样能移除调用 API 路由的样板代码,转而使用 React 的 useActionState 等功能处理加载和错误状态。

例如,创建第一个服务端操作:

'use server'

export async function create() {}

可以从客户端导入并使用服务端操作,类似于调用 JavaScript 函数。无需手动创建 API 端点:

'use client'

import { create } from './actions'

export function Button() {
  return <button onClick={() => create()}>创建</button>
}

了解更多关于使用服务端操作变更数据的内容。

静态导出(可选)

Next.js 还支持生成完全静态站点。相比严格 SPA 具有以下优势:

  • 自动代码分割:Next.js 会为每个路由生成 HTML 文件,而非单一 index.html,访客无需等待客户端 JavaScript 包即可更快获取内容。
  • 提升用户体验:每个路由都有完全渲染的页面,而非所有路由共用最小骨架。客户端导航时仍保持即时 SPA 式切换。

要启用静态导出,更新配置:

next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'export',
}

export default nextConfig

运行 next build 后,Next.js 会创建包含应用 HTML/CSS/JS 资源的 out 文件夹。

注意:静态导出不支持 Next.js 服务端功能。了解更多

迁移现有项目至 Next.js

您可以按以下指南逐步迁移:

如果已在 Pages Router 中使用 SPA,可学习如何逐步采用 App Router