如何使用 Next.js 构建单页应用 (SPA)
Next.js 全面支持构建单页应用 (Single-Page Applications, SPA)。
这包括通过预取实现快速路由切换、客户端数据获取、使用浏览器 API、集成第三方客户端库、创建静态路由等功能。
如果您已有现成的 SPA 应用,可以无需大幅改动代码即可迁移至 Next.js。随后 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(所有内容都在客户端渲染)开始。如果项目增长,Next.js 允许您根据需要逐步添加更多服务端功能(如 React 服务端组件、服务端操作等)。
示例
让我们探讨构建 SPA 的常见模式及 Next.js 的解决方案。
在 Context Provider 中使用 React 的 use
钩子
我们推荐在父组件(或布局)中获取数据,返回 Promise,然后在客户端组件中使用 React 的 use
钩子解包值。
Next.js 可以在服务端早期开始数据获取。在此示例中,即根布局——应用的入口点。服务端可以立即开始向客户端流式传输响应。
通过将数据获取"提升"到根布局,Next.js 会在应用其他组件之前先在服务端启动指定请求。这消除了客户端瀑布流,避免了客户端与服务端之间的多次往返。由于服务端更接近(理想情况下与数据库同处一地),还能显著提升性能。
例如,更新根布局调用 Promise 但不等待它:
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>
)
}
import { UserProvider } from './user-provider'
import { getUser } from './user' // 某个服务端函数
export default function RootLayout({ children }) {
let userPromise = getUser() // 不要 await
return (
<html lang="en">
<body>
<UserProvider userPromise={userPromise}>{children}</UserProvider>
</body>
</html>
)
}
虽然您可以延迟并传递单个 Promise 作为属性给客户端组件,但我们通常将此模式与 React context provider 配对使用。这使得通过自定义 React 钩子更易从客户端组件访问。
您可以将 Promise 转发给 React context provider:
'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>
);
}
'use client'
import { createContext, useContext, ReactNode } from 'react'
const UserContext = createContext(null)
export function useUser() {
let context = useContext(UserContext)
if (context === null) {
throw new Error('useUser 必须在 UserProvider 内使用')
}
return context
}
export function UserProvider({ children, userPromise }) {
return (
<UserContext.Provider value={{ userPromise }}>
{children}
</UserContext.Provider>
)
}
最后,您可以在任何客户端组件中调用 useUser()
自定义钩子并解包 Promise:
'use client'
import { use } from 'react'
import { useUser } from './user-provider'
export function Profile() {
const { userPromise } = useUser()
const user = use(userPromise)
return '...'
}
'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>
)
}
import { SWRConfig } from 'swr'
import { getUser } from './user' // 某个服务端函数
export default function RootLayout({ children }) {
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 '...'
}
'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>
边界将被暂停。
SWR | RSC | RSC + SWR | |
---|---|---|---|
SSR 数据 | |||
SSR 时流式传输 | |||
请求去重 | |||
客户端功能 |
使用 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,
})
这对于依赖 window
或 document
等浏览器 API 的第三方库很有用。您还可以添加 useEffect
检查这些 API 是否存在,如果不存在则返回 null
或预渲染的加载状态。
客户端浅路由
如果您正从 Create React App 或 Vite 等严格 SPA 迁移,可能有现有代码通过浅路由更新 URL 状态。这对于不使用默认 Next.js 文件系统路由的应用视图间手动切换很有用。
Next.js 允许使用原生 window.history.pushState
和 window.history.replaceState
方法更新浏览器历史栈而不重新加载页面。
pushState
和 replaceState
调用会集成到 Next.js 路由器中,使您能与 usePathname
和 useSearchParams
同步。
'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() {}
'use server'
export async function create() {}
您可以从客户端导入并使用服务端操作,类似于调用 JavaScript 函数。无需手动创建 API 端点:
了解更多关于使用服务端操作变更数据。
静态导出(可选)
Next.js 还支持生成完全静态站点。相比严格 SPA 有一些优势:
- 自动代码分割:不同于发送单个
index.html
,Next.js 会为每个路由生成 HTML 文件,因此访问者无需等待客户端 JavaScript 包即可更快获取内容。 - 改进用户体验:不同于所有路由的最小骨架,您获得每个路由的完整渲染页面。当用户客户端导航时,切换保持即时且类似 SPA。
要启用静态导出,更新配置:
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
您可以按照我们的指南逐步迁移到 Next.js:
如果已在 Pages Router 中使用 SPA,可以学习如何逐步采用 App Router。