0x01 概述
(1)基本信息
- NextJS 官网:https://nextjs.org/
- NextJS 是一个轻量级的 React 服务端渲染(SSR)应用框架
- The React Framework for Production
- A full-stack framework for ReactJS
- NextJS 解决了常见问题使构建 React 应用更加容易
NextJS 基于 React 框架:React18
(2)主要特性和优点
- 内置服务端渲染支持(Server-side Rendering,SSR)
- 页面自动预加载,从而提供更好的 SEO 和初始负载
- React 的页面通过 JavaScript 渲染,搜索引擎爬取时,页面为空,不利于 SEO
- 更好的初始负载提高 FCP
- 相比客户端渲染,服务端渲染可以在服务器上获取数据并呈现完成的页面
- 采用单页面应用(SPA)
- 该单页面是动态渲染的
- 页面自动预加载,从而提供更好的 SEO 和初始负载
- 基于文件的路由
- 通过文件和文件夹目录定义页面和路由,而非使用代码
- 提供全栈 React 开发能力
- 更容易地添加后端代码到 React 应用中
- 存储数据、获取数据、身份认证等功能可以被添加到 React 工程中
(3)创建第一个工程
-
使用命令
npx create-next-app react-next-app
创建名为 react-next-app 的工程- 首次使用该命令时,会提示需要安装 create-next-app,输入 y 表示同意安装
-
工程设置时,可以选择是否引入 TypeScript、ESLint、Tailwind CSS、使用 src 目录、App Router、自定义别名路由
- 以下案例默认仅引入 Tailwind CSS
-
使用命令
cd react-next-app
进入工程目录-
node_modules:依赖目录
-
pages:页面目录
-
api:后端接口目录
-
_app.js:应用文件
-
_document.js:HTML 文档文件
-
index.js:入口文件
export default function Home() { return <main>这是一个 NextJS 项目的 Home 页面</main>; }
-
-
public:公共资源目录
-
styles:样式目录
-
-
使用命令
npm install
安装其他依赖 -
使用命令
npm run dev
启动工程
0x02 路由
(1)路由
-
在 React 框架中,采用的是基于代码的路由(React Router),但在 NextJS 中,通过目录结构推断出路由
- NextJS 使用基于文件(File-based)的路由
-
举例:有如下目录结构
flowchart LR pages-->A(index.js) & about.js & blogs blogs-->B(index.js) & C("[id].js") & D("[...slug].js")-
pages\index.js:/
export default function Home() { return <main>主页面</main>; }
-
pages\about.js:/about
export default function About() { return <div>关于页</div>; }
-
pages\blog\index.js:/blog
export default function Blog() { return <div>博客页</div>; }
-
pages\blog[id].js:/blog/:id,动态路由
import { useRouter } from "next/router"; export default function BlogId() { const router = useRouter(); return <div>Blog id: {router.query.id}</div>; }
文件夹的命名也可以使用
[]
实现动态路由 -
pages\blog\ [...slug].js:/blog/*,通配路由,如 /blog/2024/6
import { useRouter } from "next/router"; export default function BlogSlug() { const router = useRouter(); console.log(router.query); return <div>Blog Slug</div>; }
-
(2)导航
- 导航用于在单页面内根据路由切换页面,防止因发送全新的 HTTP 请求来加载新页面而丢失上下文或 Redux
- 导航包括声明式导航和编程式导航
a. 声明式导航
-
声明式导航通过
<Link></Link>
标签实现:修改 pages\index.jsimport Link from "next/link"; export default function Home() { return ( <main> <p>主页面</p> <nav className={"flex gap-4"}> <Link className={"px-8 py-2 bg-red-700"} href="/about"> 关于 </Link> <Link className={"px-8 py-2 bg-blue-700"} href="/blog" replace> 博客 </Link> </nav> </main> ); }
href
属性用于指定路由replace
属性用于设置路由被替代,而非默认跳转,特点是不可返回上个页面
-
导航到动态路由:修改 pages\blog\index.js
import Link from "next/link"; export default function Blog() { const blogs = [ { id: 1, title: "博客1" }, { id: 2, title: "博客2" }, { id: 3, title: "博客3" }, ]; return ( <div> <p>博客页</p> <ul> {blogs.map((blog) => ( <li key={blog.id}> <Link href={`/blog/${blog.id}`}>{blog.title}</Link> </li> ))} </ul> </div> ); }
-
通过对象设置路由导航:修改 pages\blog\index.js
<ul> {blogs.map((blog) => ( <li key={blog.id}> <Link href={ { pathname: "/blog/[id]", query: { id: blog.id }, } } > {blog.title} </Link> </li> ))} </ul>
b. 编程式导航
-
编程式导航通过方法实现:修改 pages\blog\index.js
import { useRouter } from "next/router"; export default function Blog() { const blogs = [ { id: 1, title: "博客1" }, { id: 2, title: "博客2" }, { id: 3, title: "博客3" }, ]; const router = useRouter(); const clickHandler = (id) => { router.push("/blog/" + id); }; return ( <div> <p>博客页</p> <ul> {blogs.map((blog) => ( <li key={blog.id}> <button onClick={() => clickHandler(`${blog.id}`)}> {blog.title} </button> </li> ))} </ul> </div> ); }
-
编程式导航也可以使用对象设置,与声明式导航相同
(3)特定页面
-
在 pages 目录下新建 404.js
export default function NotFound() { return <div>404 Not Found</div>; }
-
当访问不存在的路由时,会渲染 404.js 页面
0x03 页面渲染
- 预渲染是指当浏览器请求页面时,返回的是渲染完成 HTML 页面,而非 React 那样返回 HTML 基本结构和 JavaScript 代码
- React 渲染页面 DOM 元素很快,但加载数据需要时间。预渲染在保证快速渲染 DOM 元素的同时,提前请求并加载好数据
- 预渲染有两种方式:静态生成、服务端渲染
(1)静态生成
-
在构建期间,预生成一个在服务端准备好数据的页面
- 预生成指所有 HTML 代码以及所有内容的数据都是提前准备好的
-
页面被提前准备好,并可以由应用提供服务的服务器或 CDN 缓存
-
getStaticProps(context) {}
是异步函数- 其中的代码仅在服务端运行,即后端代码
- 可以从页面组件内部导出,告诉 NextJS 需要预生成的页面及其需要的数据
- 仅在页面组件中很重要
- 被 NextJS 监听
-
举例:
-
在工程根目录新建 data 目录,其中新建 response.json
{ "products": [ { "id": 1, "name": "Product 1" }, { "id": 2, "name": "Product 2" }, { "id": 3, "name": "Product 3" } ] }
-
修改 pages\index.js
export default function Home(props) { const { products } = props; return ( <ul> {products.map((product) => ( <li key={product.id}>{product.name}</li> ))} </ul> ); } export async function getStaticProps() { return { props: { products: [ { id: 1, name: "Product 1" }, { id: 2, name: "Product 2" }, { id: 3, name: "Product 3" }, ], }, }; }
此时,采用了硬编码方式,接下来通过
fs
使用文件系统获取数据 -
修改 pages\index.js
import fs from "fs/promises"; import path from "path"; export default function Home(props) {/* ... */} export async function getStaticProps() { const filePath = path.join(process.cwd(), "data", "response.json"); const jsonData = await fs.readFile(filePath); const data = JSON.parse(jsonData); return { props: { products: data.products, }, }; }
-
a. 数据异常处理
-
当请求到的数据为空时,可以通过判断并返回 404 页面
// ... const data = JSON.parse(jsonData); if (data.products.length === 0) { return { notFound: true }; } // ...
-
当未请求到数据时,可以通过判断并重定向到指定页面
// ... const data = JSON.parse(jsonData); if (!data) { return { redirect: { destination: '/no-data' } }; } // ...
b. 增量静态生成(ISR)
-
页面不只是在构建的时候静态生成一次,而是不断更新,即使开发者没有主动重新命令部署
-
当页面预生成后,每隔一段时间,页面将在服务端重新预生成,并根据实际情况选择继续返回现有页面或新页面
-
在返回时,添加
revalidata
属性,值为时间,单位为秒return { props: { products: data.products, }, revalidate: 600, };
c. 动态页面
-
修改 pages\index.js
<ul> {products.map((product) => ( <li key={product.id}> <Link href={`/${product.id}`}>{product.name}</Link> </li> ))} </ul>
-
创建 pages\ [id].js
import { Fragment } from "react"; import fs from "fs/promises"; import path from "path"; export default function ProductDetail(props) { const { product } = props; return ( <Fragment> <h1>{product.name}</h1> </Fragment> ); } export async function getStaticProps(context) { const { params } = context; const id = params.id; const filePath = path.join(process.cwd(), "data", "response.json"); const jsonData = await fs.readFile(filePath); const data = JSON.parse(jsonData); const product = data.products.find((product) => product.id === id); return { props: { product: product, }, }; }
此时,访问 http://localhost:3000/1 等路由会报错,因为预生成的页面不清楚有多少动态路由需要生成,因此需要使用异步方法
getStaticPaths
-
修改 data\response.json
{ "products": [ { "id": "1", "name": "Product 1" }, { "id": "2", "name": "Product 2" }, { "id": "3", "name": "Product 3" } ] }
-
修改 pages\ [id].js
// ... export async function getStaticPaths() { return { paths: [ { params: { id: "1" } }, { params: { id: "2" } }, { params: { id: "3" } }, ], fallback: false, }; }
此时,在
return
的paths
中使用硬编码的方法,固定了需要预生成的 id 个数及取值范围,对于动态变化的数据,这么做显然不合适,而fallback
属性正用于解决该问题 -
修改 pages\ [id].js
// ... export async function getStaticPaths() { return { paths: [{ params: { id: "1" } }], fallback: "blocking", }; }
- 此时,NextJS 会自动预生成
paths
属性中指定的页面,并根据实际参数即时生成相关页面 - 当数据量过大时,预生成页面的时间也需要很长的时间,考虑到某次访问时,并非需要访问所有页面,因此可以修改
fallback
属性为 true
- 此时,NextJS 会自动预生成
-
修改 pages\ [id].js
// ... export async function getStaticPaths() { return { paths: [{ params: { id: "1" } }], fallback: true, }; }
此时,NextJS 会预生成所有页面,但是除了在
paths
属性中指定的页面外,其他页面默认属于不常访问的页面而保存在服务端中,当访问这些页面时会报错,因此需要判断是否已加载当前访问的页面,否则添加加载等待说明 -
修改 pages\ [id].js
// ... export default function ProductDetail(props) { const { product } = props; if (!product) { return <p>Loading...</p>; } return ( <Fragment> <h1>{product.name}</h1> </Fragment> ); } // ... export async function getStaticPaths() { return { paths: [{ params: { id: "1" } }], fallback: true, }; }
此时,仍需硬编码第一个 path,而 path 的值来自请求的结果,因此需要将请求数据的代码封装成函数,在
getStaticPaths
方法中调用 -
修改 pages\ [id].js
// ... async function getData() { const filePath = path.join(process.cwd(), "data", "response.json"); const jsonData = await fs.readFile(filePath); return JSON.parse(jsonData); } export async function getStaticProps(context) { const { params } = context; const id = params.id; const data = await getData(); const product = data.products.find((product) => product.id === id); return { props: { product: product, }, }; } export async function getStaticPaths() { const data = await getData(); const ids = data.products.map((product) => product.id); const pathsWithParams = ids.map((id) => ({ params: { id: id } })); return { paths: pathsWithParams, fallback: true, }; }
此时,如果需要访问的 id 对应的页面不存在时,需要返回 404 页面,而
fallback
属性为 true 时不会自动返回 404 页面,而是不断尝试获取不存在的页面,因此需要在getStaticProps
方法中处理 -
修改 pages\ [id].js
// ... export async function getStaticProps(context) { const { params } = context; const id = params.id; const data = await getData(); const product = data.products.find((product) => product.id === id); if (!product) { return { notFound: true }; } return { props: { product: product, }, }; } // ...
(2)服务端渲染
-
服务端渲染一般在以下情况使用:
- 需要对每个请求预渲染
- 需要访问请求对象,如 Cookies 等
-
NextJS 允许运行“真正的服务端代码”
-
getServerSideProps
是异步函数- 与
getStaticProps
特点类似
- 与
-
举例:修改 pages\index.js
export default function Home(props) { return <div>{props.name}</div>; } export async function getServerSideProps() { return { props: { name: "SRIGT", }, }; }
-
getServerSideProps
的上下文context
可以访问参数的同时,还访问完整请求对象以及返回的响应// ... export async function getServerSideProps(context) { const { params, req, res } = context; console.log(params); console.log(req); console.log(res); return {/* ... */}; }
-
处理动态页面:修改 pages\ [id].js
import { Fragment } from "react"; export default function ProductDetail(props) { return ( <Fragment> <h1>{props.id}</h1> </Fragment> ); } export async function getServerSideProps(context) { const { params } = context; const id = params.id; return { props: { id: "id" + id, }, }; }
0x04 数据请求
(1)客户端数据请求
-
存在一些不需要或不能预渲染的数据
- 高频变化的数据
- 高度用户特定数据
- 原子化数据
-
举例:修改 pages\index.js
import { useEffect, useState } from "react"; export default function Home() { const [products, setProducts] = useState(); const [isLoading, setIsLoading] = useState(false); useEffect(() => { setIsLoading(true); fetch("URL") .then((res) => res.json()) .then((data) => { const productsData = []; for (const key in data) { productsData.push({ id: key, name: data[key].name, }); } console.log(productsData); setProducts(productsData); setIsLoading(false); }); }, []); if (isLoading) { return <p>Loading...</p>; } if (!products) { return <p>No products found.</p>; } return ( <ul> {products.map((product) => ( <li key={product.id}>{product.name}</li> ))} </ul> ); }
(2)useSWR
-
SWR(stale-while-revalidate)一种由 HTTP RFC 5861 推广的 HTTP 缓存失效策略
- 首先从缓存中返回数据(过期的),同时发送 fetch 请求(重新验证),最后得到最新数据
- 组件将会不断地、自动获得最新数据流,UI 也会一直保持快速响应
-
使用命令
npm install swr
安装 SWR -
修改 pages\index.js
import { useEffect, useState } from "react"; import useSWR from "swr"; export default function Home() { const [products, setProducts] = useState(); const { data, error } = useSWR("URL"); useEffect(() => { if (data) { const productsData = []; for (const key in data) { productsData.push({ id: key, name: data[key].name, }); } setProducts(productsData); } }, [data]); if (error) { return <p>Failed to load.</p>; } if (!data || !products) { return <p>Loading...</p>; } return ( <ul> {products.map((product) => ( <li key={product.id}>{product.name}</li> ))} </ul> ); }
0x05 页面优化
(1)头部优化
-
网页头部主要指 HTML 文档中
<head></head>
部分,可以进行 SEO<title></title>
:网页标题<meta />
:元数据
-
配置头部内容:修改 pages\index.js
import Head from "next/head"; export default function Home() { return ( <div> <Head> <title>网页标题</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="keywords" content="关键词1,关键词2" /> <meta name="description" content="网页描述" /> </Head> <p>主页面</p> </div> ); }
此时,仅配置了当前页面的头部内容,而其他页面则需要重复这一段代码
-
NextJS 工程中的 pages\ _app.js 是路由的应用组件,在最后为每个页面渲染并显示,可以在此处设置全局的头部内容
import "@/styles/globals.css"; import Head from "next/head"; export default function App({ Component, pageProps }) { return ( <div> <Head> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> </Head> <Component {...pageProps} /> </div> ); }
- NextJS 会自动合并 _app.js 和组件中设置
<Head>
,如果出现冲突,则以最新的内容为准,即最后写的内容
- NextJS 会自动合并 _app.js 和组件中设置
-
NextJS 工程中的 pages\ _document.js 是可选的,用于定义整个 HTML 文档
- 可以在应用组件树之外,添加 HTML 元素
(2)复用优化
-
对于页面中重复的组件、逻辑和配置,可以通过声明变量并在需要的地方调用这个变量,如:
import Head from "next/head"; export default function Home() { const headContent = ( <Head> <title>网页标题</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="keywords" content="关键词1,关键词2" /> <meta name="description" content="网页描述" /> </Head> ); return ( <div> {headContent} <p>主页面</p> </div> ); }
(3)图像优化
-
图像优化指一般
<img />
加载的图像是全尺寸完整的图片,会延长页面加载时间,需要对图像进行压缩 -
NextJS 提供了用于优化图像的特殊组件:
<Image />
- 会创建图像的多个版本,并根据操作系统和设备显示不同的图像
- 保存在 .next\cache\images 中,在需要的时候生成
- 当页面大小发生变化时,NextJS 会在达到临界大小时,重新请求合适版本的图像
- 特殊属性
width
与height
用于设置在页面中图像的宽高
import Image from "next/image"; export default function Home() { return ( <div> <p>Image: </p> <Image src={"/image.jfif"} alt={"picture"} width={400} height={300} /> <p>img: </p> <img src={"/image.jfif"} alt={"picture"} className={"w-[400px]"} /> </div> ); }
- 会创建图像的多个版本,并根据操作系统和设备显示不同的图像
-
默认情况下,
img
图像会立即加载,而Image
图像是延迟加载(懒加载)- 当图像不可见时,NextJS 不会加载该图像
-
通过官方文档查看其他属性及其使用方法
0x06 API 路由
(1)概述
- 在一些需要进行交互的页面中,需要通过 API(Application Programming Interface)与后端建立联系
- REST API 是最流行的 API 形式之一
- NextJS 允许构建 REST API 作为应用的一部分,接收不同类型的 HTTP 请求
- 请求一般通过 JavaScript 代码发送(如 Ajax),而非通过浏览器中的 URL
(2)发送数据
-
在根目录中创建 data\feedback.json
[]
-
在 pages 目录下创建 api 目录
- 目录名称必须为 api,此时将会被 NextJS 识别
-
在 api 目录下创建 feedback.js
export default function handler(req, res) { // req 表示请求, res 表示响应 // 以下可以编写服务端的代码 res.status(200).json({ name: "SRIGT" }); }
-
修改 pages\index.js
import { useRef } from "react"; export default function Home() { const emailInputRef = useRef(); const feedbackInputRef = useRef(); const submitHandler = (event) => { event.preventDefault(); const email = emailInputRef.current.value; const feedback = feedbackInputRef.current.value; fetch("/api/feedback", { method: "POST", body: JSON.stringify({ email: email, feedback: feedback }), headers: { "Content-Type": "application/json", }, }) .then((res) => res.json()) .then((data) => console.log(data)); }; return ( <div> <h1>Feedback</h1> <form onSubmit={submitHandler}> <div className={"bg-gray-300 py-4"}> <label htmlFor="email">邮箱</label> <input type="email" id="email" ref={emailInputRef} /> </div> <div className={"bg-gray-300 py-4"}> <label htmlFor="feedback">反馈</label> <textarea id="feedback" rows="5" ref={feedbackInputRef} /> </div> <button className={"bg-gray-200 px-8 pt-2"}>发送</button> </form> </div> ); }
-
完善 api\feedback.js
import fs from "fs"; import path from "path"; export default function handler(req, res) { if (req.method === "POST") { const email = req.body.email; const feedback = req.body.feedback; const newData = { id: new Date().toISOString(), email: email, feedback: feedback, }; const filePath = path.join(process.cwd(), "data", "feedback.json"); const fileData = fs.readFileSync(filePath); const oldData = JSON.parse(fileData); oldData.push(newData); fs.writeFileSync(filePath, JSON.stringify(oldData)); res.status(201).json({ message: "Success!", feedback: newData }); } else { res.status(200).json({ name: "SRIGT" }); } }
-
访问 http://localhost:3000/ 并填写表单
(3)获取数据
-
修改 pages\api\feedback.js
// ... else if (req.method === "GET") { const filePath = path.join(process.cwd(), "data", "feedback.json"); const fileData = fs.readFileSync(filePath); const data = JSON.parse(fileData); res.status(200).json({ feedback: data }); } // ...
-
修改 pages\index.js
import { useRef, useState } from "react"; export default function Home() { const emailInputRef = useRef(); const feedbackInputRef = useRef(); const [feedbacks, setFeedbacks] = useState([]); // ... function clickHandler() { fetch("/api/feedback") .then((res) => res.json()) .then((data) => setFeedbacks(data.feedback)); } return ( <div> {/* ... */} <button onClick={clickHandler} className={"bg-gray-200 px-8 pt-2 mt-4"}> 获取所有反馈 </button> <ul> {feedbacks.map((feedback) => ( <li key={feedback.id}> {feedback.email}: {feedback.feedback} </li> ))} </ul> </div> ); }
(4)动态路由
-
在 api 目录下创建 [feedbackId].js
import fs from "fs"; import path from "path"; export default function handler(req, res) { const id = req.query.feedbackId; const filePath = path.join(process.cwd(), "data", "feedback.json"); const fileData = fs.readFileSync(filePath); const data = JSON.parse(fileData); const selectedData = data.find((feedback) => feedback.id === id); res.status(200).json({ feedback: selectedData }); }
-
修改 pages\index.js
import fs from "fs"; import path from "path"; import { Fragment, useState } from "react"; export default function Home(props) { const [data, setData] = useState(); function detailHandler(id) { fetch(`/api/${id}`) .then((res) => res.json()) .then((data) => setData(data)); } return ( <Fragment> {data && <p>{data.email}</p>} <ul> {props.feedbacks.map((feedback) => ( <li key={feedback.id}> {feedback.feedback}{" "} <button onClick={detailHandler.bind(null, feedback.id)}> 详细 </button> </li> ))} </ul> </Fragment> ); } export async function getStaticProps() { const filePath = path.join(process.cwd(), "data", "feedback.json"); const fileData = fs.readFileSync(filePath); const data = JSON.parse(fileData); return { props: { feedbacks: data, }, }; }