【Next.js】002-路由篇|App Router
文章目录
- 【Next.js】002-路由篇|App Router
- 一、前言
- 二、文件系统(file-system)
- 1、说明
- 2、演练
- 创建代码
- 运行访问
- 让 Cursor 分析错误
- 别偷懒,还是探究一下 Pages Router 方式吧
- 创建代码
- 运行并访问项目
- /about 路径
- 三、从 Pages Router 到 App Router
- 四、使用 App Router
- 1、定义路由
- 2、定义页面(Pages)
- 说明
- 演练
- 代码修改
- 运行访问
- 3、定义布局(Layouts)
- 说明
- 演练
- 代码修改
- 运行访问
- 嵌套布局
- 代码修改
- 运行访问
- 让 Cursor 分析错误
- 修复后
- 根布局(Root Layout)
- 4、定义模板(Templates)
- 说明
- 演练
- 代码修改
- 运行访问
- 5、布局 VS 模板
- 状态保持
- 渲染行为
- 使用场景
- 代码结构
- 6、定义加载界面(Loading UI)
- 说明
- 演练
- 代码修改
- 运行访问
- 基本原理
- 代码修改
- 访问测试
- 7、定义错误处理(Error Handling)
- 说明
- 演练
- 代码修改
- 运行访问
- 8、定义 404 页面
- 说明
- 运行访问
- 五、参考链接
一、前言
路由(Router)是 Next.js 应用的重要组成部分。在 Next.js 中,路由决定了一个页面如何渲染或者一个请求该如何返回。
Next.js 有两套路由解决方案,之前的方案称之为“Pages Router”,目前的方案称之为“App Router”,两套方案目前是兼容的,都可以在 Next.js 中使用。
从 v13.4 起,App Router 已成为默认的路由方案,新的 Next.js 项目建议使用 App Router。
本篇我们会学习 App Router 下路由的定义方式和常见的文件约定。
二、文件系统(file-system)
1、说明
Next.js 的路由基于的是文件系统,也就是说,一个文件就可以是一个路由。举个例子,你在 pages
目录下创建一个 index.js
文件,它会直接映射到 /
路由地址:
// pages/index.js
import React from 'react'
export default () => <h1>Hello world</h1>
在 pages
目录下创建一个 about.js
文件,它会直接映射到 /about
路由地址:
// pages/about.js
import React from 'react'
export default () => <h1>About us</h1>
2、演练
创建代码
运行访问
让 Cursor 分析错误
回答:这个错误的原因是:你的项目中同时使用了 Next.js 的 App Router(新路由系统)和 Pages Router(传统路由系统)来处理根路径"/",这会导致路由冲突。
Next.js 13+ 版本引入了 App Router,但它不能与 Pages Router 混用于相同的路径。我们需要选择其中一种路由系统。
上面是 Pages Router 的方式,我们现在使用新的 App Router 方式,暂时不再对 Pages Router 方式深究。
别偷懒,还是探究一下 Pages Router 方式吧
创建代码
运行并访问项目
/about 路径
三、从 Pages Router 到 App Router
现在你打开使用 create-next-app
创建的项目,你会发现默认并没有 pages
这个目录。查看 packages.json
中的 Next.js 版本,如果版本号大于 13.4
,那就对了!
Next.js 从 v13 起就使用了新的路由模式 —— App Router。之前的路由模式我们称之为“Pages Router”,为保持渐进式更新,依然存在。从 v13.4 起,App Router 正式进入稳定化阶段,App Router 功能更强、性能更好、代码组织更灵活,以后就让我们使用新的路由模式吧!
可是这俩到底有啥区别呢?Next.js 又为什么升级到 App Router 呢?知其然知其所以然,让我们简单追溯一下。以前我们声明一个路由,只用在 pages
目录下创建一个文件就可以了,以前的目录结构类似于:
└── pages
├── index.js
├── about.js
└── more.js
这种方式有一个弊端,那就是 pages
目录的所有 js 文件都会被当成路由文件,这就导致比如组件不能写在 pages
目录下,这就不符合开发者的使用习惯。(当然 Pages Router 还有很多其他的问题,只不过目前我们介绍的内容还太少,为了不增加大家的理解成本,就不多说了)
升级为新的 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)等。
简单的来说,App Router 制定了更加完善的规范,使代码更好被组织和管理。至于这些文件具体的功能和介绍,不要着急,本篇我们会慢慢展开。
四、使用 App Router
1、定义路由
文件夹被用来定义路由。 每个文件夹都代表一个对应到 URL 片段的路由片段。**创建嵌套的路由,只需要创建嵌套的文件夹。**举个例子,下图的 app/dashboard/settings
目录对应的路由地址就是 /dashboard/settings
:
2、定义页面(Pages)
说明
那如何保证这个路由可以被访问呢?你需要创建一个特殊的名为 page.js
的文件。至于为什么叫 page.js
呢?除了 page
有“页面”这个含义之外,你可以理解为这是一种约定或者规范。(如果你是 Next.js 的开发者,你也可以约定为 index.js
甚至 yayu.js
!)
在上图这个例子中:
app/page.js
对应路由/
app/dashboard/page.js
对应路由/dashboard
app/dashboard/settings/page.js
对应路由/dashboard/settings
analytics
目录下因为没有page.js
文件,所以没有对应的路由。这个文件可以被用于存放组件、样式表、图片或者其他文件。
当然不止 .js
文件,Next.js 默认是支持 React、TypeScript 的,所以 .js
、.jsx
、.tsx
都是可以的。
那 page.js
的代码该如何写呢?最常见的是展示 UI,比如:
// app/page.js
export default function Page() {
return <h1>Hello, Next.js!</h1>
}
演练
代码修改
运行访问
3、定义布局(Layouts)
说明
布局是指多个页面共享的 UI。在导航的时候,布局会保留状态、保持可交互性并且不会重新渲染(解决性能啊),比如用来实现后台管理系统的侧边导航栏。
定义一个布局,你需要新建一个名为 layout.js
的文件,该文件默认导出一个 React 组件,该组件应接收一个 children
prop,chidren
表示子布局(如果有的话)或者子页面。
举个例子,我们新建目录和文件如下图所示:
相关代码如下:
// app/dashboard/layout.js
export default function DashboardLayout({
children,
}) {
return (
<section>
<nav>nav</nav>
{children}
</section>
)
}
演练
代码修改
运行访问
嵌套布局
代码修改
运行访问
让 Cursor 分析错误
# 分析结果
布局文件重复问题
你在 app/about/layout.js 和 app/layout.js 中都定义了 RootLayout,这会导致布局嵌套和冲突。在 Next.js 中:
app/layout.js 是根布局,应用于所有页面
app/about/layout.js 应该只定义 about 路径下特有的布局
# 说明
app/layout.js 是根布局,应用于所有页面,使用 RootLayout;
app/about/layout.js 应该只定义 about 路径下特有的布局,使用 AboutLayout 。
修复后
注意:下面的写法有一处错误,应遵循:根布局必须包含
html
和body
标签,其他布局不能包含这些标签。后面学到模板我才发现这里写错了!
根布局(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 篇》。 - 你可以使用路由组创建多个根布局。
- 默认根布局是服务端组件,且不能设置为客户端组件。
4、定义模板(Templates)
说明
模板类似于布局,它也会传入每个子布局或者页面。但不会像布局那样维持状态。
布局每次不会重新渲染,这样是节省性能的,模板不会维持状态,应该是每次都重新渲染。
模板在路由切换时会为每一个 children 创建一个实例。这就意味着当用户在共享一个模板的路由间跳转的时候,将会重新挂载组件实例,重新创建 DOM 元素,不保留状态。这听起来有点抽象,没有关系,我们先看看模板的写法,再写个 demo 你就明白了。
定义一个模板,你需要新建一个名为 template.js
的文件,该文件默认导出一个 React 组件,该组件接收一个 children
prop。我们写个示例代码。
在 app
目录下新建一个 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)到底有啥用?》,对这两种情况都做了举例说明
演练
代码修改
运行访问
5、布局 VS 模板
布局和模板都是用于在多个页面之间共享 UI 的机制,但它们有一些重要的区别:
状态保持
-
布局 (Layout):
- 在路由切换时会保持状态
- 组件实例会被复用
- 不会重新创建 DOM 元素
- 适合需要保持状态的场景,如导航栏、侧边栏等
-
模板 (Template):
- 在路由切换时不会保持状态
- 每次都会创建新的组件实例
- 会重新创建 DOM 元素
- 适合需要重置状态的场景,如表单、计数器等
渲染行为
-
布局:
- 只在首次加载时渲染一次
- 路由切换时不会重新渲染
- 更节省性能
-
模板:
- 每次路由切换都会重新渲染
- 为每个子路由创建新实例
- 性能开销相对较大
使用场景
-
布局适合:
- 网站的通用结构(导航栏、页脚等)
- 需要保持状态的 UI 组件
- 对性能要求较高的场景
-
模板适合:
- 依赖 useEffect 和 useState 的功能
- 需要在路由切换时重置的功能
- 需要修改框架默认行为的场景(如 Suspense)
代码结构
-
布局:
- 使用 layout.js 文件
- 可以嵌套使用
- 会自动复用已渲染的组件
-
模板:
- 使用 template.js 文件
- 也可以嵌套使用
- 每次都会创建新的组件树
选择使用布局还是模板,主要取决于你的具体需求。如果需要保持状态和提高性能,选择布局;如果需要重置状态和独立实例,选择模板。
6、定义加载界面(Loading UI)
说明
现在我们已经了解了 page.js
、layout.js
、template.js
的功能,然而特殊文件还不止这些。App Router 提供了用于展示加载界面的 loading.js
。
这个功能的实现借助了 React 的Suspense
API。关于 Suspense 的用法,可以查看 《React 之 Suspense》。它实现的效果就是当发生路由变化的时候,立刻展示 fallback UI,等加载完成后,展示数据。
// 在 ProfilePage 组件处于加载阶段时显示 Spinner
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>
初次接触 Suspense 这个概念时,很多人会有一个疑问 - “fallback UI 是如何自动关闭的呢?”
虽然 React 官方文档对此没有详细说明,但其实背后的实现原理并不复杂。当组件(如 ProfilePage)在加载数据时,会抛出一个 Promise 对象。Suspense 会捕获这个 Promise,并为其添加一个 then 回调函数。这个回调函数负责将 fallback UI 替换为实际内容。当数据加载完成后,Promise 变为 resolved 状态,then 回调函数执行,从而自动完成 UI 的切换。
理解了这个原理后,让我们来看看如何在项目中使用 loading.js
。首先在 dashboard
目录下创建一个 loading.js
文件:
// app/about/loading.js
export default function AboutLoading() {
return <div>Loading about...</div>;
}
演练
代码修改
运行访问
加载中
加载完成
基本原理
就是这么简单。其关键在于 page.js
导出了一个 async 函数。
loading.js
的实现原理是将 page.js
和下面的 children 用 <Suspense>
包裹。因为page.js
导出一个 async 函数,Suspense 得以捕获数据加载的 promise,借此实现了 loading 组件的关闭。
当然实现 loading 效果,不一定非导出一个 async 函数。也可以借助 React 的 use 函数。
代码修改
顺便去掉了:React.Profiler
// app/about/page.js
'use client';
import React, { use } from "react";
// 模拟获取数据的异步函数
async function getData() {
// 延迟3秒模拟网络请求
await new Promise((resolve) => setTimeout(resolve, 3000));
return {
message: '这是About页面!',
author: '訾博',
time: '2024年12月28日 12点01分'
};
}
// 使用 use 函数来处理异步数据
export default function Page() {
// use 函数会自动处理 Promise,无需使用 await
const data = use(getData());
return (
<div>
<h1>{data.message}</h1>
<h1>{data.author} {data.time}</h1>
</div>
);
}
访问测试
上面是针对 /about
单独实现一个 loading 效果,如果想实现全局的,那就在 app
目录下再写一个 loading.js
即可(不再演示)。
如果同一文件夹既有 layout.js
又有 template.js
又有 loading.js
,那它们的层级关系是怎样呢?
对于这些特殊文件的层级问题,直接一张图搞定:
7、定义错误处理(Error Handling)
说明
文件 error.js
。顾名思义,用来创建发生错误时的展示 UI。
其实现借助了 React 的 Error Boundary 功能。简单来说,就是给 page.js 和 children 包了一层 ErrorBoundary
。
代码示例
前面的代码嵌套太多,太复杂,现对代码进行了简化!
'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>}</>
);
}
让我们回顾一下层级问题:
从这张图里你会发现一个问题:因为 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
建议还是要写的。
演练
代码修改
运行访问
8、定义 404 页面
说明
最后再讲一个特殊文件 —— not-found.js
。顾名思义,当该路由不存在的时候展示的内容。
Next.js 项目默认的 not-found 效果如下:
如果你要替换这个效果,只需要在 app
目录下新建一个 not-found.js
,代码示例如下:
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h2>页面未找到</h2>
<p>无法找到您请求的资源</p>
<Link href="/">返回首页</Link>
</div>
);
}
运行访问
五、参考链接
- Routers - MDN Web Docs Glossary: Definitions of Web-related terms | MDN
- Building Your Application: Routing
- Routing: Defining Routes
- Routing: Pages and Layouts
- Routing: Loading UI and Streaming
- Routing: Error Handling
- File Conventions: not-found.js
- Functions: notFound