diff --git "a/24.06/nextjs14\346\225\231\347\250\213.md" "b/24.06/nextjs14\346\225\231\347\250\213.md" new file mode 100644 index 00000000..00e42568 --- /dev/null +++ "b/24.06/nextjs14\346\225\231\347\250\213.md" @@ -0,0 +1,807 @@ +--- +title: next.js教程 +description: 基于next.js14的教程 +tags: + - next.js + - 教程 + - 前端 + - ssr +--- + +# 1 前言 +本教程需要你有一定的前端基础,尤其是`react`基础,否则请先学习`react`;本教程基本是对官方文档的实践和再解读,使其通俗化。 + +本教程基于目前最新的`next.js` 14版本,如果你还没有安装`node.js`,请先安装`node.js>18.17`和`npm`,之后`next`有更新,可能部分内容不适用,请注意diff。 + +创建`next`项目,选择如下参数,使用`js`和`src`目录,这个是个人偏好,如果选择了其他选项,请自行注意diff。 +```bash +npx create-next-app +``` + + + +# 2 目录文件与路由 +这是创建完成后的目录结构: + +``` +└── 📁my-app + └── .eslintrc.json + └── .gitignore + └── jsconfig.json + └── next.config.mjs + └── package-lock.json + └── package.json + └── postcss.config.mjs + └── 📁public + └── next.svg + └── vercel.svg + └── README.md + └── 📁src + └── 📁app + └── favicon.ico + └── globals.css + └── layout.js + └── page.js + └── tailwind.config.js +``` + +挨个解释下每个文件用途: +- `public`放置静态资源,例如图片,字体等,通过`/next.svg`根路径访问。 +- `src`放置源码,其中的`app`目录最为重要,因为使用`app router`,所有的页面都在这个目录下. +- `next.config.mjs`是`next.js`的配置文件,可以配置`webpack`等。 +- `postcss.config.mjs`与`tailwind.config.js`是`tailwind`的配置文件。 +- `package.json`是`node.js`的配置文件,可以配置`npm`包的依赖。 +- `jsconfig.json`是js的一些配置,当前主要内容是配置了`@/*`等价于`./src/*`。 + +然后我们要把`app`目录中的内容展开说明一下,因为选择了`app router`,所以我们的所有页面都应该按照约定放置到app目录中。 + +## 2.1 page.js的作用 +`page.js`是最终呈现的页面代码,路由方式为: +- `/`会路由到`app/page.js`文件 +- `/a`会路由到`app/a/page.js`文件 + +`page.js`也可以改名为`page.jsx`等后缀格式,修改其内容,可以看到页面会发生变化,`export default`默认暴露出的react组件,会作为页面的内容,如下。 +```js :app/page.js +export default function Home() { + return ( +

Hello

+ ); +} +``` +![image](https://i.imgur.com/VKsB8nm.png) + +创建`app/a/page.js`文件,并访问`http://localhost:3000/a` + +![image](https://i.imgur.com/Ezw3pYA.png) + +## 2.2 layout.js的作用 +`layout.js`是页面的布局文件,上面的`page.js`的内容会作为`layout.js`中的`children`属性渲染。 + +`layout.js`的生效方式为叠加生效: +- `app/layout.js`会对所有页面生效,包括`app/a/page.js` +- `app/a/layout.js`会对a路径及其子路径生效,即`app/a/page.js`会有两层`layout`,先`app/layout.js`然后是`app/a/layout.js`。 + +刚才的页面有条纹状的样式,是因为`layout.js`引入了`globals.css`,我们简化下`layout.js` +```js :layout.js +export const metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} +``` + +![image](https://i.imgur.com/tnp1vHK.png) + +然后创建`app/a/layout.js`修改`/a`路径下的`layout`,会先渲染`app/a/layout.js`,然后再渲染`app/layout.js`,最后把`app/a/page.js`作为`children`。 + +![image](https://i.imgur.com/hUlkdws.png) + +![image](https://i.imgur.com/AwbPnr3.png) + +## 2.3 not-found.js与error.js +与`layout.js`类似,每个目录下面都可以设置`not-found.js`与`error.js`。分别用来处理该路径下,找不到页面和服务端报错的情况。 + +在演示之前我们把`globals.css`进行精简,并在`app/layout.js`中重新引入,以便使用`tailwind.css`提供的简洁`className`。 + +```css :globals.css +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +```js :layout.js +import './globals.css'; + +...... +``` + +添加`not-found.js`到`app`目录下,当访问不存在的页面时候就会返回该页面。 + +![notfound](https://i.imgur.com/4O6ghtE.png) + +添加`error.js`到`app`目录下,当服务端报错的时候会返回该页面,注意`error.js`必须用`"use client"`声明为客户端渲染的组件,后面我们会介绍是什么,以及为什么。 + +![error](https://i.imgur.com/9qPkNj2.png) + +## 2.4 `[slug]`与`[...slug]`动态路由 +上面介绍了`app`目录下每个目录都会构成路由中的一部分,录入`/a`对应`app/a/page.js`,而`/a/b/c`则对应`app/a/b/c/page.js`,这就是静态的路由。而动态路由是: +- `/b/xxx`对应`/b/[slug]/page.js`,其中`xxx`会以`{"slug":"xxx"}`的形式作为`params`参数,传入`page.js` +- 同样的多级动态路由则使用多个`[path]`即可,例如`/c/[year]/[month]` + +![image](https://i.imgur.com/DjkoX4l.png) + +![image](https://i.imgur.com/Dqgfbef.png) + +`[xx]`只能匹配路径中一个级别,如果想要匹配多级,则使用`[...xx]` + +![image](https://i.imgur.com/DlC8mMW.png) + + + +## 2.5 `_dir`私有目录 +`app/a`会被暴露到`/a`路径下,而`_`开头的目录名默认不会被创建为路径,例如`_dir/page.js`并不能通过`/_dir`访问到,可以用来放置一些与`UI`无关的代码,比如一些server端的文件操作等。 + +![image](https://i.imgur.com/aCs0eYU.png) + +如果一个工具类`util.js`,你可以放到`app/_utils`目录下,来表示他是`app`的一部分,不会在`app`之外被使用。也可以放到`src/utils`目录下,他表示可以在整个项目级别被访问。 +## 2.6 `(group)`分组路由 +中括号`[slug]`是动态路由,而小括号路径`(group)`是分组路由,分组目录不参与路由的路径,只是把多个目录放到同一个目录下。一方面是结构更清晰,另一方面可以在分组级别创建`layout.js`对分组内生效。 + +![image](https://i.imgur.com/MnLcc4v.png) + +## 2.7 loading.js与template.js +`loading.js`表示页面加载时候样子,他会在加载的过程中替代`page.js`的位置,直到后者加载完成,例如服务端需要一些io操作,获取数据之后才能渲染,此时就会触发`loading`,此外`loading.js`可以目录级别指定/ +![image](https://i.imgur.com/Y3BUuCr.gif) + +`template.js`与`layout.js`非常相似,唯一的不同在于,使用`Link`跳转页面的时候,如果发现前后页面使用相同的`layout`,则这部分组件不会卸载,只会更新变化的内容。而使用`template`则会强制卸载并重新装载组件。 + +我们在上面`login`和`register`,页面下方添加``使得两个页面可以跳转: +```js :login.js +import Link from "next/link"; + +export default function Login() { + return ( +
+

登录

+
+ + 切换到注册页 + +
) +} +``` +```js :register.js +import Link from "next/link"; + +export default function Login() { + return
+

注册

+
+ + 切换到登录页 + +
+} +``` +然后在`(auth)`目录下添加`template.js`并修改`layout.js` +```js :template.js +export default function AuthLayout({children}) { + return
+ +
{children}
+
+} +``` +```js :layout.js +export default function AuthLayout({children}) { + return
+

+ Welcome to my website +

+ +
{children}
+
+} +``` + +此时切换页面,就会发现,`layout`下的`input`组件没有卸载,仍然保留原来的value,而`template`中的组件会被卸载,重新渲染。 + +![image](https://i.imgur.com/BjCYi1V.gif) + +当然这是next中的`Link`切换才有的效果,如果是普通的`a`标签,或者直接地址栏重新输入地址,则都会卸载。 + +## 2.8 `@parallel`并行路由 +`@dir`目录代表的是并行路由,用`slot`的概念更确切一些。当我们创建`app/f/layout.js`页面,希望页面中有多个槽位,每个位置展示不同的内容,并且需要根据路径变化而变化。 +```js :app/f/layout.js +export default function Parallel({left, right, children}) { + return
+
{left}
+
{right}
+
+} +``` +`layout.js`中的参数`left`和`right`通过,当前目录下的`@left/page.js`和`@right/page.js`传入,不需要`import`自动传入。而`children`则是当前目录下的`page.js`这里我们没有创建该文件,忽略`children`变量即可。 + +到这为止,效果与我们直接在`layout.js`或者`page.js`中去`import left right`是一样的。但是每个目录下又可以有新的目录,这样就会产生复杂的路由搭配。 + +``` +└── 📁f + └── 📁@left + └── page.js + └── 📁@right + └── 📁item + └── page.js + └── page.js + └── layout.js +``` +在`@right`下面创建`item`目录,并创建`page.js`,并修改`right.js`,添加一个`Link`标签,此时跳转到`/f/item`,左边还是`Left`组件,右边从`Right`变成了`Item`组件。 +```js :right.js +import Link from "next/link"; + +export default function Right() { + return <>

Right

+ GoToItem + +} +``` +![image](https://i.imgur.com/OCVl7Ad.gif) + +但是通过`url`直接访问是404,这是因为直接访问的时候,左边的组件也需要并行去路由,此时找不到`@left/item/page.js`,就404了,解决方案是通过`@left/default.js`来配置并行路由的默认值。 + +当然也可以直接把`@left/page.js`重命名为`@left/default.js`即可。 + +## 2.9 `(.)path`拦截路由 +`(.)path`这是一个目录的名字,这个`(.)path/page.js`会拦截,当前路径下想要通过`Link`跳转到`./path`这个路径的请求。 + +例如在`app/g`下创建如下路径 +``` +└── 📁g + └── 📁g1 + └── page.js + └── page.js +``` +其中g配置`Link`跳转`/g/g1`而g1则跳转到`/g`,实现互相跳转。 + +![img](https://i.imgur.com/xp5Svox.gif) + +此时,创建`g/(.)g1/page.js`实现拦截: +```js :g/(.)g1/page.js +import Link from "next/link"; + +export default function G1() { + return <> +

被拦截后的g1

+ Go To g + +} +``` +注意,如果你的系统下没有被拦截,可能是`developer`模式导致的bug,可以尝试`npm run build && npm run start`再试试看,这个bug可能在后续的版本会修复。 + +![image](https://i.imgur.com/NrMJSaX.gif) + + +![image](https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F37d97d0f7eea431fa9185676bd798645?format=webp&width=2000) + +如果想要拦截上一级则使用`(..)path`如果是上面两级就`(..)(..)path`,如果是拦截根路径下的则可以直接使用`(...)path`。 +## 2.10 `route.js`配置api接口 +与`page.js`类似,`route.js`可以配置http接口,其中方法名决定了请求的类型例如下面`GET`方法代表可以处理`GET`请求。 + +注意:这里的`GET`是固定的写法,`export`的不是`default`而是`GET`这个函数,创建`POST`函数在相同文件下,就对应`POST`请求,其他方法类似的。 + +![image](https://i.imgur.com/zBSH1fY.png) + +避免在同一个目录下,同时存在`page.js`和`route.js`,会导致只有后者生效。注意在`route.js`中入参的request,返回的response都是`Web Fetch API`标准定义的类型,[参考](https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API),大多数操作都可以参考mdn,例如`cookie` `header`等,下面只列出部分常见的。 + +参数与返回值说明: +```js +// POST /posts/1?query=hi {a:1} +// params = {id:1} +// query = hi +// jsonFromParam = {a:1} + +// request是Request类型, params是当route.js位于[slug]中获取路径参数用的 +export async function POST(request, {params}) { + const searchParams = request.nextUrl.searchParams; + // 查询字符串,nextUrl是额外注入的,非web api自带 + // 也可以用const { searchParams } = new URL(request.url); + + const query = searchParams.get("query") // searchParams.query也行 + const jsonParam = await request.json(); // 解析json参数 + const formParam = await request.formData(); // 解析form参数 + // 返回json结果 + return Response.json({id:1, name: "frank"}) + // 等价于 + // return new Response(JSON.stringify({id:1, name: "frank"}), { + // headers: { + // "Content-Type": "application/json" + // }, + // status: 201, + // } + // ) +} +``` +对于返回值可以是WebAPI中的`Response`如上,但为了简化操作,也可以使用`NextResponse`是继承自`Response`的。 +```js +return NextResponse.redirect(new URL('/', request.url)) +// 重定向到另一个页面/接口 + +return NextResponse.rewrite(new URL('/', request.url)) +// 内容是另一个页面/接口,但是路径还是当前的 +``` +!!注意:api的内容在prod模式下,会被缓存很久,要想禁用缓存,可以在`route.js`中添加 +```js :route.js +export const dynamic = 'force-dynamic' // 默认值auto,会尽可能占用内存来缓存 +``` +## 2.11 `middleware.js`配置中间件 +`/src/middleware.js`会拦截所有请求,进行注入处理,注意中间件是全局的,并且是在`src`目录,即和`app`目录同级下。 +```js :middleware.js +export function middlreware(request) { + // 对request进行校验,例如路径,cookie,header等等判断权限 + if (url != login && 没有登陆) { + return NextResponse.redirect(new URL('/login')) + } + return NextResponse.next(); // 代表通过,继续访问该路径 +} +``` +## 2.11 目录文件与路由小结 +文件名: +- `layout.js`布局页,每个目录下可以有一个 +- `template.js`模板页,每个目录下可以有一个 +- `page.js`内容页,每个目录下可以有一个 +- `loading.js`默认加载页,每个目录下可以有一个 +- `not-found.js`默认的404页 +- `error.js`默认的报错页 +- `default.js`在并行路由中的默认slot +- `route.js`后端接口 +- `middleware.js`中间件,拦截处理所有请求的 + +目录名: +- `a`参与路由`/a` +- `a/b`参与路由`a/b` +- `_private`不参与路由 +- `[slug]`与`[...slug]`动态路由 +- `(test)`分组,不参与路由 +- `@slot`并行路由 +- `(.)path` `(..)path` `(...)path`拦截路由 + +# 3 渲染 +`next` `remix` 等框架都是服务端渲染(SSR server side rendering),与之对应的客户端渲染(CSR client side rendering),这两个概念我们简单介绍下。 + +正常的`react`项目是CSR,他的交互流程是,客户端请求页面,服务端返回的`html`和`js`代码,其中`html`代码中,只有一个`
`,等`js`下载完成后,会在客户端动态的把各种`dom`追加到这个根`div`上面去,形成最后的页面,这就是客户端渲染。 + +以前的`php`项目都是SSR,他的交互流程是,客户端请求页面,服务端同样返回`html`和`js`代码,只不过服务端在返回之前会识别出其中服务端需要计算的部分例如`

`,就会将`php`标签在服务端识别出来,并且运行其中的代码把最终的输出内容替换到这个地方,最终返回给客户端的代码是`

Frank

`这样的html代码,这就是服务端渲染。 + +服务端渲染很早就有了,他在性能、网络传输等方面都要好过客户端渲染,但是因为前端框架的演进,被客户端渲染替代。而`next`等框架又回到了服务端渲染,算是一种“倒退”,只不过现在的服务端渲染比当初的更加复杂,可以使用同一种语言js、同一套框架react、并且数据传输和用户体验上更加无缝。 + +`next`中不同的url路径,有三种重要的服务端的渲染模式: +- 静态渲染,如果发现该路径下的页面是完全不会改变的,会优先按照静态渲染,直接生成`html`页面,后续不会变了。 +- 动态渲染,如果有读取`cookie` `[slug]`等,根据参数有不同的行为,则无法预渲染,就会使用动态渲染,即每个请求来了在服务端渲染。 +- 流渲染,拆分同一页面多个组件的加载,用`Suspense`组件wrap,达到并行路由相同效果,先加载的先显示,是一种更精细的渲染控制。 +## 3.1 静态渲染(预渲染) +我们新建一个`next app`,简化一下`src/app/page.js`,如下: +```js :page.js +export default function Home() { + return
hello
; +} +``` +通过`npm run build`的日志,能看到一共俩页面,一个是首页,还有一个是默认的404页面,此外还有一些js等文件,这两个页面前面是个圆圈,下面注释说这是静态预渲染的页面,也就是三种渲染的第一种。默认能够预渲染的,都会用这种方式。 + +![image](https://i.imgur.com/mzMLRGM.png) + +我们在构建完成后,到`.next/server/app/index.html`中可以看到`/`对应的代码,实际访问`/`的时候,就是直接获取的这个`html`文件。 + +![image](https://i.imgur.com/dozBdZn.png) + +![image](https://i.imgur.com/nWZ4Myh.png) + +直接访问`localhost:3000`也能看到相同的代码,从这个代码中我们会看到有一些必要的js文件,此外下方有`self.__next_f.push`字样的代码,内容是一个奇怪的格式(他与index.rsc内容一致),这个函数其实是`next`自己维护的,用来动态的修改页面的内容的,只不过这里我们是纯静态的,内容与他函数中的内容完全一致,所以看上去没有任何效果。 + +例如我们修改`index.html`来探究`self.__next_f.push`的作用,这里我将`title` `viewport` `description`直接注释掉了,然后`npm run start`。 + +![image](https://i.imgur.com/mdEhKQl.png) + +![image](https://i.imgur.com/0muBPC4.png) + +这就是`next`提供的动态加载的机制,在`html`的最后通过这种特殊的`payload`格式,可以对当前的页面进行调整,可以调整展示的内容,也可以动态的去加载其他`css` `js`文件,提供更复杂的后续逻辑。我们会在后续的两种渲染种看到。 + +## 3.2 动态渲染(服务端即时渲染) +使用`[slug]`或使用`cookie`是最简单的造成页面只能动态渲染的方式,动态的参数是无法预知他的取值的。 +```js :page.js +import { cookies } from "next/headers"; + +export default function Home() { + console.log(cookies().getAll()); + return
hello
; +} +``` +重新`build`,此时`/`不再是静态渲染,而是动态渲染,如下: + +![image](https://i.imgur.com/a5hCmGW.png) + +此时访问页面,发现页面代码与之前完全一样,但是找不到`index.html`了,这就是动态渲染,或者叫即时渲染,是请求过来的时候,服务端临时计算出来的`html`代码并返回的。 + +![image](https://i.imgur.com/5DqAkcz.png) + +接下来:我们设置一个等待时间,然后设置`loading.js`。 +```js :page.js {4} +import { cookies } from "next/headers"; + +export default async function Home() { + await new Promise(res=>setTimeout(res, 3000)) + console.log(cookies().getAll()); + return
hello
; +} + +``` +```js :loading.js +export default function Loading() { + return
Loading...
+} +``` +然后重新`build`,注意一定要保留上面`cookies`代码,不然就会直接静态渲染了。此时打开页面会显示`Loading...`,3s后显示`hello`,我们查看页面代码: + +在页面刚加载3s内的时候,我们点开`html`的源码如下,发现页面中确实是有个`loading`的div。 + +![image](https://i.imgur.com/7YhyYV4.png) + +3s后html的代码会被自动更新,这是http提供的一种流式加载的技术,在后面流渲染中也是使用了这个技术, + +![image](https://i.imgur.com/GMDcRmt.png) + +3s后的`html`代码中,会多出一部分代码,新追加了一个隐藏的`div`,id是`S:1`,并追加了一段js,把`P:1`用`S:1`给替换掉了。 + +我们可能希望页面是动态渲染的,但是被识别成了静态渲染,例如直接`
{new Date()}
`就会被识别为静态渲染,导致时间一直固定死了,此时我们希望该页面是动态渲染的,可以在`page.js`中强制指定`dynamic`,在之前api部分提到过。 +```diff :page.js ++ export const dynamic = 'force-dynamic'; //默认是auto +``` + +## 3.3 流渲染 +流渲染的原理与上面介绍的类似,流渲染为了解决一个页面要么是静态渲染,要么是动态渲染的问题,当一个页面有多个组件,例如`sider` `header`等组件渲染很快,只有`content`组件渲染较慢,需要请求db数据等情况。那么就适合把页面拆分,采用流渲染,当然如果仔细看这篇文档的话,会发现`并行路由`其实就解决了这个问题,只不过`流渲染`提供了更小代价的写法,两者效果一致。 + +例如我们把`page.js`改为由`Left`和`Right`组成,其中左边是需要1s加载完成,而右边是3s,此时的效果是前3s,页面都是`loading...`,然后一下子左右都加载出来。 + +![image](https://i.imgur.com/feGMBgS.gif) + +添加`Suspense`组件后,效果就变成`left`先加载完就会先展示,然后是`Right`。 +```js :page.js +import Left from "./left"; +import Right from "./right"; +import { Suspense } from "react"; + +export const dynamic = 'force-dynamic'; // 强制指定动态渲染 +export default async function Home() { + return
+ Left is loading
}> + + + Right is loading}> + + + ; +} +``` +![image](https://i.imgur.com/GsqItlu.gif) + +其原理与之前介绍的一样,都是用了http的一边加载一边展示的特性,标注为未加载完成,就可以后续持续向`html`代码中注入内容。 + +![image](https://i.imgur.com/QkMbYeh.png) + +加载中,不断追加html内容的过程中,html的标签其实`body` `html`等标签都是未关闭的,只不过浏览器能自动纠错,帮我们关闭,这里也是利用了这个特性。 + +![iamege](https://i.imgur.com/sZhcNeu.png) + +## 3.4 渲染小结 +`next`提供了三种渲染方式,大部分时候我们不需要关注具体每个页面使用的渲染方式是什么,因为`next`会自动帮我们找到合适的渲染方式,还可以用`export const dynamic = 'force-dynamic';`来强制指定动态渲染。 + +一般被检测到不会有变化的内容,就会使用静态渲染,使其直接变成`html`静态文件,这是速度最快的;如果使用了`cookie` `[slug]`等动态参数,那么就只能使用动态渲染;而动态渲染中还有一种更细粒度控制不同组件渲染生命周期的流渲染。 + +# 4 服务端组件与客户端组件 +`next`虽然是`SSR`,但是又引入了react的服务端组件(React Server Component/RSC/SC)和客户端组件(RCC)的概念,这里会有一些让人困惑。如今的`next`已经变成了`SSR` `CSR` `Static` `Dynamic` `SSG` `ISR` `RSC` `RCC`等诸多技术混合一体的复杂技术框架了。 +- `SSR`与`CSR`: `next`基本是`SSR`服务端渲染,但是上面`流渲染`的模式,也会推送`js`到客户端渲染。 +- `Static` `Dynamic` `SSG` `ISR`: 都是指服务端渲染的一些策略。页面没有数据纯静态的,会提前渲染出`html`这是`Static`,页面有数据获取,但是提前`generateStaticParams`配置了,这就是`SSG`本质也是`Static`一种,只不过`next`中将两种区分开了,`ISR`增量静态生成与`SSG`辅助产生的,当提前配置的路径有新增时候,可以采用`ISR`增量的生成新的静态页面。`Dynamic`则是纯动态服务端渲染,上面看到过了。 +- `RSC`与`RCC`: 默认都是服务端组件,只有`use client`的才是客户端组件,服务端组件运行环境是`Nodejs`,客户单是浏览器,服务端组件可以访问`fs` `db`等,客户端则可以访问`document` `window`等,此外对于事件(点击事件等)的处理只能在`RCC`中处理。 + +注意:并不是客户端组件就一定是客户端渲染,服务端组件就一定服务端渲染,只是运行的环境和可访问的api不同。`next`中基本都是服务端渲染的,客户端组件也是在服务端渲染的。只不过与服务端组件不同的是发送的`js`的代码会不一样。 +## 4.1 服务端组件 +前面介绍的都是服务端组件,运行环境是`Nodejs`,所以我们可以把一些诸如`DB`查询、`api`查询等后端的工作放在这里,配合`loading.js`或者`Suspense`可以实现很好的加载中用户交互,实现与`useQuery`一样的效果,`searchParams`参数是从url中解析查询字符串,会使当前组件是动态渲染。 +```js :page.js +export default async function Home({ searchParams }) { + const id = searchParams.id || 1; + const json = await queryDataFromApi(id) + return ( +
+

id:{json.id}

+

title:{json.title}

+

completed:{"" + json.completed}

+
+ ); +} + +async function queryDataFromApi(id) { + const res = await fetch('https://jsonplaceholder.typicode.com/todos/'+id) + const json = await res.json() + return json +} +``` +### 4.1.1 服务端组件特定功能 +export的特定配置项: + +1 `export const dynamic = 'auto'`指定当前页面强制动态或静态渲染则使用`force-dynamic | force-static`。上面已经看到过了。 + +2 `export function generateMetadata()`与`export const metadata`作用一样,是定义当前页面的``部分。上面也看到过了。 + +3 `export async function generateStaticParams()`对动态页面也进行静态参数缓存,例如`/todo/:id`路由下,我知道id目前的取值是0-9,那么可以提前生成这10个页面,超过9的,还按照动态的方式渲染。此功能非常常用,这里展示用法,创建`todo/[id]/page.js`用于处理`/todo/:id`的页面。 +```js :todo/[id]/page.js +export default async function Home({ params }) { + const { id } = params; + const json = await queryDataFromApi(id) + return ( +
+

id:{json.id}

+

title:{json.title}

+

completed:{"" + json.completed}

+
+ ); +} +async function queryDataFromApi(id) { + const res = await fetch('https://jsonplaceholder.typicode.com/todos/' + id) + const json = await res.json() + return json +} +export async function generateStaticParams() { + const arr = [] + for (var i=0; i<10; i++){ + arr.push({id: "" + i}); + } + return arr; +} +``` +构建的时候0-9的页面就会静态构建,这里是`SSG(Static Site Generator)` + +![image](https://i.imgur.com/dq40lzU.png) + +![image](https://i.imgur.com/Ia9U1vd.png) + +访问0-9的时候,速度很快因为已经渲染成html了,而访问>9的页面时候会慢,因为此时是动态渲染,去发起fetch请求数据了。 + +![image](https://i.imgur.com/GjbNiEN.gif) + +4 `export const dynamicParams = true` 上面提到的没有静态生成的页面id>9的,默认是动态渲染,如果这里配置为`false`,则直接404。 +```diff :todo/[id]/page.js ++ export const dynamicParams = false +``` +![image](https://i.imgur.com/mlOF1rM.png) + +5 `export const revalidate = false` 同样与`generateStaticParams`有关,是重新构建的时长`false | number`,可以配置一个数字代表秒数,多少秒后,我们将前面代码`generateStaticParams`去掉,观察默认行为。 +```diff :todo/[id]/page.js ++ export const dynamicParams = true //改回默认值或删除 +- export async function generateStaticParams() { +- const arr = [] +- for (var i=0; i<10; i++){ +- arr.push({id: "" + i}); +- } +- return arr; +- } +``` +![image](https://i.imgur.com/oVNEUG4.gif) + +我们发现默认不会有`ISR`增量的静态页面`1.html`产生,但是访问还是变快了,是因为有`fetch-cache`会对`fetch`函数进行了请求级别的缓存。 + +```js :todo/[id]/page.js ++ export const revalidate = 10 +``` +![image](https://i.imgur.com/mAH5S5R.gif) + +↑当增加了`revalidate`为10s,发现和原来一样,并没有增量产生新的页面,因为页面构建的时候没有找到`generateStaticParams`,因而按照`dynamic`构建了,所以我们还得加回来如下,返回个空数组即可,只是为了标识该页面,使其`SSG`构建。 +```js :todo/[id]/page.js ++ export const revalidate = 10 ++ export async function generateStaticParams() { ++ return []; ++ } +``` +![image](https://i.imgur.com/L1hNiOT.png) + +当我们请求页面的时候,就发现是有`ISR增量构建静态html`,如下,并且该增量构建页面的有效期是配置的10s,10s后请求`todo/1`会重新构建这个页面。 + +![image](https://i.imgur.com/SWKu9sB.gif) + + +可以直接使用的函数: + +1 `fetch()`完全兼容WebAPI中的`fetch`功能,不需要额外`import node-fetch`,就可以直接使用,并且有缓存配置,上面其实看到了`fetch-cache`目录。也就是该函数默认的结果都会缓存。 +```js +fetch(`https://...`, { cache: 'force-cache' | 'no-store' }) +// 是否开启缓存的配置,默认是force-cache + +fetch(`https://...`, { next: { revalidate: false | 0 | number } }) +// 如果开启缓存,这个配置是决定有效期的,默认false是无限长缓存 +// 0是关闭缓存,1是1s有效期... +``` +2 `cookies()`上面已经看到了,该函数能获取到`cookie`中的内容,并且会导致组件动态渲染。 +```js +import { cookies } from 'next/headers' +... + const cookieStore = cookies() + const theme = cookieStore.get('theme') +``` +3 `headers()`请求头 +```js +import { headers } from 'next/headers' +... + const headersList = headers() + const referer = headersList.get('referer') +``` +4 `notFound()`跳转到404页面,该函数是抛出一个特定的异常实现的,因而不需要return +```js +import { notFound } from 'next/navigation' +...... + if (!user) { + notFound() // 抛出异常实现的,不用管后面是否有代码 + } +``` +5 `redirect(path, 'replace|push')`跳转,也是通过一个特定的异常抛出实现的,`replace`这个一般不用改。 +```js +import { redirect } from 'next/navigation' +... + if (!user) { + redirect('/login') + } +``` +## 4.2 客户端组件 +客户端组件,主要用来处理用户交互事件和钩子函数,以及访问浏览器相关的api。例如在服务端组件中使用`onClick`会报错 +```js :page.js +export default function() { + return <> + + +} +``` +就会报错 +``` + ⨯ Error: Event handlers cannot be passed to Client Component props. + + +} +``` +相对应的如果声明为客户端组件,就无法使用服务端的api和功能了。`build`会发现客户端组件是`Static`静态预渲染的,所以客户端组件只是说js代码会在浏览器运行,但是渲染还是服务端渲染。 + +![image](https://i.imgur.com/pwdFWF8.png) + +这个始终在服务端渲染的模式,会导致一个问题,就是如果在渲染的函数中,使用了`document`等变量,因为渲染是服务端渲染,是没有这个变量的,此时就会报错。那该如何正确的使用客户端组件调用浏览器的api呢? + +就是通过`useEffect`,这一点非常非常重要,用`useEffect`钩子,确保一定是在客户端运行的整段代码,因为是个回调函数,所以build的时候执行不到里面的代码,这部分代码会发送到客户端去执行,避开了服务端渲染时,访问`document`变量。 +```js :page.js +'use client' + +import {useEffect} from 'react' + +export default function App() { + useEffect(() => { + const dom = document.getElementById("123") + console.log(dom) + }) + return <> + + +} +``` +而如果是引入的第三方库,直接在`import`的时候就需要访问`document`等,那么就需要在`useEffect`中动态引入,例如`asciinema-player`就是这样的组件,他在next中正确的使用姿势如下。 +```jsx +'use client' +import React, { useEffect, useRef } from 'react'; +import 'asciinema-player/dist/bundle/asciinema-player.css'; + +const AsciinemaPlayer = ({ src, options }) => { + const playerRef = useRef(null); + const hasInitialized = useRef(false); + useEffect(() => { + // 动态导入 asciinema-player 以确保它只在客户端加载 + import('asciinema-player').then((asciinemaPlayer) => { + if (playerRef.current && !hasInitialized.current) { + asciinemaPlayer.create(src, playerRef.current, options); + hasInitialized.current = true; + } + }); + }, []); + + return
; +}; + +export default AsciinemaPlayer; +``` + +客户端组件,只有万不得已才去使用,并且尽量保证需要客户端组件的部分,单独拆成组件,大部分仍用服务端渲染,客户端只在组件树的最低层进行使用。 + +因为客户端组件有传染性,客户端组件中`import`的组件都会在这里变成客户端组件,而服务端组件没有传染性,可以引入客户端组件,井水不犯河水。 + +### 4.2.1 客户端组件特定功能 +除了事件和react钩子,next提供了特定的钩子: + +1 `useParams()`与服务端组件的入参`{params}`功能一致 +```js +'use client' + +import { useParams } from 'next/navigation' + +export default function ExampleClientComponent() { + const params = useParams() + console.log(params) + return <> +} +``` + +2 `usePathname()`获取当前路径,服务端组件中没有这个功能 +```js +'use client' + +import { usePathname } from 'next/navigation' + +export default function ExampleClientComponent() { + const pathname = usePathname() + return

Current pathname: {pathname}

+} +``` + +![img](https://i.imgur.com/atyNKUx.png) + +3 `useRouter()`跳转,官方建议不要用,而是使用`` +```js +'use client' + +import { useRouter } from 'next/navigation' + +export default function Page() { + const router = useRouter() + + return ( + + ) +} +``` + +4 `useSearchParams`与服务端组件参数中`{searchParams}`功能一致,解析查询字符串。 +```js +'use client' + +import { useSearchParams } from 'next/navigation' + +export default function SearchBar() { + const searchParams = useSearchParams() + + const search = searchParams.get('search') + + // URL -> `/dashboard?search=my-project` + // `search` -> 'my-project' + return <>Search: {search} +} +``` +## 4.3 交叉使用的注意事项 \ No newline at end of file diff --git a/app/blog/[month]/[slug]/page.js b/app/blog/[month]/[slug]/page.js index 1aa54482..036451e8 100644 --- a/app/blog/[month]/[slug]/page.js +++ b/app/blog/[month]/[slug]/page.js @@ -17,6 +17,7 @@ import querystring from 'querystring'; import Discussion from '@/app/components/Discussion'; import { Button, Card, Tooltip, DirectoryTree } from '@/app/components/Antd'; import { Tabs, Item } from '@/app/components/Tabs'; +import AsciinemaPlayer from '@/app/components/AsciinemaPlayer'; export default async function Post({ params }) { let { month, slug } = params; @@ -91,7 +92,8 @@ export default async function Post({ params }) { Tooltip, DirectoryTree, Tabs, - Item + Item, + AsciinemaPlayer }} />
diff --git a/app/components/AsciinemaPlayer.jsx b/app/components/AsciinemaPlayer.jsx new file mode 100644 index 00000000..fe9e8fea --- /dev/null +++ b/app/components/AsciinemaPlayer.jsx @@ -0,0 +1,21 @@ +'use client' +import React, { useEffect, useRef } from 'react'; +import 'asciinema-player/dist/bundle/asciinema-player.css'; + +const AsciinemaPlayer = ({ src, options }) => { + const playerRef = useRef(null); + const hasInitialized = useRef(false); + useEffect(() => { + // 动态导入 asciinema-player 以确保它只在客户端加载 + import('asciinema-player').then((asciinemaPlayer) => { + if (playerRef.current && !hasInitialized.current) { + asciinemaPlayer.create(src, playerRef.current, options); + hasInitialized.current = true; + } + }); + }, []); + + return
; +}; + +export default AsciinemaPlayer; \ No newline at end of file diff --git a/app/demo/page.jsx b/app/demo/page.jsx new file mode 100644 index 00000000..feafd48b --- /dev/null +++ b/app/demo/page.jsx @@ -0,0 +1,10 @@ +import AsciinemaPlayer from '@/app/components/AsciinemaPlayer' +export default function() { + return <> + + +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index e9a4e08c..7baa21c8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -200,4 +200,9 @@ nav>ol.toc-level-1 { .toc-wrapper .toc a { font-weight: 700; color: var(--w-indigo-light); +} + +.markdown-body .ap-player pre { + margin: 0; + padding: 0; } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 41baa621..5645085e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@react-types/shared": "^3.23.1", "@types/mdx": "^2.0.13", "antd": "^5.18.0", + "asciinema-player": "^3.7.1", "clsx": "^2.1.1", "esbuild": "^0.21.4", "esbuild-plugin-css-modules": "^0.3.0", @@ -3978,6 +3979,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asciinema-player": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.7.1.tgz", + "integrity": "sha512-zDJteGjBzNQhHEnD0aG7GqV3E53sOyKb1WCxKNRm2PquU70Lq3s4xxb91wyDS0hBJ3J/TB8aY3y8gjGPN+T23A==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "solid-js": "^1.3.0" + } + }, "node_modules/assert": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -10881,6 +10891,25 @@ "randombytes": "^2.1.0" } }, + "node_modules/seroval": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.0.7.tgz", + "integrity": "sha512-n6ZMQX5q0Vn19Zq7CIKNIo7E75gPkGCFUEqDpa8jgwpYr/vScjqnQ6H09t1uIiZ0ZSK0ypEGvrYK2bhBGWsGdw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.0.7.tgz", + "integrity": "sha512-GO7TkWvodGp6buMEX9p7tNyIkbwlyuAWbI6G9Ec5bhcm7mQdu3JOK1IXbEUwb3FVzSc363GraG/wLW23NSavIw==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10980,6 +11009,16 @@ "node": ">=8" } }, + "node_modules/solid-js": { + "version": "1.8.17", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.8.17.tgz", + "integrity": "sha512-E0FkUgv9sG/gEBWkHr/2XkBluHb1fkrHywUgA6o6XolPDCJ4g1HaLmQufcBBhiF36ee40q+HpG/vCZu7fLpI3Q==", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "^1.0.4", + "seroval-plugins": "^1.0.3" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", diff --git a/package.json b/package.json index 1071cd37..0d63afdd 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@react-types/shared": "^3.23.1", "@types/mdx": "^2.0.13", "antd": "^5.18.0", + "asciinema-player": "^3.7.1", "clsx": "^2.1.1", "esbuild": "^0.21.4", "esbuild-plugin-css-modules": "^0.3.0",