Next.js 实战笔记 2.0:深入 App Router 高阶特性与布局解构
上一篇笔记:
上篇笔记主要回顾了一些 Next12 到 Next15 的一些变化,这里继续学习/复习一些已有或者是新的变化
turbo 的补充
在实际运行的过程当中,我发现使用 yarn dev --turbo
运行,编译并不稳定——不确定是因为我的 Mac 还是 intel 的原因,毕竟现在很多的优化都是针对 M 芯片做的,总之目前还是 fallback 到了默认的开发模式……
其他保留页面
除了 page.js
和 layout.js
之外,NextJS 还有其他两个保留页面
报错页面
也就是 error.js
,大体的实现如下:
"use client";
import React from "react";
const MealsErrorPage = () => {
return (
<main className="error">
<h1>An Error Occurred!</h1>
<p>Fail to fetch meal data. Please try again later.</p>
</main>
);
};
export default MealsErrorPage;
需要注意的是, error.js
必须要使用 use client
,因为这个页面即会处理 server end 的异常,也会处理 client end 的异常
它的作用与 layout
类似,在当前/兄弟姐妹/子页面出现异常后,会渲染当前页面
not found
大体实现如下:
import React from "react";
const NotFoundPage = () => {
return (
<main className="not-found">
<h1>Not Found</h1>
<p>Could not find the page you are looking for.</p>
</main>
);
};
export default NotFoundPage;
和 error.js
类似,不过在组件内调用 notFound();
也可以重定向到当前页面
表单
其实这部分不完全是 NextJS 的内容,更多的是 React 19 提出的新功能。这里会基于 NextJS 中的实现进行讨论,React 的话,等到 NextJS 的内容过完了后,重新过一遍 React18 和 19 的新特性
提交表单
之前在使用 React 的表单时,提交事件其实不由 action
触发,而是通过 onClick
+ preventDefault()
可以绕过 action
进行实现。不过目前 NextJS 目前则可以直接通过 action
在 server end 完成表单的提交,并且将表单中有的数据包成 formData
作为参数
下面是一个简单的实现:
export default function ShareMealPage() {
const shareMeal = async (formData) => {
// use server must be an async function
"use server";
const meal = {
creator: formData.get("name"),
creator_email: formData.get("email"),
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
};
console.log(meal);
};
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
<form className={classes.form} action={shareMeal}></form>
</main>
</>
);
}
服务端输出的结果:
这里需要注意的是,如果组件本身使用了 use client
,那么在方法内使用 use server
就会报错……
useFormStatus
这里简单的提一下使用方法,就是一个返回的 pending
可以更灵活的运用
const { pending, data, method, action } = useFormStatus();
具体的使用案例如下:
"use client";
import React from "react";
import { useFormStatus } from "react-dom";
const MealsFormSubmit = () => {
const { pending } = useFormStatus();
return (
<button disabled={pending}>{pending ? "Submitting" : "Share Meal"}</button>
);
};
export default MealsFormSubmit;
我这里是单独拆了一个组件出来使用,这个方法和官方提供的使用方法类似:
import { useFormStatus } from "react-dom";
import action from "./actions";
function Submit() {
const status = useFormStatus();
return <button disabled={status.pending}>Submit</button>;
}
export default function App() {
return (
<form action={action}>
<Submit />
</form>
);
}
具体的操作,React 在内部已经实现了,只要通过 action
进行触发,就可以顺利地监听到表单的状态变化
useFormState
目前 React 官方是把 useFormState
重命名成了 useActionState
,并且用法是一样的——除了后者是从 react
中导入,前者是 react-dom
中导入:
In earlier React Canary versions, this API was part of React DOM and called
useFormState
.
但是我看了下,不知道为啥用 useActionState
会报错,用 useFormState
暂时没问题。介于我用的这个版本,useFormState
还没有被移除,因此暂时就使用了 useFormState
hook 的 signature 如下:
const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);
同理,因为是 hook,所以也需要使用 use client
具体使用方法如下:
"use client";
import ImagePicker from "@/components/meals/image-picker";
import classes from "./page.module.css";
import { shareMeal } from "@/lib/action";
import MealsFormSubmit from "@/components/meals/meals-form-submit";
import { useFormState } from "react-dom";
export default function ShareMealPage() {
const [state, formAction] = useFormState(shareMeal, { message: null });
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
<form className={classes.form} action={formAction}>
<p className={classes.actions}>
{state.message && <p>{state.message}</p>}
</p>
</form>
</main>
</>
);
}
shareMeal
的实现如下:
export const shareMeal = async (prevState, formData) => {
const meal = {
creator: formData.get("name"),
creator_email: formData.get("email"),
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
};
if (
isInvalidText(meal.title) ||
isInvalidText(meal.summary) ||
isInvalidText(meal.instructions) ||
isValidEmail(meal.creator_email) ||
isValidEmail(meal.creator) ||
!meal.creator_email.includes("@") ||
!meal.image ||
meal.image.size === 0
) {
return {
message: "Invalid input",
};
}
await saveMeal(meal);
redirect("/meals/");
};
这部分其实没什么特别好深入挖掘的,使用方法和官方文档基本一致,属于跟着官方文档实现就好了,大体需要注意的地方有:
- form 的
action
需要使用useFormState
返回的第二个值,这样方便 React 进行监听 - 原本的 action fn 第一个参数需要接受
initialState
作为第一个参数
💡:我个人觉得,将 useFormState
和 useFormStatus
封装成一个通用的 custom hook,保证全局的 initialState
一致,这样处理起来可能会更加的高效,也可以更好地减少 boilerplate 代码
缓存
这部分主要是使用 revalidatePath()
这个方法,在进行重定向的时候,去清除 NextJS 中存在的缓存
说实话,这部分的内容可能真的是要多做一点 deploy 之后,才有更多的感觉。目前我有一个小项目是通过 NextJS+github actions 部署到 GH Pages 上的,我只能说似乎是因为 use client
的关系,页面还是会零零碎碎的去 fetch 一些小的 JS 文件。只不过因为页面整体的内容比较少,加载速度还是比较快——大概在 100-200ms 之间,因此目前我还没有花太多的时间和心力去研究 deploy 这部分的内容
dynamic metadata
metadata 的内容在 1.0 中已经提过了,这里讲的是动态的 metadata 的实现方式,主要是通过这个 generateMetadata
的方法自动生成的。 generateMetadata
也是一个保留词,具体使用方法如下:
export const generateMetadata = async ({ params }) => {
const meal = await getMeal(params.mealSlug);
return {
title: meal.title,
description: meal.summary,
};
};
路由
这里再多提一些关于路由的内容,更多更完整的内容,还是可以到官方文档: **Project structure and organization** 中去去查找,并且自己测试试验,再根据项目需求判断是否需要
parallel routes
个人感觉,parallel routes 是一个更方便管理子组件的一种实现。官方文档中说了,parallel routes 的实现必须要依赖于 layout.js
,而且 parallel routes,也就是用 @folder
这种语法,会生成独立的 slot,但是不会生成独立的 URL
如下面这个案例:
@archive
和 @latest
会作为两个独立的 slots,可以在 layout.js
中获取,但是它的路径还是在 localhost:3000/archive
下,单独访问 localhost:3000/archive/@archive
或是 localhost:3000/archive/@latest
会报错,因为 NextJS 内部并没有实现对应的路径
具体的排列方式如下:
import React from "react";
const ArchiveLayout = ({ archive, latest }) => {
return (
<div>
<h1>News Archive</h1>
<section id="archive-filter">{archive}</section>
<section id="archive-latest">{latest}</section>
</div>
);
};
export default ArchiveLayout;
这种情况下, archive
和 latest
的内容会被并排渲染:
parallel routes + 动态路由
现在总体来说,需求还是比较明确的:
- archive 显示按照年月分类的文档
- latest 显示最近的几个文档
按照 NextJS 的结构,那么文档目录就应该是现在这个样子的:
不过这就造成了一个问题:
这是因为,parallel routes 中的路径存在不匹配的情况—— @archive
下有 [year]
,但是 @latest
下面没有,NextJS 没有办法完美匹配路径,因此就抛出了异常
这种情况下解决方式有两种:
@latest
下也创立对应的[year]
结构缺点就是语意不明确,而且会增加很多无意义的结构
在当前的业务情况下,
@latest
默认只会显示最近的几条数据,并不需要根据 年/月 进行搜索使用
default.js
default.js
是 parallel route 的 fallback 页面,具体实现如下:💡 这里的
default.js
中的内容和page.js
完全一致,因此后期实现中将page.js
删除了
最终渲染效果如下:
刚开始看到这个 @
的用法还是不太理解,后面回顾了一下过去做的几个项目,发现这个 slots 还是可以比较好的解决过去项目中,我碰到的几个痛点:
- 超大表单
这个在填写付款方法、地址的时候经常碰上,不过我们那时候的业务场景更复杂一些,总体上来说大概会有 6-7 个 steps,每个 steps 的路径一致,但是表单不一样 - 同一个路径中根据不同条件渲染不同内容
catch all route
其实 NextJS 还是提供了其他的不同实现方法,这个业务场景下,因为只有 年/月 的搜查,其实创建对应的文件夹结构也不是不行,而且对于 NotFound
的支持会更好一些。不过案例中选择用了 catch all route 这个也比较常见实现进行学习
组件部分的实现比较简单:
import NewsList from "@/app/_components/news-list";
import {
getAvailableNewsMonths,
getAvailableNewsYears,
getNewsForYear,
getNewsForYearAndMonth,
} from "@/app/_lib/news";
import Link from "next/link";
import React from "react";
const FilteredNewsPage = ({ params }) => {
const filter = params.filter;
const selectedYear = filter?.[0];
const selectedMonth = filter?.[1];
let news;
let links = getAvailableNewsYears();
if (selectedYear && !selectedMonth) {
news = getNewsForYear(selectedYear);
links = getAvailableNewsMonths(selectedYear);
} else if (selectedYear && selectedMonth) {
news = getNewsForYearAndMonth(selectedYear, selectedMonth);
links = [];
}
let newsContent = <p>No news found for the selected period.</p>;
if (news?.length) {
newsContent = <NewsList news={news} />;
}
return (
<>
<header id="archive-header">
<nav>
<ul>
{links.map((link) => {
const href = selectedYear
? `/archive/${selectedYear}/${link}`
: `/archive/${link}`;
return (
<li key={link}>
<Link href={href}>{link}</Link>
</li>
);
})}
</ul>
</nav>
</header>
{newsContent}
</>
);
};
export default FilteredNewsPage;
这里需要注意的是 params
的返回值,从字符串变成了数组。这是 catch all 的特性,也就是拦截所有的 params
目录结构如下:
需要注意的是这种情况下, @archive
下的 page.js
就会导致冲突,因为 [[...filter]]
本身就拦截了所有的路径——前面也提到过了
最终效果如下: