逐步从 Pages 路由迁移到 App 路由
App 路由是 NextJS 13 引入的新特性。
它的功能相较于目前使用的 Pages 路由更加丰富,并且支持更多的特性。
例如它支持布局,服务器组件等特性。
具体可以查看官方的文档。
App 路由是可以和 Pages 路由共存的。
这就意味着我们可以逐步进行迁移,逐步替换。
NextJS 会优先使用 App 路由。
在 App 路由中,使用 app
目录而不是 pages
目录。
且不支持 pages/_app.js
和 pages/_document.js
。取而代之的是 app/layout.jsx
。
App 路由文件约定
文件名 | 作用 | 扩展名 |
---|---|---|
layout | 页面布局(不重新渲染) | .js , .jsx , .tsx |
page | 页面文件 | .js , .jsx , .tsx |
loading | 加载 UI | .js , .jsx , .tsx |
not-found | 未找到 UI | .js , .jsx , .tsx |
error | 错误 UI | .js , .jsx , .tsx |
global-error | 全局错误 UI | .js , .jsx , .tsx |
route | API 终端节点 | .js , .ts |
template | 会重新渲染的布局 | .js , .jsx , .tsx |
default | 默认页面 | .js , .jsx , .tsx |
路由段差异
App 路由的文件约定与 Pages 路由不同。
Pages 路由的路由段可以使用文件名和文件夹名。
pages/index.jsx
对应/
pages/about.jsx
对应/about
pages/user/[id].jsx
对应/user/1
而 App 路由定义路由段只能使用文件夹名,而不允许使用文件名。
app/page.jsx
对应/
app/about/page.jsx
对应/about
app/user/[id]/page.jsx
对应/user/1
路由分组
使用 ()
包裹文件夹,可以将子目录视为一个路由组。这个文件夹将不参与路由段。
app/user/[id]/page.jsx
对应/user/1
app/user/[id]/(profile)/page.jsx
对应/user/1/profile
退出路由
为文件夹增加 _
前缀,即可将该文件夹和它的所有子目录退出路由段。
app/_user/[id]/page.jsx
不渲染app/_user/[id]/(profile)/page.jsx
不渲染
并行路由
允许在同一布局中渲染多个页面。形成类似仪表盘的形式。
|- app
|- @menus
|- default.jsx
|- page.jsx
|- layout.jsx
|- page.jsx
default.jsx
将作为默认显示内容
// app/layout.jsx
export default function RootLayout({ children, menus }) {
return (
<main>
<aside>{menus}</aside>
<main>
{children}
</main>
</main>
)
}
拦截路由
允许拦截路由跳转,但当页面刷新时,则不会触发。
可查看官方文档。
可以用在用户登录,权限验证等场景。在当前页打开模态框显示登录表单(此时路由已经变化为登录页面)。刷新后,会渲染登录页面。
不支持静态导出!
引入布局
在项目中,对于重复布局将进行重复的引入,传值。将会繁琐且易错。
通过使用布局,可以避免这一问题。此外,布局之间还可以进行嵌套。
在根布局,可以编写共同的页眉和页脚。在商品页,可以编写商品分类,描述等共同组件。
|- Root Layout 根布局 编写通用的页眉页脚
|- Product Layout 商品页布局 编写商品页通用的分类,描述
|- Product Content 商品页 展示商品明细
// app/layout.jsx
import Header from '@/components/Header'
import Footer from '@/components/Footer'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}
// app/product/layout.jsx
import ProductCategory from '@/components/ProductCategory'
import ProductDescription from '@/components/ProductDescription'
export default function ProductLayout({ children }) {
return (
<div>
<ProductCategory />
{children}
<ProductDescription />
</div>
)
}
// app/product/page.jsx
export default function ProductPage() {
return (
<div>
商品明细
</div>
)
}
布局在页面中只会渲染一次,如果需要每次加载都重新渲染,则使用 template
文件。
// app/template.jsx
export default function RootTemplate({ children }){
// todo...
return (
<div>
{children}
</div>
)
}
修改元数据
原先的 Pages 路由,元数据都是通过 next/head
编写在组件中的。
App 路由则是将元数据单独拆分出来了。
// index.jsx
export const metadata = {
title: '首页标题',
description: '首页描述'
}
export default function HomePage(){
return (
<div>
首页
</div>
)
}
这样子,我们只需要在顶层处理元数据,而不需要在页面内额外处理。使得我们可以专注于页面内容的开发。
动态元数据
有时,静态元数据不满足业务需求,需要动态变化。可以使用 generateMetadata
API。
export async function generateMetadata() {
return requestHomeMetadata()
.then(response => response.json())
.then(raw => {
// todo ...
return raw
})
.then(data => {
return {
title: data.title,
description: data.description
}
})
}
export default function HomePage(){
return (
<div>
首页
</div>
)
}
更加丰富的元数据
除了基本的元数据,App 路由还支持更多的元数据。
export const metadata = {
title: '首页标题',
description: '首页描述',
keywords: '首页关键词',
openGraph: {
title: 'OG 标题',
description: 'OG 描述',
url: 'https://example.com/page',
images: [{ url: '/og.png' }],
},
twitter: {
title: 'Twitter 标题',
description: 'Twitter 描述',
images: [{ url: '/twitter.png' }],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
}
},
manifest: '/manifest.json',
alternates: {
canonical: '/',
languages: {
'zh-CN': '/zh-cn',
},
},
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
},
// ...
}
export default function HomePage(){
return (
<div>
首页
</div>
)
}
useRouter
与 Pages 路由不同,App 路由虽然也使用 useRouter
,但是是从 next/navigation
中导入的,而不是 Pages 路由的 next/router
。
原先的 Pages 路由的 useRouter
提供的 query
、pathname
等属性在 App 路由中不存在,取而代之的是需要从 next/navigation
导入的 useSearchParams
,usePathname
等 API。
且需要注意,App 路由和 Pages 路由的 useRouter
是不兼容的。
官方文档中也有说明。
因为是逐步迁移,组件也将复用。所以要手动封装一下,用来处理两者之间的不同。
'use client'
import { useParams, usePathname, useRouter as useRawAppRouter, useSearchParams } from 'next/navigation'
import { useRouter as useRawPagesRouter } from 'next/router'
import { useEffect, useMemo, useRef } from 'react'
const useAppRouter = () => {
// App Router 模拟 router 对象结构
const router = useRawAppRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const params = useParams()
const query = useMemo(
() => ({
...Object.fromEntries(searchParams.entries()),
...params,
}),
[searchParams, params],
)
const searchString = searchParams.toString()
const asPath = searchString ? `${pathname}?${searchString}` : pathname
const events = useRef({})
return {
push: router.push,
replace: router.replace,
forward: router.forward,
back: router.back,
prefetch: router.prefetch,
refresh: router.refresh,
asPath,
pathname,
query,
params,
}
}
export const useRouter = () => {
// 判断是否处于 Pages Router 环境(__NEXT_DATA__ 是 Pages 特有的)
const isPagesRouter = typeof window !== 'undefined' && '__NEXT_DATA__' in window
const useRouter = isPagesRouter ? useRawPagesRouter : useAppRouter
const router = useRouter()
return router
}
export default useRouter
用于静态导出的动态路由
在 App 路由中,没有 getStaticPaths
,取而代之的是 generateStaticParams
。
如果需要 getStaticProps
,则在页面组件中直接 fetch
即可。
页面必须是服务端组件才能够直接
fetch
// user/[id]/page.jsx
export async function generateStaticParams(){
return [
{ id: '1' },
{ id: '2' },
{ id: '3' }
]
}
export default async function HomePage({ params }){
const info = await requestUserInfo(params.id)
return (
<div>
<p>当前用户 ID {params.id}</p>
<p>昵称 {info.name}</p>
</div>
)
}
因为 NextJS 路径时字符串匹配的
所以 generateStaticParams 应该返回的是
Record<string, string>
类型的数据
以避免路径匹配出错
除了页面外,还可以在布局中使用 generateStaticParams
。页面会自动获取到 generateStaticParams
的返回值。
// user/[id]/layout.jsx
export async function generateStaticParams(){
return [
{ id: '1' },
{ id: '2' },
{ id: '3' }
]
}
export default function UserLayout({ children, params }){
return (
<div>
<p>当前用户 ID {params.id}</p>
{children}
</div>
)
}
以上将最终渲染:
- user/1
- user/2
- user/3
这三个路由。
使用客户端组件
App 路由默认所有组件都是服务端组件。不允许使用例如 useEffect
, useState
等客户端 API。
如果需要使用,则将交互部分抽离为单独组件,并在文件顶部定义 "use client"
即可将组件声明为客户端组件。
只需要在最顶层的客户端组件标注即可
客户端组件使用的其他组件都将被视为客户端组件
// goto.jsx
'use client'
const GotoDiscord = () => {
emitGAClickCod6DiscordButton()
window.open('https://discord.gg/xxx', '_blank')
}
export default function Goto() {
return (
<div
onClick={GotoDiscord}
className='reviewsButterh mx-auto mb-[20px] mt-[26px] flex h-[50px] w-[188px] items-center justify-center rounded-[5px] bg-[linear-gradient(270deg,_#328EFF_0%,_#5E34E7_100%)] text-center font-[600] Fsm:mx-auto Fsm:mb-[45px] Fsm:mt-[23px] Fsm:h-[42px]'
>
Save 8% off Now
</div>
)
}
错误显示和加载页面
App 路由提供了以 error
和 loading
命名的文件,来支持自定义页面加载和错误显示。
在页面初次加载或请求失败时,会自动渲染,而无需手动处理。
// /user/error.jsx
"use client"
export default function Error({ error, reset }) {
return (
<div>
<h2>加载用户信息出错!</h2>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</div>
)
}
// /user/loading.jsx
export default function Loading() {
return <p>加载用户信息中...</p>
}
最佳实践
- 不要滥用
"use client"
,将组件职责划分清清楚。仅在必要时使用客户端组件。 - 合理使用
loading
/error
/template
提高用户体验。 - 服务端组件只负责请求数据和结构输出,不负责交互。
- 需要交互的部分才抽离为客户端组件,避免客户端负载过重。