Nextjs 学习笔记
- NextJS 学习笔记
- next cli
- 路由
- 渲染
- 数据获取篇 | 数据获取、缓存与重新验证
NextJS 学习笔记
next cli
next build
执行
next build将会创建项目的生产优化版本构建输出如下:

从上图可以看出,构建时会输出每条路由的信息,比如 Size 和 First Load JS。注意这些值指的都是 gzip 压缩后的大小。其中 First Load JS 会用绿色、黄色、红色表示,绿色表示高性能,黄色或红色表示需要优化。
这里要解释一下 Size 和 First Load JS 的含义。正常我们开发的 Next.js 项目,其页面表现类似于单页应用,即路由跳转(我们称之为“导航”)的时候,页面不会刷新,而会加载目标路由所需的资源然后展示,所以:
加载目标路由一共所需的 JS 大小 = 每个路由都需要依赖的 JS 大小 + 目标路由单独依赖的 JS 大小
其中:
- 加载目标路由一共所需的 JS 大小就是
First Load JS- 目标路由单独依赖的 JS 大小就是
Size- 每个路由都需要依赖的 JS 大小就是图中单独列出来的
First load JS shared by all
也就是说:
First Load JS = Size + First load JS shared by all以上图中的
/路由地址为例,89 kB(First Load JS)= 5.16 kB(Size) + 83.9 kB(First load JS shared by all)
使用官方文档中的介绍就是:
Size:导航到该路由时下载的资源大小,每个路由的大小只包括它自己的依赖项First Load JS:加载该页面时下载的资源大小First load JS shared by all:所有路由共享的 JS 大小会被单独列出来
路由
Next.js 有两套路由解决方案,之前的方案称之为“Pages Router”,目前的方案称之为“App Router”,两套方案目前是兼容的,都可以在 Next.js 中使用。
从 v13.4 起,App Router 已成为默认的路由方案,新的 Next.js 项目建议使用 App Router。
文件系统(file-system)
Next.js 的路由基于的是文件系统,也就是说,一个文件就可以是一个路由。举个例子,你在
pages目录下创建一个index.js文件,它会直接映射到/路由地址;在
pages目录下创建一个about.js文件,它会直接映射到/about路由地址;
从 Pages Router 到 App Router
以前我们声明一个路由,只用在
pages目录下创建一个文件就可以了,以前的目录结构类似于:
└── pages
├── index.js
├── about.js
└── more.js
这种方式有一个弊端,那就是
pages目录的所有 js 文件都会被当成路由文件,这就导致比如组件不能写在pages目录下,这就不符合开发者的使用习惯。
升级为新的 App Router 后,现在的目录结构类似于:
src/
└── app
├── page.js
├── layout.js
├── template.js
├── loading.js
├── error.js
└── not-found.js
├── about
│ └── page.js
└── more
└── page.js
使用新的模式后,你会发现
app下多了很多文件。这些文件的名字并不是乱起的,而是 Next.js 约定的一些特殊文件。从这些文件的名称中你也可以了解文件实现的功能,比如布局(layout.js)、模板(template.js)、加载状态(loading.js)、错误处理(error.js)、404(not-found.js)等。
使用 Pages Router
当然你也可以继续使用 Pages Router,如果你想使用 Pages Router,只需要在
src目录下创建一个pages文件夹或者在根目录下创建一个pages文件夹。其中的 JS 文件会被视为 Pages Router 进行处理。
但是要注意,虽然两者可以共存,但 App Router 的优先级要高于 Pages Router。而且如果两者解析为同一个 URL,会导致构建错误。
使用 App Router
定义路由(Routes)
首先是定义路由,文件夹被用来定义路由。每个文件夹都代表一个对应到 URL 片段的路由片段。创建嵌套的路由,只需要创建嵌套的文件夹。举个例子,下图的
app/dashboard/settings目录对应的路由地址就是/dashboard/settings:

定义页面(Pages)
那如何保证这个路由可以被访问呢?你需要创建一个特殊的名为
page.js的文件。至于为什么叫page.js呢?除了page有“页面”这个含义之外,你可以理解为这是一种约定或者规范。

在上图这个例子中:
app/page.js对应路由/app/dashboard/page.js对应路由/dashboardapp/dashboard/settings/page.js对应路由/dashboard/settingsanalytics目录下因为没有page.js文件,所以没有对应的路由。这个文件可以被用于存放组件、样式表、图片或者其他文件。
当然不止
.js文件,Next.js 默认是支持 React、TypeScript 的,所以.js、.jsx、.tsx都是可以的。
定义布局(Layouts)
布局是指多个页面共享的 UI。在导航的时候,布局会保留状态、保持可交互性并且不会重新渲染,比如用来实现后台管理系统的侧边导航栏。
定义一个布局,你需要新建一个名为
layout.js的文件,该文件默认导出一个 React 组件,该组件应接收一个childrenprop,chidren表示子布局(如果有的话)或者子页面。

// app/dashboard/layout.js
export default function DashboardLayout({
children,
}) {
return (
<section>
<nav>nav</nav>
{children}
</section>
)
}
// app/dashboard/page.js
export default function Page() {
return <h1>Hello, Dashboard!</h1>
}
当访问
/dashboard的时候,效果如下:

其中,
nav来自于app/dashboard/layout.js,Hello, Dashboard!来自于app/dashboard/page.js你可以发现:同一文件夹下如果有 layout.js 和 page.js,page 会作为 children 参数传入 layout。换句话说,layout 会包裹同层级的 page。
app/dashboard/settings/page.js代码如下:
// app/dashboard/settings/page.js
export default function Page() {
return <h1>Hello, Settings!</h1>
}
当访问
/dashboard/settings的时候,效果如下:

其中,
nav来自于app/dashboard/layout.js,Hello, Settings!来自于app/dashboard/settings/page.js
你可以发现:布局是支持嵌套的,
app/dashboard/settings/page.js会使用app/layout.js和app/dashboard/layout.js两个布局中的内容,不过因为我们没有在app/layout.js写入可以展示的内容,所以图中没有体现出来。
根布局(Root Layout)
布局支持嵌套,最顶层的布局我们称之为根布局(Root Layout),也就是
app/layout.js。它会应用于所有的路由。除此之外,这个布局还有点特殊。
使用
create-next-app默认创建的layout.js代码如下:
// app/layout.js
import './globals.css'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
其中:
app目录必须包含根布局,也就是app/layout.js这个文件是必需的。- 根布局必须包含
html和body标签,其他布局不能包含这些标签。如果你要更改这些标签,不推荐直接修改,参考《Metadata 篇》。- 你可以使用路由组创建多个根布局。
- 默认根布局是服务端组件,且不能设置为客户端组件。
定义模板(Templates)
模板类似于布局,它也会传入每个子布局或者页面。但不会像布局那样维持状态。
模板在路由切换时会为每一个 children 创建一个实例。这就意味着当用户在共享一个模板的路由间跳转的时候,将会重新挂载组件实例,重新创建 DOM 元素,不保留状态。
定义一个模板,你需要新建一个名为
template.js的文件,该文件默认导出一个 React 组件,该组件接收一个childrenprop。我们写个示例代码。
在
app目录下新建一个template.js文件:

template.js代码如下:
// app/template.js
export default function Template({ children }) {
return <div>{children}</div>
}
你会发现,这用法跟布局一模一样。它们最大的区别就是状态的保持。如果同一目录下既有
template.js也有layout.js,最后的输出效果如下:
<Layout>
{/* 模板需要给一个唯一的 key */}
<Template key={routeParam}>{children}</Template>
</Layout>
也就是说
layout会包裹template,template又会包裹page。
某些情况下,模板会比布局更适合:
- 依赖于 useEffect 和 useState 的功能,比如记录页面访问数(维持状态就不会在路由切换时记录访问数了)、用户反馈表单(每次重新填写)等
- 更改框架的默认行为,举个例子,布局内的 Suspense 只会在布局加载的时候展示一次 fallback UI,当切换页面的时候不会展示。但是使用模板,fallback 会在每次路由切换的时候展示
注:关于模板的适用场景,可以参考《Next.js v14 的模板(template.js)到底有啥用?》,对这两种情况都做了举例说明
布局 VS 模板
项目目录如下:
app
└─ dashboard
├─ layout.js
├─ page.js
├─ template.js
├─ about
│ └─ page.js
└─ settings
└─ page.js
其中
dashboard/layout.js代码如下:
'use client'
import { useState } from 'react'
import Link from 'next/link'
export default function Layout({ children }) {
const [count, setCount] = useState(0)
return (
<>
<div>
<Link href="/dashboard/about">About</Link>
<br/>
<Link href="/dashboard/settings">Settings</Link>
</div>
<h1>Layout {count}</h1>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
{children}
</>
)
}
dashboard/template.js代码如下:
'use client'
import { useState } from 'react'
export default function Template({ children }) {
const [count, setCount] = useState(0)
return (
<>
<h1>Template {count}</h1>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
{children}
</>
)
}
dashboard/page.js代码如下:
export default function Page() {
return <h1>Hello, Dashboard!</h1>
}
dashboard/about/page.js代码如下:
export default function Page() {
return <h1>Hello, About!</h1>
}
dashboard/settings/page.js代码如下:
export default function Page() {
return <h1>Hello, Settings!</h1>
}
最终展示效果如下(为了方便区分,做了部分样式处理):

现在点击两个
Increment按钮,会开始计数。随便点击下数字,然后再点击About或者Settings切换路由,你会发现,Layout 后的数字没有发生变化,Template 后的数字重置为 0。这就是所谓的状态保持。

注:当然如果刷新页面,Layout 和 Template 后的数字肯定都重置为 0。
定义加载界面(Loading UI)
现在我们已经了解了
page.js、layout.js、template.js的功能,然而特殊文件还不止这些。App Router 提供了用于展示加载界面的loading.js。
这个功能的实现借助了 React 的
SuspenseAPI。关于 Suspense 的用法,可以查看 《React 之 Suspense》。它实现的效果就是当发生路由变化的时候,立刻展示 fallback UI,等加载完成后,展示数据。
// 在 ProfilePage 组件处于加载阶段时显示 Spinner
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>
初次接触 Suspense 这个概念的时候,往往会有一个疑惑,那就是——“在哪里控制关闭 fallback UI 的呢?”
哪怕在 React 官网中,对背后的实现逻辑并无过多提及。但其实实现的逻辑很简单,简单的来说,ProfilePage 会 throw 一个数据加载的 promise,Suspense 会捕获这个 promise,追加一个 then 函数,then 函数中实现替换 fallback UI 。当数据加载完毕,promise 进入 resolve 状态,then 函数执行,于是更新替换 fallback UI。
了解了原理,那我们来看看如何写这个
loading.js吧。dashboard目录下我们新建一个loading.js。
// app/dashboard/loading.js
export default function DashboardLoading() {
return <>Loading dashboard...</>
}
同级的
page.js代码如下:
// app/dashboard/page.js
async function getData() {
await new Promise((resolve) => setTimeout(resolve, 3000))
return {
message: 'Hello, Dashboard!',
}
}
export default async function DashboardPage(props) {
const { message } = await getData()
return <h1>{message}</h1>
}
不再需要其他的代码,loading 的效果就实现了:

就是这么简单。其关键在于
page.js导出了一个 async 函数。
loading.js的实现原理是将page.js和下面的 children 用<Suspense>包裹。因为page.js导出一个 async 函数,Suspense 得以捕获数据加载的 promise,借此实现了 loading 组件的关闭。

当然实现 loading 效果,不一定非导出一个 async 函数。也可以借助 React 的
use函数。现在我们在dashboard下新建一个about目录,在其中新建page.js文件。
// /dashboard/about/page.js
import { use } from 'react'
async function getData() {
await new Promise((resolve) => setTimeout(resolve, 5000))
return {
message: 'Hello, About!',
}
}
export default function Page() {
const {message} = use(getData())
return <h1>{message}</h1>
}
如果你想针对
/dashboard/about单独实现一个 loading 效果,那就在about目录下再写一个loading.js即可。
如果同一文件夹既有
layout.js又有template.js又有loading.js,那它们的层级关系是怎样呢?
对于这些特殊文件的层级问题,直接一张图搞定:

定义错误处理(Error Handling)
error.js。顾名思义,用来创建发生错误时的展示 UI。
其实现借助了 React 的 Error Boundary 功能。简单来说,就是给 page.js 和 children 包了一层
ErrorBoundary。

dashboard目录下新建一个error.js,目录效果如下:
'use client' // 错误组件必须是客户端组件
// dashboard/error.js
import { useEffect } from 'react'
export default function Error({ error, reset }) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// 尝试恢复
() => reset()
}
>
Try again
</button>
</div>
)
}
为触发 Error 错误,同级
page.js的代码如下:
"use client";
// dashboard/page.js
import React from "react";
export default function Page() {
const [error, setError] = React.useState(false);
const handleGetError = () => {
setError(true);
};
return (
<>{error ? Error() : <button onClick={handleGetError}>Get Error</button>}</>
);
}
效果如下:

有时错误是暂时的,只需要重试就可以解决问题。所以 Next.js 会在
error.js导出的组件中,传入reset函数,帮助尝试从错误中恢复。该函数会触发重新渲染错误边界里的内容。如果成功,会替换展示重新渲染的内容。
还记得上节讲过的层级问题吗?让我们回顾一下:

从这张图里你会发现一个问题:因为
Layout和Template在ErrorBoundary外面,这说明错误边界不能捕获同级的layout.js或者template.js中的错误。如果你想捕获特定布局或者模板中的错误,那就需要在父级的error.js里进行捕获。
那问题来了,如果已经到了顶层,就比如根布局中的错误如何捕获呢?为了解决这个问题,Next.js 提供了
global-error.js文件,使用它时,需要将其放在app目录下。
global-error.js会包裹整个应用,而且当它触发的时候,它会替换掉根布局的内容。所以,global-error.js中也要定义<html>和<body>标签。
global-error.js示例代码如下:
'use client'
// app/global-error.js
export default function GlobalError({ error, reset }) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
)
}
注:
global-error.js用来处理根布局和根模板中的错误,app/error.js建议还是要写的
定义 404 页面
not-found.js。顾名思义,当该路由不存在的时候展示的内容。
Next.js 项目默认的 not-found 效果如下:

如果你要替换这个效果,只需要在
app目录下新建一个not-found.js,代码示例如下:
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find requested resource</p>
<Link href="/">Return Home</Link>
</div>
)
}
关于
app/not-found.js一定要说明一点的是,它只能由两种情况触发:
- 当组件抛出了 notFound 函数的时候
- 当路由地址不匹配的时候
所以
app/not-found.js可以修改默认 404 页面的样式。但是,如果not-found.js放到了任何子文件夹下,它只能由notFound函数手动触发。比如这样:
// /dashboard/blog/page.js
import { notFound } from 'next/navigation'
export default function Page() {
notFound()
return <></>
}
执行 notFound 函数时,会由最近的 not-found.js 来处理。但如果直接访问不存在的路由,则都是由
app/not-found.js来处理。
对应到实际开发,当我们请求一个用户的数据时或是请求一篇文章的数据时,如果该数据不存在,就可以直接丢出
notFound函数,渲染自定义的not-found.js界面。示例代码如下:
// app/dashboard/blog/[id]/page.js
import { notFound } from 'next/navigation'
async function fetchUser(id) {
const res = await fetch('https://...')
if (!res.ok) return undefined
return res.json()
}
export default async function Profile({ params }) {
const user = await fetchUser(params.id)
if (!user) {
notFound()
}
// ...
}
注:后面我们还会讲到“路由组”这个概念,当
app/not-found.js和路由组一起使用的时候,可能会出现问题。具体参考 《Next.js v14 如何为多个根布局自定义不同的 404 页面?竟然还有些麻烦!欢迎探讨》
链接与导航
如何在 Next.js 中实现链接和导航。
所谓“导航”,指的是使用 JavaScript 进行页面切换,通常会比浏览器默认的重新加载更快,因为在导航的时候,只会更新必要的组件,而不会重新加载整个页面。
在 Next.js 中,有 4 种方式可以实现路由导航:
- 使用
<Link>组件- 使用
useRouterHook(客户端组件)- 使用
redirect函数(服务端组件)- 使用浏览器原生 History API
<Link>组件
Next.js 的
<Link>组件是一个拓展了原生 HTML<a>标签的内置组件,用来实现预获取(prefetching) 和客户端路由导航。这是 Next.js 中路由导航的主要和推荐方式。
基础使用
基本的使用方式如下:
import Link from 'next/link'
export default function Page() {
return <Link href="/dashboard">Dashboard</Link>
}
支持动态渲染
支持路由链接动态渲染:
import Link from 'next/link'
export default function PostList({ posts }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
)
}
获取当前路径名
如果需要对当前链接进行判断,你可以使用 usePathname() ,它会读取当前 URL 的路径名(pathname)。示例代码如下:
'use client'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
export function Navigation({ navLinks }) {
const pathname = usePathname()
return (
<>
{navLinks.map((link) => {
const isActive = pathname === link.href
return (
<Link
className={isActive ? 'text-blue' : 'text-black'}
href={link.href}
key={link.name}
>
{link.name}
</Link>
)
})}
</>
)
}
跳转行为设置
App Router 的默认行为是滚动到新路由的顶部,或者在前进后退导航时维持之前的滚动距离。
如果你想要禁用这个行为,你可以给
<Link>组件传递一个scroll={false}属性,或者在使用router.push和router.replace的时候,设置scroll: false:
// next/link
<Link href="/dashboard" scroll={false}>
Dashboard
</Link>
// useRouter
import { useRouter } from 'next/navigation'
const router = useRouter()
router.push('/dashboard', { scroll: false })
useRouter() hook
第二种方式是使用 useRouter,这是 Next.js 提供的用于更改路由的 hook。使用示例代码如下:
'use client'
import { useRouter } from 'next/navigation'
export default function Page() {
const router = useRouter()
return (
<button type="button" onClick={() => router.push('/dashboard')}>
Dashboard
</button>
)
}
注意使用该 hook 需要在客户端组件中。(顶层的
'use client'就是声明这是客户端组件)
redirect 函数
客户端组件使用 useRouter hook,服务端组件则可以直接使用 redirect 函数,这也是 Next.js 提供的 API,使用示例代码如下:
import { redirect } from 'next/navigation'
async function fetchTeam(id) {
const res = await fetch('https://...')
if (!res.ok) return undefined
return res.json()
}
export default async function Profile({ params }) {
const team = await fetchTeam(params.id)
if (!team) {
redirect('/login')
}
// ...
}
History API
也可以使用浏览器原生的 window.history.pushState 和 window.history.replaceState 方法更新浏览器的历史记录堆栈。通常与 usePathname(获取路径名的 hook) 和 useSearchParams(获取页面参数的 hook) 一起使用。
比如用 pushState 对列表进行排序:
'use client'
import { useSearchParams } from 'next/navigation'
export default function SortProducts() {
const searchParams = useSearchParams()
function updateSorting(sortOrder) {
const params = new URLSearchParams(searchParams.toString())
params.set('sort', sortOrder)
window.history.pushState(null, '', `?${params.toString()}`)
}
return (
<>
<button onClick={() => updateSorting('asc')}>Sort Ascending</button>
<button onClick={() => updateSorting('desc')}>Sort Descending</button>
</>
)
}
交互效果如下:

replaceState 会替换浏览器历史堆栈的当前条目,替换后用户无法后退,比如切换应用的地域设置(国际化):
'use client'
import { usePathname } from 'next/navigation'
export default function LocaleSwitcher() {
const pathname = usePathname()
function switchLocale(locale) {
// e.g. '/en/about' or '/fr/contact'
const newPath = `/${locale}${pathname}`
window.history.replaceState(null, '', newPath)
}
return (
<>
<button onClick={() => switchLocale('en')}>English</button>
<button onClick={() => switchLocale('fr')}>French</button>
</>
)
}
动态路由(Dynamic Routes)
有的时候,你并不能提前知道路由的地址,就比如根据 URL 中的 id 参数展示该 id 对应的文章内容,文章那么多,我们不可能一一定义路由,这个时候就需要用到动态路由。
[folderName]
使用动态路由,你需要将文件夹的名字用方括号括住,比如
[id]、[slug]。这个路由的名字会作为paramsprop 传给布局、 页面、 路由处理程序 以及 generateMetadata 函数。
举个例子,我们在
app/blog目录下新建一个名为[slug]的文件夹,在该文件夹新建一个page.js文件,代码如下:
// app/blog/[slug]/page.js
export default function Page({ params }) {
return <div>My Post: {params.slug}</div>
}

当你访问
/blog/a的时候,params的值为{ slug: 'a' }。当你访问
/blog/yayu的时候,params的值为{ slug: 'yayu' }。以此类推。
[...folderName]
在命名文件夹的时候,如果你在方括号内添加省略号,比如
[...folderName],这表示捕获所有后面所有的路由片段。
也就是说,
app/shop/[...slug]/page.js会匹配/shop/clothes,也会匹配/shop/clothes/tops、/shop/clothes/tops/t-shirts等等。
举个例子,
app/shop/[...slug]/page.js的代码如下:
// app/shop/[...slug]/page.js
export default function Page({ params }) {
return <div>My Shop: {JSON.stringify(params)}</div>
}

当你访问
/shop/a的时候,params的值为{ slug: ['a'] }。当你访问
/shop/a/b的时候,params的值为{ slug: ['a', 'b'] }。当你访问
/shop/a/b/c的时候,params的值为{ slug: ['a', 'b', 'c'] }。以此类推
[[...folderName]]
在命名文件夹的时候,如果你在双方括号内添加省略号,比如
[[...folderName]],这表示可选的捕获所有后面所有的路由片段。
也就是说,
app/shop/[[...slug]]/page.js会匹配/shop,也会匹配/shop/clothes、/shop/clothes/tops、/shop/clothes/tops/t-shirts等等。
它与上一种的区别就在于,不带参数的路由也会被匹配(就比如
/shop)
举个例子,
app/shop/[[...slug]]/page.js的代码如下:
// app/shop/[[...slug]]/page.js
export default function Page({ params }) {
return <div>My Shop: {JSON.stringify(params)}</div>
}

当你访问
/shop的时候,params 的值为{}。当你访问
/shop/a的时候,params 的值为{ slug: ['a'] }。当你访问
/shop/a/b的时候,params 的值为{ slug: ['a', 'b'] }。当你访问
/shop/a/b/c的时候,params 的值为{ slug: ['a', 'b', 'c'] }。以此类推。
路由组(Route groups)
在
app目录下,文件夹名称通常会被映射到 URL 中,但你可以将文件夹标记为路由组,阻止文件夹名称被映射到 URL 中。
使用路由组,你可以将路由和项目文件按照逻辑进行分组,但不会影响 URL 路径结构。路由组可用于比如:
- 按站点、意图、团队等将路由分组
- 在同一层级中创建多个布局,甚至是创建多个根布局
那么该如何标记呢?把文件夹用括号括住就可以了,就比如
(dashboard)。
按逻辑分组
将路由按逻辑分组,但不影响 URL 路径:

你会发现,最终的 URL 中省略了带括号的文件夹(上图中的
(marketing)和(shop))。
创建不同布局
借助路由组,即便在同一层级,也可以创建不同的布局:

在这个例子中,
/account、/cart、/checkout都在同一层级。但是/account和/cart使用的是/app/(shop)/layout.js布局和app/layout.js布局,/checkout使用的是app/layout.js
创建多个根布局
创建多个根布局:

创建多个根布局,你需要删除掉
app/layout.js文件,然后在每组都创建一个layout.js文件。创建的时候要注意,因为是根布局,所以要有<html>和<body>标签。
这个功能很实用,比如你将前台购买页面和后台管理页面都放在一个项目里,一个 C 端,一个 B 端,两个项目的布局肯定不一样,借助路由组,就可以轻松实现区分。
再多说几点:
- 路由组的命名除了用于组织之外并无特殊意义。它们不会影响 URL 路径。
- 注意不要解析为相同的 URL 路径。举个例子,因为路由组不影响 URL 路径,所以
(marketing)/about/page.js和(shop)/about/page.js都会解析为/about,这会导致报错。- 创建多个根布局的时候,因为删除了顶层的
app/layout.js文件,访问/会报错,所以app/page.js需要定义在其中一个路由组中。- 跨根布局导航会导致页面完全重新加载,就比如使用
app/(shop)/layout.js根布局的/cart跳转到使用app/(marketing)/layout.js根布局的/blog会导致页面重新加载(full page load)。
注:当定义多个根布局的时候,使用
app/not-found.js会出现问题。具体参考 《Next.js v14 如何为多个根布局自定义不同的 404 页面?竟然还有些麻烦!欢迎探讨》
平行路由(Parallel Routes)
平行路由可以使你在同一个布局中同时或者有条件的渲染一个或者多个页面(类似于 Vue 的插槽功能)。
用途 1:条件渲染
举个例子,在后台管理页面,需要同时展示团队(team)和数据分析(analytics)页面:

平行路由的使用方式是将文件夹以
@作为开头进行命名,比如在上图中就定义了两个插槽@team和@analytics。
插槽会作为 props 传给共享的父布局。在上图中,
app/layout.js从 props 中获取了@team和@analytics两个插槽的内容,并将其与 children 并行渲染:
// app/layout.js
// 这里我们用了 ES6 的解构,写法更简洁一点
export default function Layout({ children, team, analytics }) {
return (
<>
{children}
{team}
{analytics}
</>
)
}
注:从这张图也可以看出,
childrenprop 其实就是一个隐式的插槽,/app/page.js相当于app/@children/page.js。
除了让它们同时展示,你也可以根据条件判断展示:

在这个例子中,先在布局中获取用户的登录状态,如果登录,显示 dashboard 页面,没有登录,显示 login 页面。这样做的一大好处就在于代码完全分离。
用途 2:独立路由处理
平行路由可以让你为每个路由定义独立的错误处理和加载界面:

用途 3:子导航
注意我们描述 team 和 analytics 时依然用的是“页面”这个说法,因为它们就像书写正常的页面一样使用 page.js。除此之外,它们也能像正常的页面一样,添加子页面,比如我们在
@analytics下添加两个子页面:/page-viewsand/visitors:

平行路由跟路由组一样,不会影响 URL,所以
/@analytics/page-views/page.js对应的地址是/page-views,/@analytics/visitors/page.js对应的地址是/visitors,你可以导航至这些路由:
// app/layout.js
import Link from "next/link";
export default function RootLayout({ children, analytics }) {
return (
<html>
<body>
<nav>
<Link href="/">Home</Link>
<br />
<Link href="/page-views">Page Views</Link>
<br />
<Link href="/visitors">Visitors</Link>
</nav>
<h1>root layout</h1>
{analytics}
{children}
</body>
</html>
);
}
当导航至这些子页面的时候,子页面的内容会取代
/@analytics/page.js以 props 的形式注入到布局中,效果如下:

这也就是说,每个插槽都可以有自己独立的导航和状态管理,就像一个小型应用一样。这种特性适合于构建复杂的应用如 dashboard。
最后,让我们总结一下使用平行路由的优势:
- 使用平行路由可以将单个布局拆分为多个插槽,使代码更易于管理,尤其适用于团队协作的时候
- 每个插槽都可以定义自己的加载界面和错误状态,比如某个插槽加载速度比较慢,那就可以加一个加载效果,加载期间,也不会影响其他插槽的渲染和交互。当出现错误的时候,也只会在具体的插槽上出现错误提示,而不会影响页面其他部分,有效改善用户体验
- 每个插槽都可以有自己独立的导航和状态管理,这使得插槽的功能更加丰富,比如在上面的例子中,我们在
@analytics插槽下又建了查看页面 PV 的/page-views、查看访客的/visitors,使得同一个插槽区域可以根据路由显示不同的内容
那你可能要问了,我就不使用平行路由,我就完全使用拆分组件的形式,加载状态和错误状态全都自己处理,子路由也统统自己处理,可不可以?
当然是可以的,只要不嫌麻烦的话……
注意:使用平行路由的时候,热加载有可能会出现错误。如果出现了让你匪夷所思的情况,重新运行 npm run dev 或者构建生产版本查看效果
default.js
为了让大家更好的理解平行路由,我们写一个示例代码。项目结构如下:
app
├─ @analytics
│ └─ page-views
│ │ └─ page.js
│ └─ visitors
│ │ └─ page.js
│ └─ page.js
├─ @team
│ └─ page.js
├─ layout.js
└─ page.js
// 其中 app/layout.js代码如下:
import Link from "next/link";
import "./globals.css";
export default function RootLayout({ children, team, analytics }) {
return (
<html>
<body className="p-6">
<div className="p-10 mb-6 bg-sky-600 text-white rounded-xl">
Parallel Routes Examples
</div>
<nav className="flex items-center justify-center gap-10 text-blue-600 mb-6">
<Link href="/">Home</Link>
<Link href="/page-views">Page Views</Link>
<Link href="/visitors">Visitors</Link>
</nav>
<div className="flex gap-6">
{team}
{analytics}
</div>
{children}
</body>
</html>
);
}
// app/page.js代码如下:
export default function Page() {
return (
<div className="p-10 mt-6 bg-sky-600 text-white rounded-xl">
Hello, App!
</div>
);
}
// app/@analytics/page.js代码如下:
export default function Page() {
return <div className="h-60 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">Hello, Analytics!</div>
}
// app/@analytics/page-views/page.js代码如下:
export default function Page() {
return <div className="h-60 flex-1 rounded-xl bg-indigo-600 text-white flex items-center justify-center">Hello, Analytics Page Views!</div>
}
// app/@analytics/visitors/page.js代码如下:
export default function Page() {
return <div className="h-60 flex-1 rounded-xl bg-indigo-700 text-white flex items-center justify-center">Hello, Analytics Visitors!</div>
}
// app/@team/page.js代码如下:
export default function Page() {
return <div className="h-60 flex-1 rounded-xl bg-sky-500 text-white flex items-center justify-center">Hello, Team!</div>
}
此时访问
/,效果如下:

到这里其实还只是上节例子的样式美化版。现在,点击
Visitors链接导航至/visitors路由,然后刷新页面,此时你会发现,页面出现了 404 错误:

这是为什么呢?为什么我们从首页导航至
/visitors的时候可以正常显示?而直接进入/visitors就会出现 404 错误呢?
先说说为什么从首页导航至
/visitors的时候可以正常显示?这是因为 Next.js 默认会追踪每个插槽的状态,具体插槽中显示的内容其实跟导航的类型有关:
- 如果是软导航(Soft Navigation,比如通过
<Link />标签),在导航时,Next.js 将执行部分渲染,更改插槽的内容,如果它们与当前 URL 不匹配,维持之前的状态- 如果是硬导航(Hard Navigation,比如浏览器刷新页面),因为 Next.js 无法确定与当前 URL 不匹配的插槽的状态,所以会渲染 404 错误
简单的来说,访问
/visitors本身就会造成插槽内容与当前 URL 不匹配,按理说要渲染 404 错误,但是在软导航的时候,为了更好的用户体验,如果 URL 不匹配,Next.js 会继续保持该插槽之前的状态,而不渲染 404 错误。
那么问题又来了?不是写了
app/@analytics/visitors/page.js吗?怎么会不匹配呢?对于@analytics而言,确实是匹配的,但是对于@team和children就不匹配了!
也就是说,当你访问
/visitors的时候,读取的不仅仅是app/@analytics/visitors/page.js,还有app/@team/visitors/page.js和app/visitors/page.js。不信我们新建这两个文件测试一下。
新建
app/@team/visitors/page.js,代码如下:
export default function Page() {
return <div className="h-60 flex-1 rounded-xl bg-indigo-700 text-white flex items-center justify-center">Hello, Team Visitors!</div>
}
新建
app/visitors/page.js,代码如下:
export default function Page() {
return (
<div className="p-10 mt-6 bg-sky-600 text-white rounded-xl">
Hello, App Visitors!
</div>
);
}
此时再访问
/visitors,刷新一下页面试试,效果如下:

那么问题又来了,如果我在某一个插槽里新建了一个路由,我难道还要在其他插槽里也新建这个路由吗?这岂不是很麻烦?
为了解决这个问题,Next.js 提供了 default.js。当发生硬导航的时候,Next.js 会为不匹配的插槽呈现 default.js 中定义的内容,如果 default.js 没有定义,再渲染 404 错误。
现在删除
app/@team/visitors/page.js和app/visitors/page.js,改用 default.js:
新建
app/@team/default.js,代码如下:
export default function Page() {
return (
<div className="p-10 mt-6 bg-sky-600 text-white rounded-xl">
Hello, App Default!
</div>
);
}
新建
app/default.js,代码如下:
export default function Page() {
return (
<div className="p-10 mt-6 bg-sky-600 text-white rounded-xl">
Hello, App Default!
</div>
);
}

拦截路由(Intercepting Routes)
拦截路由允许你在当前路由拦截其他路由地址并在当前路由中展示内容。
效果展示
让我们直接看个案例,打开 dribbble.com 这个网站,你可以看到很多美图:
现在点击任意一张图片:
此时页面弹出了一层 Modal,Modal 中展示了该图片的具体内容。如果你想要查看其他图片,点击右上角的关闭按钮,关掉 Modal 即可继续浏览。值得注意的是,此时路由地址也发生了变化,它变成了这张图片的具体地址。如果你喜欢这张图片,直接复制当前的地址分享给朋友即可。
而当你的朋友打开时,其实不需要再以 Modal 的形式展现,直接展示这张图片的具体内容即可。现在刷新下该页面,你会发现页面的样式不同了:
在这个样式里没有 Modal,就是展示这张图片的内容。
同样一个路由地址,却展示了不同的内容。这就是拦截路由的效果。如果你在
dribbble.com想要访问dribbble.com/shots/xxxxx,此时会拦截dribbble.com/shots/xxxxx这个路由地址,以 Modal 的形式展现。而当直接访问dribbble.com/shots/xxxxx时,则是原本的样式。


了解了拦截路由的效果,让我们再思考下使用拦截路由的意义是什么。
简单的来说,就是希望用户继续停留在重要的页面上。比如上述例子中的图片流页面,开发者肯定是希望用户能够持续在图片流页面浏览,如果点击一张图片就跳转出去,会打断用户的浏览体验,如果点击只展示一个 Modal,分享操作又会变得麻烦一点。拦截路由正好可以实现这样一种平衡。又比如任务列表页面,点击其中一项任务,弹出 Modal 让你能够编辑此任务,同时又可以方便的分享任务内容。
实现方式
那么这个效果该如何实现呢?在 Next.js 中,实现拦截路由需要你在命名文件夹的时候以
(..)开头,其中:
(.)表示匹配同一层级(..)表示匹配上一层级(..)(..)表示匹配上上层级。(...)表示匹配根目录
但是要注意的是,这个匹配的是路由的层级而不是文件夹路径的层级,就比如路由组、平行路由这些不会影响 URL 的文件夹就不会被计算层级。
/feed/(..)photo对应的路由是/feed/photo,要拦截的路由是/photo,两者只差了一个层级,所以使用(..)。
示例代码
我们写个 demo 来实现这个效果,目录结构如下:
app
├─ layout.js
├─ page.js
├─ data.js
├─ default.js
├─ @modal
│ ├─ default.js
│ └─ (.)photo
│ └─ [id]
│ └─ page.js
└─ photo
└─ [id]
└─ page.js
先 Mock 一下图片的数据,
app/data.js代码如下:
export const photos = [
{ id: "1", src: "http://placekitten.com/210/210" },
{ id: "2", src: "http://placekitten.com/330/330" },
{ id: "3", src: "http://placekitten.com/220/220" },
{ id: "4", src: "http://placekitten.com/240/240" },
{ id: "5", src: "http://placekitten.com/250/250" },
{ id: "6", src: "http://placekitten.com/300/300" },
{ id: "7", src: "http://placekitten.com/500/500" },
];
// app/page.js代码如下:
import Link from "next/link";
import { photos } from "./data";
export default function Home() {
return (
<main className="flex flex-row flex-wrap">
{photos.map(({ id, src }) => (
<Link key={id} href={`/photo/${id}`}>
<img width="200" src={src} className="m-1" />
</Link>
))}
</main>
);
}
// app/layout.js 代码如下:
import "./globals.css";
export default function Layout({ children, modal }) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
此时访问
/,效果如下:

现在我们再来实现下单独访问图片地址时的效果,新建
app/photo/[id]/page.js,代码如下:
import { photos } from "../../data";
export default function PhotoPage({ params: { id } }) {
const photo = photos.find((p) => p.id === id);
return <img className="block w-1/4 mx-auto mt-10" src={photo.src} />;
}
访问
/photo/6,效果如下:

现在我们开始实现拦截路由,为了和单独访问图片地址时的样式区分,我们声明另一种样式效果。
app/@modal/(.)photo/[id]/page.js代码如下:
import { photos } from "../../../data";
export default function PhotoModal({ params: { id } }) {
const photo = photos.find((p) => p.id === id)
return (
<div className="flex h-60 justify-center items-center fixed bottom-0 bg-slate-300 w-full">
<img className="w-52" src={photo.src} />
</div>
)
}
因为用到了平行路由,所以我们需要设置 default.js。
app/default.js和app/@modal/default.js的代码都是:
export default function Default() {
return null
}

你可以看到,在
/路由下,访问/photo/5,路由会被拦截,并使用@modal/(.)photo/[id]/page.js的样式。
路由处理程序
路由处理程序是指使用 Web Request 和 Response API 对于给定的路由自定义处理逻辑。
简单的来说,前后端分离架构中,客户端与服务端之间通过 API 接口来交互。这个“API 接口”在 Next.js 中有个更为正式的称呼,就是路由处理程序。
定义路由处理程序
写路由处理程序,你需要定义一个名为
route.js的特殊文件。(注意是route不是router)

该文件必须在
app目录下,可以在app嵌套的文件夹下,但是要注意page.js和route.js不能在同一层级同时存在。
page.js和route.js本质上都是对路由的响应。page.js主要负责渲染 UI,route.js主要负责处理请求。如果同时存在,Next.js 就不知道用谁的逻辑了。
GET 请求
新建
app/api/posts/route.js文件,代码如下:
import { NextResponse } from 'next/server'
export async function GET() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const data = await res.json()
return NextResponse.json({ data })
}
在这个例子中:
- 我们
export一个名为GET的async函数来定义 GET 请求处理,注意是 export 而不是 export default- 我们使用
next/server的 NextResponse 对象用于设置响应内容,但这里不一定非要用NextResponse,直接使用Response也是可以的:
export async function GET() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const data = await res.json()
return Response.json({ data })
}
但在实际开发中,推荐使用
NextResponse,因为它是 Next.js 基于Response的封装,它对 TypeScript 更加友好,同时提供了更为方便的用法,比如获取 Cookie 等。
- 我们将接口写在了
app/api文件夹下,并不是因为接口一定要放在名为api文件夹下(与 Pages Router 不同)。如果你代码写在app/posts/route.js,对应的接口地址就是/posts。放在api文件夹下只是为了方便区分地址是接口还是页面。
支持方法
Next.js 支持
GET、POST、PUT、PATCH、DELETE、HEAD和OPTIONS这些 HTTP 请求方法。如果传入了不支持的请求方法,Next.js 会返回405 Method Not Allowed。
传入参数
每个请求方法的处理函数会被传入两个参数,一个
request,一个context。两个参数都是可选的:
export async function GET(request, context) {}
request (optional)
request 对象是一个 NextRequest 对象,它是基于 Web Request API 的扩展。使用 request ,你可以快捷读取 cookies 和处理 URL。
我们这里讲讲如何获取 URL 参数:
export async function GET(request, context) {
// 访问 /home, pathname 的值为 /home
const pathname = request.nextUrl.pathname
// 访问 /home?name=lee, searchParams 的值为 { 'name': 'lee' }
const searchParams = request.nextUrl.searchParams
}
其中 nextUrl 是基于 Web URL API 的扩展(如果你想获取其他值,参考 URL API),同样提供了一些方便使用的方法。
context (optional)
目前
context只有一个值就是params,它是一个包含当前动态路由参数的对象。举个例子:
// app/dashboard/[team]/route.js
export async function GET(request, { params }) {
const team = params.team
}
当访问
/dashboard/1时,params 的值为{ team: '1' }。其他情况还有:
| Example | URL | params |
|---|---|---|
app/dashboard/[team]/route.js |
/dashboard/1 |
{ team: '1' } |
app/shop/[tag]/[item]/route.js |
/shop/1/2 |
{ tag: '1', item: '2' } |
app/blog/[...slug]/route.js |
/blog/1/2 |
{ slug: ['1', '2'] } |
注意第二行:此时 params 返回了当前链接所有的动态路由参数。
缓存行为
默认缓存
默认情况下,使用
Response对象(NextResponse也是一样的)的 GET 请求会被缓存。
让我们举个例子,新建
app/api/time/route.js,代码如下:
export async function GET() {
console.log('GET /api/time')
return Response.json({ data: new Date().toLocaleTimeString() })
}
注意:在开发模式下,并不会被缓存,每次刷新时间都会改变:

现在我们部署生产版本,运行
npm run build && npm run start:

你会发现,无论怎么刷新,时间都不会改变。这就是被缓存了。
可是为什么呢?Next.js 是怎么实现的呢?
让我们看下构建(npm run build)时的命令行输出:

根据输出的结果,你会发现
/api/time是静态的,也就是被预渲染为静态的内容,换言之,/api/time的返回结果其实在构建的时候就已经确定了,而不是在第一次请求的时候才确定。
退出缓存
但大家也不用担心默认缓存带来的影响。实际上,默认缓存的条件是非常“严苛”的,这些情况都会导致退出缓存:
GET请求使用Request对象
// 修改 app/api/time/route.js,代码如下:
export async function GET(request) {
const searchParams = request.nextUrl.searchParams
return Response.json({ data: new Date().toLocaleTimeString(), params: searchParams.toString() })
}
现在我们部署生产版本,运行
npm run build && npm run start:


此时会动态渲染,也就是在请求的时候再进行服务端渲染,所以时间会改变。
- 添加其他 HTTP 方法,比如 POST
// 修改 app/api/time/route.js,代码如下:
export async function GET() {
console.log('GET /api/time')
return Response.json({ data: new Date().toLocaleTimeString() })
}
export async function POST() {
console.log('POST /api/time')
return Response.json({ data: new Date().toLocaleTimeString() })
}
此时会转为动态渲染。这是因为 POST 请求往往用于改变数据,GET 请求用于获取数据。如果写了 POST 请求,表示数据会发生变化,此时不适合缓存。
- 使用像 cookies、headers 这样的动态函数
// 修改 app/api/time/route.js,代码如下:
export async function GET(request) {
const token = request.cookies.get('token')
return Response.json({ data: new Date().toLocaleTimeString() })
}
此时会转为动态渲染。这是因为 cookies、headers 这些数据只有当请求的时候才知道具体的值。
- 路由段配置项手动声明为动态模式
// 修改 app/api/time/route.js,代码如下:
export const dynamic = 'force-dynamic'
export async function GET() {
return Response.json({ data: new Date().toLocaleTimeString() })
}
此时会转为动态渲染。这是因为你手动设置为了动态渲染模式……
重新验证
除了退出缓存,也可以设置缓存的时效,适用于一些重要性低、时效性低的页面。
有两种常用的方案,一种是使用路由段配置项。
// 修改 app/api/time/route.js,代码如下:
export const revalidate = 10
export async function GET() {
return Response.json({ data: new Date().toLocaleTimeString() })
}
export const revalidate = 10表示设置重新验证频率为 10s,但是要注意:这句代码的效果并不是设置服务器每 10s 会自动更新一次
/api/time。而是最少 10s 后才重新验证。举个例子,假设你现在访问了
/api/time,此时时间设为 0s,10s 内持续访问,/api/time返回的都是之前缓存的结果。当 10s 过后,假设你第 12s 又访问了一次/api/time,此时虽然超过了 10s,但依然会返回之前缓存的结果,但同时会触发服务器更新缓存,当你第 13s 再次访问的时候,就是更新后的结果。简单来说,超过 revalidate 设置时间的首次访问会触发缓存更新,如果更新成功,后续的返回就都是新的内容,直到下一次触发缓存更新。
还有一种是使用
next.revalidate选项。
export async function GET() {
const res = await fetch('https://api.thecatapi.com/v1/images/search')
const data = await res.json()
console.log(data)
return Response.json(data)
}
让我们在开发模式下打开这个页面:

你会发现与之前的
/api/time不同,/api/image接口返回的数据在开发模式下刷新就已经不会改变了,即使 console.log 每次都会打印,返回的结果却还是一样。这是因为 Next.js 拓展了原生的 fetch 方法,会自动缓存 fetch 的结果。现在我们使用
next.revalidate设置 fetch 请求的重新验证时间,修改app/api/image/route.js,代码如下:
export async function GET() {
const res = await fetch('https://api.thecatapi.com/v1/images/search', {
next: { revalidate: 5 }, // 每 5 秒重新验证
})
const data = await res.json()
console.log(data)
return Response.json(data)
}
在本地多次刷新页面,你会发现数据发生了更新:

如果你使用生产版本,虽然在构建的时候,
/api/image显示的是静态渲染,但是数据会更新。具体更新的规律和第一种方案是一样的,这里就不多赘述了。注:Next.js 的缓存方案我们还会在 《缓存篇 | Caching》中详细介绍。
写接口常见问题
如何获取网址参数?
// app/api/search/route.js
// 访问 /api/search?query=hello
export function GET(request) {
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('query') // query
}
如何处理 Cookie?
第一种方法是通过
NextRequest对象:
// app/api/route.js
export async function GET(request) {
const token = request.cookies.get('token')
request.cookies.set(`token2`, 123)
}
其中,
request是一个NextRequest对象。正如上节所说,NextRequest相比Request提供了更为便捷的用法,这就是一个例子。
此外,虽然我们使用 set 设置了 cookie,但设置的是请求的 cookie,并没有设置响应的 cookie。
第二种方法是通过
next/headers包提供的cookies方法。
因为 cookies 实例只读,如果你要设置 Cookie,你需要返回一个使用
Set-Cookieheader 的 Response 实例。示例代码如下:
// app/api/route.js
import { cookies } from 'next/headers'
export async function GET(request) {
const cookieStore = cookies()
const token = cookieStore.get('token')
return new Response('Hello, Next.js!', {
status: 200,
headers: { 'Set-Cookie': `token=${token}` },
})
}
如何处理 Headers ?
第一种方法是通过
NextRequest对象:
// app/api/route.js
export async function GET(request) {
const headersList = new Headers(request.headers)
const referer = headersList.get('referer')
}
第二种方法是
next/headers包提供的headers方法。
因为 headers 实例只读,如果你要设置 headers,你需要返回一个使用了新 header 的 Response 实例。使用示例如下
// app/api/route.js
import { headers } from 'next/headers'
export async function GET(request) {
const headersList = headers()
const referer = headersList.get('referer')
return new Response('Hello, Next.js!', {
status: 200,
headers: { referer: referer },
})
}
如何重定向?
重定向使用
next/navigation提供的redirect方法,示例如下:
import { redirect } from 'next/navigation'
export async function GET(request) {
redirect('https://nextjs.org/')
}
如何获取请求体内容?
// app/items/route.js
import { NextResponse } from 'next/server'
export async function POST(request) {
const res = await request.json()
return NextResponse.json({ res })
}
如果请求正文是 FormData 类型:
// app/items/route.js
import { NextResponse } from 'next/server'
export async function POST(request) {
const formData = await request.formData()
const name = formData.get('name')
const email = formData.get('email')
return NextResponse.json({ name, email })
}
如何设置 CORS ?
// app/api/route.ts
export async function GET(request) {
return new Response('Hello, Next.js!', {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}
如何响应无 UI 内容?
你可以返回无 UI 的内容。在这个例子中,访问
/rss.xml的时候,会返回 XML 结构的内容:
// app/rss.xml/route.ts
export async function GET() {
return new Response(`<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Next.js Documentation</title>
<link>https://nextjs.org/docs</link>
<description>The React Framework for the Web</description>
</channel>
</rss>`)
}
注:
sitemap.xml、robots.txt、app icons和open graph images这些特殊的文件,Next.js 都已经提供了内置支持,这些内容我们会在《Metadata 篇 | 基于文件》详细讲到。
Streaming
openai 的打字效果背后用的就是流:
// app/api/chat/route.js
import OpenAI from 'openai'
import { OpenAIStream, StreamingTextResponse } from 'ai'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
export const runtime = 'edge'
export async function POST(req) {
const { messages } = await req.json()
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
stream: true,
messages,
})
const stream = OpenAIStream(response)
return new StreamingTextResponse(stream)
}
当然也可以直接使用底层的 Web API 实现 Streaming:
// app/api/route.js
// https://developer.mozilla.org/docs/Web/API/ReadableStream#convert_async_iterator_to_stream
function iteratorToStream(iterator) {
return new ReadableStream({
async pull(controller) {
const { value, done } = await iterator.next()
if (done) {
controller.close()
} else {
controller.enqueue(value)
}
},
})
}
function sleep(time) {
return new Promise((resolve) => {
setTimeout(resolve, time)
})
}
const encoder = new TextEncoder()
async function* makeIterator() {
yield encoder.encode('<p>One</p>')
await sleep(200)
yield encoder.encode('<p>Two</p>')
await sleep(200)
yield encoder.encode('<p>Three</p>')
}
export async function GET() {
const iterator = makeIterator()
const stream = iteratorToStream(iterator)
return new Response(stream)
}
注:Streaming 更完整详细的示例和解释可以参考 《如何用 Next.js v14 实现一个 Streaming 接口?》
中间件
使用中间件,你可以拦截并控制应用里的所有请求和响应。
比如你可以基于传入的请求,重写、重定向、修改请求或响应头、甚至直接响应内容。一个比较常见的应用就是鉴权,在打开页面渲染具体的内容前,先判断用户是否登录,如果未登录,则跳转到登录页面。
定义
写中间件,你需要在项目的根目录定义一个名为
middleware.js的文件:
// middleware.js
import { NextResponse } from 'next/server'
// 中间件可以是 async 函数,如果使用了 await
export function middleware(request) {
return NextResponse.redirect(new URL('/home', request.url))
}
// 设置匹配路径
export const config = {
matcher: '/about/:path*',
}
注意:这里说的项目根目录指的是和
pages或app同级。但如果项目用了src目录,则放在src下。
在这个例子中,我们通过
config.matcher设置中间件生效的路径,在middleware函数中设置中间件的逻辑,作用是将/about、/about/xxx、/about/xxx/xxx这样的的地址统一重定向到/home,效果如下:

设置匹配路径
有两种方式可以指定中间件匹配的路径。
matcher 配置项
第一种是使用
matcher配置项,示例代码如下:
export const config = {
matcher: '/about/:path*',
}
// matcher 不仅支持字符串形式,也支持数组形式,用于匹配多个路径:
export const config = {
matcher: ['/about/:path*', '/dashboard/:path*'],
}
初次接触的同学可能会对
:path*这样的用法感到奇怪,这个用法来自于 path-to-regexp 这个库,它的作用就是将/user/:name这样的路径字符串转换为正则表达式。
path-to-regexp 通过在参数名前加一个冒号来定义命名参数(Named Parameters),matcher 支持命名参数,比如
/about/:path匹配/about/a和/about/b,但是不匹配/about/a/c
注:实际测试的时候,
/about/:path并不能匹配/about/xxx,只能匹配/about,如果要匹配/about/xxx,需要写成/about/:path/
命名参数的默认匹配逻辑是
[^/]+,但你也可以在命名参数后加一个括号,在其中自定义命名参数的匹配逻辑,比如/about/icon-:foo(\\d+).png匹配/about/icon-1.png,但不匹配/about/icon-a.png。
命名参数可以使用修饰符,其中
*表示 0 个或 1 个或多个,?表示 0 个或 1 个,+表示 1 个或多个,比如
/about/:path*匹配/about、/about/xxx、/about/xxx/xxx/about/:path?匹配/about、/about/xxx/about/:path+匹配/about/xxx、/about/xxx/xxx
也可以在圆括号中使用标准的正则表达式,比如
/about/(.*)等同于/about/:path*,比如/(about|settings)匹配/about和/settings,不匹配其他的地址。/user-(ya|yu)匹配/user-ya和/user-yu。
一个较为复杂和常用的例子是:
export const config = {
matcher: [
/*
* 匹配所有的路径除了以这些作为开头的:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
除此之外,还要注意,路径必须以
/开头。matcher的值必须是常量,这样可以在构建的时候被静态分析。使用变量之类的动态值会被忽略。
matcher 的强大可远不止正则表达式,matcher 还可以判断查询参数、cookies、headers:
export const config = {
matcher: [
{
source: '/api/*',
has: [
{ type: 'header', key: 'Authorization', value: 'Bearer Token' },
{ type: 'query', key: 'userId', value: '123' },
],
missing: [{ type: 'cookie', key: 'session', value: 'active' }],
},
],
}
在这个例子中,不仅匹配了路由地址,还要求 header 的 Authorization 必须是 Bearer Token,查询参数的 userId 为 123,且 cookie 里的 session 值不是 active。
注:关于 has 和 missing,可以参考 API 篇 | next.config.js(上)。
条件语句
import { NextResponse } from 'next/server'
export function middleware(request) {
if (request.nextUrl.pathname.startsWith('/about')) {
return NextResponse.rewrite(new URL('/about-2', request.url))
}
if (request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.rewrite(new URL('/dashboard/user', request.url))
}
}
matcher 很强大,可有的时候不会写真的让人头疼,那就在具体的逻辑里写!
中间件逻辑
接下来我们看看中间件具体该怎么写:
export function middleware(request) {
// 如何读取和设置 cookies ?
// 如何读取 headers ?
// 如何直接响应?
}
如何读取和设置 cookies?
用法跟路由处理程序一致,使用 NextRequest 和 NextResponse 快捷读取和设置 cookies。
对于传入的请求,NextRequest 提供了
get、getAll、set和delete方法处理 cookies,你也可以用has检查 cookie 或者clear删除所有的 cookies。
对于返回的响应,NextResponse 同样提供了
get、getAll、set和delete方法处理 cookies。示例代码如下:
import { NextResponse } from 'next/server'
export function middleware(request) {
// 假设传入的请求 header 里 "Cookie:nextjs=fast"
let cookie = request.cookies.get('nextjs')
console.log(cookie) // => { name: 'nextjs', value: 'fast', Path: '/' }
const allCookies = request.cookies.getAll()
console.log(allCookies) // => [{ name: 'nextjs', value: 'fast' }]
request.cookies.has('nextjs') // => true
request.cookies.delete('nextjs')
request.cookies.has('nextjs') // => false
// 设置 cookies
const response = NextResponse.next()
response.cookies.set('vercel', 'fast')
response.cookies.set({
name: 'vercel',
value: 'fast',
path: '/',
})
cookie = response.cookies.get('vercel')
console.log(cookie) // => { name: 'vercel', value: 'fast', Path: '/' }
// 响应 header 为 `Set-Cookie:vercel=fast;path=/test`
return response
}
在这个例子中,我们调用了
NextResponse.next()这个方法,这个方法专门用在 middleware 中,毕竟我们写的是中间件,中间件进行一层处理后,返回的结果还要在下一个逻辑中继续使用,此时就需要返回NextResponse.next()。当然如果不需要再走下一个逻辑了,可以直接返回一个 Response 实例,接下来的例子中会演示其写法。
如何读取和设置 headers?
用法跟路由处理程序一致,使用 NextRequest 和 NextResponse 快捷读取和设置 headers。示例代码如下:
// middleware.js
import { NextResponse } from 'next/server'
export function middleware(request) {
// clone 请求标头
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-hello-from-middleware1', 'hello')
// 你也可以在 NextResponse.rewrite 中设置请求标头
const response = NextResponse.next({
request: {
// 设置新请求标头
headers: requestHeaders,
},
})
// 设置新响应标头 `x-hello-from-middleware2`
response.headers.set('x-hello-from-middleware2', 'hello')
return response
}
这个例子比较特殊的地方在于调用 NextResponse.next 的时候传入了一个对象用于转发 headers,根据 NextResponse 的官方文档,目前也就这一种用法。
CORS
这是一个在实际开发中会用到的设置 CORS 的例子:
import { NextResponse } from 'next/server'
const allowedOrigins = ['https://acme.com', 'https://my-app.org']
const corsOptions = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
export function middleware(request) {
// Check the origin from the request
const origin = request.headers.get('origin') ?? ''
const isAllowedOrigin = allowedOrigins.includes(origin)
// Handle preflighted requests
const isPreflight = request.method === 'OPTIONS'
if (isPreflight) {
const preflightHeaders = {
...(isAllowedOrigin && { 'Access-Control-Allow-Origin': origin }),
...corsOptions,
}
return NextResponse.json({}, { headers: preflightHeaders })
}
// Handle simple requests
const response = NextResponse.next()
if (isAllowedOrigin) {
response.headers.set('Access-Control-Allow-Origin', origin)
}
Object.entries(corsOptions).forEach(([key, value]) => {
response.headers.set(key, value)
})
return response
}
export const config = {
matcher: '/api/:path*',
}
如何直接响应?
用法跟路由处理程序一致,使用 NextResponse 设置返回的 Response。示例代码如下:
import { NextResponse } from 'next/server'
import { isAuthenticated } from '@lib/auth'
export const config = {
matcher: '/api/:function*',
}
export function middleware(request) {
// 鉴权判断
if (!isAuthenticated(request)) {
// 返回错误信息
return new NextResponse(
JSON.stringify({ success: false, message: 'authentication failed' }),
{ status: 401, headers: { 'content-type': 'application/json' } }
)
}
}
执行顺序
在 Next.js 中,有很多地方都可以设置路由的响应,比如 next.config.js 中可以设置,中间件中可以设置,具体的路由中可以设置,所以要注意它们的执行顺序:
headers(next.config.js)redirects(next.config.js)- 中间件 (
rewrites,redirects等)beforeFiles(next.config.js中的rewrites)- 基于文件系统的路由 (
public/,_next/static/,pages/,app/等)afterFiles(next.config.js中的rewrites)- 动态路由 (
/blog/[slug])fallback中的 (next.config.js中的rewrites)
注:
beforeFiles顾名思义,在基于文件系统的路由之前,afterFiles顾名思义,在基于文件系统的路由之后,fallback顾名思义,垫底执行。
执行顺序具体是什么作用呢?其实我们写个 demo 测试一下就知道了,文件目录如下:
next-app
├─ app
│ ├─ blog
│ │ ├─ [id]
│ │ │ └─ page.js
│ │ ├─ yayu
│ │ │ └─ page.js
│ │ └─ page.js
├─ middleware.js
└─ next.config.js
next.config.js中声明redirects、rewrites:
module.exports = {
async redirects() {
return [
{
source: '/blog/yayu',
destination: '/blog/yayu_redirects',
permanent: true,
},
]
},
async rewrites() {
return {
beforeFiles: [
{
source: '/blog/yayu',
destination: '/blog/yayu_beforeFiles',
},
],
afterFiles: [
{
source: '/blog/yayu',
destination: '/blog/yayu_afterFiles',
},
],
fallback: [
{
source: '/blog/yayu',
destination: `/blog/yayu_fallback`,
},
],
}
},
}
middleware.js的代码如下:
import { NextResponse } from 'next/server'
export function middleware(request) {
return NextResponse.redirect(new URL('/blog/yayu_middleware', request.url))
}
export const config = {
matcher: '/blog/yayu',
}
app/blog/page.js代码如下:
import { redirect } from 'next/navigation'
export default function Page() {
redirect('/blog/yayu_page')
}
app/blog/[id]/page.js代码如下:
import { redirect } from 'next/navigation'
export default function Page() {
redirect('/blog/yayu_slug')
}
现在我们在多个地方都配置了重定向和重写,那么问题来了,现在访问
/blog/yayu,最终浏览器地址栏里呈现的 URL 是什么?答案是
/blog/yayu_slug。按照执行顺序,访问/blog/yayu,先根据next.config.js的redirects重定向到/blog/yayu_redirects,于是走到动态路由的逻辑,重定向到/blog/yayu_slug。
中间件相关配置项
Next.js v13.1 为中间件增加了两个新的配置项,
skipMiddlewareUrlNormalize和skipTrailingSlashRedirect,用来处理一些特殊的情况。
运行时
使用 Middleware 的时候还要注意一点,那就是目前 Middleware 只支持 Edge runtime,并不支持 Node.js runtime。这意味着写 Middleware 的时候,尽可能使用 Web API,避免使用 Node.js API。
中间件的代码维护
如果项目比较简单,中间件的代码通常不会写很多,将所有代码写在一起倒也不是什么太大问题。可当项目复杂了,比如在中间件里又要鉴权、又要控制请求、又要国际化等等,各种逻辑写在一起,中间件很快就变得难以维护。如果我们要在中间件里实现多个需求,该怎么合理的拆分代码呢?
一种简单的方式是:
import { NextResponse } from 'next/server'
async function middleware1(request) {
console.log(request.url)
return NextResponse.next()
}
async function middleware2(request) {
console.log(request.url)
return NextResponse.next()
}
export async function middleware(request) {
await middleware1(request)
await middleware2(request)
}
export const config = {
matcher: '/api/:path*',
}
一种更为优雅的方式是借助高阶函数:
import { NextResponse } from 'next/server'
function withMiddleware1(middleware) {
return async (request) => {
console.log('middleware1 ' + request.url)
return middleware(request)
}
}
function withMiddleware2(middleware) {
return async (request) => {
console.log('middleware2 ' + request.url)
return middleware(request)
}
}
async function middleware(request) {
console.log('middleware ' + request.url)
return NextResponse.next()
}
export default withMiddleware2(withMiddleware1(middleware))
export const config = {
matcher: '/api/:path*',
}
请问此时的执行顺序是什么?试着打印一下吧。是不是感觉回到了学 redux 的时候?
但这样写起来还是有点麻烦,让我们写一个工具函数帮助我们:
import { NextResponse } from 'next/server'
function chain(functions, index = 0) {
const current = functions[index];
if (current) {
const next = chain(functions, index + 1);
return current(next);
}
return () => NextResponse.next();
}
function withMiddleware1(middleware) {
return async (request) => {
console.log('middleware1 ' + request.url)
return middleware(request)
}
}
function withMiddleware2(middleware) {
return async (request) => {
console.log('middleware2 ' + request.url)
return middleware(request)
}
}
export default chain([withMiddleware1, withMiddleware2])
export const config = {
matcher: '/api/:path*',
}
请问此时的执行顺序是什么?答案是按数组的顺序,middleware1、middleware2。
如果使用这种方式,实际开发的时候,代码类似于:
import { chain } from "@/lib/utils";
import { withHeaders } from "@/middlewares/withHeaders";
import { withLogging } from "@/middlewares/withLogging";
export default chain([withLogging, withHeaders]);
export const config = {
matcher: '/api/:path*',
}
具体写中间件时:
export const withHeaders = (next) => {
return async (request) => {
// ...
return next(request);
};
};
渲染
CSR、SSR、SSG、ISR
以前学习 Next.js 可能是听说了 Next.js 一个框架就可以实现 CSR、SSR、SSG、ISR 这些功能,但在 Next.js v13 之后,Next.js 推出了基于 React Server Component 的 App Router。
SSR、SSG 等名词也在最新的文档中被弱化、少有提及(这些功能当然还在的),但理解这些名词背后的原理和区别,依然有助于我们理解和使用 Next.js。
CSR
CSR,英文全称“Client-side Rendering”,中文翻译“客户端渲染”。顾名思义,渲染工作主要在客户端执行。
像我们传统使用 React 的方式,就是客户端渲染。浏览器会先下载一个非常小的 HTML 文件和所需的 JavaScript 文件。在 JavaScript 中执行发送请求、获取数据、更新 DOM 和渲染页面等操作。
这样做最大的问题就是不够快。(SEO 问题是其次,现在的爬虫已经普遍能够支持 CSR 渲染的页面)
在下载、解析、执行 JavaScript以及请求数据没有返回前,页面不会完全呈现。
Next.js 实现 CSR
Next.js 支持 CSR。
在页面中使用 React
useEffecthook,而不是服务端的渲染方法(比如getStaticProps和getServerSideProps,这两个方法后面会讲到),举个例子:
// pages/csr.js
import React, { useState, useEffect } from 'react'
export default function Page() {
const [data, setData] = useState(null)
useEffect(() => {
const fetchData = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
setData(result)
}
fetchData().catch((e) => {
console.error('An error occurred while fetching the data: ', e)
})
}, [])
return <p>{data ? `Your data: ${JSON.stringify(data)}` : 'Loading...'}</p>
}
可以看到,请求由客户端发出,同时页面显示 loading 状态,等数据返回后,主要内容在客户端进行渲染。
当访问
/csr的时候,渲染的 HTML 文件为:

JavaScript 获得数据后,最终更新为:

SSR
SSR,英文全称“Server-side Rendering”,中文翻译“服务端渲染”。顾名思义,渲染工作主要在服务端执行。
比如打开一篇博客文章页面,没有必要每次都让客户端请求,万一客户端网速不好呢,那干脆由服务端直接请求接口、获取数据,然后渲染成静态的 HTML 文件返回给用户。
虽然同样是发送请求,但通常服务端的环境(网络环境、设备性能)要好于客户端,所以最终的渲染速度(首屏加载时间)也会更快。
虽然总体速度是更快的,但因为 CSR 响应时只用返回一个很小的 HTML,SSR 响应还要请求接口,渲染 HTML,所以其响应时间会更长,对应到性能指标 TTFB (Time To First Byte),SSR 更长。
Next.js 实现 SSR
Next.js 支持 SSR,我们使用 Pages Router 写个 demo:
// pages/ssr.js
export default function Page({ data }) {
return <p>{JSON.stringify(data)}</p>
}
export async function getServerSideProps() {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos`)
const data = await res.json()
return { props: { data } }
}
使用 SSR,你需要导出一个名为
getServerSideProps的 async 函数。这个函数会在每次请求的时候被调用。返回的数据会通过组件的 props 属性传递给组件。

服务端会在每次请求的时候编译 HTML 文件返回给客户端。查看 HTML,这些数据可以直接看到:

SSG
SSG,英文全称“Static Site Generation”,中文翻译“静态站点生成”。
SSG 会在构建阶段,就将页面编译为静态的 HTML 文件。
比如打开一篇博客文章页面,既然所有人看到的内容都是一样的,没有必要在用户请求页面的时候,服务端再请求接口。干脆先获取数据,提前编译成 HTML 文件,等用户访问的时候,直接返回 HTML 文件。这样速度会更快。再配上 CDN 缓存,速度就更快了。
所以能用 SSG 就用 SSG。“在用户访问之前是否能预渲染出来?”如果能,就用 SSG。
Next.js 实现 SSG
Next.js 支持 SSG。当不获取数据时,默认使用的就是 SSG。我们使用 Pages Router 写个 demo:
// pages/ssg1.js
function About() {
return <div>About</div>
}
export default About
像这种没有数据请求的页面,Next.js 会在构建的时候生成一个单独的 HTML 文件。
不过 Next.js 默认没有导出该文件。如果你想看到构建生成的 HTML 文件,修改
next.config.js文件:
const nextConfig = {
output: 'export'
}
module.exports = nextConfig
再执行
npm run build,你就会在根目录下看到生成的out文件夹,里面存放了构建生成的 HTML 文件。
那如果要获取数据呢?这分两种情况。
第一种情况,页面内容需要获取数据。就比如博客的文章内容需要调用 API 获取。Next.js 提供了
getStaticProps。写个 demo:
// pages/ssg2.js
export default function Blog({ posts }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export async function getStaticProps() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
return {
props: {
posts,
},
}
}
getStaticProps会在构建的时候被调用,并将数据通过 props 属性传递给页面。
(还记得
getServerSideProps吗?两者在用法上类似,不过getServerSideProps是在每次请求的时候被调用,getStaticProps在每次构建的时候)
第二种情况,是页面路径需要获取数据。
这是什么意思呢?就比如数据库里有 100 篇文章,我肯定不可能自己手动定义 100 个路由,然后预渲染 100 个 HTML 吧。Next.js 提供了
getStaticPaths用于定义预渲染的路径。它需要搭配动态路由使用。写个 demo:
新建
/pages/post/[id].js,代码如下:
// /pages/post/[id].js
export default function Blog({ post }) {
return (
<>
<header>{post.title}</header>
<main>{post.body}</main>
</>
)
}
// 指定要预渲染的路径
export async function getStaticPaths() {
// 获取所有可能的路径参数
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
// 生成路径数组
const paths = posts.map((post) => ({
params: { id: String(post.id) },
}))
// { fallback: false } 意味着当访问其他路由的时候返回 404
return { paths, fallback: false }
}
export async function getStaticProps({ params }) {
// 如果路由地址为 /posts/1, params.id 为 1
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`)
const post = await res.json()
return { props: { post } }
}
其中,
getStaticPaths和getStaticProps都会在构建的时候被调用,getStaticPaths定义了哪些路径被预渲染,getStaticProps获取路径参数,请求数据传给页面。
当你执行
npm run build的时候,就会看到 post 文件下生成了一堆 HTML 文件:

ISR
ISR,英文全称“Incremental Static Regeneration”,中文翻译“增量静态再生”。
还是打开一篇博客文章页面,博客的主体内容也许是不变的,但像比如点赞、收藏这些数据总是在变化的吧。使用 SSG 编译成 HTML 文件后,这些数据就无法准确获取了,那你可能就退而求其次改为 SSR 或者 CSR 了。
考虑到这种情况,Next.js 提出了 ISR。当用户访问了这个页面,第一次依然是老的 HTML 内容,但是 Next.js 同时静态编译成新的 HTML 文件,当你第二次访问或者其他用户访问的时候,就会变成新的 HTML 内容了。
Next.js 实现 ISR
Next.js 支持 ISR,并且使用的方式很简单。你只用在
getStaticProps中添加一个revalidate即可。我们基于 SSG 的示例代码上进行修改:
// pages/post/[id].js
// 保持不变
export default function Blog({ post }) {
return (
<>
<header>{post.title}</header>
<main>{post.body}</main>
</>
)
}
// fallback 的模式改为 'blocking'
export async function getStaticPaths() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
const paths = posts.slice(0, 10).map((post) => ({
params: { id: String(post.id) },
}))
return { paths, fallback: 'blocking' }
}
// 使用这种随机的方式模拟数据改变
function getRandomInt(max) {
return Math.floor(Math.random() * max);
}
// 多返回了 revalidata 属性
export async function getStaticProps({ params }) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${getRandomInt(100)}`)
const post = await res.json()
return {
props: { post },
revalidate: 10
}
}
revalidate表示当发生请求的时候,至少间隔多少秒才更新页面。
这听起来有些抽象,以
revalidate: 10为例,在初始请求后和接下来的 10 秒内,页面都会使用之前构建的 HTML。10s 后第一个请求发生的时候,依然使用之前编译的 HTML。但 Next.js 会开始构建更新 HTML,从下个请求起就会使用新的 HTML。(如果构建失败了,就还是用之前的,等下次再触发更新)
当你在本地使用
next dev运行的时候,getStaticProps会在每次请求的时候被调用。所以如果你要测试 ISR 功能,先构建出生产版本,再运行生产服务。也就是说,测试 ISR 效果,用这俩命令:
next build // 或 npm run build
next start // 或 npm run start

你可以看到,页面刷新后,文章内容发生变化。然后 10s 内的刷新,页面内容都没有变化。10s 后的第一次刷新触发了更新,10s 后的第二次刷新内容发生了变化。
注意这次
getStaticPaths函数的返回为return { paths, fallback: 'blocking' }。它表示构建的时候就渲染paths里的这些路径。如果请求其他的路径,那就执行服务端渲染。在上节 SSG 的例子中,我们设置fallback为 false,它表示如果请求其他的路径,就会返回 404 错误。
所以在这个 ISR demo 中,如果请求了尚未生成的路径,Next.js 会在第一次请求的时候就执行服务端渲染,编译出 HTML 文件,再请求时就从缓存里返回该 HTML 文件。SSG 优雅降级到 SSR。
支持混合使用
在写 demo 的时候,想必你已经发现了,其实每个页面你并没有专门声明使用哪种渲染模式,Next.js 是自动判断的。所以一个 Next.js 应用里支持混合使用多种渲染模式。
当页面有
getServerSideProps的时候,Next.js 切成 SSR 模式。没有getServerSideProps则会预渲染页面为静态的HTML。那你可能会问,CSR 呢?就算用 CSR 模式,Next.js 也要提供一个静态的 HTML,所以还是要走预渲染这步的,只不过相比 SSG,渲染的内容少了些。
页面可以是 SSG + CSR 的混合,由 SSG 提供初始的静态页面,提高首屏加载速度。CSR 动态填充内容,提供交互能力。举个例子:
// pages/postList.js
import React, { useState } from 'react'
export default function Blog({ posts }) {
const [data, setData] = useState(posts)
return (
<>
<button onClick={async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
setData(posts.slice(10, 20))
}}>换一批</button>
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</>
)
}
export async function getStaticProps() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
return {
props: {
posts: posts.slice(0, 10),
},
}
}
初始的文章列表数据就是在构建的时候写入 HTML 里的,在点击换一批按钮的时候,则是在客户端发送请求重新渲染内容。
React Server Components
React Server Component 把数据请求的部分放在服务端,由服务端直接给客户端返回带数据的组件。
最终的目标是:在原始只有 Client Components 的情况下,一个 React 树的结构如下:

在使用 React Server Component 后,React 树会变成:

其中黄色节点表示 React Server Component。在服务端,React 会将其渲染会一个包含基础 HTML 标签和客户端组件占位的树。它的结构类似于:

因为客户端组件的数据和结构在客户端渲染的时候才知道,所以客户端组件此时在树中使用特殊的占位进行替代。
当然这个树不可能直接就发给客户端,React 会做序列化处理,客户端收到后会在客户端根据这个数据重构 React 树,然后用真正的客户端组件填充占位,渲染最终的结果。

使用 React Server Component,因为服务端组件的代码不会打包到客户端代码中,它可以减小包(bundle)的大小。且在 React Server Component 中,可以直接访问后端资源。当然因为在服务端运行,对应也有一些限制,比如不能使用 useEffect 和客户端事件等。
Server-side Rendering
Server-side Rendering,中文译为“服务端渲染”,在上篇 渲染篇 | 从 CSR、SSR、SSG、ISR 开始说起 已经介绍过,并提供了一个基于 Pages Router 的 demo:
// pages/ssr.js
export default function Page({ data }) {
return <p>{JSON.stringify(data)}</p>
}
export async function getServerSideProps() {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos`)
const data = await res.json()
return { props: { data } }
}
从这个例子中可以看出,Next.js v12 之前的 SSR 都是通过
getServerSideProps这样的方法,在页面层级获取数据,然后通过 props 传给每个组件,然后将整个组件树在服务端渲染为 HTML。
但是 HTML 是没有交互性的(non-interactive UI),客户端渲染出 HTML 后,还要等待 JavaScript 完全下载并执行。JavaScript 会赋予 HTML 交互性,这个阶段被称为水合(Hydration)。此时内容变为可交互的(interactive UI)。
从这个过程中,我们可以看出 SSR 的几个缺点:
- SSR 的数据获取必须在组件渲染之前
- 组件的 JavaScript 必须先加载到客户端,才能开始水合
- 所有组件必须先水合,然后才能跟其中任意一个组件交互
可以看出 SSR 这种技术“大开大合”,加载整个页面的数据,加载整个页面的 JavaScript,水合整个页面,还必须按此顺序串行执行。如果有某些部分慢了,都会导致整体效率降低。
此外,SSR 只用于页面的初始化加载,对于后续的交互、页面更新、数据更改,SSR 并无作用。
RSC 与 SSR
了解了这两个基本概念,现在让我们来回顾下 React Server Components 和 Server-side Rendering,表面上看,RSC 和 SSR 非常相似,都发生在服务端,都涉及到渲染,目的都是更快的呈现内容。但实际上,这两个技术概念是相互独立的。RSC 和 SSR 既可以各自单独使用,又可以搭配在一起使用(搭配在一起使用的时候是互补的)。
正如它们的名字所表明的那样,Server-side Rendering 的重点在于 Rendering,React Server Components 的重点在于 Components。
简单来说,RSC 提供了更细粒度的组件渲染方式,可以在组件中直接获取数据,而非像 Next.js v12 中的 SSR 顶层获取数据。RSC 在服务端进行渲染,组件依赖的代码不会打包到 bundle 中,而 SSR 需要将组件的所有依赖都打包到 bundle 中。
当然两者最大的区别是:
SSR 是在服务端将组件渲染成 HTML 发送给客户端,而 RSC 是将组件渲染成一种特殊的格式,我们称之为 RSC Payload。这个 RSC Payload 的渲染是在服务端,但不会一开始就返回给客户端,而是在客户端请求相关组件的时候才返回给客户端,RSC Payload 会包含组件渲染后的数据和样式,客户端收到 RSC Payload 后会重建 React 树,修改页面 DOM。
让我们本地开启一下当时 React 提供的 Server Components Demo:

你会发现
localhost这个 HTML 页面的内容就跟 CSR 一样,都只有一个用于挂载的 DOM 节点。当点击左侧 Notes 列表的时候,会发送请求,这个请求的地址是:
http://localhost:4000/react?location={"selectedId":3,"isEditing":false,"searchText":""}
返回的结果是:

除此之外没有其他的请求了。其实这条请求返回的数据就是 RSC Payload。
让我们看下这条请求,我们请求的这条笔记的标题是 Make a thing,具体内容是 It's very easy to make some……,我们把返回的数据具体查看一下,你会发现,返回的请求里包含了这些数据:

不仅包含数据,完整渲染后的 DOM 结构也都包含了。客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)。
这也就带来了我们常说的 SSR 和 RSC 的最大区别,那就是状态的保持(渲染成不同的格式是“因”,状态的保持是“果”)。每次 SSR 都是一个新的 HTML 页面,所以状态不会保持(传统的做法是 SSR 初次渲染,然后 CSR 更新,这种情况,状态可以保持,不过现在讨论的是 SSR,对于两次 SSR,状态是无法维持的)。
但是 RSC 不同,RSC 会被渲染成一种特殊的格式(RSC Payload),可以多次重新获取,然后客户端根据这个特殊格式更新 UI,而不会丢失客户端状态。
所谓不丢失状态,让我们看个例子:

在上图中,我们新建了一条 note,重点在左侧的搜索结果列表,新建后,原本的那条 note 依然保持了展开状态。
Next.js 的服务端组件、客户端组件虽然是基于 RSC 提出的用于区分组件类型的概念,但在具体实现上,为了追求高性能,技术上其实是融合了 RSC 和 SSR(前面也说过,RSC 和 SSR 互补)。这里比较是纯粹的 RSC 和 SSR,以防大家在概念理解上产生混淆。
Suspense 与 Streaming
传统 SSR
在最近的两篇文章里,我们已经介绍了 SSR 的原理和缺陷。简单来说,使用 SSR,需要经过一系列的步骤,用户才能查看页面、与之交互。具体这些步骤是:
- 服务端获取所有数据
- 服务端渲染 HTML
- 将页面的 HTML、CSS、JavaScript 发送到客户端
- 使用 HTML 和 CSS 生成不可交互的用户界面(non-interactive UI)
- React 对用户界面进行水合(hydrate),使其可交互(interactive UI)

这些步骤是连续的、阻塞的。这意味着服务端只能在获取所有数据后渲染 HTML,React 只能在下载了所有组件代码后才能进行水合:

还记得上篇总结的 SSR 的几个缺点吗?
- SSR 的数据获取必须在组件渲染之前
- 组件的 JavaScript 必须先加载到客户端,才能开始水合
- 所有组件必须先水合,然后才能跟其中任意一个组件交互
Suspense
为了解决这些问题,React 18 引入了 Suspense 组件。我们来介绍下这个组件:
<Suspense>允许你推迟渲染某些内容,直到满足某些条件(例如数据加载完毕)。你可以将动态组件包装在 Suspense 中,然后向其传递一个 fallback UI,以便在动态组件加载时显示。如果数据请求缓慢,使用 Suspense 流式渲染该组件,不会影响页面其他部分的渲染,更不会阻塞整个页面。
让我们来写一个例子,新建
app/dashboard/page.js,代码如下:
import { Suspense } from 'react'
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function PostFeed() {
await sleep(2000)
return <h1>Hello PostFeed</h1>
}
async function Weather() {
await sleep(8000)
return <h1>Hello Weather</h1>
}
async function Recommend() {
await sleep(5000)
return <h1>Hello Recommend</h1>
}
export default function Dashboard() {
return (
<section style={{padding: '20px'}}>
<Suspense fallback={<p>Loading PostFeed Component</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Loading Weather Component</p>}>
<Weather />
</Suspense>
<Suspense fallback={<p>Loading Recommend Component</p>}>
<Recommend />
</Suspense>
</section>
)
}
在这个例子中,我们用 Suspense 包装了三个组件,并通过 sleep 函数模拟了数据请求耗费的时长。加载效果如下:

可是 Next.js 是怎么实现的呢?
让我们观察下 dashboard 这个 HTML 文件的加载情况,你会发现它一开始是 2.03s,然后变成了 5.03s,最后变成了 8.04s,这不就正是我们设置的 sleep 时间吗?
查看 dashboard 请求的响应头:

Transfer-Encoding标头的值为chunked,表示数据将以一系列分块的形式进行发送。
分块传输编码(Chunked transfer encoding)是超文本传输协议(HTTP)中的一种数据传输机制,允许 HTTP由网页服务器发送给客户端应用( 通常是网页浏览器)的数据可以分成多个部分。分块传输编码只在 HTTP 协议1.1版本(HTTP/1.1)中提供。
再查看 dashboard 返回的数据(这里我们做了简化):
<!DOCTYPE html>
<html lang="en">
<head>
// ...
</head>
<body class="__className_aaf875">
<section style="padding:20px">
<!--$?-->
<template id="B:0"></template>
<p>Loading PostFeed Component</p>
<!--/$-->
<!--$?-->
<template id="B:1"></template>
<p>Loading Weather Component</p>
<!--/$-->
<!--$?-->
<template id="B:2"></template>
<p>Loading Recommend Component</p>
<!--/$-->
</section>
// ...
<div hidden id="S:0">
<h1>Hello PostFeed</h1>
</div>
<script>
// 交换位置
$RC = function(b, c, e) {
// ...
};
$RC("B:0", "S:0")
</script>
<div hidden id="S:2">
<h1>Hello Recommend</h1>
</div>
<script>
$RC("B:2", "S:2")
</script>
<div hidden id="S:1">
<h1>Hello Weather</h1>
</div>
<script>
$RC("B:1", "S:1")
</script>
</body>
</html>
可以看到使用 Suspense 组件的 fallback UI 和渲染后的内容都会出现在该 HTML 文件中,说明该请求持续与服务端保持连接,服务端在组件渲染完后会将渲染后的内容追加传给客户端,客户端收到新的内容后进行解析,执行类似于
$RC("B:2", "S:2")这样的函数交换 DOM 内容,使 fallback UI 替换为渲染后的内容。
这个过程被称之为 Streaming Server Rendering(流式渲染),它解决了上节说的传统 SSR 的第一个问题,那就是数据获取必须在组件渲染之前。使用 Suspense,先渲染 Fallback UI,等数据返回再渲染具体的组件内容。
使用 Suspense 还有一个好处就是 Selective Hydration(选择性水合)。简单的来说,当多个组件等待水合的时候,React 可以根据用户交互决定组件水合的优先级。比如 Sidebar 和 MainContent 组件都在等待水合,快要到 Sidebar 了,但此时用户点击了 MainContent 组件,React 会在单击事件的捕获阶段同步水合 MainContent 组件以保证立即响应,Sidebar 稍后水合。
总结一下,使用 Suspense,可以解锁两个主要的好处,使得 SSR 的功能更加强大:
- Streaming Server Rendering(流式渲染):从服务器到客户端渐进式渲染 HTML
- Selective Hydration(选择性水合):React 根据用户交互决定水合的优先级
Streaming
Suspense 背后的这种技术称之为 Streaming。将页面的 HTML 拆分成多个 chunks,然后逐步将这些块从服务端发送到客户端。

这样就可以更快的展现出页面的某些内容,而无需在渲染 UI 之前等待加载所有数据。提前发送的组件可以提前开始水合,这样当其他部分还在加载的时候,用户可以和已完成水合的组件进行交互,有效改善用户体验。

Streaming 可以有效的阻止耗时长的数据请求阻塞整个页面加载的情况。它还可以减少加载第一个字节所需时间(TTFB)和首次内容绘制(FCP),有助于缩短可交互时间(TTI),尤其在速度慢的设备上。
传统SSR

使用 Streaming 后:

使用
在 Next.js 中有两种实现 Streaming 的方法:
- 页面级别,使用
loading.jsx- 特定组件,使用
<Suspense>
<Suspense>上节已经介绍过,loading.jsx在 《路由篇 | App Router》也介绍过。这里分享一个使用loading.jsx的小技巧,那就是当多个页面复用一个 loading.jsx 效果的时候可以借助路由组来实现。
目录结构如下:
app
├─ (dashboard)
│ ├─ about
│ │ └─ page.js
│ ├─ settings
│ │ └─ page.js
│ ├─ team
│ │ └─ page.js
│ ├─ layout.js
│ └─ loading.js
其中
app/(dashboard)/layout.js代码如下:
import Link from 'next/link'
export default function DashboardLayout({
children,
}) {
return (
<section>
<nav className="flex items-center justify-center gap-10 text-blue-600 mb-6">
<Link href="/about">About</Link>
<Link href="/settings">Settings</Link>
<Link href="/team">Team</Link>
</nav>
{children}
</section>
)
}
app/(dashboard)/loading.js代码如下:
export default function DashboardLoading() {
return <div className="h-60 flex-1 rounded-xl bg-indigo-500 text-white flex items-center justify-center">Loading</div>
}
app/(dashboard)/about/page.js代码如下:
const sleep = ms => new Promise(r => setTimeout(r, ms));
export default async function About() {
await sleep(2000)
return (
<div className="h-60 flex-1 rounded-xl bg-teal-400 text-white flex items-center justify-center">Hello, About!</div>
)
}
剩余两个组件代码与 About 组件类似。最终的效果如下:

缺点
Suspense 和 Streaming 确实很好,将原本只能先获取数据、再渲染水合的传统 SSR 改为渐进式渲染水合,但还有一些问题没有解决。就比如用户下载的 JavaScript 代码,该下载的代码还是没有少,可是用户真的需要下载那么多的 Javascript 代码吗?又比如所有的组件都必须在客户端进行水合,对于不需要交互性的组件其实没有必要进行水合。
为了解决这些问题,目前的最终方案就是上一篇介绍的 RSC:

当然这并不是说 RSC 可以替代 Suspense,实际上两者可以组合使用,带来更好的性能体验。我们会在实战篇的项目中慢慢体会。
服务端组件和客户端组件
服务端组件和客户端组件是 Next.js 中非常重要的概念。如果没有细致的了解过,你可能会简单的以为所谓服务端组件就是 SSR,客户端组件就是 CSR,服务端组件在服务端进行渲染,客户端组件在客户端进行渲染等等,实际上并非如此。
服务端组件
在 Next.js 中,组件默认就是服务端组件。
举个例子,新建
app/todo/page.js,代码如下:
export default async function Page() {
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
const data = (await res.json()).slice(0, 10)
console.log(data)
return <ul>
{data.map(({ title, id }) => {
return <li key={id}>{title}</li>
})}
</ul>
}
请求会在服务端执行,并将渲染后的 HTML 发送给客户端:

因为在服务端执行,
console打印的结果也只可能会出现在命令行中,而非客户端浏览器中。
优势
使用服务端渲染有很多好处:
- 数据获取:通常服务端环境(网络、性能等)更好,离数据源更近,在服务端获取数据会更快。通过减少数据加载时间以及客户端发出的请求数量来提高性能
- 安全:在服务端保留敏感数据和逻辑,不用担心暴露给客户端
- 缓存:服务端渲染的结果可以在后续的请求中复用,提高性能
- bundle 大小:服务端组件的代码不会打包到 bundle 中,减少了 bundle 包的大小
- 初始页面加载和 FCP:服务端渲染生成 HTML,快速展示 UI
- Streaming:服务端组件可以将渲染工作拆分为 chunks,并在准备就绪时将它们流式传输到客户端。用户可以更早看到页面的部分内容,而不必等待整个页面渲染完毕
因为服务端组件的诸多好处,在实际项目开发的时候,能使用服务端组件就尽可能使用服务端组件。
限制
虽然使用服务端组件有很多好处,但使用服务端组件也有一些限制,比如不能使用 useState 管理状态,不能使用浏览器的 API 等等。如果我们使用了 Next.js 会报错,比如我们将代码修改为:
import { useState } from 'react';
export default async function Page() {
const [title, setTitle] = useState('');
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
const data = (await res.json()).slice(0, 10)
console.log(data)
return <ul>
{data.map(({ title, id }) => {
return <li key={id}>{title}</li>
})}
</ul>
}
此时浏览器会报错:

报错提示我们此时需要使用客户端组件。那么又该如何使用客户端组件呢?
客户端组件
使用客户端组件,你需要在文件顶部添加一个
"use client"声明,修改app/todo/page.js,代码如下:
'use client'
import { useEffect, useState } from 'react';
function getRandomInt(min, max) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}
export default function Page() {
const [list, setList] = useState([]);
const fetchData = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
const data = (await res.json()).slice(0, getRandomInt(1, 10))
setList(data)
}
useEffect(() => {
fetchData()
}, [])
return (
<>
<ul>
{list.map(({ title, id }) => {
return <li key={id}>{title}</li>
})}
</ul>
<button onClick={() => {
location.reload()
}}>换一批</button>
</>
)
}
在这个例子中,我们使用了 useEffect、useState 等 React API,也给按钮添加了点击事件、使用了浏览器的 API。无论使用哪个都需要先声明为客户端组件。
注意:
"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个"use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。
优势
- 交互性:客户端组件可以使用 state、effects 和事件监听器,意味着用户可以与之交互
- 浏览器 API:客户端组件可以使用浏览器 API 如地理位置、localStorage 等
服务端组件 VS 客户端组件
如何选择使用?
| 如果你需要…… | 服务端组件 | 客户端组件 |
|---|---|---|
| 获取数据 | ✅ | ❌ |
| 访问后端资源(直接) | ✅ | ❌ |
| 在服务端上保留敏感信息(访问令牌、API 密钥等) | ✅ | ❌ |
| 在服务端使用依赖包,从而减少客户端 JavaScript 大小 | ✅ | ❌ |
| 添加交互和事件侦听器(onClick(), onChange() 等) | ❌ | ✅ |
| 使用状态和生命周期(useState(), useReducer(), useEffect()等) | ❌ | ✅ |
| 使用仅限浏览器的 API | ❌ | ✅ |
| 使用依赖于状态、效果或仅限浏览器的 API 的自定义 hook | ❌ | ✅ |
| 使用 React 类组件 | ❌ | ✅ |
渲染环境
服务端组件只会在服务端渲染,但客户端组件会在服务端渲染一次,然后在客户端渲染。
这是什么意思呢?让我们写个例子,新建
app/client/page.js,代码如下:
'use client'
import { useState } from 'react';
console.log('client')
export default function Page() {
console.log('client Page')
const [text, setText] = useState('init text');
return (
<button onClick={() => {
setText('change text')
}}>{text}</button>
)
}
新建
app/server/page.js,代码如下:
console.log('server')
export default function Page() {
console.log('server Page')
return (
<button>button</button>
)
}
现在运行
npm run build,会打印哪些数据呢?答案是无论客户端组件还是服务端组件,都会打印:

而且根据输出的结果,无论是
/client还是/server走的都是静态渲染。当运行
npm run start的时候,又会打印哪些数据呢?答案是命令行中并不会有输出,访问
/client的时候,浏览器会有打印:

访问
/server的时候,浏览器不会有任何打印:

客户端组件在浏览器中打印,这可以理解,毕竟它是客户端组件,当然要在客户端运行。可是客户端组件为什么在编译的时候会运行一次呢?让我们看下
/client的返回:

你会发现
init text其实是来自于 useState 中的值,但是却依然输出在 HTML 中。这就是编译客户端组件的作用,为了第一次加载的时候能更快的展示出内容。
所以其实所谓服务端组件、客户端组件并不直接对应于物理上的服务器和客户端。服务端组件运行在构建时和服务端,客户端组件运行在构建时、服务端(生成初始 HTML)和客户端(管理 DOM)。
交替使用服务端组件和客户端组件
实际开发的时候,不可能纯用服务端组件或者客户端组件,当交替使用的时候,一定要注意一点,那就是:
服务端组件可以直接导入客户端组件,但客户端组件并不能导入服务端组件
'use client'
// 这是不可以的
import ServerComponent from './Server-Component'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ServerComponent />
</>
)
}
组件默认是服务端组件,但当组件导入到客户端组件中会被认为是客户端组件。客户端组件不能导入服务端组件,其实是在告诉你,如果你在服务端组件中使用了诸如 Node API 等,该组件可千万不要导入到客户端组件中。
但你可以将服务端组件以 props 的形式传给客户端组件:
'use client'
import { useState } from 'react'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
)
}
import ClientComponent from './client-component'
import ServerComponent from './server-component'
export default function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
使用这种方式,
<ClientComponent>和<ServerComponent>代码解耦且独立渲染。
组件渲染原理
在服务端:
Next.js 使用 React API 编排渲染,渲染工作会根据路由和 Suspense 拆分成多个块(chunks),每个块分两步进行渲染:
- React 将服务端组件渲染成一个特殊的数据格式称为 React Server Component Payload (RSC Payload)
- Next.js 使用 RSC Payload 和客户端组件代码在服务端渲染 HTML
RSC payload 中包含如下这些信息:
- 服务端组件的渲染结果
- 客户端组件占位符和引用文件
- 从服务端组件传给客户端组件的数据
在客户端:
- 加载渲染的 HTML 快速展示一个非交互界面(Non-interactive UI)
- RSC Payload 会被用于协调(reconcile)客户端和服务端组件树,并更新 DOM
- JavaScript 代码被用于水合客户端组件,使应用程序具有交互性(Interactive UI)

注意:上图描述的是页面初始加载的过程。其中 SC 表示 Server Components 服务端组件,CC 表示 Client Components 客户端组件。
我们在上节《渲染篇 | Suspense 与 Streaming》讲到 Suspense 和 Streaming 也有一些问题没有解决,比如该加载的 JavaScript 代码没有少、所有组件都必须水合,即使组件不需要水合。
使用服务端组件和客户端组件就可以解决这个问题,服务端组件的代码不会打包到客户端 bundle 中。渲染的时候,只有客户端组件需要进行水合,服务端组件无须水合。
而在后续导航的时候:
- 客户端组件完全在客户端进行渲染
- React 使用 RSC Payload 来协调客户端和服务端组件树,并更新 DOM

最佳实践:使用服务端组件
共享数据
当在服务端获取数据的时候,有可能出现多个组件共用一个数据的情况。
面对这种情况,你不需要使用 React Context(当然服务端也用不了),也不需要通过 props 传递数据,直接在需要的组件中请求数据即可。这是因为 React 拓展了 fetch 的功能,添加了记忆缓存功能,相同的请求和参数,返回的数据会做缓存。
async function getItem() {
const res = await fetch('https://.../item/1')
return res.json()
}
// 函数被调用了两次,但只有第一次才执行
const item = await getItem() // cache MISS
// 第二次使用了缓存
const item = await getItem() // cache HIT
当然这个缓存也是有一定条件限制的,比如只能在 GET 请求中,具体的限制和原理我们会在缓存篇中具体讲解。
组件只在服务端使用
由于 JavaScript 模块可以在服务器和客户端组件模块之间共享,所以如果你希望一个模块只用于服务端,就比如这段代码:
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
这个函数使用了 API_KEY,所以它应该是只用在服务端的。如果用在客户端,为了防止泄露,Next.js 会将私有环境变量替换为空字符串,所以这段代码可以在客户端导入并执行,但并不会如期运行。
最佳实践:使用客户端组件
客户端组件尽可能下移
为了尽可能减少客户端 JavaScript 包的大小,尽可能将客户端组件在组件树中下移。
举个例子,当你有一个包含一些静态元素和一个交互式的使用状态的搜索栏的布局,没有必要让整个布局都成为客户端组件,将交互的逻辑部分抽离成一个客户端组件(比如
<SearchBar />),让布局成为一个服务端组件:
// SearchBar 客户端组件
import SearchBar from './searchbar'
// Logo 服务端组件
import Logo from './logo'
// Layout 依然作为服务端组件
export default function Layout({ children }) {
return (
<>
<nav>
<Logo />
<SearchBar />
</nav>
<main>{children}</main>
</>
)
}
从服务端组件到客户端组件传递的数据需要序列化
当你在服务端组件中获取的数据,需要以 props 的形式向下传给客户端组件,这个数据需要做序列化。
这是因为 React 需要先在服务端将组件树先序列化传给客户端,再在客户端反序列化构建出组件树。如果你传递了不能序列化的数据,这就会导致错误。
如果你不能序列化,那就改为在客户端使用三方包获取数据吧。
服务端渲染策略
现在让我们新建一个
app/server/page.js,代码如下:
export default async function Page() {
const url = (await (await fetch('https://api.thecatapi.com/v1/images/search')).json())[0].url
return (
<img src={url} width="300" alt="cat" />
)
}
其中,https://api.thecatapi.com/v1/images/search 是一个返回猫猫图片的接口,每次调用都会返回一张随机的猫猫图片数据。
现在让我们运行
npm run dev,开发模式下,每次刷新都会返回一张新的图片:

现在让我们运行
npm run build && npm run start,然而此时每次刷新都还是这张 emo 的猫猫:

这是为什么呢?
让我们看下构建时的输出结果:

/server被标记为Static,表示被预渲染为静态内容。也就是说,/server的返回内容其实在构建的时候就已经决定了。页面返回的图片正是构建时调用猫猫接口返回的那张图片。那么问题来了,如何让
/server每次都返回新的图片呢?这就要说到 Next.js 的服务端渲染策略了。
服务端渲染策略
Next.js 存在三种不同的服务端渲染策略:
- 静态渲染
- 动态渲染
- Streaming
静态渲染(Static Rendering)
这是默认渲染策略,路由在构建时渲染,或者在重新验证后后台渲染,其结果会被缓存并且可以推送到 CDN。适用于未针对用户个性化且数据已知的情况,比如静态博客文章、产品介绍页面等。
开头中的例子就是构建时渲染。那么如何在重新验证后后台渲染呢?
具体重新验证的方法我们会在《缓存篇 | Caching》中详细介绍。这里为了举例说一种 —— 使用路由段配置项
revalidate。
修改
app/server/page.js,代码如下:
export const revalidate = 10
export default async function Page() {
const url = (await (await fetch('https://api.thecatapi.com/v1/images/search')).json())[0].url
return (
<img src={url} width="300" alt="cat" />
)
}
此时虽然在
npm run build的输出中,/server依然是标记为静态渲染,但图片已经可以更新了,虽然每隔一段时间才更新:

其中
revalidate=10表示设置重新验证频率为 10s,但是要注意:
这句代码的效果并不是设置服务器每 10s 会自动更新一次
/server。而是至少 10s 后进行重新验证。
举个例子,假设你现在访问了
/server,此时时间设为 0s,10s 内持续访问,/server返回的都是之前缓存的结果。当 10s 过后,假设你第 12s 又访问了一次/server,此时虽然超过了 10s,但依然会返回之前缓存的结果,但同时会触发服务器更新缓存,当你第 13s 再次访问的时候,就是更新后的结果。
简单来说,超过 revalidate 设置时间的首次访问会触发缓存更新,如果更新成功,后续的返回就都是新的内容,直到下一次触发缓存更新。
动态渲染(Dynamic Rendering)
路由在请求时渲染,适用于针对用户个性化或依赖请求中的信息(如 cookie、URL 参数)的情况。
在渲染过程中,如果使用了动态函数(Dynamic functions)或者未缓存的数据请求(uncached data request),Next.js 就会切换为动态渲染:
| 动态函数 | 数据缓存 | 渲染策略 |
|---|---|---|
| 否 | 缓存 | 静态渲染 |
| 是 | 缓存 | 动态渲染 |
| 否 | 未缓存 | 动态渲染 |
| 是 | 未缓存 | 动态渲染 |
注意:作为开发者,无须选择静态还是动态渲染,Next.js 会自动根据使用的功能和 API 为每个路由选择最佳的渲染策略
使用动态函数(Dynamic functions)
动态函数指的是获取只有在请求时才能得到信息(如 cookie、请求头、URL 参数)的函数。
在 Next.js 中这些动态函数是:
使用这些函数的任意一个,都会导致路由转为动态渲染。
第一个例子,修改
app/server/page.js,代码如下:
import { cookies } from 'next/headers'
export default async function Page() {
const cookieStore = cookies()
const theme = cookieStore.get('theme')
const url = (await (await fetch('https://api.thecatapi.com/v1/images/search')).json())[0].url
return (
<img src={url} width="300" alt="cat" />
)
}
运行
npm run build && npm run start,此时/server显示为动态渲染:

访问效果如下:

第二个例子,使用 searchParams,修改
app/server/page.js,代码如下:
export default async function Page({ searchParams }) {
const url = (await (await fetch('https://api.thecatapi.com/v1/images/search')).json())[0].url
return (
<>
<img src={url} width="300" alt="cat" />
{new Date().toLocaleTimeString()}
{JSON.stringify(searchParams)}
</>
)
}
运行
npm run build && npm run start,此时/server显示为动态渲染:

但是图片却没有在页面刷新的时候改变(此时又是一只 emo 的猫猫):

页面确实是动态渲染,因为每次刷新时间都发生了改变。但为什么图片没有更新呢?
这是因为动态渲染和数据请求缓存是两件事情,页面动态渲染并不代表页面涉及的请求一定不被缓存。正是因为 fetch 接口的返回数据被缓存了,这才导致了图片每次都是这一张。
修改
app/server/page.js,代码如下:
export default async function Page({ searchParams }) {
const url = (await (await fetch('https://api.thecatapi.com/v1/images/search', { cache: 'no-store' })).json())[0].url
return (
<>
<img src={url} width="300" alt="cat" />
{new Date().toLocaleTimeString()}
{JSON.stringify(searchParams)}
</>
)
}
我们为 fetch 请求添加了
{ cache: 'no-store' },使 fetch 请求退出了缓存。此时运行生产版本,图片和时间在刷新的时候都会改变:

注:同样是转为动态渲染,为什么使用 cookies 的时候,fetch 请求没有被缓存呢?这就是接下来要讲的内容。
当你在
headers或cookies方法之后使用 fetch 请求会导致请求退出缓存,这是 Next.js 的自动逻辑,但还有哪些情况导致 fetch 请求自动退出缓存呢?让我们往下看。
使用未缓存的数据请求(uncached data request)
在 Next.js 中,fetch 请求的结果默认会被缓存,但你可以设置退出缓存,一旦你设置了退出缓存,就意味着使用了未缓存的数据请求(uncached data request),会导致路由进入动态渲染,如:
fetch请求添加了cache: 'no-store'选项fetch请求添加了revalidate: 0选项fetch请求在路由处理程序中并使用了POST方法- 在
headers或cookies方法之后使用fetch请求- 配置了路由段选项
const dynamic = 'force-dynamic'- 配置了路由段选项
fetchCache,默认会跳过缓存fetch请求使用了Authorization或者Cookie请求头,并且在组件树中其上方还有一个未缓存的请求
举个例子,修改
app/server/page.js,代码如下:
export default async function Page() {
const url = (await (await fetch('https://api.thecatapi.com/v1/images/search', { cache: 'no-store' })).json())[0].url
return (
<>
<img src={url} width="300" alt="cat" />
{new Date().toLocaleTimeString()}
</>
)
}
此时页面会转为动态渲染,每次刷新页面都会出现新的图片。
关于动态渲染再重申一遍:数据缓存和渲染策略是分开的。假如你选择了动态渲染,Next.js 会在请求的时候再渲染 RSC Payload 和 HTML,但其中涉及的数据请求,依然是可以从缓存中获取的。
Streaming
使用
loading.js或者 React Suspense 组件会开启 Streaming。具体参考小册《渲染篇 | Suspense 与 Streaming》
其他术语防混淆
除了静态渲染、动态渲染、动态函数、未缓存数据请求等术语,阅读官方文档的时候,你还可能遇到局部渲染、动态路由等这些与“渲染”、“动态”、“静态”有关的词,所以我们在这里列出来帮助大家区分。
局部渲染(Partial rendering)
局部渲染指的是仅在客户端重新渲染导航时更改的路由段,共享段的内容的继续保留。举个例子,当在两个相邻的路由间导航的时候,
/dashboard/settings和/dashboard/analytics,settings和analytics页面会重新渲染,共享的dashboard布局会保留。

局部渲染的目的也是为了减少路由切换时的传输数据量和执行时间,从而提高性能。
动态路由(Dynamic Routes)
动态路由我们在小册《路由篇 | 动态路由、路由组、平行路由和拦截路由》中讲过:
export default function Page({ params }) {
return <div>My Post: {params.slug}</div>
}
动态路由并不一定是动态渲染,你也可以用
generateStaticParams静态生成路由。
但有的时候,动态路由(Dynamic Routes)会用来表达“动态渲染的路由”(dynamically rendered routes)这个意思。在官网中,很少用到静态路由(Static Routes)这个词,用到的时候是用来表达“静态渲染的路由”(statically rendered routes)。
动态段(Dynamic Segment)
路由中的动态段,举个例子,
app/blog/[slug]/page.js中[slug]就是动态段。
数据获取篇 | 数据获取、缓存与重新验证
数据请求
在 Next.js 中如何获取数据呢?
Next.js 优先推荐使用原生的 fetch 方法,因为 Next.js 拓展了原生的 fetch 方法,为其添加了缓存和更新缓存(重新验证)的机制。
这样做的好处在于可以自动复用请求数据,提高性能。坏处在于如果你不熟悉,经常会有一些“莫名奇妙”的状况出现……
服务端使用 fetch
Next.js 拓展了原生的 fetch Web API,可以为服务端的每个请求配置缓存(caching)和重新验证( revalidating)行为。
你可以在服务端组件、路由处理程序、Server Actions 中搭配
async/await语法使用 fetch。
// app/page.js
async function getData() {
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
if (!res.ok) {
// 由最近的 error.js 处理
throw new Error('Failed to fetch data')
}
return res.json()
}
export default async function Page() {
const data = await getData()
return <main>{JSON.stringify(data)}</main>
}
默认缓存
默认情况下,Next.js 会自动缓存服务端
fetch请求的返回值(背后用的是数据缓存(Data Cache))。
// fetch 的 cache 选项用于控制该请求的缓存行为
// 默认就是 'force-cache', 平时写的时候可以省略
fetch('https://...', { cache: 'force-cache' })
但这些情况默认不会自动缓存:
- 在 Server Action 中使用的时候
- 在定义了非 GET 方法的路由处理程序中使用的时候
简单的来说,在服务端组件和只有 GET 方法的路由处理程序中使用 fetch,返回结果会自动缓存。
logging 配置项
const nextConfig = {
logging: {
fetches: {
fullUrl: true
}
}
};
export default nextConfig;
目前 logging 只有这一个配置,用于在开发模式下显示 fetch 请求和缓存日志:

上图日志的意思是:
访问
/api/cache路由,其中 GET 请求了 https://dog.ceo/api/breeds/image/random 这个接口,接口 20ms 返回,状态码 200,此次请求命中了缓存(HIT)。
这个日志会帮助我们查看缓存情况(实际用的时候有的日志结果不是很准,还有待改进)。
服务端组件
第一种在服务端组件中使用,修改
app/page.js,代码如下:
async function getData() {
// 接口每次调用都会返回一个随机的猫猫图片数据
const res = await fetch('https://api.thecatapi.com/v1/images/search')
if (!res.ok) {
throw new Error('Failed to fetch data')
}
return res.json()
}
export default async function Page() {
const data = await getData()
return <img src={data[0].url} width="300" />
}
运行
npm run dev,开启开发模式:

在开发模式下,为了方便调试,可以使用浏览器的硬刷新(Command + Shift + R)清除缓存,此时数据会发生更改(cache: SKIP)。普通刷新时因为会命中缓存(cache: HIT),数据会保持不变。
运行
npm run build && npm run start开启生产版本:

因为 fetch 请求的返回结果被缓存了,无论是否硬刷新,图片数据都会保持不变。
路由处理程序 GET 请求
第二种在路由处理程序中使用,新建
app/api/cache/route.js,代码如下:
export async function GET() {
const res = await fetch('https://dog.ceo/api/breeds/image/random')
const data = await res.json()
return Response.json({ data })
}
运行
npm run dev,开启开发模式:

开发模式下,浏览器硬刷新的时候会跳过缓存,普通刷新的时候则会命中缓存。可以看到第一次硬刷新的时候,请求接口时间为 912ms,后面普通刷新的时候,因为使用缓存中的数据,数据返回时间都是 1ms 左右。
运行
npm run build && npm run start开启生产版本:

因为 fetch 请求的返回结果被缓存了,无论是否硬刷新,接口数据都会保持不变。
重新验证
在 Next.js 中,清除数据缓存并重新获取最新数据的过程就叫做重新验证(Revalidation)。
Next.js 提供了两种方式重新验证:
一种是基于时间的重新验证(Time-based revalidation),即经过一定时间并有新请求产生后重新验证数据,适用于不经常更改且新鲜度不那么重要的数据。
一种是按需重新验证(On-demand revalidation),根据事件手动重新验证数据。按需重新验证又可以使用基于标签(tag-based)和基于路径(path-based)两种方法重新验证数据。适用于需要尽快展示最新数据的场景。
基于时间的重新验证
用基于时间的重新验证,你需要在使用 fetch 的时候设置next.revalidate选项(以秒为单位):
fetch('https://...', { next: { revalidate: 3600 } })
或者通过路由段配置项进行配置,使用这种方法,它会重新验证该路由段所有的
fetch请求。
// layout.jsx | page.jsx | route.js
export const revalidate = 3600
注:在一个静态渲染的路由中,如果你有多个请求,每个请求设置了不同的重新验证时间,将会使用最短的时间用于所有的请求。而对于动态渲染的路由,每一个
fetch请求都将独立重新验证。
按需重新验证
使用按需重新验证,在路由处理程序或者 Server Action 中通过路径( revalidatePath) 或缓存标签 revalidateTag 实现。
revalidatePath
新建
app/api/revalidatePath/route.js,代码如下:
import { revalidatePath } from 'next/cache'
export async function GET(request) {
const path = request.nextUrl.searchParams.get('path')
if (path) {
revalidatePath(path)
return Response.json({ revalidated: true, now: Date.now() })
}
return Response.json({
revalidated: false,
now: Date.now(),
message: 'Missing path to revalidate',
})
}
此时访问
/api/revalidatePath?path=/就会更新/的 fetch 请求返回数据,此时访问
/api/revalidatePath?path=/api/cache就会更新/api/cache的 fetch 请求返回数据,交互效果如下:

注意:图演示的是开发模式下的情况,用 revalidatePath 确实更新了对应路径上的 fetch 缓存结果。但如果大家部署到生产版本,你是发现 revalidatePath 只对页面生效,对路由处理程序并不生效。
这是因为
/api/cache被静态渲染了,首先你要将/api/cache转为动态渲染,然后才能测试 revalidatePath 的效果。但是转为动态渲染,比如使用 cookies 等函数,又会触发 Next.js 的自动逻辑,让 fetch 请求退出缓存。
简而言之,如果你想在生产环境测试 revalidatePath 对路由处理程序的影响,你需要多做一些配置:
// 路由动态渲染
export const revalidate = 0
// fetch 强制缓存
export const fetchCache = 'force-cache'
export async function GET() {
const res = await fetch('https://dog.ceo/api/breeds/image/random')
const data = await res.json()
return Response.json({ data, now: Date.now() })
}
这样的代码在生产环境下,是可以被 revalidatePath 重新验证的。效果同开发模式下的截图。
revalidateTag
Next.js 有一个路由标签系统,可以跨路由实现多个 fetch 请求重新验证。具体这个过程为:
- 使用 fetch 的时候,设置一个或者多个标签标记请求
- 调用 revalidateTag 方法重新验证该标签对应的所有请求
// app/page.js
export default async function Page() {
const res = await fetch('https://...', { next: { tags: ['collection'] } })
const data = await res.json()
// ...
}
在这个例子中,为
fetch请求添加了一个collection标签。在 Server Action 中调用revalidateTag,就可以让所有带collection标签的 fetch 请求重新验证。
// app/actions.js
'use server'
import { revalidateTag } from 'next/cache'
export default async function action() {
revalidateTag('collection')
}
错误处理和重新验证
如果在尝试重新验证的过程中出现错误,缓存会继续提供上一个重新生成的数据,而在下一个后续请求中,Next.js 会尝试再次重新验证数据
退出数据缓存
当
fetch请求满足这些条件时都会退出数据缓存:
fetch请求添加了cache: 'no-store'选项fetch请求添加了revalidate: 0选项fetch请求在路由处理程序中并使用了POST方法- 使用
headers或cookies的方法之后使用fetch请求- 配置了路由段选项
const dynamic = 'force-dynamic'- 配置了路由段选项
fetchCache,默认会跳过缓存fetch请求使用了Authorization或者Cookie请求头,并且在组件树中其上方还有一个未缓存的请求
在具体使用的时候,如果你不想缓存某个单独请求:
// layout.js | page.js
fetch('https://...', { cache: 'no-store' })
不缓存多个请求,可以借助路由段配置项:
// layout.js | page.js
export const dynamic = 'force-dynamic'
Next.js 推荐单独配置每个请求的缓存行为,这可以让你更精细化的控制缓存行为。
服务端使用三方请求库
也不是所有时候都能使用 fetch 请求,如果你使用了不支持或者暴露 fetch 方法的三方库(如数据库、CMS 或 ORM 客户端),但又想实现数据缓存机制,那你可以使用 React 的
cache函数和路由段配置项来实现请求的缓存和重新验证。
// app/utils.js
import { cache } from 'react'
export const getItem = cache(async (id) => {
const item = await db.item.findUnique({ id })
return item
})
现在我们调用两次
getItem:
// app/item/[id]/layout.js
import { getItem } from '@/utils/get-item'
export const revalidate = 3600
export default async function Layout({ params: { id } }) {
const item = await getItem(id)
// ...
}
// app/item/[id]/page.js
import { getItem } from '@/utils/get-item'
export const revalidate = 3600
export default async function Page({ params: { id } }) {
const item = await getItem(id)
// ...
}
在这个例子中,尽管
getItem被调用两次,但只会产生一次数据库查询。
客户端使用路由处理程序
如果你需要在客户端组件中获取数据,可以在客户端调用路由处理程序。路由处理程序会在服务端被执行,然后将数据返回给客户端,适用于不想暴露敏感信息给客户端(比如 API tokens)的场景。
如果你使用的是服务端组件,无须借助路由处理程序,直接获取数据即可。
客户端使用三方请求库
你也可以在客户端使用三方的库如 SWR 或 React Query 来获取数据。这些库都有提供自己的 API 实现记忆请求、缓存、重新验证和更改数据。
建议与最佳实践
有一些在 React 和 Next.js 中获取数据的建议和最佳实践,本节来介绍一下:
尽可能在服务端获取数据
尽可能在服务端获取数据,这样做有很多好处,比如:
- 可以直接访问后端资源(如数据库)
- 防止敏感信息泄漏
- 减少客户端和服务端之间的来回通信,加快响应时间
在需要的地方就地获取数据
如果组件树中的多个组件使用相同的数据,无须先全局获取,再通过 props 传递,你可以直接在需要的地方使用
fetch或者 Reactcache获取数据,不用担心多次请求造成的性能问题,因为fetch请求会自动被记忆化。这也同样适用于布局,毕竟本来父子布局之间也不能传递数据。
适当的时候使用 Streaming
Streaming 和
Suspense都是 React 的功能,允许你增量传输内容以及渐进式渲染 UI 单元。页面可以直接渲染部分内容,剩余获取数据的部分会展示加载态,这也意味着用户不需要等到页面完全加载完才能与其交互。

串行获取数据
在 React 组件内获取数据时,有两种数据获取模式,并行和串行。

所谓串行数据获取,数据请求相互依赖,形成瀑布结构,这种行为有的时候是必要的,但也会导致加载时间更长。
谓并行数据获取,请求同时发生并加载数据,这会减少加载数据所需的总时间。
们先说说串行数据获取,直接举个例子:
// app/artist/page.js
// ...
async function Playlists({ artistID }) {
// 等待 playlists 数据
const playlists = await getArtistPlaylists(artistID)
return (
<ul>
{playlists.map((playlist) => (
<li key={playlist.id}>{playlist.name}</li>
))}
</ul>
)
}
export default async function Page({ params: { username } }) {
// 等待 artist 数据
const artist = await getArtist(username)
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<div>Loading...</div>}>
<Playlists artistID={artist.id} />
</Suspense>
</>
)
}
在这个例子中,
Playlists组件只有当Artist组件获得数据才会开始获取数据,因为Playlists组件依赖artistId这个 prop。这也很容易理解,毕竟只有先知道了是哪位艺术家,才能获取这位艺术家对应的曲目。
在这种情况下,你可以使用
loading.js或者 React 的<Suspense>组件,展示一个即时加载状态,防止整个路由被数据请求阻塞,而且用户还可以与未被阻塞的部分进行交互。
关于阻塞数据请求:
- 防止出现串行数据请求的方法是在应用程序根部全局获取数据,但这会阻塞其下所有路由段的渲染,直到数据加载完毕。
- 使用
await的fetch请求都会阻塞渲染和下方所有组件的数据请求,除非它们使用了<Suspense>或者loading.js。另一种替代方式就是使用并行数据请求或者预加载模式。
并行数据请求
要实现并行请求数据,你可以在使用数据的组件外定义请求,然后在组件内部调用,举个例子:
import Albums from './albums'
// 组件外定义
async function getArtist(username) {
const res = await fetch(`https://api.example.com/artist/${username}`)
return res.json()
}
async function getArtistAlbums(username) {
const res = await fetch(`https://api.example.com/artist/${username}/albums`)
return res.json()
}
export default async function Page({ params: { username } }) {
// 组件内调用,这里是并行的
const artistData = getArtist(username)
const albumsData = getArtistAlbums(username)
// 等待 promise resolve
const [artist, albums] = await Promise.all([artistData, albumsData])
return (
<>
<h1>{artist.name}</h1>
<Albums list={albums}></Albums>
</>
)
}
在这个例子中,
getArtist和getArtistAlbums函数都是在Page组件外定义,然后在Page组件内部调用。用户需要等待两个 promise 都 resolve 后才能看到结果。
为了提升用户体验,可以使用 Suspense 组件来分解渲染工作,尽快展示出部分结果。
预加载数据
防止出现串行请求的另外一种方式是使用预加载。举个例子:
// app/article/[id]/page.js
import Article, { preload, checkIsAvailable } from './components/Article'
export default async function Page({ params: { id } }) {
// 获取文章数据
preload(id)
// 执行另一个异步任务,这里是伪代码,比如判断文章是否有权限访问
const isAvailable = await checkIsAvailable()
return isAvailable ? <Article id={id} /> : null
}
而在具体的 preload 函数中,则要搭配 React 的 cache 函数一起使用:
// components/Article.js
import { getArticle } from '@/utils/get-article'
import { cache } from 'react'
export const getArticle = cache(async (id) => {
// ...
})
export const preload = (id) => {
void getArticle(id)
}
export const checkIsAvailable = (id) => {
// ...
}
export default async function Article({ id }) {
const result = await getArticle(id)
// ...
}
使用 React cache server-only 和预加载模式
你可以将
cache函数,preload模式和 server-only 包一起使用,创建一个可在整个应用使用的数据请求工具函数。
// utils/get-article.js
import { cache } from 'react'
import 'server-only'
export const preloadArticle = (id) => {
void getArticle(id)
}
export const getArticle = cache(async (id) => {
// ...
})
现在,你可以提前获取数据、缓存返回结果,并保证数据获取只发生在服务端。此外,布局、页面、组件都可以使用
utils/get-article.js
注:如果想要细致了解 preload 函数和 server-only 以及 cache 的特性,可以查看: (技巧)当 Next.js 遇到频繁重复的数据库操作时,记住使用 React 的 cache 函数
缓存:Caching
理论上,缓存不是使用 Next.js 的必要知识。因为 Next.js 会自动根据你使用的 API 做好缓存管理。但实际上,你还是要认真学习下缓存,至少要清楚知道 Next.js 的缓存机制有哪些,大致的工作原理,以及如何退出缓存,否则遇到缓存问题的时候你甚至不知道如何解决……
Next.js 中有四种缓存机制:
| 机制 | 缓存内容 | 存储地方 | 目的 | 期间 |
|---|---|---|---|---|
| 请求记忆(Request Memoization) | 函数返回值 | 服务端 | 在 React 组件树中复用数据 | 每个请求的生命周期 |
| 数据缓存(Data Cache ) | 数据 | 服务端 | 跨用户请求和部署复用数据 | 持久(可重新验证) |
| 完整路由缓存(Full Route Cache) | HTML 和 RSC payload | 服务端 | 降低渲染成本、提高性能 | 持久(可重新验证) |
| 路由缓存(Router Cache) | RSC payload | 客户端 | 减少导航时的服务端请求 | 用户会话或基于时间 |
默认情况下,Next.js 会尽可能多的使用缓存以提高性能和降低成本。像路由默认会采用静态渲染,数据请求的结果默认会被缓存。下图是构建时静态路由渲染以及首次访问静态路由的原理图:

在这张图中:
打包构建
/a时(BUILD TIME),因为路由中的请求是首次,所以都会MISS,从数据源获取数据后,将数据在请求记忆和数据缓存中都保存了一份(SET),并将生成的 RSC Payload 和 HTML 也在服务端保存了一份(完整路由缓存)。
当客户端访问
/a的时候,命中服务端缓存的 RSC Payload 和 HTML,并将 RSC Payload 在客户端保存一份(路由缓存)。
缓存行为是会发生变化的,具体取决的因素有很多,比如路由是动态渲染还是静态渲染,数据是缓存还是未缓存,请求是在初始化访问中还是后续导航中。
请求记忆(Request Memoization)
工作原理
React 拓展了 fetch API,当有相同的 URL 和参数的时候,React 会自动将请求结果缓存。也就是说,即时你在组件树中的多个位置请求一份相同的数据,但数据获取只会执行一次。

这样当你跨路由(比如跨布局、页面、组件)时,你不需要在顶层请求数据,然后将返回结果通过 props 转发,直接在需要数据的组件中请求数据即可,不用担心对同一数据发出多次请求造成的性能影响。
// app/page.js
async function getItem() {
// 自动缓存结果
const res = await fetch('https://.../item/1')
return res.json()
}
// 函数调用两次,但只会执行一次请求
const item = await getItem() // cache MISS
const item = await getItem() // cache HIT
这是请求记忆的工作原理图:

在这种图中,当渲染
/a路由的时候,由于是第一次请求,会触发缓存MISS,函数被执行,请求结果会被存储到内存中(缓存SET),当下一次相同的调用发生时,会触发缓存HIT,数据直接从内存中取出。
它背后的原理想必大家也想到了,就是函数记忆,《JavaScript 权威指南》中就有类似的函数:
function memoize(f) {
var cache = {};
return function(){
var key = arguments.length + Array.prototype.join.call(arguments, ",");
if (key in cache) {
return cache[key]
}
else return cache[key] = f.apply(this, arguments)
}
}
关于请求记忆,要注意:
- 请求记忆是 React 的特性,并非 Next.js 的特性。 React 和 Next.js 都做了请求缓存,React 的方案叫做“请求记忆”,Next.js 的方案叫做“数据缓存”,两者有很多不同
- 请求记忆只适合用于用
GET方法的fetch请求- 请求记忆只应用于 React 组件树,也就是说你在
generateMetadata、generateStaticParams、布局、页面和其他服务端组件中使用 fetch 会触发请求记忆,但是在路由处理程序中使用则不会触发,因为这就不在 React 组件树中了
持续时间
缓存会持续在服务端请求的生命周期中,直到 React 组件树渲染完毕。它的存在是为了避免组件树渲染的时候多次请求同一数据造成的性能影响。
重新验证
由于请求记忆只会在渲染期间使用,因此也无须重新验证。
退出方式
这个行为是 React 的默认优化。不建议退出。
如果你不希望 fetch 请求被记忆,可以借助 AbortController 这个 Web API,具体使用方式如下(虽然这个 API 本来的作用是用来中止请求):
const { signal } = new AbortController()
fetch(url, { signal })
React Cache
如果你不能使用 fetch 请求,但是又想实现记忆,可以借助 React 的 cache 函数:
// utils/get-item.ts
import { cache } from 'react'
import db from '@/lib/db'
export const getItem = cache(async (id: string) => {
const item = await db.item.findUnique({ id })
return item
})
数据缓存(Data Cache)
工作原理
Next.js 有自己的数据缓存方案,可以跨服务端请求和构建部署存储数据。之所以能够实现,是因为 Next.js 拓展了 fetch API,在 Next.js 中,每个请求都可以设置自己的缓存方式。
不过与 React 的请求记忆不同的是,请求记忆因为只用于组件树渲染的时候,所以不用考虑数据缓存更新的情况,但 Next.js 的数据缓存方案更为持久,则需要考虑这个问题。
默认情况下,使用
fetch的数据请求都会被缓存,这个缓存是持久的,它不会自动被重置。你可以使用fetch的cache和next.revalidate选项来配置缓存行为:
fetch(`https://...`, { cache: 'force-cache' | 'no-store' })
fetch(`https://...`, { next: { revalidate: 3600 } })
这是 Next.js 数据缓存的工作原理图:

让我们解释一下:当渲染的时候首次调用,请求记忆和数据缓存都会 MISS,从而执行请求,返回的结果在请求记忆和数据缓存中都会存储一份。
当再次调用的时候,因为添加了
{cache: 'no-store'}参数,请求参数不同,请求记忆会 MISS,而这个参数会导致数据缓存跳过,所以依然是执行请求,因为配置了 no-store,所以数据缓存也不会缓存返回的结果,请求记忆则会正常做缓存处理。
持续时间
数据缓存在传入请求和部署中都保持不变,除非重新验证或者选择退出。
重新验证
Next.js 提供了两种方式更新缓存:
一种是基于时间的重新验证(Time-based revalidation),即经过一定时间并有新请求产生后重新验证数据,适用于不经常更改且新鲜度不那么重要的数据。
一种是按需重新验证(On-demand revalidation),根据事件手动重新验证数据。按需重新验证又可以使用基于标签(tag-based)和基于路径(path-based)两种方法重新验证数据。适用于需要尽快展示最新数据的场景。
基于时间
基于时间的重新验证,需要使用
fetch的next.revalidate选项设置缓存的时间(注意它是以秒为单位)。
// 每小时重新验证
fetch('https://...', { next: { revalidate: 3600 } })
可以借助路由段配置项来配置该路由所有的 fetch 请求:
// layout.jsx / page.jsx / route.js
export const revalidate = 3600
这是基于时间的重新验证原理图:

通过这种图,你可以发现:并不是 60s 后该请求会自动更新,而是 60s 后再有请求的时候,会进行重新验证,60s 后的第一次请求依然会返回之前的缓存值,但 Next.js 将使用新数据更新缓存。60s 后的第二次请求会使用新的数据。
按需更新
使用按需重新验证,数据可以根据路径(
revalidatePath)和 缓存标签(revalidateTag) 按需更新。
revalidatePath用在路由处理程序或 Server Actions 中,用于手动清除特定路径中的缓存数据:
revalidatePath('/')
revalidateTag依赖的是 Next.js 的缓存标签系统,当使用 fetch 请求的时候,声明一个标签,然后在路由处理程序或是 Server Actions 中重新验证具有某一标签的请求:
// 使用标签
fetch(`https://...`, { next: { tags: ['a', 'b', 'c'] } })
// 重新验证具有某一标签的请求
revalidateTag('a')
这是按需更新的原理图:

你会发现,这跟基于时间的重新验证有所不同。第一次调用请求的时候,正常缓存数据。当触发按需重新验证的时候,将会从缓存中删除相应的缓存条目。下次请求的时候,又相当于第一次调用请求,正常缓存数据。
退出方式
如果你想要退出数据缓存,有两种方式:
一种是将
fetch的cache选项设置为no-store,示例如下,每次调用的时候都会重新获取数据:
fetch(`https://...`, { cache: 'no-store' })
一种是使用路由段配置项,它会影响该路由段中的所有数据请求:
export const dynamic = 'force-dynamic'
实战体验
修改
app/page.js,代码如下:
async function getData() {
// 接口每次调用都会返回一个随机的猫猫图片数据
const res = await fetch('https://api.thecatapi.com/v1/images/search')
return res.json()
}
export async function generateMetadata() {
const data = await getData()
return {
title: data[0].id
}
}
export default async function Page() {
const data = await getData()
return (
<>
<h1>图片 ID:{data[0].id}</h1>
<img src={data[0].url} width="300" />
<CatDetail />
</>
)
}
async function CatDetail() {
const data = await getData()
return (
<>
<h1>图片 ID:{data[0].id}</h1>
<img src={data[0].url} width="300" />
</>
)
}
代码的逻辑很简单,访问
/会在 generateMetadata 函数、页面、子组件中调用 3 次接口,接口每次调用都会返回一张随机的猫猫图片数据,请问此时运行生产版本,3 次返回的数据是一致的吗?
让我们实际运行一下,效果如下:

无论是普通刷新还是硬刷新,图片都会保持不遍,且 3 次接口调用数据返回一致。
原因也很简单,首先是静态渲染,页面在构建的时候进行渲染,其次虽然调用了 3 次接口,但因为有请求记忆、数据缓存,3 次调用接口数据返回一致。
现在我们关掉数据缓存,在
app/page.js中添加代码:
现在我们关掉数据缓存,在
app/page.js中添加代码:
// 强制 fetch 不缓存
export const fetchCache = 'force-no-store'
运行生产版本,此时交互效果如下:

因为设置了 fetch 不缓存,页面自动从静态渲染转为动态渲染,所以每次刷新,接口都会返回新的图片。但因为有请求记忆,3 次接口调用都是返回一样的图片。
此时我们再关闭请求记忆,修改
app/page.js:
async function getData() {
const { signal } = new AbortController()
const res = await fetch('https://api.thecatapi.com/v1/images/search', { signal })
return res.json()
}
运行生产版本,此时交互效果如下:

此时页面动态渲染,数据缓存和请求记忆都已关闭,所以每次请求都会返回不同的图片数据。
总结:请求记忆和数据缓存
最后让我们比较一下请求记忆和数据缓存:
请求记忆是 React 的数据缓存方案,它只持续在组件树渲染期间,目的是为了避免组件树渲染的时候多次请求同一数据造成的性能影响。
数据缓存是 Next.js 的数据缓存方案,它可以跨部署和请求缓存,缓存数据不会失效,除非重新验证或者主动退出。目的在于优化应用性能。
实际项目开发的时候,请求记忆和数据缓存往往同时存在,共同作用。

完整路由缓存(Full Route Cache)
Next.js 在构建的时候会自动渲染和缓存路由,这样当访问路由的时候,可以直接使用缓存中的路由而不用从零开始在服务端渲染,从而加快页面加载速度。
那你可能要问,缓存路由是个什么鬼?我听过缓存数据,但是路由怎么缓存呢?让我们复习下 Next.js 的渲染原理:
Next.js 使用 React 的 API 来编排渲染。当渲染的时候,渲染工作会根据路由和 Suspense 拆分成多个 chunk,每个 chunk 分为两步进行渲染:
- React 会将服务端组件渲染成一种特殊的数据格式,我们称之为 React Server Component Payload,简写为 RSC payload。比如一个服务端组件的代码为:
<div>
Don’t give up and don’t give in.
<ClientComponent />
</div>
React 会将其转换为如下的 Payload:
["$","div",null,{"children":["Don’t give up and don’t give in.", ["$","$L1",null,{}]]}]
1:I
这个格式针对流做了优化,它们可以以流的形式逐行从服务端发送给客户端,客户端可以逐行解析 RSC Payload,渐进式渲染页面。
当然这个 RSC payload 代码肯定是不能直接执行的,它包含的更多是信息:
- 服务端组件的渲染结果
- 客户端组件的占位和引用文件
- 从服务端组件传给客户端组件的数据
比如这个 RSC Payload 中的
$L1表示的就是 ClientComponent,客户端会在收到 RSC Payload 后,解析下载 ClientComponent 对应的 bundle 地址,然后将执行的结果渲染到$L1占位的位置上。
- Next.js 会用 RSC payload 和客户端组件代码在服务端渲染 HTML
这张图生动的描述了这个过程:

简单来说,路由渲染的产物有两个,一个是 RSC Payload,一个是 HTML。完整路由缓存,缓存的就是这两个产物。
不过路由在构建的时候是否会被缓存取决于它是静态渲染还是动态渲染。静态路由默认都是会被缓存的,动态路由因为只能在请求的时候被渲染,所以不会被缓存。这张图展示了静态渲染和动态渲染的差异:

持续时间
完整路由缓存默认是持久的,这意味着可以跨用户请求复用。
失效方式
有两种方式可以使完整路由缓存失效:
- 重新验证数据:重新验证数据缓存会使完整路由缓存失效,毕竟渲染输出依赖于数据
- 重新部署:数据缓存是可以跨部署的,但完整路由缓存会在重新部署中被清除
退出方式
退出完整路由缓存的方式就是将其改为动态渲染:
- 使用动态函数:使用动态函数后会改为动态渲染,此时数据缓存依然可以用
- 使用路由段配置项:
dynamic = 'force-dynamic'或revalidate = 0这会跳过完整路由缓存和数据缓存,也就是说,每次请求时都会重新获取数据并渲染组件。此时路由缓存依然可以用,毕竟它是客户端缓存- 退出数据缓存:如果路由中有一个 fetch 请求退出了缓存,则会退出完整路由缓存。这个特定的 fetch 请求会在每次请求时重新获取,其他 fetch 请求依然会使用数据缓存。Next.js 允许这种缓存和未缓存数据的混合
简单来说,完整路由缓存只适用于静态渲染,在服务端保留静态渲染的产物 RSC Payload 和 HTML。
使用动态渲染则会退出完整路由缓存。如何让路由从静态渲染转为动态渲染,也可以参考 《渲染篇 | 服务端渲染策略》。
路由缓存(Router Cache)
Next.js 有一个存放在内存中的客户端缓存,它会在用户会话期间按路由段存储 RSC Payload。这就是路由缓存。

原理图很好理解,当访问
/a的时候,因为是首次访问(MISS),将/(layout)和/a(page)放在路由缓存中(SET),当访问与/a共享布局的/b的时候,使用路由缓存中的/(layout),然后将/b(page)放在路由缓存中(SET)。再次访问/a的时候,直接使用路由缓存中(HIT)的/(layout)和/b(page)。
不止如此,当用户在路由之间导航,Next.js 会缓存访问过的路由段并预获取用户可能导航的路由(基于视口内的
<Link>组件)。这会为用户带来更好的导航体验:
- 即时前进和后退导航,因为访问过的路由已经被缓存,并且预获取了新路由
- 导航不会导致页面重载,并且会保留 React 的状态和浏览器状态
// app/layout.js
import Link from "next/link";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<div>
<Link href="/a">Link to /a</Link>
<br />
<Link href="/b">Link to /b</Link>
</div>
{children}
</body>
</html>
)
}
两个路由的代码类似:
// app/a/page.js | app/b/page.js
export default function Page() {
return (
<h1>Component X</h1>
)
}
当首次访问
/a的时候,因为 Link 组件的/a和/b都在视口内,所以会预加载/a和/b的 RSC Payload:

得益于预加载和缓存,无论是导航还是前进后退都非常顺滑:

持续时间
路由缓存存放在浏览器的临时缓存中,有两个因素决定了路由缓存的持续时间:
- Session,缓存在导航时持续存在,当页面刷新的时候会被清除
- 自动失效期:单个路由段会在特定时长后自动失效
- 如果路由是静态渲染,持续 5 分钟
- 如果路由是动态渲染,持续 30s
比如上面的 demo 中如果等 5 分钟后再去点击,就会重新获取新的 RSC Payload
通过添加
prefetch={true}(Link 组件的 prefetch 默认就为 true)或者在动态渲染路由中调用router.prefetch,可以进入缓存 5 分钟。
失效方式
有两种方法可以让路由缓存失效:
- 在 Server Action 中
- 通过
revalidatePath或revalidateTag重新验证数据- 使用
cookies.set或者cookies.delete会使路由缓存失效,这是为了防止使用 cookie 的路由过时(如身份验证)- 调用
router.refresh会使路由缓存失效并发起一个重新获取当前路由的请求
退出方式
无法退出路由缓存。你可以通过给
<Link>组件的prefetch传递false来退出预获取,但依然会临时存储路由段 30s,这是为了实现嵌套路由段之间的即时导航。此外访问过的路由也会被缓存。
总结: 路由缓存和完整路由缓存
路由缓存和完整路由缓存的区别:
- 路由缓存发生在用户访问期间,将 RSC Payload 暂时存储在浏览器,导航期间都会持续存在,页面刷新的时候会被清除。而完整路由缓存则会持久的将 RSC Payload 和 HTML 缓存在服务器上
- 完整路由缓存仅缓存静态渲染的路由,路由缓存可以应用于静态和动态渲染的路由
在实际项目开发中,路由缓存可能是一个让人头疼的问题。因为它经常使用,但又无法退出,为此有的时候需要特殊处理,所以关于路由缓存可以多关注一下。
之前说过 Next.js 会自动根据你使用的 API 做好缓存管理,具体 API 跟四种缓存的关系表为:
| API | 路由缓存 | 完整路由缓存 | 数据缓存 | 请求记忆 |
|---|---|---|---|---|
<Link prefetch> |
Cache | |||
router.prefetch |
Cache | |||
router.refresh |
Revalidate | |||
fetch |
Cache | Cache | ||
fetch options.cache |
Cache or Opt out | |||
fetch options.next.revalidate |
Revalidate | Revalidate | ||
fetch options.next.tags |
Cache | Cache | ||
revalidateTag |
Revalidate (Server Action) | Revalidate | Revalidate | |
revalidatePath |
Revalidate (Server Action) | Revalidate | Revalidate | |
const revalidate |
Revalidate or Opt out | Revalidate or Opt out | ||
const dynamic |
Cache or Opt out | Cache or Opt out | ||
cookies |
Revalidate (Server Action) | Opt out | ||
headers, searchParams |
Opt out | |||
generateStaticParams |
Cache | |||
React.cache |
Cache |
注:Cache 表示触发缓存,Revalidate 表示触发重新验证,Opt out 表示触发退出缓存
在开发项目中遇到缓存问题的时候,可以先根据使用的 API 判断涉及的缓存类型,然后再选择合适的方式重新验证或者退出缓存。
数据获取篇 | Server Actions
Server Actions 是指在服务端执行的异步函数,它们可以在服务端和客户端组件中使用,以处理 Next.js 应用中的数据提交和更改。
基本用法
定义一个 Server Action 需要使用 React 的 "use server" 指令。按指令的定义位置分为两种用法:
- 将 "use server" 放到一个 async 函数的顶部表示该函数为 Server Action(函数级别)
- 将 "use server" 放到一个单独文件的顶部表示该文件导出的所有函数都是 Server Actions(模块级别)
Server Actions 可以在服务端组件使用,也可以在客户端组件使用。
当在服务端组件中使用的时候,两种级别都可以使用:
// app/page.jsx
export default function Page() {
// Server Action
async function create() {
'use server'
// ...
}
return (
// ...
)
}
而在客户端组件中使用的时候,只支持模块级别。需要先创建一个文件(文件名无约定,很多开发者常命名为 "actions"),在顶部添加 "use server" 指令:
'use server'
// app/actions.js
export async function create() {
// ...
}
当需要使用的时候,导入该文件:
import { create } from '@/app/actions'
export function Button() {
return (
// ...
)
}
也可以将 Server Action 作为 props 传给客户端组件:
<ClientComponent updateItem={updateItem} />
'use client'
export default function ClientComponent({ updateItem }) {
return <form action={updateItem}>{/* ... */}</form>
}
使用场景
在 Pages Router 下,如果要进行前后端交互,需要先定义一个接口,然后前端调用接口完整前后端交互。而在 App Router 下,这种操作都可以简化为 Server Actions。
也就是说,如果你要实现一个功能,按照传统前后端分离的架构,需要自己先写一个接口,用于前后端交互,那就都可以尝试使用 Server Actions,除非你就是需要写接口方便外部调用。
而在具体使用上,虽然 Server Actions 常与
<form>一起使用,但其实还可以在事件处理程序、useEffect、三方库、其他表单元素(如<button>)中调用。
实战体会
我们的目标是写一个简单的 ToDoList:

写之前我们先用传统的 Pages Router 来实现一遍,通过对比来感受传统的使用 API 开发和使用 Server Actions 开发之间的区别。
Pages Router - API
实现一个 ToDoList,我们需要先创建一个
/api/todo接口。新建app/api/todos/route.js,代码如下:
import { NextResponse } from 'next/server'
const data = ['阅读', '写作', '冥想']
export async function GET() {
return NextResponse.json({ data })
}
export async function POST(request) {
const formData = await request.formData()
const todo = formData.get('todo')
data.push(todo)
return NextResponse.json({ data })
}
此时访问
/api/todos,效果如下:

现在我们开始写页面,在项目根目录新建
pages目录(用了 src,就放到 src 下),新建pages/form.js,代码如下:
import { useEffect, useState } from "react"
export default function Page() {
const [todos, setTodos] = useState([])
useEffect(() => {
const fetchData = async () => {
const { data } = await (await fetch('/api/todos')).json()
setTodos(data)
}
fetchData()
}, [])
async function onSubmit(event) {
event.preventDefault()
const response = await fetch('/api/todos', {
method: 'POST',
body: new FormData(event.currentTarget),
})
const {data} = await response.json()
setTodos(data)
}
return (
<>
<form onSubmit={onSubmit}>
<input type="text" name="todo" />
<button type="submit">Submit</button>
</form>
<ul>
{todos.map((todo, i) => <li key={i}>{todo}</li>)}
</ul>
</>
)
}
代码很简单,页面加载的时候 GET 请求
/api/todos渲染待办事项,表单提交的时候 POST 请求/api/todos修改数据,然后渲染最新的待办事项。交互效果如下:

App Router - Server Actions
那么用 Server Actions 该怎么实现呢?
新建
app/form2/page.js,代码如下:
import { findToDos, createToDo } from './actions';
export default async function Page() {
const todos = await findToDos();
return (
<>
<form action={createToDo}>
<input type="text" name="todo" />
<button type="submit">Submit</button>
</form>
<ul>
{todos.map((todo, i) => <li key={i}>{todo}</li>)}
</ul>
</>
)
}
新建
app/form2/actions.js,代码如下:
'use server'
import { revalidatePath } from "next/cache";
const data = ['阅读', '写作', '冥想']
export async function findToDos() {
return data
}
export async function createToDo(formData) {
const todo = formData.get('todo')
data.push(todo)
revalidatePath("/form2");
return data
}
交互效果如下(其实效果跟 Pages Router 下相同):

Server Actions
就让我们以这个简单的 Server Actions Demo 为例来分析下 Server Actions。
基本原理
首先是原理,Server Actions 是怎么实现的呢?让我们看下表单对应的 HTML 元素:

Next.js 会自动插入一个
<input type="hidden">,其值为$ACTION_ID_xxxxxxxx,用于让服务端区分 Action(因为一个页面可能使用多个 Server Actions)。
当点击 Submit 的时候,触发表单提交,会发送一个 POST 请求到当前页面地址:

请求会携带表单中的值,以及 $ACTION_ID:

接口返回 RSC Payload,用于渲染更新后的数据:

其中,中文在 Chrome 显示乱码了(火狐可以正常查看)。RSC Payload 中包含最新的数据(返回最新的数据是因为我们调用了 revalidatePath):

简而言之:
- Server Actions 背后使用的是 POST 请求方法,请求当前页面地址,根据 $ACTION_ID 区分
- Server Actions 与 Next.js 的缓存和重新验证架构集成。调用 Action 时,Next.js 可以一次性返回更新的 UI 和新数据
使用好处
其次我们说说使用 Server Actions 的好处:
- 代码更简洁。你也不需要手动创建接口,而且 Server Actions 是函数,这意味着它们可以在应用程序的任意位置中复用。
- 当结合 form 使用的时候,支持渐进式增强。也就是说,即使禁用 JavaScript,表单也可以正常提交:
注意要点
最后讲讲使用 Server Actions 的注意要点。
- Server Actions 的参数和返回值都必须是可序列化的,简单的说,JSON.stringfiy 这个值不出错
- Server Actions 会继承使用的页面或者布局的运行时和路由段配置项,包括像 maxDuration 等字段
支持事件
前面也说过:
而在具体使用上,虽然 Server Actions 常与
<form>一起使用,但其实还可以在事件处理程序、useEffect、三方库、其他表单元素(如<button>)中调用。
如果是在事件处理程序中,该怎么使用呢?
我们为刚才的 ToDoList 增加一个 “添加运动” 的按钮。当点击的时候,将运动添加到 TODO 中:

修改
app/form2/page.js,代码如下:
import { findToDos, createToDo } from './actions';
import Button from './button';
export default async function Page() {
const todos = await findToDos();
return (
<>
<form action={createToDo}>
<input type="text" name="todo" />
<button type="submit">Submit</button>
</form>
<Button>添加运动</Button>
<ul>
{todos.map((todo, i) => <li key={i}>{todo}</li>)}
</ul>
</>
)
}
新建
app/form2/button.js,代码如下:
'use client'
import { createToDoDirectly } from './actions';
export default function Button({children}) {
return <button onClick={async () => {
const data = await createToDoDirectly('运动')
alert(JSON.stringify(data))
}}>{children}</button>
}
修改
app/form2/actions.js,添加代码:
export async function createToDoDirectly(value) {
const form = new FormData()
form.append("todo", value);
return createToDo(form)
}
交互效果如下:

这里的 Server Actions 是怎么实现的呢?
其实还是发送了一个 POST 请求到当前地址:

返回的依然是 RSC Payload:

细节
想必大家已经熟悉了 Server Actions 的基本用法,Server Actions 自 Next.js v14 起进入稳定阶段,以后应该会是 Next.js 开发全栈项目时获取数据的主要方式,一定要熟练掌握。
其实使用 Server Actions 还有很多细节,比如如何获取表单提交时的等待状态?服务端如何验证字段?如何进行乐观更新?如何进行错误处理?如何获取 Cookies、Headers 等数据?如何重定向?……


浙公网安备 33010602011771号