next.js教程

基于next.js14的教程

42 min read,created at 2024-06-15
next.js教程前端ssr

1 前言

本教程需要你有一定的前端基础,尤其是react基础,否则请先学习react;本教程基本是对官方文档的实践和再解读,使其通俗化。

本教程基于目前最新的next.js 14版本,如果你还没有安装node.js,请先安装node.js>18.17npm,之后next有更新,可能部分内容不适用,请注意diff。

创建next项目,选择如下参数,使用jssrc目录,这个是个人偏好,如果选择了其他选项,请自行注意diff。

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.mjsnext.js的配置文件,可以配置webpack等。
  • postcss.config.mjstailwind.config.jstailwind的配置文件。
  • package.jsonnode.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组件,会作为页面的内容,如下。

app/page.js
export default function Home() {
  return (
    <h1>Hello</h1>
  );
}

image

创建app/a/page.js文件,并访问http://localhost:3000/a

image

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

layout.js
export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

image

然后创建app/a/layout.js修改/a路径下的layout,会先渲染app/a/layout.js,然后再渲染app/layout.js,最后把app/a/page.js作为children

image

image

2.3 not-found.js与error.js

layout.js类似,每个目录下面都可以设置not-found.jserror.js。分别用来处理该路径下,找不到页面和服务端报错的情况。

在演示之前我们把globals.css进行精简,并在app/layout.js中重新引入,以便使用tailwind.css提供的简洁className

globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
layout.js
import './globals.css';

......

添加not-found.jsapp目录下,当访问不存在的页面时候就会返回该页面。

notfound

添加error.jsapp目录下,当服务端报错的时候会返回该页面,注意error.js必须用"use client"声明为客户端渲染的组件,后面我们会介绍是什么,以及为什么。

error

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

image

[xx]只能匹配路径中一个级别,如果想要匹配多级,则使用[...xx]

image

2.5 _dir私有目录

app/a会被暴露到/a路径下,而_开头的目录名默认不会被创建为路径,例如_dir/page.js并不能通过/_dir访问到,可以用来放置一些与UI无关的代码,比如一些server端的文件操作等。

image

如果一个工具类util.js,你可以放到app/_utils目录下,来表示他是app的一部分,不会在app之外被使用。也可以放到src/utils目录下,他表示可以在整个项目级别被访问。

2.6 (group)分组路由

中括号[slug]是动态路由,而小括号路径(group)是分组路由,分组目录不参与路由的路径,只是把多个目录放到同一个目录下。一方面是结构更清晰,另一方面可以在分组级别创建layout.js对分组内生效。

image

2.7 loading.js与template.js

loading.js表示页面加载时候样子,他会在加载的过程中替代page.js的位置,直到后者加载完成,例如服务端需要一些io操作,获取数据之后才能渲染,此时就会触发loading,此外loading.js可以目录级别指定/ image

template.jslayout.js非常相似,唯一的不同在于,使用Link跳转页面的时候,如果发现前后页面使用相同的layout,则这部分组件不会卸载,只会更新变化的内容。而使用template则会强制卸载并重新装载组件。

我们在上面loginregister,页面下方添加<Link>使得两个页面可以跳转:

login.js
import Link from "next/link";

export default function Login() {
    return (
    <div>
        <h1>登录</h1>
        <hr />
        <Link href="register" className="text-blue-300">
            切换到注册页
        </Link>
    </div>)
}
register.js
import Link from "next/link";

export default function Login() {
    return <div> 
        <h1>注册</h1>
        <hr />
        <Link href="login" className="text-blue-300">
            切换到登录页
        </Link>
    </div>
}

然后在(auth)目录下添加template.js并修改layout.js

template.js
export default function AuthLayout({children}) {
    return <div>
        <label>from template:
            <input className="text-black"/>
        </label>
        <div>{children}</div>
    </div>
}
layout.js
export default function AuthLayout({children}) {
    return <div>
        <h1>
            Welcome to my website
        </h1>
        <label>from layout:
            <input className="text-black" />
        </label>
        <div>{children}</div>
    </div>
}

此时切换页面,就会发现,layout下的input组件没有卸载,仍然保留原来的value,而template中的组件会被卸载,重新渲染。

image

当然这是next中的Link切换才有的效果,如果是普通的a标签,或者直接地址栏重新输入地址,则都会卸载。

2.8 @parallel并行路由

@dir目录代表的是并行路由,用slot的概念更确切一些。当我们创建app/f/layout.js页面,希望页面中有多个槽位,每个位置展示不同的内容,并且需要根据路径变化而变化。

app/f/layout.js
export default function Parallel({left, right, children}) {
    return <div className="flex">
        <div>{left}</div>
        <div>{right}</div>
    </div>
}

layout.js中的参数leftright通过,当前目录下的@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组件。

right.js
import Link from "next/link";

export default function Right() {
    return <><h1>Right</h1>
        <Link href="./f/item">GoToItem</Link>
    </>
}

image

但是通过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

此时,创建g/(.)g1/page.js实现拦截:

g/(.)g1/page.js
import Link from "next/link";

export default function G1() {
    return <>
    <h1>被拦截后的g1</h1>
    <Link href="/g">Go To g</Link>
    </>
}

注意,如果你的系统下没有被拦截,可能是developer模式导致的bug,可以尝试npm run build && npm run start再试试看,这个bug可能在后续的版本会修复。

image

image

如果想要拦截上一级则使用(..)path如果是上面两级就(..)(..)path,如果是拦截根路径下的则可以直接使用(...)path

2.10 route.js配置api接口

page.js类似,route.js可以配置http接口,其中方法名决定了请求的类型例如下面GET方法代表可以处理GET请求。

注意:这里的GET是固定的写法,export的不是default而是GET这个函数,创建POST函数在相同文件下,就对应POST请求,其他方法类似的。

image

避免在同一个目录下,同时存在page.jsroute.js,会导致只有后者生效。注意在route.js中入参的request,返回的response都是Web Fetch API标准定义的类型,参考,大多数操作都可以参考mdn,例如cookie header等,下面只列出部分常见的。

参数与返回值说明:

// 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的。

return NextResponse.redirect(new URL('/', request.url)) 
// 重定向到另一个页面/接口

return NextResponse.rewrite(new URL('/', request.url)) 
// 内容是另一个页面/接口,但是路径还是当前的

!!注意:api的内容在prod模式下,会被缓存很久,要想禁用缓存,可以在route.js中添加

route.js
export const dynamic = 'force-dynamic' // 默认值auto,会尽可能占用内存来缓存

2.11 middleware.js配置中间件

/src/middleware.js会拦截所有请求,进行注入处理,注意中间件是全局的,并且是在src目录,即和app目录同级下。

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,他的交互流程是,客户端请求页面,服务端返回的htmljs代码,其中html代码中,只有一个<div id="root"></div>,等js下载完成后,会在客户端动态的把各种dom追加到这个根div上面去,形成最后的页面,这就是客户端渲染。

以前的php项目都是SSR,他的交互流程是,客户端请求页面,服务端同样返回htmljs代码,只不过服务端在返回之前会识别出其中服务端需要计算的部分例如<h1><?php echo $username ?></h1>,就会将php标签在服务端识别出来,并且运行其中的代码把最终的输出内容替换到这个地方,最终返回给客户端的代码是<h1>Frank</h1>这样的html代码,这就是服务端渲染。

服务端渲染很早就有了,他在性能、网络传输等方面都要好过客户端渲染,但是因为前端框架的演进,被客户端渲染替代。而next等框架又回到了服务端渲染,算是一种“倒退”,只不过现在的服务端渲染比当初的更加复杂,可以使用同一种语言js、同一套框架react、并且数据传输和用户体验上更加无缝。

next中不同的url路径,有三种重要的服务端的渲染模式:

  • 静态渲染,如果发现该路径下的页面是完全不会改变的,会优先按照静态渲染,直接生成html页面,后续不会变了。
  • 动态渲染,如果有读取cookie [slug]等,根据参数有不同的行为,则无法预渲染,就会使用动态渲染,即每个请求来了在服务端渲染。
  • 流渲染,拆分同一页面多个组件的加载,用Suspense组件wrap,达到并行路由相同效果,先加载的先显示,是一种更精细的渲染控制。

3.1 静态渲染(预渲染)

我们新建一个next app,简化一下src/app/page.js,如下:

page.js
export default function Home() {
  return <div>hello</div>;
}

通过npm run build的日志,能看到一共俩页面,一个是首页,还有一个是默认的404页面,此外还有一些js等文件,这两个页面前面是个圆圈,下面注释说这是静态预渲染的页面,也就是三种渲染的第一种。默认能够预渲染的,都会用这种方式。

image

我们在构建完成后,到.next/server/app/index.html中可以看到/对应的代码,实际访问/的时候,就是直接获取的这个html文件。

image

image

直接访问localhost:3000也能看到相同的代码,从这个代码中我们会看到有一些必要的js文件,此外下方有self.__next_f.push字样的代码,内容是一个奇怪的格式(他与index.rsc内容一致),这个函数其实是next自己维护的,用来动态的修改页面的内容的,只不过这里我们是纯静态的,内容与他函数中的内容完全一致,所以看上去没有任何效果。

例如我们修改index.html来探究self.__next_f.push的作用,这里我将title viewport description直接注释掉了,然后npm run start

image

image

这就是next提供的动态加载的机制,在html的最后通过这种特殊的payload格式,可以对当前的页面进行调整,可以调整展示的内容,也可以动态的去加载其他css js文件,提供更复杂的后续逻辑。我们会在后续的两种渲染种看到。

3.2 动态渲染(服务端即时渲染)

使用[slug]或使用cookie是最简单的造成页面只能动态渲染的方式,动态的参数是无法预知他的取值的。

page.js
import { cookies } from "next/headers";

export default function Home() {
  console.log(cookies().getAll());
  return <div>hello</div>;
}

重新build,此时/不再是静态渲染,而是动态渲染,如下:

image

此时访问页面,发现页面代码与之前完全一样,但是找不到index.html了,这就是动态渲染,或者叫即时渲染,是请求过来的时候,服务端临时计算出来的html代码并返回的。

image

接下来:我们设置一个等待时间,然后设置loading.js

page.js
import { cookies } from "next/headers";

export default async function Home() {
  await new Promise(res=>setTimeout(res, 3000))
  console.log(cookies().getAll());
  return <div>hello</div>;
}

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

然后重新build,注意一定要保留上面cookies代码,不然就会直接静态渲染了。此时打开页面会显示Loading...,3s后显示hello,我们查看页面代码:

在页面刚加载3s内的时候,我们点开html的源码如下,发现页面中确实是有个loading的div。

image

3s后html的代码会被自动更新,这是http提供的一种流式加载的技术,在后面流渲染中也是使用了这个技术,

image

3s后的html代码中,会多出一部分代码,新追加了一个隐藏的div,id是S:1,并追加了一段js,把P:1S:1给替换掉了。

我们可能希望页面是动态渲染的,但是被识别成了静态渲染,例如直接<div>{new Date()}</div>就会被识别为静态渲染,导致时间一直固定死了,此时我们希望该页面是动态渲染的,可以在page.js中强制指定dynamic,在之前api部分提到过。

page.js
+ export const dynamic = 'force-dynamic'; //默认是auto

3.3 流渲染

流渲染的原理与上面介绍的类似,流渲染为了解决一个页面要么是静态渲染,要么是动态渲染的问题,当一个页面有多个组件,例如sider header等组件渲染很快,只有content组件渲染较慢,需要请求db数据等情况。那么就适合把页面拆分,采用流渲染,当然如果仔细看这篇文档的话,会发现并行路由其实就解决了这个问题,只不过流渲染提供了更小代价的写法,两者效果一致。

例如我们把page.js改为由LeftRight组成,其中左边是需要1s加载完成,而右边是3s,此时的效果是前3s,页面都是loading...,然后一下子左右都加载出来。

image

添加Suspense组件后,效果就变成left先加载完就会先展示,然后是Right

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 <div>
    <Suspense 
    fallback={<div>Left is loading</div>}>
      <Left/>
    </Suspense>
    <Suspense
    fallback={<div>Right is loading</div>}>
      <Right/>
    </Suspense>
  </div>;
}

image

其原理与之前介绍的一样,都是用了http的一边加载一边展示的特性,标注为未加载完成,就可以后续持续向html代码中注入内容。

image

加载中,不断追加html内容的过程中,html的标签其实body html等标签都是未关闭的,只不过浏览器能自动纠错,帮我们关闭,这里也是利用了这个特性。

iamege

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等诸多技术混合一体的复杂技术框架了。

  • SSRCSR: next基本是SSR服务端渲染,但是上面流渲染的模式,也会推送js到客户端渲染。
  • Static Dynamic SSG ISR: 都是指服务端渲染的一些策略。页面没有数据纯静态的,会提前渲染出html这是Static,页面有数据获取,但是提前generateStaticParams配置了,这就是SSG本质也是Static一种,只不过next中将两种区分开了,ISR增量静态生成与SSG辅助产生的,当提前配置的路径有新增时候,可以采用ISR增量的生成新的静态页面。Dynamic则是纯动态服务端渲染,上面看到过了。
  • RSCRCC: 默认都是服务端组件,只有use client的才是客户端组件,服务端组件运行环境是Nodejs,客户单是浏览器,服务端组件可以访问fs db等,客户端则可以访问document window等,此外对于事件(点击事件等)的处理只能在RCC中处理。

注意:并不是客户端组件就一定是客户端渲染,服务端组件就一定服务端渲染,只是运行的环境和可访问的api不同。next中基本都是服务端渲染的,客户端组件也是在服务端渲染的。只不过与服务端组件不同的是发送的js的代码会不一样。

4.1 服务端组件

前面介绍的都是服务端组件,运行环境是Nodejs,所以我们可以把一些诸如DB查询、api查询等后端的工作放在这里,配合loading.js或者Suspense可以实现很好的加载中用户交互,实现与useQuery一样的效果,searchParams参数是从url中解析查询字符串,会使当前组件是动态渲染。

page.js
export default async function Home({ searchParams }) {
  const id = searchParams.id || 1;
  const json = await queryDataFromApi(id)
  return (
    <div>
      <p>id:{json.id}</p>
      <p>title:{json.title}</p>
      <p>completed:{"" + json.completed}</p>
    </div>
  );
}

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作用一样,是定义当前页面的<meta>部分。上面也看到过了。

3 export async function generateStaticParams()对动态页面也进行静态参数缓存,例如/todo/:id路由下,我知道id目前的取值是0-9,那么可以提前生成这10个页面,超过9的,还按照动态的方式渲染。此功能非常常用,这里展示用法,创建todo/[id]/page.js用于处理/todo/:id的页面。

todo/[id]/page.js
export default async function Home({ params }) {
    const { id } = params;
    const json = await queryDataFromApi(id)
    return (
        <div>
            <p>id:{json.id}</p>
            <p>title:{json.title}</p>
            <p>completed:{"" + json.completed}</p>
        </div>
    );
}
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

image

访问0-9的时候,速度很快因为已经渲染成html了,而访问>9的页面时候会慢,因为此时是动态渲染,去发起fetch请求数据了。

image

4 export const dynamicParams = true 上面提到的没有静态生成的页面id>9的,默认是动态渲染,如果这里配置为false,则直接404。

todo/[id]/page.js
+ export const dynamicParams = false

image

5 export const revalidate = false 同样与generateStaticParams有关,是重新构建的时长false | number,可以配置一个数字代表秒数,多少秒后,我们将前面代码generateStaticParams去掉,观察默认行为。

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

我们发现默认不会有ISR增量的静态页面1.html产生,但是访问还是变快了,是因为有fetch-cache会对fetch函数进行了请求级别的缓存。

todo/[id]/page.js
+ export const revalidate = 10

image

↑当增加了revalidate为10s,发现和原来一样,并没有增量产生新的页面,因为页面构建的时候没有找到generateStaticParams,因而按照dynamic构建了,所以我们还得加回来如下,返回个空数组即可,只是为了标识该页面,使其SSG构建。

todo/[id]/page.js
+ export const revalidate = 10
+ export async function generateStaticParams() {
+     return [];
+ }

image

当我们请求页面的时候,就发现是有ISR增量构建静态html,如下,并且该增量构建页面的有效期是配置的10s,10s后请求todo/1会重新构建这个页面。

image

可以直接使用的函数:

1 fetch()完全兼容WebAPI中的fetch功能,不需要额外import node-fetch,就可以直接使用,并且有缓存配置,上面其实看到了fetch-cache目录。也就是该函数默认的结果都会缓存。

fetch(`https://...`, { cache: 'force-cache' | 'no-store' })        
// 是否开启缓存的配置,默认是force-cache

fetch(`https://...`, { next: { revalidate: false | 0 | number } }) 
// 如果开启缓存,这个配置是决定有效期的,默认false是无限长缓存
// 0是关闭缓存,1是1s有效期...

2 cookies()上面已经看到了,该函数能获取到cookie中的内容,并且会导致组件动态渲染。

import { cookies } from 'next/headers'
...
  const cookieStore = cookies()
  const theme = cookieStore.get('theme')

3 headers()请求头

import { headers } from 'next/headers'
...
  const headersList = headers()
  const referer = headersList.get('referer')

4 notFound()跳转到404页面,该函数是抛出一个特定的异常实现的,因而不需要return

import { notFound } from 'next/navigation'
......
  if (!user) {
    notFound() // 抛出异常实现的,不用管后面是否有代码
  }

5 redirect(path, 'replace|push')跳转,也是通过一个特定的异常抛出实现的,replace这个一般不用改。

import { redirect } from 'next/navigation'
...
  if (!user) {
    redirect('/login')
  }

4.2 客户端组件

客户端组件,主要用来处理用户交互事件和钩子函数,以及访问浏览器相关的api。例如在服务端组件中使用onClick会报错

page.js
export default function() {
    return <>
        <button onClick={()=>alert(123)}>click</button>
    </>
}

就会报错

 ⨯ Error: Event handlers cannot be passed to Client Component props.
  <button onClick={function onClick} children=...>

因为事件和钩子,只能在客户端组件使用,正确的使用用户交互事件与钩子如下:

page.js
'use client'

import { useState } from "react"

export default function Rsc() {
    const [count, setCount] = useState(0)
    return <>
        <button onClick={()=>{alert(count); setCount(count+1)}}>click</button>
    </>
}

相对应的如果声明为客户端组件,就无法使用服务端的api和功能了。build会发现客户端组件是Static静态预渲染的,所以客户端组件只是说js代码会在浏览器运行,但是渲染还是服务端渲染。

image

这个始终在服务端渲染的模式,会导致一个问题,就是如果在渲染的函数中,使用了document等变量,因为渲染是服务端渲染,是没有这个变量的,此时就会报错。那该如何正确的使用客户端组件调用浏览器的api呢?

就是通过useEffect,这一点非常非常重要,用useEffect钩子,确保一定是在客户端运行的整段代码,因为是个回调函数,所以build的时候执行不到里面的代码,这部分代码会发送到客户端去执行,避开了服务端渲染时,访问document变量。

page.js
'use client'

import {useEffect} from 'react'

export default function App() {
    useEffect(() => {
        const dom = document.getElementById("123")
        console.log(dom)
    })
    return <>
        <button id="123" onClick={()=>alert(111)}>click</button>
    </>
}

而如果是引入的第三方库,直接在import的时候就需要访问document等,那么就需要在useEffect中动态引入,例如asciinema-player就是这样的组件,他在next中正确的使用姿势如下。

'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 <div ref={playerRef} ></div>;
};

export default AsciinemaPlayer;

客户端组件,只有万不得已才去使用,并且尽量保证需要客户端组件的部分,单独拆成组件,大部分仍用服务端渲染,客户端只在组件树的最低层进行使用。

因为客户端组件有传染性,客户端组件中import的组件都会在这里变成客户端组件,而服务端组件没有传染性,可以引入客户端组件,井水不犯河水。

4.2.1 客户端组件特定功能

除了事件和react钩子,next提供了特定的钩子:

1 useParams()与服务端组件的入参{params}功能一致

'use client'
 
import { useParams } from 'next/navigation'
 
export default function ExampleClientComponent() {
  const params = useParams()
  console.log(params)
  return <></>
}

2 usePathname()获取当前路径,服务端组件中没有这个功能

'use client'
 
import { usePathname } from 'next/navigation'
 
export default function ExampleClientComponent() {
  const pathname = usePathname()
  return <p>Current pathname: {pathname}</p>
}

img

3 useRouter()跳转,官方建议不要用,而是使用<Link>

'use client'
 
import { useRouter } from 'next/navigation'
 
export default function Page() {
  const router = useRouter()
 
  return (
    <button type="button" onClick={() => router.push('/dashboard')}>
      Dashboard
    </button>
  )
}

4 useSearchParams与服务端组件参数中{searchParams}功能一致,解析查询字符串。

'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 交叉使用的注意事项

前面提到了客户端组件的传染性,所以最好在组件树low level去使用客户端组件,例如如果在app/layout.js中使用use client指令,就不是一个明智的选择,会导致所有组件都会变成客户端组件,所有服务端功能都无法使用了。

但是有时候客户端组件的功能就是比较靠上才行,例如react的Context组件,需要用useContext,即只能用客户端组件,而Context又是一个比较靠上的功能,这种时候就需要用一个特殊的技巧,来躲过“客户端组件import的组件都会变成客户端组件”这条规律。那就是使用props作为属性传入,例如最常用的就是作为children传入,就不会有import引入了。

使用props例如children,这样就能实现:服务端组件内引客户端组件,客户端组件再用children嵌套服务端组件,伪代码如下:

<ServerComponent1>
    <ClientComponent1>
        <ServerComponent2><ServerComponent2>
    </ClientComponent1>
</ServerComponent1>

常见的例如XXProvider XXContext都可以借鉴这个用法,我们以reactContext为例,首先安装一个js-cookie的库,方便在浏览器端读写cookie

npm i js-cookie
page.js
import Context from "./context"
import ServerComponent from './server'
import ClientComponent from './client'


// 这个例子中,服务端客户端组件交错使用:
// 当前页面App是服务端组件
// Context是一个客户端组件,使用了reactContext
// ServerComponent是一个服务端组件
// ClientComponent是一个客户端组件
export default function App() {
   return <>
    <Context>
        <ServerComponent></ServerComponent>
        <hr />
        <ClientComponent></ClientComponent>
    </Context>
   </>
}
context.js
'use client'

import Cookies from "js-cookie";
import { createContext, useEffect, useState } from "react"

export const ThemeContext = createContext('light');
export default function Context({children}) {
    const [theme, setTheme] = useState("light");

    useEffect(()=>{
        Cookies.set('theme', theme);
    }, [theme])

    const switchTheme = () => setTheme(
        theme == 'light' ? 'dark' : 'light')

    return <ThemeContext.Provider value={theme}>
        <button onClick={switchTheme}>切换</button>
        <div>
            Context组件 客户端组件 theme={theme}
        </div>
        <hr/>
        {children}
    </ThemeContext.Provider>    
}
server.js
import { cookies } from "next/headers";

export default function ServerComponent() {
    const cs = cookies()
    const theme = cs?.get('theme')?.value;
    return <div>ServerComponent theme from cookie theme={theme}</div>
}
client.js
'use client'

import { useContext } from "react";
import { ThemeContext } from "./context";

export default function Client() {
    const theme  = useContext(ThemeContext)
    return <>
        客户端组件:点击展示当前的Context
        <div>
            <button onClick={()=>alert(theme)}>展示</button>
        </div>
    </>
}

接下来打开主页,分别展示了三个组件 Client-Server-Client交错,客户端组件因为theme赋值了初值light,直接展示出来了;而服务端组件是请求的时候读取的cookie,前面组件是在useEffect赋值的cookie,因而请求直接来的时候cookie是空的,所以没有值;cookie在页面加载完之后,theme=light。

image

此时点击展示按钮,显示light,直接从context中读取的内容,虽然中间交错隔了个ServerComponent但是仍然能获取到。

image

然后点击切换按钮,然后再点击展示,theme上下文的值,会被修改,并且会触发ClientComponent组件的渲染,点击显示的值是更新后的dark

image

此时刷新页面,cookie的值会传到服务端,此时是dark所以服务端组件显示dark。但是客户端组件,因为重新刷新,被设置了初值light展示light

img