除本站自媒体官号以外,本文的内容禁止任一组织、个人、账号或平台,以任何形式转载到 RainyDesign.cn 站外。
任何个人或组织进行非法转载的,本站工作人员可对其追究法律责任。
具体信息详见 条例与条款 。
Next.JS + Strapi + Tailwind CSS 全套网建实战教学 —— Next.JS
使用 Next.JS 构建你的网站项目,掌握目录管理、组件开发、数据传递、页面渲染等知识点。
章节回顾
上一个章节我们配置好了 MySQL,也搭建好了 Strapi CMS、管理了数据结构并填写了数据,我们可以看看现在学到哪了,课程的完整目录如下:
-
网站部署 (撰写中)
网站部署:Cloudflare、Vercel或租赁云服务器(仍在撰写)
网站部署:注册域名 + SSL证书申请(仍在撰写)
SEO:搜索引擎提交收录(仍在撰写)
-
资源与教程项目包下载
点击此处下载,最后更新于:2026 年 5 月 26 日。为获取良好的学习体验,请务必以最新版本为主! - FAQ(常见疑问解答)
三、Next.JS
又见面了宝子们 :D 今天我们来讲讲搭建 Next.JS 网站项目所需的关键知识点。
3.1 Next.JS:创建项目并管理目录
现在让我们用终端命令行创建一个 Next.JS 项目,看看里头都有啥;为了便于从头理解 Next.JS 的整体结构,我们采用手动安装方式,逐步添加脚本、目录与设置。如需直接自动完成测试内容,请参考 Next.JS 官方文档,也可搭配我们先前写过的博客 八小时快速建立网站:无门槛独立建站指南 - 资源管理 学习。
3.1.1 Next.JS:手动创建项目
以下内容与 Strapi 相同,依旧以 ~/projects 目录为例,在终端中输入:
代码已复制!
# 以下为终端命令行 cd ~/Documents/my-project # 进入目录 mkdir nextjs # 创建目录 cd nextjs # 进入目录 yarn add next@latest react@latest react-dom@latest # 直接用 Yarn 安装最新的 Next.JS、React 与 React-DOM # 如需安装特定版本则需要适配合适的依赖,如 yarn add next@16.0.5 react@19.2.0 react-dom@19.2.0 # 等待读条完毕,显示 “✨ Done in xx.xxs.“ 后继续
接下来打开目录下的 package.json ,在文件内添加项目名称、版本号、许可证声明等等,再添加名为 scripts 的第二个对象。完毕之后文件内容显示如下:
代码已复制!
{ "name": "my-nextjs-project", "version": "0.1.0", "private": true, "license": "MIT", "dependencies": { // 不作修改 }, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint", "lint:fix": "eslint --fix" } }
保存改动后执行脚本就可以运行了,但我们还缺乏必要的 pages 目录,现在需要手动创建一下。
3.1.2 Next.JS:创建主页并测试
请在终端中继续输入以下指令:
代码已复制!
mkdir pages # 创建 pages 目录 cd pages # 进入目录 touch index.js # 创建 index.js 文件(或在 VSCode 中手动创建)
打开新建的 pages/index.js ,为其添加以下内容并保存:
代码已复制!
export default function Home() { return <h1>Hello, Next.JS!</h1> }
接下来我们还需为页面创建可自定义的 App 文件,同样可以手动创建,或是在终端中输入 touch _app.js ,然后为其添加以下内容并保存:
代码已复制!
export default function App({ Component, pageProps }) { return <Component {...pageProps} /> }
最后我们还需要在页面目录创建一个 _document.js,以下是需要填写的内容:
代码已复制!
import { Html, Head, Main, NextScript } from 'next/document' export default function Document() { return ( <Html> <Head /> <body> <Main /> <NextScript /> </body> </Html> ) }
确认以上三个文件均已创建完毕后,在终端输入 yarn dev 运行程序,就可以于端口号 3000 访问 Next.JS 项目了:
成功的话我们会看到 Hello Next.JS 页面:

然后回到 VSCode 终端,我们会发现终端提示了一个警告:
代码已复制!
⚠ Cross origin request detected from 127.0.0.1 to /_next/* resource. In a future major version of Next.js, you will need to explicitly configure "allowedDevOrigins" in next.config to allow this. Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins
阅读文档后会发现,这是因为我们没有设置 next.config.js 中的 allowedDevOrigins 属性导致的,所以我们需要创建文件并设置一下参数:
代码已复制!
module.exports = { allowedDevOrigins: ['127.0.0.1', 'localhost'], }
3.1.3 Next.JS:了解项目结构
接下来让我们了解一下 Next.JS 的项目结构,我们可以访问官方原文档 Next.JS 官方文档 - 项目结构 配合学习:
-
顶层文件夹:
app: 应用路由pages: 页面路由public: 公共文件夹,存储静态内容src: 应用源文件夹(可选)
-
顶层文件:
next.config.js: 管理 Next.JS 项目设置package.json: 管理依赖与脚本instrumentation.ts: OpenTelemetry 以及监听、日志管理文件middleware.ts: Next.JS 请求中间件.env: 默认环境文件.env.local: 本地环境.env.production: 生产环境.env.development: 开发环境.eslintrc.json: ESLint 配置文件.gitignore: Gitignore 配置文件next-env.d.ts: Next.JS 的 TypeScript 声明文件tsconfig.json: TypeScript 格式的配置文件jsconfig.json: Javascript 格式的配置文件
-
文件约定:
文件名 后缀名 说明 使用场景 _app.js.jsx.tsx自定义 App 全局布局、状态管理、全局样式 _document.js.jsx.tsx自定义 Document 修改 HTML 结构、添加全局脚本 _error.js.jsx.tsx自定义错误页面 统一错误处理和展示 404.js.jsx.tsx自定义 404 页面未找到时的展示 500.js.jsx.tsx自定义 500 服务器错误时的展示 -
路由:
- 文件夹:
文件夹 后缀名 说明 index.js.jsx.tsx主页 folder/index.js.jsx.tsx嵌套页面 - 文件:
文件 后缀名 说明 index.js.jsx.tsx主页 file.js.jsx.tsx嵌套页面 -
动态路由:
- 文件夹:
文件夹 后缀名 说明 [folder]/index.js.jsx.tsx动态路由片段 [...folder]/index.js.jsx.tsx捕获所有路由片段(Catch-all) [[...folder]]/index.js.jsx.tsx可选捕获所有路由片段(Catch-all) - 文件:
文件 后缀名 说明 [file].js.jsx.tsx动态路由片段 [...file].js.jsx.tsx捕获所有路由片段(Catch-all) [[...file]].js.jsx.tsx可选捕获所有路由片段(Catch-all)
以及一个项目文件夹的约定性结构参考:
代码已复制!
nextjs ├── .next # 构建缓存 ├── node_modules # 依赖模块 ├── public # 静态资源 ├── src # 项目源码 │ ├── components # React 组件 │ ├── lib # 外部服务和核心业务逻辑 │ ├── pages # 页面路由 │ ├── hooks # 自定义 React hooks │ ├── utils # 工具函数和辅助函数 │ └── styles # 样式文件 ├── .env # 环境变量 ├── .gitignore # Git 忽略配置 ├── jsconfig.json # 路径映射配置 ├── next.config.js # Next.JS 配置 ├── package.json # 项目依赖 ├── postcss.config.mjs # PostCSS 配置 ├── README.md # 项目说明 └── yarn.lock # 依赖锁定文件(如用 npm 管理项目则为package-lock.json)
接下来我们将逐步讲解上述内容。
3.1.4 Next.JS:管理目录、修改配置
了解完目录结构与路由后,我们来继续为项目添加静态资源公共目录:直接于项目根目录下新建 public 目录即可,之后所有存放在 public 目录下的文件都能被直接读取到,我们将 课程资源包 中 nextjs/public/temp 下的 test-image.jpg 直接复制到项目的 public 目录下,然后访问 http://127.0.0.1:3000/test-image.jpg 查看:
公共目录下的文件可以直接通过 Next.JS 项目 URL 的根路径获取,同样的,public 下的目录也可以直接通过 URL 的子路径获取,比如我们再于其中创建一个 temp 文件夹,并将图片移动到该路径( public/test/test-image.jpg ),之后访问 http://127.0.0.1:3000/temp/test-image.jpg 看看:

了解完 public 目录的工作模式后,我们就可以将项目资源包中的 项目文件/nextjs/public 整个复制粘贴到我们的 Next.JS 项目目录下了。
现在我们还需要在项目下创建一个 src 文件夹,这里存放了我们的样式、组件、插件、资源等源内容。我们可以将 pages 目录也存入 src 目录下方便管理;请于 Next.JS 终端界面按下 Ctrl + C 终止项目运行,然后手动操作或在终端中输入:
代码已复制!
cd ~/Documents/my-project/nextjs # 回到项目目录 mkdir src # 创建 src 目录 mv ./pages ./src/pages # 将 pages 移动到 src 目录下
接下来映射绝对路径,这能能让我们在路径管理上更便捷直观。首先我们需要在项目目录下创建名为 jsconfig.json ( TypeScript 则为 tsconfig.json )的配置文件:
代码已复制!
touch jsconfig.json # 于根目录下创建 jsconfig.json
然后编辑内容:
代码已复制!
{ "compilerOptions": { "baseUrl": "src/", "paths": { "@/pages/*": ["pages/*"], "@/styles/*": ["styles/*"], "@/components/*": ["components/*"] } } }
保存文件后,再次启动项目就能看到 Hello Next.JS 界面顺利显示了;上述配置文件中的 baseUrl 用于设置基本绝对路径,下面的 paths 参数则用作相对路径补充,这样一来我们就可以通过该配置文件执行全局路径映射,而不用担心变更文件层级后需要手动修改所有引入的模块路径了,所以下文中的 src/pages/ 与 src/components/ 我们也会使用对应的 @/pages/ 与 @/components/ 路径。
pages 或 app 目录优先级对比 src/pages 与 src/app 较高,当存在前者时,后者将会被忽略,因此请避免同时存在多个路由目录。3.2 Next.JS:管理路由,搭建基础布局与页面
3.2.1 Next.JS:页面与路由
我们先来理解一下 Next.JS 的路由逻辑:
- 索引路由(Index Route):
@/pages/index.js→ 路由为/@/pages/blog/index.js→ 路由为/blog
- 页面路由(Page Route):
@/pages/blog.js→ 路由也为/blog- 与
pages/blog/index.js功能相同,但不能同时存在
- 嵌套路由(Nested Route):
@/pages/blog/[slug].js→ 路由为/blog/[params]
一路做到这里,添加完其他页面(About、Course、Blog)之后,本项目的页面路由在目录中的表现大致是这样的:
代码已复制!
nextjs ├── .next ├── node_modules ├── src │ └── pages │ ├── _app.js │ ├── _document.js │ ├── index.js │ ├── course.js │ ├── blog.js │ └── about.js ├── public ├── jsconfig.json ├── package.json ├── package.json └── yarn.lock
我们还可以为每个页面写点内容,比如如下文所示在 course.js 中 return 一个 <p>This is course page.</p>,然后访问每个页面看看效果。
代码已复制!
export default function Course() { return <p>This is course page.</p> }
保存以查看实时变动: http://127.0.0.1:3000/course 。完毕之后我们会发现,这个目录还少了 article 页面,结合 字段列表文档 - 合集字段 - Article 来看,得知 article 为 blog 的子路由,且内容为动态类型,并不像其他几个页面一样是一个单页。这里我们就要讲到动态路由了。
3.2.2 Next.JS:动态路由
我们可以使用 Next.JS 的 useRouter() 进行动态路由测试。首先,我们需要在 @/pages 目录下创建 blog 目录,并以一个标识(segment name)命名,格式为 [segmentName],如 [id] 或 [slug],创建好页面后的项目文件结构树如下所示:
代码已复制!
nextjs ├── ... ├── src │ └── pages │ ├── blog # 动态 blog 目录 │ │ └── [slug].js # 动态 blog 文章页面,数据取 Strapi 中的 Article 类目下的文档 │ ├── category # 动态 category 目录 │ │ └── [slug].js # 动态 category 页面,数据取 Strapi 中的 Category 类目下的文档 │ ├── _app.js │ ├── _document.js │ ├── about.js │ ├── blog.js # 静态 blog 页面 │ ├── course.js │ ├── index.js │ └── ... └── ...
/blog 会显示 @/pages/blog.js 的内容,访问 /blog/任意值 会显示 @/pages/blog/[slug].js 的内容, /category 同理。然后编辑 @/pages/blog/[slug]/js,引入 useRouter 模块并调用函数,然后 return 出 useRouter.query.[segmentName] 的值。
这里我们使用了slug,并定义了 router = useRouter(),所以我们能通过 {router.query.slug} 获取到这个值:
代码已复制!
import { useRouter } from 'next/router' export default function BlogArticle() { const router = useRouter() return <p>Post: {router.query.slug}</p> }
我们可以通过在 /blog 输入任何值来测试它,就像这样:http://127.0.0.1:3000/blog/test,通过更改 /blog 后面的 /test,我们可以看到字符串顺利传入了 {router.query.slug}。
我们还可以在参数前面加上 ... 以捕获所有路由,格式为 [...segmentName].js。
示例对比:
- 单层动态路由
[slug].js:只能匹配/blog/hello,无法匹配/blog/a/b - 捕获所有路由
[...slug].js:可以匹配/blog/hello、/blog/a/b、/blog/a/b/c等
让我们将 [slug].js 重命名为 [...slug].js 测试多级路由:http://127.0.0.1:3000/blog/a/b
代码已复制!
import { useRouter } from 'next/router' export default function BlogArticle() { const router = useRouter() const { slug } = router.query return ( <div> <p>Router: {Array.isArray(slug) ? slug.join('/') : slug}</p> </div> ) }
之后我们将通过传入参数改变 API 的 URL,以获取特定的动态数据并做页面渲染。
3.2.3 Next.JS:自定义 App、Document 与错误页
我们在上文 创建主页并测试 小节中提到了文件约定列表,包含了 _app.js、_document.js、_error.js、404.js、500.js 这五个 @/pages/ 目录下的文件。现在就让我们来看看如何自定义这些文件。
首先是 _app.js,它被 Next.JS 官方定义为用于初始化所有页面的组件,此外官方规定的用途还有:
- 应用共享布局,以在所有页面中显示
- 注入附加数据到所有页面当中
- 添加全局 CSS
举例来说,我们可以在 _app.js 中添加一些元素,以应用到所有页面:
代码已复制!
export default function App({ Component, pageProps }) { return ( <> <p>Lorem ipsum</p> <Component {...pageProps} /> </> ) }
<> </> 。.next 缓存并重新运行项目:你也可以手动删除
.next目录,介于它是一个隐藏文件夹,你需要在 MacOS 系统中按下 Shift(↑) + Command(⌘) + . 显示隐藏文件;在 Windows 中,你需要在文件管理器上方的菜单栏中,选择“查看”选项卡并勾选 “显示隐藏的文件与文件夹”。
代码已复制!
rm -rf .next # 清除缓存文件夹 yarn dev # 启动项目
现在我们能在各个页面看到 _app.js 带来的全局改动了,由于篇幅问题我们将会在后续内容讲到数据注入与全局 CSS。
接下来说说 _document.js,这个文件用于控制所有页面的 <html> 与 <head>、<body> 三个标签的内容,不过需注意的是,_document.js 中调用的是以 PascalCase 规则命名的内建同名组件如 <Head />,而非原生标签,像 <Head /> 没有特定内容的直接自闭合即可。简单来说,要给将整个网站声明为英文环境,我们可以直接改动 <Html> 组件,为其添加一句 lang="en":
代码已复制!
import { Html, Head, Main, NextScript } from 'next/document' export default function Document() { return ( <Html lang="en"> <Head /> <body> <Main /> <NextScript /> </body> </Html> ) }
保存后我们查看 项目网站 ,能在 F12 开发者工具中看到 html 的 lang="en" 属性了。然后让我们为整个网站添加标签栏小图标,由于之前我们已经将 课程资源包 中的 项目文件/nextjs/public/ 目录复制到我们的 Next.JS 项目,所以现在我们仅需在 <Head> 中添加标签,就像这样:
代码已复制!
import { Html, Head, Main, NextScript } from 'next/document' export default function Document() { return ( <Html lang="en"> <Head> <link rel="icon" href="/favicon.ico" /> </Head> <body> <Main /> <NextScript /> </body> </Html> ) }
保存以应用 favicon 。
_document.js 文件只在服务器端被渲染过,所以任何事件如 onClick 不得添加到该文件之中。接下来说说自定义错误页面,官方提供了 404.js 和 500.js 这两种类型,需要添加更多类型则需在 _error.js 中依照错误数据进行条件渲染。我们先来看看 Next.JS 默认的错误页面,我们先像这样输入一个不存在的路由:
可以看到页面报 404 了,左上角还有我们为 _app.js 添加的全局内容:

现在让我们创建 404.js 到 @/pages/ 目录下并 return 一些内容:
代码已复制!
export default function Custom404(){ return <h1>404 - Page not found</h1> }
可以看到 404 页面已经更新。关于更多错误的条件判断渲染,我们将于后续课程逐步讲述。
3.2.4 Next.JS:单独共享布局与分页布局
介绍完页面,现在了解一下 Next.JS 中的布局,以设定好公共内容,这对全站优化大有裨益:
- 设定全站共用的字体、样式、脚本,以及各类多媒体静态资源;
- 引入全站通用组件,如导航、页尾、浮动菜单,从而集中化管理;
- 配合
useRouter和 内建的Link组件以实现页面部分更新,大大减小客户端与服务器压力等等。
现在就让我们来撰写一个**单独共享布局(Single Shared Layout)**样例,并将其引用到所有页面中去。
第一步:先在 src/ 目录下创建一个 components/ 目录,再在其中创建一个 Layout.js 与 global/ 目录,并于 global/ 目录中创建 Nav.js 与 一个 Footer.js 组件,完成后的项目结构如下显示:
代码已复制!
nextjs ├── ... ├── src │ ├── pages │ │ ├── ... │ └── components │ ├── Layouts.js │ └── global │ ├── Nav.js │ └── Footer.js └── ...
@/pages/ 目录下的页面文件请遵循 lowercase 命名规则,而位于 @/components/ 目录下的组件名称则采用 PascalCase 命名规则。请查阅 Next.JS 开发指南(仍在撰写) - 项目结构 来了解更多规范化命名标准。第二步:为 @/components/global/ 目录下的 Nav.js 与 Footer.js 添加内容:
Nav.js
代码已复制!
export default function Nav() { return <p>This is navigation.</p> }
Footer.js
代码已复制!
export default function Footer() { return <p>This is footer.</p> }
第三步,编辑 Layout.js :
代码已复制!
import Nav from '@/components/global/Nav' import Footer from '@/components/global/Footer' export default function Layout({ children }) { return ( <> <Nav /> <main>{ children }</main> <Footer /> </> ) }
最后,通过编辑 _app.js ,将其引用到整个项目中去:
代码已复制!
import Layout from '@/components/Layout' export default function App({ Component, pageProps }) { return ( <Layout> <Component {...pageProps} /> </Layout> ) }
然后重启开发服务器以应用更改:
代码已复制!
# 停止当前服务器(Ctrl + C) # 然后重新启动 yarn dev
_app.js 或添加新的布局组件时,建议重启开发服务器以确保更改生效。如果遇到缓存问题,可以手动清除 .next 目录:
代码已复制!
rm -rf .next # macOS/Linux rmdir /s .next # Windows yarn dev # 重新启动
再次访问 项目主页 ,不出意外的话,你会看到导航和页尾的内容都正确显示了:

接下来再讲讲分页布局(Per-Page Layout)。我们可以将其作为一个组件分别引入到各个页面中去,假设我们只需要一部分页面显示订阅栏 subscription,我们现在可以创建以下两个文件,分别是 @/components/NestedLayout.js 以及 @/components/global/Subscription.js:
@/components/NestedLayout.js
代码已复制!
import Subscription from '@/components/global/Subscription' export default function NestedLayout({ children }) { return ( <> <p>Nested layout</p> <Subscription /> </> ) }
@/components/global/Subscription.js
代码已复制!
export default function Subscription() { return <p>This is subscription.</p> }
然后在 Blog 页面中引入内容:
代码已复制!
import NestedLayout from '@/components/NestedLayout' export default function NestedLayout() { return ( <> <p>This is blog page.</p> <NestedLayout /> </> ) }
此时我们可以来对比一下 项目主页 和 博客页面 ,会立刻发现两者都有 _app.js 引入的 Layout,但 NestedLayout 仅在后者中显示:
Index (Single Shared Layout)

Blog (Shared Layout with Per-Page Layout)

以上便是单独共享布局与分页布局的区别与使用方法。
3.2.5 Next.JS:链接与导航
官方文档到本章节会开始讲解命令式路由(Imperative Routing)与浅路由(Shallow Routing),不过为了便于宝子们理解,我们将在后续课程中掌握对应的函数、API 和组件后进行深度讲解,此处仅讲解下链接与导航(Linking & Navigation)。
Next.JS 内建了一个强大的 <Link> 组件来优化整个网站于客户端的效率与体验。对比传统 <a> 标签,其已知的优点包括:
<Link>实现点击时的特定数据渲染,而不是像<a>那样跳转并刷新整个页面;<Link>组件会智能预取链接的页面资源,在生产环境中默认预取可见的链接,以提高导航性能;<Link>组件渲染为标准的<a>标签,配合 Next.JS 的 SSR/SSG 特性,有助于搜索引擎爬取和索引。
至于 <a> 标签,指向站外时这将会是传统且更好的使用选择;我们将在之后的课程中详细讲解这两者的差异与使用场景与案例,现在先让我们为导航栏撰写一些示例内容:
代码已复制!
import Link from 'next/link' export default function Nav() { return ( <> <ul> <li> <Link href="/">Home</Link> </li> <li> <Link href="/course">Course</Link> </li> <li> <Link href="/blog">Blog</Link> </li> <li> <Link href="/about">About</Link> </li> </ul> </> ) }
访问 项目主页 ,按下 F12 查看 Network 标签,刷新页面后再点击每个链接看看:

我们会发现点击链接时页面并没有跳转,而是每点击一次获取一个页面文件——它以极高的效率仅获取需要的文件并实时渲染出来;我们会在下文提供这些内建组件的使用案例并作详解。
3.3 Next.JS:API 函数与页面渲染(SSR、SSG、CSR)
探索 Next.JS 提供的各项 API 与核心渲染策略。
3.3.1 Next.JS:API 函数简介
我们能在 Next.JS 官方文档 - API 参考 页面看到组件、文件系统约定、函数、配置、CLI、Edge 运行环境与快速启动包这七个内容:

组件我们集中放在下个小节讲解,其他内容在这个阶段暂时不重要所以跳过,让我们先浏览下 Next.15 的 API 函数:
| 函数名称 | 用途/功能描述 | 补充说明 |
|---|---|---|
getInitialProps | 在服务器端为 React 组件获取动态数据 | Legacy API,除了需要为 _app 获取全局数据以外不推荐使用 |
getServerSideProps | 服务器端渲染函数,用于在每次请求时获取数据 | SSR |
getStaticPaths | 静态生成函数,用于定义需要预渲染的动态路由路径 | SSG |
getStaticProps | 静态生成函数,用于在构建时获取数据 | SSG |
NextRequest | Next.JS 请求对象的 API 引用 | 主要用于 App Router 和 Middleware |
NextResponse | Next.JS 响应对象的 API 引用 | 主要用于 App Router 和 Middleware |
useAmp | 在页面中启用 AMP,并控制 Next.JS 添加 AMP 的方式 | 将于 Next.16 废弃,不建议学习 |
useReportWebVitals | 用于报告 Web Vitals 性能指标 | next/web-vitals |
useRouter | Next.JS 路由器的 API,用于访问和操作路由 | next/router |
userAgent | 扩展 Web Request API 的用户代理助手函数 | next/server |
接下来为宝子们着重讲解下前四个渲染函数。
3.3.2 Next.JS:服务端渲染 vs 静态页面生成
首先让我们用一张表格枚举三类渲染的特性与函数:
| 渲染方式 | 全称 | 内建函数 | 说明 |
|---|---|---|---|
| SSR | Server-side Rendering(服务器端渲染) | getServerSideProps() | 每一次请求都将使服务端渲染一次页面,也称之为动态渲染(Dynamic Rendering) |
| SSG | Static Site Generation(静态页面生成) | getStaticProps(); getStaticPaths() | 判断取决于外部数据的是你的页面内容还是页面路径,随后生成静态页面 |
| CSR | Client-side Rendering(客户端渲染) | useEffect(); useSWR | 在客户端初次加载页面后,可选获取数据并于本地重渲染部分 DOM |
先说说 SSR ,当一个页面执行 SSR 渲染方式时,用户的每一次请求都会让服务器端生成一次 HTML 下发;只有当你的页面需要实时显示变动,且时常修改内容,我们才建议用 SSR 方式进行数据获取并渲染页面。我们使用 getServerSideProps() 异步函数来实现服务器端动态渲染,示例如下:
代码已复制!
export default function Home({ data }) { console.log(data) // 在控制台打印传入的数据 return ( // 传入 data.updatedAt 值 <> <h1>Hello, Next.JS!</h1> <p>This page was updated at {data.updatedAt}.</p> </> ) } // 每一次客户端请求都将调用这个函数 export async function getServerSideProps() { const res = await fetch(`http://127.0.0.1:1337/api/home`) // 访问位于本地的 Strapi API const response = await res.json() // 将其转换为 JSON // 通过 props 向页面传递数据 return { props: { data: response.data } } }
这样一来,我们对 Strapi 后台中 Home 页面的任何修改就都会直接反应在更新时间上;打开控制台,我们还能看到 Home 的数据被正确获取了(当然由于缺少了 populate 函数,这里并没有拿到任何填充数据)。
接下来说说 SSG,以 About 页面举例:
代码已复制!
export default function About() { return <p>This is about page.</p> }
相信一部分宝子们要忍不住发问了:
- Q:等一下,这不就 return 了一段原生 HTML 吗?
- A:确实是的,当我们访问 About 页面时,
_document提供 HTML 文档结构,_app接收页面组件和数据,Layout.js组件提供页面布局框架,最后about.js作为具体页面内容通过pageProps接收数据并完成渲染。
- A:确实是的,当我们访问 About 页面时,
- Q:说是这么说,这不相当于直接写 HTML 吗?数据在哪?
- A:让我们使用
getStaticProps()函数做对比,以 Course 页面为例:
- A:让我们使用
代码已复制!
export default function Course({ data }) { return <p>This is course page, the page was updated at {data.updatedAt}.</p> } // 与 getServerSideProps 不同,本函数仅在项目构建(next build)阶段执行 export async function getStaticProps() { const res = await fetch(`http://127.0.0.1:1337/api/course`) const response = await res.json() return { props: { data: response.data } } }
接着,咱们 next build 看看:
代码已复制!
# 于终止 Next.JS 项目后 yarn build # 构建项目 # 终端输出: $ next build ▲ Next.js 16.0.5 (Turbopack) ✓ Finished TypeScript in 57.1ms Creating an optimized production build ... ✓ Compiled successfully in 644.9ms ✓ Collecting page data using 11 workers in 360.4ms ✓ Generating static pages using 11 workers (9/9) in 278.6ms ✓ Finalizing page optimization in 5.8ms Route (pages) ┌ ƒ / ├ /_app ├ ○ /404 ├ ○ /about ├ ○ /blog ├ ● /blog/[...slug] │ ├ /blog/beautiful-picture │ ├ /blog/a-bug-is-becoming-a-meme-on-the-internet │ └ /blog/this-shrimp-is-awesome ├ ○ /category/[slug] └ ● /course ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses getStaticProps) ƒ (Dynamic) server-rendered on demand ✨ Done in ...
上述内容中的 ○ 为静态内容预渲染,● 是静态 HTML 预渲染,而 ƒ 则为动态渲染页面,也称之为 SSR。
接下来我们用 yarn start 启动项目,然后打开 首页 再在开发者管理器中查看 Network 选项卡:

我们会发现 about.js 与 blog.js 已全部读取完毕,点击跳转不会有任何新的请求发送;SSG 页面在客户端路由跳转时会预加载 JSON,但不会重新获取服务器数据,也就是获取 [PageName].json 到本地执行重渲染。
接下来让我们去 Strapi 修改一下 Home 和 Course 页面的内容,然后分别点击两个页面看看,发现 Home 已经根据最后获取的 JSON 产生了变化,但 Course 没有变化:
Home

代码已复制!
{ "pageProps": { "data": { "id": 14, "documentId": "uy6uxon8ztjpzgp0f5dj6ers", "createdAt": "2025-11-26T10:38:10.018Z", "updatedAt": "2025-11-28T08:31:19.229Z", "publishedAt": "2025-11-28T08:31:19.285Z" } }, "__N_SSP": true }
Course

代码已复制!
{ "pageProps": { "data": { "id": 8, "documentId": "trxqfskyf7ewr627e7ajvx85", "createdAt": "2025-11-26T10:36:01.045Z", "updatedAt": "2025-11-28T08:22:54.826Z", "publishedAt": "2025-11-28T08:22:54.871Z" } }, "__N_SSG": true } Explain
通过上述两者于 Dev tools - Network 中读取的 JSON 文件,会发现前者已更新,而后者仍是先前的数据,再对比下 Strapi 接口看看:
此外,当我们每次将指针悬停在 Course 链接上时,能看到 Network 中立刻显示读取了一次 course.json;而当每一次点击 Home 链接时,Next.JS 才会从 Strapi 获取数据 index.json 并下发至客户端。需着重注意两者区别在于:
- SSG 页面:其 JSON 文件在悬停时预加载,但数据来源于构建时生成的静态文件,也就是构建输出目录
.next/static/下的预生成文件,内容固定不变 - SSR 页面:每次请求都会触发服务器端数据获取,也就是从 Strapi API 获取,内容可实时更新
这就解释了为什么修改 Strapi 后端数据时,SSR 页面立即反映变化,而 SSG 页面需要重新构建才能看到更新。以下是三者的详细对比:
| 对比项 | SSR | SSG | CSR |
|---|---|---|---|
| 渲染时机 | 每次请求时 | 构建时 | 客户端加载后 |
| 数据新鲜度 | 实时 | 构建时的快照 | 取决于请求时机 |
| 首屏加载速度 | 中等 | 最快 | 较慢 |
| 服务器压力 | 高 | 低 | 无 |
| SEO 友好度 | 优秀 | 优秀 | 较差 |
| 适用场景 | 实时数据展示 | 内容相对固定 | 用户交互频繁 |
| 示例页面 | 新闻首页、股票行情 | 博客文章、产品页 | 用户仪表盘、聊天应用 |
| 成本 | 高(服务器资源) | 低(静态托管) | 低(静态托管) |
性能优化最佳实践:选择合适的渲染策略
- ✅ 内容不常变化 → 使用 SSG
- ✅ 需要实时数据 → 使用 SSR
- ✅ 用户交互频繁 → 使用 CSR
- ✅ 需要定时更新 → 使用 ISR
此外在项目构建后如需实时更新,则需要为返回值添加 ISR 参数,我们将于 第五章 - Next.JS:为 SSG 页面设置 ISR 以实现性能优化与预生成 中讲解。
3.3.3 Next.JS:基于页面内容或路径的静态页面生成
由于上个章节我们使用了 yarn build 构建项目并使用 yarn start 启动项目于生产环境,所以现在我们需要先在终端里清除缓存并启动自开发模式:
代码已复制!
# 于终止项目进程后 rm -rf .next # 清理缓存 yarn dev # 开发模式
先前提到了 SSG 有两种异步函数,分别是 getStaticProps() 和 getStaticPaths() ,现在讲讲两者区别。官方的定义是,如果你的页面内容取决于外部数据就用前者,如果是页面路径就用后者。
简单来说,getStaticProps() 用于获取页面数据,getStaticPaths() 则专门用于动态路由的路径预生成,如对应 Strapi 的 pluralApiId (/api/articles/ 等等)。举例如下:
@/pages/blog.js
代码已复制!
export default function Blog({ data }) { return <p>This is Blog page, the page was updated at {data.updatedAt}.</p> } export async function getStaticProps() { const res = await fetch(`http://127.0.0.1:1337/api/blog`) const response = await res.json() return { props: { data: response.data } } }
Blog 和 Home、Course、About 一样,是常见的静态路由
@/pages/blog/[...slug].js
代码已复制!
export default function BlogArticle({ data }) { return <p>Post title: {data.title}</p> } // 本函数仅在项目构建(next build)阶段执行 export async function getStaticPaths() { // 1. 从 Strapi 获取所有文章数据 const res = await fetch('http://127.0.0.1:1337/api/articles') const response = await res.json() // 2. 将文章数据转换为 Next.JS 需要的路径格式 // 例如:[{ params: { slug: ['beautiful-picture'] } }, ...] const paths = response.data.map((article) => ({ params: { slug: [article.slug] // 数组形式支持嵌套路由,如 /blog/category/article }, })) // 3. 返回路径列表和 fallback 配置 // fallback: false 表示未预生成的路径将返回 404 return { paths, fallback: false } } // 需要注意的是,使用 `getStaticPaths()` 时必须同时使用 `getStaticProps()`,因为 Next.JS 需要知道如何为每个预生成的路径获取对应的数据 // 同上,本函数仅在项目构建(next build)阶段执行 export async function getStaticProps({ params }) { const slug = params.slug[0] // 获取 URL 中的 slug 参数 // 用 REST API 过滤 slug 相等的文章。如路由指向 `/blog/article-name`,则 `slug` 的值应为 `article-name` const res = await fetch(`http://127.0.0.1:1337/api/articles?filters[slug][$eq]=${slug}`) const response = await res.json() return { props: { data: response.data[0] // 取第一个匹配的文章(由于 slug 是唯一标识,这里必然只有一个) } } }
Article 与 Category 则需要采用 getStaticPaths() 方法获取路径
通过访问 http://127.0.0.1:3000/blog/beautiful-picture ,我们能看到当传入正确的 slug 值时,博客文章的标题会被渲染出来。
getStaticPaths() 与 Slugify 的结合使用。我们将会在后续课程开展讲解。3.3.4 Next.JS:客户端渲染:数据获取与动态交互
通过 React Hooks(如 useEffect 与 useState)或第三方库(如 useSWR),我们可以在客户端实现数据获取与实时渲染。以下枚举一些使用场景:
-
useEffect:
- 数据获取:组件挂载后获取初始数据,或依赖变化时重新获取数据
- DOM 操作:获取元素的坐标与大小,操作 DOM 元素
- 事件管理:为 DOM 添加或移除事件监听器
- 副作用清理:管理定时器、订阅等需要清理的副作用
- 生命周期模拟:模拟类组件的生命周期方法
-
useState:
- 状态管理:管理组件内部的局部状态(如表单输入、开关状态等)
- UI 交互:控制模态框显示/隐藏、选项卡切换、下拉菜单展开等
- 数据存储:临时存储从 API 获取的数据或用户输入
- 条件渲染:基于状态值决定渲染不同的 UI 内容
- 计数器逻辑:实现点击计数、分页控制等数值变化场景
让我们用 About 页面做一组例子:
代码已复制!
import { useState, useEffect } from 'react' export default function About() { // useState:管理点击计数状态 const [clickCount, setClickCount] = useState(0) const [showNewContent, setShowNewContent] = useState(false) // useEffect:组件挂载时设置2秒延时显示新内容 useEffect(() => { const timer = setTimeout(() => setShowNewContent(true), 2000) return () => clearTimeout(timer) }, []) return ( <> <p>This is about page. {showNewContent ? 'The page has loaded and passed 2 secs.' : ''}</p> <button onClick={() => setClickCount(prev => prev + 1)}> This button has been clicked {clickCount} times. </button> </> ) }
加载页面,页面将于等待2秒后出现新内容;点击按钮,计数器将不断自增。这两个例子生动证明了 CSR(客户端渲染)在无需数据请求的情况下可以完全脱离服务端执行,而接下来是一个结合数据请求的例子:
代码已复制!
import { useState, useEffect } from 'react' export default function Blog() { const [data, setData] = useState(null) // 设置数据状态为空 useEffect(() => { const timer = setTimeout(() => { fetch(`http://127.0.0.1:1337/api/blog?populate=*`) // 于 2 秒之后获取包含了全量填充数据的 blog 数据 .then(res => res.json()) .then(response => setData(response.data)) }, 2000) return () => clearTimeout(timer) }, []) if (!data) return <p>Loading...</p> // 由于数据初始为空,会先显示 Loading... ,状态改变时才会 return 下列 HTML return ( <> <p>This is Blog page.</p> <p>Today's featured article: {data.articlesOfTheDay.title}</p> </> ) }
在本示例中,Next.JS 服务端仅提供初始页面框架,所有的动态内容更新都依赖于客户端向 Strapi 服务端请求 JSON 数据,然后在浏览器中执行状态更新和 DOM 渲染。
3.4 Next.JS: 内建组件与资源引用
Next.JS 提供了一些内建组件与资源优化,搭配 React Hooks 开发起来直观高效。
3.4.1 Next.JS: 内建组件
首先讲讲组件。让我们来看一个包含了 <Head>、<Image>、<Link> 与 <Script> 的 404 页面案例:
代码已复制!
// Import Next.JS components import Head from 'next/head' import Image from 'next/image' import Link from 'next/link' import Script from 'next/script' export default function Custom404(){ return ( <> <Head> <title>404 - Page not found</title> <meta name="description" content="Page not found" /> </Head> <Script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4" strategy="beforeInteractive" /> <div className="..."> <Image src="/temp/test-image.jpg" width={100} height={100} alt="Test image of guide project"/> <h1>404 - Page not found</h1> <Link href="/">Back to home</Link> </div> </> ) }
在 Next.JS 的 HTML 片段中,所有的 HTML 标签的
class属性必须编写为className,它会在编译阶段转换为标准的class属性以兼容网页浏览器。
在这个例子中:
- Head:管理页面的
<head>标签内容,设置页面标题和描述 - Image:优化图片加载,自动处理响应式和懒加载
- Link:实现客户端路由跳转,无需页面刷新
- Script:优化外部脚本加载,这里引入了 Tailwind CSS
接下来让我们将组件和原生 HTML 做个对比:
| 组件标签 | 渲染的 HTML 标签 | 对比原生 HTML | 主要优势 | 适用场景 |
|---|---|---|---|---|
<Head> | <head> | 支持多个组件同时修改、自动去重、优先级管理 | SEO 优化 | 设置页面标题、描述、关键词 |
<Image> | <img> | 自动优化、懒加载、响应式、WebP 转换、防布局偏移 | 性能优化 | 展示图片、缩略图、背景图 |
<Link> | <a> | 客户端路由、预加载、无刷新跳转 | 用户体验 | 站内导航、菜单链接 |
<Script> | <script> | 加载策略控制、性能优化、错误处理 | 性能优化 | 引入第三方脚本、分析工具 |
其中 <Script> 有几个可选的加载策略:
| 策略 | 说明 | 加载时机 | 适用场景 |
|---|---|---|---|
beforeInteractive | 在页面交互前加载 | 页面 hydration 之前 | 关键脚本(如 polyfills) |
afterInteractive | 在页面交互后加载 | 页面 hydration 之后 | 分析工具、广告脚本 |
lazyOnload | 空闲时加载 | 浏览器空闲时 | 非关键脚本、聊天插件 |
worker | 在 Web Worker 中加载 | 后台线程 | 计算密集型任务 |
为了确保项目代码的可读性、易维护性,以及上线时生产模式下的性能优化,除了上文提到的外链建议用 <a> 标签以外,Next.JS 官方建议使用以上组件替换所有对应的原生 HTML标签。
💡 请注意:一个页面上不该存在两个 <Head>,并且 <Script> 作为全局脚本时应该放置在 <Head> 正下方,而功能或组件脚本则可以靠近对应的模块,由于 <Script> 的执行实际由其 Strategy 决定,而非原生 <script> 那样由在 DOM 结构中的次序而定,所以编写代码时应优先确保可读性。
<Head> 引入自 'next/head',用于单个页面的头部内容管理;而 _document.js 中引入自 'next/document' 的 <Head> 用于定义整个应用的 HTML 文档结构。两者命名相同但用途不同,请勿混淆。Script 将会实现整个页面的 preflight 预格式化,所以你会看到 li 自带的样式消失、链接没有下划线,且所有字体改成了衬线体;切换到其他路由时,由于页面只有部分加载,并没有整个刷新,所以 Tailwind CSS CDN 的 Script 仍会影响页面样式,这是正常现象,刷新即可。除了 <Head> 以外,每一个 Next.JS 的内建组件都新增了多种接口,如 <Image> 可用的 placeholder 等等,我们将在之后的开发过程中具体讲解这些接口。
3.4.2 Next.JS:CSS 引用
接下来让我们引入 CSS。在 Next.JS 官方支持的 CSS 引入方法中,包括模块化 CSS、全局 CSS、外联 CSS、Sass 与 CSS-in-JS,不过首推的依然是 Tailwind CSS,现在我们就来为项目配置。
代码已复制!
# 于终止项目进程后 yarn add -D tailwindcss @tailwindcss/postcss # 安装基于 PostCSS 的 Tailwind CSS 依赖 touch postcss.config.mjs # 创建 PostCSS 配置文件
然后编辑该文件内容:
代码已复制!
export default { plugins: { '@tailwindcss/postcss': {}, }, }
接着在 @/styles/ 目录下创建 globals.css,在其中添加 @import "tailwindcss",如下所示:
代码已复制!
@import "tailwindcss";
最后在项目的 _app 文件中引入:
代码已复制!
import Layout from '@/components/Layout' import '@/styles/globals.css' export default function App({ Component, pageProps }) { return ( <Layout> <Component {...pageProps} /> </Layout> ) }
然后启动项目,通过为 @/pages/index.js 的 HTML 元素添加 className="text-center" 属性来应用 Tailwind CSS 示例:
代码已复制!
export default function Home({ data }) { return ( <> <h1 className="text-center">Hello, Next.JS!</h1> <p>This page was updated at {data.updatedAt}.</p> </> ) } export async function getStaticProps() { const res = await fetch(`http://127.0.0.1:1337/api/home`) const response = await res.json() return { props: { data: response.data } } }
通过访问首页 http://127.0.0.1:3000,我们能直接看到如下改动:
- 整个页面已应用 Preflight 样式重置,浏览器为
<body>默认添加的margin值消失了,<h1>、<ul><li>、<a>等标签都不再具备默认样式; - 字体从默认的衬线体替换为非衬线体;
className="text-center"为代码片段中的<h1>应用了文字居中样式。
上述代码已经包含了基于 Tailwind CSS 的全局引用样式,本课程项目也会在后续教学中实际运用模块化 CSS。
3.4.3 Next.JS:字体引用
Next.JS 内建了 next/font/local 与 next/font/google 两种引入方式,个人更推荐海外项目直接引入 Google Fonts,不过这里我们还是先从本地引入开始讲解。
现在,于 @/components/Layout.js 中引入如下内容:
代码已复制!
import localFont from 'next/font/local' // 引入本地字体模块 import Nav from '@/components/global/Nav' import Footer from '@/components/global/Footer' const sen = localFont({ src: '../../public/fonts/Sen-VariableFont_wght.ttf' // 此处无法引用 jsconfig.json 的路径映射,必须采用相对路径获取 }) export default function Layout({ children }) { return ( <div className={sen.className}> {/* 为外部 div 添加 className 属性,传入名为 fontName.ClassName 的对象 */} <Nav /> <main>{ children }</main> <Footer /> </div> ) }
保存后查看页面,发现我们已经为其应用了 Sen 字体家族;如果你正在使用的并非一个可变字体,或者你想为单个字体家族引入一套文件,那可以像这样编辑:
代码已复制!
import localFont from 'next/font/local' // 引入本地字体模块 import Nav from '@/components/global/Nav' import Footer from '@/components/global/Footer' const sen = localFont({ src: [ // 作为数组传入这一组字体,定义子重、样式等字体属性 { path: '../../public/fonts/static/sen/Sen-ExtraBold.ttf', weight: '800', style: 'normal' }, { path: '../../public/fonts/static/sen/Sen-Bold.ttf', weight: '700', style: 'normal' }, { path: '../../public/fonts/static/sen/Sen-SemiBold.ttf', weight: '600', style: 'normal' }, { path: '../../public/fonts/static/sen/Sen-Medium.ttf', weight: '500', style: 'normal' }, { path: '../../public/fonts/static/sen/Sen-Regular.ttf', weight: '400', style: 'normal' } ] }) export default function Layout({ children }) { // 为 <main> 标签添加 fontName.className 的对象 return ( <div className={sen.className}> <Nav /> <main>{ children }</main> <Footer /> </div> ) }
此外,如果我们最终部署网站的服务器可以直接连接到 Google CDN,且无需指定特殊的本地字体,那就更推荐使用 Google Font 直接引入字体。首先请访问 Google Font 选择一个你想要的字体,比如我们这边搜索到的 Sen;

点一下 "Get Font",再点击 "Get embed code":

我们能看到官方显示的字体名称为 Sen,将其记录下来后引入到 Next.JS 项目中来:
代码已复制!
import { Sen } from 'next/font/google' // 引入谷歌字体模块 import Nav from '@/components/global/Nav' import Footer from '@/components/global/Footer' const sen = Sen({ subsets: ['latin'] // 设置一个 subsets 参数 }) export default function Layout({ children }) { return ( <div className={sen.className}> <Nav /> <main>{ children }</main> <Footer /> </div> ) }
刷新查看,已经发现字体家族已经应用好了;关于配合 Tailwind CSS 使用的方法将于下文讲述。
3.5 Next.JS + Strapi:开发与数据传递
接下来我们就要结合上文学到的知识,用 REST API 从 Strapi 获取数据,并在 Next.JS 中渲染出来。
3.5.1 Next.JS:环境变量与实用工具
首先我们先要设置一下 Next.JS 各环境下的全局变量以供组件引用。
开始之前,我们可以快速复习一下本文档 了解项目结构 小节中提到的顶层文件的知识点片段:
- ...
.env: 默认环境文件.env.local: 本地环境.env.production: 生产环境.env.development: 开发环境- ...
在本地开发时创建一个 .env.local 文件在目录下即可。以下是内容示例:
代码已复制!
NEXT_PUBLIC_API_URL='http://127.0.0.1:1337' NEXT_PUBLIC_SITE_URL='http://127.0.0.1:3000'
接下来,我们可以在项目的任意源码中自由地引入这些变量。以 Home 举例:
代码已复制!
export default function Home({ data }) { return ( <> <h1 className="text-center">Hello, Next.JS!</h1> <p>This page was updated at {data.updatedAt}.</p> </> ) } export async function getStaticProps() { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/home`) // 将其替换为环境配置中的全局变量 const response = await res.json() return { props: { data: response.data } } }
在后续部署与测试时,我们仅需创建不同的 .env 文件,或修改对应的字符串就可以实现轻松管理了。
此外,由于 await fetch 与 .json() 非常常用,我们可以将其封装为一个实用工具将其存放在 @/lib/ 目录下,就像这样子:
代码已复制!
nextjs ├── ... ├── src │ ├── components │ ├── lib │ │ └── api.js # 新建文件 │ └── pages └── ...
在此之前不要忘了设置一下 jsconfig.json , 为其添加 @/lib 目录:
代码已复制!
{ "compilerOptions": { "baseUrl": "src/", "paths": { "@/pages/*": ["pages/*"], "@/styles/*": ["styles/*"], "@/components/*": ["components/*"], "@/lib/*": ["lib/*"] } } }
接着创建并编辑 api.js,内容如下:
代码已复制!
export async function fetcher(url) { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/${url}`); const data = await response.json(); return data; }
这样页面便可一步到位地从 API 获取数据并 JSON 化:
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' export default function Home({ data }) { ... } export async function getStaticProps() { const response = await fetcher(`home`) // 只需要传入 api/ 之后的内容即可 return { props: { data: response.data } } }
现在我们可以为每个页面调用 @/lib/api.js,并使用 fetcher async 方法了。
此外 Strapi 的时间也需要格式化,我们来创建一个 @/lib/formatDate.js 方便格式化时间:
代码已复制!
export default function formatDate(dateString) { const date = new Date(dateString) return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }) }
之后我们便可直接在页面中引入这一工具并使用,下述章节会做详解。
3.5.2 Next.JS:组件建立与引入
接下来让我们为整个站点中所有的静态页面添加所需的组件。我们在本章节需要实现的 @/components/ 目标结构树长这样:
代码已复制!
nextjs ├── .next ├── node_modules ├── public ├── src │ ├── components │ │ ├── article # 博客文章模块 │ │ │ └── ArticleDynamicZone # 文章动态内容 │ │ ├── blog # 博客页模块 │ │ │ └── FeaturedArticles.js # 推荐文章 │ │ ├── category # 目录页模块 │ │ │ └── CategoryIntroductions.js # 目录介绍 │ │ ├── course # 课程页模块 │ │ │ ├── Courses.js # 公开课程 │ │ │ └── SpecializedCourses.js # 专业培训 │ │ ├── global # 全局模块 │ │ │ ├── Background.js # 全站背景 │ │ │ ├── CustomizedHead.js # 统一接口管理的页头 │ │ │ ├── Footer.js # 页尾 │ │ │ ├── Nav.js # 导航 │ │ │ └── Subscription.js # 订阅栏 │ │ ├── home # 主页模块 │ │ │ ├── Comparisons.js # 对比 │ │ │ └── Frameworks.js # 技术栈 │ │ │ ├── GuideChapters.js # 指南章节 │ │ │ ├── Introduction.js # 介绍 │ │ │ ├── IntroductionDecoration.js # 介绍装饰 │ │ │ ├── Playground.js # 代码调试场 │ │ ├── shared # 跨页共享模块 │ │ │ ├── About.js # 关于 │ │ │ ├── ArticleCard.js # 文章卡片 │ │ │ ├── ArticleCardContainer.js # 文章卡片容器 │ │ │ └── Information.js # 信息 │ │ ├── Layout.js # 布局模块 │ │ └── NestedLayout.js # 嵌套布局模块,暂时用不到 │ └── pages ├── .env ├── .gitignore ├── jsconfig.json ├── package.json ├── postcss.config.mjs └── yarn.lock
for i in blog/FeaturedArticles.js category/CategoryIntroductions.js course/Courses.js course/SpecializedCourses.js global/Background.js home/Comparisons.js h ome/Freameworks.js home/GuideChapters.js home/Introduction.js home/IntroductionDecoration.js home/Playground.js shared/About.js shared/ArticleCard.js shared/ArticleCardContainer.js shared/Informa tion.js; do cp article/ArticleDynamicZone.js "$i"; done
以博客文章组件 ArticleDynamicZone.js 为例:
代码已复制!
export default function ArticleDynamicZone() { return <p>Article dynamic zone</p> }
请依照这个格式创建好每一个组件,然后为 Layout 组件添加 Background 与 Subscription 子组件:
代码已复制!
import { Sen } from 'next/font/google' import Background from '@/components/global/Background' import Nav from '@/components/global/Nav' import Subscription from '@/components/global/Subscription' import Footer from '@/components/global/Footer' const sen = Sen({ subsets: ['latin'] }) export default function Layout({ children }) { return ( <div className={sen.className}> <Background /> <Nav /> <main>{ children }</main> <Subscription /> <Footer /> </div> ) }
然后我们还需要改造一下 _app.js 以全局传入 /api/global 数据:
代码已复制!
// Import global CSS file import '@/styles/globals.css' // Import libraries import { fetcher } from '@/lib/api' // Import components import Layout from '@/components/Layout' export default function App({ Component, pageProps, globalData }) { // 接收 globalData 参数 return ( <Layout> <Component {...pageProps} globalData={globalData} /> {/* 传递数据给子组件 */} </Layout> ) } // _app 必须使用 getInitialProps 方法 ,不支持 getStaticProps 与 getServerSideProps 等方法 App.getInitialProps = async (appContext) => { // 获取全局数据,其中 aboutUs.image 与 navigation.image 需要深度填充 const response = await fetcher(`global?populate[0]=defaultSeo&populate[1]=defaultSeo.shareImage&populate[2]=aboutUs&populate[3]=aboutUs.image&populate[4]=contact&populate[5]=navigation&populate[6]=navigation.image&populate[7]=footer&populate[8]=subscription&populate[9]=social`) // 调用页面的 getInitialProps 方法(如果存在) let pageProps = {} // 初始化 pageProps 为空对象 if (appContext.Component.getInitialProps) { // 如果当前页面组件有 getInitialProps 方法,则调用它获取页面特定的数据 pageProps = await appContext.Component.getInitialProps(appContext.ctx) } return { pageProps, globalData: response.data, } }
createContext 与 useContext 的搭配使用,但对于小型网站,getInitialProps 是更好的选择,它提供了更好的 SEO、更快的加载速度和更简单的代码结构;后续课程中我们将按需讲解这两个 Hooks。最后分别在各个页面引用各组件并传入数据,以 Home 举例:
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Introduction from '@/components/home/Introduction' import Comparisons from '@/components/home/Comparisons' import GuideChapters from '@/components/home/GuideChapters' import Playground from '@/components/home/Playground' import About from '@/components/shared/About' export default function Home({ globalData, data }) { return ( <> <CustomizedHead /> <Introduction /> <Comparisons /> <GuideChapters /> <Playground /> <About /> </> ) } export async function getStaticProps() { const response = await fetcher(`home`) return { props: { data: response.data } } }
其他静态页面内容如下:
@/pages/about.js
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' export default function About({ globalData, data }) { return ( <> <CustomizedHead /> <Information /> </> ) } export async function getStaticProps() { const response = await fetcher(`about`) return { props: { data: response.data } } }
@/pages/blog.js
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import FeaturedArticles from '@/components/blog/FeaturedArticles' import ArticleCardContainer from '@/components/shared/ArticleCardContainer' export default function Blog({ globalData, data }) { return ( <> <CustomizedHead /> <Information /> <FeaturedArticles /> <ArticleCardContainer /> </> ) } export async function getStaticProps() { const response = await fetcher(`blog`) return { props: { data: response.data } } }
@/pages/course.js
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import Courses from '@/components/course/Courses' import SpecializedCourses from '@/components/course/SpecializedCourses' import About from '@/components/shared/About' export default function Course({ globalData, data }) { return ( <> <CustomizedHead /> <Information /> <Courses /> <SpecializedCourses /> <About /> </> ) } export async function getStaticProps() { const response = await fetcher(`course`) return { props: { data: response.data } } }
动态页面中的模块需要做 API 条件渲染,因此于下文讲解。此外 error 页面的内容大致固定,不需要从接口拿数据,所以放在下个章节与 Tailwind CSS 一并讲解。
3.5.3 Next.JS:主页开发与组件传值
接下来让我们从 Home 开始开发。
第一步: 获取数据 我们可以使用 Postman 或 Insomnia 从 API 获取数据,或是直接按下列代码所示在浏览器查看。
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Introduction from '@/components/home/Introduction' import Comparisons from '@/components/home/Comparisons' import GuideChapters from '@/components/home/GuideChapters' import Playground from '@/components/home/Playground' import About from '@/components/shared/About' export default function Home({ globalData, data }) { console.log(globalData, data) // 控制台打印全局数据与页面数据 return ( <> <CustomizedHead /> <Introduction /> <Comparisons /> <GuideChapters /> <Playground /> <About /> </> ) } export async function getStaticProps() { const response = await fetcher(`home?populate=*`) return { props: { data: response.data } } }
这里填充的仅是深度为 1 的数据,而结合 字段列表文档 - 单页字段 - home 中的数据结构来看,主页最深的数据已经下探到 data.comparisonInformations.content.detailList 这个层级,所以仅仅填充一层是远远不够的。
第二步: 调整 API 参数 根据 Strapi REST API 的要求,我们需要手动逐个调用数据。调整后的 API 参数如下:
代码已复制!
http://127.0.0.1:1337/api/home?populate[0]=introduction&populate[1]=frameworks&populate[2]=comparisons&populate[3]=comparisonInformations.contents&populate[4]=comparisonInformations.contents.detailList&populate[5]=comparisonInformations.contents.logo&populate[6]=guideChapters&populate[7]=guideChapterContents&populate[8]=guideChapters.image&populate[9]=playground&populate[10]=playgroundContents&populate[11]=seo
现在控制台应该输出所有组件所需的数据,接下来让我们为各个组件传入数据,并进行 HTML 框架搭建。继续以 Home 为例:
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Introduction from '@/components/home/Introduction' import Comparisons from '@/components/home/Comparisons' import GuideChapters from '@/components/home/GuideChapters' import Playground from '@/components/home/Playground' import About from '@/components/shared/About' export default function Home({ globalData, data }) { console.log(globalData, data) return ( <> {/* 为组件传入各项数据 */} <CustomizedHead globalData={globalData} data={data.seo}/> <Introduction globalData={globalData} data={data.introduction} frameworksData={data.frameworks}/> <Comparisons globalData={globalData} comparisons={data.comparisons} comparisonInformations={data.comparisonInformations}/> {/* 为 Comparisons 组件结构传入两个独特名称的 props ,提供较高的可读性和易维护性,下同 */} <GuideChapters guideChapters={data.guideChapters} guideChapterContents={data.guideChapterContents}/> <Playground globalData={globalData} playground={data.playground} playgroundContents={data.playgroundContents}/> <About globalData={globalData} data={globalData.aboutUs}/> </> ) } export async function getStaticProps() { const response = await fetcher(`home?populate[0]=introduction&populate[1]=frameworks&populate[2]=comparisons&populate[3]=comparisonInformations.contents&populate[4]=comparisonInformations.contents.detailList&populate[5]=comparisonInformations.contents.logo&populate[6]=guideChapters&populate[7]=guideChapterContents&populate[8]=guideChapters.image&populate[9]=playground&populate[10]=playgroundContents&populate[11]=seo`) return { props: { data: response.data } } }
第三步: 传值测试
现在我们可以将 Home 页面的 console.log 去除,并为各个组件添加 {data} prop ,并按需添加 {globalData} prop 以进行传值并作数据测试了。以 @/components/home/Introduction.js 为例:
代码已复制!
export default function Introduction({ data }) { console.log(data) // 在组件内部打印 data 以在控制台查看 return <p>Introduction</p> }
控制台输出的打印内容大致是这样的:
代码已复制!
{ "id": 2, "heading": "MAKING POSSIBLE.", "description": "<p className=\"font-semibold text-blue-500\">Hold tight!</p><p>We’re racing ahead with cutting-edge web technologies to lead the industry.</p>", "codeblockLanguage": "JavaScript", "codeblockFile": "Title.js", "codeblockContent": "...\\n return {\\n <>\\n <h2 className=\"uppercase text-5xl font-medium”>{data.attributes.title}</h2>\\n <p className=\"font-semibold text-blue-500”>Hold tight!</p>\\n <p>We’re racing ahead with cutting-edge web technologies to lead the industry.</p>\\n </>}\\n...", "buttonText": "Start Tutorial >>", "buttonLink": "/", "openInNewTab": false, "decoration": "<div></div>" }
第四步: 搭建HTML 确认控制台已打印数据后,让我们用对应的 HTML 元素将组件搭建起来:
代码已复制!
// Import Next components import Link from 'next/link' // Import components import IntroductionDecoration from './IntroductionDecoration' import Frameworks from './Frameworks' export default function Introduction({ globalData, data, frameworksData }) { return ( <div> {/* 外部容器 */} <div> {/* 内部容器 */} <div> {/* 左半部分 */} <h2>{data.heading}</h2> <div> {/* 富文本容器 */} <p>{data.description}</p> {/* 这会输出未格式化的 Markdown 原文 */} </div> <div> {/* 代码块容器 */} <span></span> {/* 装饰背景框 */} <span>{data.codeblockLanguage}</span> <span>{data.codeblockFile}</span> <div> <p>{data.codeblockContent}</p> {/* 这会输出未格式化的 Markdown 原文 */} </div> </div> <Link href={data.buttonLink} {...(data.openInNewTab && { target: '_blank' })}>{data.buttonText}</Link> {/* 条件判断语句,如果 data.openInNewTab 为真才显示 target 参数与对应属性 */} </div> <div> {/* 右半部分 */} {data && data.customizedDecoration ? <div>{data.decoration}</div> : <IntroductionDecoration globalData={globalData}/>} {/* 自定义代码块或内置组件 */} </div> </div> <Frameworks data={frameworksData}/> </div> ) }
Awesome,Home 页面下调用的 @/components/home/Introduction.js 组件的所有内容都正确显现出来了,但是其中的代码块并没有被正确地渲染出来,由于涉及到较多插件,这里我们放到 第五章 - React.JS (JSX) :使用 ReactMarkdown 插件美化正文 讲。
接下来是比较难的部分,我们需要使用 map() 方法将一组数组类型的数据循环渲染出来,以下以上述中引入的子组件 @/components/home/Frameworks.js 为例:
代码已复制!
export default function Frameworks({ data }) { return ( <ul> {/* 用 ul 包裹无序列表项目 */} {/* data.frameworks 是一个数组,使用 map 循环渲染每个框架项,并添加 index 索引; 这里是一个对象,所以不要忘了 return 出去 */} {data && data.map((item, index) => ( <li key={index}> {/* 数组循环必须要有 key,所以传入的 index 派上了用场 */} <div> {/* 图标容器,实现背景 */} <span className={`icon icon-framework-${item.icon}`}></span> {/* 给 span 添加一个 icon 类名以作区分,下同 */} </div> <p>{item.name}</p> <p>{item.description}</p> </li> ))} </ul> ) }
这块如果没有问题,接下来就是稍微麻烦一点的 @/components/home/Comparisons.js。由于这个组件结构相对复杂,我们将分步骤来完成:
第一步: 搭建基础结构和上半部分内容 首先让我们完成组件的基础框架,以及相对简单的上半部分介绍区域:
代码已复制!
// Import Next components import Link from 'next/link' import Image from 'next/image' export default function Comparisons({ comparisons, comparisonInformations }) { console.log(comparisonInformations) // 先查看数据结构 return ( <div> {/* 外部容器 */} <div> {/* 上半部分介绍 */} <h2>{comparisons.heading}</h2> <p>{comparisons.description}</p> </div> <div> {/* 下方内容容器,暂时留空 */} {/* 这里将在后续步骤中填充 Tab 栏和卡片内容 */} </div> <Link href={comparisons.buttonLink} {...(comparisons.openInNewTab && { target: '_blank' })}>{comparisons.buttonText}</Link> {/* 链接按钮 */} </div> ) }
刷新页面,确认上半部分内容能够正确显示。
第二步: 添加 Tab 栏导航 接下来我们在下方内容区域添加 Tab 栏,用于显示不同的比较类别:
代码已复制!
// Import Next components import Link from 'next/link' import Image from 'next/image' export default function Comparisons({ comparisons, comparisonInformations }) { return ( <div> {/* 外部容器 */} <div> {/* 上半部分介绍 */} <h2>{comparisons.heading}</h2> <p>{comparisons.description}</p> </div> <div> {/* 下方内容 */} <ul> {/* Tab 栏 */} {/* 将三种 tab 名称与图标用 map 方法循环出来 */} {comparisonInformations.map((comparison, comparisonIndex) => ( <li key={comparisonIndex}> <span className={`icon-${comparison.icon}`}>{comparison.name}</span> </li> ))} </ul> <div> {/* 下半部容器 */} {/* 这里将在第三步中添加卡片内容 */} </div> </div> <Link href={comparisons.buttonLink} {...(comparisons.openInNewTab && { target: '_blank' })}>{comparisons.buttonText}</Link> {/* 链接按钮 */} </div> ) }
此时页面应该能显示出 Tab 栏的导航项目。
第三步: 卡片内容渲染 最后是最复杂的部分 - 多层嵌套的卡片内容渲染,包含推荐逻辑和条件渲染:
代码已复制!
// Import Next components import Link from 'next/link' import Image from 'next/image' export default function Comparisons({ globalData, comparisons, comparisonInformations }) { return ( <div> {/* 外部容器 */} <div> {/* 上半部分介绍 */} <h2>{comparisons.heading}</h2> <p>{comparisons.description}</p> </div> <div> {/* 下方内容 */} <ul> {/* Tab 栏 */} {comparisonInformations.map((comparison, index) => ( <li key={index}> <span className={`icon icon-tab-${comparison.icon}`}>{comparison.name}</span> </li> ))} </ul> <div> {/* 下半部容器 */} {comparisonInformations.map((comparison, comparisonIndex) => { {/* 分别渲染三组卡片内容 */} const order = { 'first': 0, 'second': 1, 'third': 2 }[comparison.recommendedContent] ?? comparisonIndex; {/* 判断传入的值,将对应的字符串绑定 index 值 */} return ( <div key={comparisonIndex}> {/* 卡片容器 */} {comparison.contents.map((content, contentIndex) => ( <div key={contentIndex} className={`${order == contentIndex ? 'recommended' : ''}`}> {/* 第二层:卡片,如 order 等于 contentIndex, 则添加一个名为 recommended 的 className */} <div> {/* 评星与下载量容器,因为要被 Logo 图片容器压住所以文档流在上 */} <span></span> {/* 背景裁切左下角圆角 */} <p> <span></span> {content.rating} </p> <p> <span></span> {content.downloads} </p> </div> <div> {/* Logo 图片容器 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${content.logo.url}`} alt={`${content.logo.alternativeText}${globalData.globalAddOn}`} width={content.logo.width} height={content.logo.height}/> {/* 此处要注意是从 Strapi 获取文件信息,且 alternativeText 需拼接 globalData.globalAddOn */} </div> <div> {/* 卡片外部容器 */} <div> {/* 卡片正文容器 */} {content && content.useDetailListAsContent ? <ul> {/* 第三层:卡片正文,判断 useDetailListAsContent 的值,为真则启用 detailList */} {content.detailList.map((detail, detailIndex) => ( <li key={detailIndex}> <span className={`${detail.advantage ? 'icon-pro' : 'icon-con'}`}></span> <p>{detail.description}</p> </li> ))} </ul> : <>{content.richtext}</>} {/* useDetailListAsContent 为假则启用 richtext */} </div> </div> </div> ))} </div> ) })} </div> </div> <Link href={comparisons.buttonLink} {...(comparisons.openInNewTab && { target: '_blank' })}>{comparisons.buttonText}</Link> {/* 链接按钮 */} </div> ) }
此时因为 Next.JS 项目会直接报错,提示你没有在 next.config.js 中设置一个图片源,所以我们需要设置一下:
代码已复制!
module.exports = { allowedDevOrigins: ['127.0.0.1', 'localhost'], images: { remotePatterns: [ { protocol: 'http', hostname: '127.0.0.1', port: '1337', pathname: '/uploads/**', }, { protocol: 'http', hostname: 'localhost', port: '1337', pathname: '/uploads/**', }, ], unoptimized: process.env.NODE_ENV === 'development', }, }
Ok,故障解除,我们能看到 Comparisons.js 组件被成功渲染出来了。解决掉最难的组件之后,剩下的都比较简单了,以下是其他三个组件的代码:
@/components/home/CustomizedHead.js
代码已复制!
// Import Next components import Head from 'next/head' export default function CustomizedHead({ globalData, data }) { const seo = { metaTitle: data?.metaTitle + globalData.globalAddOn || globalData.defaultSeo.metaTitle + globalData.globalAddOn, metaDescription: data?.metaDescription || globalData.defaultSeo.metaDescription, metaKeyword: data?.metaKeyword || "", shareImage: data?.shareImage?.url || globalData.defaultSeo.shareImage.url, } return ( <Head> <title>{seo.metaTitle}</title> <meta name="description" content={seo.metaDescription} /> <meta property="og:title" content={seo.metaTitle} /> <meta property="og:description" content={seo.metaDescription} /> <meta property="og:keyword" content={seo.metaKeyword} /> <meta property="og:image" content={`${process.env.NEXT_PUBLIC_API_URL}${seo.shareImage}`} /> </Head> ) }
@/components/home/GuideChapters.js
代码已复制!
// Import Next components import Link from 'next/link' export default function GuideChapters({ guideChapters, guideChapterContents }) { return ( <div> {/* 外部容器 */} <div> {/* 左侧信息 */} <h2>{guideChapters.heading}</h2> <p>{guideChapters.description}</p> <Link href={guideChapters.buttonLink} {...(guideChapters.openInNewTab && { target: '_blank' })}>{guideChapters.buttonText}</Link> </div> <div> {/* 右侧章节栏 */} {guideChapterContents.map((item, index) => ( <div key={index}> <div></div> {/* 背景容器 */} <div> {/* 内容容器 */} <p>Chapter.{index + 1}</p> <h4>{item.heading}</h4> <p>{item.description}</p> </div> <Link key={index} href={item.link} {...(item.openInNewTab && { target: '_blank' })}></Link> {/* 伴随屏幕尺寸改变遮罩状态的 a 标签 */} </div> ))} </div> </div> ) }
@/components/home/Playground.js
代码已复制!
// Import Next components import Link from 'next/link' export default function Playground({ globalData, playground, playgroundContents }) { return ( <div> <div> {/* 左侧代码调试场 */} <ul> {/* 下方左侧菜单栏 */} {playgroundContents.map((item, index) => ( <li key={index}> <span className={`icon icon-playground-${item.icon}`}></span> { item.customizedName ? <p>{item.name}</p> : <span className={`icon text-playground-${item.icon}`}></span> } {/* 如 customizedName 为真则使用文字 */} </li> ))} </ul> <div> {/* 示例渲染结果,溢出滚动 */} <div> {/* 渲染结果容器 */} {playgroundContents.map((item, index) => ( <div key={index}>{item.codeblock}</div> ))} </div> </div> <div> {/* 下方右侧代码块 */} {playgroundContents.map((item, index) => ( <pre key={index}> <code>{item.codeblock}</code> {/* 保持代码格式输出 */} </pre> ))} </div> </div> <div> {/* 右侧信息 */} <h2>{playground.heading}</h2> <p>{playground.description}</p> <Link href={playground.buttonLink} {...(playground.openInNewTab && { target: '_blank' })}>{playground.buttonText}</Link> </div> </div> ) }
因为 playground 的三个选项字体不同,所以我们将其整合到了精灵图
icons.svg里头,并通过customizedName选项决定是否使用文字。
最后,填写共享组件 About.js 的内容:
代码已复制!
// Import Next Components import Link from 'next/link' import Image from 'next/image' export default function About({ globalData, data }) { return ( <div> <div> {/* 左侧信息 */} <h2>{data.heading}</h2> <p>{data.description}</p> <Link href={data.buttonLink} {...(data.openInNewTab && { target: '_blank' })}>{data.buttonText}</Link> </div> <div> {/* 图片容器 */} <div> {/* 内部滚动容器 */} { data && data.image.map((item, index) => ( <div key={index}> {/* 图片背景框 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${item.url}`} alt={`${item.alternativeText}${globalData.globalAddOn}`} width={item.width} height={item.height} /> </div> ))} </div> </div> </div> ) }
这样一来,主页的所有组件接口就都做好了。
常见问题解决:
- 如果插件报错,请将插件注释掉,查看数据是否正确获取;
- 如果数据为空,验证 API populate 参数是否正确;
- 如果遇到 CORS 错误,确保已正确配置
next.config.js; - 如果图片无法显示,检查 Strapi 的 uploads 文件夹访问权限。
3.5.4 Next.JS:分页开发与 Markdown 渲染
接下来让我们完成其他分页的开发,本小节需完成的目标页面如下:
代码已复制!
nextjs ├── ... ├── src │ ├── components │ └── pages │ ├── blog │ │ └── ... │ ├── category │ │ └── ... │ ├── _app.js │ ├── _document.js │ ├── 404.js │ ├── 500.js │ ├── about.js │ ├── blog.js │ ├── course.js │ ├── error.js │ └── index.js └── ...
先从 About 页面开始:
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' export default function About({ globalData, data }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Information globalData={globalData} data={data.banner}/> <div>{data.body}</div> {/* 这会输出未格式化的 Markdown 原文 */} </> ) } export async function getStaticProps() { const response = await fetcher(`about?populate[0]=banner.image&populate[1]=seo`) return { props: { data: response.data } } }
首先搭建好共享组件 Information.js :
代码已复制!
// Import Next Component import Link from 'next/link' import Image from 'next/image' export default function Information({ globalData, data }) { return ( <div> {data.image ? <Image src={`${process.env.NEXT_PUBLIC_API_URL}${data.image.url}`} alt={`${data.image.alternativeText}${globalData.globalAddOn}`} width={data.image.width} height={data.image.height} /> : ''} {/* 背景图片 */} <div> {/* 左侧信息 */} <h1>{data.heading}</h1> <p>{data.description}</p> <Link href={data.buttonLink} {...(data.openInNewTab && { target: '_blank' })}>{data.buttonText}</Link> {/* 条件判断语句,如果 data.openInNewTab 为真才显示 target 参数与对应属性 */} </div> </div> ) }
接下来检查 About 中的 <div>{data.body}</div> ,我们发现 Strapi 并不会对 Markdown 执行编译或渲染,所以 {data.body} 直接输出了原始数据。为了解决这个问题,我们需要依赖一些实用的插件,比如 React Markdown,现在就让我们来安装配置一下:
代码已复制!
# 停止 Next.JS 项目后执行 yarn add react-markdown # 或 npm install react-markdown
安装完毕后启动项目,封装一个实用工具 markdown.js,将其存放在 @/lib/ 目录下:
代码已复制!
nextjs ├── ... ├── src │ ├── components │ ├── lib │ │ ├── api.js │ │ ├── formatDate.js │ │ └── markdown.js # 新建文件 │ └── pages └── ...
为其添加内容:
代码已复制!
// Import dependencies import React from 'react' import ReactMarkdown from 'react-markdown' export default function Markdown({ data }) { if (!data) return null return ( <ReactMarkdown >{data}</ReactMarkdown> ) }
保存上述内容之后,于 About 页面中引用:
代码已复制!
// Import Libraries import { fetcher } from '@/lib/api' import Markdown from '@/lib/markdown' // Import components ... export default function About({ globalData, data }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Information globalData={globalData} data={data.banner}/> <Markdown data={data.body} /> </> ) } export async function getStaticProps() { ... }
可以看到 Markdown 已经被正确渲染出来了,接下来是 Blog 页面和组件代码:
代码已复制!
// Import library import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import FeaturedArticles from '@/components/blog/FeaturedArticles' import ArticleCardContainer from '@/components/shared/ArticleCardContainer' export default function Blog ({ globalData, data, latestArticles }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Information globalData={globalData} data={data.banner}/> <FeaturedArticles globalData={globalData} articlesOfTheDay={data.articlesOfTheDay} articlesOfTheDay2={data.articlesOfTheDay2} latestArticles={latestArticles} /> <ArticleCardContainer globalData={globalData} data={data.featuredCategories}/> </> ) } export async function getStaticProps() { const response = await fetcher(`blog?populate[0]=articlesOfTheDay.author&populate[1]=articlesOfTheDay.category&populate[2]=articlesOfTheDay.cover&populate[3]=articlesOfTheDay2.author&populate[4]=articlesOfTheDay2.category&populate[5]=articlesOfTheDay2.cover&populate[6]=banner.image&populate[7]=featuredCategories.articles.cover&populate[8]=featuredCategories.articles.author&populate[9]=seo`) // 接收最后发布的三篇文章 const latestArticles = await fetcher(`articles?sort=publishedAt:desc&populate=*&pagination[limit]=3`) return { props: { data: response.data, latestArticles: latestArticles.data } } }
Blog 页面
@/components/blog/FeaturedArticles.js
代码已复制!
// Import components import ArticleCard from '@/components/shared/ArticleCard' export default function FeaturedArticles({ globalData, articlesOfTheDay, articlesOfTheDay2, latestArticles }) { return ( <div> <div> {/* 左侧每日推荐 */} <p>Articles of the day</p> <div> <ArticleCard globalData={globalData} data={articlesOfTheDay} tag={{ name: articlesOfTheDay.category.name, slug: articlesOfTheDay.category.slug }}/> {/* 直接为 tag 传入 category 中的数据,下同 */} <ArticleCard globalData={globalData} data={articlesOfTheDay2} tag={{ name: articlesOfTheDay2.category.name, slug: articlesOfTheDay2.category.slug }}/> </div> </div> <div> {/* 右侧最新发布 */} <p>Latest</p> <div> <div> {/* 溢出滚动容器 */} {latestArticles.map((article, index) => { const tag = { name: article.category.name, slug: article.category.slug } return ( <ArticleCard key={index} globalData={globalData} data={article} tag={tag} /> ) })} </div> </div> </div> </div> ) }
@/components/shared/ArticleCardContainer.js
代码已复制!
// Import Next components import Link from 'next/link' // Import components import ArticleCard from '@/components/shared/ArticleCard' // 引入博客卡片 export default function ArticleCardContainer ({ globalData, data }){ return ( <div> {data && data.map((category, categoryIndex) => { // 数组循环 category const tag = { name: category.name, slug: category.slug } // 定义需要传入的 prop 与数据 return category.articles.length ? ( // 判断 category.article 是否选中了文章,如果一篇都没有,此处数组为空,则不做渲染 <div key={categoryIndex}> {/* 渲染每个 category 盒模型 */} <div> {/* 上半部分名称与链接 */} <h3>{category.name}</h3> <Link href={`/category/${category.slug}`}>View all >></Link> </div> <div> {/* 下半部分卡片容器 */} {/* 数组循环 category.articles */} {category.articles.map(( articleCard, articleCardIndex) => ( <ArticleCard key={articleCardIndex} globalData={globalData} data={articleCard} tag={tag}/> ))} </div> </div> ) : null })} </div> ) }
@/components/shared/ArticleCard.js
代码已复制!
// Import libraries import formatDate from '@/lib/formatDate' // Import Next components import Image from 'next/image' import Link from 'next/link' export default function ArticleCard({ globalData, data, tag }) { const formatedDate = formatDate(data.publishedAt) // 格式化日期 return ( <div> <Link href={`/blog/${data.slug}`}> <Image src={`${process.env.NEXT_PUBLIC_API_URL}${data.cover.url}`} alt={`${data.cover.alternativeText}${globalData.globalAddOn}`} width={data.cover.width} height={data.cover.height} loading="lazy" /> {/* 从 data.cover 对象获取所有数据,包括图片宽高 */} </Link> <div> {/* 下部容器 */} <Link href={`/blog/${data.slug}`}> <h4>{data.title}</h4> </Link> <div> {/* 作者与时间 */} <p>{data.author.name}</p> <p>{formatedDate}</p> </div> <div> {/* 文章 Tag 栏 */} <Link href={`/category/${tag.slug}`}>{tag.name}</Link> {/* 传入并渲染 Tag 数据为 HTML */} </div> </div> </div> ) }
Blog 页面专用组件
然后是 Course 页面与两个专用组件:
@/pages/course.js/
代码已复制!
// Import library import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import Courses from '@/components/course/Courses' import SpecializedCourses from '@/components/course/SpecializedCourses' import About from '@/components/shared/About' export default function Course({ globalData, data }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Information globalData={globalData} data={data.banner}/> <Courses globalData={globalData} data={data.courses}/> <SpecializedCourses globalData={globalData} specializedCourses={data.specializedCourses} specializedCoursesContents={data.specializedCoursesContents}/> <About globalData={globalData} data={globalData.aboutUs}/> </> ) } export async function getStaticProps() { const response = await fetcher(`course?populate[0]=banner&populate[1]=banner.image&populate[2]=courses.image&populate[3]=specializedCourses&populate[4]=specializedCoursesContents.image&populate[5]=seo`) return { props: { data: response.data } } }
@/components/courses/Courses.js/
代码已复制!
// Import Next components import Image from 'next/image' import Link from 'next/link' export default function Courses({ globalData, data }) { return ( <div> {/* 外部容器 */} <div> {/* 溢出滚动容器 */} {data && data.map((item, index) => ( <div key={index}> {/* 课程卡片 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${item.image.url}`} alt={`${item.image.alternativeText}${globalData.globalAddOn}`} width={item.image.width} height={item.image.height}/> <Link href={item.link} {...(item.openInNewTab && { target: '_blank' })}> {/* 内容容器 */} <div> {/* 图标容器 */} <span className={`icon icon-course-${item.icon}`}></span> </div> <h3>{item.heading}</h3> </Link> </div> ))} </div> </div> ) }
@/components/courses/SpecializedCourses.js/
代码已复制!
// Import Next components import Image from 'next/image' export default function SpecializedCourses({ globalData, specializedCourses, specializedCoursesContents }) { return ( <div> <div> {/* 左侧容器 */} <div> {/* 信息容器 */} <h2>{specializedCourses.heading}</h2> <p>{specializedCourses.description}</p> </div> <div> {/* 溢出滚动容器 */} <ul> {/* 课程列表 */} {specializedCoursesContents.map((listItem, listIndex) => ( <li key={listIndex}> <input type="radio" name="specializedCourses"/> <span className={`icon icon-specialized-course-${listItem.icon}`}></span> <h3>{listItem.heading}</h3> </li> ))} </ul> </div> </div> <div> {/* 右侧图片容器 */} {specializedCoursesContents.map((contentItem, contentIndex) => ( <div key={contentIndex}> <span></span> {/* 阴影层 */} <span></span> {/* 装饰背景 */} <div> {/* 图片定位容器 */} <input type="text" name="specializedCoursesImage" /> <Image src={`${process.env.NEXT_PUBLIC_API_URL}${contentItem.image.url}`} alt={`${contentItem.image.alternativeText}${globalData.globalAddOn}`} width={contentItem.image.width} height={contentItem.image.height} loading="lazy"/> <p>{contentItem.description}</p> </div> </div> ))} </div> </div> ) }
最后是 Error、404、与 500 页面:
@/pages/_error.js
代码已复制!
// Import Next.JS components import Head from 'next/head' import Image from 'next/image' import Link from 'next/link' function Error({ globalData, statusCode }) { // 这里拿到状态编码 return ( <> <Head> <title>{statusCode ? `${statusCode} - Server Error` : 'Client Error'}{globalData.globalAddOn}</title> {/* 有状态编码就输出 `编码 - Server Error`,无则输出 'Client Error',下同 */} <meta name="description" content={statusCode ? `A ${statusCode} error occurred on server` : 'An error occurred on client'} /> </Head> <div> <Image src="/temp/test-image.jpg" width={100} height={100} alt={`Test image of guide project${globalData.globalAddOn}`}/> <h1> {statusCode ? `${statusCode} - Server Error` : 'Client Error'} </h1> <Link href="/">Back to home</Link> </div> </> ) } // 通过 getInitialProps 获取错误参数 Error.getInitialProps = ({ res, err }) => { // 定义一个 statusCode 变量并 return 出去,如果有响应对象就拿到状态编码,如无则获取错误对象的编码,两者皆无则默认为 404 const statusCode = res ? res.statusCode : err ? err.statusCode : 404 return { statusCode } } export default Error
@/pages/404.js
代码已复制!
// Import Next.JS components import Head from 'next/head' import Image from 'next/image' import Link from 'next/link' export default function Custom404({ globalData }){ // 需要将 metaTitle 包装为一整个数据传入 <title> const metaTitle = "404 - Page not found" + globalData.globalAddOn; return ( <> <Head> <title>{metaTitle}</title> <meta name="description" content="Page not found" /> </Head> <div> <Image src="/temp/test-image.jpg" width={100} height={100} alt={`Test image of guide project${globalData.globalAddOn}`}/> <h1>404 - Page not found</h1> <Link href="/">Back to home</Link> </div> </> ) }
@/pages/500.js
代码已复制!
// Import Next.JS components import Head from 'next/head' import Image from 'next/image' import Link from 'next/link' export default function Custom500({ globalData }){ const metaTitle = "500 - Internal server error" + globalData.globalAddOn; return ( <> <Head> <title>{metaTitle}</title> <meta name="description" content="Internal server error" /> </Head> <div> <Image src="/temp/test-image.jpg" width={100} height={100} alt={`Test image of guide project${globalData.globalAddOn}`}/> <h1>500 - Internal server error</h1> <Link href="/">Back to home</Link> </div> </> ) }
这样一来,本项目所有的单页数据就都已获取并渲染出来了。
3.5.5 Next.JS:合集页开发
终于来到本章节的最后一个小节了,本课程要完成的两个合集页如下所示:
代码已复制!
nextjs ├── ... ├── src │ ├── components │ ├── lib │ └── pages │ ├── blog │ │ └── [slug].js │ ├── category │ │ └── [slug].js │ └── ... └── ...
先从博客文章页面开始。由于 Strapi 默认生成的示例 Article 页面并非以各类组件封装,而是直接通过接口传过来,那我们也可以将其结构通过 HTML 标签在页面中直接展开:
代码已复制!
// Import libraries import { fetcher } from '@/lib/api' // Import Next components import Image from 'next/image' import Link from 'next/link' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import ArticleDynamicZone from '@/components/article/ArticleDynamicZone' export default function BlogArticle({ globalData, data }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo} /> <div> <Image src={`${process.env.NEXT_PUBLIC_API_URL}${data.cover.url}`} alt={`${data.cover.alternativeText}${globalData.globalAddOn}`} width={data.cover.width} height={data.cover.height} loading="lazy"/> <div> <h1>{data.title}</h1> <Link href={`/category/${data.category.slug}`}>{data.category.name}</Link> <p> {data.author.name}, <span>{data.publishedAt}</span> </p> </div> </div> <ArticleDynamicZone globalData={globalData} data={data.blocks} /> </> ) } export async function getStaticPaths() { const response = await fetcher(`articles`) const paths = response.data.map((article) => ({ params: { slug: [article.slug] }, })) return { paths, fallback: 'blocking' } } export async function getStaticProps({ params }) { const slug = params.slug[0] const response = await fetcher(`articles?filters[slug][$eq]=${slug}&populate[0]=author&populate[1]=category&populate[2]=cover&populate[3]=seo&populate[4]=blocks&populate[5]=blocks.file&populate[6]=blocks.files`) return { props: { data: response.data[0] } } }
博客文章组件 ArticleDynamicZone.js 与 Category 页面暂时跳过,放在后续章节讲解,这样一来其他页面就都做好了。
3.5.6 Next.JS:全局组件开发
此时还剩下 Nav.js、Footer.js、Subscription.js 三个组件没有完成,其中 Nav 涉及到一个折叠面板所以在后续章节精讲,先看下其他两项的逻辑。
对于 Footer,我们需要从 global 这一 API 获取数据并渲染出描述、版权信息、社媒与联络,并且匹配头部导航渲染一个尾部导航;而 Subscription 则需要在客户端提交时通过 fetch API 以 POST 方法向 Strapi 服务器发送一组数据,现在就让我们逐个实现一下。不过在开始之前,这三个组件好像都在 Layout.js 里头,而非在每个页面进行传参,所以我们需要先对其改造一下:
代码已复制!
... export default function Layout({ globalData, children }) { return ( <div className={sen.className}> <Background globalData={globalData}/> {/* 在这里传入数据,下同 */} <Nav globalData={globalData}/> <main>{ children }</main> <Subscription globalData={globalData}/> <Footer globalData={globalData} /> </div> ) }
此时我们在 Footer 中填写接口并用 console.log 打印数据,会发现响应结果是 undefined:
代码已复制!
export default function Footer({ globalData }) { console.log(globalData) return <p>This is footer.</p> }
Q:Layout 明明已经向子组件传参了,为什么 Footer 会拿不到数据呢?
A:问题显然不在这两者身上,那此外唯一有关联的就是 _app 了。
仔细一看,原来我们只给其中 <Layout> 组件包裹的页面组件 <Component> 传递了 globalData 参数,而 Layout 本身并未传递数据,所以我们只需要为其添加接口就行:
代码已复制!
... export default function App({ Component, pageProps, globalData }) { // 接收 globalData 参数 return ( <Layout globalData={globalData}> {/* 传递数据给全局布局 */} <Component {...pageProps} globalData={globalData} /> {/* 传递数据给子组件 */} </Layout> ) } ...
保存页面后发现数据顺利传入,我们终于可以安心从 Footer 讲起了:
代码已复制!
// Import Next components import Image from 'next/image' import Link from 'next/link' export default function Footer({ globalData }) { return ( <footer> <div> {/* 上半部分 */} <div> {/* 上半部 Logo */} <Image src="/logo/logo-footer.png" alt={`Logo${globalData.globalAddOn}`} width={60} height={40}/> <p>{globalData.footer.description}</p> </div> <div> {/* 上半部联络方式与导航 */} <ul> <li>Contact</li> {globalData.contact.map((contactItem, contactIndex) => ( <li key={contactIndex}> <span className={`icon icon-footer-contact-${contactItem.type}`}></span> <a href={contactItem.link}>{contactItem.text}</a> </li> ))} </ul> <ul> <li>Navigation</li> <li><Link href="/">Home</Link></li> <li><Link href="/course/">Course</Link></li> <li><Link href="/blog/">Blog</Link></li> <li><Link href="/about/">About</Link></li> </ul> </div> </div> <div> {/* 下半部分 */} <p>{globalData.footer.copyright}</p> <div> {globalData.social.map((socialItem, socialIndex) => ( <a key={socialIndex} href={socialItem.link} target="_blank" rel="noopener noreferrer"><span className={`icon icon-footer-social-${socialItem.type}`}></span></a> ))} </div> </div> </footer> ) }
接下来完成 Subscription 。首先我们可以把 POST 请求封装起来,让我们在 @/lib/api.js 中封装一个 post() 函数:
代码已复制!
async function fetcher(url) { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/${url}`); const data = await response.json(); return data; } async function post(url, fields) { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${url}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ data: { ...fields } }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error?.message || 'Something went wrong'); } return { success: true, data }; } export { fetcher, post }
然后新建一个实用工具,我们需要创建一个 @/utils 目录并存放一个 handleFormSubmit.js 表单提交工具函数,目录结构如下:
代码已复制!
nextjs ├── ... ├── src │ ├── ... │ └── utils │ └── handleFormSubmit.js └── ...
每次增加新的子目录时,请别忘了先在 jsconfig.json 中添加路径映射:
代码已复制!
{ "compilerOptions": { "baseUrl": "src/", "paths": { "@/pages/*": ["pages/*"], "@/styles/*": ["styles/*"], "@/components/*": ["components/*"], "@/lib/*": ["lib/*"], "@/utils/*": ["utils/*"] } } }
然后再编辑 handleFormSubmit.js :
代码已复制!
// Import libraries import { post } from '@/lib/api' async function handleFormSubmit(event, url) { // 阻止默认提交行为 event.preventDefault(); // 阻止重复提交 let couldRun = true; if (couldRun) { couldRun = false; try { // 获取表单数据 const formData = new FormData(event.target); // 提取所有表单数据 const fields = {}; for (let [key, value] of formData.entries()) { if (value.trim()) { // 只包含非空字段 fields[key] = value.trim(); } } // 检查是否有数据,如无则 alert if (Object.keys(fields).length === 0) { alert('Please fill in at least one field'); return { success: false }; } // 调用 POST 请求 const result = await post(url, fields); // 成功提示 alert("Submit confirmed! We will be in touch shortly."); // 清空表单 event.target.reset(); couldRun = true; return result; } catch (error) { console.error('API Error:', error); alert(error.message || 'Something went wrong'); couldRun = true; return { success: false, error }; } } } export { handleFormSubmit };
最后再编写 Subscription.js 的 HTML 结构并传入数据:
代码已复制!
// Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Subscription({ globalData }) { return ( <div> {/* 外部容器与背景 */} <div> {/* 内部容器,句中定位与布局 */} <div> {/* 标题与段落 */} <h4>{globalData.subscription.heading}</h4> <p>{globalData.subscription.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/subscriptions')}> {/* 表单 URL 会被拼接为 API_URL + /api/subscriptions */} <input name="subscription" placeholder={globalData.subscription.inputPlaceholder} required/> <button type="submit"></button> </form> </div> </div> ) }
试着提交一下,页面弹窗提示 "Submit confirmed!",再去 Strapi 看一下,数据已经成功接收并记录了:


3.6 Next.JS:章节知识点汇总
经过本章节的学习,我们已经初步完成了一个 Next.JS 的前端网站框架。从项目初始化到页面开发,从数据获取到组件封装,我们系统地掌握了 Next.JS 的核心特性和最佳实践。在进入下一章的样式开发之前,让我们回顾一下本章涉及的关键知识点:
- Next.JS 简介
- Next.JS:一个基于 React 的全栈框架,由 Vercel 公司开发并维护
- 核心特性:支持服务端渲染(SSR)、静态站点生成(SSG)、增量静态再生(ISR)等多种渲染模式
- 适用场景:企业官网、电商平台、博客系统、SaaS 应用等需要 SEO 优化和高性能的项目
- 官网:nextjs.org
- 运行环境:Node.JS
- 技术栈:React 18+、Webpack/Turbopack、Babel
- Next.JS 特性
- 文件系统路由:基于
pages目录自动生成路由,无需手动配置 - 混合渲染:同一应用中可混用 SSR、SSG、ISR 和 CSR
- 自动代码分割:按页面自动拆分 JavaScript,优化首屏加载
- 内置优化:Image 组件(图片优化)、Font 优化(字体加载)、Script 组件(脚本优化)
- API Routes:支持在
pages/api目录创建后端 API 端点 - 快速刷新:开发时保留组件状态的热更新
- 零配置:默认配置即可满足大多数场景,按需扩展
- 文件系统路由:基于
- Next.JS 资源引入
- 静态资源:放置在
public目录,通过/根路径访问 - 图片引入:使用
next/image的 Image 组件,自动优化、懒加载、响应式 - 字体引入:使用
next/font优化 Google Fonts 或本地字体加载 - 样式引入:支持 CSS Modules、全局 CSS、Sass、CSS-in-JS(styled-components、emotion)
- 外部资源:在
next.config.js中配置images.domains允许外部图片域名 - 环境变量:使用
.env.local文件,NEXT_PUBLIC_前缀的变量可在浏览器端访问 - 路径别名:在
jsconfig.json中配置@/等别名,简化导入路径
- 静态资源:放置在
- Next.JS 开发与数据传递
- 页面开发流程
- 在
pages目录创建页面文件(如index.js、about.js) - 定义页面组件,接收
props参数 - 使用
getStaticProps或getServerSideProps获取数据 - 将数据通过
props传递给页面组件 - 在组件中渲染数据和子组件
- 在
- 动态路由开发流程
- 创建动态路由文件(如
[slug].js、[id].js) - 实现
getStaticPaths返回所有可能的路径参数 - 实现
getStaticProps根据参数获取对应数据 - 捕获全部路由时使用
params.slug[0]获取数组中的首个数据
- 创建动态路由文件(如
- 全局数据传递
- 在
_app.js的getInitialProps中获取全局数据(如网站配置、导航菜单) - 将
globalData通过 props 传递给 Layout 和 Component - 在各页面和组件中通过
props.globalData访问 - 父组件通过
props向子组件传递数据
- 在
- API 数据获取
- 封装 API 请求函数(如
@/lib/api.js中的fetcher和post) - 在
getStaticProps或getServerSideProps中调用 - 使用
NEXT_PUBLIC_API_URL拼接 REST API 进行数据查询 - 使用 Strapi 查询参数(
populate、filters、sort等)精确获取所需数据
- 封装 API 请求函数(如
- 页面开发流程
至此我们已经掌握了 Next 的核心功能与特性,宝子们先休息一下,然后开始下一章——使用 Tailwind CSS 进行网页布局与样式管理;通过下一章的学习,我们将把这个项目打造成一个视觉精美、交互流畅的现代化网站。

