如何使用 Next.js 构建单页应用
Next.js 完全支持构建单页应用程序(SPAs)。
这包括通过预取实现的快速路由转换、客户端数据获取、使用浏览器 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 Server Components、Server Actions 等)。
示例
让我们探索构建 SPA 的常见模式以及 Next.js 如何解决它们。
在 Context Provider 中使用 React 的 use
我们建议在父组件(或布局)中获取数据,返回 Promise,然后在 Client Component 中用 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>
)
}
虽然你可以延迟并将单个 Promise 作为 prop 传递给 Client Component,但我们通常会看到这种模式与 React context provider 配合使用。这通过自定义 React Hook 实现从 Client Components 更容易访问。
你可以将 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 must be used within a UserProvider');
}
return context;
}
export function UserProvider({
children,
userPromise
}: {
children: ReactNode;
userPromise: Promise<User | null>;
}) {
return (
<UserContext.Provider value={{ userPromise }}>
{children}
</UserContext.Provider>
);
}
最后,你可以在任何 Client Component 中调用 useUser()
自定义钩子并解包 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>
)
}
因为这是一个 Server Component,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 的所有交互性。
由于 Next.js 自动处理初始 fallback
数据,你现在可以删除之前检查 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
期间会被预渲染。如果你想禁用 Client Component 的预渲染,只在浏览器环境中加载它,你可以使用 next/dynamic
:
import dynamic from 'next/dynamic'
const ClientOnlyComponent = dynamic(() => import('./component'), {
ssr: false,
})
这对依赖浏览器 API 如 window
或 document
的第三方库很有用。你还可以添加一个 useEffect
来检查这些 API 是否存在,如果不存在,则返回 null
或加载状态,这将被预渲染。
客户端浅层路由
如果你正在从严格的 SPA 迁移,如 Create React App 或 Vite,你可能有一些使用浅层路由更新 URL 状态的现有代码。这对于在应用程序中手动转换视图而_不_使用 Next.js 默认的文件系统路由很有用。
Next.js 允许你使用原生的 window.history.pushState
和 window.history.replaceState
方法来更新浏览器的历史堆栈,而无需重新加载页面。
pushState
和 replaceState
调用与 Next.js Router 集成,允许你与 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 中路由和导航的工作原理。
在 Client Components 中使用 Server Actions
你可以在继续使用 Client Components 的同时逐步采用 Server Actions。这允许你删除调用 API 路由的样板代码,而是使用 React 功能如 useActionState
来处理加载和错误状态。
例如,创建你的第一个 Server Action:
'use server'
export async function create() {}
你可以从客户端导入并使用 Server Action,类似于调用 JavaScript 函数。你不需要手动创建 API 端点:
'use client'
import { create } from './actions'
export function Button() {
return <button onClick={() => create()}>创建</button>
}
了解更多关于使用 Server Actions 修改数据。
静态导出(可选)
Next.js 还支持生成完全静态的站点。与严格的 SPA 相比,这有一些优势:
- 自动代码分割:Next.js 不会只提供单个
index.html
,而是为每个路由生成一个 HTML 文件,因此访问者无需等待客户端 JavaScript 包就能更快地获取内容。 - 改善用户体验:你不再为所有路由提供最小的骨架,而是为每个路由提供完全渲染的页面。当用户在客户端导航时,转换仍然保持即时和类 SPA 的体验。
要启用静态导出,请更新你的配置:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'export',
}
export default nextConfig
运行 next build
后,Next.js 将创建一个 out
文件夹,其中包含应用程序的 HTML/CSS/JS 资源。
注意: 静态导出不支持 Next.js 服务器功能。了解更多。
将现有项目迁移到 Next.js
你可以通过遵循我们的指南逐步迁移到 Next.js:
如果你已经在使用带有 Pages Router 的 SPA,你可以了解如何逐步采用 App Router。