本文是Next.js学习过程的一个记录
[01-02]
Next.js是一个全栈的react框架,它简化了用react构建全栈应用的过程。




[03]
主要是Meal项目
运行在服务器端的组件:

page是一个nextjs的保留页面,它是运行在服务器端的,你可以看到终端中打印了这行console日志:

浏览器端也可以输出,但是会有前缀Server,提示你这个是服务器端组件:

nextjs要怎么设置标签页的图标
在app目录下放置favicon.ico,Next.js会自动使用;如需自定义,可在src/app/layout.tsx的<head>中添加<link rel="icon" href="/自定义图标路径" />。
component可以放在app文件夹外面

个人比较推荐这种做法,app只用来管理和路由有关的页面,当然了,app当中的路由的文件夹里面也可以嵌套components,里面的文件只要命名不是小写的layout,error,not-found,template,page这些就没事,而且一般组件是大写开头的,不会被识别为一个页面文件。
动态路由

使用方式如图,起一个叫做[slug]的文件夹,不能有其他的名字(比如你可以会想,我在传递路径的时候传递/blog/1,然后这里的嵌套路由的文件夹定义为post-[slug]然后slug会自动接收到你传进去的1,这样是会报错的❌),就叫[slug](也可以叫别的名字,但是必须包含在中括号里面,比如可以是[mealSlug]),这样这个slug就是接收到任何路由,使用时如下:
<Link className='text-[rgb(244,195,61)] text-2xl border-b-2' href={'/blog/post-1'}>Post1</Link>
<Link className='text-[rgb(244,195,61)] text-2xl border-b-2' href={'/blog/post-2'}>Post2</Link>
即:/blog/post-1,然后在这个[slug]里的page.tsx中,要如何使用呢?看下面:
export default async function Blog({ params }: { params: Promise<{ [key: string]: string | string[] | undefined }> }) {
const { slug } = await params
console.log('params:', params)
return (
<main className=" min-h-screen flex flex-col items-center justify-center font-poppins">
<h1 className="text-4xl font-bold text-blue-600">This is {slug}</h1>
</main>
);
}
需要注意params的类型是:{ params: Promise<{ [key: string]: string | string[] | undefined }> },然后使用的时候,必须用await,这是因为在Next.js 13+ 的 app router 中,params 可能是一个 Promise,需要先 await 后再访问属性。你可以将 Blog 声明为 async,并用:
const { slug } = await params获取参数。这样可避免“should be awaited”报错。
这是await之后的输出:

如果没有await的话,status将会是pending
所以,解构出来的slug将会接收到post-1和post-2
[!WARNING]
注意,如果你的动态路由的文件夹叫做:
[slug],那么你在获取params的时候,也要用slug,比如:const {slug} = await params或者const slug = (await params).slug。又假如,你的文件夹的名字叫做:[newsSlug],那么同样,你在解构params的时候,也要用这个名字,即:const {newsSlug} = await params或者const slug = (await params).newsSlug
关于服务器组件和客户端组件之间的数据流式传输
一个Promise的数据,你可以直接获取,不需要await,然后直接传递给客户端组件,在客户端组件使用use钩子等待Promise的结果,这样不会阻塞服务器端组件的渲染,当然了,你也可以在服务器组件中使用await等待Promise的结果,有结果了,客户端组件就会显示出应有的数据,但是这样会阻塞服务器组件的渲染(这是理所应当的,都在服务器组件await了,await后面的逻辑当然就不可以执行咯,显示在页面上可能就是大面积的空白,这是不可接受的)
这样会阻塞服务器组件的渲染:
export default async function App() {
const messageContent = await fetchMessage();
return <Message messageContent={messageContent} />
}
这样就不会阻塞,不需要await,直接将Promise传递给子组件,在子组件中use拿到Promise resolve之后的数据就可以了:
"use client";
import { use, Suspense } from "react";
function Message({ messagePromise }) {
const messageContent = use(messagePromise); // 👈 用use钩子等待Promise的数据
return <p>Here is the message: {messageContent}</p>;
}
export function MessageContainer({ messagePromise }) {
return (
<Suspense fallback={<p>⌛Downloading message...</p>}> // 👈 在用use拿到数据之前,会一直显示fallback中的内容
<Message messagePromise={messagePromise} />
</Suspense>
);
}
关于next.js的layout
默认的App Router是有一个根布局的,也就是最外层的layout.tsx文件,这个是项目的总体布局,一般而言网站的总体布局都是一样的,我们可以设置header和footer在这里,如果你想为一个页面应用另一个布局的话,可以在那个页面的文件夹的目录里面,也就是page.tsx的同级目录设置一个layout的嵌套布局,然后这个子页面就会应用这个嵌套布局,但是,即使是这样,也不意味着最外层的布局失去了它的作用,它依旧是生效的,只不过是相当于在在外层的布局的基础上在子页面使子页面的布局生效罢了,是一种“叠叠乐”的感觉。当前的解释,可以查看下面的碰到的问题 2.
使用nextjs的css module来模块化的导入css的样式
现有以下的样式文件:
main-header.module.css
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem 1rem;
}
@media (min-width: 768px) {
.header {
padding: 2rem 10%;
}
}
.nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 1.5rem;
/* font-family: 'Montserrat', sans-serif; */
font-size: 1.25rem;
}
.nav a {
text-decoration: none;
color: #ddd6cb;
font-weight: bold;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
}
.nav a:hover,
.nav a:active {
background: linear-gradient(90deg, #ff8a05, #f9b331);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 18px rgba(248, 190, 42, 0.8);
}
.active {
background: linear-gradient(90deg, #ff8a05, #f9b331);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
text-decoration: none;
color: #ddd6cb;
font-weight: bold;
font-family: 'Montserrat', sans-serif;
letter-spacing: 0.15rem;
text-transform: uppercase;
font-size: 1.5rem;
}
.logo img {
width: 5rem;
height: 5rem;
object-fit: contain;
filter: drop-shadow(0 0 0.75rem rgba(0, 0, 0, 0.5));
}
乍一看是一个很普通的css文件,而且实际上你的nextjs项目完全可以使用tailwindcss,但是nextjs提供了一种css module的方式,能让你比较方便的使用样式文件,使用方式如下:
先导入:import classes from './main-header.module.css'
然后在jsx中使用:
export default function MainHeader() {
return <header className={classes.header}>
<Link href={'/'} className={classes.logo}>
<img src={logoImg.src} alt="foods" />
NextLevel Food
</Link>
<nav className={classes.nav}>
<ul className={classes.ul}>
<li>
<Link href={'/meals'}>Browse Meals</Link>
</li>
<li>
<Link href={'/community'}>Foodies Community</Link>
</li>
</ul>
</nav>
</header>
}
使用classes.logo这样的方式,如果有嵌套的样式,你只要设置一个父样式,里面所有的子样式会自动应用,无需每一个子元素都应用一次classes.xxx
实际上,tailwindcss的样式类会有不够用的时候,比如某些特别细化的样式,所以还是需要单独的css文件或者其他css的预处理器,所以可以二者相结合,优先使用tailwindcss,不够用的时候,再特殊使用css module
使用better-sqlite3初始化一个sqlite数据库
initdb.ts
const sql = require('better-sqlite3');
const db = sql('meals.db');
const dummyMeals = [
{
title: 'Juicy Cheese Burger',
slug: 'juicy-cheese-burger',
image: '/images/burger.jpg',
summary:
'A mouth-watering burger with a juicy beef patty and melted cheese, served in a soft bun.',
instructions: `
1. Prepare the patty:
Mix 200g of ground beef with salt and pepper. Form into a patty.
2. Cook the patty:
Heat a pan with a bit of oil. Cook the patty for 2-3 minutes each side, until browned.
3. Assemble the burger:
Toast the burger bun halves. Place lettuce and tomato on the bottom half. Add the cooked patty and top with a slice of cheese.
4. Serve:
Complete the assembly with the top bun and serve hot.
`,
creator: 'John Doe',
creator_email: 'johndoe@example.com',
},
{
title: 'Spicy Curry',
slug: 'spicy-curry',
image: '/images/curry.jpg',
summary:
'A rich and spicy curry, infused with exotic spices and creamy coconut milk.',
instructions: `
1. Chop vegetables:
Cut your choice of vegetables into bite-sized pieces.
2. Sauté vegetables:
In a pan with oil, sauté the vegetables until they start to soften.
3. Add curry paste:
Stir in 2 tablespoons of curry paste and cook for another minute.
4. Simmer with coconut milk:
Pour in 500ml of coconut milk and bring to a simmer. Let it cook for about 15 minutes.
5. Serve:
Enjoy this creamy curry with rice or bread.
`,
creator: 'Max Schwarz',
creator_email: 'max@example.com',
},
{
title: 'Homemade Dumplings',
slug: 'homemade-dumplings',
image: '/images/dumplings.jpg',
summary:
'Tender dumplings filled with savory meat and vegetables, steamed to perfection.',
instructions: `
1. Prepare the filling:
Mix minced meat, shredded vegetables, and spices.
2. Fill the dumplings:
Place a spoonful of filling in the center of each dumpling wrapper. Wet the edges and fold to seal.
3. Steam the dumplings:
Arrange dumplings in a steamer. Steam for about 10 minutes.
4. Serve:
Enjoy these dumplings hot, with a dipping sauce of your choice.
`,
creator: 'Emily Chen',
creator_email: 'emilychen@example.com',
},
{
title: 'Classic Mac n Cheese',
slug: 'classic-mac-n-cheese',
image: '/images/macncheese.jpg',
summary:
"Creamy and cheesy macaroni, a comforting classic that's always a crowd-pleaser.",
instructions: `
1. Cook the macaroni:
Boil macaroni according to package instructions until al dente.
2. Prepare cheese sauce:
In a saucepan, melt butter, add flour, and gradually whisk in milk until thickened. Stir in grated cheese until melted.
3. Combine:
Mix the cheese sauce with the drained macaroni.
4. Bake:
Transfer to a baking dish, top with breadcrumbs, and bake until golden.
5. Serve:
Serve hot, garnished with parsley if desired.
`,
creator: 'Laura Smith',
creator_email: 'laurasmith@example.com',
},
{
title: 'Authentic Pizza',
slug: 'authentic-pizza',
image: '/images/pizza.jpg',
summary:
'Hand-tossed pizza with a tangy tomato sauce, fresh toppings, and melted cheese.',
instructions: `
1. Prepare the dough:
Knead pizza dough and let it rise until doubled in size.
2. Shape and add toppings:
Roll out the dough, spread tomato sauce, and add your favorite toppings and cheese.
3. Bake the pizza:
Bake in a preheated oven at 220°C for about 15-20 minutes.
4. Serve:
Slice hot and enjoy with a sprinkle of basil leaves.
`,
creator: 'Mario Rossi',
creator_email: 'mariorossi@example.com',
},
{
title: 'Wiener Schnitzel',
slug: 'wiener-schnitzel',
image: '/images/schnitzel.jpg',
summary:
'Crispy, golden-brown breaded veal cutlet, a classic Austrian dish.',
instructions: `
1. Prepare the veal:
Pound veal cutlets to an even thickness.
2. Bread the veal:
Coat each cutlet in flour, dip in beaten eggs, and then in breadcrumbs.
3. Fry the schnitzel:
Heat oil in a pan and fry each schnitzel until golden brown on both sides.
4. Serve:
Serve hot with a slice of lemon and a side of potato salad or greens.
`,
creator: 'Franz Huber',
creator_email: 'franzhuber@example.com',
},
{
title: 'Fresh Tomato Salad',
slug: 'fresh-tomato-salad',
image: '/images/tomato-salad.jpg',
summary:
'A light and refreshing salad with ripe tomatoes, fresh basil, and a tangy vinaigrette.',
instructions: `
1. Prepare the tomatoes:
Slice fresh tomatoes and arrange them on a plate.
2. Add herbs and seasoning:
Sprinkle chopped basil, salt, and pepper over the tomatoes.
3. Dress the salad:
Drizzle with olive oil and balsamic vinegar.
4. Serve:
Enjoy this simple, flavorful salad as a side dish or light meal.
`,
creator: 'Sophia Green',
creator_email: 'sophiagreen@example.com',
},
];
db.prepare(`
CREATE TABLE IF NOT EXISTS meals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
image TEXT NOT NULL,
summary TEXT NOT NULL,
instructions TEXT NOT NULL,
creator TEXT NOT NULL,
creator_email TEXT NOT NULL
)
`).run();
async function initData() {
const stmt = db.prepare(`
INSERT INTO meals VALUES (
null,
@slug,
@title,
@image,
@summary,
@instructions,
@creator,
@creator_email
)
`);
for (const meal of dummyMeals) {
stmt.run(meal);
}
}
initData();
其中,以下代码是什么意思?
async function initData() {
const stmt = db.prepare(`
INSERT INTO meals VALUES (
null,
@slug,
@title,
@image,
@summary,
@instructions,
@creator,
@creator_email
)
`);
for (const meal of dummyMeals) {
stmt.run(meal);
}
}
这段代码定义了一个异步函数 initData,用于将 dummyMeals 数组中的每个菜品对象插入到 meals 数据表中。它先准备一条带参数的 SQL 插入语句,然后遍历每个菜品对象,依次插入到数据库。这样可以批量初始化数据库数据。
那为什么insert语句中有很多@开头的变量呢?
@开头的变量是SQL中的命名参数,用于安全地传递数据,防止SQL注入,并让每次插入时自动用对象里的对应属性值替换。
一个关于认为延迟加载时间的一个“bug”
请看以下代码:
Meals
import Link from 'next/link'
import classes from './page.module.css'
import MealsGrid from '@/components/meals/meals-grid'
export default function Meals() {
return <>
<header className={classes.header}>
<h1>
Delicious meals, created <span>by you</span>
</h1>
<p>Choose your favorite recipe and cook it yourself. It is easy and fun!</p>
<p className={classes.cta}>
<Link href={'/meals/share'}>
Share Your Favorite Recipe
</Link>
</p>
</header>
<main>
<MealsGrid/>
</main>
</>
}
MealGrid
import { getMeals } from '@/lib/meals'
import classes from './meals-grid.module.css'
import MealItem from './meals-item'
export default async function MealsGrid() {
const meals = await getMeals()
return <>
<ul className={classes.meals}>
{meals.map(meal => <li key={meal.id}>
<MealItem {...meal} />
</li>)}
</ul>
</>
};
当进入/meals页面的时候,你会发现页面正在loading,当然,这是由于我们人为加上了2s的等待时间,我有个疑问就是:
[!NOTE]
当前我是在MealsGrid中使用的异步请求的,这个请求被我认为的加上了2s的等待时间,可是Meals本身并不是个异步组件,按理来说是不是应该先显示Meals的其他内容,然后等到MealsGrid中的查询完成之后,再显示MealGrid部分的内容?
-
原因 1(渲染模型):在 App Router 下,MealsGrid是服务端组件且是 async。即便父组件Meals不是 async,React 服务端渲染会在生成整棵子树时遇到 MealsGrid 的 await,没有边界时会“阻塞”该子树的输出,直到 MealsGrid 的数据准备好。 -
原因 2(没有边界就不能局部流式):想要“先出头部,后补网格”,需要一个 Suspense 边界把慢节点和其他内容隔离。没有 Suspense 时,React 不会把父层(头部)先流出来,而是等待子树(包含 MealsGrid)一起完成。 -
原因 3(Next 的streaming规则):Next.js 的流式渲染依赖 React Suspense。路由段级别可以用 loading.tsx 提供整段的加载状态;而你这种“页面内局部慢组件”要在父组件里手动加 Suspense,否则不会部分渲染。 -
原因 4(你当前的代码行为):MealsGrid 内部 await getMeals() 有 2s 延迟,父组件没有 Suspense 包裹它,所以整个页面的这部分渲染会等 2s 一起出来,看起来像“头部也被拖住了”。 -
结论:你的理解“应先显示 Meals 其他内容,再等 MealsGrid 完成再显示”是对的,但前提是给 MealsGrid 外层加一个 Suspense 边界(或提供该段的 loading UI)。否则 React/Next 会等它完成再一起输出。
这种情况下,就要用React的Suspense来解决了:
Meals
<main>
<Suspense fallback={<MealsLoadingPage />}>
<MealsGrid />
</Suspense>
</main>
这样,meals中的其他内容就会先显示,然后单独等待这个子组件的内容:
进入meals页面时:
你会发现子组件正在单独loading,不会影响该页面其他内容的显示

等到这个子组件加载完成的时候,就会显示出它的内容了:

🧐引出的新问题
我现在已经正确处理了Suspense,确保可以流式渲染页面,但是存在几个问题:
-
当我点击Navlink中的Link时,MealsGrid就会进行loading,然后如果我此时迅速切换到另一个Link,那么进入meals页面的loading请求会正常完成而不是因为我快速导航之后被打断,这是正常的吗?
这是正常的。你在 MealsGrid 里的数据获取发生在服务端渲染阶段(RSC/Route Handler/DB 调用)。客户端快速切换路由时,Next 会尝试通过 Abort 信号中断可中断的 fetch;但像同步 DB 查询(better-sqlite3)这类不可中断的工作仍会在服务端完成,只是结果会被丢弃,不再送达客户端。所以你看到“请求完成”但页面已离开是预期行为。
-
我先进入meals页面,然后点击header中的Link进入Community页面,然后用浏览器的返回按钮再次进入meals页面的时候,会发现无需loading。可是当我从meals页面点击header的Link进入Community页面后,再点击header的Link进入Meals页面,此时Meals的loading就会进行加载,为什么会这样?
-
浏览器“后退”会优先用历史条目的缓存(bfcache 或 Next 的路由内存缓存)直接恢复先前的页面状态,因此基本不触发重新数据请求,也就看不到 loading。
-
点击链接回到同一路由是一次“新导航”,是否复用缓存取决于:
-
是否存在仍有效的路由缓存条目(Next 路由缓存有大小/生命周期/逐出策略)。
-
是否开启过 prefetch 并且缓存未失效。
-
是否被页面/段配置为动态(如 revalidate = 0、dynamic = 'force-dynamic'、使用不可缓存的数据源等),这些都会导致每次导航重新渲染并加载。
-
因此:后退是“恢复旧历史条目”,几乎瞬间;点击链接是“创建新历史条目”,在无可用缓存或被标记动态时会重新请求,从而出现 loading。
-
user server
use server 指令指定一个函数或文件在服务器端执行。它可以在文件顶部使用,以表明文件中的所有函数都是服务器端的,或者在函数顶部内联使用,以将该函数标记为服务器函数。这是 React 的一个特性。
'use server' 是 Next.js 13/14 引入的指令,用于标记某个函数为“服务端函数”(Server Action)。作用如下:
-
让该函数只能在服务器端执行,不能在客户端调用。
-
通常用于处理表单提交、数据库操作等需要服务端安全环境的逻辑。
-
可以直接作为 React 组件中
<form action={fn}>的 action,简化服务端表单处理流程。 -
避免手动创建 API 路由,提升开发效率和安全性。
服务端函数:服务端函数指只能在服务器上执行的函数,不能在浏览器端运行。它通常用于处理敏感操作(如数据库读写、鉴权等),保证数据安全和业务逻辑不暴露给客户端。实现数据库增删改查的函数就是服务端函数。客户端组件如果要操作数据库,通常是通过发送 API 请求(如 fetch 调用接口),而不是直接访问数据库。服务端函数则可以直接操作数据库,无需额外 API 层。
const shareMeal = async () => {
'use server'
// pass
}
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}>
...
帮我解释一下这个shareMeal函数,以及为什么form的action是shareMeal而不是一个url
shareMeal 是一个异步函数,并且在函数体内使用了 'use server',这表示它是一个 Next.js 13/14 的 Server Action。
form 的 action={shareMeal},不是传统的 URL,而是直接绑定到这个 server action 函数。这样表单提交时,数据会直接传递给 shareMeal,由服务器端处理,无需跳转或手动指定 API 路径。这是 Next.js 新的表单处理方式,简化了服务端表单处理流程。
但是,最好是将shareMeal抽到一个文件中,这个文件全部都是运行在服务端的函数
actions.ts
"use server";
import { redirect } from "next/navigation";
import { saveMeal } from "./meals";
import type { MealFormData } from "@/types/meals";
export const shareMeal = async (formData: FormData) => {
const meal = {
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
creator: formData.get("name"),
creator_email: formData.get("email"),
};
try {
await new Promise(res => setTimeout(res, 2000))
await saveMeal(meal as MealFormData);
} catch (error) {
console.log("上传图片出错了");
}
redirect("/meals");
};
其中值得注意的是,如果redirect写在try内部是不会进行跳转的,因为redirect('/meals') 在 Next.js 中是通过抛出内部 RedirectError 来中断执行实现跳转的。你把它放在 try...catch 里,错误被捕获,于是跳转被“吃掉”,进入 catch 输出“上传图片出错了”。而 saveMeal 已经完成,所以数据和图片都在,但页面没跳转。造成的结果就是:没有跳转,但是所有的信息都被正确上传。所以,要像上面那样放在try的外面。
[!CAUTION]
注意,如果这里没有写try...catch来捕获错误的话,会跳转到error.tsx页面当中去。
meals-form-submit.tsx
"use client";
import { useFormStatus } from "react-dom";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
export default function MealFormSubmit() {
const { pending } = useFormStatus();
const didMount = useRef(false);
const toastId = useRef<string | number | null>(null);
const router = useRouter();
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
if (pending) {
if (!toastId.current) {
toastId.current = toast.loading("Submitting...");
} else {
toast.loading("Submitting...", { id: toastId.current });
}
} else {
if (toastId.current) {
toast.success("Meal submitted successfully", { id: toastId.current });
toastId.current = null;
router.push('/meals');
}
}
}, [pending, router]);
return (
<button type="submit" disabled={pending}>
{pending ? "Submitting..." : "Share Meal"}
</button>
);
}
当前组件MealFormSubmit的执行过程如下:
- 初始化
-
组件首次渲染时,
useFormStatus获取表单提交状态pending。 -
didMount和toastId用useRef初始化,分别用于标记组件是否已挂载和存储toast的ID。 -
useRouter用于后续页面跳转。
- 副作用处理(
useEffect)
-
组件首次挂载时,
didMount.current为false,只将其设为true,不执行后续逻辑(避免初次渲染时误触发toast)。 -
之后每次pending或router变化时:
-
如果
pending为true(表单正在提交), -
若
toastId.current为空,弹出“Submitting...”的loading提示,并保存toast的ID。 -
若
toastId.current已有值,更新对应toast为loading状态。 -
如果pending为false(提交结束),
-
若toastId.current有值,弹出“
Meal submitted successfully”成功提示,重置toastId,并跳转到/meals页面。
- 渲染
- 渲染一个
<button>,根据pending状态切换按钮文本和禁用状态。
总结:
该组件用于表单提交按钮,能根据提交状态显示loading和成功提示,并在提交成功后自动跳转页面。通过useEffect和useRef精确控制toast的显示与更新,避免重复弹窗。
此时actions.ts要改成:
"use server";
import { saveMeal } from "./meals";
import type { MealFormData } from "@/types/meals";
export const shareMeal = async (formData: FormData) => {
const meal = {
title: formData.get("title"),
summary: formData.get("summary"),
instructions: formData.get("instructions"),
image: formData.get("image"),
creator: formData.get("name"),
creator_email: formData.get("email"),
};
try {
await new Promise(res => setTimeout(res, 2000))
await saveMeal(meal as MealFormData);
return { ok: true } as const;
} catch (error) {
console.error("上传图片出错了", error);
return { ok: false } as const;
}
};
更新服务端的提交动作以取消服务端重定向,返回提交结果;然后更新 MealFormSubmit 在提交成功后切换为 success 并发起客户端跳转,从而避免 loading 残留。
-
为什么去掉 redirect:
-
服务端的 redirect('/meals') 是通过抛出 RedirectError 立刻中断执行实现跳转的,会在 pending 结束瞬间把当前页面卸载,导致你在客户端想先把 loading 切成 success 再跳转的流程被打断(toast 还没来得及更新或直接被卸载)。
-
改为让服务端动作只“完成保存并返回结果”,把“何时跳转”这件事交给客户端控制,才能确保先把同一个 toast id 的 loading 替换为 success,再执行 router.push('/meals')。
-
-
为什么返回
{ ok: true }:-
语义化地表达动作结果,便于拓展(如以后用 useFormState 消费返回值,展示表单级错误/成功信息)。
-
在你当前用法里,UI 的“成功与否”其实是靠 pending 从 true→false 的边沿判断来驱动的,返回值暂时没在客户端直接读取,但为后续增强留了接口。
-
-
为什么写 as const:
- 让 TypeScript 推导为字面量类型
{ ok: true }而不是宽泛的{ ok: boolean },方便将来用 useFormState 做精确的类型收窄和分支判断。当前不用也不影响运行,仅是类型友好度提升。
- 让 TypeScript 推导为字面量类型
-
返回值在哪里被使用:
-
目前没有在客户端直接读取返回值;你的 MealFormSubmit 仅用 useFormStatus().pending 来驱动 toast 流程与 router.push。如果未来改为:
-
在组件中使用
useActionState(shareMeal, initialState),就能拿到{ ok: true | false }做更细致的 UI 分支(如错误提示、字段高亮、停留在原页等)。
-
所以当前可以直接不要return任何东西
saveMeal
export async function saveMeal(meal: MealFormData) {
const title = String(meal.title ?? '').trim();
const summary = String(meal.summary ?? '').trim();
const creator = String(meal.creator ?? '').trim();
const creator_email = String(meal.creator_email ?? '').trim();
const unsafeInstructions = String(meal.instructions ?? '');
const instructions = xss(unsafeInstructions);
if (!title || !summary || !creator || !creator_email || !instructions) {
throw new Error('Invalid form data');
}
const slug = slugify(title, { lower: true });
// 处理图片
let imagePath = '';
if (meal.image) {
let arrayBuf: ArrayBuffer | null = null;
let ext = 'png';
if (typeof meal.image === 'string') {
// 若已是相对路径(如二次提交),直接使用
imagePath = meal.image;
} else if (meal.image instanceof ArrayBuffer) {
arrayBuf = meal.image;
} else {
// File(Web File)
const file = meal.image as File;
arrayBuf = await file.arrayBuffer();
const byName = file.name?.split('.').pop();
if (byName) ext = byName;
// 也可从 MIME 推断:file.type 如 image/jpeg -> jpg
}
if (!imagePath && arrayBuf) {
const fileName = `${slug}.${ext}`;
fs.mkdirSync('public/images', { recursive: true });
const buffer = Buffer.from(arrayBuf);
fs.writeFileSync(`public/images/${fileName}`, buffer);
imagePath = `/images/${fileName}`;
}
}
const params = {
title,
summary,
instructions,
creator,
creator_email,
image: imagePath,
slug,
};
db.prepare(`
INSERT INTO meals (title, summary, instructions, creator, creator_email, image, slug)
VALUES (@title, @summary, @instructions, @creator, @creator_email, @image, @slug)
`).run(params);
}
执行过程详细分析:
-
参数预处理
-
将meal对象中的
title、summary、creator、creator_email转为字符串并去除首尾空格。 -
将
instructions转为字符串,并用xss库进行过滤,防止XSS攻击。
-
-
表单校验
- 检查上述字段是否有空值,若有则抛出Invalid form data错误。
-
生成slug
- 用
slugify库将title转为小写的slug字符串,作为唯一标识。
- 用
-
图片处理
-
初始化
imagePath为空字符串。 -
判断
meal.image类型: -
若为字符串,直接赋值(如已是相对路径)。
-
若为
ArrayBuffer,直接赋值。 -
若为
File对象,调用arrayBuffer()获取二进制数据,并尝试从文件名获取扩展名。 -
若有二进制数据且
imagePath为空,则: -
生成文件名(用slug和扩展名)。
-
确保
public/images目录存在。 -
写入图片文件到
public/images目录。 -
设置
imagePath为图片的相对路径。
-
-
参数组装
- 将所有字段组装成
params对象。
- 将所有字段组装成
-
写入数据库
- 用
db.prepare准备SQL语句,将params插入到meals表中。
- 用
总结:
该函数主要完成表单数据校验、图片处理(支持多种类型)、防XSS、slug生成,并最终将数据写入数据库。
关于Nextjs的缓存 👉 可见碰到的问题 - 4
这是一个很好的问题!Next.js 出现缓存情况的原因涉及其设计哲学和性能优化策略。让我详细解释:
Next.js 缓存机制的原因
1. 性能优化的核心理念
Next.js 的设计目标是构建 快速的 Web 应用,缓存是实现这一目标的关键:
// Next.js 会缓存这些操作的结果
export async function getMeals() {
// 数据库查询结果会被缓存
const allMeals = db.prepare("SELECT * FROM meals").all();
return allMeals;
}
2. 多层缓存架构
Next.js 13+ App Router 引入了复杂的缓存系统:
┌─────────────────┐
│ 浏览器缓存 │ ← HTTP 缓存头
└─────────────────┘
┌─────────────────┐
│ 路由缓存 │ ← Router Cache (客户端)
└─────────────────┘
┌─────────────────┐
│ 完整路由缓存 │ ← Full Route Cache (服务端)
└─────────────────┘
┌─────────────────┐
│ 数据缓存 │ ← Data Cache (fetch 和其他)
└─────────────────┘
3. Static Generation (静态生成) 的默认行为
// 在 page.tsx 中,这种组件默认是静态的
export default async function MealsPage() {
const meals = await getMeals(); // 这会在构建时执行一次
return <MealsGrid meals={meals} />;
}
为什么这样设计?
- 更快的首次加载:页面在构建时预渲染,用户访问时立即显示
- 更少的服务器负载:减少实时数据库查询
- 更好的 SEO:搜索引擎可以索引静态内容
- 更好的用户体验:页面瞬间加载
4. 缓存失效的挑战
这就是您遇到问题的原因:
// 在 actions.ts 中
export const shareMeal = async (prevState: ShareMealState, formData: FormData) => {
// 1. 保存新的 meal 到数据库
await saveMeal(meal as MealFormData);
// 2. 但是首页的 getMeals() 结果仍然是旧的缓存!
// 3. 需要手动告诉 Next.js 重新获取数据
revalidatePath('/meals'); // 这就是解决方案
};
5. 不同的缓存策略
Next.js 提供了多种控制缓存的方式:
// 1. 动态路由 - 每次请求都重新生成
export const dynamic = 'force-dynamic';
// 2. 重新验证时间 - 定期更新缓存
export const revalidate = 60; // 60秒后重新验证
// 3. 手动重新验证 - 在数据变更时
revalidatePath('/meals');
revalidateTag('meals');
// 4. 禁用特定 fetch 的缓存
fetch(url, { cache: 'no-store' });
6. 为什么不自动检测数据变化?
您可能会想:"为什么 Next.js 不能自动检测数据库变化?"
技术原因:
- 数据库是外部系统:Next.js 无法监听所有可能的数据源变化
- 性能考虑:实时监听会消耗大量资源
- 复杂性:不同的数据库、API、文件系统都有不同的变化检测机制
设计哲学:
- 显式优于隐式:开发者明确控制何时需要重新获取数据
- 可预测性:缓存行为是可预测和可控的
- 灵活性:可以针对不同场景选择不同的缓存策略
最佳实践
export const shareMeal = async (prevState: ShareMealState, formData: FormData) => {
try {
await saveMeal(meal as MealFormData);
// 清除相关页面的缓存
revalidatePath('/meals'); // 清除 meals 页面缓存
revalidatePath('/'); // 清除首页缓存
// 或者使用标签重新验证(更精确)
// revalidateTag('meals-data');
} catch (error) {
// 错误处理
}
redirect('/meals');
};
总结
Next.js 的缓存机制是一个权衡的结果:
优点:
- ✅ 极快的页面加载速度
- ✅ 更少的服务器资源消耗
- ✅ 更好的用户体验
- ✅ 优秀的 SEO 表现
代价:
- ❌ 需要手动管理缓存失效
- ❌ 学习曲线相对陡峭
- ❌ 数据一致性需要额外关注
这就是现代 Web 框架的趋势:默认快速,按需动态。通过合理使用 [revalidatePath](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html)、revalidateTag 等 API,您可以在保持高性能的同时确保数据的实时性。
关于next build

以上是打包时终端显示的内容,其中:
Route (app) Size First Load JS
┌ ○ /_not-found 0 B 0 kB
└ ƒ /products/[id] 0 B 0 kB
○ (静态) 作为静态内容预渲染
ƒ (动态) 服务端按需渲染
所以以上的实际打包显示的内容中,只有/meals/[slug]这个页面是按需渲染的,其他的都是静态渲染的,如果不用revalidatePath的话,就会出现数据一直不会变化的缓存问题。
关于一个上传文件,图片在打包后无法正常显示的问题
当你在本地可以正常上传文件到public文件夹,并且本地是可以正常显示的,但是在打包后,图片不能上传到public文件夹中,导致meal的图片不能正常显示,nextjs官方建议把图片存储到第三方的存储中,比如aws
为某一个页面添加metadata
静态添加
export const metadata = {
title:'meal_page',
description:'meals page'
}
动态添加
export async function generateMetadata({ params }: { params: Promise<{ [key: string]: string }> }) {
const { slug } = await params
const meal = getMeal(slug)
return {
title: meal.title,
description: meal.summary
}
}
在某个页面中的页面组件外面添加一个这样的异步函数,就会变成动态的metadata
这个异步函数的名字必须是:generateMetadata,详见文档: 🔗
关于share页面会触发上级meal的loading页面的原因
由于现在share页面存在form表单,所以现在share页面使用了use client,属于是客户端组件,当首次进入这个页面的时候,浏览器加载这个组件需要时间,所以会进入上一级的Suspense边界中,导致显示上一级的loading字样,即:Fetching meals...,解决办法有二:
- 为share页面特殊设置自己的loading页面,这样就能不显示上级的loading页面的字样
- 将share页面设置为服务端组件,这样页面就可以进行预加载,不会使用客户端浏览器来进行加载,然后将表单用客户端组件即可。
[04]

关于并行路由
Parallel Routes 允许您在同一布局中同时或有条件地渲染一个或多个页面。它们对于应用程序中高度动态的部分(如仪表板和社交网站上的动态消息)非常有用。
例如,考虑一个仪表板,您可以使用并行路由同时渲染 team 和 analytics 页面:

并行路由是 Next.js App Router 的一个强大特性,允许你在同一个页面上同时渲染多个页面或组件。以 @ 开头的文件夹被称为 插槽(Slots)。
当前的文件结构:

工作原理
-
布局组件接收插槽作为 props:
export default function ArchiveLayout({ archive, latest }) {- [archive](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) prop 对应
@archive文件夹的内容 - [latest](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) prop 对应
@latest文件夹的内容
- [archive](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) prop 对应
-
同时渲染多个内容区域:
export default function ArchiveLayout({ archive, latest }: { archive: React.ReactNode, latest: React.ReactNode }) { return <> <div> <h1>News Archive</h1> <section id="archive-filter"> {archive} // 渲染 @archive/page.tsx 的内容 </section> <section id="archive-latest"> {latest} // 渲染 @latest/page.tsx 的内容 </section> </div> </> }
实际效果
当用户访问 /archive 路由时,页面会同时显示:
- "News Archive" 标题
- [page.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 的内容(当前显示 "Archive Page")
- [page.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 的内容(当前显示 "Latest Page")
使用场景
这种模式特别适合:
- 仪表板:同时显示多个数据面板
- 内容聚合页面:如新闻归档页面,同时显示归档列表和最新内容
- 复杂布局:需要独立加载状态和错误处理的不同区域
优势
- 独立数据获取:每个插槽可以有自己的 loading.tsx 和 error.tsx
- 更好的用户体验:不同区域可以
独立加载,不会相互阻塞 - 代码组织:将相关但独立的功能分离到不同的插槽中
🧐由此引出的一个问题
既然这种是并行路由,也就是在一个页面上显示许多不同的组件,那么假如我将需要的组件都定义在components文件夹中,在一个页面中分别导入需要的组件,并在页面中渲染出来,这种方式和并行路由有什么不同吗?
从表面上看,并行路由和简单的组件导入确实都能在一个页面上显示多个组件。但是它们之间有几个重要的区别:
1. 路由层面 vs 组件层面
传统组件导入方式:
// components/ArchiveList.tsx
export default function ArchiveList() {
return <div>Archive List</div>
}
// components/LatestNews.tsx
export default function LatestNews() {
return <div>Latest News</div>
}
// app/archive/page.tsx
import ArchiveList from '@/components/ArchiveList'
import LatestNews from '@/components/LatestNews'
export default function ArchivePage() {
return (
<div>
<h1>News Archive</h1>
<ArchiveList />
<LatestNews />
</div>
)
}
并行路由方式:
// app/archive/@archive/page.tsx (独立的路由)
// app/archive/@latest/page.tsx (独立的路由)
// app/archive/layout.tsx (布局协调器)
2. 独立的生命周期管理
并行路由的优势👇
-
独立的加载状态:
// app/archive/@archive/loading.tsx export default function ArchiveLoading() { return <div>Archive loading...</div> } // app/archive/@latest/loading.tsx export default function LatestLoading() { return <div>Latest loading...</div> } -
独立的错误处理:
// app/archive/@archive/error.tsx export default function ArchiveError() { return <div>Archive failed to load</div> } // app/archive/@latest/error.tsx export default function LatestError() { return <div>Latest news failed to load</div> }
传统组件方式:
如果一个组件出错或加载慢,会影响整个页面的渲染。(也就是说,错了一个的话,整个页面都会报错,整个页面都会被牵连导致无法渲染)
3. 数据获取的独立性
并行路由:
// app/archive/@archive/page.tsx
async function getArchiveData() {
// 可能很慢的API调用
const res = await fetch('/api/archive', { cache: 'no-store' })
return res.json()
}
export default async function ArchivePage() {
const data = await getArchiveData() // 独立获取数据
return <div>{/* 渲染归档数据 */}</div>
}
// app/archive/@latest/page.tsx
async function getLatestData() {
// 快速的API调用
const res = await fetch('/api/latest')
return res.json()
}
export default async function LatestPage() {
const data = await getLatestData() // 独立获取数据
return <div>{/* 渲染最新数据 */}</div>
}
**结果:LatestPage数据可以立即显示,ArchivePage数据可以稍后加载,互不阻塞。
传统组件方式:
// app/archive/page.tsx
export default async function ArchivePage() {
// 所有数据必须在这里获取
const [archiveData, latestData] = await Promise.all([
getArchiveData(), // 如果这个慢,整个页面都要等待
getLatestData()
])
return (
<div>
<ArchiveList data={archiveData} />
<LatestNews data={latestData} />
</div>
)
}
4. URL 路由的影响
并行路由还可以响应 URL 参数的变化:
// app/archive/@archive/[...slug]/page.tsx
export default function ArchiveWithFilter({ params }) {
// 可以根据 URL 参数渲染不同内容
return <div>Archive filtered by: {params.slug}</div>
}
// 访问 /archive/2023/tech 时,只有 @archive 插槽会更新
// @latest 插槽保持不变
[!NOTE]
🧐访问 /archive/2023/tech 时,只有 @archive 插槽会更新?
很好的问题!这涉及到 Next.js 并行路由的独立路由匹配机制。让我详细解释一下:
并行路由的匹配规则
在并行路由中,每个插槽都有自己独立的路由系统。当访问一个 URL 时,Next.js 会尝试为每个插槽找到匹配的路由。
具体示例分析
假设我们有这样的文件结构:
app/archive/
├── layout.tsx
├── @archive/
│ ├── page.tsx // 匹配 /archive (如果没有[[...filter]]的话,/archive会匹配这个路径,如果有,这个page将不存在)
│ └── [[...slug]]/
│ └── page.tsx // 匹配 /archive/任何路径
└── @latest/
└── page.tsx // 只匹配 /archive
路由匹配过程
当用户访问 /archive/2023/tech 时:
-
@archive 插槽的匹配:
URL: /archive/2023/tech 插槽: @archive 匹配尝试: ❌ @archive/page.tsx → 只匹配 /archive ✅ @archive/[[...slug]]/page.tsx → 匹配 /archive/2023/tech params.slug = ['2023', 'tech'] -
@latest 插槽的匹配
URL: /archive/2023/tech 插槽: @latest 匹配尝试: ❌ @latest/page.tsx → 只匹配 /archive,不匹配 /archive/2023/tech
关键机制:default.tsx
app/archive/
├── layout.tsx
├── @archive/
│ ├── page.tsx
│ └── [...slug]/page.tsx
└── @latest/
├── page.tsx
└── default.tsx // 🔑 关键文件
// app/archive/@latest/default.tsx
export default function LatestDefault() {
return <div>Latest News (default view)</div>
}
完整的匹配结果:
访问 /archive →
@archive 渲染: @archive/page.tsx
@latest 渲染: @latest/page.tsx
访问 /archive/2023/tech →
@archive 渲染: @archive/[...slug]/page.tsx (params.slug = ['2023', 'tech'])
@latest 渲染: @latest/default.tsx (因为/archive/2023/tech没有匹配的路由,所以@latest就会显示default.tsx而不是page.tsx)
实际代码示例:
// app/archive/@archive/[[...slug]]/page.tsx
export default function ArchiveWithFilter({
params
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
return (
<div>
<h2>Archive Filter</h2>
<p>Filtering by: {slug.join(' / ')}</p>
{/* 根据 slug 参数渲染不同的归档内容 */}
</div>
)
}
// app/archive/@latest/default.tsx
export default function LatestDefault() {
return (
<div>
<h2>Latest News</h2>
<p>Always showing latest news regardless of URL</p>
{/* 始终显示最新内容,不受 URL 参数影响 */}
</div>
)
}
// app/archive/layout.tsx
export default function ArchiveLayout({
archive,
latest
}: {
archive: React.ReactNode
latest: React.ReactNode
}) {
return (
<div>
<h1>News Archive</h1>
<div style={{ display: 'flex' }}>
<div style={{ flex: 1 }}>
{archive} {/* 会根据 URL 变化 */}
</div>
<div style={{ flex: 1 }}>
{latest} {/* 保持不变或显示 default */}
</div>
</div>
</div>
)
}
总结
访问 /archive/2023/tech 时只有 @archive 插槽更新的原因是:
- 独立路由系统:每个插槽都有自己的路由匹配规则
- 精确匹配:[page.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 只匹配
/archive,不匹配更深的路径 - 回退机制:找不到匹配路由时使用
default.tsx - 插槽隔离:一个插槽的路由变化不影响其他插槽
这种设计让你可以创建复杂的页面布局,其中某些区域响应 URL 变化,而其他区域保持稳定。
5. 代码分割和性能
并行路由:
- 每个插槽都是独立的路由,自动代码分割
- 可以独立缓存和优化
- 支持流式渲染(Streaming)
传统组件:
- 所有组件打包在同一个页面 bundle 中
- 整个页面作为一个单元进行缓存
总结
虽然两种方式都能实现"在一个页面显示多个组件",但并行路由提供了:
- 更好的用户体验 - 独立加载,互不阻塞
- 更好的错误恢复 - 部分失败不影响整体
- 更好的性能 - 独立缓存和优化
- 更好的开发体验 - 独立开发和测试每个插槽
- 更复杂的交互 - 可以响应 URL 变化
传统组件导入更适合简单的UI组合,而并行路由更适合复杂的应用页面,特别是那些需要独立数据获取和状态管理的场景。
捕获所有路由:Catch-All Segment
动态路由可以扩展为捕获所有后续路由的通配符,方法是在括号内添加省略号...
例如, app/shop/[...slug]/page.js 将匹配 /shop/clothes ,但也会匹配 /shop/clothes/tops , /shop/clothes/tops/t-shirts 等等。
| Route | Example URL | params |
|---|---|---|
app/shop/[...slug]/page.js | /shop/a | { slug: ['a'] } |
app/shop/[...slug]/page.js | /shop/a/b | { slug: ['a', 'b'] } |
app/shop/[...slug]/page.js | /shop/a/b/c | { slug: ['a', 'b', 'c'] } |
捕获段(Catch-All Segment)可以通过在双方括号中包含参数来使其成为可选的:[[...folderName]]。称为:可选捕获所有段(Optional Catch-All Segment)
捕获所有段和可选捕获所有段之间的区别在于,使用可选捕获时,不包含参数的路由也会被匹配(如上例中的 /shop)。
| Route | Example URL | params |
|---|---|---|
app/shop/[[...slug]]/page.js | /shop | { slug: undefined } |
app/shop/[[...slug]]/page.js | /shop/a | { slug: ['a'] } |
app/shop/[[...slug]]/page.js | /shop/a/b | { slug: ['a', 'b'] } |
app/shop/[[...slug]]/page.js | /shop/a/b/c | { slug: ['a', 'b', 'c'] } |
应用在我们的项目中,此时文件夹的结构变成:

记住,page.tsx只能在 archive/@archive/[[...filter]]下存在,原本@archive本身也是有一个page.tsx的,要删掉它,不然会报错导航到not-found页面中去。
[!CAUTION]
解答为什么会导航到not-found中去
因为这是一个经典的 并行路由配置冲突,请看路由结构:
archive文件夹的结构:
app/archive/
├── layout.tsx // 并行路由布局,期望插槽参数
├── page.tsx // ❌ 冲突:普通页面
├── @archive/ // archive 插槽
└── @latest/ // latest 插槽
这创建了一个 路由冲突:
冲突的原因:
- 并行路由布局 ([layout.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html)) 期望接收插槽作为 props
- 而普通页面 ([page.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html)) 是不提供插槽参数的 👉 也就是说,像这样的并行路由,你必须要有一个layout文件,然后每个并行的路由文件夹内必须要有一个
page.tsx,layout.tsx会自动接受插槽参数,插槽的参数和并行路由文件夹的名字是相同的 - 导致Next.js 无法解决冲突:
- 应该渲染 [page.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html)?
- 还是渲染 [layout.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) + 插槽?
当文件夹结构是上面那样的时候,我们可以运行一下终端看看是不是这样子,运行终端后报错如下:
You cannot define a route with the same specificity as an optional catch-all route
("/archive" and "/archive[[...filter]]")
报错解释:
Next.js 检测到路由冲突:
- [page.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 创建了路由:
/archive - [page.tsx](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 也匹配路由:
/archive(因为[[...filter]]是可选的,见上面的可选捕获表格就可以知道了)
两个路由都尝试处理相同的 URL 路径,这在 Next.js 中是不允许的。
解决方案:
-
将archive下的直接page.tsx删去即可
-
如果确实需要一个/archive的特殊页面的话,可以这样
app/archive/ ├── layout.tsx ├── @archive/ │ ├── page.tsx // 专门处理 /archive │ └── [...filter]/ // 处理 /archive/year/month (注意:单方括号) │ └── page.tsx └── @latest/ └── default.tsx -
新的路由结构,完全不同的行为:
app/archive/ ├── page.tsx // 简单的归档概览页 └── filter/ // 移动过滤功能到子路由 ├── layout.tsx // 并行路由布局 ├── @archive/ └── @latest/
error.tsx
关于错误处理的页面,必须要添加上use client
因为错误在服务端和客户端组件当中均有可能发生,而客户端组件在服务端和客户端均能工作,所以这里要用use client
React Server Component & React Client Component

当我想为MainHeader的navlink设置当前页面高亮样式的时候,会出现一个hydration错误,先看代码:
'use client'
import { usePathname } from 'next/navigation';
import Link from 'next/link';
export default function NavLink({ pathName }: { pathName: string }) {
const path = usePathname()
const LinkName = pathName.slice(1)[0].toUpperCase() + pathName.slice(2)
return <Link className={path.includes(pathName) ? 'active' : undefined} href={pathName}>{LinkName}</Link>
}
浏览器报错:
Uncaught Error: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
为什么会发生这个错误?
- Next.js 的 SSR 机制:页面首先在服务器端渲染
- Hydration 过程:客户端接管并"激活"已渲染的 HTML
- 状态不一致:[usePathname()](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 在服务器端和客户端返回不同的值
- React 检测到差异:抛出 hydration 错误
解决方案:
'use client'
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { useEffect, useState } from 'react';
export default function NavLink({ pathName }: { pathName: string }) {
const pathname = usePathname()
const [isActive, setIsActive] = useState(false)
const LinkName = pathName.slice(1)[0].toUpperCase() + pathName.slice(2)
useEffect(() => {
setIsActive(pathname.includes(pathName))
}, [pathname, pathName])
return (
<Link
className={isActive ? 'active' : undefined}
href={pathName}
>
{LinkName}
</Link>
)
}
- 使用 [useState](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 来管理 [isActive](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 状态,初始值为
false - 使用 [useEffect](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 在客户端 hydration 完成后更新状态
- 避免了 SSR/客户端不一致:服务器端始终渲染相同的初始状态
解答一些问题:
- useEffect 为什么在 hydration 后执行?
- 因为 React 的设计:[useEffect](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 只在客户端执行,且在 DOM 操作完成后执行
- 它能保证在 hydration 后吗?
- ✅ 是的,[useEffect](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 永远在 hydration 完成后执行
- ❌ 但这不意味着它是最佳解决方案([useEffect](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 解决了 hydration 错误,但可能带来闪烁)
拦截路由(Intercepting Routes)
拦截路由允许你在当前布局内从应用程序的其他部分加载一个路由。这种路由范式在你希望在不切换上下文的情况下显示路由内容时非常有用。
例如,当点击动态中的照片时,可以在模态框中显示照片,并将其覆盖在动态之上。在这种情况下,Next.js 会拦截 /photo/123 路由,隐藏 URL,并将其覆盖在 /feed 上。如下图所示:

然而,通过点击可共享的URL或刷新页面来导航到照片时,整个照片页面应完整显示,而不是模态窗口。不应发生路由拦截:

拦截路由本质上拦截了内部导航请求,让你不是直接显示导航后的页面,你会看到如果你刷新页面或者从外部网站进入页面。
现在我们创建了这样的一个文件夹的结构:

[newsSlug]进入的page.tsx是这样的:

然后我们做的一件事就是,当点击这个页面中的图片的时候,跳转到[newsSlug]下的image文件夹中的page.tsx当中去:

跳转后的结果:

你会发现整个页面都显示了图片,而且url也变化了,这其实并不是我们想要的结果,也就是说这还不是拦截路由。
现在我们去定义一个这样的拦截路由,如果我们想拦截的路由是点击图片后的路由,也就是:/images/news/${newsItem?.image}/page.tsx,那么我们就要根据image文件夹所在的位置,为它量身定做一个拦截路由:

由于这个拦截路由当前是和image同级的,所以前面的小括号里是一个.,然后括号后跟着和image一样的名字,所以最终是:(.)image
官方文档中有详细论述:
拦截路由可以使用 (..) 规约定义,这与相对路径规约 ../ 类似,但适用于路由段。
你可以使用:
(.)以匹配同一级别的段落(..)(..)以匹配两级以上的段落(...)以匹配从 根 目录开始的段落
例如,您可以通过创建一个(..)photo目录来从feed段中拦截photo段。

可以看到这个(..)photo这个拦截路由对应的photo文件夹是在这个拦截路由的上一级的,所以要用(..)
**注意事项:**该拦截路由的约定(也就是(.),(..)这些)是基于路由段的,而不是文件系统的。例如,它不会考虑并行路由中的@slot文件夹。可以看以下的文件结构:

你可以看到,现在多出了两个并行路由,此时image的拦截路由(.)image被移动到了@modal中按理来说应该是(..)image对不对?但实际上并不是这样,因为@modal实际上并不算做是路由的一部分,只是一种组织路由的方式,会被next省略,同时这也印证了注意事项中说的:拦截路由是基于路由段的,而不是文件系统的所以,实际上(.)image和它需要拦截的路由image还是同级的,所以这里是(.)而不是(..)
可以看下下面的拦截路由的效果,我们拦截了点击图片导航到image页面的路由,现在拦截之后会自动跳转到拦截的页面中去,也就是一个modal当中去,注意看路由的变化,点击周围的区域还会关闭modal,利用的是router.back(),返回上一级路由。

路由组
当前的起始页如下所示:

此时的文件夹结构:

可以看到,我在页面中放了一个Read the latest news的按钮,点击后会跳转到news页面中去,所以我不想在起始页面出现顶部的MainHeader,但是它又是总layout的一部分:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<div id='page'>
<MainHeader /> 👈 这个header是总布局的一部分,所以起始页面也会有它
{children}
</div>
</body>
</html>
)
}
那么,如果我们如果不想要这个MainHeader的话,要怎么做呢?此时就要引入路由组的概念了:
**路由组 **是一种文件夹约定,可让您按类别或团队组织路由。
一个路由组可以通过在文件夹名称外添加括号来创建: (folderName) 。此约定表示该文件夹用于组织目的,不应包含在路由的URL路径中。

使用案例:
- 按团队、关注点或功能组织路由。
- 定义多个**根布局**。
- 选择将特定路由段纳入共享布局,而将其他路由段排除在外。
注意事项:
- 完整页面加载:如果您在使用不同根布局的路由之间导航,将会触发完整页面重新加载。例如,从使用
app/(shop)/layout.js的/cart导航到使用app/(marketing)/layout.js的/blog。这仅适用于多个根布局。 - 冲突的路径:不同组中的路由不应解析为相同的URL路径。例如,
(marketing)/about/page.js和(shop)/about/page.js都会解析为/about并导致错误。 - 顶级根布局:如果您使用多个根布局且没有顶级
layout.js文件,请确保您的主页路由(/)在其中一个路由组中定义,例如app/(marketing)/page.js。
所以应用了路由组之后的文件夹结构如下所示:

可以看到,(content)和(marketing)两个路由组文件夹只是用来组织路由用的,不会被包含在url当中。
先看(marketing),可以看到这个文件夹下面直接就是两个文件,说明这个文件夹下面匹配的page.tsx就是跟路由的页面,它有它自己的布局文件layout.tsx,它的具体代码:
import "../globals.css";
export const metadata = {
title: "Next.js Page Routing & Rendering",
description: "Learn how to route to different pages.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
可以看到, 这个首页的布局文件我们去除了MainHeader,并且globals.css的路径也需要改,这样首页就没有header了。
再看(content),由于它下面有archive和news文件夹,所以URL就是按照它们的文件夹名字来的,而这两个文件夹的页面也有它们共同的布局文件layout.tsx:
import '../globals.css';
import MainHeader from '@/components/layout/MainHeader'
export const metadata = {
title: 'Next.js Page Routing & Rendering',
description: 'Learn how to route to different pages.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<div id='page'>
<MainHeader />
{children}
</div>
</body>
</html>
)
}
可以看到,这个布局文件是有header的,也需要header。
于是效果如下:
首页:

其他页面:

可以看到分别是有header和无header的。
至此,我们就实现了不同的页面使用不同的布局的效果,这个特性非常有用。
Route Handlers
路由处理程序。
路由处理程序允许您使用Web 请求和响应 API为特定路由创建自定义请求处理程序。路由处理程序仅在 app 目录内可用。

路由处理程序可以嵌套在 app 目录内的任何位置,类似于 page.js 和 layout.js 。但是同一路由段级别上不能有 route.js 文件,与 page.js 处于同一级别。

简单的route.ts代码:
export function GET(request){
console.log('request:',request)
return new Response('Hello! GET!')
}
浏览器访问:localhost:3000/api/,效果:

路由解析
你可以将 route 考虑为最基本的路由原语。
- 他们不参与布局或客户端导航,如
page.tsx。 - 在不能在同一路径下存在
route.js文件和page.js文件。
| Page | Route | Result |
|---|---|---|
app/page.js | app/route.js | :x:Conflict |
app/page.js | app/api/route.js | :o:Valid |
app/[user]/page.js | app/api/route.js | :o: Valid |
中间件
中间件允许你在请求完成之前运行代码。然后,根据传入的请求,你可以通过重写、重定向、修改请求或响应头或直接响应来修改响应。
使用案例:
一些中间件有效的情景包括:
- 快速读取传入请求的部分内容后进行重定向
- 根据A/B测试或实验结果重写不同页面的内容
- 修改所有页面或部分页面的标题头部
中间件不适合用于:
- 缓慢的数据获取
- 会话管理
使用 fetch 与 options.cache 、 options.next.revalidate 或 options.next.tags 在中间件中无效。
在项目根目录下创建一个 middleware.ts (或 .js )文件,或者如果适用的话,可以在 src 内创建,使其与 pages 或 app 处于同一级别。
注意:虽然每个项目仅支持一个middleware.ts文件,您仍然可以将中间件逻辑组织到模块中。将中间件功能拆分到单独的.ts或.js文件中,并将它们导入到主middleware.ts文件中。这有助于更清晰地管理特定路由的中间件,并在middleware.ts中集中控制。通过强制使用单个中间件文件,可以简化配置,防止潜在冲突,并通过避免多个中间件层来优化性能。
例如:
middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url))
}
// See "Matching Paths" below to learn more
export const config = {
matcher: '/about/:path*',
}
项目代码中的简单中间件文件:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
console.log("middleware request:", request);
return NextResponse.next();
}
export const config = {
matcher:'/news'
}
中间件文件的作用
这个 middleware.ts 文件是 Next.js 中的中间件,它的主要作用包括:
- 请求拦截: 在请求到达页面组件之前拦截所有匹配的请求
- 请求处理: 可以对请求进行修改、重定向、重写或阻止
- 日志记录: 当前代码中通过
console.log记录请求信息 - 条件处理: 基于请求的 URL、headers、cookies 等信息执行不同的逻辑
什么时候会输出 middleware 函数中的 request 对象?
根据当前的配置,console.log("middleware request:", request) 会在以下情况下输出:
- 访问
/news路径时: 当用户访问http://localhost:3000/news时 - 访问
/news的子路径时: 当用户访问任何以/news开头的路径时,例如:/news/some-article/news/category/tech/news/2023/12/25/article-title
输出的 request 对象包含了丰富的请求信息:
request.url: 完整的请求 URLrequest.method: HTTP 方法 (GET, POST, etc.)request.headers: 请求头信息request.cookies: Cookie 信息request.nextUrl: Next.js 解析后的 URL 对象
config 的作用
config 对象用于配置中间件的匹配规则,决定哪些请求会触发中间件函数。
当前配置分析
export const config = {
matcher: '/news'
}
当前的配置意味着:
- 匹配模式: 只有访问
/news路径及其子路径的请求才会触发中间件 - 影响范围:
- ✅ 会触发:
/news,/news/article-1,/news/category/tech - ❌ 不会触发:
/,/about,/contact,/archive
- ✅ 会触发:
其他 matcher 配置示例
// 匹配所有路径
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)'
// 匹配多个路径
matcher: ['/news/:path*', '/archive/:path*']
// 匹配特定文件类型
matcher: '/api/:path*'
当前配置会发生什么
在当前配置下,当用户访问任何以 /news 开头的路径时:
- 请求拦截: Next.js 会在渲染页面之前调用 middleware 函数
- 日志输出: 控制台会打印出完整的请求对象信息
- 请求继续:
NextResponse.next()让请求继续到目标页面 - 性能影响: 轻微的性能开销,因为每次访问
/news路径都会执行中间件逻辑
实际应用场景
这种中间件配置常用于:
- 访问日志: 记录用户对新闻页面的访问
权限检查: 验证用户是否有权访问新闻内容- A/B 测试: 根据用户特征重定向到不同版本的新闻页面
- 地理位置重定向: 根据用户位置重定向到本地化的新闻内容
- 性能监控: 统计新闻页面的访问量和响应时间
[05]
开始学习数据的获取

关于backend文件夹的结构:

这里的backend是在src文件夹当中的, 实际上是一个独立的后端项目,使用了express框架和better-sqlite3,创建api服务和数据库.
关于express框架
Express 是一个非常流行的 Node.js Web 应用程序框架。让我来详细解释一下:
Express 是什么
Express.js 是一个轻量级、灵活的 Node.js Web 应用程序框架,它提供了一系列强大的功能来构建 Web 应用程序和 API。
主要特点
- 轻量级:最小化的核心功能,可以通过中间件扩展
- 灵活:支持多种应用程序架构和设计模式
- 中间件支持:丰富的中间件生态系统
- 路由系统:强大的路由功能
- 模板引擎:支持多种模板引擎
在backend/app.ts中的作用:
import express from 'express'; // 导入 Express 框架
const app = express(); // 创建一个 Express 应用实例,这个实例就是你的 Web 服务器
app.use(cors()) // 使用 CORS 中间件,允许跨域请求
/*
* 路由处理
* 定义当客户端访问 GET /news 时的处理逻辑
* - req 是请求对象,包含请求信息
* - res 是响应对象,用于发送响应
*/
app.get('/news', (req, res) => { // 定义 GET 路由
const news = db.prepare('SELECT * FROM news').all();
res.json(news); // 返回 JSON 响应
});
app.listen(8080); // 启动服务器,让服务器开始监听 8080 端口的请求
实际作用
在你的应用中,Express 创建了一个 HTTP 服务器,它:
- 监听端口 8080
- 提供
/newsAPI 端点:返回数据库中的所有新闻数据 - 支持跨域请求:通过 CORS 中间件
- 返回 JSON 格式数据:便于前端应用消费
使用场景
Express 通常用于:
- RESTful API 开发
- Web 应用程序后端
- 微服务架构
- 实时应用程序
- 静态文件服务
你的代码实际上创建了一个简单的新闻 API 服务器,前端应用可以通过 http://localhost:8080/news 来获取新闻数据。
[!CAUTION]
注意,我们的项目依赖中安装了better-sqlite3,需要编译本地二进制文件,
better-sqlite3的本地二进制文件缺失的问题。这是一个常见的问题,通常发生在包的本地编译没有正确完成时。
安装的时候,通常会有一个warning,此时只要:
pnpm approve-builds
然后正常npm start即可,npm start在backend中其实就是node app.js
backend跑起来之后:

此时通过:localhost:8080/news/就可以访问到数据库中的信息:

在组件中获取数据的方法
此时在newsPage中获取这个数据库中数据的方法:
第一种:
运行独立的后端获取数据,在客户端组件中的“传统”获取数据的方法:
"use client";
import { useEffect, useState } from "react";
import NewsList from "@/components/news/news-list";
export default function NewsPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
const [news, setNews] = useState();
useEffect(() => {
async function fetchNews() {
setIsLoading(true);
const response = await fetch("http://localhost:8080/news");
if (!response.ok) {
setError("Failed to fetch news.");
setIsLoading(false);
}
const news = await response.json();
setIsLoading(false);
setNews(news);
}
fetchNews();
}, []);
if (isLoading) {
return <p>Loading...</p>;
}
if (error) {
return <p>{error}</p>;
}
let newsContent;
if (news) {
newsContent = <NewsList news={news} />;
}
return (
<>
<h1>News Page</h1>
{newsContent}
</>
);
}
但是以上的方式不是在nextjs中获取数据的最佳方式,而且此时如果你在浏览器查看源代码的话,搜索news的标题内容什么的,你是看不到内容的,原因:🧐?
第二种:
以下是nextjs中获取数据的方式,可以在“浏览源代码”中看见,也便于seo,是在服务端组件中运行的:
import NewsList from "@/components/news/news-list";
export default async function NewsPage() {
const response = await fetch("http://localhost:8080/news");
if (!response.ok) {
throw new Error("Failed to fetch news.");
}
const news = await response.json();
let newsContent;
// 这里并不需要,因为当前的服务端组件是一个异步组件,已经在上面await news了,也就是说,只要没有得到news的数据,下面的jsx是不会出现的,所以下面就没有必要进行判断news是否存在了
// if (news) {
// newsContent = <NewsList news={news} />;
// }
return (
<>
<h1>News Page</h1>
<NewsList news={news} />
</>
);
}
第三种:
下面的方式更好,无需额外的http往返,直接将数据库文件放到项目的根目录中,我们就能在服务端组件中直接访问到数据库,适合有数据源的情况(也就是有数据库文件):
news/page.tsx
import NewsList from "@/components/news/news-list";
import { getAllNews } from "@/lib/news";
export default async function NewsPage() {
const news = getAllNews(); // 只能在服务端组件上执行,因为只有服务端组件能够直接访问数据库
return (
<>
<h1>News Page</h1>
<NewsList news={news} />
</>
);
}
getAllNews()
import sql from "better-sqlite3";
import { DUMMY_NEWS } from "@/dummy-news";
const db = sql("data.db");
interface newsType {
id: string;
slug: string;
title: string;
image: string;
date: string;
content: string;
}
export function getAllNews() {
const news = db.prepare("SELECT * FROM news").all();
return news as newsType[];
}
第三种方式其实算是最好的方式。
假如现在我们要给getAllNews添加一个人为模拟的等待时间,因为接口请求可能需要一定的时间,不一定是瞬间相应的,此时:
export async function getAllNews() {
const news = db.prepare("SELECT * FROM news").all();
await new Promise((resolve) => setTimeout(resolve, 2000)); // 模拟加载时间
return news as newsType[];
}
由于这个时候getAllNews是async,所以返回的是一个Promise,那么相应的也要为使用到这个函数的服务端组件添加await:
export default async function NewsPage() {
const news = await getAllNews(); 👈👈👈👈👈👈👈
return (
<>
<h1>News Page</h1>
<NewsList news={news} />
</>
);
}
但是这个时候进入news页面就会卡住2s,体验非常不好:

可以很明显的看到,卡住了一下,此时如果想解决这种情况,使用next的loading.tsx就好了:
export default function NewLoading() {
return <p>Loading...</p>;
}
新建一个loading:

此时的效果:

可以看到,现在可以立即进入news页面,不会被卡住,加载的期间会显示loading字样,避免多余的页面卡顿,体验极好。
如果并行路由内也需要用这种异步获取数据的情况,并行路由的loading.tsx是无效的,要用Suspense:
import NewsList from "@/components/news/news-list"
import { getLatestNews } from "@/lib/news"
import { Suspense } from 'react'
async function LatestNewsContent() {
const latestNews = await getLatestNews()
return <NewsList news={latestNews} />
}
export default function LatestPage() {
return (
<>
<h2>Latest News</h2>
<Suspense fallback={<p>Latest News Loading...</p>}> 👈 手动使用Suspense
<LatestNewsContent />
</Suspense>
</>
)
}
但是如果此时用archive这个父级的loading,也就是和layout同一级的loading,是可以接管archive的所有并行路由的loading的。
[!TIP]
由此引出
nextjs的loading page和react的Suspense之间的区别
Next.js Loading Page vs React Suspense
1.抽象层级的区别
Next.js Loading Page(更高层抽象)
// 文件系统路由自动处理
app/
page.tsx // 页面组件
loading.tsx // 自动作为该路由的 loading 状态
React Suspense(更底层控制)
// 需要手动包装和控制
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
2.控制粒度
Next.js Loading Page(路由级别 - 粗糙)
// loading.tsx - 只能控制整个路由的加载状态
export default function Loading() {
return <div>整个页面正在加载...</div>
}
// page.tsx - 整个页面被 loading.tsx 覆盖
export default async function Page() {
const data1 = await fetchData1(); //
const data2 = await fetchData2(); // 这些都会被统一的 loading 覆盖
const data3 = await fetchData3(); //
return <PageContent data1={data1} data2={data2} data3={data3} />
}
React Suspense(组件级别 - 精细)
页面当中的每个部分都可以精细控制加载状态,不会影响布局元素的显示
// 可以精确控制每个异步组件的加载状态
export default function Page() {
return (
<div>
<h1>页面标题</h1> {/* 立即显示 */}
<Suspense fallback={<div>用户信息加载中...</div>}>
<UserProfile /> {/* 只有这部分有加载状态 */}
</Suspense>
<Suspense fallback={<div>文章列表加载中...</div>}>
<ArticleList /> {/* 独立的加载状态 */}
</Suspense>
<div>页面底部</div> {/* 立即显示 */}
</div>
)
}
3. 工作原理
loading page:
// Next.js 内部自动生成类似这样的结构:
<Suspense fallback={<LoadingComponent />}>
<PageComponent />
</Suspense>
suspense:
// 你完全控制 Suspense 边界的位置和行为
<Suspense
fallback={<CustomLoading />}
// 可以添加错误边界等高级功能
>
<YourComponent />
</Suspense>
4. 实际应用场景对比
场景1: 简单页面加载
// ✅ Next.js Loading Page 更简单
// app/products/loading.tsx
export default function Loading() {
return <ProductListSkeleton />
}
// app/products/page.tsx
export default async function ProductsPage() {
const products = await fetchProducts()
return <ProductList products={products} />
}
场景2: 复杂页面,需要分部分加载,这些加载出来的部分出现的先后顺序可能都不一样
// ✅ React Suspense 更合适
export default function ComplexPage() {
return (
<div>
<Header /> {/* 立即显示 */}
<Suspense fallback={<UserSkeleton />}>
<UserInfo /> {/* 异步加载用户信息 */}
</Suspense>
<div className="content-area">
<Suspense fallback={<PostsSkeleton />}>
<RecentPosts /> {/* 异步加载最新文章 */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<RecentComments /> {/* 异步加载最新评论 */}
</Suspense>
</div>
<Footer /> {/* 立即显示 */}
</div>
)
}
5. 性能和用户差异
loading page
// 用户看到:
// 1. 空白页面
// 2. 整个 loading 页面
// 3. 完整的页面内容(一次性显示)
// 时间线:
// 0s: 空白
// 0s-2s: Loading 页面
// 2s: 完整页面
Suspense
// 用户看到:
// 1. 页面框架立即显示
// 2. 各部分逐步加载完成
// 3. 更流畅的用户体验
// 时间线:
// 0s: 页面框架 + Loading 骨架
// 1s: 用户信息加载完成
// 1.5s: 文章列表加载完成
// 2s: 评论列表加载完成
6. 最佳实践建议
使用 Next.js Loading Page 的情况:
- ✅ 简单的页面加载
- ✅ 整个页面内容都依赖同一个数据源
- ✅ 快速开发原型
- ✅ 页面级别的加载就足够了
使用 React Suspense 的情况:
- ✅ 复杂页面需要部分加载
- ✅ 不同组件有不同的数据依赖
- ✅ 需要更好的用户体验
- ✅ 需要精确控制加载行为
7. 混合使用策略
// 可以同时使用两者!
// app/dashboard/loading.tsx - 作为后备
export default function DashboardLoading() {
return <div>仪表板加载中...</div>
}
// app/dashboard/page.tsx - 内部使用 Suspense 精细控制
export default function Dashboard() {
return (
<div>
<DashboardHeader />
<div className="grid">
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</div>
)
}
总结
- Next.js Loading Page: 路由级别,简单易用,适合简单场景
- React Suspense: 组件级别,精细控制,适合复杂场景
选择建议:
- 🔥 简单页面 → Next.js Loading Page
- 🚀 复杂页面 → React Suspense
- 💪 最佳方案 → 两者结合使用
[06]
next-data-mutaion中,三个数据库表的建立过程
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
first_name TEXT,
last_name TEXT,
email TEXT
)`);
db.exec(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY,
image_url TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
db.exec(`
CREATE TABLE IF NOT EXISTS likes (
user_id INTEGER,
post_id INTEGER,
PRIMARY KEY(user_id, post_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE
)`);
-
首先是
users表字段解析:
id INTEGER PRIMARY KEY:- 作用:用户的唯一标识符
- 为什么用 INTEGER:整数类型查询速度快,占用空间小
- 为什么是 PRIMARY KEY:确保每个用户都有唯一的ID,自动创建索引,提高查询性能
- SQLite特性:INTEGER PRIMARY KEY 会自动递增(AUTOINCREMENT)
first_name TEXT和last_name TEXT:- 作用:存储用户的姓名
- 为什么分开存储:便于按姓或名进行搜索和排序
- TEXT类型:可变长度字符串,节省空间
email TEXT:- 作用:用户的邮箱地址
- 用途:登录验证、找回密码、发送通知等
-
其次是
posts表字段解析:
id INTEGER PRIMARY KEY:帖子的唯一标识符,同上image_url TEXT NOT NULL:- 作用:存储帖子的图片URL
- NOT NULL:每个帖子必须有图片,体现了这是一个图片为主的社交应用
title TEXT NOT NULL和content TEXT NOT NULL:- 作用:帖子的标题和内容
- NOT NULL:确保帖子有基本信息
created_at TEXT DEFAULT CURRENT_TIMESTAMP:- 作用:记录帖子创建时间
- DEFAULT CURRENT_TIMESTAMP:如果插入时不指定时间,自动使用当前时间
- 用途:按时间排序、显示发布时间
user_id INTEGER:- 作用:外键,指向发布这个帖子的用户
- 为什么不是 NOT NULL:理论上应该是 NOT NULL,这里可能是设计上的疏漏
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE:- 外键约束:确保 user_id 必须存在于 users 表中
- ON DELETE CASCADE:当用户被删除时,该用户的所有帖子也会被自动删除
- 数据一致性:防止出现"孤儿帖子"(没有对应用户的帖子)
-
最后是
likes表设计解析:
user_id INTEGER和post_id INTEGER:- 作用:记录哪个用户点赞了哪个帖子
- 多对多关系:一个用户可以点赞多个帖子,一个帖子可以被多个用户点赞
PRIMARY KEY(user_id, post_id):- 复合主键:用户ID和帖子ID的组合作为主键
- 防止重复点赞:同一个用户不能对同一个帖子点赞多次
- 唯一性约束:数据库层面保证数据完整性
- 两个外键约束:
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADEFOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE- 作用:确保点赞记录中的用户和帖子都真实存在
- CASCADE删除:用户或帖子被删除时,相关的点赞记录也会被删除
表之间的关系图:
users (1) -----> (N) posts | | | | (N) (N) | | +---> likes <-------+关系说明:
users → posts:一对多关系- 一个用户可以发布多个帖子
- 一个帖子只能属于一个用户
users → likes:一对多关系- 一个用户可以点赞多个帖子
- 一个点赞只能属于一个用户
posts → likes:一对多关系- 一个帖子可以被多个用户点赞
users ↔ posts(通过likes):多对多关系- 通过 likes 表建立用户和帖子之间的点赞关系
[!IMPORTANT]
这里引入例子来帮助说明这个
一对多和多对多的关系一对多关系(1:N)生活中的例子
想象一下:
- 一个作者可以写多本书,但每本书只有一个作者
- 一个班主任可以管理多个学生,但每个学生只有一个班主任
- 一个用户可以发布多个帖子,但每个帖子只属于一个用户
在我们的数据库中:
users → posts让我用具体数据来展示:
users 表:
id | first_name | last_name | email ---|------------|-----------|------------------ 1 | John | Doe | john@example.com 2 | Max | Schwarz | max@example.composts 表:
id | title | content | user_id ---|---------------|--------------|-------- 1 | 我的第一篇帖子 | 大家好! | 1 2 | 美食分享 | 今天的午餐 | 1 3 | 旅行日记 | 北京之旅 | 1 4 | 编程心得 | 学习Python | 2关系解释:
John (user_id=1)发布了3个帖子 (post_id: 1,2,3)Max (user_id=2)发布了1个帖子 (post_id: 4)- 每个帖子的
user_id只能指向一个用户
为什么是一对多?
- 一个用户 → 多个帖子 ✅
- 一个帖子 → 一个用户 ✅
多对多关系(M:N)生活中的例子
想象一下:
- 一个学生可以选修多门课程,一门课程可以被多个学生选修
- 一个演员可以参演多部电影,一部电影可以有多个演员
- 一个用户可以点赞多个帖子,一个帖子可以被多个用户点赞
在我们的数据库中:
users ↔ posts(通过likes表)继续用上面的数据,加上点赞:
likes 表:
user_id | post_id --------|-------- 1 | 4 (John 点赞了 Max 的帖子) 2 | 1 (Max 点赞了 John 的第一篇帖子) 2 | 2 (Max 点赞了 John 的美食分享) 2 | 3 (Max 点赞了 John 的旅行日记)关系解释:
- John (user_id=1):点赞了 1 个帖子
- Max (user_id=2):点赞了 3 个帖子
- 帖子1:被 1 个用户点赞 (Max)
- 帖子2:被 1 个用户点赞 (Max)
- 帖子3:被 1 个用户点赞 (Max)
- 帖子4:被 1 个用户点赞 (John)
为什么是多对多?
- 一个用户 → 多个帖子(点赞) ✅
- 一个帖子 → 多个用户(点赞) ✅
为什么需要中间表(likes)?如果没有中间表会怎样?
错误方案1:在 users 表中加 liked_posts 字段
id | name | liked_posts ---|------|------------- 1 | John | "4" 2 | Max | "1,2,3"问题: 违反了数据库规范化原则,难以查询和维护
错误方案2:在 posts 表中加 liked_by_users 字段
id | title | liked_by_users ---|-------|--------------- 1 | 帖子1 | "2" 2 | 帖子2 | "2"问题: 同样违反规范化,数据冗余
正确方案:使用中间表 likes
user_id | post_id --------|-------- 1 | 4 2 | 1 2 | 2 2 | 3优势:
- 数据规范化:每个点赞关系占一行
- 易于查询:可以轻松统计点赞数、查找谁点赞了什么
- 易于维护:添加/删除点赞只需操作一行数据
- 可扩展:将来可以加字段(如点赞时间)
在代码中如何体现这些关系
查询一对多关系// 查询某个用户的所有帖子 const userPosts = db.prepare(` SELECT * FROM posts WHERE user_id = ? `).all(userId);查询多对多关系// 查询某个用户点赞的所有帖子 const likedPosts = db.prepare(` SELECT posts.* FROM posts INNER JOIN likes ON posts.id = likes.post_id WHERE likes.user_id = ? `).all(userId); // 查询某个帖子被哪些用户点赞 const usersWhoLiked = db.prepare(` SELECT users.* FROM users INNER JOIN likes ON users.id = likes.user_id WHERE likes.post_id = ? `).all(postId);总结
一对多(1:N):- 直接在"多"的一方存储"一"的外键
- 例:posts 表中的 user_id
多对多(M:N):- 必须使用中间表
- 中间表存储两个外键的组合
- 例:likes 表存储 user_id 和 post_id
设计优势
- 数据规范化:避免数据冗余,每个信息只存储一次
- 引用完整性:外键约束确保数据一致性
- 级联删除:自动维护数据关系,避免孤儿记录
- 性能优化:主键自动创建索引,查询效率高
- 扩展性好:可以轻松添加新功能(如评论、分享等)
这种设计是社交媒体应用的经典模式,能够高效地处理用户、内容和互动关系。

Server Action
这是一个react就支持的功能,并不是nextjs独有的。但是直接在react项目中使用可能是没有用的,因为这是服务端的功能,要通过nextjs解锁这个功能。
所以我们现在来创建一个action来处理表单的提交:


如果你像上面这样做,是不可以的,浏览器会报错:

因为直接在form中传递函数只在客户端组件中生效,这里由于是服务端组件,是会报错的。
那么要如何解决呢?
一个最简单的改法就是:
async function createPost(formData){
'use server'
const title = formData.get('title')
const image = formData.get('image')
const content = formData.get('content')
console.log('title:',title)
console.log('image:',image)
console.log('content:',content)
}
- 这个函数必须是async
- 必须在函数的首行添加
use server
这样,你会在终端看到:

其中,title,image,content就是浏览器中填入的数据,它在终端输出,说明这个函数是在服务端执行的,没有问题。
[!TIP]
首先,我们引入对
useActionState钩子的辨析,很显然,它必须用于客户端组件中
const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);
在组件的顶层调用 useActionState 即可创建一个随 表单动作被调用 而更新的 state。在调用 useActionState 时在参数中传入现有的表单动作函数以及一个初始状态,无论 Action 是否在 pending 中,它都会返回一个新的 action 函数和一个 form state 以供在 form 中使用。这个新的 form state 也会作为参数传入提供的表单动作函数。
[!IMPORTANT]
我这里来提供一个说人话的
useActionState的执行过程,保证你一看就懂,请看:在
[09]的项目中,我们刚开始学习Authentication,有一个登录页面,其中在Create Account的时候,涉及到了Server Action,我们先简单看一下代码实现:
actions/auth.js"use server"; export async function signup(prevState, formData) { console.log("prevState:", prevState); // 👀 输出上一次表单的state const email = formData.get("email"); const password = formData.get("password"); let errors = {}; if (!email.includes("@")) { errors.email = "Please enter a valid email address."; } if (password.trim().length < 8) { errors.password = "Password must be at least 8 characters long."; } console.log("errors is:", errors); if (Object.keys(errors).length > 0) { return { errors: errors }; } // 存到数据库中 }
auth-form.js"use client"; import Link from "next/link"; import { useActionState } from "react"; import { signup } from "@/actions/auth"; export default function AuthForm() { const [formState, formAction] = useActionState(signup, {}); // 👈👀 请看这里 return ( <form id="auth-form" action={formAction} > <div> <img src="/images/auth-icon.jpg" alt="A lock icon" /> </div> <p> <label htmlFor="email">Email</label> <input // type="email" name="email" id="email" /> </p> <p> <label htmlFor="password">Password</label> <input type="password" name="password" id="password" /> </p> {formState.errors && ( <ul id="form-errors"> {Object.keys(formState.errors).map((error, index) => ( <li key={index}>{formState.errors[error]}</li> ))} </ul> )} <p> <button type="submit">Create Account</button> </p> <p> <Link href="/">Login with existing account.</Link> </p> </form> ); }3次点击提交后的执行结果,第一次两个都输入错误(邮箱没有@符号,密码输入为空),第二次邮箱输入正确,但是密码为空,第三次只有密码正确。看图:
执行过程简述:
当你第一次点击
Create Account这个submit按钮进行提交的时候,由于form表单使用的是signup这个action函数,我们就会跳到action函数中去,并执行它,首先可以看到要输出prevState,由于是第一次执行,所以state被赋予初始值{}(这个{}是我们在useActionState的第二个参数中手动传递的,因为我们action函数中会返回一个对象,所以初始值要传递一个{}),我们可以看到上图的第一次提交部分,prevState输出为{},这其实也很好理解,第一次提交嘛,上一次当然没有state咯,所以就为初始值。此时由于第一次提交的时候,我们两个input的输入都是有问题的,所以errors里面有两条错误信息,这两条错误信息会被返回,页面上就会显示两行li。 当你第二次点击提交按钮的时候,此时邮箱我们输入正确了(有@符号),但是密码我们让它为空,也就是输错了,此时我们会发现,
prevState输出为有两条错误信息的对象,这很好理解,因为我们上一次的action函数的返回值就是包含有两条错误信息的对象,所以useActionState的上一次的state当然就是包含有两条错误信息的对象咯,然后由于邮箱输入正确,所以此时的errors所在的对象就只有一条错误信息了。 当我们第三次提交的时候,也是类似的,此时
prevState就为上一次的action函数的返回结果,也就是只有一条包含密码错误的错误信息的对象,图中很容易能看出,而此时由于我们邮箱输入为错,密码输入正确,所以errors所在的对象就是一个包含有邮箱错误信息的对象了,并返回,同理,下一次的prevState的内容将会是包含这个邮箱错误信息的对象了。 总之就是这样,
useActionState的解构出来的数组的第一个state和这里的prevState是联通的,二者一样,第一次的值均为赋予的初始值,第一次提交之后,action函数就会有返回值了,此时state的值就会被action的返回值更新,变成一个新的表单state,其实理解起来还是很简单的。
先看官方文档中的以下代码:
import { useActionState } from "react";
async function increment(previousState, formData) {
return previousState + 1;
}
function StatefulForm({}) {
const [state, formAction] = useActionState(increment, 0);
return (
<form>
{state}
<button formAction={formAction}>+1</button>
</form>
)
}
form state 是一个只在表单被提交触发 action 后才会被更新的值。如果该表单没有被提交,该值会保持传入的初始值不变(按照上述代码中的话就是0了)。
参数
-
fn:当按钮被按下或者表单被提交时触发的函数(就是action函数了)。当action函数被调用时,该函数会接收到表单的上一个 state(初始值为传入的initialState参数,否则为上一次执行完该函数的结果)作为函数的第一个参数,余下参数为普通表单动作接到的参数(一般是formData)。说人话就是,一个fn一般就是一个
action函数,它的一般结构就是:actionFn(previousState,formData),第一次执行的时候,previousState就是useActionState传入的初始值,比如:useActionState(fn,{}),那么初始值就为{},等到下一次执行到actionFn的时候呢,previousState就为上一次actionFn返回的结果了,和reduce函数的思想有点像。 -
initialState:state 的初始值。任何可序列化的值都可接收。当 action 被调用一次后该参数会被忽略。 -
可选的
permalink:一个包含了在特定情况下(后述)表单提交后将跳转到的独立 URL 的字符串。此参数用于渐进式地增强应用了动态内容的页面(例如 feeds):如果fn是一个 服务器函数,并且表单在 JavaScript 包加载之前提交,则浏览器将导航到指定的permalinkURL,而不是当前页面的 URL。确保在目标页面上渲染相同的表单组件(包括相同的fn和permalink),以便 React 知道应如何同步状态。一旦表单被激活,此参数将不再起作用。
返回值
useActionState 返回一个包含以下值的数组,即:[state, formAction, isPending]:
- 当前的 state。第一次渲染期间,该值为传入的
initialState参数值。在 action 被调用后该值会立马变为 action 的返回值。 - 一个新的 action 函数用于在你的
form组件的action参数或表单中任意一个button组件的formAction参数中传递。这个 action 也可以手动在startTransition中调用。 - 一个
isPending标识,用于表明是否有正在 pending 的 Transition。
现在,我们来解决图片上传的问题,这次我们不把图片上传到本地的项目文件夹中(试想一下图片很大,或者图片数量很多,那么云服务器就炸了。。。),所以我们使用一个免费的图床cloudinary。然后按照项目中的cloudinary.js中的方式就可以上传图片到图床中了。
然后我们要注意到一个问题,当我们在使用form表单的action时,除非你要对表单上传的过程进行控制(比如你要根据上传的过程改变上传按钮的文本时)我们才需要使用useActionState,否则,我们可以直接定义一个async的服务端action函数,传入到form表单的action属性中,来对表单数据进行处理,请看下面这个例子:
<form
action={togglePostLikeStatus}
className={post.isLiked ? "liked" : ""}
>
<LikeButton />
</form>
LikeButton
export default function LikeButton() {
return (
<button className="like-button">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
</svg>
</button>
);
}
可以看到,LikeButton就是一个普通的按钮而已,但是它默认是可以进行提交的,所以我们可以将这个组件用form表单进行包裹,就可以使用服务端的action函数才操作form表单了。
但是现在有一个问题,请看togglePostLikeStatus函数:
export async function togglePostLikeStatus(postId: number) {
updatePostLikeStatus(postId, 2);
}
它是需要一个参数的,但是我们在form表单中是没有传递参数给它的,所以我们有以下两种办法给它传递参数:
-
使用隐藏的 input 字段(推荐)
<form action={togglePostLikeStatus} className={post.isLiked ? "liked" : ""} > <input type="hidden" name="postId" value={post.id} /> 👈👈👈👈👈👈👈👈👈 这里 <LikeButton /> </form>那在对应的action函数中,也就是
togglePostLikeStatus中,就要改成:// 如果使用隐藏 input export async function togglePostLikeStatus(formData) { const postId = formData.get('postId'); const userId = 2; // 假设当前用户ID是2 // 调用数据库函数 await updatePostLikeStatus(postId, userId); // 重新验证页面数据 revalidatePath('/'); } -
使用bind方法
<form action={togglePostLikeStatus.bind(null, post.id)} className={post.isLiked ? "liked" : ""} > <LikeButton /> </form>这种方式的action就要改成:(和之前其实没有区别,formData这里没用上)
// 如果使用 bind 方法 export async function togglePostLikeStatus(postId, formData) { const userId = 2; // 假设当前用户ID是2 // 调用数据库函数 await updatePostLikeStatus(postId, userId); // 重新验证页面数据,这样你在点赞之后,页面就会显示点赞之后的状态,不调用这个方法的话,就会有缓存,导致必须刷新页面才能看到变化 revalidatePath('/'); }
为什么推荐使用隐藏 input?
- 更标准的 HTML 表单方式
- 可以传递多个参数(可以添加更多隐藏字段)
- 更容易理解和维护
- 与传统表单处理方式一致
如果你需要传递多个参数(比如 postId 和 userId),可以这样:
<form
action={togglePostLikeStatus}
className={post.isLiked ? "liked" : ""}
>
<input type="hidden" name="postId" value={post.id} />
<input type="hidden" name="userId" value={currentUserId} />
<LikeButton />
</form>
然后在server action中:
export async function togglePostLikeStatus(formData) {
const postId = formData.get('postId');
const userId = formData.get('userId');
await updatePostLikeStatus(postId, userId);
revalidatePath('/');
}
[!TIP]
我在下面引入乐观更新😊
上面的做法是可以的,但是我们可以继续进行优化,比如使用react的乐观更新的钩子useOptimistic:
以下是posts.js中关于乐观更新的使用,我会剖析它的用法在下面:
'use client'
export default function Posts({ posts }) {
const [optimisticPosts, updateOptimisticPosts] = useOptimistic(
posts,
(previousPosts, updatedPostId) => {
const updatingPostIndex = previousPosts.findIndex(
(post) => post.id === updatedPostId
);
if (updatingPostIndex === -1) {
return previousPosts;
}
const updatingPost = { ...previousPosts[updatingPostIndex] };
// 修复:添加括号确保正确的运算顺序
updatingPost.likes = updatingPost.likes + (updatingPost.isLiked ? -1 : 1);
updatingPost.isLiked = !updatingPost.isLiked;
const newPosts = [...previousPosts];
newPosts[updatingPostIndex] = updatingPost;
return newPosts;
}
);
什么是乐观更新?
简单理解
乐观更新 = 先改界面,再告诉服务器
想象你在微信群里发消息:
- 传统方式:你打字 → 发送 → 等服务器确认 → 消息才显示 ❌ 体验差
- 乐观更新:你打字 → 消息立即显示 → 后台发送给服务器 ✅ 体验好
核心思想:假设操作会成功,先更新UI,失败了再回滚。
代码详细剖析
-
useOptimistic Hook 的使用
const [optimisticPosts, updateOptimisticPosts] = useOptimistic( posts, // 是真实的据源 (previousPosts, updatedPostId) => { // 用来更新“乐观数据”的函数 // 这里定义如何"乐观地"更新数据 } );参数解释:
posts:来自服务器的真实数据optimisticPosts:包含乐观更新的数据(用于渲染)updateOptimisticPosts:触发乐观更新的函数
-
更新逻辑分析
(previousPosts, updatedPostId) => { // 1. 找到要更新的帖子 const updatingPostIndex = previousPosts.findIndex( (post) => post.id === updatedPostId ); if (updatingPostIndex === -1) { return previousPosts; // 没找到,返回原数据 } // 2. 复制要更新的帖子(避免直接修改) const updatingPost = { ...previousPosts[updatingPostIndex] }; updatingPost.likes = updatingPost.likes + (updatingPost.isLiked ? -1 : 1); updatingPost.isLiked = !updatingPost.isLiked; // 3. 创建新的数组 const newPosts = [...previousPosts]; newPosts[updatingPostIndex] = updatingPost; return newPosts; }
完整工作流程
用户点击点赞按钮时发生了什么?
1. 用户点击 ❤️
↓
2. updatePost(postId) 被调用
↓
3. updateOptimisticPosts(postId) 立即执行
→ UI 立即更新(点赞数+1,心形变红)
↓
4. await togglePostLikeStatus(postId) 执行
→ 发送请求到服务器
↓
5. 服务器响应回来,页面重新渲染
→ 如果成功:UI 保持不变
→ 如果失败:UI 回滚到原始状态
乐观更新的优缺点
✅ 优点
- 用户体验极佳:点击立即有反馈
- 感觉更快:不用等服务器响应
- 减少加载状态:不需要那么多 loading 动画
❌ 缺点
- 可能显示错误状态:如果服务器请求失败
- 代码复杂度增加:需要处理回滚逻辑
- 可能产生困惑:用户以为操作成功了,但实际失败
何时使用乐观更新?
适合的场景:
- ✅ 点赞/收藏:失败概率低,用户操作频繁
- ✅ 简单的状态切换:开关、标记已读等
- ✅ 添加评论:用户期望立即看到自己的评论
不适合的场景:
- ❌ 支付操作:绝对不能出错
- ❌ 删除重要数据:失败后果严重
- ❌ 复杂的表单提交:验证逻辑复杂
记忆口诀
"假装成功,悄悄验证"
- 假装:立即更新UI,假装操作成功
- 成功:大概率会成功的操作才用
- 悄悄:后台静默发送请求
- 验证:服务器验证成功或失败
最佳实践
1. 简化版模板
const [optimisticData, updateOptimistic] = useOptimistic(
realData,
(prev, action) => {
// 返回乐观更新后的数据
return updateDataOptimistically(prev, action);
}
);
// 使用时
async function handleAction(actionData) {
updateOptimistic(actionData); // 立即更新UI
await serverAction(actionData); // 发送到服务器
}
2. 错误处理
async function handleAction(actionData) {
updateOptimistic(actionData);
try {
await serverAction(actionData);
} catch (error) {
// 可以选择显示错误提示
toast.error("操作失败,请重试");
// useOptimistic 会自动回滚到原始状态
}
}
总结
乐观更新的本质就是:相信用户的操作会成功,先给用户看到成功的结果,再去验证是否真的成功。
这就像你在ATM取钱时,机器先吐钱给你,再去银行系统扣款一样 - 因为失败的概率很低,所以可以这样优化用户体验!
对于上面的叙述,引入一个问题🧐:
上面说的最佳实践的简化的模板中的optimisticData,是不是就是jsx中我们需要使用的乐观数据?还有,假如在上面你说的错误处理中,serverAction出错了的话,useOptimistic是怎么自动回滚到初始状态的呢?
1. optimisticData 就是JSX中使用的数据
是的! optimisticData 就是你在JSX中渲染的数据。
const [optimisticPosts, updateOptimistic] = useOptimistic(posts, updateFn);
return (
<ul>
{optimisticPosts.map(post => ( // ← 用这个,不是原始的 posts
<li key={post.id}>{post.title}</li>
))}
</ul>
);
2. useOptimistic 如何自动回滚?
关键机制:React 重新渲染时会重置乐观状态
// 当 serverAction 出错时:
async function handleAction() {
updateOptimistic(newData); // 1. 乐观更新UI
try {
await serverAction(); // 2. 发送请求
} catch (error) {
// 3. 这里不需要手动回滚!也就是说我可以什么都不用做
// 4. 因为父组件会重新渲染
// 5. useOptimistic 会重新初始化为原始的 posts 数据
}
}
原理:
useOptimistic的第一个参数是"真实数据源"- 当组件重新渲染时,如果没有新的乐观更新,就会
自动回到真实数据 - 服务器请求失败 → 页面重新渲染 → 乐观状态
自动重置
就像一张草稿纸,每次重新开始写字时都会擦干净重来!
乐观更新完
此时,如果你进行打包,然后使用npm start查看生产环境的应用的时候,你会发现,你创建了帖子之后,并没有显示新的帖子,看下面的gif:
这是因为nextjs进行了相当激进的缓存,你的页面都是预先渲染的,所有的变化都不会马上体现出来,数据的变化不会被检测出来,这非常不好。而且有一个神奇的情况,当你对一个帖子点赞的时候,那个已经被添加的却没有显示的帖子出现了,这是因为你对帖子点赞的时候,使用了 revalidatePath("/feed");,清空了缓存,所以新帖子当然也就出现了,但是必须要点个赞才可以,所以我们可以在create post重定向之后,手动revalidatePath一下,就可以解决这个问题。也就是:
[07]
caching
next的缓存和版本是有关系的,next14的版本做出了最激进的缓存,15的话会不那么激进,因为有些设置发生了变化。
Next.js 通过缓存渲染工作和数据请求来提高应用程序的性能并降低成本。本页提供了对 Next.js 缓存机制的深入介绍,您可以使用的配置 API 以及它们之间的交互方式。
Next.js 缓存机制对比表格
| 缓存机制 | 缓存内容 | 缓存位置 | 目的 | 持续时间 |
|---|---|---|---|---|
| 请求记忆化 | 函数的返回值 | 服务器 | 在 React 组件树中复用数据 | 每次请求生命周期 |
| 数据缓存 | 数据 | 服务器 | 在用户请求和部署之间存储数据 | 持久(可重新验证) |
| 完整路由缓存 | HTML 和 RSC 有效载荷 | 服务器 | 降低渲染成本并提高性能 | 持久(可重新验证) |
| 路由器缓存 | RSC 有效载荷 | 客户端 | 在导航时减少服务器请求 | 用户会话或基于时间 |
原表格👇:
| Mechanism | What | Where | Purpose | Duration |
|---|---|---|---|---|
| Request Memoization | Return values of functions | Server | Re-use data in a React Component tree | Per-request lifecycle |
| Data Cache | Data | Server | Store data across user requests and deployments | Persistent (can be revalidated) |
| Full Route Cache | HTML and RSC payload | Server | Reduce rendering cost and improve performance | Persistent (can be revalidated) |
| Router Cache | RSC Payload | Client | Reduce server requests on navigation | User session or time-based |
现在让我们打开一个新的项目:next-caching,这个项目中会有一个后端, 当我们访问header中的Messsages页面的时候,我们会向backend的接口发送两个一模一样的请求:
messages/page.js:
import Messages from '@/components/messages';
export default async function MessagesPage() {
const response = await fetch('http://localhost:8080/messages', {
headers: {
'X-ID': 'page',
},
});
const messages = await response.json();
if (!messages || messages.length === 0) {
return <p>No messages found</p>;
}
return <Messages messages={messages} />;
}
messages/layout.js:
export default async function MessagesLayout({ children }) {
const response = await fetch('http://localhost:8080/messages', {
headers: {
'X-ID': 'layout',
},
});
const messages = await response.json();
const totalMessages = messages.length;
return (
<>
<h1>Important Messages</h1>
<p>{totalMessages} messages found</p>
<hr />
{children}
</>
);
}
然后你再看一下后台的接口代码:
import express from 'express';
const app = express();
app.get('/messages', (req, res) => {
const requestSource = req.headers['x-id'];
console.log(`${new Date().toISOString()}: EXECUTING /messages on backend from ${requestSource}`);
res.json([
{ id: 1, text: 'Hello World' },
{ id: 2, text: 'Another message from the separate backend' },
]);
});
app.listen(8080);
可以看到,我们分别在layout和page中都发送了同一个请求,由于接口的console中有输出时间,我们很容易就能看出来是两条单独的请求,而且每次刷新页面都会出现两次新的请求:

[!NOTE]
注意看上图,每次刷新
messages页面,都会发出两次不同的请求,你可以看出时间都是不同的,说明是两次独立的请求
事情变得有些奇怪了,不是说next可以对请求进行缓存吗?那为什么会发送两次请求呢?
其实我们还未对这种重复请求的情况进行处理,这种重复请求的情况正是我们需要避免的。但是其实这两次请求的配置是不同的,因为header不同,那么发出两次独立的请求也很正常了。如果你将这两个请求的header都去掉,那么两个请求将完全一样,next将会只发出一次请求:

如果是next14的版本,不论你怎么刷新页面,都将不再请求,而使用的缓存,这是next的服务端缓存,算是比较激进,在next15以上的版本有改动,该改动就是当你刷新页面的时候,还是会发出一次请求:

如图,我多刷新了2次。稍后解释原因。
我们除了可以使用revalidatePath来使一个页面的缓存无效外,我们在fetch请求的时候,还可以配置cache属性:

如图,使用force-cache,这是next14的默认行为,提供了一种很激进的缓存策略,请求相同就不会再次进行请求了,除非配置发生变化,而next15发生了变化:

变成了no-store,这是next15的默认行为,这也是之前刷新页面会再发送一次请求的原因,no-store,不再存储缓存的数据了。
那如果我们设置的是force-cache,要怎么让它不一直使用缓存数据,而是隔一段时间去重新请求新数据呢?可以这么做:
const response = await fetch("http://localhost:8080/messages", {
cache:'force-cache',
next: {
revalidate: 5,
},
});
配置说明
cache: 'force-cache'- 这是标准的 Web API fetch 缓存配置
force-cache表示强制使用缓存- 如果缓存中有数据,则直接使用缓存的响应,不会发起网络请求
- 如果缓存中没有数据,才会发起网络请求并缓存结果
next: { revalidate: 5 }- 这是 Next.js 特有的扩展配置
revalidate: 5表示数据的重新验证时间为 5 秒- 这意味着缓存的数据在 5 秒内被认为是"新鲜"的
- 超过 5 秒后,下次访问时会触发后台重新获取数据(ISR - Incremental Static Regeneration)
缓存策略组合效果
这两个配置组合使用实现了以下缓存行为:
-
首次访问:发起网络请求,获取数据并缓存
-
5秒内再次访问:直接返回缓存数据,不发起网络请求
-
5秒后访问:
- 立即返回缓存的数据(用户不会感到延迟)
- 同时在后台发起新的网络请求更新缓存
- 下次访问时使用更新后的数据
适用场景
这种配置适合:
- 数据更新不太频繁的场景
- 需要快速响应用户请求的场景
- 可以容忍短暂数据不一致的场景
- 想要减少服务器负载的场景
Request Memorization
Next.js 扩展了 fetch API,以自动为具有相同 URL 和选项的请求进行 缓存。这意味着您可以在 React 组件树的不同位置调用相同的 fetch 函数,而只需执行一次。

可以从图中看到,最左边的组件树中有多请求,中间是左边组件树从上往下的请求顺序,最后发出的请求只有最右边的3个。
例如,如果您需要在路由中使用相同的数据(例如在布局、页面和多个组件中),您不必在树的顶部获取数据并跨组件传递属性。相反,您可以在需要数据的组件中获取数据,而无需担心为相同数据在网络中多次请求所带来的性能影响。说人话就是:你没必要小心翼翼担心额外的网络请求带来的性能影响,想要获取数据,可以直接用网络请求去拿数据,而不用在某一个顶级组件中请求到数据后进行跨组件传递,因为即使你请求了再多次,也是变成一次,因为请求被缓存了。
例如:
async function getItem() {
// `fetch` 函数会被自动记忆,结果会被缓存
const res = await fetch('https://.../item/1')
return res.json()
}
// getItem函数被调用了2次,但是只会在第一次执行
const item = await getItem() // 缓存未命中,此时执行这个请求,也就是执行第一次的请求,拿到数据之后,数据会缓存到内存中
// 第二次调用可以出现在页面中的任何位置
const item = await getItem() // 缓存命中,此时是第二次请求,由于已经有数据了,直接缓存命中,无需再一次发送请求
这种请求缓存是如何工作的:
- 在渲染路由时,当第一次调用特定请求时,其结果不会在内存中,因此不会被缓存。
- 因此,该函数将被执行,从外部源获取数据,并将结果存储在内存中。(
这很好理解,第一次没有数据当然要去获取了,获取了之后才可以缓存嘛) - 同一渲染传递中对该请求的后续函数调用将被缓存
HIT,并且数据将从内存中返回而无需执行函数。 - 一旦路由渲染完成且渲染过程结束,内存将被“重置”,所有请求缓存条目都将被清除。
注意事项:
- 请求缓存是 React 的一个特性,而不是 Next.js 的特性。这里包含它是为了展示它与其他缓存机制的交互方式。
- 缓存仅适用于
GET方法的fetch请求。 - 备忘录只适用于 React 组件树,这意味着:
- 它适用于
fetch中的generateMetadata、generateStaticParams、布局、页面和其他服务器组件的请求。 - 它不适用于路由处理程序(Route Handlers)中的
fetch请求,因为这些请求不属于React组件树的一部分。
- 它适用于
- 对于
fetch不适用的情况(例如某些数据库客户端、CMS客户端或GraphQL客户端),您可以使用React缓存功能(react-cache-function)来缓存函数。这一点,在下面的Data Cache中有详细论述
请求缓存的持续时间(Duration)
缓存持续到服务器请求结束,直到 React 组件树渲染完成。
重新验证(revalidating)
由于请求缓存不会在服务器请求之间共享,并且仅在渲染期间适用,因此无需重新验证它。
建议
备忘录功能仅适用于GET方法的fetch请求,其他方法,如POST和DELETE,不会被缓存。此默认行为是React的一种优化措施,我们不建议放弃使用。
要管理个别请求,您可以使用signal属性来自AbortController。
const { signal } = new AbortController()
fetch(url, { signal })
Data Cache
当我们不再使用网络请求去访问后端,而是访问一个项目中存在的数据库的时候,也就是说,我们的数据是实时查询数据库获得的时候,是不是也需要缓存呢?答案是无疑的,我们可以看一下以下例子:
messages.js
import sql from 'better-sqlite3';
const db = new sql('messages.db');
function initDb() {
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
text TEXT
)`);
}
initDb();
export function addMessage(message) {
db.prepare('INSERT INTO messages (text) VALUES (?)').run(message);
}
export function getMessages() {
console.log('Fetching messages from db');
return db.prepare('SELECT * FROM messages').all();
}
这是一个创建了数据库并提供了两个数据库查询函数的js文件,我们再来看一下此时不请求后端后是怎么获取数据的:
messages/page.js
import { getMessages } from "@/lib/messages";
import Messages from "@/components/messages";
export default async function MessagesPage() {
const messages = getMessages();
if (!messages || messages.length === 0) {
return <p>No messages found</p>;
}
return <Messages messages={messages} />;
}
messages/layout.js
import { getMessages } from "@/lib/messages";
export default async function MessagesLayout({ children }) {
// 改为从数据库中获取数据
const messages = getMessages();
const totalMessages = messages.length;
return (
<>
<h1>Important Messages</h1>
<p>{totalMessages} messages found</p>
<hr />
{children}
</>
);
}
由于布局文件会包含messages/page,所以当我们刷新messages页面的时候,就会查询两次数据库,两次查询都是完全相同的,所以这是不必要的性能浪费:

那么要如何优化呢?
此时我们要使用react的cache函数,在messages.js中作出如下改动:
import {cache} from 'react'
export const getMessages = cache(function getMessages() {
console.log("Fetching messages from db");
return db.prepare("SELECT * FROM messages").all();
});
此时,刷新messages页面,相同的查询就只会有一次了:

除了从react中导入的cache函数外,还有一个函数可以使用:
import { unstable_cache } from 'next/cache'
[!WARNING]
警告: 当此 API 达到稳定状态时,将被use-cache替代。
unstable_cache 允许您缓存昂贵操作的结果,如数据库查询,并在多个请求中重复使用。
我们有必要辨明unstable_cache和cache的区别
1. React 的 cache 函数(当前使用的)
import { cache } from "react";
export const getMessages = cache(function getMessages() {
console.log("Fetching messages from db");
return db.prepare("SELECT * FROM messages").all();
});
特点:
- 请求级缓存:缓存只在单个请求生命周期内有效
- 去重功能:同一个请求中多次调用相同函数,只执行一次
- 自动清理:请求结束后缓存自动清理
- 适用场景:避免在同一个请求中重复执行相同的数据库查询
2. Next.js 的 unstable_cache 函数
import { unstable_cache } from "next/cache";
export const getMessages = unstable_cache(
function getMessages() {
console.log("Fetching messages from db");
return db.prepare("SELECT * FROM messages").all();
},
['messages'], // 缓存键
{
revalidate: 60, // 60秒后过期
tags: ['messages'] // 用于手动失效缓存
}
);
特点:
- 跨请求缓存:缓存可以在多个请求之间共享
- 持久化缓存:数据会被持久化存储(文件系统或其他存储)
- 可配置过期时间:通过
revalidate控制缓存过期 - 支持标签系统:可以通过标签批量失效相关缓存
- 手动失效:可以使用
revalidateTag()主动清除缓存
主要区别对比
| 特性 | React cache | Next.js unstable_cache |
|---|---|---|
| 缓存范围 | 单个请求内 | 跨请求、跨用户 |
| 持久化 | 否 | 是 |
| 配置选项 | 无 | 支持过期时间、标签等 |
| 性能影响 | 避免重复计算 | 显著减少数据库查询 |
| 使用场景 | 请求去重 | 数据缓存优化 |
什么叫“单个请求内大量重复调用”?
一个请求就是用户访问一次页面。在渲染这个页面的过程中,可能会多次调用同一个函数:
// 页面组件
function MessagesPage() {
const messages = getMessages(); // 第1次调用
const count = messages.length; // 这里又需要 messages
return (
<div>
<Header messageCount={getMessages().length} /> {/* 第2次调用 */}
<Sidebar recentMessages={getMessages()} /> {/* 第3次调用 */}
<MainContent messages={getMessages()} /> {/* 第4次调用 */}
</div>
);
}
同一个页面渲染过程中调用了4次 getMessages() - 这就是"单个请求内重复调用"。
“跨请求共享”又要怎么理解?
不同用户或同一用户的不同访问都能使用同一份缓存:
用户A 访问页面 → 第1个请求 → 查询数据库 → 缓存结果
用户B 访问页面 → 第2个请求 → 直接使用缓存 ✅
用户A 刷新页面 → 第3个请求 → 直接使用缓存 ✅
用户C 访问页面 → 第4个请求 → 直接使用缓存 ✅
对比两种缓存:
React cache(请求级):
请求1: 调用4次 → 第1次查数据库,后3次用内存
请求2: 调用4次 → 第1次查数据库,后3次用内存
请求3: 调用4次 → 第1次查数据库,后3次用内存
unstable_cache(跨请求):
请求1: 调用4次 → 第1次查数据库,后3次读缓存文件
请求2: 调用4次 → 4次都读缓存文件
请求3: 调用4次 → 4次都读缓存文件
所以"跨请求共享"就是不同的页面访问可以共用同一份缓存数据。
如果我们只使用unstable_cache而不使用cache,在/messages/new页面添加了message后跳转回/messages页面的时候,会调用两次数据库查询
如题,会输出两次:Fetching messages from db,说明执行了两次数据库查询。
原因分析
当您添加消息并跳转到 /messages 页面时:
- [revalidateTag("msg")](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 清除了缓存
- layout.js 调用 [getMessages()](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) → 第1次数据库查询
- [page.js](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 调用 [getMessages()](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) → 第2次数据库查询
为什么会这样?
因为 [revalidateTag()](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 清除了 [unstable_cache](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 的缓存,所以在同一个请求中:
- Layout 组件执行时缓存已被清空,需要重新查询
- Page 组件执行时,[unstable_cache](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 认为这是另一个独立的调用,再次查询
解决方案
这正是需要同时使用两个缓存的场景:
export const getMessages = unstable_cache(
cache(async function getMessages() { // 添加 React cache
console.log("Fetching messages from db");
return db.prepare("SELECT * FROM messages").all();
}),
["messages"],
{
tags: ["msg"],
}
);
[!IMPORTANT]
revalidateTag只会清除unstable_cache的缓存,而不会清除cache的缓存
revalidateTag 的作用范围
revalidateTag("msg")只清除 unstable_cache 的缓存:
- ✅ 清除持久化的跨请求缓存
- ❌ 不会清除
React的cache的请求级缓存(unstable_cache属于next/cache,而cache属于React,二者来源不同,需注意)
具体行为
当您调用 revalidateTag("msg")后跳转到 /messages 页面:
只有 unstable_cache时:
export const getMessages = unstable_cache(fn, ["messages"], { tags: ["msg"] });
// layout.js 调用 → unstable_cache 缓存被清空 → 执行数据库查询
// page.js 调用 → unstable_cache 缓存仍为空 → 再次执行数据库查询
同时使用两者时:
export const getMessages = unstable_cache(
cache(fn), // React cache 不受 revalidateTag 影响
["messages"],
{ tags: ["msg"] }
);
// layout.js 调用 → unstable_cache 缓存被清空 → 执行数据库查询 → React cache 缓存结果
// page.js 调用 → React cache 直接返回内存中的结果 → 不执行数据库查询
关键点
revalidateTag是Next.js的 API,只影响 Next.js 的缓存系统- React
cache是React的 API,在请求级别独立工作 - 两者是不同层级的缓存机制,互不干扰
这就是为什么在使用 revalidateTag 清除缓存的场景下,React cache仍然能够防止同一请求内的重复执行。
此时当我们在/messages/new页面添加了message之后,跳转回/messages页面,就不会有多余的数据库查询,每次刷新页面的时候也会使用unstable_cache的缓存了,不会有任何多余的数据库查询(不使用unstable_cache只使用cache的话,每次刷新页面将多次查询合成一次还是会执行一次数据库查询)
1.什么叫“React的cache只在请求级别工作”,怎么理解这个“请求级别”呢?它缓存的数据保存在哪里?
"请求级别"是什么?
一个请求 = 用户访问一次页面的完整过程:
用户访问 /messages 页面
↓
Next.js 开始渲染
↓
Layout 组件渲染 → 调用 getMessages()
↓
Page 组件渲染 → 调用 getMessages()
↓
HTML 返回给用户
↓
请求结束,缓存清空
缓存数据保存在哪里?
保存在服务器的内存中(JavaScript 的 Map 或类似数据结构):
// React 内部类似这样的实现
const requestCache = new Map();
function cache(fn) {
return function(...args) {
const key = JSON.stringify(args);
if (requestCache.has(key)) {
return requestCache.get(key); // 从内存读取
}
const result = fn(...args);
requestCache.set(key, result); // 存储到内存
return result;
}
}
// 请求结束时
requestCache.clear(); // 内存清空js
2.unstable_cache使用的是nextjs的缓存机制,它缓存的数据保存在哪里?
这是持久化缓存
缓存数据保存在哪里?
保存在文件系统中,具体位置:
项目根目录/.next/cache/fetch-cache/
├── [hash1].json
├── [hash2].json
└── [hash3].json
确实在.next文件夹中存在这样的json文件,我已验证过。
或者如果配置了其他存储:
- Redis(生产环境常用)
- 数据库
- 其他持久化存储
工作机制:
// Next.js 内部类似这样
function unstable_cache(fn, keyParts, options) {
return async function(...args) {
const cacheKey = generateKey(keyParts, args);
const filePath = `.next/cache/fetch-cache/${cacheKey}.json`;
// 检查文件是否存在且未过期
if (fs.existsSync(filePath) && !isExpired(filePath, options.revalidate)) {
return JSON.parse(fs.readFileSync(filePath)); // 从文件读取
}
const result = await fn(...args);
fs.writeFileSync(filePath, JSON.stringify(result)); // 写入文件
return result;
}
}
对比总结
| 特性 | React [cache](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) | Next.js [unstable_cache](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) |
|---|---|---|
| 存储位置 | 服务器内存 | 文件系统/Redis |
| 生命周期 | 单个请求 | 跨请求持久化 |
| 清理时机 | 请求结束自动清理 | 手动清理或过期 |
| 访问速度 | 极快(内存) | 较快(文件I/O) |
| 用途 | 请求内去重 | 跨请求数据缓存 |
实际例子
// 第一次访问页面
请求1: Layout → getMessages() → 查询数据库 → 存储到【文件】+ 【内存】
Page → getMessages() → 从【内存】读取
// 用户刷新页面
请求2: Layout → getMessages() → 从【文件】读取 → 存储到【内存】
Page → getMessages() → 从【内存】读取
这就是为什么两层缓存配合使用能获得最佳性能!
🧐一个新的问题
react的cache在请求结束之后,缓存会清空吗?也就是说,假如有一个a页面,有多个地方调用getMessages,由于有cache函数的存在,只会被调用一次实际上,然后我跳转到了b页面,对于a页面,请求结束了,缓存被清空了,等到我再次导航到a页面,还是会出现一次调用对吗?
完全正确!
React [cache](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) 的生命周期
每次页面访问 = 一个独立的请求
访问 A 页面 (请求1)
├── Layout 调用 getMessages() → 执行数据库查询
├── Page 调用 getMessages() → cache 返回缓存结果
├── 其他组件调用 getMessages() → cache 返回缓存结果
└── 请求结束 → 缓存清空 ❌
跳转到 B 页面 (请求2)
└── 新的请求开始 → 新的 cache 实例
再次访问 A 页面 (请求3)
├── Layout 调用 getMessages() → 重新执行数据库查询 ✅
├── Page 调用 getMessages() → cache 返回缓存结果
└── 请求结束 → 缓存清空 ❌
关键理解
- React
cache= 单次访问内的去重工具(单次访问指的是访问某个页面,也就是访问某个url) - 不是持久化缓存,不会跨页面访问保存数据
- 每次导航都是全新的开始
Full Route Cache
大致意思就是,所有页面都会预先由next进行预渲染,你在打包的时候就可以看到:

你可以看到这句话:(Static) prerendered as static content,空心的圆圈代表被预先渲染成了静态的内容,你可以看到app下的所有页面都是如此,也就是说,此时如果你运行打包后的产物,不管你怎么刷新页面,都不会向后端发送请求,因为内容是静态的,这是nextjs为了让我们更快看到内容所为之,如果你想让一个页面每次刷新都是最新的内容的话,有两个办法:1. 手动revalidatePath,2. 在页面顶部启用缓存的API,下面是启用缓存API dynamic api,也就是:export const dynamic = 'force-dynamic'的结果:

你会看到,打包的产物中出现了一个(Dynamic) server-rendered on demand,也就是服务器按需渲染。这个可以解决一个页面一直刷新而从不请求后端的情况,但是更加推荐自己手动去revalidatePath,因为这样页面还是能获得预渲染的性能,又可以按需渲染,一举两得。
关于revalidatePath的一些补充
revalidatePath('/messages'),默认的type就是page,所以这行代码只会丢弃掉特定的页面,也就是/messages这个页面,而messages嵌套的子路由均不会被丢弃缓存revalidatePath('/messages','layout'),当你手动置顶type为layout时,这就告诉了nextjs,当前的/messages是一个布局,此时它的所有子路由均会被丢弃缓存revalidatePath('/','layout'),如果是只是一个/的话,这将重新验证所有的页面,丢弃所有页面的缓存。
关于revalidateTag的使用说明
这个函数极其类似于Tanstack Query的invalidateQueries函数,作用都是让某个查询失效并重新获取新的数据,二者的用法非常类似,核心思想都是“由于用户的操作导致了数据的过时而使请求的缓存失效并重新进行获取”。
用法:
revalidateTags('msg') // 这会使一个特定的tags的fetch请求缓存失效并重新获取
fetch请求也要进行相应的配置:
const response = await fetch("http://localhost:8080/messages",{
next:{
tags:['msg']
}
});
其中的tags配置项是一个字符数组,这个数组可以有多个子项。
不同的Nextjs的缓存API
见:链接
介绍几个,都对页面的缓存有影响:
这些api都是全局作用的,就是在你的page的顶部配置,避免了一个个去配置fetch请求的配置项。
revalidate
export const revalidate = 5
dynamic
export const dynamic = 'force-dynamic'
connection()
The connection() 函数允许您指示渲染应在继续之前等待传入的用户请求。之前的next版本中使用的是:unstable_noStore
import { connection } from 'next/server'
export default async function Page() {
await connection()
// 下面的任何内容都会被排除在预渲染之外
const rand = Math.random()
return <span>{rand}</span>
}import { connection } from 'next/server'ts
[08]
optimization
首先是图片的优化,链接:🔗
原来使用img标签的时候,你需要图片的src属性:
import logo from "@/assets/logo.png";
<img
src={logo.src}
alt="Mobile phone with posts feed on it"
/>
但是使用了next的Image组件后,你就不需要src属性了:
import logo from "@/assets/logo.png";
<Image
src={logo}
alt="Mobile phone with posts feed on it"
/>
为什么要做出这样的改变?
其实这里的logo并不是一个图片的链接,它是一个对象,你可以输出这个logo对象看一下:
{
"src": "/_next/static/media/logo.6e3d55cc.png",
"width": 600,
"height": 600,
"blurWidth": 8,
"blurHeight": 8,
"blurDataURL": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAABE0lEQVR42gEIAff+AP///wD+/v8I+/n8TPTx+ZXo4O+h4dnrg9rR5Vf6+vwHAP7+/gn05O995tHm7MG12P+agrX/qIq4/c/A27X39vocAPr4/EfSxODsp5LD/4NyrP+AeLL/inez/5RyqPnXyuBxAPby+YWtosn+jmai/4eUvf+ApMb/e3Sr/4Fnpf/ZzOmuAObh7oCkibb9o2Gd/6Nxp/+FgLD/aVWV/66czf7u6PibAMCuyneCV5j9kGyp/6lqo/90UJH/gnCu/8q+4Of18vpFANvX4zSUdaO6glKR+mxQj/2Zi7j6zbXa6uji8mb+/v8GAP7+/wHq6vAao5a3h5uUtZHi3e1d6+byRfz7/Qf///8AJFO6ErR7decAAAAASUVORK5CYII="
}
它是一个StaticImageData类型的,你把鼠标悬浮在导入的logo上面就能看到它的类型是这个:
export interface StaticImageData {
src: string;
height: number;
width: number;
blurDataURL?: string;
blurWidth?: number;
blurHeight?: number;
}
它会自动有一个宽和高,next帮你计算好的,不然的话Image组件是需要指定宽高的,宽和高很重要,能够让图片有空间可用,防止布局偏移。事实上,你可以看到这个Image组件会渲染成下面那样,多出了很多属性,包括lazy,width,height等等:

而且原本的图片是png的,next会自动帮我们渲染成webp的,大大减少了传输的大小。
如果是header中的logo,应该添加一个priority属性,确保其始终可以加载,会提前加载,浏览器就不需要检查是否要加载这个图片,而不是loaidng='lazy',lazy的时候会检查,因为lazy只有在视口内才会被加载,有一个判断的逻辑。(从next16开始,priority已经被弃用,你应该使用preload属性来取代,设置为true,会自动添加一个link标签来提前加载这个图像,设置为false则不会)
怎么利用图床厂商的url来设置不同图片的尺寸呢?比如cloudinary就提供了这个功能,我们可以这样设置一个远程的图片链接的url:
function imageLoader(config) {
console.log("config:", config);
const urlStart = config.src.split("upload/")[0];
const urlEnd = config.src.split("upload/")[1];
const transformmations = `w_200,q_${config.quality}`;
return `${urlStart}upload/${transformmations}/${urlEnd}`;
}
<Image
loader={imageLoader}
src={post.image}
alt={post.title}
quality={50}
width={200}
height={120}
/>
使用loader函数来动态改造url,上面是在原本的url中插入一些参数,比如控制宽度为200,质量为Image组件中设置的50。
当你使用远程的图片链接的时候,next.config.js中要配置:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [{ hostname: "res.cloudinary.com" }],
qualities:[50,75]
},
};
export default nextConfig;
metadata
// 静态metadata
export const metadata = {
title:'Latest Posts',
description:'Browse our post'
}
// 动态metadata
export async function generateMetadata() {
const posts = await getPosts()
const numberOfPosts = posts.length
return {
title:`Browse all our ${numberOfPosts} posts.`,
description:'Browse all our posts!'
}
}
[09]
Authentication 用户认证

现在我们的项目是:next-auth,分为两个页面,一个是首页登录页,一个是training页面,training页面是一个受保护的页面,只有被认证了才可以进入。
一种不好存储密码的方式
auth.js
"use server";
import { createUser } from "@/lib/user";
export async function signup(prevState, formData) {
console.log("prevState:", prevState);
const email = formData.get("email");
const password = formData.get("password");
let errors = {};
if (!email.includes("@")) {
errors.email = "Please enter a valid email address.";
}
if (password.trim().length < 8) {
errors.password = "Password must be at least 8 characters long.";
}
console.log("errors is:", errors);
if (Object.keys(errors).length > 0) {
return { errors: errors };
}
// 存到数据库中
createUser(email, password);
}
user.js
import db from "./db";
/**
*
* @param {*} email
* @param {*} password
* @returns 这行代码的作用是:
获取新插入记录的ID:lastInsertRowid 是 SQLite 数据库的一个属性,它包含了最后一次 INSERT 操作自动生成的行 ID。即:返回新创建用户的唯一标识符(ID)
返回给调用者:将这个 ID 返回给调用 createUser 函数的代码
*/
export function createUser(email, password) {
const result = db
.prepare("INSERT INTO users (email,password) VALUES(?,?)")
.run(email, password);
return { success: true, id: result.lastInsertRowid };
}
可以看到,我们将密码明文存储在了数据库中,这显然是不可取的,我们需要对密码进行加密,否则任何可以查看数据库的人都能知道任何其他人的密码了,如果密码泄露了的话,后果不堪设想。
于是我们就要解决这个问题。
hash.js
import crypto from 'node:crypto';
export function hashUserPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hashedPassword = crypto.scryptSync(password, salt, 64);
return hashedPassword.toString('hex') + ':' + salt;
}
然后包裹密码即可:
createUser(email, hashUserPassword(password));
注册函数signup
完整signup函数:
export async function signup(prevState, formData) {
console.log("prevState:", prevState);
const email = formData.get("email");
const password = formData.get("password");
let errors = {};
if (!email.includes("@")) {
errors.email = "Please enter a valid email address.";
}
if (password.trim().length < 8) {
errors.password = "Password must be at least 8 characters long.";
}
console.log("errors is:", errors);
if (Object.keys(errors).length > 0) {
return { errors: errors };
}
// 存到数据库中,用try-catch包裹,因为如果我们违反了唯一性约束的话,就会报错
try {
createUser(email, hashUserPassword(password));
} catch (error) {
console.error("Database error:", error);
if (error.code === "SQLITE_CONSTRAINT_UNIQUE") {
return {
errors: {
email:
"It seems like an account for the chosen email already exists.",
},
};
} else if (error.code === "SQLITE_ERROR") {
return {
errors: {
general: "Database setup error. Please contact administrator.",
},
};
} else {
return {
errors: {
general: `Database error (${error.code}): ${error.message}`,
},
};
}
}
redirect("/training");
}
授权
我们要确保如果用户没有登陆,就不可以访问/training页面,要怎么做呢?

那么用户登陆的时候,就会有以下过程,第一部分:User Login:

第二部分:Access Protected Resources

以上两部分就是身份验证要做的事情。
较为受欢迎的一个可以和nextjs结合使用,用来身份验证的库,叫做:Lucia Auth,还有一个库也很流行,叫做:NextAuth.js
更加推荐用Lucia,这个库中说Auth.js很臃肿。
以下是使用Lucia创建session的一个函数:
import { Lucia } from "lucia";
import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import db from "./db";
import { cookies } from "next/headers";
const adapter = new BetterSqlite3Adapter(db, {
user: "users",
session: "sessions",
});
const lucia = new Lucia(adapter, {
sessionCookie: {
expires: false,
attributes: {
secure: process.env.NODE_ENV === "production",
},
},
});
export async function createAuthSession(userId) {
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(
sessionCookie.name, // cookie的名称(通常是lucia生成的标准名称)
sessionCookie.value, // cookie的值(包含session ID的加密字符串)
sessionCookie.attributes // cookie的属性(过期时间、域名、安全标志等)
);
}
几个疑问:
-
为什么需要创建session?为什么用lucia创建session?这个session存储在什么位置?
-
为什么需要创建session?
- 无状态HTTP问题:HTTP协议本身是无状态的,服务器无法知道两个请求是否来自同一用户
- 用户认证持久化:用户登录后,需要在多个页面/请求间保持登录状态
- 安全性:相比每次请求都发送用户名密码,session更安全
-
为什么用Lucia?
- 现代化:专为现代Web应用设计,支持TypeScript
- 安全性:自动处理session过期、CSRF保护、安全cookie设置
- 简单性:API简洁,易于使用和维护
- 灵活性:支持多种数据库适配器
-
session存储位置:存储在数据库的sessions表中(参考
db.js)
-
-
为什么已经有了session了,还要创建一个sessionCookies?有什么意义?
这涉及到客户端-服务器通信机制:
// 服务器端的session记录(存在数据库中) const session = { id: "abc123", userId: "user456", expiresAt: 1234567890, // ... 其他session数据 } // 客户端的cookie(存在浏览器中) const sessionCookie = { name: "auth_session", value: "encrypted_session_id_abc123", // 加密的session ID attributes: { httpOnly: true, // 防止XSS攻击 secure: true, // 只在HTTPS下传输 sameSite: "lax" // CSRF保护 } }分工明确:
- Session(服务器端):完整的会话数据,包含用户信息、权限等
- Cookie(客户端):只包含session ID的安全载体
-
为什么最后还要用cookies()函数的set方法?有什么用?
这是HTTP通信的关键步骤:
// 1. 服务器生成cookie const sessionCookie = lucia.createSessionCookie(session.id); // 2. 通过HTTP响应头发送给浏览器 (await cookies()).set(/* ... */); // 等同于在HTTP响应中添加: // Set-Cookie: auth_session=encrypted_value; HttpOnly; Secure; Path=/ // 3. 浏览器接收并保存cookie // 4. 后续请求自动发送cookie // Cookie: auth_session=encrypted_value完整的认证流程:
- 用户登录成功 → 创建session(数据库)
- 生成sessionCookie → 包含session ID
- 设置cookie到浏览器 →
cookies().set() - 浏览器自动在后续请求中发送cookie
- 服务器验证cookie中的session ID → 确认用户身份
🧐一个疑问
也就是说,这个createAuthSession是要在server action中被调用的,不是单独执行的。还有一件事不懂,你上面说的实际执行流程中,用户在浏览器中点击"登录"按钮,然后浏览器发送 HTTP POST 请求到服务器,却被Nextjs接收请求?我们现在在本地开发,哪来的服务器?又怎么会被nextjs接收?不是很明白nextjs与服务器有什么关系。
回答可看:这个文件
🧐另一个疑问
1.为什么服务端的组件渲染就会那么快?客户端渲染为什么就慢呢? 2.你说cookies()函数是为http请求准备响应头,怎么准备?发送http请求的时候是怎么代入到请求头中的呢?(请求头还是响应头我分不清楚) 回答整理到一个md文件中
回答可看:这个文件
我们可以通过给url添加的params来区分登陆还是注册
import AuthForm from "@/components/auth-form";
export default async function Home({ searchParams }) {
const formMode = (await searchParams).mode || "login";
return <AuthForm mode={formMode} />;
}
可以看到,我们设置了一个初始的mode,如果不存在(await searchParams).mode的话,那么formMode默认就会是login,此时,我们看一下登录表单的结构:
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { signup } from "@/actions/auth-actions";
// mode: login || signup
export default function AuthForm({ mode }) {
const [formState, formAction] = useActionState(signup, {});
return (
<form
id="auth-form"
action={formAction}
>
<div>
<img
src="/images/auth-icon.jpg"
alt="A lock icon"
/>
</div>
<p>
<label htmlFor="email">Email</label>
<input
// type="email"
name="email"
id="email"
/>
</p>
<p>
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
id="password"
/>
</p>
{formState?.errors && (
<ul id="form-errors">
{Object.keys(formState.errors).map((error, index) => (
<li key={index}>{formState.errors[error]}</li>
))}
</ul>
)}
<p>
<button type="submit">
{mode === "signup" ? "Create Account" : "Login"}
</button>
</p>
<p>
{mode === "login" && (
<Link href="/?mode=signup">Create an account</Link>
)}
{mode === "signup" && (
<Link href="/?mode=login">Login with existing account.</Link>
)}
</p>
</form>
);
}
我们会在这里进行判断,初始情况下,我们没有点击任何按钮的时候,mode就会是login,此时显示的应该是Login的button,然后按钮下面的文字应该是:Create an account,反过来同理。
那么href="/?mode=login"有什么效果呢?如下,点击一下之后url就会显示诸如:

我们使用了(auth)作为路由组,此时如果(auth)的布局如下会报错hydration error:
export default function AuthLayout({ children }) {
return (
<html lang="en">
<header id="auth-header">
<p>Welcome back!</p>
<form action={logout}>
<button>Logout</button>
</form>
</header>
<body>{children}</body>
</html>
);
}
因为你写布局的时候,header不能包含在<html></html>中,改成:
export default function AuthLayout({ children }) {
return (
<>
<header id="auth-header">
<p>Welcome back!</p>
<form action={logout}>
<button>Logout</button>
</form>
</header>
{children}
</>
);
}
