APP Router
以前的路由规则是所谓的Pages Router:
举个例子:
你在 pages 目录下创建一个 index.js 文件,它会直接映射到 / 路由地址。
这样就导致整体路由大概是这样的:
└── pages
├── index.js
├── about.js
└── more.js
但是实际上这种做法在项目操作中有很多不利于灵活性开发的问题。本篇文档重点介绍的是,从Version13.4开始实行的心得路由规则:APP Router
升级了之后的Router文件目录大概是这样的:
src/
└── app
├── page.js
├── layout.js
├── template.js
├── loading.js
├── error.js
└── not-found.js
├── about
│ └── page.js
└── more
└── page.js
这里面多了一些特殊的文件,都是NextJS官方设定的有特殊用处的文件,比如说:
- 布局(layout.js)
- 模板(template.js)
- 加载状态(loading.js)
- 错误处理(error.js)
- 404(not-found.js)
简单来说,APP Router的规则制定的更加完善,更好管理代码和组织,接下来我们来一步步分析新的路由规则。
定义路由(Routes)
文件夹被用来定义路由,每个文件夹都代表了一个对应到URL路由的路由片段,创建嵌套的路由,只需要创建嵌套的文件夹就行了。比如下面的图:
app/dashboard/settings
对应的路由就是/dashboard/settings
定义页面(Pages)
那么我要如何保证这个路由能够被访问到呢?
我们需要创建一个特殊的名为page.js
的文件在对应的文件夹下面。
在上面这个图的例子中:
- app/page.js 对应路由 /
- app/dashboard/page.js 对应路由 /dashboard
- app/dashboard/settings/page.js 对应路由/dashboard/settings
- analytics 目录下因为没有 page.js 文件,所以没有对应的路由。这个文件可以被用于存放组件、样式表、图片或者其他文件。
当然,也不止.js
文件,.jsx
、.tsx
都是可以的
值得一提的是,虽然每个文件夹下的page.js都叫这个名字,但是你写的时候,可以更加具象化一点,比如,在dashboard目录下的page可以叫做:
const DashBoardPage: React.FC = () => {
return (
<div>
<h1>这是仪表盘页面</h1>
</div>
);
};
export default DashBoardPage;
只要导出了就没问题。
定义布局(layouts)
布局指的是多个页面共享的UI。在导航的时候,布局会保留状态、保持可交互性并且不会重新渲染,比如老生常谈的,后台管理系统的侧边栏导航。
定义一个布局,你需要一个名字叫做layout.js
的文件,这个文件会默认导出一个React的组件,该组件应该接受一个children
Prop,children
一般代表的是子布局(如果有的话)或者子页面。
比如说我现在的文件结构是这样的:
注意看,目录是app/dashboard/settings/page.tsx
的文件的同级有一个layout,外部文件夹dashboard又有一个layout,而在app目录下还有一个layout。
如我上面所说的,布局文件接受的children要么是子布局,要么是子页面。
总结一下:
- 同一文件夹下如果有 layout.js 和 page.js,page 会作为 children 参数传入 layout。换句话说,layout 会包裹同层级的 page。
- 布局是支持嵌套的,
app/dashboard/settings/page.js
会使用app/layout.js
和app/dashboard/layout.js
以及app/dashboard/settings/layout.js
三个布局中的内容。
根布局(root layout)
上面说到,布局是支持嵌套的,最顶层的布局我们把它叫做根布局,也就是app/layout.js
,它会应用到所有的路由,除此之外,这个布局还有一些特殊。
我们通过create-next-app
创建默认的layout.js
的代码是这样的:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
- app 目录必须包含根布局,也就是 app/layout.js 这个文件是必需的。
- 根布局必须包含 html 和 body标签,其他布局不能包含这些标签。如果你要更改这些标签,不推荐直接修改
- 你可以使用路由组创建多个根布局,路由组后面的文档会提到
- 默认根布局是服务端组件,且不能设置为客户端组件。
定义模板(templates)
模板类似于布局,它也会传入每个子布局或者页面。
但不会像布局那样维持状态。(这一点一定要切记,切记,切记!)
模板在路由切换时会为每一个 children 创建一个实例。这就意味着当用户在共享一个模板的路由间跳转的时候,将会重新挂载组件实例,重新创建 DOM 元素,不保留状态。
如果一个目录下又有layout,又有template,那么就会是这样的情况:
layout 会包裹 template,template 又会包裹 page。
某些情况下,模板会比布局更适合:
- 依赖于 useEffect 和 useState 的功能,比如记录页面访问数(维持状态就不会在路由切换时记录访问数了)、用户反馈表单(每次重新填写)等
- 更改框架的默认行为,举个例子,布局内的 Suspense 只会在布局加载的时候展示一次 fallback UI,当切换页面的时候不会展示。但是使用模板,fallback 会在每次路由切换的时候展示
我们来挨着解释一下这个原因:
首先我们来罗列一下layouts和templates的一些关键点
Layouts的状态
- 用于封装页面组件的布局结构,它们在页面间切换时,会保持挂载状态。这就意味着,如果你在一个布局组件中使用状态或者效果(useState或者useEffect),这些状态或者效果会在页面切换的时候被保留下来
- 布局组件是用来定义那些在多个页面共享的UI部分,比如导航栏,侧边栏,页脚等等
- 可以用于持久化那些不依赖于单个页面的状态,比如用户认证状态、主题选择等等
Templates的状态
- 提供了页面的结构模板,但是在页面进行切换的时候,模板本身会重新挂载。这就意味着,与布局不同,模板内部的状态不会在新页面加载的时候保持
- Template用于定义特定页面或者页面组的布局和结构,这些通常不会持久化状态,因为每次进入新页面的时候,模板都会重新加载,因此所有的局部状态都会重置掉
这样的设计就可以允许开发者在需要全局状态持久化的地方使用Layouts,而在每次需要都重新加载的地方使用Templates。比如:你可以在用户切换页面的时候重置表单或者重载页面数据。
那么我们说回上面的两种场景,为什么模板更适合这些情况?
1.记录页面访问次数和表单
- 假如你想追踪用户对每个独立页面的访问次数,如果使用布局,由于布局组件在页面之间切换通常不会重新挂载,那么,布局中的
useEffect
这个效果函数用来增加访问计数可能不会在每次页面切换的时候触发,布局中的状态和效果如果被保留,这不利于记录单独页面的访问。 - 相反的,如果使用模板,每次用户访问新的页面,模板就会重新挂载,重新执行内部的
useEffect
,这就可以使得你准确地增加和显示每个页面的访问次数 - 对于需要用户填写的表单,通常期望的效果都是用户访问表单页面的时候,是清空的,当然如果也有希望保留的,不过一般不会采用Layout来做,因为会有保留草稿的操作,会有别的更好的办法实现。
2.Suspense
- 布局方式只会在布局首次加载的时候显示
fallback
。在应用程序内部页面间切换的时候,由于布局组件不会重新挂载,fallback
不会再次显示。
下面我会写一个demo:
这是目录结构。按照我们前面说的,Layout和Template同一级,会由Layout包裹住Template,然后包裹住page。
Layout:
// app/dashboard/layout.tsx
"use client";
import Link from "next/link";
import { useState } from "react";
const DashBoardLayout: React.FC<{ children: React.ReactNode }> = ({
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}
</>
);
};
export default DashBoardLayout;
Template:
// app/dashboard/template.tsx
"use client";
import { useState } from "react";
const DashboardTemplate: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [count, setCount] = useState(0);
return (
<>
<h1>Template {count}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
{children}
</>
);
};
export default DashboardTemplate;
现在点击两个 Increment 按钮,会开始计数。随便点击下数字,然后再点击 About或者 Settings切换路由,你会发现,Layout 后的数字没有发生变化,Template 后的数字重置为 0。这就是所谓的状态保持。
注:当然如果刷新页面,Layout 和 Template 后的数字肯定都重置为 0。
定义加载界面(Loading UI)
App Router 提供了用于展示加载界面的loading.js
这个功能的实现借助了 React 的Suspense API。它实现的效果就是当发生路由变化的时候,立刻展示 fallback UI,等加载完成后,展示数据。
在这里小提一下:举个的Suspense代码的例子:
// 在 ProfilePage 组件处于加载阶段时显示 Spinner
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>
Suspense工作原理其实很简单,分为三步:
1.抛出 Promise:
当 Suspense 包裹的组件(如你的 ProfilePage)进行数据加载时,它会通过抛出一个 Promise 来暂停自己的渲染。这通常是通过 React 的 Concurrent Mode 下的某个库或工具实现的,比如 Relay 或新的 React 18 的 useTransition API。
2.捕获 Promise:
Suspense 组件捕获这个抛出的 Promise。在 Promise 处于等待(pending)状态时,Suspense 会渲染其 fallback 属性指定的组件,通常是一个加载指示器(如你的
3.Promise 解决:
当 Promise 解决(resolved)并且异步数据加载完成时,Suspense 接收到这个信号,并重新尝试渲染其子组件。如果此时子组件可以无障碍地完成渲染(即不再抛出 Promise),Suspense 则停止显示 fallback 组件,并展示加载完成的内容
了解了loading.js的背后原理,我们可以来看一下具体的实现:
在开始实现之前,我们必须认识到:
loadin.js基本上就是用来适配SSR的(server 服务器组件)
Suspense 有两种方式,普通组件需要 lazy+Suspense 配合使用(csr)
服务端组件可以直接 async,因此可以直接 Suspense,也就是 loading.js
那么,在我们了解了必要的东西之后,我们来看看它的实现吧:
这个是层级目录:
切记,loading和page是在一个目录之下的,这样才可以为其套上Suspense
loading的代码:
const DashLoading: React.FC = () => {
return <div>Loading...</div>;
};
export default DashLoading;
page的代码:
// /app/dashboard/page.js
import React from "react";
const fetchData = async (): Promise<string> => {
// 模拟网络请求
return new Promise((resolve) => {
setTimeout(() => {
resolve("Dashboard Data Loaded");
}, 3000);
});
};
const DashBoardPage: React.FC = async () => {
const data = await fetchData();
return (
<div>
<h1>这是仪表盘页面: {data}</h1>
</div>
);
};
export default DashBoardPage;
就是这么简单,一个简单的loading效果就实现了:
loading.js
的实现原理是将 page.js
和下面的 children 用 <Suspense>
包裹。因为page.js
导出一个 async 函数,Suspense 得以捕获数据加载的 promise,借此实现了 loading 组件的关闭。
最关键的地方其实在于page.js
导出了一个async函数。
但是其实在React最新发布的方法里,有一个函数是use
// /app/dashboard/page.js
import React, { use } from "react";
const fetchData = async (): Promise<string> => {
// 模拟网络请求
return new Promise((resolve) => {
setTimeout(() => {
resolve("Dashboard Data Loaded");
}, 3000);
});
};
const DashBoardPage: React.FC = () => {
const data = use(fetchData());
return (
<div>
<h1>这是仪表盘页面: {data}</h1>
</div>
);
};
export default DashBoardPage;
如果你想针对 /dashboard/about
单独实现一个loading效果,那就在 about
目录下再写一个 loading.js
即可。
对于这些特殊文件的层级问题 ,层级关系就是下面这样的:
定义错误处理(Error Handing)
再讲讲特殊文件error.js
。顾名思义,就是拿来创建错误发生的时候的展示UI。
其原理就是借助了React的Error Boundary。简单来说就是给page.js和其Children包了一层ErrorBoundary
PS:注意,错误处理组件必须是client组件
"use client";
import { useEffect } from "react";
interface ErrorProps {
error: Error;
reset: () => void;
}
const ErrorComponent: React.FC<ErrorProps> = ({ error, reset }) => {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={reset}>Try again</button>
</div>
);
};
export default ErrorComponent;
应用的page文件:
"use client";
import { useState } from "react";
const ErrorExamplePage: React.FC = () => {
const [error, setError] = useState(false);
const handleGetError = () => {
setError(true);
};
return (
// Error()这里就是一个构造器,会自动识别同目录下的error.tsx文件
<>{error ? Error() : <button onClick={handleGetError}>Get Error</button>}</>
);
};
export default ErrorExamplePage;
有时错误是暂时的,只需要重试就可以解决问题。所以 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>
标签。
"use client";
// app/global-error.js
interface GlobalErrorProps {
error: Error;
reset: () => void;
}
const GlobalError: React.FC<GlobalErrorProps> = ({ error, reset }) => {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
};
export default GlobalError;
注:global-error.js 用来处理根布局和根模板中的错误,app/error.js 建议还是要写的
定义404页面
顾名思义,当该路由不存在的时候展示的内容。
nextjs提供的默认的404效果是这样的:
如果你要替换这个效果,只需要在 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 一定要说明一点的是,它只能由两种情况触发:
1.当组件抛出了 notFound 函数的时候
2.当路由地址不匹配的时候
所以说app/not-found.js
可以修改默认404页面的样式,但是,如果not-found.js
放在了任何子文件夹下,它只能用notFound
函数手动去触发,比如:
import { notFound } from "next/navigation";
const BlogPage: React.FC = () => {
notFound();
return <></>;
};
export default BlogPage;
执行 notFound 函数时,会由最近的 not-found.js 来处理。
(比如我在dashboard下面的blog目录调用了这个函数,但是blog没有not-found.js,那么就去使用dashboard的,要是也没有,就会逐步上升到app下面的,如果app也没有,那就是用默认的了)
但如果直接访问不存在的路由,则都是由 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 和路由组一起使用的时候,可能会出现问题。