危险

为之则易,不为则难

0%

Next.js 核心

Next.js 核心,完。

关于服务端组件

基本说明

Next.js 组件有两种大的类型划分,一种是客户端组件,在使用的使用需要手动的在顶部加一个 use client,一旦这样加完之后,这就和我们传统的编写 React 组件差异不大,或者说我们可以像编写传统 React 组件那样来编写这个代码,比较特殊的是服务端组件,它只会在服务端进行执行,把执行完毕的结果再交给客户端进行渲染,咱默认情况下创建的组件其实就是服务端组件。

那么为什么要有这个服务端组件呢?

或者说使用它有怎样的一个好处呢,这儿有个文章进行了比较详细的分析,你这儿可以先理解为他是在原来这些模式上的精进,待会我在描述一些观点的时候其实也是来自于这个文章。

咱先看一下官方对它的一个说明,为什么使用服务端组件,或者使用它有那些好处?

1. …

2. …

3. 性能更好,举个例子来说,相比较早期的 SSR,早期的 SSR 也有一些问题,例如它需要整个页面都在服务器端进行渲染,客户端需要接收和执行大量的 JavaScript 用于水合整个应用程序,以激活前端交互功能

而服务端组件则是组件级别的渲染,意味着可以将部分组件标记为服务端组件,这些组件不必,或者无需在客户端水合,因为我们在编写服务端组件的时候会刻意的不让它涉及到客户端相关的能力。这样就减少了发送到客户端的 JS 量,降低了客户端的负担。

总之使用服务端组件,可以更细粒度的进行渲染控制,有一些不需要交互的组件就可以在服务端处理,而需要交互的部分(例如表单或按钮)则使用客户端组件渲染。

它还支持流式渲染(Streaming Rendering),可以逐步将 HTML 发送到客户端,从而提升页面的感知性能(页面更快可见)。

在 Next.js 中,组件默认就是服务端组件,例如咱建个页面,src\app\todo\page.tsx。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function getRandomInt(min: number, max: number) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}

export default async function Page() {
const res = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=10");
const data = (await res.json()).slice(0, getRandomInt(1, 10));
console.log(data);
return (
<ul>
{data.map(({ title, id }: { title: string; id: number }) => {
return <li key={id}>{title}</li>;
})}
</ul>
);
}

代码的具体含义就是通过发请求拿到数据,截取一部分数据,最后进行渲染。其实我告诉大家,这个代码它只会在服务端执行,然后服务端把渲染完毕后的结果再响应给客户端,由于请求是在服务端执行,那这个打印其实也只会出现在服务端的命令行中,浏览器控制台中其实是看不到的。

使用服务端组件的限制

前面咱给大家说了使用服务端组件有很多的好处,但其实除了这些好处之外呢,它在使用的时候也会有一些限制,这儿呢咱给大家举个例子进行说明,例如咱期望把前面编写的这个服务端组件代码,改造成传统的编写 React 组件那样的形式,看一下会不会有什么问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
"use client";
import { useEffect, useState } from "react";

function getRandomInt(min: number, max: number) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}

export default function Page() {
const [data, setData] = useState([]);

const fetchData = async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/todos");
const data = (await res.json()).slice(0, getRandomInt(1, 10));
setData(data);
};

useEffect(() => {
fetchData();
}, []);
return (
<>
<ul>
{data.map(({ title, id }: { title: string; id: number }) => {
return <li key={id}>{title}</li>;
})}
</ul>
<button
onClick={() => {
location.reload();
}}
>
刷新页面
</button>
</>
);
}

这时候打开浏览器看下效果,会发现发生了错误,这个意思是说我们使用了服务端组件不允许的一些操作,需要我们通过 ‘use client’ 指令把它变为一个客户端组件。那么具体是哪些操作是服务端组件所不允许写的呢?例如 useState、useEffect 这些 Hook,也给按钮添加了点击事件,并且还使用了浏览器相关的 API,其实无论使用了这些中的任何一个操作,都需要把这个组件声明为客户端组件。

所以这时候我们只需要在最顶部添加一个 ‘use client’ 指令即可。

关于服务端和客户端组件使用的时候还有哪些限制,或者说使用的时候这两者之间该如何做出选择,其实官方这儿也有个列表进行了说明,例如咱点一下这个链接,根据这个表格咱再给大家总结一下,咱先看一下需要使用客户端组件的地方,第一使用了事件绑定相关的操作,第二 … 除此之外,大家应该尽可能的使用服务端组件。

客户端组件只在客户端执行吗

前面咱给大家说了,服务端组件只会在服务端执行,这时候有同学可能会想,那是不是客户端组件也只会在客户端执行呢?其实啊并不是,每次刷新页面的时候客户端组件也会在服务端执行,而服务端执行这一次的目的是为了生成初始的内容给到客户端,为了 SSR。这又是什么意思呢?

咱同样通过代码给大家进行一个说明,这儿呢,是咱上一次写的代码,为了方便测试,咱稍微加一点东西,例如第一步加个初始内容 … 第二部在这打印一下数据和小牛牛。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
"use client";
import { useEffect, useState } from "react";

function getRandomInt(min: number, max: number) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}

export default function Page() {
// #1
const [data, setData] = useState([
{
userId: 9999,
id: 9999,
title: "Eiusmod fugiat reprehenderit ad nulla.",
completed: false,
},
]);

const fetchData = async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/todos");
const data = (await res.json()).slice(0, getRandomInt(1, 10));
setData(data);
};

// #2
console.log(data, "🤠");

useEffect(() => {
fetchData();
}, []);
return (
<>
<ul>
{data.map(({ title, id }: { title: string; id: number }) => {
return <li key={id}>{title}</li>;
})}
</ul>
<button
onClick={() => {
location.reload();
}}
>
刷新页面
</button>
</>
);
}

1. 接下来咱打开浏览器刷新一下页面,你会发现服务端也进行打印了小牛牛,也就意味着这个客户端组件其实确实也在服务端执行了,刚才给大家说了,执行这一次的目的是为了把初始数据返回到客户端,为了 SSR,给大家证明一下,这时候回到浏览器控制台,咱找到 localhost 这个请求,你会发现初始返回的数据就有 ‘Eiusmod fugiat reprehenderit ad nulla.’ 这样一段话,而这个就是 useState 的初始内容。

2. 当然再回到客户端这儿呢,你会发现它肯定也进行了打印,说明客户端组件也肯定必然的在客户端执行了。但是这儿好像打印了好多好多次,这个原因啊是我们开启了严格模式导致的,关闭严格模式的话,嗯..这儿应该打印两次才对,一次是初始的,一次是通过 setData 更新后的渲染,这儿呢大家也不用担心,项目打包上线后它就是正常的情况了。

3. 说到项目打包,其实还有一个细节,就是当我们执行 npm run build 的时候,这儿的代码执行会提前到在构建的时候,也就是说在我们执行 npm run build 的时候就会生成初始内容,例如咱执行 npm run build 给大家看一下效果,果然会发现在控制台输出了小牛牛 …说明在刚刚打包构建的时候就执行了客户端代码,输出了小牛牛。

4. 再次执行 npm start 运行打包后的代码,打开浏览器查看,你会发现这儿的打印次数和咱前面分析的一样,只有两次,这是正常的。

5. 所以最后咱总结一下,服务端代码确实只会在服务端执行(当然也包括构建的时候),而客户端代码除了会在客户端执行,还可能在服务端或者构建的时候执行。

交叉使用服务端和客户端组件

实际开发的时候,不可能全部只使用服务端或者全部只使用客户端组件,一般需要交叉使用,这个时候就有一个注意点需要大家记住,那就是:服务端组件可以引入客户端组件。但客户端组件一般不要直接引入服务端组件,注意这儿我说的是一般不要,这是为什么呢?

因为我们在组件中一旦声明了 “use client” 指令,这时候导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分,换句话说都会被打包成客户端代码。

例如这时候,当被导入的服务端组件使用了 Node 相关的 API,那被客户端组件引入后肯定就会发生错误。

咱这儿呢,同样给大家举例说明。

例如咱在首页写一个点击 +1 的案例,由于待会会用到 useState,所以咱创建一个 Client-Component,然后再首页引入这个客户端组件。

src\components\Client-Component.tsx

1
2
3
4
5
6
7
8
9
10
11
12
"use client";

import { useState } from "react";
export default function ClientComponent() {
const [count, setCount] = useState(0);

return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
</>
);
}

app/page.tsx

1
2
3
4
5
6
import React from "react";
import ClientComponent from "@/components/Client-Component";

export default function Page() {
return <ClientComponent />;
}

接下来咱再创建一个组件 Server-Component,默认情况下它就是一个服务端组件,这时候再回到 Client-Component,引入刚刚的 Server-Component,现在这种情况就是:客户端组件直接引入了服务端组件。

src\components\Server-Component.tsx

1
2
3
4
5
import React from "react";

export default function ServerComponent() {
return <div>ServerComponent</div>;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
"use client";

import { useState } from "react";
import ServerComponent from "@/components/Server-Component";
export default function ClientComponent() {
const [count, setCount] = useState(0);

return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ServerComponent />
</>
);
}

此时打开浏览器,你会发现好像也没有什么问题。

这时候咱继续的对服务端组件进行改造,例如引入 Node 的 fs api 看一下。

1
2
3
4
5
6
import React from "react";
import fs from "node:fs";
export default function ServerComponent() {
fs;
return <div>ServerComponent</div>;
}

会发现浏览器出现了报错,好了这就是咱给大家说的客户端不要引入服务端组件。

这时候你可能会想,老师那我写服务端组件的时候不涉及客户端不能使用的一些操作是不是就可以被客户端导入了,理论上来说是的,但问题是你怎么确定这个组件以后就一定不会用到服务端相关的操作或特性呢?所以官方这儿为了避免服务端代码被乱导,咱可以安装一个包做一个限制,可以在服务端组件的顶部加一个 import 'server-only',这样的话这个服务端代码就只能被服务端组件所导入了。

但开发当中还有一种可能,那就是这个服务端组件确确实实需要呈现在客户端组件中进行展示,其实也有办法解决。

那就是可以将服务端组件以 props 的形式传给客户端,这种使用方式呢,Next.js 会内部进行处理,它会把服务端代码和客户端进行隔离开,不让它一起打包到客户端。

咱给大家改造看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
"use client";

import { useState } from "react";
export default function ClientComponent({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);

return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
);
}

page.tsx

1
2
3
4
5
6
7
8
9
import ClientComponent from "@/components/Client-Component";
import ServerComponent from "@/components/Server-Component";
export default function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
);
}

好啦,这就是咱要给大家说的,服务端组件和客户端组件交叉使用时候的一些注意点。

最佳实践

服务端组件

1. 共享数据

多个服务端组件,有可能出现使用同一个数据的情况,这个时候如何进行数据共享。这时候我们首先考虑的并不是使用 React Context(当然这个 API 服务端也用不了)或者 props 进行 传递数据,而是直接在需要使用数据的服务端组件中再次使用 fetch 获取数据即可。这是因为 React 拓展了 fetch 的功能,添加了缓存相关的功能,对同一地址的多次请求,无需担心多次请求带来的性能问题,当然这个缓存的命中也是有一定条件限制的,比如只能在 GET 请求中,具体的使用细节咱后面再说。

不过这儿呢还有一个比较大的注意点,先给大家说一下,咱现在使用的 next 版本是 14.2.5,fetch 函数默认情况下就具有缓存。

1
2
3
4
5
6
async function getItem() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
cache: "force-cache",
});
return res.json();
}

但就在三周之前官方发布了 15 的版本,其中有一个破坏性的变更,那就是默认情况下 fetch 不再进行缓存了,咱给大家看一下记录。

这一点官方文档也同步进行了说明,配置项 cache 的默认值由原来的 force-cache 变成了 no-store。

那是不是意味着我们不需要再学习缓存相关的内容了呢?其实不然,一个来说现在绝大多数项目和周边生态配套的还是 14 版本,第二个呢利用缓存确实有利于我们后续的项目优化,所以咱还是有必要先学一学,建议大家学习的时候先和我的版本保持一致,等后面我切换为 15 版本的时候大家再进行升级就好啦。

还有一点需要大家注意,咱现在看的 next 文档默认也是 15 的版本,要和代码中的版本对应上的话,大家可以在这儿进行切换…

好啦这是咱给大家说的第一点,服务端组件之间共享数据的时候可以直接使用 fetch 进行获取就好啦。

2. 将仅限 Server 的代码排除在客户端环境之外

好,接下来看一下关于服务端组件使用时候的第二个注意点,将仅限服务端的代码排除在客户端环境之外,这句话什么意思呢?

官方给我们举了这样一个场景,咱拿这个例子给大家说一下。例如咱有这么一段代码,这个代码的含义是获取环境变量中的 API_KEY,但这个 API_KEY 为了安全性我们一般只会放在服务端,所以这个整段代码被服务端组件导入没有什么问题,可以正常使用。

但是不保证有人在客户端组件也导入了这个代码,这个时候 Next.js 出于安全性的考虑是获取不到这个 API_KEY,或者说会把获取到的这个 API_KEY 替换为空字符串,于是啊,程序运行的时候就出现问题了。

当然如果你就是期望客户端获取到这个 API_KEY,那咱在环境变量中起名字的时候要加这样一个前缀,但对于咱说的这个场景来说咱并不期望加这个前缀让客户端访问到。

所以咱干脆能不能不让客户端组件,导入这个只在服务端才能正常运行的代码文件呢?

其实这个时候咱可以安装一个 server-only 这个包,然后在这个只期望服务端引用的代码文件的顶部导入一下,一旦这样做了,客户端在导入这个代码的时候就直接会报错了,这正式我们期望。

3. 使用三方包

接下来说一下使用服务端组件时候的第三个这一点,如何使用第三方包?

毕竟 React Server Component 是一个新特性,生态里的很多第三方包可能还没有跟上,这样在服务端组件引用第三方包的时候就可能会导致一些问题。

比如当我们在服务端组件使用 <Carousel /> 这个第三方包的时候,就会出现问题,因为 Carousel 这个组件内部肯定使用了 React hook 相关的一些东西,但是它内部并没有声明 ‘use client’ 相关的指令,所以当被服务端组件引入的时候,那这个第三方包就会被当作服务端组件处理,而服务端组件内部是不能使用 React hook 相关 API 的,所以这时候肯定就会报错。

那如何解决这个问题呢?第一种方式呢,改造第三方组件的代码,加一个 use client 指令,但其实这种方式不太现实,因为这是第三方的我们没法直接修改。

这时候其实我们这样做,我们可以自己再额外创建一个客户端组件,例如叫 carousel.tsx,用咱们自己创建的这个客户端组件再去引入第三方的组件,然后再暴漏出去,然后服务端组件再引入咱们自己定义的这个组件就好啦。

因为咱前面给大家说过,只要是被客户端组件引入的子组件都会被和这个客户端组件一起打包成一个 bundle,换句话来说引入的这个没有使用 use client 声明的服务端组件也会被当作客户端组件处理,这个时候它内部再使用 React Hook 相关的 API 自然也就没有问题了。

好啦,这是咱给大家的使用第三方组件时候的一个注意点。

custom-carousel.tsx

1
2
3
4
"use client";

import { Carousel } from "acme-carousel";
export default Carousel;

后续服务端组件再引入我们自定义的这个 <Carousel /> 组件就好了:

1
2
3
4
5
6
7
8
9
10
import Carousel from "./custom-carousel";

export default function Page() {
return (
<div>
<p>View pictures</p>
<Carousel />
</div>
);
}

4. 使用 Context Provider

使用服务端时这儿呢还有一个注意点,那就是如何在服务端组件使用 Context API,解决方式啊,和前面咱们说的在服务端使用第三方组件的处理方式相似。

就比如说,咱在 Root Layout 组件里面,使用 React createContext API 配合 value 提供全局数据,期望被所有的组件访问到。

但是很遗憾的是这儿会发生报错,因为 Root Layout 必须是一个服务端组件,而服务端组件是不能使用这个 React API 的。

解决方式啊,和前面也是一样的,咱建一个自己的 provider 客户端组件,然后通过 context api 提供数据,这个时候呢还要再做一步,那就是接收一下 children,在这个 context Provider 组件内部试用一下这个 children。

最后咱在 Root Layout 里面导入自定义的 Provider 客户端组件,然后再这样使用一下,这样整个应用程序的所有其他客户端组件都能够使用这里的 Context 提供的数据拉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { createContext } from "react";

// 服务端组件并不支持 createContext
export const ThemeContext = createContext({});

export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
</body>
</html>
);
}

为了解决这个问题,你需要在客户端组件中进行创建和渲染:

1
2
3
4
5
6
7
8
9
"use client";

import { createContext } from "react";

export const ThemeContext = createContext({});

export default function ThemeProvider({ children }) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}

然后再在根节点使用:

1
2
3
4
5
6
7
8
9
10
11
import ThemeProvider from "./theme-provider";

export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

客户端组件

1. 客户端组件尽可能下移

为了尽可能减少客户端 JavaScript 包的大小,尽可能将客户端组件在组件树中下移。

尽量把服务端组件定义在外层去获取数据,把客户端组件定义在最里层去处理用户交互

举个例子,当你有一个包含一些静态元素和一个交互式的使用状态的搜索栏的布局,没有必要让整个布局都成为客户端组件,将交互的逻辑部分抽离成一个客户端组件(比如 <SearchBar />),让布局成为一个服务端组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SearchBar 客户端组件
import SearchBar from "./searchbar";
// Logo 服务端组件
import Logo from "./logo";

// Layout 依然作为服务端组件
export default function Layout({ children }) {
return (
<>
<nav>
<Logo />
<SearchBar />
</nav>
<main>{children}</main>
</>
);
}

2. 从服务端组件到客户端组件传递的数据需要序列化

当你在服务端组件中获取的数据,需要以 props 的形式向下传给客户端组件,这个数据需要做序列化。

这是因为 React 需要先在服务端将组件树先序列化传给客户端,再在客户端反序列化构建出组件树。如果你传递了不能序列化的数据,这就会导致错误。

如果你不能序列化,那就改为在客户端使用三方包获取数据吧。

路由/服务器渲染策略

静态渲染

接下来咱再学习一下关于服务端组件的渲染策略,咱主要给大家说两种,分别是静态渲染和动态渲染。

首先咱给大家看一下什么是静态渲染,所谓静态渲染,也就是路由对应的组件啊在构建的阶段或者说在重新验证的时候啊,在后台就已经渲染完毕了,这个时候后续对路由的请求结果都会被缓存,它比较适合于静态的文章博客或产品介绍,静态渲染对应的路由它打包的标志是一个圈。

例如咱给大家举个例子来说明问题,咱先给大家看一下,什么叫构建的阶段就已经渲染完毕了。例如咱有这样一个页面,这个代码就表示在页面当中输出当前日期的时间部分。

1
2
3
4
export default async function Page() {
console.log("🤠");
return <h1>{new Date().toLocaleTimeString()}</h1>;
}

这个时候咱执行 npm run build 进行打包构建,等一下,会发现构建出来的 / 这个路由对应的是一个圈,这个圈就表示啊打包构建的时候,内容就被预渲染完毕了,后续在请求这个路由的时候会一直使用构建时候的这个路由对应的缓存内容,浏览器展示的时间就一直是打包构建时候的这个时间,咱打开浏览器给大家验证一下。

说到这儿,有同学可能会想,那老师是不是只要是静态渲染,后续请求的时候这儿的内容就不会更新啊,其实也不完全是这样的。

例如咱再给大家演示下静态渲染的第二种形式,叫重新验证,所谓重新验证:就是清除数据缓存并重新获取最新数据的过程,咱就是称为是重新验证,重新验证的方式有很多,咱这儿先给大家举一种方式,基于时间的重新验证。

这个时候,咱对这个代码进行改造,在头部加一个 export const revalidate = 10,这就表示超过 revalidate 设置的时间,就是 10s,首次访问会触发缓存更新,注意这儿仅仅是缓存更新,只有再次请求才会返回新内容,咱同样给大家打包验证一下。会发现打包时候这儿虽然也是一个圈,但其实这个内容在重新验证后会得到更新的。

1
2
3
4
5
6
export const revalidate = 10;

export default async function Page() {
console.log("🤠");
return <h1>{new Date().toLocaleTimeString()}</h1>;
}

好啦,这是咱给大家说的关于路由的,或者说服务端组件的静态渲染,待会咱再给大家说一下动态渲染,好啦谢谢大家。

动态渲染

接着咱再看一下动态渲染:所谓动态渲染,就是路由在请求的时候进行渲染,如果使用了动态函数(Dynamic functions)或者未缓存的数据请求(uncached data request),Next.js 就会自动切换为动态渲染,动态渲染打包时候的符号长得好像是 f。

咱先看给大家演示一下第一种情况,使用动态函数会自动切换为动态渲染:所谓动态函数指的是只有在请求时才能得到信息(如 cookie、请求头、URL 参数)的函数。

1
2
3
4
5
6
7
8
import { cookies } from "next/headers";

export default async function Page() {
const cookieStore = cookies();
cookieStore.get("token");
console.log("🤠");
return <h1>{new Date().toLocaleTimeString()}</h1>;
}

接下来再演示一下使用未缓存的数据请求也会触发动态渲染。

1
2
3
4
5
6
7
8
9
10
11
export default async function Page() {
const src = (
await (
await fetch("https://api.thecatapi.com/v1/images/search", {
cache: "no-store",
})
).json()
)[0].url;
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt="cat" />;
}

还有一种渲染策略是流式渲染,页面级别的可以使用 loading.tsx 进行处理,特定组件的流式可以使用 <Suspense> 进行处理,这个大家先知道到这就可以了。

说了这么几个渲染策略,有同学可能会问,那老师什么时候使用 … 其实作为开发者,无须选择静态还是动态渲染,Next.js 会自动根据使用的功能和 API 为每个路由选择最佳的渲染策略。

数据获取和缓存

基本使用

在 Next.js 中,获取数据咱比较推荐的是使用 fetch 方法,因为它内部进行了扩展,例如添加了缓存和更新缓存的能力。

在使用 fetch 的时候,它的语法是这样的,它的第二个参数是一个配置对象,其中有个 cache 是用来控制缓存的,默认情况下它的值是 force-cache,表示强制缓存。

1
fetch("https://...", { cache: "force-cache" });

这儿呢有个注意点给大家先强调一下,只在于服务端组件或者在只有 GET 方法的路由处理程序中使用 fetch 函数,才是默认缓存的,例如当咱在 Server Action 当中使用 fetch 的时候,他就不会走默认缓存了。这时候第二个参数的默认值则变成了 no-store,表示不缓存,至于什么是 Server Action 咱后面会说。。

这里大家只需要先知道,使用 fetch 函数啊并不一定都是具有默认缓存的,代码中的一些有意甚至无意的写法都可能会导致它退出缓存,有哪些写法可能会导致 fetch 缓存的退出呢?大家下去以后可以点一下这个标题先大概过一下就行了,不用去记,碰到问题咱在针对性的去解决就好啦。

当然我们也可以通过一些方法明确的验证缓存的时效性,例如这些方法,这个待会我们会专门说到。

我想给大家先演示一下 fetch 缓存的一个表现,演示之前咱先开启 fetch 请求的日志,方便待会啊观察效果,怎么开启呢?这个时候可以点开 next.config.mjs

1
2
3
4
5
6
7
8
9
const nextConfig = {
logging: {
fetches: {
fullUrl: true,
},
},
};

export default nextConfig;

好,咱这先停一下,下节课进行演示,使用 fetch 函数默认缓存的两种情况,一种是在服务端组件中使用,一种是在只有 GET 方法的路由处理程序中使用,好谢谢大家。

服务端组件演示

现在服务端组件进行演示,例如咱有这样一个接口 https://dog.ceo/api/breeds/image/random,这个接口地址呢,每次请求都会返回随机的一张图片,咱先用浏览器打开给大家看一下效果,注意看这儿,会发现每次刷新确实都会变化。

这时候咱期望通过 fetch 函数请求这个接口,拿到图片地址后通过 img 标签进行展示,然后刷新几次页面,最后观察一下最终的效果如何,好接下来打开 VSCode 编写代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const fetchData = async () => {
// const res = await fetch("https://api.thecatapi.com/v1/images/search");
const res = await fetch("https://dog.ceo/api/breeds/image/random");
if (!res.ok) {
throw new Error("Failed to fetch data");
}
return res.json();
};
export default async function Page() {
const r = await fetchData();
console.log("🤠");
// eslint-disable-next-line @next/next/no-img-element
return <img src={r.message} width="300" alt="Dog" />;
}

通过观察我们发现,请求 / 斜杠路由的时候总共花了这么多时间,其中 fetch 请求用了 xxx 毫秒,同时走了缓存,这儿有个注意点,根据我前面的测试第一次展示的这个时间未必准确,例如我把接口延迟个几秒返回,这个时间还是很短,这可能是开发工具的 Bug,我们知道就行了。这时候我们在浏览器连续的再刷新几次观察一下效果,会发现图片没有变化,打开控制台查看会发现这三次走的都是缓存,而且时间还会更快一些。

还有一个注意点可以给大家演示一下,就是开发环境下强制刷新会清除缓存,咱打开浏览器给大家测试一下,此时强制刷新,会发现首先这儿的数据发生了变化,同时打开后端控制台有这样一个标志,表示退出了缓存,回到浏览器再次普通刷新,其实它又会命中缓存,会发现图片没有变化,控制台又出现了命中缓存的标记。

路由处理程序 GET 请求演示

在路由处理程序中使用,新建 app/api/cache/route.ts

1
2
3
4
5
6
export async function GET() {
const res = await fetch("https://dog.ceo/api/breeds/image/random");

const data = await res.json();
return Response.json({ data });
}

开发环境下,运行 npm run dev,和前面一样,强制刷新跳过缓存,普通刷新会命中缓存。可以看到第一次硬刷新的时候,请求接口时间为 912ms,后面普通刷新的时候,因为使用缓存中的数据,数据返回时间都是 1ms 左右。

运行 npm run build && npm run start 生产环境下测试,无论是否强制刷新,fetch 都会被缓存,接口数据保持不变。

最后还有一个比较坑爹的注意点给大家强调下,就是咱刚刚所有的演示都是在开发环境下,如果对项目进行打包,缓存的表现和开发环境会不一样,这时候无论是否强制刷新,都会进行缓存,图片数据都会保持不变,这个咱就不再进行测试了。

数据验证

好接下来咱学习一下关于数据验证,所谓数据验证啊,说到是清除数据缓存并重新获取最新数据的这个过程啊就叫做重新验证。

Next.js 提供了两种数据验证的方式:一种是基于时间的重新验证,它表示经过一定时间并有新请求产生后会重新验证数据,它比较适合于数据不需要马上更新,或者对数据的实时性啊要求没那么高的情况。

另一种是按需重新验证,什么叫按需呢?也是说啊,我需要验证的时候啊,我就直接调用方法进行验证就好啦,这种数据验证的方式啊,比较适用于需要马上展示最新数据的场景,按需重新验证啊又分为两种,分别是基于路径的和基于标签的。

听上去感觉有点晕,没关系,咱同样通过代码给大家演示一下就会非常清楚啦。

基于时间的重新验证

首先咱看一下基于时间的重新验证,它有两种使用方式,

一种是在使用 fetch 的时候设置 next.revalidate 选项,注意这个单位是秒,例如这儿写的 3600,这就表示到达 1 小时并有新的请求产生就会进行数据验证,再次请求会得到新的内容。

1
fetch("https://...", { next: { revalidate: 10 } });

另一种使用方式是通过路由段配置项进行配置,使用这种方法,它会重新验证这个路由当中所有的 fetch 请求,这个代码可以写到 layout.tsx、page.tsx 或 route.ts 文件中。

1
export const revalidate = 10;

好,给大家演示一下,咱就给大家演示一个说明问题就好啦。

按需重新验证

revalidatePath

接下来看一下按需重新验证,首先看一下基于路径的按需重新验证,他需要用到 revalidatePath 这个函数,例如咱需要重新验证 / 斜杠页面或者 /api/cache 的这个路由处理程序的缓存,这个时候就可以调用 revalidatePath 这个函数,里面传递 / 或 /api/cache 路径即可。

revalidatePath 这个函数啊,可以写到 Server Action 或者路由处理程序中,Server Action 咱还没有学习,所以咱可以把代码写到路由处理程序中。

咱期望的效果是这样的,当用户请求 /api/revalidatePath?path=/ 的时候就会更新斜杠路径的缓存,请求 /api/revalidatePath?path=/api/cache 就会更新 /api/cache 路径的缓存。

app/api/revalidatePath/route.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { revalidatePath } from "next/cache";
import { NextRequest } from "next/server";

export async function GET(request: NextRequest) {
const path = request.nextUrl.searchParams.get("path");

if (path) {
revalidatePath(path);
return Response.json({ revalidated: true, time: Date.now() });
}

return Response.json({
revalidated: false,
time: Date.now(),
});
}
revalidateTag

好接下来再给大家演示一下基于标签的重新验证,他的语法是这样的,使用 fetch 函数的时候,我们可以指定一个或者多个标签,用于标记这个请求,后面再调用 revalidateTag 方法传递对应的标签名即可验证对应的请求,他的语法格式是这样的。

好接下来咱同样编写一个根据标签验证请求的接口,app/api/revalidateTag/route.ts

/api/revalidateTag?tag=collection

1
2
3
4
5
6
7
import { revalidateTag } from "next/cache";

export async function GET(request) {
const tag = request.nextUrl.searchParams.get("tag");
revalidateTag(tag);
return Response.json({ revalidated: true, now: Date.now() });
}

在进行数据库查询的时候如何实现缓存

也不是所有时候都能使用 fetch 请求,如果你使用了不支持或者暴露 fetch 方法的三方库(如数据库、CMS 或 ORM 客户端),但又想实现数据缓存机制,那你可以使用 React 的 cache 函数和路由段配置项来实现请求的缓存和重新验证。

src/db/index.ts

1
2
3
4
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient({
log: ["query"],
});

举个例子:src/utils/index.ts

1
2
3
4
5
6
7
8
9
import { db } from "@/db";
import { cache } from "react";

export const getItem = cache(async (id: number) => {
const item = await db.snippet.findUnique({
where: { id },
});
return item;
});

在这个例子中,尽管 getItem 被调用两次,但只会产生一次数据库查询。这儿呢,大家可以先记住这个技巧,就是当遇到频繁重复的数据库操作时,记住使用 React 的 cache 函数

Next.js 中的缓存

缓存概述

好,接下来咱说一下 Next.js 当中的缓存,在 Next.js 当中啊,存在四种类型的缓存,咱有必要逐个学习一下,否则在后续的项目开发过程中会有各种各样困惑的地方,咱这儿呢,先对这四种缓存做一个基本的概述,后面啊,咱再通过案例的形式给大家详细的分析缓存的细节或者说啊缓存的流程,让大家深刻的明白这张图的含义。

首先看一下第一种,叫做请求记忆,它说的是当我们多次使用 fetch 函数发请求的时候啊,多次请求具有相同的 URL 和请求参数,React 会自动的将请求的结果进行缓存,这样当我们在跨路由或跨组件使用相同数据的时候啊,就不需要先在顶层请求数据,然后再将数据通过 Props 传递给后代这种形式了,我们只需要在需要数据的服务端组件中通过 fetch 函数直接再次发出请求重新获取数据就好啦,无需担心多次 Fetch 带来的性能问题,这就得益于请求记忆的缓存能力。关于这一点呢,我们在前面服务端组件最佳实践的时候啊也给大家说过。

官方这儿呢,有一个图片说明,意思是说你在这样一个组件树当中啊,虽然写了这么多次请求代码,但最后真正发起的只有三个,因为他会把相同的请求啊进行合并,换句话来说已经发起过的请求会产生请求记忆。

下面呢,官方也有一个代码举例,例如定义了一个 getItem 请求函数,虽然下面呢调用了两次,但其实第二次的请求并不会真正发出,而是直接来自于请求记忆的缓存结果。

1
2
3
4
5
6
async function getItem() {
const res = await fetch("https://.../item/1");
return res.json();
}
const item = await getItem();
const item = await getItem();

有一个小细节,需要给大家说一下,准确来说这个请求记忆的缓存能力是 Reactfetch API 能力的扩展,其实这和 Next.js 并没有直接关系。

好啦这是关于第一种缓存请求记忆

第二种缓存形式是 Data Cache,又叫数据缓存,它是 Next.js 对 Fetch API 缓存能力的再次扩展,关于这一点呢,咱前面详细的说过,数据缓存是什么,表现是怎样的,以及如何退出,尤其是如何重新验证数据缓存,咱这儿呢就不再重复了。

第三种是完整路由缓存,它说的是 Next.js 在构建的时候,会自动渲染并缓存路由,这样当客户端访问路由的时候,直接使用的是缓存中的路由,这样可以加快页面的加载速度,而不是说当客户端访问路由的时候啊从零开始在服务端重新渲染。

关于这样一种缓存形式,官方这里啊讲了很多,但是对 Next.js 还没有足够了解的情况下你会越看越糊涂,这里大家只需要记住一点,那就是这句话:静态渲染的路由走的是完全路由缓存,再重复一遍,静态渲染的路由走的是完全路由缓存。

还有还有第四种缓存叫客户端路由缓存,它说的是当用户在路由之间导航的时候啊,Next.js 会在客户端缓存访问过的路由,这种客户端缓存和前面三种有个比较直观的差异,那就是它存在于客户端,而前面三种缓存都存在于服务端,还有一点需要大家注意的是,当页面手动刷新的时候会清除客户端缓存。

好啦,关于这四种形式的缓存啊,我这么说你肯定会觉得有一点蒙,尤其是大家看到官方的这个缓存图片的时候。

不要怕兄弟们,待会啊,咱给大家通过案例的形式,逐一进行说明,让大家看懂这个图片,让大家想听不懂都难!

全路由缓存演示

好,接下来咱通过案例的形式给大家演示一下缓存的整个流程,让大家明白整个图片的含义,为了方便演示啊,咱这儿呢首先对前面的项目进行清理,只保留首页就好啦。

接下来根据这个图片的信息,我们会发现,它所分析的分别是打包构建阶段和客户端请求阶段,针对斜杠 /a 路由页面的一个分析过程,所以咱也创建一个 a 路由页面,给它对照上,里面写上一点代码,咱希望在这个页面啊,展示 3 张图片,这个时候写出的代码大概是这样的,首先咱定义一个函数叫 fetchImg…,注意这个接口的特点是每次请求会返回随机的一张小狗狗 … 放了方便调试,咱这儿也输出一头小牛牛。

app/a/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const fetchImg = async () => {
const r = await fetch(`https://dog.ceo/api/breeds/image/random`);
return r.json();
};

export default async function Page() {
const obj1 = await fetchImg();
const obj2 = await fetchImg();
const obj3 = await fetchImg();
console.log("🤠");
return (
<div>
<img src={obj1.message} width={300} />
<img src={obj2.message} width={300} />
<img src={obj3.message} width={300} />
</div>
);
}

好接下来咱打包构建这个项目,给大家分析一下,打包构建的时候发生了什么事情

1. 首先,首次打包构建呢会请求 /a 路由,

第 1 次 fetch 请求会 MISS 错过请求记忆、也会 MISS 数据缓存,记下来从数据源获取数据,数据拿到之后呢, SET 到数据缓存,再 SET 到请求记忆缓存。

接下来第 2 次 fetch 请求,由于和上一次的请求地址和请求参数都完全一样,所以会命中请求记忆缓存直接拿到上一次的数据,我这儿呢,改造了一下这个图片,大概是这样的。

接下来又是第 3 次 fetch 请求,同样和第一次的请求地址和请求参数完全一样,所以会命中请求记忆缓存直接拿到上一次的数据。

所以这儿大家记住,由于请求记忆缓存,这三张图片的展示肯定是完全一样的,因为真正的数据请求只发了一次。

2. 接下来生成这个 RSC Payload(关于这个 RSC Payload 啊,它其实是服务端组件渲染出来的一种比较特殊的数据格式,里面记录了后续渲染所需的一些信息,大家先了解到这儿就好了),如果这个服务端组件引入的还有其他客户端组件的话,Next.js 会结合 RSC Payload 和引入的客户端组件的代码在服务端生成 HTML,最终这两者,也就是 RSC Payload 和 HTML 也会在服务端进行缓存(而这一次的缓存就叫做完整路由缓存或全路由缓存)。

3. 好接下来我们执行 npm start 运行打包后的代码,分析在客户端请求 /a 的过程,我们请求 /a 或刷新页面的时候,首先会 MISS 客户端路由缓存,接下来就命中了全路由缓存,拿到全路由缓存的 RSC Payload 和生成的 HTML,又把 RSC Payload 在客户端进行了缓存(而这一次缓存啊其实就是客户端路由缓存),最后呢客户端会逐行解析 RSC Payload 的内容进行渐进式的渲染。

这里咱再次强调,每次刷新页面的时候啊,客户端路由缓存都会被清除,客户端路由缓存它只存在于路由导航期间,所以后续刷新 /a 页面展示的还会是全路由缓存的结果,而不是客户端路由缓存的结果。

4. 另外咱打开控制台,大家发现前面的几次刷新,咱这儿也并不会有小牛牛的输出,意味着这儿的代码压根都不会再走了,因为 /a 是静态渲染,通过前面打包的这个标志也能证明,而静态渲染打包的时候就决定了内容,前面咱也特意的给大家强调过一句话,静态渲染走的是全路由缓存,所以全路由缓存为啥叫全路由缓存啊,你体会体会,是不是把几乎所有的一切都缓存住了。

好了,这是咱要给大家说的路由缓存的基本流程,待会咱继续的改造这个代码。

请求记忆演示

好接下来咱看一下上一次打包之后的效果,首先三张图片一样,是因为打包的时候走了请求记忆缓存导致的,当然无论再怎么刷新这儿的也不会发生变化啦,这是因为静态渲染,这儿走了全路由缓存。

需要大家注意的是:缓存行为或者是缓存的流程是会发生变化的,取决的因素有很多,比如路由是动态渲染还是静态渲染,有没有产生请求记忆,数据是缓存还是未缓存,请求是在初始化刷新的时候访问的还是后续通过 Link 标签进行路由导航的。

例如这个时候咱在代码中轻轻的加一个 headers 动态函数,需要大家注意的是,这个动态函数的引入会让路由变成动态渲染,也就意味着每次请求 /a 页面的时候服务端组件的代码都会执行,这个时候其实就失去了全路由缓存。

接下来我们对代码进行打包,再次运行 npm start 启动项目给大家看一下,浏览器刷新,会发现这儿输出了小牛牛。

当然三张图片还是一样,这是因为三次请求的地址和参数都一样,请求记忆缓存导致的。当然再次刷新页面这三张图片整体也不会有什么新的变化,这是因为 fetch 请求具有默认的数据缓存导致的。

虽然说我这么改造和上一次的效果完全一样,但其实内部命中的缓存是有差异的。例如这一次它就肯定没有走全路由缓存。

好啦,这是关于咱对缓存行为变化的一个说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { headers } from "next/headers";

const fetchImg = async () => {
const r = await fetch(`https://dog.ceo/api/breeds/image/random`);
return r.json();
};

export default async function Page() {
const obj1 = await fetchImg();
const obj2 = await fetchImg();
const obj3 = await fetchImg();
await headers();
console.log("🤠");
return (
<div>
<img src={obj1.message} width={300} />
<img src={obj2.message} width={300} />
<img src={obj3.message} width={300} />
</div>
);
}

例如接下来我们继续对这个代码进行改造,例如我们这时候只需要把 headers 的调用写到 fetch 请求的上面,大家暂停一下视频,可以先思考一下浏览器中会是怎样的一个表现?

大家还记得吗,前面咱给大家说过,在动态函数的下面调用 fetch 方法会导致 fetch 默认缓存的退出。

所以这时候我们在户端每次请求 /a 路由的时候,这时候的 fetch 请求就不在具有数据缓存了,也会在每次刷新页面请求到新的图片地址,但由于这三次的接口的请求都一样,所以请求记忆缓存还是有的,这三张图片的效果还是完全一样的。

其实咱这儿的写法就相当于给 fetch 函数设置了 cache no-cache,这样也会导致页面动态渲染,退出全路由缓存,同时每次刷新页面同样会得到三张新的一样的图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { headers } from "next/headers";

const fetchImg = async () => {
const r = await fetch(`https://dog.ceo/api/breeds/image/random`);
return r.json();
};

export default async function Page() {
const obj1 = await fetchImg();
const obj2 = await fetchImg();
const obj3 = await fetchImg();
await headers();
console.log("🤠");
return (
<div>
<img src={obj1.message} width={300} />
<img src={obj2.message} width={300} />
<img src={obj3.message} width={300} />
</div>
);
}

好接下来,咱再给大家演示一种情况,演示一下退出请求记忆后的表现是怎样的。如何退出请求记忆呢,这时候官方这儿有一种写法,可以这样。

好,大家同样先思考一下,这样改造后,打包之后的效果是怎样的呢?

好,首先 fetch 没有缓存,每次 fetch 都会产生新图片这个没有问题,其次没有了请求记忆,所以每一次也都会调用 fetch 拿到新图片,所以最终的表现就是,三张图片不一样,每次刷新又会得到新的三张不一样的图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const fetchImg = async () => {
const { signal } = new AbortController();
const r = await fetch(`https://dog.ceo/api/breeds/image/random`, {
signal,
cache: "no-cache",
});
return r.json();
};

export default async function Page() {
const obj1 = await fetchImg();
const obj2 = await fetchImg();
const obj3 = await fetchImg();
console.log("🤠");
return (
<div>
<img src={obj1.message} width={300} />
<img src={obj2.message} width={300} />
<img src={obj3.message} width={300} />
</div>
);
}

最后呢给大家留一个思考题,如果这时候我把 cache: ‘no-cache’ 删掉,重新打包后,在浏览器的表现又是怎样的呢?

客户端路由缓存

接下来咱看一下关于客户端路由缓存,他说的是:当用户在路由之间导航的时候啊,Next.js 会在客户端缓存访问过的路由。

1. 就比如官方这儿,给了一个原理图,当我们访问 斜杠 a 页面的时候,因为是首次访问,会 MISS 客户端路由缓存,接下来命中了服务端的全路由缓存或进行动态渲染(这取决于这个路由是静态渲染还是动态渲染的),然后返回信息,接下来会将斜杠 a 对应的 Layout 和 斜杆 a 这个页面本身设置在客户端路由缓存中。

2. 当我们访问访问 斜杠 b 页面的时候,由于它和 斜杠 a 页面共享的是同一个 layout,所以这时候 斜杠 b 会直接使用客户端路由缓存中的 layout,然后 /b 对应的页面会命中服务端的全路由缓存,或者说进行服务端动态渲染,然后返回信息,接下来 斜杠 b 这个页面也会添加到客户端缓存当中,目前客户端路由缓存中啊就拥有了 /a、/b 以及他们共享的 layout。

3. 当后续再次通过路由导航访问 /a 的时候,这时候会直接使用客户端路由缓存中的 /(layout)和 /a 这个页面。

好啦这就是官方说的对于导航时的客户端路由缓存的一个流程。

不仅如此,Next.js 啊,还会对,存在于视口内的 Link 组件,对应的路由页面进行预加载,所以当我们在客户端导航的时候,既有缓存又有预加载,就会感觉体验非常的丝滑。

同样给大家举个例子说明问题,例如这时候咱希望实现这样一个效果,有两个导航按钮,分别是新闻和体育,点击新闻跳转到新闻页面,里面展示当前的时间,点击体育跳转到体育页面,里面也展示当前的时间,最后咱分析一下会有怎样的表现。

utils/index.ts

1
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

src\app(cache)\layout.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Link from "next/link";

export default function CacheLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<section>
<nav>
<Link href="/news">新闻</Link>
<Link href="/sports">体育</Link>
</nav>
{children}
</section>
);
}

src\app(cache)\loading.tsx

1
2
3
4
5
import React from "react";

export default function Loading() {
return <div>loading ...</div>;
}

src\app(cache)\news\page.tsx

1
2
3
4
5
6
import { sleep } from "@/utils";

export default async function News() {
await sleep(3000);
return <div>News {new Date().toLocaleString()}</div>;
}

src\app(cache)\sports\page.tsx

1
2
3
4
5
6
import { sleep } from "@/utils";

export default async function Sports() {
await sleep(3000);
return <div>Sports {new Date().toLocaleString()}</div>;
}

执行 npm run build && npm start,打开浏览器,会发现首先没有 loading 效果,其次啊,不管怎么刷新,这个时间啊也没有变化,这个原因是什么呢?这是因为我们的页面是静态渲染的,咱可以通过控制台给大家看一下,这个代码的结果在打包构建的时候啊就已经执行完毕了,页面刷新的时候其实使用的是全路由缓存的内容。

所以为了刷新时看到这个实时的时间,就需要动态渲染,那么如何变为动态渲染的呢?

这时候可以在统一的 layout 里面添加 export const dynamic = 'force-dynamic',这时候再次执行 npm run build 打包,看发现两个页面就是动态渲染的了,接下来执行 npm start 启动项目,这时候重新刷新页面会发现时间也是当前时间了。

好接下来再给大家看一下预加载的效果,虽然说啊,现在请求的是新闻这个页面,但由于 体育这个 Link 也在可视区,所以它对应组件的 RSC Payload 也肯定进行了预加载,怎么证明呢?

这时候咱先打开 F12,清除一下原来的信息,重新刷新一下,这时候会发现有这样的信息,其实这个信息就是体育页面预加载过来的 RSC Payload,注意这儿预加载的是 RSC Payload,并不是所有内容,我们后续点击体育页面的时候,这个页面还需要解析 RSC Payload,进行后续的一些操作。

好,接下来我们第一次点击体育,进行了路由跳转,首先出现了 loading 没有问题,然而当我们再点击 news、再次点击 sports 的时候发现就没有 loading 加载效果了,甚至连时间都没有变化了,其实这就是客户端路由缓存的结果,那么该如何处理这个问题呢,或者我这儿该如何看到最新的时间呢?

好,接下来看一下该如何处理上面的问题?

首先看一下第一种方式呢,就是等待客户端路由缓存失效。

客户端路由缓存是有有效期的,静态渲染是 5 分钟后失效,动态渲染是 30s 后失效,咱们现在是动态渲染,所以应该来说等待 30s 后再点击导航按钮就会 MISS 客户端缓存,重新触发服务端的动态渲染了,这时候就会正常显示 loading,正常显示当前的时间,如果咱再等待 30s 再次点击新闻,会发现同样加载了 loading,时间也是正确的。

第二种方式是不用 Link 组件,改用原生的 <a> 标签进行跳转,但是这种方式会导致页面刷新,所以咱也很少会这样用。

第三种方式是重新验证客户端路由缓存,重新验证呢有两种写法:一种在 Server Actions 中配合重新验证或 Cookie 相关的操作,不过我们这里并没有用到 Server Actions,还有一种呢是,在路由导航的时候调用 router.refresh,有一点需要注意,这儿由于用到了 router,所以需要把当前 Layut 组件声明为客户端组件。

然后还需要把客户端 layout 里面的 export const dynamic = “force-dynamic”; 去掉,因为它只适用于服务端组件,把这句话分别添加到 news 和 sports 页面中让这两个页面动态渲染。

第四种实现方式我见也有人这样处理,就是把 router.refresh() 的操作封装到一个组件,什么时候调用 router.refresh() 呢,

https://nextjs.org/docs/14/app/api-reference/functions/use-router#router-events

app/(cache)/navigation-events.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"use client";

import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";

export function NavigationEvents() {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();

useEffect(() => {
router.refresh();
}, [pathname, searchParams]);

return null;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Link from "next/link";
import { Suspense } from "react";
import { NavigationEvents } from "./navigation-events";

export const dynamic = "force-dynamic";

export default function CacheLayout({ children }) {
return (
<section className="p-5">
<nav className="flex items-center justify-center gap-10 text-blue-600 mb-6">
<Link href={`/about`}>About</Link>
<Link href={`/settings`}>Settings</Link>
</nav>
{children}
<Suspense fallback={null}>
<NavigationEvents />
</Suspense>
</section>
);
}

Server Action

Server Action 是指在服务端执行的异步函数,它可以在服务端和客户端组件中被使用。定义一个 Server Action 的语法是,需要使用 React 的 “use server” 指令,按照指令定义的位置啊,可以分为两种用法:

1. 第一种用法称为函数级别的使用,它说的是可以将 “use server” 放到一个 async 函数的顶部,那么该函数则表示为一个 Server Action,就像这样:

1
2
3
4
5
6
7
8
9
10
11
export default function Page() {
// Server Action
async function create() {
'use server'
// ...
}

return (
// ...
)
}

2. 第二种用法称为模块级别的使用,它说的是将 “use server” 指令放到一个单独的 ts 文件的顶部,那么该文件导出的所有函数都是 Server Action,就像下面这样:

app/actions.ts

1
2
3
4
5
6
7
8
9
"use server";

export async function addTodo() {
// ...
}

export async function delTodo() {
// ...
}

有一点需要大家注意:当在服务端组件使用 Server Action 的时候,两种级别的语法都可以,而在客户端组件中使用的时候,只支持模块级别。当然在客户端使用还有一种办法就是:也可以将服务端组件中的 Server Action 作为 props 再传给客户端组件,例如就像下面这样:

1
2
3
4
5
6
7
8
9
10
// 服务端组件
export default function Page() {
// Server Action
async function updateItem() {
"use server";
// ...
}
// 传递给客户端组件
return <ClientComponent updateItem={updateItem} />;
}
1
2
3
4
5
"use client";

export default function ClientComponent({ updateItem }) {
return <form action={updateItem}>{/* ... */}</form>;
}

使用举例

好,接下来咱给大家举个具体的例子,来说一下不使用 Server Action 该如何去做,使用 Serve Action 又该如何去做?以及使用 Server Action 的好处,这个案例要完成的效果是这样的,点击按钮,把 input 中的数据添加到列表。

咱先用传统的方式给大家实现一遍,传统方式就是前端调用 API 接口完成数据添加,大概可以这样写:

Page Router:API

src\app\api\todos\route.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { NextRequest, NextResponse } from "next/server";

const data = ["吃饭", "睡觉", "打豆豆"];

export async function GET() {
return NextResponse.json({ data });
}

export async function POST(request: NextRequest) {
const formData = await request.formData();
const todo = formData.get("todo") as string;
data.push(todo);
return NextResponse.json({ data });
}

src\app\page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
"use client";

import { useEffect, useState } from "react";

export default function Page() {
// #1
const [todos, setTodos] = useState([]);

// #2
useEffect(() => {
const fetchData = async () => {
const { data } = await (await fetch("/api/todos")).json();
setTodos(data);
};
fetchData();
}, []);

// #3
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
// event.target => 点谁是谁
// event.currentTarget => 谁绑上谁
const response = await fetch("/api/todos", {
method: "POST",
body: new FormData(event.currentTarget),
});
const { data } = await response.json();
setTodos(data);
}
return (
<div className="p-10">
<form onSubmit={onSubmit}>
<input type="text" name="todo" className="border p-2" />
<button type="submit" className="border ml-2 p-2">
提交
</button>
</form>
<ul className="leading-8 mt-4">
{todos.map((todo, i) => (
<li key={i}>{todo}</li>
))}
</ul>
</div>
);
}

好啦,这是大家比较熟悉的传统写法,Server Action 的方式如何去做呢?

Server Action 通常与 <form> 表单一起使用,可以在前端直接调用 Server Action,无需编写 API 接口,例如咱对这个案例进行改写,首先咱定义一个 Server Action:

src/actions/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
"use server";

const data = ["吃饭", "睡觉", "打豆豆"];

export async function getTodo() {
return data;
}

export async function addTodo(formData: FormData) {
// 这儿有个方法给大家提一下,叫 Object.fromEntries,它可以把 formData 转成普通对象的形式
const todo = formData.get("todo") as string;
data.push(todo);
}

src/app/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { getTodo, addTodo } from "@/actions";

export default async function Page() {
const todos = await getTodo();
return (
<div className="p-10">
{/* React 扩展了 HTML <form> 元素以允许使用 action 属性调用 Server Action */}
<form action={addTodo}>
<input type="text" name="todo" className="border p-2" />
<button type="submit" className="border ml-2 p-2">
Add
</button>
</form>
<ul className="leading-8 mt-4">
{todos.map((todo: string, i: number) => (
<li key={i}>{todo}</li>
))}
</ul>
</div>
);
}

点击按钮后,会发现数据没有变化,这是因为客户端缓存导致的,这时候使用 revalidatePath("/");revalidateTag('xxx') 更新即可。

使用 Server Actions 有怎样的好处呢,首先你会发现代码变得非常的简洁,不需要再手动创建接口,而且 Server Actions 是函数,这意味着它可以在应用程序的任意位置很方便的复用。另外最重要的一点是:结合 form 使用的时候,支持渐进式增强,也就是说,即使这时候禁用 JavaScript,表单也可以正常提交,咱可以给大家演示一下…会发现正常提交了数据,当然这种情况就是会刷新页面。


还有一个细节要给大家说一下,开发中实际使用 form 配合 Server Action 使用的时候,往往比这复杂一些,例如我举个例子,如何传递其他参数到 Server Action 中呢?其实有两种方式,第一种方式呢,就是咱可以在 Server Action 的外面再套一层函数,这时候就可以进行任意参数的传递了。

还有第二种方式是官方提到的,在这儿,就是可以通过 bind 的方式进行处理。

配合事件使用

Server Actions 确实常常与 <form> 一起使用,但其实还可以配合事件处理程序、useEffect、第三方库等一起使用。如果是在事件处理程序中,该怎么使用呢?我们为刚才的案例增加一个按钮,当点击的时候,添加一个“牛牛”到列表中:

由于要用到点击事件,所以这时候就需要把当前页面声明为客户端组件,不过我这儿并不建议大家直接在这个页面顶部添加 ‘use client’,因为这样做的把会把整个页面甚至这个页面引入的其他模块或组件的代码都进行打包,这样会增加客户端的负担。

那怎么办呢?前面咱给大家讲组件使用最佳实践的时候,其实除了服务端组件,客户端组件在使用的时候也有一些最佳实践,那就是在使用客户端组件的时候应该尽量下沉,官方这儿也有说明,官方这个意思是说 …。

所以这时候可以把 form 单独抽离一个客户端组件,然后被这个页面引用,其实这就是所谓的客户端组件下沉,例如。

src\components\client-button.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"use client";

import { addTodo } from "@/actions";
export default function Button({ children }: { children: React.ReactNode }) {
return (
<button
className="border p-2 ml-2"
onClick={async () => {
const form = new FormData();
form.append("todo", "🤠");
await addTodo(form);
}}
>
{children}
</button>
);
}

在 src/app/page.tsx 中引入上面的 Button 组件即可。

useFormStatus

接下来我们说一下 Server Action 处理表单提交时常搭配使用的一些 API。首先是 useFormStatus,这是 React 官方的 hook,用于返回表单提交的状态信息,包括提交中、提交成功和提交失败。

这个 API 在使用的时候有一个注意点,就是不能和 form 标签直接放在同一个组件中,里面下面的写法就是错误的:

src\components\submit-form.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ...
export default async function Page() {
// pending will never be true
// #1
const { pending } = useFormStatus();
return (
<div className="p-10">
<form action={addTodo}>
{/* ... */}
{/* #2 */}
<button type="submit" className="border ml-2 p-2" disabled={pending}>
{pending ? "Adding" : "Add"}
</button>
</form>
</div>
);
}

那该怎么使用呢?这时候我们可以把这个 API 放到 form 标签下的组件的内部,像下面这样这样:

src/components/submit-button.tsx

1
2
3
4
5
6
7
8
9
10
11
"use client";
import { useFormStatus } from "react-dom";
export default function SubmitButton() {
const { pending } = useFormStatus();

return (
<button type="submit" disabled={pending} className="border ml-2 p-2">
{pending ? "Adding" : "Add"}
</button>
);
}

为了看到效果更佳只管一些,这时候咱可以在 src\actions\index.ts 文件的 Server Action 中加一个睡眠 …

有一点需要大家注意的是 useFormStatus 这个 hook 是在 react-dom 中引入的,它在 React18 版本还是个实验性的 API,我们现在使用的 Next.js 其实配套的是 React18 的版本。但是在 Next15 版本或者 React19 中, useFormStatus 这个 API 和 useFormState 这两个 API 一起被替换成了 useActionState,并且需要从 react 中引入,关于 useFormState 的使用我们待会会说。

useFormState

接下来再说一个 API,叫 useFormState,它可以拿到函数或 Server Action 的返回结果,根据这个结果呢,咱可以做一些后续处理。

它的语法格式是这样的,它可以接收一个函数和一个初始值,当然在 Next.js 中这个接收的函数可以是 Server Action,返回的是一个数组,数组中就包含了这个函数返回的状态和可以直接用于表达的 Server Action,或者叫 formAction,这时候它接收到的这个函数或者 Server Action 就会有两个参数,第一个参数是 prevState,第二个是提交表单时候接收到的 formData。

这听起来感觉有点复杂,咱同样给大家举例说明问题,例如咱继续对前面的案例进行改造,期望添加完成后在这儿给出一个提示消息,这时候咱看一些用 useFormState 该如何去实现。

由于要用到 useFormState,而他只能用于客户端组件,所以咱比较建议的做法还是抽离一个客户端组件进行下沉,然后咱这个页面再导入抽离的或者下沉的客户端组件就好啦。

src\components\submit-form.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"use client";
import { addTodo } from "@/actions";
import SubmitButton from "@/components/submit-button";
import ClientButton from "@/components/client-button";
import { useFormState } from "react-dom";

const initialState = {
message: "",
};
export default function Page() {
// #1
const [state, formAction] = useFormState(addTodo, initialState);
return (
<>
{/* #2 */}
<form action={formAction}>
<input type="text" name="todo" className="border p-2" />
<SubmitButton />
<ClientButton>牛牛</ClientButton>
</form>
{/* #3 */}
<p className="text-teal-500 mt-4 text-sm">{state?.message}</p>
</>
);
}

src\actions\index.ts

1
2
3
4
5
6
7
8
9
10
11
12
export async function addTodo(prevState: { message: string }, formData: FormData) {
// 这儿有个方法给大家提一下,叫 Object.fromEntries,它可以把 formData 转成普通对象的形式
const todo = formData.get("todo") as string;
data.push(todo);

revalidatePath("/");

return {
...prevState,
message: `add ${todo} success!`,
};
}

表单验证

Next.js 推荐基本的表单验证使用 HTML 元素自带的,如 required、type=”email” 等,例如我们希望必填,这时候就可以这样写:

src\components\submit-form.tsx

1
<input type="text" name="todo" className="border p-2" required />

对于更高阶的服务端数据验证,可以使用 zod 这样的 schema 验证库来验证表单数据的结构,例如我们期望输入的最小长度是 2,最大长度是 5,这时候借助 zod 就可以这样写:

先定义一个 Schema

1
const todoSchema = z.string().min(2, { message: "Must be 2 or more characters long" }).max(5, { message: "Must be 5 or fewer characters long" });

然后在 Server Action 中:

1
2
3
4
5
6
7
8
9
10
11
const validatedFields = todoSchema.safeParse(formData.get("todo"));

if (!validatedFields.success) {
// z.ZodError<string>
console.log(JSON.stringify(validatedFields.error));
return {
message: validatedFields.error.flatten().formErrors.toString(),
};
}

// ...

关于错误处理,一种是 try catch,在 catch 中 return 错误信息,页面中进行展示,另一种是通过 throw new Error(This is error is in the Server Action); 抛出错误信息,有最近的 error.tsx 页面进行处理。

Error boundaries must be Client Components.

Snippets Project

Changing Data with Mutations

线上效果预览:https://snippets-1tbv24oz5-ifers-projects.vercel.app/

接下来的一些课程咱做一个综合性的案例,案例的目标是,让大家学会,咱前面学习的那么多的知识点在实际当中该如何综合的应用起来。这个案例包含完整的增删改查,总共有四个页面,具体交互是这样的:

- 首先首页是一个列表,点击列表右上角的 New 按钮,会跳转到新增界面,它长这样,此时路由地址变成了 /snippets/new;

- 点击列表每一行右边的 View 按钮,会跳转到详情界面,它长这样,此时路由地址变成了 /snippets/1,当然根据前面咱学习的知识点,大家应该想到这儿其实是一个动态路由,也就是这个 1 可能会变成其他的 id;

- 点击详情界面的 Edit 按钮,会跳转到编辑界面,它长这样,此时路由地址变成了 /snippets/1/edit;

- 点击详情界面的 Delete 按钮,会删除这条数据,删除成功会跳转到首页。

好,这是关于咱待会要完成案例效果的说明。

1. 首先创建项目,需要大家注意的是,咱这儿还是先用 14 的版本;

1
npx create-next-app@14

2. 咱这个案例呢,期望用到 SQLite 这个数据库对数据进行存储,不过咱并不想直接写 SQL 语句,所以给大家推荐 Prisma 这个,现在比较流行的 ORM 工具,它可以来帮助我们操作数据库,如何使用呢?

首先要安装 Prisma,执行 npm i prisma,接下来通过 npx prisma init 命令来初始化 Prisma,并且通过 –datasource-provider sqlite 命令来指定数据库是 sqlite。所以这句话的完整意思是,初始化 Prisma 并指定数据库类型是 sqlite。

1
2
3
4
5
npm i prisma
# npx prisma init 初始化一个 prisma 项目
# --datasource-provider sqlite 指定数据库类型是 sqlite
# 这时候会生成 Prisma 文件夹和一个 .env 文件
npx prisma init --datasource-provider sqlite

这时候会发现多了两个文件,一个是 env,这里面指定了后面数据库存放的路径,一个是 schema.prisma,里面生成了一些初始的配置,我们先不用管,后面用到哪些的时候再说。

如何通过 Prisma 操作 SQLite

接下来看一下如何使用 Prisma 操作数据库。

首先要编写模型,打开 prisma\schema.prisma,咱这个案例操作的数据是 Snippet,代码片段,它至少应该有 id、title、code 这三个字段,所以可以这样:

1
2
3
4
5
model Snippet {
id Int @id @default(autoincrement())
title String
code String
}
1
2
3
4
5
6
# 根据 Prisma 模型创建数据库表
npx prisma db push
# 接下来咱通过 Navicat 给大家看一下根据 Schema 生成的数据库表结构

# 根据 Prisma 模型生成 Prisma 客户端用于操作数据库
npx prisma generate

好,现在也有了 Prisma Client,演示一下如何用它来操作数据库,例如咱希望往数据库添加一条数据,src\db\index.ts

1
2
3
4
5
6
7
8
9
10
iimport { PrismaClient } from "@prisma/client";

export const db = new PrismaClient();

db.snippet.create({
data: {
title: "Title!",
code: "const abc = () => {}",
},
}).then(console.log)

src/app/page.tsx 引入 import ‘@/db’,执行 npm run dev,刷新一下 http://localhost:3000/,让首页的代码执行,发现控制条进行了打印,说明这儿添加成功了,打开 Navicat 查看,也会发现数据库里确实多了一条数据。

1
2
3
4
5
import React from "react";
import "@/db";
export default function Page() {
return <div>首页</div>;
}

除了通过 Navicat 查看,大家也可以 VSCode 下载个插件叫 SQLite Viewer,也可以通过点击这个 db 文件直接进行查看。

添加功能

首先咱新建一个添加的页面,看一下这个图片,这个页面的路径应该是,src\app\snippets\new\page.tsx

  1. Create a prisma client to access our database

  2. Create a form in SnippetCreatePage

  3. Define a Server Action. This is a function that will be called when the form is submitted.

  4. In the Server Action, validate the users input then create a new snippet.

  5. Redirect the user to the Home Page, which lists all the snippets.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export default function SnippetCreatePage() {
return (
<form>
<h3 className="font-bold m-3">Create a Snippet</h3>
<div className="flex flex-col gap-4">
<div className="flex gap-4">
<label htmlFor="title" className="w-12">
Title
</label>
<input name="title" className="border rounded p-2 w-full" id="title" />
</div>
<div className="flex gap-4">
<label htmlFor="code" className="w-12">
Code
</label>
<textarea name="code" className="border rounded p-2 w-full" id="code" />
</div>
<button type="submit" className="rounded p-2 bg-blue-200">
Create
</button>
</div>
</form>
);
}

src\app\layout.tsx

1
<div className="container mx-auto px-12">{children}</div>

Server Action

src\app\snippets\new\page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { useFormState } from "react-dom";
import { db } from "@/db";
import { redirect } from "next/navigation";

export default function SnippetCreatePage() {
async function createSnippet(formData: FormData) {
// This needs tobe a server action!
"use server";

// Check the user's inputs and make sure they're valid
const title = formData.get("title") as string;
const code = formData.get("code") as string;

// Create a new record in the database
const snippet = await db.snippets.create({
data: {
title,
code,
},
});
console.log(snippet);
// Redirect te user back to the root route
// 注意这个是服务端跳转的方法
redirect("/");
}
return (
<form action={createSnippet}>
<h3 className="font-bold m-3">Create a Snippet</h3>
<div className="flex flex-col gap-4">
<div className="flex gap-4">
<label htmlFor="title" className="w-12">
Title
</label>
<input name="title" className="border rounded p-2 w-full" id="title" />
</div>
<div className="flex gap-4">
<label htmlFor="code" className="w-12">
Code
</label>
<textarea name="code" className="border rounded p-2 w-full" id="code" />
</div>
<button type="submit" className="rounded p-2 bg-blue-200">
Create
</button>
</div>
</form>
);
}
1
2
3
4
5
6
7
Title: Function that Adds Numbers

Code:

function add(a, b) {
return a + b
}

好,这时候打开数据库看一下数据有没有添加过来。

展示首页

src/app/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { db } from "@/db";
import Link from "next/link";
export default async function Home() {
const snippets = await db.snippet.findMany();
const renderedSnippets = snippets.map((snippet) => {
return (
<Link href={`/snippets/${snippet.id}`} key={snippet.id} className="flex justify-between items-center p-2 border rounded">
<div>{snippet.title}</div>
<div>View</div>
</Link>
);
});
return (
<div>
<div className="flex m-2 justify-between items-center">
<h1 className="text-xl font-bold">Snippets</h1>
<Link href="/snippets/new" className="border p-2 rounded">
New
</Link>
</div>
<div className="flex flex-col gap-2">{renderedSnippets}</div>
</div>
);
}
1
2
3
4
5
Function that Multiples Numbers

function multiply(a, b) {
return a * b
}

好,从添加到列表,我们再整体测试一下。

展示详情

接下来看一下详情页面的处理,还是分为两块去写,一个是静态界面,一个是详情数据的获取。

静态界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import Link from "next/link";
export default function Page() {
return (
<>
<div className="flex m-4 justify-between items-center">
<h1 className="text-xl font-bold">Function that multiple Numbers</h1>
<div className="flex gap-4">
<Link href="/snippets/1/edit" className="p-2 border rounded border-teal-500">
Edit
</Link>
<button className="p-2 border rounded border-teal-500">Delete</button>
</div>
</div>
<pre className="p-3 border rounded bg-gray-200 border-gray-200">
<code>const multiple = (a, b) => a * b</code>
</pre>
</>
);
}

接下来获取动态路由参数 id,然后根据 id 呢获取详情。

这儿呢,有个注意点,可以先给大家提一下,就是在 Next15 版本中,获取动态路由参数 params 的时候,写法发生了变化,如果你用的是 15 版本,前面要添加 await 才可以拿到 params,就像官方说的这样,https://nextjs.org/docs/messages/sync-dynamic-apis

And change it to this:

1
2
3
4
5
const { id } = await props.params;

const snippet = await db.snippet.findFirst({
where: { id: parseInt(id) },
});

Also, we need to update the Interface and wrap the params in a Promise:

1
2
3
4
5
interface SnippetShowPageProps {
params: Promise<{
id: string;
}>;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { db } from "@/db";
import Link from "next/link";
import { notFound } from "next/navigation";

// #1
interface SnippetShowPageProps {
params: {
id: string;
};
}
export default async function Page(props: SnippetShowPageProps) {
// #2
const snippet = await db.snippet.findFirst({
where: {
id: parseInt(props.params.id),
},
});
// #3
if (!snippet) {
// 这儿可以返回一段 JSX
// return <div>Sorry but we cant find that snippet.</div>
// 也可以调用 notFound,这样会触发加载最近的 not-found 页面
return notFound();
}

return (
<>
<div className="flex m-4 justify-between items-center">
{/* #4 */}
<h1 className="text-xl font-bold">{snippet.title}</h1>
<div className="flex gap-4">
<Link href="/snippets/1/edit" className="p-2 border rounded border-teal-500">
Edit
</Link>
<button className="p-2 border rounded border-teal-500">Delete</button>
</div>
</div>
{/* #5 */}
<pre className="p-3 border rounded bg-gray-200 border-gray-200">
<code>{snippet.code}</code>
</pre>
</>
);
}

src\app\snippets[id]\not-found.tsx

1
2
3
4
5
6
7
export default function SnippetNotFound() {
return (
<div>
<h1 className="text-xl bold">Sorry, but we couldnt found that particular snippet.</h1>
</div>
);
}

如果这个页面展示慢的话,咱这儿还可以定义一个 loading 页面,例如:src\app\snippets[id]\loading.tsx

1
2
3
export default function SnippetLoadingPage() {
return <div>Loading...</div>;
}

删除功能

咱可以通过两种方式来完成删除功能,第一种通过事件绑定,然后调用 Server Action 的方式,第二种还是通过 Form 表单配合 Serve Action 的方式去处理,咱分别给大家展示一下。

首先看一下事件绑定的方式。

src\actions\index.ts

1
2
3
4
5
6
7
8
9
"use server";
import { db } from "@/db";
import { redirect } from "next/navigation";
export async function deleteSnippet(id: number) {
await db.snippet.delete({
where: { id },
});
redirect("/");
}

src\components\snippet-del-button.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"use client";

import { deleteSnippet } from "@/actions";

export default function SnippetDelButton(props: { id: number }) {
const handleDelete = () => {
deleteSnippet(props.id);
};
return (
<button onClick={handleDelete} className="p-2 border rounded border-teal-500">
Delete
</button>
);
}
1
2
3
4
5
// Makes sure we have updated data before we attempt to navigate
// 还有一点优化,给大家说一下,建议外面包一个 startTransition,这样可以在数据变成完成之后再导航到另一个页面,这正是我们期望的
startTransition(async () => {
await deleteSnippet(props.id);
});

src\app\snippets[id]\page.tsx

1
<SnippetDelButton id={snippet.id} />

接下来再看一下 Form 表单配合 Server Action 的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import * as actions from "@/actions";
interface SnippetShowPageProps {
params: {
id: string;
};
}
export default async function SnippetShowPage(props: SnippetShowPageProps) {
// ...
const deleteSnippetAction = actions.deleteSnippet.bind(null, snippet.id);
return (
<div>
<form action={deleteSnippetAction}>
<button className="p-2 border rounded">Delete</button>
</form>
</div>
);
}

这两种方式都可以实现咱们的需求,那我们应该使用哪一种方式呢?

其实从文档的介绍来看,官方比较推崇现在的这种写法,就是 Form 表单配合 Server Action 的形式,咱也给大家说过这种方式的好处,例如它支持渐进增强,即便这时候我们禁用掉 JS 它也能正常工作。

当然第一种事件绑定的方式好像更加直观一些,或者更加的符合我们以往的编码经验,所以具体使用哪一种呢,大家根据情况自行选择就好啦,反正现在我们两种都掌握啦!

跳转到编辑

好,接下来咱继续完成代码编辑页面的内容,这儿呢,会有这么三步,第一步展示编辑的内容到这样一个组件当中,第二步收集输入的数据,第三步呢,点击提交调用 Server Action 更改数据库。

ok,咱先完成第一步,展示编辑的内容到这样一个组件当中,新建页面:src\app\snippets[id]\edit\page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { db } from "@/db";
import { notFound } from "next/navigation";
import SnippetEditForm from "@/components/snippet-edit-form";

interface SnippetEditPageProps {
params: {
id: string;
};
}

export default async function SnippetEditPage(props: SnippetEditPageProps) {
const id = parseInt(props.params.id);
const snippet = await db.snippet.findFirst({
where: { id },
});
if (!snippet) {
return notFound();
}
return <SnippetEditForm snippet={snippet} />;
}

当引入 @monaco-editor/react 这个组件的时候,你会发现报错了,这是因为这个组件它内部肯定使用了 Hook 相关的操作,而咱现在是一个服务端组件,所以根据咱前面讲的,大家回忆一下该如何使用第三方组件呢?

ok,是不是这个时候还需要我们手动的再建一个客户端组件,在咱们自己的客户端组件中引入第三方组件就好啦,当然有同学也会想,老师我直接把当前组件声明为客户端组件不就好啦,其实咱并不建议直接这样做,因为首先,如果声明了客户端组件,那这儿就不能直接使用 async/await 这种写法了,其次它也不太符合咱前面给大家说的客户端组件下沉的原则。

所以综合来看,咱还是建议单独再抽一个客户端组件,例如:src\components\snippet-edit-form.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
"use client";

import type { Snippet } from "@prisma/client";
import Editor from "@monaco-editor/react";
import { useState } from "react";
import * as actions from "@/actions";

interface SnippetEditFormProps {
snippet: Snippet;
}

export default function SnippetEditForm({ snippet }: SnippetEditFormProps) {
return (
<>
<Editor
height="40vh"
theme="vs-dark"
language="javascript"
defaultValue={snippet.code}
options={{
minimap: { enabled: false },
}}
/>
<form>
<button type="submit" className="p-2 border rounded">
Save
</button>
</form>
</>
);
}

好,这是关于这个界面的第一步数据展示,接下来咱继续完善第二步,收集数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
"use client";

import type { Snippet } from "@prisma/client";
import Editor from "@monaco-editor/react";
import { useState } from "react";
import * as actions from "@/actions";

interface SnippetEditFormProps {
snippet: Snippet;
}

export default function SnippetEditForm({ snippet }: SnippetEditFormProps) {
// 查看问答 Get value
const [code, setCode] = useState(snippet.code);
const handleEditorChange = (value: string = "") => {
setCode(value);
};

return (
<div>
<Editor
height="40vh"
theme="vs-dark"
language="javascript"
defaultValue={snippet.code}
options={{
minimap: { enabled: false },
}}
onChange={handleEditorChange}
/>
<form>
<button type="submit" className="p-2 border rounded">
Save
</button>
</form>
</div>
);
}

Server action cannot be defined in Client Components.

Installing the Monaco Editor in Next.js 15

In the upcoming lecture, we will be installing the Monaco Editor in our project. Next.js 15 now makes use of React v19 by default, which isn’t quite compatible with this library.

When installing you will need to add the –legacy-peer-deps flag:

1
npm install @monaco-editor/react --legacy-peer-deps

修改数据

处理方法 1

我们可以在父服务器页面定义 SnippetEditPage 一个 Server Action,然后将其作为 prop 传递到 SnippetEditForm

1
2
3
export default function SnippetEditPage() {
return <SnippetEditForm onSubmit={updateSnippet} />;
}

有一个注意点,通常服务器组件无法将时间处理程序向下传递给客户端组件,这是使用服务器组件的一个限制。

处理方法 2

单独定义 Server Action,src/actions/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
"use server";

import { redirect } from "next/navigation";
import { db } from "@/db";

export async function editSnippet(id: number, code: string) {
await db.snippet.update({
where: { id },
data: { code },
});
redirect(`/snippets/${id}`);
}

src\components\snippet-edit-form.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
"use client";

import Editor from "@monaco-editor/react";
import { useState } from "react";
import * as actions from "@/actions";

interface SnippetEditFormProps {
snippet: any;
}

export default function SnippetEditForm({ snippet }: SnippetEditFormProps) {
const [code, setCode] = useState(snippet.code);
const handleEditorChange = (value: string = "") => {
setCode(value);
};
const editSnippetAction = actions.editSnippet.bind(null, snippet.id, code);
console.log(snippet.code, 888883);
return (
<div>
<Editor
height="40vh"
theme="vs-dark"
language="javascript"
defaultValue={snippet.code}
options={{
minimap: { enabled: false },
}}
onChange={handleEditorChange}
/>
<form action={editSnippetAction}>
<button type="submit" className="p-2 border rounded">
Save
</button>
</form>
</div>
);
}

Server Forms with the UseFormState Hook

Error Handling with Server Actions

1. Remember, a big point of forms is that they can work without JS in the browser

2. Right now, forms in our pages are sending info to a server action

3. We need to somehow communicate info from a server action back to a page

4. React-dom (not react) contains a hook called ‘useFormState’ specifically for this

这都是因为我们试图在浏览器中不运行任何 JavaScript 的情况下完成这些工作。

useActionState in Next v15

In the upcoming lecture we will be implementing the useFormState hook in our code. If you are using Next.js v15, then, you will need to make a slight change to the import and name.

Change the import from this:

1
import { useFormState } from "react-dom";

to this:

1
import { useActionState } from "react";

Change the hook name from this:

1
const [formState, action] = useFormState(actions.createSnippet, {

to this:

1
const [formState, action] = useActionState(actions.createSnippet, {

useFormState 表单验证

1. 待会要用到 useFormState 这个 Hook,它只能在客户端组件使用,而客户端组件是不能使用这种函数级别的 Server Action 的,所以先把这个代码进行剪切:src\actions\index.ts;

src\app\snippets\new\page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"use client";

const initState = { message: "" };

export default function Page() {
const [state, formAction] = useFormState(createSnippet, initState);

return (
<form action={formAction}>
<div className="flex flex-col gap-4">
{/*...*/}
{state.message ? <div className="my-2 p-2 bg-red-200 border rounded border-red-400">{state.message}</div> : null}
<button className="rounded p-2 bg-blue-200" type="submit">
Create
</button>
</div>
</form>
);
}

src\actions\index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export async function createSnippet(prevState: { message: string }, formData: FormData) {
const title = formData.get("title") as string;
const code = formData.get("code") as string;

if (typeof title !== "string" || title.length < 3) {
return {
message: "Title must be longer",
};
}
if (typeof code !== "string" || code.length < 3) {
return {
message: "Code must be longer",
};
}

const snippet = await db.snippet.create({
data: {
title,
code,
},
});

redirect("/");
}

错误处理,src\app\snippets\new\error.tsx

https://nextjs.org/docs/app/api-reference/file-conventions/error

1
2
3
4
5
6
7
8
9
10
"use client";

interface ErrorPageProps {
error: Error;
reset: () => void;
}

export default function ErrorPage({ error }: ErrorPageProps) {
return <div>{error.message}</div>;
}

推荐 try / catch …,体验更好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export async function createSnippet(formState: { message: string }, formData: FormData) {
try {
const title = formData.get("title");
const code = formData.get("code");
if (typeof title !== "string" || title.length < 3) {
return {
message: "Title must be longer",
};
}
if (typeof code !== "string" || code.length < 3) {
return {
message: "Code must be longer",
};
}
// Create a new record in the database
const snippet = await db.snippet.create({
data: {
title,
code,
},
});
console.log("🚀 ~ createSnippet ~ snippet:", snippet);
// throw new Error("Failed to save to database");
} catch (err: unknown) {
if (err instanceof Error) {
return {
message: err.message,
};
} else {
return {
message: "Something went wrong...",
};
}
}
// Redirect the user back to the root route
redirect("/");
}

最后一个问题,关于 redirect(“/“); 的位置 … 不要直接把 redirect 只放到 try 中,这样的话 redirect 下面的发生错误时,catch 捕获的会是 redirect 的错误,而不是下面代码的错误。

关于构建

1. 添加的数据刷新后不见了。

/ 是静态的,走的是完全路由缓存,如何变成动态的? What makes a page dynamic?

  • Calling a ‘dynamic function’ or referencing a ‘dynamic variable’ when your route renders

    • cookies.set() / cookies.delete()
    • useSearchParams() / searchParams prop
  • Assigning specific route segment config options

    • export const dynamic = ‘force-dynamic’
    • export const revalidate = 0
  • Calling ‘fetch’ and Opting out of caching of the response

    • fetch(‘…’, { next: { revalidate: 0 } })
  • Using a dynamic route

    • /snippets/[id]/page.tsx
    • /snippets/[id]/edit/page.tsx

你可以通过不同的方式控制缓存,而不是完全禁用他。

1
2
3
4
5
Time-Based

On-Demand

Disable Caching

第一种

1
2
3
4
5
export const revalidate = 3
export default async function Page() {
const snippets = await db.snippets.findMany()
return <div>{ snippets.map(..) }</div>
}

第二种:在某些非常确定的时间点上,我们真正确定某些路径上的数据可能已经发生了变化

1
2
3
import { revalidatePath } from "next/cache";

revalidatePath("/snippets");

第三种

1
2
3
export const revalidate = 0;
// or
export const dynamic = "force-dynamic";

src\actions\index.ts

1
2
3
4
deleteSnippet;
revalidatePath("/");
createSnippet;
revalidatePath("/");

动态路由不一定是动态渲染

案例进行到这儿,其实还有一些优化空间,例如现在所呈现出来的列表对应的详情啊它其实是动态渲染的,通过前面的打包大家也能够发现,也就是在客户端请求页面地址的时候服务端会进行数据渲染。

动态渲染呢并不一定总是好的,例如他会丢失缓存带来的一些好处,我们其实还有办法可以进一步优化这个详情页面,这个办法就是使用静态生成,又叫 SSG。什么是 SSG 呢?

它说的是在构建阶段,就将页面编译为静态的 HTML 文件,比如这儿关于代码的详情,每个人看到的内容都是一样的,既然如此,那就没有必要在用户每次请求页面的时候,服务端再进行渲染。

干脆啊,在构建的时候直接获取数据,提前编译成 HTML 文件,等用户访问的时候啊,直接返回提前构建好的 HTML 文件,这样的话速度就会非常的快。

具体如何去做呢?

我们可以在详情页面通过导出一个叫做 generateStaticParams 的函数,他的语法格式是这样的,例如可以在里面请求所有的数据列表,然后返回一个数组,里面包含了一系列的参数对象,
这个函数啊,会在构建的时候被自动调用,并且数组里面的一个一个的参数会传递到 Page 里面生成一个一个的静态页面。

1
2
3
4
5
6
7
8
9
// npm run build,自动调用
export async function generateStaticParams() {
const snippets = await db.snippet.findMany();
return snippets.map((snippet: { id: number }) => {
return {
id: snippet.id.toString(),
};
});
}
1
2
3
// Next caches the result
<SnippetShowPage params={{ id: 1 }} />
<SnippetShowPage params={{ id: 2 }} />

代码写到这儿,应该还有一些问题,什么问题呢?例如咱给大家编辑一下这个页面看一下效果?

你会发现这个页面在刷新的时候,又回到了初始的状态,这并不是我们期望的,原因就是因为这个页面是静态生成的,但是我们编辑页面再回来的时候还是希望这儿能够看到最新的结果,这该如何去做呢?

src\actions\index.ts

1
revalidatePath(`/snippets/${id}`);