指南与教程 - 背景图片 | Rainy Design Studio

Next.js + Strapi + Tailwind CSS 建站教程 - 第三部分

RainyMay 26, 2026

本文章的版权归属 Rainy Design Studio - 雨点设计工作室 ,且最终解释权归站长所有。
除本站自媒体官号以外,本文的内容禁止任一组织、个人、账号或平台,以任何形式转载到 RainyDesign.cn 站外。
任何个人或组织进行非法转载的,本站工作人员可对其追究法律责任。

具体信息详见 条例与条款

Next.JS + Strapi + Tailwind CSS 全套网建实战教学 —— Next.JS

使用 Next.JS 构建你的网站项目,掌握目录管理、组件开发、数据传递、页面渲染等知识点。

章节回顾

上一个章节我们配置好了 MySQL,也搭建好了 Strapi CMS、管理了数据结构并填写了数据,我们可以看看现在学到哪了,课程的完整目录如下:

  1. 准备工作

    1. 准备工作:基本需求
    2. 准备工作:项目流程
    3. 准备工作:通读设计稿 + 数据与接口设计
    4. 准备工作:MySQL简介
  2. Strapi

    1. Strapi:创建无头数据管理系统
    2. Strapi:管理数据库表结构
    3. Strapi:管理多媒体资源
    4. Strapi:填写内容、配置 API 权限并测试
    5. Strapi:REST API 讲解,实现增删改查及筛排分页
    6. Strapi:章节知识点汇总
  3. Next.JS

    📌 当前位置
    1. Next.JS:创建项目并管理目录
    2. Next.JS:管理路由,搭建基础布局与页面
    3. Next.JS:API 函数与页面渲染(SSR、SSG、CSR)
    4. Next.JS:内建组件与资源引用
    5. Next.JS + Strapi:开发与数据传递
    6. Next.JS:章节知识点汇总
  4. Tailwind CSS

    1. Tailwind CSS:简介、安装、新特性与旧版本升级
    2. Tailwind CSS:示例、核心概念与演练
    3. Tailwind CSS:全局组件样式开发
    4. Tailwind CSS:主要页面布局实战
    5. Tailwind CSS:章节知识点汇总
  5. 细节完善

    1. React.JS (JSX) :使用 ReactMarkdown 插件美化正文
    2. Strapi + Next.JS + JSX:博客文章搜索
    3. Strapi + Next.JS + JSX:文章列表分页
    4. Next.JS:为 SSG 页面设置 ISR 以实现性能优化与预生成
    5. Strapi Lifecycles hooks:客户联络自动转发邮件
    6. Next.JS:自定义错误页面如 404、500
    7. Next.JS:创建 sitemap.xml 文件并自更新
  6. 网站部署 (撰写中)

    1. 网站部署:Cloudflare、Vercel或租赁云服务器(仍在撰写)

    2. 网站部署:注册域名 + SSL证书申请(仍在撰写)

    3. SEO:搜索引擎提交收录(仍在撰写)

  7. 资源与教程项目包下载

    点击此处下载,最后更新于:2026 年 5 月 26 日。为获取良好的学习体验,请务必以最新版本为主!
  8. FAQ(常见疑问解答)

三、Next.JS

又见面了宝子们 :D 今天我们来讲讲搭建 Next.JS 网站项目所需的关键知识点。

3.1 Next.JS:创建项目并管理目录

现在让我们用终端命令行创建一个 Next.JS 项目,看看里头都有啥;为了便于从头理解 Next.JS 的整体结构,我们采用手动安装方式,逐步添加脚本、目录与设置。如需直接自动完成测试内容,请参考 Next.JS 官方文档,也可搭配我们先前写过的博客 八小时快速建立网站:无门槛独立建站指南 - 资源管理 学习。

请注意:截止至截稿日期,该文档教程适配的 Next.JS 版本号为 v16.0.5,内容将会随着官方更新产生差异,具体以官方最新版本为准,或请自行安装该版本号以适配章节内容。

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 的第二个对象。完毕之后文件内容显示如下:

package.json
{ "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:创建主页并测试

以下内容以 JavaScript 为例,如需采用 TypeScript ,请访问 Next.JS 官方文档 - 手动安装 了解。

请在终端中继续输入以下指令:

mkdir pages # 创建 pages 目录 cd pages # 进入目录 touch index.js # 创建 index.js 文件(或在 VSCode 中手动创建)

打开新建的 pages/index.js ,为其添加以下内容并保存:

@/pages/index.js
export default function Home() { return <h1>Hello, Next.JS!</h1> }

接下来我们还需为页面创建可自定义的 App 文件,同样可以手动创建,或是在终端中输入 touch _app.js ,然后为其添加以下内容并保存:

@/pages/_app.js
export default function App({ Component, pageProps }) { return <Component {...pageProps} /> }

最后我们还需要在页面目录创建一个 _document.js,以下是需要填写的内容:

@/pages/_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 项目了:

http://127.0.0.1:3000

成功的话我们会看到 Hello Next.JS 页面:

Next.JS - Hello Next.JS | Rainy Design Studio 雨点设计工作室

然后回到 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 属性导致的,所以我们需要创建文件并设置一下参数:

next.config.js
module.exports = { allowedDevOrigins: ['127.0.0.1', 'localhost'], }
小提示:如果你在局域网中协同开发,可以将同事设备的内网来源也加入白名单,例如常见的 192.168.*.* 网段。请填写你同事设备的实际内网 IP(如 192.168.1.23)。

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 看看:

Next.JS - 公共文件夹 | Rainy Design Studio 雨点设计工作室

了解完 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

然后编辑内容:

jsconfig.json
{ "compilerOptions": { "baseUrl": "src/", "paths": { "@/pages/*": ["pages/*"], "@/styles/*": ["styles/*"], "@/components/*": ["components/*"] } } }

保存文件后,再次启动项目就能看到 Hello Next.JS 界面顺利显示了;上述配置文件中的 baseUrl 用于设置基本绝对路径,下面的 paths 参数则用作相对路径补充,这样一来我们就可以通过该配置文件执行全局路径映射,而不用担心变更文件层级后需要手动修改所有引入的模块路径了,所以下文中的 src/pages/src/components/ 我们也会使用对应的 @/pages/@/components/ 路径。

小提示:项目目录下的 pagesapp 目录优先级对比 src/pagessrc/app 较高,当存在前者时,后者将会被忽略,因此请避免同时存在多个路由目录。

3.2 Next.JS:管理路由,搭建基础布局与页面

3.2.1 Next.JS:页面与路由

我们先来理解一下 Next.JS 的路由逻辑:

  1. 索引路由(Index Route)
  • @/pages/index.js → 路由为 /
  • @/pages/blog/index.js → 路由为 /blog
  1. 页面路由(Page Route)
  • @/pages/blog.js → 路由也为 /blog
  • pages/blog/index.js 功能相同,但不能同时存在
  1. 嵌套路由(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>,然后访问每个页面看看效果。

@/pages/course.js
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} 获取到这个值:

@/pages/blog/[slug].js
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

@/pages/blog/[...slug].js
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.js404.js500.js 这五个 @/pages/ 目录下的文件。现在就让我们来看看如何自定义这些文件。

首先是 _app.js,它被 Next.JS 官方定义为用于初始化所有页面的组件,此外官方规定的用途还有:

  • 应用共享布局,以在所有页面中显示
  • 注入附加数据到所有页面当中
  • 添加全局 CSS

举例来说,我们可以在 _app.js 中添加一些元素,以应用到所有页面:

@/pages/_app.js
export default function App({ Component, pageProps }) { return ( <> <p>Lorem ipsum</p> <Component {...pageProps} /> </> ) }
在 Next.JS 中如需 return 一个包含了多个一级元素的 html 片段,则必须在外部包裹 <> </>

保存文件后,请浏览 homeblog 等页面查看改动。

不过有时可能会发生改动不生效的情况,此时必须清除缓存并重启;请于 Next.JS 项目终端按下 Ctrl + C 终止项目运行,再执行以下语句清除 .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"

@/pages/_document.js
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> 中添加标签,就像这样:

@/pages/_document.js
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.js500.js 这两种类型,需要添加更多类型则需在 _error.js 中依照错误数据进行条件渲染。我们先来看看 Next.JS 默认的错误页面,我们先像这样输入一个不存在的路由:

http://127.0.0.1:3000/123

可以看到页面报 404 了,左上角还有我们为 _app.js 添加的全局内容:

Next.JS - 错误页面 | Rainy Design Studio 雨点设计工作室

现在让我们创建 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.jsglobal/ 目录,并于 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.jsFooter.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

@/components/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 ,将其引用到整个项目中去:

@/pages/_app.js
import Layout from '@/components/Layout' export default function App({ Component, pageProps }) { return ( <Layout> <Component {...pageProps} /> </Layout> ) }

然后重启开发服务器以应用更改:

# 停止当前服务器(Ctrl + C) # 然后重新启动 yarn dev
小提示:通常情况下,Next.JS 会自动检测文件更改并热重载。但当修改 _app.js 或添加新的布局组件时,建议重启开发服务器以确保更改生效。

如果遇到缓存问题,可以手动清除 .next 目录:

rm -rf .next # macOS/Linux rmdir /s .next # Windows yarn dev # 重新启动

再次访问 项目主页 ,不出意外的话,你会看到导航和页尾的内容都正确显示了:

Next.JS - Layout | Rainy Design Studio 雨点设计工作室

接下来再讲讲分页布局(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 页面中引入内容:

@/pages/blog.js
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)


Next.JS - 单独共享布局 Single Shared Layout | Rainy Design Studio 雨点设计工作室

Blog (Shared Layout with Per-Page Layout)


Next.JS - 分页布局 Per-Page Layout | Rainy Design Studio 雨点设计工作室

以上便是单独共享布局与分页布局的区别与使用方法。

官方文档到本章节会开始讲解命令式路由(Imperative Routing)与浅路由(Shallow Routing),不过为了便于宝子们理解,我们将在后续课程中掌握对应的函数、API 和组件后进行深度讲解,此处仅讲解下链接与导航(Linking & Navigation)。

Next.JS 内建了一个强大的 <Link> 组件来优化整个网站于客户端的效率与体验。对比传统 <a> 标签,其已知的优点包括:

  • <Link> 实现点击时的特定数据渲染,而不是像 <a> 那样跳转并刷新整个页面;
  • <Link> 组件会智能预取链接的页面资源,在生产环境中默认预取可见的链接,以提高导航性能;
  • <Link> 组件渲染为标准的 <a> 标签,配合 Next.JS 的 SSR/SSG 特性,有助于搜索引擎爬取和索引。

至于 <a> 标签,指向站外时这将会是传统且更好的使用选择;我们将在之后的课程中详细讲解这两者的差异与使用场景与案例,现在先让我们为导航栏撰写一些示例内容:

@/components/global/Nav.js
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 标签,刷新页面后再点击每个链接看看:

Next.JS - Linking and Navigation | Rainy Design Studio 雨点设计工作室

我们会发现点击链接时页面并没有跳转,而是每点击一次获取一个页面文件——它以极高的效率仅获取需要的文件并实时渲染出来;我们会在下文提供这些内建组件的使用案例并作详解。

3.3 Next.JS:API 函数与页面渲染(SSR、SSG、CSR)

探索 Next.JS 提供的各项 API 与核心渲染策略。

3.3.1 Next.JS:API 函数简介

我们能在 Next.JS 官方文档 - API 参考 页面看到组件、文件系统约定、函数、配置、CLI、Edge 运行环境与快速启动包这七个内容:

Next.JS - 官方文档 - API Reference 页面 | Rainy Design Studio 雨点设计工作室

组件我们集中放在下个小节讲解,其他内容在这个阶段暂时不重要所以跳过,让我们先浏览下 Next.15 的 API 函数:

函数名称用途/功能描述补充说明
getInitialProps在服务器端为 React 组件获取动态数据Legacy API,除了需要为 _app 获取全局数据以外不推荐使用
getServerSideProps服务器端渲染函数,用于在每次请求时获取数据SSR
getStaticPaths静态生成函数,用于定义需要预渲染的动态路由路径SSG
getStaticProps静态生成函数,用于在构建时获取数据SSG
NextRequestNext.JS 请求对象的 API 引用主要用于 App Router 和 Middleware
NextResponseNext.JS 响应对象的 API 引用主要用于 App Router 和 Middleware
useAmp在页面中启用 AMP,并控制 Next.JS 添加 AMP 的方式将于 Next.16 废弃,不建议学习
useReportWebVitals用于报告 Web Vitals 性能指标next/web-vitals
useRouterNext.JS 路由器的 API,用于访问和操作路由next/router
userAgent扩展 Web Request API 的用户代理助手函数next/server

接下来为宝子们着重讲解下前四个渲染函数。

3.3.2 Next.JS:服务端渲染 vs 静态页面生成

首先让我们用一张表格枚举三类渲染的特性与函数:

渲染方式全称内建函数说明
SSRServer-side Rendering(服务器端渲染)getServerSideProps()每一次请求都将使服务端渲染一次页面,也称之为动态渲染(Dynamic Rendering)
SSGStatic Site Generation(静态页面生成)getStaticProps(); getStaticPaths()判断取决于外部数据的是你的页面内容还是页面路径,随后生成静态页面
CSRClient-side Rendering(客户端渲染)useEffect(); useSWR在客户端初次加载页面后,可选获取数据并于本地重渲染部分 DOM

先说说 SSR ,当一个页面执行 SSR 渲染方式时,用户的每一次请求都会让服务器端生成一次 HTML 下发;只有当你的页面需要实时显示变动,且时常修改内容,我们才建议用 SSR 方式进行数据获取并渲染页面。我们使用 getServerSideProps() 异步函数来实现服务器端动态渲染,示例如下:

@/pages/index.js
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 函数,这里并没有拿到任何填充数据)。

小提示:相信某些细心的宝子们会发现某些情况下控制台将输出两次内容;考虑到开发期间的测试需要,Next.JS 在 v13 版本后默认启用了 React 18 的 Strict Mode,它会故意双重执行一些函数来帮助检测副作用,以确保组件的幂等性,包含 SSR 与 CSR 各渲染一次。

接下来说说 SSG,以 About 页面举例:

@/pages/about.js
export default function About() { return <p>This is about page.</p> }

相信一部分宝子们要忍不住发问了:

  • Q:等一下,这不就 return 了一段原生 HTML 吗?
    • A:确实是的,当我们访问 About 页面时,_document 提供 HTML 文档结构,_app 接收页面组件和数据,Layout.js 组件提供页面布局框架,最后 about.js 作为具体页面内容通过 pageProps 接收数据并完成渲染。
  • Q:说是这么说,这不相当于直接写 HTML 吗?数据在哪?
    • A:让我们使用 getStaticProps() 函数做对比,以 Course 页面为例:
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 选项卡:

Next.JS - SSR vs SSG | Rainy Design Studio 雨点设计工作室

我们会发现 about.jsblog.js 已全部读取完毕,点击跳转不会有任何新的请求发送;SSG 页面在客户端路由跳转时会预加载 JSON,但不会重新获取服务器数据,也就是获取 [PageName].json 到本地执行重渲染。

接下来让我们去 Strapi 修改一下 Home 和 Course 页面的内容,然后分别点击两个页面看看,发现 Home 已经根据最后获取的 JSON 产生了变化,但 Course 没有变化:

Home


Next.JS - SSR vs SSG - Home | Rainy Design Studio 雨点设计工作室

{ "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


Next.JS - SSR vs SSG - Course | Rainy Design Studio 雨点设计工作室

{ "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 页面需要重新构建才能看到更新。以下是三者的详细对比:

对比项SSRSSGCSR
渲染时机每次请求时构建时客户端加载后
数据新鲜度实时构建时的快照取决于请求时机
首屏加载速度中等最快较慢
服务器压力
SEO 友好度优秀优秀较差
适用场景实时数据展示内容相对固定用户交互频繁
示例页面新闻首页、股票行情博客文章、产品页用户仪表盘、聊天应用
成本高(服务器资源)低(静态托管)低(静态托管)

性能优化最佳实践:选择合适的渲染策略

  • ✅ 内容不常变化 → 使用 SSG
  • ✅ 需要实时数据 → 使用 SSR
  • ✅ 用户交互频繁 → 使用 CSR
  • ✅ 需要定时更新 → 使用 ISR
小提示:在开发模式(next dev)下,getStaticProps 会在每次请求时执行,与 getServerSideProps 行为相似。只有在生产构建(next build + next start)中才能看到真正的静态生成效果。

此外在项目构建后如需实时更新,则需要为返回值添加 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 值时,博客文章的标题会被渲染出来。

小提示:你知道吗?Strapi 社区为开发者们提供了很多插件支持,其中 Slugify 能够自动生成所有 Collection Types 的 URL 以简化开发复杂度,而最佳实践为 getStaticPaths() 与 Slugify 的结合使用。我们将会在后续课程开展讲解。

3.3.4 Next.JS:客户端渲染:数据获取与动态交互

通过 React Hooks(如 useEffectuseState)或第三方库(如 useSWR),我们可以在客户端实现数据获取与实时渲染。以下枚举一些使用场景:

  • useEffect

    • 数据获取:组件挂载后获取初始数据,或依赖变化时重新获取数据
    • DOM 操作:获取元素的坐标与大小,操作 DOM 元素
    • 事件管理:为 DOM 添加或移除事件监听器
    • 副作用清理:管理定时器、订阅等需要清理的副作用
    • 生命周期模拟:模拟类组件的生命周期方法
  • useState

    • 状态管理:管理组件内部的局部状态(如表单输入、开关状态等)
    • UI 交互:控制模态框显示/隐藏、选项卡切换、下拉菜单展开等
    • 数据存储:临时存储从 API 获取的数据或用户输入
    • 条件渲染:基于状态值决定渲染不同的 UI 内容
    • 计数器逻辑:实现点击计数、分页控制等数值变化场景

让我们用 About 页面做一组例子:

@/pages/about.js
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(客户端渲染)在无需数据请求的情况下可以完全脱离服务端执行,而接下来是一个结合数据请求的例子:

@/pages/blog.js
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 属性以兼容网页浏览器。

知识点:这是由于 "class" 一词在 JavaScript 中是用于定义类的保留关键字,不能作为对象属性名直接使用,且 DOM API 本身就使用 className,React 采用此命名与原生 API 保持一致。

在这个例子中:

  • 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 文档结构。两者命名相同但用途不同,请勿混淆。
小提示:页头中的 Tailwind CSS CDNScript 将会实现整个页面的 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 配置文件

然后编辑该文件内容:

postcss.config.mjs
export default { plugins: { '@tailwindcss/postcss': {}, }, }

接着在 @/styles/ 目录下创建 globals.css,在其中添加 @import "tailwindcss",如下所示:

@/styles/globals.css
@import "tailwindcss";

最后在项目的 _app 文件中引入:

@/pages/_app.js
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 示例:

@/pages/index.js
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> 应用了文字居中样式。
小知识:与当前 404 页面用作示例引入的 Tailwind CSS CDN 脚本不同,本地安装方式支持自定义配置、按需编译,避免网络依赖,更适合生产环境使用。

上述代码已经包含了基于 Tailwind CSS 的全局引用样式,本课程项目也会在后续教学中实际运用模块化 CSS。

3.4.3 Next.JS:字体引用

Next.JS 内建了 next/font/localnext/font/google 两种引入方式,个人更推荐海外项目直接引入 Google Fonts,不过这里我们还是先从本地引入开始讲解。

现在,于 @/components/Layout.js 中引入如下内容:

@/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 字体家族;如果你正在使用的并非一个可变字体,或者你想为单个字体家族引入一套文件,那可以像这样编辑:

@/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: [ // 作为数组传入这一组字体,定义子重、样式等字体属性 { 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;

Next.JS - 字体引入 - Google Font | Rainy Design Studio 雨点设计工作室

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

Next.JS - 字体引入 - Get embed code | Rainy Design Studio 雨点设计工作室

我们能看到官方显示的字体名称为 Sen,将其记录下来后引入到 Next.JS 项目中来:

@/components/Layout.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 使用的方法将于下文讲述。

性能提示:Google Fonts 会自动优化字体加载,支持字体预加载和子集化,通常比本地字体有更好的缓存策略;但在做 Google Font 测试期间请注意全程科学上网,否则会出现拿不到字体的情况(这将导致项目启动非常慢,并且拿不到的话会报错),或请宝子们使用本地字体,对后续的课程体验没有影响。

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 文件在目录下即可。以下是内容示例:

.env.local
NEXT_PUBLIC_API_URL='http://127.0.0.1:1337' NEXT_PUBLIC_SITE_URL='http://127.0.0.1:3000'

接下来,我们可以在项目的任意源码中自由地引入这些变量。以 Home 举例:

@/pages/index.js
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 目录:

jsconfig.json
{ "compilerOptions": { "baseUrl": "src/", "paths": { "@/pages/*": ["pages/*"], "@/styles/*": ["styles/*"], "@/components/*": ["components/*"], "@/lib/*": ["lib/*"] } } }

接着创建并编辑 api.js,内容如下:

@/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 为例:

@/components/article/ArticleDynamicZone.js
export default function ArticleDynamicZone() { return <p>Article dynamic zone</p> }

请依照这个格式创建好每一个组件,然后为 Layout 组件添加 BackgroundSubscription 子组件:

@/components/Layout.js
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 数据:

@/pages/_app.js
// 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, } }
React 原生 Hook 的方式是 createContextuseContext 的搭配使用,但对于小型网站,getInitialProps 是更好的选择,它提供了更好的 SEO、更快的加载速度和更简单的代码结构;后续课程中我们将按需讲解这两个 Hooks。

最后分别在各个页面引用各组件并传入数据,以 Home 举例:

@/pages/index.js
// 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 获取数据,或是直接按下列代码所示在浏览器查看。

@/pages/index.js
// 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 为例:

@/pages/index.js
// 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 为例:

@/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 元素将组件搭建起来:

@/components/home/Introduction.js
// 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 为例:

@/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。由于这个组件结构相对复杂,我们将分步骤来完成:

第一步: 搭建基础结构和上半部分内容 首先让我们完成组件的基础框架,以及相对简单的上半部分介绍区域:

@/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 栏,用于显示不同的比较类别:

@/components/home/Comparisons.js
// 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 栏的导航项目。

第三步: 卡片内容渲染 最后是最复杂的部分 - 多层嵌套的卡片内容渲染,包含推荐逻辑和条件渲染:

@/components/home/Comparisons.js
// 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 中设置一个图片源,所以我们需要设置一下:

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 的内容:

@/components/shared/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 页面开始:

@/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 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

@/components/shared/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 └── ...

为其添加内容:

@/lib/markdown.js
// 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 页面中引用:

@/pages/about.js
// 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 页面和组件代码:

@/pages/blog.js
// 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 &gt;&gt;</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 标签在页面中直接展开:

@/pages/blog/[...slug].js
// 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},&nbsp; <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.jsFooter.jsSubscription.js 三个组件没有完成,其中 Nav 涉及到一个折叠面板所以在后续章节精讲,先看下其他两项的逻辑。

对于 Footer,我们需要从 global 这一 API 获取数据并渲染出描述、版权信息、社媒与联络,并且匹配头部导航渲染一个尾部导航;而 Subscription 则需要在客户端提交时通过 fetch API 以 POST 方法向 Strapi 服务器发送一组数据,现在就让我们逐个实现一下。不过在开始之前,这三个组件好像都在 Layout.js 里头,而非在每个页面进行传参,所以我们需要先对其改造一下:

@/components/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 本身并未传递数据,所以我们只需要为其添加接口就行:

@/components/Layout.js
... export default function App({ Component, pageProps, globalData }) { // 接收 globalData 参数 return ( <Layout globalData={globalData}> {/* 传递数据给全局布局 */} <Component {...pageProps} globalData={globalData} /> {/* 传递数据给子组件 */} </Layout> ) } ...

保存页面后发现数据顺利传入,我们终于可以安心从 Footer 讲起了:

@/components/global/Footer.js
// 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() 函数:

@/lib/api.js
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 中添加路径映射:

jsconfig.json
{ "compilerOptions": { "baseUrl": "src/", "paths": { "@/pages/*": ["pages/*"], "@/styles/*": ["styles/*"], "@/components/*": ["components/*"], "@/lib/*": ["lib/*"], "@/utils/*": ["utils/*"] } } }

然后再编辑 handleFormSubmit.js

@/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 结构并传入数据:

@/components/global/Subscription.js
// 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 看一下,数据已经成功接收并记录了:

Next.JS - 提交表单成功 | Rainy Design Studio 雨点设计工作室

Next.JS - Strapi 后台记录数据 | Rainy Design Studio 雨点设计工作室







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 开发与数据传递
    • 页面开发流程
      1. pages 目录创建页面文件(如 index.jsabout.js
      2. 定义页面组件,接收 props 参数
      3. 使用 getStaticPropsgetServerSideProps 获取数据
      4. 将数据通过 props 传递给页面组件
      5. 在组件中渲染数据和子组件
    • 动态路由开发流程
      1. 创建动态路由文件(如 [slug].js[id].js
      2. 实现 getStaticPaths 返回所有可能的路径参数
      3. 实现 getStaticProps 根据参数获取对应数据
      4. 捕获全部路由时使用 params.slug[0] 获取数组中的首个数据
    • 全局数据传递
      1. _app.jsgetInitialProps 中获取全局数据(如网站配置、导航菜单)
      2. globalData 通过 props 传递给 Layout 和 Component
      3. 在各页面和组件中通过 props.globalData 访问
      4. 父组件通过 props 向子组件传递数据
    • API 数据获取
      1. 封装 API 请求函数(如 @/lib/api.js 中的 fetcherpost
      2. getStaticPropsgetServerSideProps 中调用
      3. 使用 NEXT_PUBLIC_API_URL 拼接 REST API 进行数据查询
      4. 使用 Strapi 查询参数(populatefilterssort 等)精确获取所需数据



至此我们已经掌握了 Next 的核心功能与特性,宝子们先休息一下,然后开始下一章——使用 Tailwind CSS 进行网页布局与样式管理;通过下一章的学习,我们将把这个项目打造成一个视觉精美、交互流畅的现代化网站。

下一节课程:Tailwind CSS:基本介绍、安装方式、在线演练

订阅

解锁深度且前沿的前端技术、设计理念与用户交互探寻之道。