每个应用最基础的部分就是路由。
从 13 版本开始,Next.js 推出了基于 React 服务端组件的 App Router 框架。它作用于根目录的 app
目录里,且该目录下的组件默认均为服务端组件(也可为客户端组件)。
在 app
目录下,每个文件夹对应一个 路由片段 ,同时每个路由片段对应一个 URL 路径中的片段。嵌套的文件夹会生成嵌套的路由。
Next.js 约定了一些文件名(可以是 .js
.ts
.tsx
),用来生成对应的 UI 组件,如:
layout
与子路由共享的 UItemplate
会被强制渲染的 UIpage
当前路由对应的页面,会内嵌在layout
里loading
当前路由及其子路由的加载界面,充当 Suspense 组件not-found
当前路由及其子路由的“未找到”界面,充当错误边界error
当前路由及其子路由的错误界面,充当错误边界global-error
全局错误界面route
服务端 API 接口
仅当存在
page
或route
文件时才会生成路由,可以利用这一特性创建一些私有的目录或文件。
对应的页面结构大致如下:
<Layout>
<Template>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<ErrorBoundary fallback={<NotFound />}>
<Page />
</ErrorBoundary>
</Suspense>
</ErrorBoundary>
</Template>
</Layout>
子路由页面会直接内嵌到父路由之下,但 page
只会应用于当前路由。
页面和布局
page
page
只会应用于一个对应的路由,且永远是整个页面组件的叶子节点。
export default function Page() {
return <h1>Hello, Home page!</h1>;
}
layout
layout
可以在多个 page
中共用。在切换导航时,layout
会保持自身状态,不会重新渲染。
// `children` 可以是 page ,或者是内嵌 layout
export default function Layout({ children }) {
return (
<section> {/* 通常放一些共用组件,比如导航栏或者侧边栏 */}
<nav>...</nav>
{children}
</section>
)
}
一个 layout
会在当前路由及其子路由中应用,父布局会直接嵌套子布局。
app
目录下 必须 定义一个 layout
,且称为 根 layout
,通常用来定义初始的 HTML 模版:
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
仅能(且必须)在根目录的
layout
中定义html
和body
标签。
template
template
功能与 layout
类似,不同在于切换路由时,template
组件会重新构建,即状态不会保留,比如这些场景:
- 一些依赖于
useEffect
(如页面日志)或者useState
的功能; - 用来改变原先的框架行为。比如在没有
template
时,Suspense
只会在第一次展示回退界面,切换页面时则不会重新展示;而加了template
则会在每次切换页面前展示回退界面。
export default function Template({ children }) {
return <div>{children}</div>
}
template
会直接内嵌在 layout
之下:
<Layout>
{/* 注意 template 会被赋予一个唯一的 key */}
<Template key={routeParam}>{children}</Template>
</Layout>
修改元数据
可以在 layout
或 page
文件里导出一个 metadata
对象或者 generateMetadata
函数来定义网页的元数据:
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Next.js',
}
export default function Page() {/* ... */}
应当通过上述方式来修改网页的元数据,而不是 在根 layout
中添加 title
或者 meta
等头部标签。
链接及导航
Next.js 中可以通过 Link
组件或 useRouter
实现路由导航。
Link
组件
Link
组件是 Next.js 中主要的导航方式。它从 a
标签上扩展而来,支持客户端的路由导航以及预加载功能。
import Link from 'next/link'
export default function Page() {
return <Link href="/dashboard">Dashboard</Link>
}
可以用
usePathname
获取当前 URL 的路径。
如果想在导航时滚动到指定 id
,可以直接在 URL 链接里加 #
hash 链接(也可直接将 hash 链接作为 href 值):
<Link href="/dashboard#settings">Settings</Link>
在 Next.js 的默认行为中,切到一个新路由时会停留在页面的最顶部,而在导航前进后退操作时会保留原本的页面滚动位置。如果要禁用这一默认行为,可以在 Link
中传递 scroll={false}
,也可在 router.push()
或 router.replace()
中使用 scroll: false
选项:
<Link href="/dashboard" scroll={false}>Dashboard</Link>
import { useRouter } from 'next/navigation'
const router = useRouter()
router.push('/dashboard', { scroll: false })
useRouter
useRouter
可以用来手动切换路由。它只能用在客户端组件中:
'use client'
import { useRouter } from 'next/navigation'
export default function Page() {
const router = useRouter()
return (
<button onClick={() => router.push('/dashboard')}>
Dashboard
</button>
)
}
应当优先选择使用
Link
组件,除非有特定需求再去使用useRouter
。
路由导航的工作原理
App Router 采用了混合的方式去实现路由和导航。对于服务端,应用代码自动地根据路由做了代码拆分。而对于客户端,Next.js 会预加载并缓存路由片段代码,这意味着当切换路由时,浏览器并不会重载页面,只有相应的路由片段会被重新渲染。
在 Next.js 中,有两种方式可以实现路由的预加载:
Link
组件会自动进行路由的预加载:- 对于静态路由,
prefetch
默认为true
,它会对整个路由片段代码进行预加载; - 对于动态路由,
prefetch
默认为自动,只有从共用的layout
到第一个loading
中的Link
是被预加载的,并会缓存 30 秒; - 如果要禁用预加载,可以将
prefetch
设为false
。
- 对于静态路由,
- 通过
router.prefetch()
来手动进行路由预加载。
App Router 的导航还有这些特性:
- 缓存 - 在切换路由时,Next.js 会预加载并尽可能地缓存路由代码,这样可以减少请求次数;
- 部分渲染 - 在切换导航时,只有路由片段会被重新渲染,共用的部分会保留原本状态;
- 软导航 - 默认情况下浏览器会在导航时重新加载页面,这会导致所有 React 状态被重置。而在 Next.js 中,App Router 会采取软导航,即会保留浏览器和 React 的状态,并不会整个页面重新加载;
- 前进后退 - Next.js 会缓存页面的滚动位置,它会在导航前进后退时还原到页面原先的滚动位置。
路由组
在 app
目录下,内嵌的文件夹通常会被映射为 URL 路径片段。但有的时候我们仅想让文件夹起到分类路由的作用,并不想让文件夹成为 URL 路径片段,可以将文件夹名以 (folder)
的形式命名。在 Next.js 中称其为 路由组 Route Group 。
路由组里同样可以有 layout
等文件,它会对组下的所有路由应用。
路由组可能会产生路由解析的歧义。比如 (marketing)/about/page.js
和 (shop)/about/page.js
均代表一个 /about
路由,此时 Next.js 会报错。所以在使用路由组时应当避免这种情形发生。
路由组的使用可能会产生多个根
layout
,当在不同的根layout
间进行导航时会导致整个页面重新加载。
动态路由
有时候需要根据动态数据去创建路由。可以将文件夹名以 [folder]
形式命名,表示它是一个动态路由片段,比如 [id]
或 [slug]
。动态路由片段会作为参数传给 layout
page
route
和 generateMetadata
函数。
比如有一个 app/blog/[slug]/page.tsx
文件:
interface Props { params: { slug: string } }
export default function Page({ params }: Props) {
return <div>My Post: {params.slug}</div>
}
生成静态参数
generateStaticParams
可以和动态路由片段搭配使用,它会在构建时获取参数列表并直接生成静态路由。
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())
return posts.map((post) => ({
slug: post.slug,
}))
}
generateStaticParams
会对其中的 fetch
请求进行缓存,如果别的文件里也在 generateStaticParams
中使用了相同的请求,会直接使用先前缓存的数据。
剩余片段捕获
可以用 [...folder]
的命名表示捕获路由中剩余片段。比如对于 app/shop/[...slug]/page.js
:
/shop/a
生成的参数为{ slug: ['a'] }
/shop/a/b/c
生成的参数为{ slug: ['a', 'b', 'c'] }
或者用 [[...folder]]
表示剩余片段可空。比如对于 app/shop/[[...slug]]/page.js
:
/shop
生成的参数为{}
/shop/a/b
生成的参数为{ slug: ['a', 'b'] }
加载界面及流式渲染
loading.js
文件会搭配 React 的 Suspense
组件生成加载界面。当主体内容还未加载好时,服务端会先提供加载界面。
在 Next.js 中,导航行为有这些特点:
- 导航行为会立即执行;
- 导航行为是可中断的,这意味着切回路由时不必等待当前路由内容完全加载完;
- 共用的
layout
会依然保持可交互的状态。
流式渲染
在用户看到页面前,服务端渲染大致会有以下这些步骤,且步骤间是有序且阻塞的:
- 服务器会先获取页面所需的数据;
- 服务器渲染 HTML 页面;
- 将 HTML/CSS/JS 文件发送给客户端;
- 会先给用户展示不可交互的 HTML/CSS 页面;
- 最终,React 会通过 hydrate 使用户界面可交互。
虽然服务端渲染会先将不可交互的界面展示给用户,但可能会卡在最先的数据请求的步骤上。
而 流式渲染 Streaming 会将页面 HTML 拆分为更小的块并逐个发送给客户端,并且在服务端获取数据前先将部分页面内容发送给客户端。
流式渲染的分块机制是组件级别的,即每个 React 组件都可以作为一个分块。一些不依赖数据或者需要优先展示的组件会先发送给客户端,同时 React 也将更早进行 hydrate 。而其余组件将会等待服务端获取完数据后再发送给客户端。
在有大量数据请求的页面下,流式渲染会显得非常有用,它会减少 首次字节时间 TTFB 和 首屏绘制时间 FCP 。可交互时间 TTI 也有一定改善,尤其在性能较差的设备上。
要使用流式渲染相关特性,可以使用 Suspense
组件,它会有如下优化:
- 流式服务端渲染 - 将 HTML 分块发送给客户端。
- 选择性 hydrate - React 会基于用户交互去抉择先将哪些组件变得可交互。
SEO
TODO: 需要后期理解。
- Next.js 会先等待
generateMetadata
中的请求,然后才会将页面流式传输给客户端,它保证了head
标签会先被传输过去; - 流式渲染属于服务端渲染,并不会影响 SEO 。可以通过谷歌的 移动端友好性测试 来查看页面在谷歌网页爬虫下的显示情况,或者查看序列化后的 HTML 。
- 流式渲染时会返回 200 状态码,当碰到错误时并不能回头修改状态码,但会在 HTML 头部将错误信息告知给 SEO 。
错误处理
在 App Router 中,可以分层地建立 error.js
来设置错误边界。若页面内某个组件触发了错误,会原地向外寻找最近的错误边界,同时错误边界外的内容及其状态并不会受到影响,在恢复错误时也不会直接将整个页面刷新。
'use client' // 错误组件必须是客户端组件
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>
)
}
处理根 layout
的错误
注意,error
会内嵌在同层的 layout
里,因此 layout
的错误并不会触发同层的 error
。如果要给根 layout
设置错误边界,可以建立 global-error
文件。global-error
会包裹整个应用组件,因此跟根 layout
一样,global-error
的回退内容应当包含 html
和 body
标签。
处理服务端错误
如果一个错误是从服务端组件引起的,则会将该错误作为 error
参数传递给最近的 error.js
。
在生产环境中,只会往错误边界传递一个简单的 message
和 digest
信息,不会向用户暴露更多信息。digest
会包含一个自动生成的该错误的哈希编码,它会与服务端日志中的一条错误相匹配。
在开发环境中,会直接传递原错误对象,方便调试。
平行路由
平行路由 Parallel Routing 可让你在同一 layout
中放置多个 page
。可以在 layout
同级目录下建立 @folder
文件夹(也称之为 插槽 slot ),插槽中同样可以有 page
error
等文件。而在 layout
中可以直接使用插槽:
import { getUser } from '@/lib/auth';
export default function Layout(props: {
dashboard: React.ReactNode // 对应 `@dashboard` 内容
login: React.ReactNode // 对应 `@login` 内容
}) {
const isLoggedIn = getUser();
// 根据用户登录状态区分渲染的内容
return isLoggedIn ? dashboard : login;
}
不匹配的路由
TODO:原文档似乎写得有问题,暂时搁置。
插槽下也可以建立子文件,用于匹配子路由。如果 Next.js 未找到对应的子路由页面,则会使用插槽目录下的 default
文件。如果仍未找到 default
文件,则会渲染 404
页面。
在导航切换时,若 Next.js 未在插槽中找到对应的 page
,则保持渲染先前的状态,而不会渲染 default
文件。如果想强制更换渲染的内容,可以用剩余捕获路由,比如 @teams/[...catchAll]/page.js
。
拦截路由
拦截路由可以让你在当面路由页面中加载另一个路由下的内容。可在目标路由下建立这些拦截路由:
(.)folder
在同层路由下设置拦截(..)folder
在上层路由下设置拦截(..)(..)folder
在上上层路由下设置拦截(...)folder
在根路由下设置拦截
如下面这个例子中,如果用户是通过 /feed
访问了 /photo
,Next.js 则会渲染 (..)photo
中的内容。如果用户是直接访问了 /photo
则直接渲染 photo
中的内容:
+ feed
- page.js
+ (..)photo
- page.js
+ photo
- page.js
- page.js
接口路由
可以通过 router.js
文件来创建 Route Handler :
export const dynamic = 'force-dynamic' // 默认为 "force-static"
export async function GET(request: Request) {}
支持定义 GET
POST
PUT
PATCH
DELETE
HEAD
OPTIONS
方法。如果使用了 Next.js 未支持的 HTTP 方法,会返回 405 Method Not Allowed
响应。
Next.js 扩展了原生的 Request
和 Response
对象为 NextRequest
和 NextResponse
,提供了许多有用的方法。
缓存
当使用 GET 方法时,可以通过 Response
对象来告知 Route Handler 进行缓存:
export async function GET() {
const res = await fetch('https://data.mongodb-api.com/...', {
headers: {
'Content-Type': 'application/json',
'API-Key': process.env.DATA_API_KEY,
},
})
const data = await res.json()
return Response.json({ data })
}
Response.json()
只能在高于 5.2 版本的 TypeScript 中使用,或者可以使用NextResponse.json()
。
若不想使用缓存机制,可以通过这几种方式:
路由解析
同层目录下 route
不能与 page
共存。比如 app/page.js
不能与 app/route.js
共存。
app/[user]/page.js
与app/api/route.js
是可以共存。
重新验证缓存数据
可以在 fetch
请求中通过 next.revalidate
选项来设置重新验证时间间隔:
export async function GET() {
const res = await fetch('https://data.mongodb-api.com/...', {
next: { revalidate: 60 }, // Revalidate every 60 seconds
})
// ...
}
或者利用路由片段配置,直接在文件中导出 revalidate
配置:
export const revalidate = 60
动态函数
cookie
可以在 Route Handler 中通过 cookies()
访问 cookie ,它会返回一个只读的实例。如果要设置 cookie ,可以在 Response
中设置 Set-Cookie 标头:
import { cookies } from 'next/headers'
export async function GET(request: Request) {
const cookieStore = cookies()
const token = cookieStore.get('token')
return new Response('Hello', {
status: 200,
headers: { 'Set-Cookie': `token=${token.value}` },
})
}
或者通过标明 NextRequest
类型来直接访问 cookie :
import { type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const token = request.cookies.get('token')
}
标头
可以在 Route Handler 中通过 headers()
直接获取请求标头。同样返回只读的实例:
import { headers } from 'next/headers'
export async function GET(request: Request) {
const headersList = headers()
const referer = headersList.get('referer')
return new Response('Hello, Next.js!', {
status: 200,
headers: { referer: referer },
})
}
或者通过标明 NextRequest
类型来直接访问 headers :
import { type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const requestHeaders = new Headers(request.headers)
}
重定向
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
redirect('https://nextjs.org')
}
动态路由片段
同 layout
等一致,Route Handler 会读取路由中的动态数据。比如对于 app/[slug]/route.js
:
export async function GET(request: Request, { params }) {
const slug = params.slug // URL 中的 slug 片段
}
URL 查询参数
import { type NextRequest } from 'next/server'
export function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('query')
// 如对于 "/api/search?query=hello" 来说 query 是 "hello"
}
流式传输
流式传输通常在一些大语言模型中使用,比如 OpenAI 。具体可见原文档介绍:Streaming 。
请求体及表单数据
可通过 request.json()
request.formData()
获取请求体或表单数据:
export async function POST(request: Request) {
const formData = await request.formData()
const name = formData.get('name')
const email = formData.get('email')
return Response.json({ name, email })
}
跨域设置
可以直接在 Response
选项参数里设置 CORS 标头:
export const dynamic = 'force-dynamic' // defaults to force-static
export async function GET(request: 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',
},
})
}
路由片段配置
和 layout
page
一样,Route Handler 也会使用 路由片段配置 :
export const dynamic = 'auto'
export const dynamicParams = true
export const revalidate = false
export const fetchCache = 'auto'
export const runtime = 'nodejs'
export const preferredRegion = 'auto'
中间件
中间件可以让你在请求完成前执行一些代码,比如重写、重定向、修改请求或者响应。中间件会在路由(及缓存内容)匹配之前运行。
可以在项目根目录创建 middleware.js
文件来定义中间件:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// 也可以导出一个异步 async 函数
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url))
}
路径匹配
项目中的每一个路由都会调用中间件。一下是具体的执行顺序:
- 依次执行
next.config.js
的headers
和redirects
。 - 中间件 。
next.config.js
的beforeFiles
重写。- 路由文件,如
public/
app/
pages/
_next/static
。 next.config.js
的afterFiles
重写。- 动态路由(如
/blog/[slug]
)。 next.config.js
的fallback
重写。
在 middleware.js
中可以设置允许中间件执行的路径的匹配规则:
export const config = {
matcher: '/about/:path*', // 也可以是个数组,用来设置多条匹配规则
}
matcher
配置规则(详细见 Path to regexp):
- 必须 以斜杠
/
开头。 - 可以包含命名参数,如
/about/:path
。命名参数后可加修饰符*
?
+
,功能与正则中的类似,比如/about/:path*
可以匹配/about/a/b/c
,因为*
代表匹配 0 个及以上。 - 可以设置圆括号来使用正则,如
/about/(.*)
与/about/:path*
效果一致。
为了向后兼容,Next.js 会把
/public
视为/public/index
,此时/public/:path
会匹配上。
条件语句
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
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))
}
}
使用 cookie
通过 NextRequest
和 NextRequest
可以设置 cookie :
export function middleware(request: NextRequest) {
// 假设一个带有 "Cookie:nextjs=fast" 标头的请求
const allCookies = request.cookies.getAll()
console.log(allCookies) // => [{ name: 'nextjs', value: 'fast' }]
let cookie = request.cookies.get('nextjs')
console.log(cookie) // => { name: 'nextjs', value: 'fast', Path: '/' }
request.cookies.has('nextjs') // => true
request.cookies.delete('nextjs')
request.cookies.has('nextjs') // => false
// 设置响应中的 Cookie 标头
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: '/' }
// 响应中会带上 `Set-Cookie:vercel=fast;path=/test` 标头
return response
}
设置标头
通过 NextResponse
可以设置请求和响应标头:
export function middleware(request: NextRequest) {
// 克隆原请求头,并设置新标头 `x-hello-from-middleware1`
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
}
直接响应请求
可以通过 Response
或 NextResponse
直接在中间件中进行请求响应:
import { NextRequest } from 'next/server'
import { isAuthenticated } from '@lib/auth'
export function middleware(request: NextRequest) {
if (!isAuthenticated(request)) {
return Response.json(
{ success: false, message: 'authentication failed' },
{ status: 401 }
)
}
}
高级中间件设置
在 v13.1 中 Next.js 引入了 skipMiddlewareUrlNormalize
和 skipTrailingSlashRedirect
设置。
skipTrailingSlashRedirect
可以禁止让 Next.js 自行处理 URL 的尾斜杠 :
// next.config.js
module.exports = {
skipTrailingSlashRedirect: true,
}
const legacyPrefixes = ['/docs', '/blog']
export default async function middleware(req) {
const { pathname } = req.nextUrl
// 仅对一些路由不做尾斜杠处理
if (legacyPrefixes.some((prefix) => pathname.startsWith(prefix))) {
return NextResponse.next()
}
// 尾斜杠处理
if (!pathname.endsWith('/')) {
req.nextUrl.pathname += '/'
return NextResponse.redirect(req.nextUrl)
}
}
skipMiddlewareUrlNormalize
可以禁止让 Next.js 对 URL 做规范化处理。
项目组织
在 app
目录下,Next.js 只会将含有 page.js
或 route.js
的文件夹视为路由,且仅关心符合文件命名约定的文件。所以可以放心地在 app
目录下放置其它代码文件,或者对代码文件进行分类。
私有文件夹
可以通过 _folder
命名方式声明一个私有文件夹。私有文件夹及其子文件夹均不会被视为路由。
如果你确实想创建一个
_
开头的路由,可以用%5F
代替,即以%5Ffolder
命名。
组织方式
在 Next.js 项目中没有正确或错误的源代码组织方式,但以下是常见三种:
- 在
app
目录外部组织你的源代码; - 在
app
目录顶层组织你的源代码; - 根据特性或者路由来拆分组织源代码。
国际化
TODO: 用不到