React 现代全栈项目(五)
原文:
zh.annas-archive.org/md5/698b69ebe010bfc0cb8e00bb1fbda841译者:飞龙
第十五章:在 Socket.IO 中使用 MongoDB 添加持久性
现在我们已经实现了 Socket.IO 后端和前端,让我们花些时间将其与 MongoDB 数据库集成,通过在数据库中临时存储消息并在新用户加入时回放它们,这样用户在加入后可以看到聊天历史。此外,我们将重构我们的聊天应用程序,使其为未来的扩展和维护做好准备。最后,我们将通过实现新的加入和切换房间的命令来测试新的结构。
在本章中,我们将涵盖以下主要主题:
-
使用 MongoDB 存储和回放消息
-
重构应用程序以使其更具可扩展性
-
实现加入和切换房间的命令
技术要求
在我们开始之前,请从第一章**,准备全栈开发和第二章**,了解 Node.js和 MongoDB*中安装所有要求。
那些章节中列出的版本是书中使用的版本。虽然安装较新版本可能不会有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用第一章和第二章中提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch15。
如果您克隆了本书的完整仓库,Husky 在运行 npm install 时可能找不到 .git 目录。在这种情况下,只需在相应章节文件夹的根目录中运行 git init。
本章的 CiA 视频可以在以下网址找到:youtu.be/Mi7Wj_jxjhM。
使用 MongoDB 存储和回放消息
目前,如果新用户加入聊天,他们将看不到任何消息,直到有人主动发送消息。因此,新用户将无法很好地参与正在进行中的讨论。为了解决这个问题,我们可以将消息存储在数据库中,并在用户加入时回放它们。
创建 Mongoose 模式
按照以下步骤创建用于存储聊天消息的 Mongoose 模式:
-
将现有的 ch14 文件夹复制到新的 ch15 文件夹,如下所示:
$ cp -R ch14 ch15 -
在 VS Code 中打开新的 ch15 文件夹。
-
创建一个新的 backend/src/db/models/message.js 文件。
-
在其中,定义一个新的 messageSchema,我们将使用它来在数据库中存储聊天消息:
import mongoose, { Schema } from 'mongoose' const messageSchema = new Schema({ -
消息模式应包含 username(发送消息的人)、message、一个 room(消息发送的房间)和 sent 日期(消息发送的时间):
username: { type: String, required: true }, message: { type: String, required: true }, room: { type: String, required: true }, sent: { type: Date, expires: 5 * 60, default: Date.now, required: true }, })对于
发送日期,我们指定expires以使消息在 5 分钟后自动过期(5 * 60秒)。这确保我们的数据库不会因为大量的聊天消息而变得杂乱。我们还设置了default值为Date.now,以便所有消息默认标记为在当前时间发送。
信息
MongoDB 实际上只在每分钟检查一次数据过期,因此过期的文档可能会在其定义的过期时间后持续一分钟。
-
从模式创建一个模型并导出它:
export const Message = mongoose.model('message', messageSchema)
在创建 Mongoose 模式和模型后,让我们继续创建处理聊天消息的服务函数。
创建服务函数
我们需要创建服务函数来在数据库中保存一条新消息,并获取在给定房间中发送的所有消息,按发送日期排序,首先显示最旧的消息。按照以下步骤实现服务函数:
-
创建一个新的backend/src/services/messages.js文件。
-
在其中,导入Message模型:
import { Message } from '../db/models/message.js' -
然后,定义一个函数在数据库中创建一个新的Message对象:
export async function createMessage({ username, message, room }) { const messageDoc = new Message({ username, message, room }) return await messageDoc.save() } -
此外,定义一个函数以获取某个房间的所有消息,按最旧的消息列表显示:
export async function getMessagesByRoom(room) { return await Message.find({ room }).sort({ sent: 1 }) }
接下来,我们将在我们的聊天服务器中使用这些服务函数。
存储和回放消息
现在我们有了所有函数,我们需要在我们的聊天服务器中实现存储和回放消息。按照以下步骤实现功能:
-
编辑backend/src/socket.js并导入我们之前定义的服务函数:
import { createMessage, getMessagesByRoom } from './services/messages.js' -
当新用户连接时,获取当前房间的所有消息,并使用socket.emit将它们发送(回放)给用户:
export function handleSocket(io) { io.on('connection', async (socket) => { const room = socket.handshake.query?.room ?? 'public' socket.join(room) console.log(socket.id, 'joined room:', room) const messages = await getMessagesByRoom(room) messages.forEach(({ username, message }) => socket.emit('chat.message', { username, message }), ) -
此外,当用户发送消息时,将其存储在数据库中:
socket.on('chat.message', (message) => { console.log(`${socket.id}: ${message}`) io.to(room).emit('chat.message', { username: socket.user.username, message, }) createMessage({ username: socket.user.username, message, room }) }) -
按照以下方式启动前端服务器:
$ npm run dev -
然后,启动后端服务器(不要忘记启动数据库的 Docker 容器!):
$ cd backend/ $ npm run dev -
前往**http://localhost:5173/**,登录并发送一些消息。然后,打开一个新标签页,用不同的用户登录,您将看到之前发送的消息被回放:
图 15.1 – 成功回放存储的消息
注意
图 15.1中的截图是应用程序的较晚版本,其中我们在用户加入房间时显示消息(我们将在本章后面实现这些消息)。在这里,我们使用这些消息来显示当用户在发送消息后加入时,回放是有效的。
如果您等待 5 分钟然后再次加入聊天,您将看到现有的消息已过期并且不再被回放。
现在,让我们让用户界面更清晰地显示哪些消息被回放了。
可视区分回放消息
目前看来,其他用户似乎在我们加入后立即发送了消息。这并不明显表明消息是从服务器回放的。为了解决这个问题,我们可以通过例如使它们稍微灰一些来在视觉上区分回放的消息。现在让我们这样做,如下所示:
-
编辑 backend/src/socket.js 并为回放消息添加一个 replayed 标志:
const messages = await getMessagesByRoom(room) messages.forEach(({ username, message }) => socket.emit('chat.message', { username, message, replayed: true }), ) -
现在,编辑 src/components/ChatMessage.jsx,如果设置了 replayed 标志,则以较低的透明度显示消息:
export function ChatMessage({ username, message, replayed }) { return ( <div style={{ opacity: replayed ? 0.5 : 1.0 }}> -
不要忘记更新 propTypes 并添加 replayed 标志:
ChatMessage.propTypes = { username: PropTypes.string, message: PropTypes.string.isRequired, replayed: PropTypes.bool, } -
再次访问 http://localhost:5173/ 并重复相同的程序(从一个用户发送消息,然后在另一个标签页中用不同的用户登录),你将看到回放的消息现在很容易与新的消息区分开来:
图 15.2 – 回放的消息现在以较浅的颜色显示
现在我们已经成功将消息历史存储在数据库中,让我们稍微关注一下重构聊天应用程序,使其在未来更具可扩展性和可维护性。
将应用程序重构为更易于扩展
对于重构,我们将首先定义所有由我们的服务器提供的聊天功能的服务函数。
定义服务函数
按照以下步骤开始定义聊天功能的服务函数:
-
创建一个新的 backend/src/services/chat.js 文件。
-
在其中,导入与消息相关的服务函数:
import { createMessage, getMessagesByRoom } from './messages.js' -
定义一个新函数,直接向用户发送私密消息:
export function sendPrivateMessage( socket, { username, room, message, replayed }, ) { socket.emit('chat.message', { username, message, room, replayed }) }私信将被用于,例如,将消息回放给特定用户,并且不会存储在数据库中。
-
此外,定义一个函数来发送系统消息:
export function sendSystemMessage(io, { room, message }) { io.to(room).emit('chat.message', { message, room }) }系统消息将被用于,例如,宣布用户加入了房间。我们也不希望将这些存储在数据库中。
-
然后,定义一个函数来发送公共消息:
export function sendPublicMessage(io, { username, room, message }) { io.to(room).emit('chat.message', { username, message, room }) createMessage({ username, message, room }) }公共消息将被用于向房间发送常规聊天消息。这些消息存储在数据库中,以便我们稍后回放。
-
我们还定义了一个新函数来将给定的 socket 加入到 room 中:
export async function joinRoom(io, socket, { room }) { socket.join(room) -
在此函数内部,发送一个系统消息,告诉房间内所有人有人加入了:
sendSystemMessage(io, { room, message: `User "${socket.user.username}" joined room "${room}"`, }) -
然后,将房间中发送的私密消息回放给刚刚加入的用户:
const messages = await getMessagesByRoom(room) messages.forEach(({ username, message }) => sendPrivateMessage(socket, { username, message, room, replayed: true }) ) } -
最后,定义一个服务函数来从 socketId 获取用户信息。我们只需将之前在 backend/src/socket.js 中已有的代码复制粘贴到这里:
export async function getUserInfoBySocketId(io, socketId) { const sockets = await io.in(socketId).fetchSockets() if (sockets.length === 0) return null const socket = sockets[0] const userInfo = { socketId, rooms: Array.from(socket.rooms), user: socket.user, } return userInfo }
现在我们已经为聊天功能创建了服务函数,让我们在 Socket.IO 服务器中使用它们。
将 Socket.IO 服务器重构为使用服务函数
现在我们已经定义了服务函数,让我们重构聊天服务器代码以使用它们。按照以下步骤进行操作:
-
打开 backend/src/socket.js 并找到以下导入:
import { createMessage, getMessagesByRoom } from './services/messages.js'用以下导入替换前面的导入以使用新的聊天服务函数:
import { joinRoom, sendPublicMessage, getUserInfoBySocketId, } from './services/chat.js' -
替换整个handleSocket函数为以下新代码。当建立连接时,我们自动使用joinRoom服务函数加入公共房间:
export function handleSocket(io) { io.on('connection', (socket) => { joinRoom(io, socket, { room: 'public' }) -
然后,定义一个监听chat.message事件的监听器,并使用sendPublicMessage服务函数将事件发送到指定的房间:
socket.on('chat.message', (room, message) => sendPublicMessage(io, { username: socket.user.username, room, message }), )
注意
我们将chat.message事件的签名更改为现在需要传递一个房间,这样我们就可以在以后实现一种更好的处理多个房间的方法。稍后,我们需要确保调整客户端代码以适应这一点。
-
接下来,定义一个监听user.info事件的监听器,在其中我们使用async服务函数getUserInfoBySocketId并在callback中返回其结果,将此事件转换为确认:
socket.on('user.info', async (socketId, callback) => callback(await getUserInfoBySocketId(io, socketId)), ) }) -
最后,我们可以重新使用之前的身份验证中间件:
io.use((socket, next) => { if (!socket.handshake.auth?.token) { return next(new Error('Authentication failed: no token provided')) } jwt.verify( socket.handshake.auth.token, process.env.JWT_SECRET, async (err, decodedToken) => { if (err) { return next(new Error('Authentication failed: invalid token')) } socket.auth = decodedToken socket.user = await getUserInfoById(socket.auth.sub) return next() }, ) }) }
现在我们已经重构了聊天服务器,让我们继续重构客户端代码。
重构客户端代码
现在由于我们的服务器端代码使用服务函数来封装聊天应用的功能,让我们通过将客户端命令提取到单独的函数中来对客户端代码进行类似的重构,如下所示:
-
编辑src/hooks/useChat.js并在useChat钩子中定义一个新的函数来清除消息:
function clearMessages() { setMessages([]) } -
然后,定义一个async函数来获取用户所在的全部房间:
async function getRooms() { const userInfo = await socket.emitWithAck('user.info', socket.id) const rooms = userInfo.rooms.filter((room) => room !== socket.id) return rooms } -
我们现在可以在sendMessage函数中使用这些函数,如下所示:
async function sendMessage(message) { if (message.startsWith('/')) { const command = message.substring(1) switch (command) { case 'clear': clearMessages() break case 'rooms': { const rooms = await getRooms() receiveMessage({ message: `You are in: ${rooms.join(', ')}`, }) break } -
最后,我们调整chat.message事件以发送room和message。目前,我们总是向**'****public'**房间发送消息:
default: receiveMessage({ message: `Unknown command: ${command}`, }) break } } else { socket.emit('chat.message', 'public', message) } }在下一节中,我们将扩展它以能够在不同的房间之间切换。
现在我们已经成功重构了聊天应用以使其更具可扩展性,让我们通过实现新的加入和切换房间的命令来测试新结构的灵活性。
实现加入和切换房间的命令
让我们现在通过在聊天应用中实现加入和切换房间的命令来测试新结构,如下所示:
-
编辑backend/src/socket.js并在chat.message监听器下方定义一个新的监听器,当从客户端接收到chat.join事件时,它将调用joinRoom服务函数:
socket.on('chat.join', (room) => joinRoom(io, socket, { room }))如我们所见,有一个joinRoom服务函数使得在这里重新使用代码加入新房间变得非常简单。它已经发送了一条系统消息告诉每个人有人加入了房间,就像用户在连接时默认加入
public房间时一样。 -
编辑src/components/ChatMessage.jsx并显示room:
export function ChatMessage({ room, username, message, replayed }) { return ( <div style={{ opacity: replayed ? 0.5 : 1.0 }}> {username ? ( <span> <code>[{room}]</code> <b>{username}</b>: {message} </span> -
将room属性添加到propTypes定义中:
ChatMessage.propTypes = { username: PropTypes.string, message: PropTypes.string.isRequired, replayed: PropTypes.bool, room: PropTypes.string, } -
现在,编辑src/hooks/useChat.js并定义一个状态钩子来存储我们当前所在的房间:
export function useChat() { const { socket } = useSocket() const [messages, setMessages] = useState([]) public room. -
定义一个新函数来切换房间:
function switchRoom(room) { setCurrentRoom(room) }目前,我们在这里只调用了
setCurrentRoom,但我们可能希望在以后扩展这个功能,所以提前将其抽象成一个单独的函数是一个好的实践。 -
定义一个新的函数,通过发送chat.join事件和切换当前房间来加入一个房间:
function joinRoom(room) { socket.emit('chat.join', room) switchRoom(room) } -
将sendMessage函数修改为接受命令参数,如下所示:
async function sendMessage(message) { if (message.startsWith('/')) { const [command, ...args] = message.substring(1).split(' ') switch (command) {我们现在可以发送如
/join <room-name>之类的命令,房间名称将存储在args[0]中。 -
定义一个新的命令来加入一个房间,其中我们首先检查是否向命令传递了参数:
case 'join': { if (args.length === 0) { return receiveMessage({ message: 'Please provide a room name: /join <room>', }) } -
然后,我们使用getRooms函数确保我们没有已经加入房间:
const room = args[0] const rooms = await getRooms() if (rooms.includes(room)) { return receiveMessage({ message: `You are already in room "${room}".`, }) } -
最后,我们可以通过使用joinRoom函数加入房间:
joinRoom(room) break } -
类似地,我们可以实现**/switch**命令,如下所示:
case 'switch': { if (args.length === 0) { return receiveMessage({ message: 'Please provide a room name: /switch <room>', }) } const room = args[0] const rooms = await getRooms() if (!rooms.includes(room)) { return receiveMessage({ message: `You are not in room "${room}". Type "/join ${room}" to join it first.`, }) } switchRoom(room) receiveMessage({ message: `Switched to room "${room}".`, }) break }在这种情况下,我们正在检查用户是否已经在房间中。如果没有,我们告诉他们他们必须先加入房间,然后再切换到它。
-
调整chat.message事件,使其发送到currentRoom,如下所示:
} else { socket.emit('chat.message', currentRoom, message) } -
访问http://localhost:5173/**,向公共房间发送一条消息,然后通过执行**/join react命令加入react房间。向该房间发送不同的消息。
-
打开另一个浏览器窗口,用不同的用户登录,你会看到来自公共房间的第一条消息被回放了。然而,我们看不到来自react房间的消息,因为我们还没有加入它!
-
现在,在第二个浏览器窗口中,也调用**/join react**。你会看到现在第二个消息被回放了。
-
尝试使用**/switch public来切换回公共房间并发送另一条消息。你会看到两个客户端都收到了这条消息,因为他们都在公共**房间中。
这些操作的结果可以在以下屏幕截图中看到:
图 15.3 – 在不同房间聊天
概述
在本章中,我们首先通过将消息存储在 MongoDB 中来将我们的聊天应用连接到数据库。我们还学习了如何使文档在一段时间后过期。然后,我们实现了当新用户加入聊天时回放消息的功能。接下来,我们花了一些时间重构聊天应用,使其在未来更具可扩展性和可维护性。最后,我们实现了加入新房间和在不同房间之间切换的方法。
到目前为止,我们只使用库来开发我们的应用。在下一章第十六章**,使用 Next.js 入门中,我们将学习如何使用全栈 React 框架来开发应用。框架,如 Next.js,为我们提供了更多的应用结构,并提供了许多功能,例如服务器端渲染等。
第五部分:迈向企业级全栈应用
在这部分,我们将介绍Next.js作为一个企业级全栈应用框架。我们将学习它是如何工作的以及它相对于单独使用React的优势。然后,我们将使用 Next.js 和新的App Router范式创建一个应用。之后,我们将介绍React Server Components和Server Actions,作为直接与数据库接口的一种方式,无需使用 REST 或 GraphQL API。接着,我们将更深入地探讨 Next.js 框架,了解缓存、API 路由、添加元数据和如何最优地加载图片和字体。接下来,我们将学习如何使用Vercel和自定义部署设置使用Docker来部署 Next.js 应用。最后,我们将概述并简要介绍全栈开发中尚未在本书中涵盖的各种高级主题。这包括维护大型项目、优化包大小、UI 库概述以及高级状态管理解决方案等概念。
本部分包括以下章节:
-
第十六章, Next.js 入门
-
第十七章, 介绍 React Server Components
-
第十八章, 高级 Next.js 概念和优化
-
第十九章, 部署 Next.js 应用
-
第二十章, 深入全栈开发
第十六章:开始使用 Next.js
到目前为止,我们一直在使用各种库和工具来开发全栈 Web 应用程序。现在,我们介绍 Next.js 作为一款企业级全栈 Web 应用程序框架,适用于 React。Next.js 将您需要的所有全栈 Web 开发功能和工具集成在一个包中。在这本书中,我们使用 Next.js,因为它是目前最受欢迎的框架,支持所有新的 React 特性,例如 React Server Components 和 Server Actions,这些是全栈 React 开发的未来。然而,还有其他全栈 React 框架,如 Remix,最近也开始支持新的 React 特性。
在本章中,我们将学习 Next.js 的工作原理及其优势。然后,我们将使用 Next.js 重新创建我们的博客项目,以突出使用简单的打包器(如 Vite)和全框架(如 Next.js)之间的差异。在这个过程中,我们将学习 Next.js App Router 的工作原理。最后,我们将通过创建组件和页面以及定义它们之间的链接来重新创建我们的(静态)博客应用程序。
在本章中,我们将涵盖以下主要主题:
-
什么是 Next.js?
-
设置 Next.js
-
介绍 App Router
-
创建静态组件和页面
技术要求
在我们开始之前,请安装从第一章**,准备全栈开发和第二章**,了解 Node.js和 MongoDB*中提到的所有要求。
那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在使用本书中提供的代码和步骤时遇到问题,请尝试使用第一章和第二章中提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch16。
本章的 CiA 视频可在以下链接找到:youtu.be/jQFCZqCspoc。
什么是 Next.js?
Next.js 是一个 React 框架,它将您创建全栈 Web 应用程序所需的一切整合在一起。其主要特性如下:
-
原生提供良好的开发者体验,包括热模块重载、错误处理等。
-
基于文件的路由和嵌套布局,使用 Next.js 定义 API 端点的路由处理器。
-
在路由中支持国际化(i18n),允许我们创建国际化路由。
-
原生支持增强的服务端和客户端数据获取,带有缓存功能。
-
中间件,在请求完成前运行代码。
-
在无服务器运行时上运行 API 端点的选项。
-
原生支持页面静态生成。
-
当组件需要时动态流式传输组件,使我们能够快速显示初始页面,然后稍后加载其他组件。
-
高级客户端和服务器渲染,使我们不仅能够在服务器端渲染 React 组件(服务器端渲染(SSR)),还可以使用React Server Components,这允许我们在服务器端专门渲染 React 组件,而不需要向客户端发送额外的 JavaScript。
-
服务器操作用于逐步增强从客户端发送到服务器的表单和操作,使我们能够在客户端没有 JavaScript 的情况下提交表单。
-
内置对图像、字体和脚本的优化,以改善 Core Web Vitals。
-
此外,Next.js 提供了一个平台,使我们能够轻松地将我们的应用部署到 – Vercel。
总的来说,Next.js 将本书中学到的所有全栈开发知识整合在一起,对每个概念进行精炼,使其更加高级和可定制,并将所有这些内容封装在一个单独的包中。我们现在将从头开始使用 Next.js 重新创建之前章节中的博客应用。这样做将使我们能够看到使用和未使用全栈框架开发应用之间的差异。
设置 Next.js
现在我们将使用create-next-app工具设置一个新的项目,该工具会自动为我们设置一切。按照以下步骤开始:
-
打开一个新的终端窗口。确保您不在任何项目文件夹中。运行以下命令以创建一个新的文件夹并在其中初始化一个 Next.js 项目:
$ npx create-next-app@14.1.0 -
当被问及是否可以继续时,按y键并按Return/Enter键确认。
-
给项目起一个名字,例如ch16。
-
按照以下方式回答问题:
-
您想使用 TypeScript 吗?:否
-
您想使用 ESLint 吗?:是
-
您想使用 Tailwind CSS 吗?:否
-
您想使用
src/目录吗?:是 -
您想使用 App Router 吗?:是
-
您想自定义默认导入别名吗?:否
-
-
在回答完所有问题后,将在ch16文件夹中创建一个新的 Next.js 应用。输出结果应如下所示:
图 16.1 – 创建新的 Next.js 项目
-
在 VS Code 中打开新创建的ch16文件夹。
-
在新的 VS Code 窗口中,打开一个终端并使用以下命令运行项目:
$ npm run dev -
在浏览器中打开**http://localhost:3000**以查看运行的 Next.js 应用!应用应如下所示:
图 16.2 – 在浏览器中运行的我们新创建的 Next.js 应用
-
不幸的是,create-next-app没有为我们设置 Prettier,所以让我们现在快速设置一下。通过运行以下命令安装 Prettier:
$ npm install --save-dev prettier@2.8.4 \ eslint-config-prettier@8.6.0 -
在项目的根目录中创建一个新的**.prettierrc.json**文件,内容如下:
{ "trailingComma": "all", "tabWidth": 2, "printWidth": 80, "semi": false, "jsxSingleQuote": true, "singleQuote": true } -
编辑现有的 .eslintrc.json 文件,以便从 prettier 扩展,如下所示:
{ "extends": ["next/core-web-vitals", "prettier"] } -
前往 VS Code 工作区设置,将 Editor: Default Formatter 设置更改为 Prettier,并勾选 Editor: Format On Save 复选框。
现在我们已经成功创建了一个新的 Next.js 项目,并集成了 ESLint 和 Prettier!我们仍然可以设置 Husky 和 lint-staged,就像我们之前做的那样,但现在我们将坚持这个简单的设置。接下来,我们将学习更多关于 Next.js 中应用程序结构的内容。
介绍 App Router
Next.js 携带一种特殊的结构化应用程序的范式,称为 App Router。App Router 利用 src/app/ 文件夹中的文件夹结构来为我们的应用程序创建路由。根文件夹(/ 路径)是 src/app/。如果我们想定义一个路径,例如 /posts,我们需要创建一个 src/app/posts/ 文件夹。为了使这个文件夹成为一个有效的路由,我们需要在其中放置一个 page.js 文件,该文件包含在访问该路由时将被渲染的页面组件。
注意
或者,我们可以将一个 route.js 文件放入一个文件夹中,将其转换为 API 路由而不是渲染页面。我们将在 第十八章 高级 Next.js 概念和优化 中了解更多关于 API 路由的内容。
此外,Next.js 允许我们定义一个 layout.js 文件,它将被用作特定路径的布局。布局组件接受子组件,可以包含其他布局或页面。这种灵活性允许我们定义带有子布局的嵌套路由。
在 App Router 范式中还有其他特殊文件,例如 error.js 文件,当页面发生错误时将被渲染,以及 loading.js 文件,在页面加载时(使用 React Suspense)将被渲染。
查看以下带有 App Router 的文件夹结构示例:
图 16.3 – 带有 App Router 的文件夹结构示例
在前面的示例中,我们有一个 dashboard/settings/ 路由,由 dashboard 和 settings 文件夹定义。dashboard 文件夹没有 page.js 文件,所以访问 dashboard/ 将导致 404 Not Found 错误。然而,dashboard 文件夹有一个 layout.js 文件,它定义了仪表板的主要布局。settings 文件夹有一个另一个 layout.js 文件,它定义了仪表板上的设置页面布局。它还有一个 page.js 文件,当访问 dashboard/settings/ 路由时将被渲染。此外,它还有一个 loading.js 文件,在设置页面加载时在设置布局内部渲染。它还包含一个 error.js 文件,如果在加载设置页面时发生错误,它将在设置布局内部渲染。
如我们所见,App Router 使得实现常见用例变得容易,例如嵌套路由、布局、错误和加载组件。现在让我们开始定义博客应用程序的文件夹结构。
定义文件夹结构
让我们回顾并精炼博客应用程序从上一章中的路由结构:
-
/ – 我们博客的首页,包含文章列表
-
/login – 登录现有账户的登录页面
-
/signup – 创建新账户的注册页面
-
/create – 创建新博客文章的页面(此路由为新)
-
/posts/:id – 查看单个博客文章的页面
所有这些页面都共享一个带有顶部导航栏的通用布局,使我们能够在应用程序的各个页面之间导航。
让我们现在创建这个路由结构作为 App Router 中的文件夹结构:
-
删除现有的**src/app/**文件夹。
-
创建一个新的src/app/文件夹。在其内部,创建一个src/app/layout.js文件,内容如下:
export const metadata = { title: 'Full-Stack Next.js Blog', description: 'A blog about React and Next.js', } export default function RootLayout({ children }) { return ( <html lang="en"> <body> <main>{children}</main> </body> </html> ) }metadata对象是 Next.js 中一个特殊的导出对象,用于提供元标签,如<title>和<meta name="description">标签。App Router 中文件的默认导出需要是应该为相应布局/页面渲染的组件。
-
创建一个新的src/app/page.js文件,内容如下:
export default function HomePage() { return <strong>Blog home page</strong> } -
创建一个新的src/app/login/文件夹。在其内部,创建一个src/app/login/page.js文件,内容如下:
export default function LoginPage() { return <strong>Login</strong> } -
创建一个新的src/app/signup/文件夹。在其内部,创建一个src/app/signup/page.js文件,内容如下:
export default function SignupPage() { return <strong>Signup</strong> } -
创建一个新的src/app/create/文件夹。在其内部,创建一个src/app/create/page.js文件,内容如下:
export default function CreatePostPage() { return <strong>CreatePost</strong> } -
创建一个新的src/app/posts/文件夹。在其内部,创建一个新的src/app/posts/[id]/文件夹。这是一个特殊的文件夹,包含一个路由参数id,我们可以在渲染页面时使用它。
-
创建一个新的src/app/posts/[id]/page.js文件,内容如下:
export default function ViewPostPage({ params }) { return <strong>ViewPost {params.id}</strong> }如您所见,我们从 Next.js 提供的
params对象中获取id。 -
如果它已经停止运行,请使用以下命令启动 Next.js 开发服务器:
$ npm run dev
现在我们已经定义了项目的文件夹结构,让我们继续创建静态组件和页面。
创建静态组件和页面
对于我们博客的组件,我们可以重用前几章中编写的大部分代码,因为 Next.js 与纯 React 相比并没有太大的不同。只有特定的组件,如导航栏,会有所不同,因为 Next.js 有自己的路由器。我们将大多数组件创建在单独的src/components/文件夹中。这个文件夹将只包含可以在多个页面之间重用的 React 组件。所有页面和布局组件仍然在src/app/。
注意
在 Next.js 中,也可以将常规组件与页面和布局组件一起放置,对于仅在特定页面上使用的组件,在大规模项目中应该这样做。在小项目中,这并不是很重要,我们只需将所有常规组件放在一个单独的文件夹中,以便更容易地将它们与页面和布局组件区分开来。
定义组件
现在我们开始创建我们博客应用的组件:
-
创建一个新的**src/components/**文件夹。
-
创建一个新的src/components/Login.jsx文件。在其中,定义一个包含用户名字段、密码字段和提交按钮的**
**:export function Login() { return ( <form> <div> <label htmlFor='username'>Username: </label> <input type='text' name='username' id='username' /> </div> <br /> <div> <label htmlFor='password'>Password: </label> <input type='password' name='password' id='password' /> </div> <br /> <input type='submit' value='Log In' /> </form> ) }
注意
我们故意使用非受控输入字段(因此,没有useState钩子),因为在下一章将要学习的使用服务器操作的表单中,没有必要创建受控输入字段,我们将学习的内容是第十七章**,介绍 React Server Components。然而,正确定义输入字段的name属性很重要,因为当表单提交时,将使用该属性来识别字段。
-
以类似的方式,创建一个新的src/components/Signup.jsx文件,并定义具有相同字段的表单:
export function Signup() { return ( <form> <div> <label htmlFor='username'>Username: </label> <input type='text' name='username' id='username' /> </div> <br /> <div> <label htmlFor='password'>Password: </label> <input type='password' name='password' id='password' /> </div> <br /> <input type='submit' value='Sign Up' /> </form> ) } -
创建一个新的src/components/CreatePost.jsx文件,并定义一个包含必需的标题输入字段、用于定义内容的textarea和一个提交按钮的表单:
export function CreatePost() { return ( <form> <div> <label htmlFor='title'>Title: </label> <input type='text' name='title' id='title' required /> </div> <br /> <textarea name='contents' id='contents' /> <br /> <br /> <input type='submit' value='Create' /> </form> ) } -
创建一个新的src/components/Post.jsx文件。作为对前几章结构的改进,Post组件将在PostList中使用,并且只显示博客文章的标题和作者,以及一个链接到完整文章:
import PropTypes from 'prop-types' export function Post({ _id, title, author }) { return ( <article> <h3>{title}</h3> <em> Written by <strong>{author.username}</strong> </em> </article> ) } -
我们还需要定义propTypes。在这种情况下,我们将使用类似于数据库查询结果的架构,因为我们将在下一章介绍 React Server Components 时能够直接使用数据库结果:
Post.propTypes = { _id: PropTypes.string.isRequired, title: PropTypes.string.isRequired, author: PropTypes.shape({ username: PropTypes.string.isRequired, }).isRequired, contents: PropTypes.string, } -
创建一个新的src/components/PostList.jsx文件。在这里,我们将重用Post组件的propTypes,所以让我们也导入Post组件:
import { Fragment } from 'react' import PropTypes from 'prop-types' import { Post } from './Post.jsx' -
然后,我们定义PostList组件,它使用Post组件渲染每个博客文章:
export function PostList({ posts = [] }) { return ( <div> {posts.map((post) => ( <Fragment key={`post-${post._id}`}> <Post _id={post._id} title={post.title} author={post.author} /> <hr /> </Fragment> ))} </div> ) }
注意
使用唯一的 ID 作为key属性是一个最佳实践,例如数据库 ID,这样 React 可以跟踪列表中变化的项目。
-
我们现在通过使用现有的Post.propTypes来定义PostList组件的propTypes:
PostList.propTypes = { posts: PropTypes.arrayOf( PropTypes.shape(Post.propTypes) ).isRequired, } -
最后,我们创建一个新的src/components/FullPost.jsx文件,在其中显示包含所有内容的完整帖子:
import PropTypes from 'prop-types' export function FullPost({ title, contents, author }) { return ( <article> <h3>{title}</h3> <div>{contents}</div> <br /> <em> Written by <strong>{author.username}</strong> </em> </article> ) } -
我们不是从Post组件中重用propTypes,而是在这里重新定义它们,因为FullPost组件需要与Post组件不同的属性(它没有_id属性,而是有contents属性):
FullPost.propTypes = { title: PropTypes.string.isRequired, author: PropTypes.shape({ username: PropTypes.string.isRequired, }).isRequired, contents: PropTypes.string, }
现在我们已经定义了我们博客应用所需的全部组件,让我们继续正确地定义页面组件。
定义页面
在创建我们博客应用所需的各个组件后,现在让我们用适当的页面替换占位符页面组件,这些页面将渲染适当的组件。按照以下步骤开始:
-
编辑src/app/login/page.js并导入Login组件,然后渲染它:
import { Login } from '@/components/Login' export default function LoginPage() { return <Login /> }
注意
记得当我们设置 Next.js 时,是否被问及是否想要自定义默认导入别名吗?这个导入别名允许我们引用项目的src/文件夹,使我们的导入是绝对的而不是相对的。默认情况下,这是使用@别名完成的。因此,我们现在可以从@/components/Login导入,而不是必须从**../../components/Login.jsx**导入。在大型项目中,使用导入别名进行绝对导入变得特别有用,并且可以轻松地在以后重构项目。
-
编辑src/app/signup/page.js,以类似的方式导入并渲染Signup组件:
import { Signup } from '@/components/Signup' export default function SignupPage() { return <Signup /> } -
通过编辑src/app/create/page.js文件重复此过程:
import { CreatePost } from '@/components/CreatePost' export default function CreatePostPage() { return <CreatePost /> } -
现在,编辑src/app/posts/[id]/page.js文件并导入FullPost组件:
import { FullPost } from '@/components/FullPost' -
然后,定义一个示例post对象:
export default function ViewPostPage({ params }) { const post = { title: `Hello Next.js (${params.id})`, contents: 'This will be fetched from the database later', author: { username: 'Daniel Bugl' }, id into the title. -
按照以下方式渲染FullPost组件:
return ( <FullPost title={post.title} contents={post.contents} author={post.author} /> ) } -
最后,通过导入PostList组件、创建一个示例posts数组并渲染PostList组件来编辑src/app/page.js:
import { PostList } from '@/components/PostList' export default function HomePage() { const posts = [ { _id: '123', title: 'Hello Next.js', author: { username: 'Daniel Bugl' } }, ] return <PostList posts={posts} /> } -
前往http://localhost:3000/posts/123**查看使用标题中的**id**参数渲染的**FullPost**组件。您可以随意更改 URL 中的id以查看标题如何变化。以下截图显示了在/****posts/123路径上渲染的FullPost**组件:
图 16.4 – 使用 Next.js 路由参数在标题中渲染 FullPost 组件
在成功定义所有页面后,我们仍然需要一个在它们之间导航的方法,所以让我们继续通过在页面之间添加链接来继续:
在页面之间添加链接
如本章前面所述,Next.js 提供了自己的路由解决方案——App Router。路由由src/app/目录中的文件夹结构定义,并且它们都已经准备好了。现在我们唯一要做的就是添加它们之间的链接。为此,我们需要使用来自next/link的Link组件。按照以下步骤开始实现导航栏:
-
创建一个新的src/components/Navigation.jsx文件,其中我们导入Link组件和PropTypes:
import Link from 'next/link' import PropTypes from 'prop-types' -
定义一个UserBar组件,当用户登录时将被渲染,并允许用户访问创建帖子页面和注销:
export function UserBar({ username }) { return ( <form> <Link href='/create'>Create Post</Link> | Logged in as{' '} <strong>{username}</strong> <button>Logout</button> </form> ) } UserBar.propTypes = { username: PropTypes.string.isRequired, } -
然后,定义一个LoginSignupLinks组件,当用户尚未登录时将被渲染。它提供了链接到**/login和/signup**页面,允许用户在我们的应用中注册和登录:
export function LoginSignupLinks() { return ( <div> <Link href='/login'>Log In</Link> | <Link href='/signup'>Sign Up</Link> </div> ) } -
接下来,定义一个Navigation组件,它添加了一个链接到主页,然后根据用户是否登录有条件地渲染UserBar组件或LoginSignupLinks组件:
export function Navigation({ username }) { return ( <> <Link href='/'>Home</Link> {username ? <UserBar username={username} /> : <LoginSignupLinks />} </> ) } Navigation.propTypes = { username: PropTypes.string, } -
现在,我们只需要渲染Navigation组件。为了确保它在博客应用的所有页面上显示,我们将它放在根布局中。编辑src/app/layout.js并导入Navigation组件:
import { Navigation } from '@/components/Navigation' -
然后,定义一个示例user对象来模拟用户登录:
export default function RootLayout({ children }) { const user = { username: 'dan' } -
按照以下方式渲染Navigation组件:
return ( <html lang='en'> <body> <nav> <Navigation username={user?.username} /> </nav> <br /> <main>{children}</main> </body> </html> ) } -
我们还需要从列表中的单个帖子添加一个链接到完整的帖子页面。编辑src/components/Post.jsx并导入Link组件:
import Link from 'next/link' -
然后,添加一个链接到标题,如下所示:
export function Post({ _id, title, author }) { return ( <article> <h3> <Link href={`/posts/${_id}`}>{title}</Link> </h3> -
点击创建帖子链接进入相应的页面,然后使用主页链接返回。也可以尝试通过点击主页上的博客帖子标题来访问完整的帖子页面。
以下截图显示了在添加了导航栏后渲染的主页:
图 16.5 – 在 Next.js 中重新创建的我们的(静态)博客应用!
摘要
在本章中,我们首先学习了 Next.js 是什么以及它如何对全栈开发有用。然后,我们设置了一个新的 Next.js 项目,并了解了 App Router 范式。最后,我们通过创建组件、页面和导航栏,利用 Next.js 的Link组件在应用的不同页面间导航,重新创建了 Next.js 中的博客应用。
在下一章第十七章**介绍 React 服务器组件中,我们将学习如何通过创建 React 服务器组件来使我们的博客应用变得交互式,这些组件在服务器上运行,例如可以执行数据库查询。此外,我们还将学习关于服务器操作的知识,这些操作用于提交表单,例如登录、注册和创建帖子表单。
第十七章:介绍 React 服务器组件
在 Next.js 中实现我们的静态博客应用之后,是时候给它添加一些交互性了。我们不会使用传统的模式,即编写一个单独的后端服务器,前端从该服务器获取数据并发出请求,而是将使用一种名为React 服务器组件(RSCs)的新模式。这种新模式允许我们通过仅在某些 React 组件(所谓的服务器组件)上执行来直接从 React 组件访问数据库。结合服务器操作(一种从客户端调用服务器上函数的方法),这种新模式使我们能够轻松快速地开发全栈应用。在本章中,我们将学习 RSCs 和服务器操作是什么,为什么它们很重要,它们的优点是什么,以及如何正确且安全地实现它们。
在本章中,我们将涵盖以下主要主题:
-
什么是 RSCs?
-
为我们的 Next.js 应用添加数据层
-
使用 RSCs 从数据库获取数据
-
使用服务器操作进行注册、登录和创建新帖子
技术要求
在我们开始之前,请安装从第一章“为全栈开发做准备”和第二章“了解 Node.js 和 MongoDB”中提到的所有要求。
那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应成问题,但请注意,某些步骤可能会有所不同。如果你在使用本书中提供的代码和步骤时遇到问题,请尝试使用第一章和第二章中提到的版本。
你可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch17。
本章的 CiA 视频可以在以下位置找到:youtu.be/4hGZJRmZW6E。
什么是 RSCs?
到目前为止,我们一直在使用传统的 React 架构,其中所有组件都是客户端组件。我们是从客户端渲染开始的。然而,客户端渲染有一些缺点:
-
在客户端开始渲染任何内容之前,必须从服务器下载 JavaScript 客户端包,这会延迟用户的首次内容绘制(FCP)。
-
必须从服务器获取数据(在下载并执行 JavaScript 之后)才能显示任何有意义的内容,这会延迟用户的首次有意义的绘制(FMP)。
-
大部分负载都在客户端,即使是那些非交互式的页面也是如此,这对处理器较慢的客户端来说尤其成问题,例如低端移动设备或旧笔记本电脑。它还需要更多的电池来加载重量级的客户端渲染页面。
-
在某些情况下,数据是顺序获取的(例如,首先加载帖子,然后解析每个帖子的作者),这对于具有高延迟的慢速连接来说尤其是一个问题。
为了解决这些问题,服务器端渲染(SSR)被引入,但它仍然有一个很大的缺点:由于所有内容都在服务器上渲染,初始页面加载可能会很慢。这种减速发生的原因如下:
-
在显示任何数据之前,必须从服务器获取数据。
-
在客户端使用它进行水合之前,必须从服务器下载 JavaScript 客户端包。水合意味着页面已准备好供用户交互。为了刷新你对水合工作原理的了解,请查看第七章。
-
水合作用必须在客户端完成,之后才能与任何内容进行交互。
即使客户端组件在服务器上进行了预渲染,其代码也会被打包并发送到客户端进行水合。这意味着客户端组件可以在服务器(用于 SSR)和客户端上运行,但它们至少需要在客户端上能够运行。
在仅包含客户端组件的传统全栈 React 架构中,如果我们需要访问服务器的文件系统或数据库,我们需要编写一个单独的后端使用 Node.js 并公开一个 API(例如 REST API)。然后,这个 API 在客户端组件中被查询,例如,使用 TanStack Query。这些查询也可以在服务器端进行(如我们在第七章,使用服务器端渲染提高加载时间)中看到),但它们至少需要在客户端可执行。这意味着我们无法直接从 React 组件中访问文件系统或数据库,即使该代码可以在服务器上运行;它会被打包并发送到客户端,在那里运行将不会工作(或者会将内部信息,如凭证,暴露给数据库):
图 17.1 – 无 RSCs 和有 RSCs 的全栈应用架构
React 18 引入了一个名为 RSCs 的新功能,允许我们定义仅将在服务器上执行组件,只将输出发送到客户端。服务器组件可以,例如,从数据库或文件系统中获取数据,然后渲染交互式客户端组件,并将这些数据作为 props 传递给它们。这个新功能允许我们构建一个架构,我们可以更轻松地仅使用 React 编写全栈应用程序,而无需处理定义 REST API 的开销。
注意
对于某些应用程序,定义 REST API 可能仍然有意义,特别是如果后端是由更大规模项目中的另一个团队开发,或者如果它被其他服务和前端消费。
RSC 通过允许我们在服务器上独家执行代码(客户端无需水合!)和选择性地流式传输组件(这样我们就不必等待所有内容预渲染后再向客户端提供组件)来解决客户端渲染和 SSR 中的上述问题。
下图比较了 客户端渲染 (CSR) 与 SSR 和 RSC:
图 17.2 – CSR、SSR 和 RSC 的比较
正如你所见,RSC 不仅整体上更快(由于网络往返次数更少),而且可以在等待其他组件加载的同时立即显示应用程序的布局。
让我们总结一下 RSC 的最重要的特性:
-
它们可以在构建之前运行,并且不会被包含在 JavaScript 包中,从而减少包大小并提高性能。
-
它们可以在构建时运行(生成静态 HTML)或当请求到来时即时执行。有趣的是,服务器组件也可以在构建时独家执行,从而生成静态 HTML 包。这对于静态构建的 CMS 应用或个人博客可能很有用。RSC 还允许混合使用,其中初始缓存通过静态构建进行预填充,然后通过服务器操作或 Webhooks 进行后续验证。我们将在 第十八章*,高级 Next.js 概念和优化* 中了解更多关于缓存的内容。
-
它们可以将(可序列化)数据传递给客户端组件。此外,客户端组件仍然可以被服务器端渲染,以进一步提高性能!
-
在服务器组件内部,其他服务器组件可以作为 props 传递给客户端组件,允许使用组合模式,其中服务器组件被“嵌入”到交互式客户端组件中。然而,所有在客户端组件内部导入的组件都将被视为客户端组件;它们不能再是服务器组件。
在像 Next.js 这样的框架中,默认情况下,React 组件被视为服务器组件。如果我们想将其转换为客户端组件,我们需要在文件开头写入 "use client" 指令。我们需要这样做是为了使其能够添加交互性(事件监听器)或使用状态/生命周期效果和仅浏览器 API。
注意
"use client" 指令定义了服务器组件和客户端组件之间的网络边界。所有从服务器组件发送到客户端组件的数据都将被序列化并通过网络发送。当在文件中使用 "use client" 指令时,所有导入到该文件的其他模块,包括子组件,都被视为客户端包的一部分。
下图概述了何时使用服务器组件或客户端组件:
图 17.3 – 何时使用服务器组件和客户端组件概述
通常,RSC 是对客户端组件的一种优化。你可以在每个文件的顶部简单地写上"use client"并完成,但你将放弃 RSC 的所有优势!所以,尽可能使用服务器组件,但如果你发现将其拆分为服务器端和客户端部分过于复杂,不要犹豫将其定义为客户端组件。它总是可以在以后进行优化。
这种编写全栈 React 应用的新方法在理论上可能难以理解,所以请随时在本章结束时再次回到这一节。现在,我们将继续前进,并在我们的 Next.js 应用中实现 RSC,这将帮助我们理解新概念在实际中的工作方式。首先,我们将从向我们的 Next.js 应用添加数据层开始,这将允许我们稍后从 RSC 中访问数据库。
向我们的 Next.js 应用添加数据层
在传统的后端结构中,我们有数据库层、服务层和路由层。在现代的全栈 Next.js 应用中,我们不需要后端的路由层,因为我们可以直接在 RSC 中与之交互。因此,我们只需要数据库层和一个数据层来提供访问数据库的功能。理论上,我们可以在 RSC 中直接访问数据库,但最佳实践是定义特定的函数以特定方式访问它。定义这样的函数使我们能够清楚地定义哪些数据是可访问的(从而避免意外泄露过多信息)。它们也更易于重用,并使得单元测试和发现数据层中的潜在漏洞(例如,通过渗透测试)更加容易。
总结一下,主要有三种数据处理方法:
-
HTTP APIs:我们在前几章中使用这些 API 来实现我们的博客应用。当后端和前端由不同的团队工作时,这些 API 非常有用。因此,这种方法推荐用于现有的大型项目和组织。
-
数据访问层:这是我们将在本节中使用的模式。对于使用 RSC 架构的新项目来说,这是一个推荐的选择,因为它通过分离处理数据(以及与之相关的所有安全挑战)和用户界面(在 React 组件中显示数据)的职责,使得实现全栈项目更加容易。单独处理每个问题比同时处理两者的复杂性更容易解决且错误率更低。
-
组件级数据访问:这是一种在 RSC 中直接查询数据库的模式。这种方法对于快速原型设计和学习很有用。然而,由于可扩展性问题以及可能引入的安全问题,它不应在生产应用中使用。
不建议混合这些方法,所以最好选择一个并坚持下去。在我们的情况下,我们选择“数据访问层”方法,因为它是对现代 RSC 架构最安全的做法。
设置数据库连接
让我们先设置必要的包和初始化数据库连接:
-
将现有的 ch16 文件夹复制到一个新的 ch17 文件夹,如下所示:
$ cp -R ch16 ch17 -
在 VS Code 中打开 ch17 文件夹并打开一个终端。
-
我们将使用一个名为 server-only 的包来确保数据库和数据层的代码仅在服务器端执行,而不会意外地导入客户端。按照以下步骤安装它:
$ npm install server-only@0.0.1 -
我们还需要 mongoose 包来连接到数据库并创建数据库模式和模型。运行以下命令来安装它:
$ npm install mongoose@8.0.2 -
创建一个新的 src/db/ 文件夹。
-
在这个文件夹内,创建一个新的 src/db/init.js 文件,在其中我们首先导入 server-only 包以确保代码仅在服务器上执行:
import 'server-only' -
接下来,导入 mongoose:
import mongoose from 'mongoose' -
定义并导出一个 async 函数以初始化数据库:
export async function initDatabase() { const connection = await mongoose.connect(process.env.DATABASE_URL) return connection } -
现在,我们需要在 .env 文件中定义 DATABASE_URL。因此,在项目的根目录中创建一个新的 .env 文件并添加以下行:
DATABASE_URL=mongodb://localhost:27017/blog
现在数据库连接已经设置好,我们可以继续创建数据库模型。
创建数据库模型
现在,我们将为帖子用户创建数据库模型。这些模型将与我们之前章节中为我们的博客应用创建的模型非常相似。按照以下步骤开始创建数据库模型:
-
创建一个新的 src/db/models/ 文件夹。
-
在其中,创建一个新的 src/db/models/user.js 文件,在其中我们首先导入 server-only 和 mongoose 包:
import 'server-only' import mongoose, { Schema } from 'mongoose' -
定义 userSchema,它由一个唯一的必需的 username 和一个必需的 password 组成:
const userSchema = new Schema({ username: { type: String, required: true, unique: true }, password: { type: String, required: true }, }) -
如果模型尚未创建,我们创建 Mongoose 模型:
export const User = mongoose.models.user ?? mongoose.model('user', userSchema)
注意
如果模型已经存在,则返回模型,如果不存在,则创建一个新的模型,这是必要的,以避免 OverwriteModelError 问题,该问题发生在模型被导入(因此重新定义)多次时。
-
创建一个新的 src/db/models/post.js 文件,在其中我们首先导入 server-only 和 mongoose 包:
import 'server-only' import mongoose, { Schema } from 'mongoose' -
定义 postSchema,它由一个必需的 title 和 author(引用 user 模型)以及可选的 contents 组成:
const postSchema = new Schema( { title: { type: String, required: true }, author: { type: Schema.Types.ObjectId, ref: 'user', required: true }, contents: String, }, { timestamps: true }, ) -
如果模型尚未创建,我们创建 Mongoose 模型:
export const Post = mongoose.models.post ?? mongoose.model('post', postSchema) -
创建一个新的 src/db/models/index.js 文件并重新导出模型:
import 'server-only' export * from './user' export * from './post'我们从这个文件夹重新导出模型,以确保我们可以,例如,通过查询相应的用户来加载一个帖子并解析
author。这需要定义user模型,尽管它不是直接使用的。为了避免这些问题,我们简单地从定义所有模型的文件中加载模型。
在定义数据库模型之后,我们可以定义数据层函数,这些函数将提供各种访问数据库的方式。
定义数据层函数
现在我们已经有了数据库连接和架构,让我们开始定义访问数据库的数据层函数。
定义帖子数据层
我们将首先定义帖子数据层。这允许我们访问我们应用中处理帖子的所有相关函数:
-
创建一个新的 src/data/ 文件夹。
-
在其中,创建一个新的 src/data/posts.js 文件,我们将导入 server-only 包和 Post 模型:
import 'server-only' import { Post } from '@/db/models' -
定义一个 createPost 函数,它接受 userId、title 和 contents 并创建一个新的帖子:
export async function createPost(userId, { title, contents }) { const post = new Post({ author: userId, title, contents }) return await post.save() } -
接下来,定义一个 listAllPosts 函数,该函数首先从数据库中获取所有帖子,按创建日期降序排序(首先显示最新帖子):
export async function listAllPosts() { return await Post.find({}) .sort({ createdAt: 'descending' }) -
然后,我们必须通过解析 user 模型并从中获取 username 值来填充 author 字段:
.populate('author', 'username')在 Mongoose 中,
populate函数类似于 SQL 中的JOIN语句:它获取存储在author字段中的 ID,然后通过查看post架构来确定该 ID 引用了哪个模型。在post架构中,我们定义了author字段引用user架构,因此 Mongoose 将查询user模型以获取给定的 ID 并返回一个用户对象。通过提供第二个参数,我们指定我们只想从用户对象(ID 总是会返回)中获取username值。这样做是为了避免泄露内部信息,例如用户的(散列的)密码。 -
在填充帖子对象后,我们使用 .lean() 将其转换为纯的、可序列化的 JavaScript 对象:
.lean() }拥有一个可序列化的对象是必要的,以便能够将数据从 RSC 传递到常规客户端组件,因为所有传递给客户端的数据都需要跨越网络边界,因此需要可序列化。
-
最后,我们必须定义一个 getPostById 函数,该函数通过 ID 查找一个单独的帖子,填充 author 字段,并使用 lean() 将结果转换为纯 JavaScript 对象:
export async function getPostById(postId) { return await Post.findById(postId) .populate('author', 'username') .lean() }
定义用户数据层
我们现在将定义用户数据层。这将涉及创建 JWT 进行身份验证。再次强调,大部分代码将与我们在博客应用中之前实现的内容非常相似。按照以下步骤开始定义用户数据层:
-
安装 bcrypt(用于散列用户密码)和 jsonwebtoken(用于处理 JWT):
$ npm install bcrypt@5.1.1 jsonwebtoken@9.0.2 -
创建一个新的 src/data/users.js 文件,我们将导入 server-only、bcrypt、jwt 和 User 模型:
import 'server-only' import bcrypt from 'bcrypt' import jwt from 'jsonwebtoken' import { User } from '@/db/models' -
定义一个 createUser 函数,其中我们散列给定的密码,然后创建一个新的 User 模型实例并将其保存:
export async function createUser({ username, password }) { const hashedPassword = await bcrypt.hash(password, 10) const user = new User({ username, password: hashedPassword }) return await user.save() } -
接下来,定义一个 loginUser 函数,该函数首先尝试找到具有给定用户名的用户,如果没有找到用户则抛出错误:
export async function loginUser({ username, password }) { const user = await User.findOne({ username }) if (!user) { throw new Error('invalid username!') }
备注
根据您的安全需求,您可能希望考虑不要告诉潜在的攻击者存在用户名,而是返回一个通用消息,例如“无效的用户名或密码”。然而,在我们的情况下,假设用户名是公开信息,因为每个用户都是博客的作者,并且他们的用户名与文章一起发布。
-
然后,使用bcrypt将提供的密码与数据库中的哈希密码进行比较,如果密码无效则抛出一个错误:
const isPasswordCorrect = await bcrypt.compare(password, user.password) if (!isPasswordCorrect) { throw new Error('invalid password!') } -
最后,生成、签名并返回一个 JWT:
const token = jwt.sign({ sub: user._id }, process.env.JWT_SECRET, { expiresIn: '24h', }) return token } -
现在,我们将定义一个函数从用户 ID 中获取用户信息(目前我们只获取用户名,但以后可以扩展这个功能)。如果用户 ID 不存在,我们抛出一个错误:
export async function getUserInfoById(userId) { const user = await User.findById(userId) if (!user) throw new Error('user not found!') return { username: user.username } } -
接下来,定义一个函数从令牌中获取用户 ID,确保在解码 JWT 的同时验证令牌签名,使用jwt.verify:
export function getUserIdByToken(token) { if (!token) return null const decodedToken = jwt.verify(token, process.env.JWT_SECRET) return decodedToken.sub } -
最后,定义一个函数通过组合getUserIdByToken和getUserInfoById函数从令牌中获取用户信息:
export async function getUserInfoByToken(token) { const userId = getUserIdByToken(token) if (!userId) return null const user = await getUserInfoById(userId) return user } -
我们仍然需要定义JWT_SECRET环境变量,以便我们的代码能够工作。编辑**.env**并添加它,如下所示:
JWT_SECRET=replace-with-random-secret
注意
这是非常基础的 Next.js 身份验证实现。对于大型项目,建议考虑一个完整的身份验证解决方案,如 Auth.js(以前称为 next-auth)、Auth0 或 Supabase。查看 Next.js 文档以获取有关 Next.js 身份验证的更多信息:nextjs.org/docs/app/building-your-application/authentication。
现在我们有了数据层来访问数据库,我们可以开始实现 RSCs 和 Server Actions,这些将调用数据层中的函数来访问数据库中的信息并渲染显示这些信息的 React 组件,将我们的静态博客应用转变为一个完全功能的博客。
使用 RSCs 从数据库中获取数据
正如我们所学的,在使用 Next.js 时,React 组件默认被认为是服务器组件,所以所有页面组件都已经执行并在服务器上渲染。只有当我们需要使用仅客户端函数,如 hooks 或输入字段时,我们才需要通过使用“use client”指令将我们的组件转换为客户端组件。对于所有不需要用户交互的组件,我们可以简单地保持它们作为服务器组件,并且它们将仅作为静态 HTML(编码在 RSC 有效载荷中)渲染和提供,不会在客户端进行激活。对于客户端(浏览器),这些 React 组件似乎根本不存在,因为浏览器只会看到静态 HTML 代码。这种模式大大提高了我们 Web 应用程序的性能,因为客户端不需要加载 JavaScript 来渲染这些组件。它还减少了包的大小,因为需要加载我们的 Web 应用程序的 JavaScript 代码更少。
现在,让我们实现 RSCs 以从数据库中获取数据。
获取帖子列表
我们将首先实现 HomePage,其中我们获取并渲染帖子列表:
-
编辑 src/app/page.js 并导入 initDatabase 和 listAllPosts 函数:
import { initDatabase } from '@/db/init' import { listAllPosts } from '@/data/posts' -
将 HomePage 组件转换为 async 函数,这允许我们在渲染组件之前等待数据获取:
export default async function HomePage() { -
替换 样本的 posts 数组为以下代码:
await initDatabase() const posts = await listAllPosts()
获取单个帖子
现在,我们可以查看帖子列表,接下来让我们继续实现 ViewPostPage 的获取单个帖子的过程。按照以下步骤开始:
-
编辑 src/app/posts/[id]/page.js 并导入 notFound、getPostById 和 initDatabase 函数:
import { notFound } from 'next/navigation' import { getPostById } from '@/data/posts' import { initDatabase } from '@/db/init' -
将页面组件转换为 async 函数:
export default async function ViewPostPage({ params }) { -
替换 样本的 post 对象为对 initDatabase 和 getPostById 的调用:
await initDatabase() const post = await getPostById(params.id) if (!post) notFound()现在,我们需要创建一个
not-found.js文件来捕获错误并渲染不同的组件。 -
创建一个新的 src/app/posts/[id]/not-found.js 文件,其中我们渲染“帖子未找到!”信息,如下所示:
export default function ViewPostError() { return <strong>Post not found!</strong> }
提示
我们还可以添加一个 app/not-found.js 文件来处理整个应用程序中不匹配的 URL。如果用户访问应用程序未定义的路径,该文件中定义的组件将被渲染。
-
此外,我们还可以创建一个错误组件,用于渲染任何错误,例如无法连接到数据库。创建一个新的 src/app/posts/[id]/error.js 文件,其中我们渲染“加载帖子时出错!”信息,如下所示:
'use client' export default function ViewPostError() { return <strong>Error while loading the post!</strong> }错误页面需要是客户端组件,因此我们添加了
'useclient'指令。
信息
错误页面需要是客户端组件的原因是它们使用了 React ErrorBoundary 功能,该功能作为类组件实现(使用 componentDidCatch)。React 类组件不能是服务器组件,因此我们需要将错误页面作为客户端组件。
-
我们仍然需要对 Post 组件进行小幅调整,因为 _id 现在实际上不再是字符串了;相反,它是一个 ObjectId 对象。编辑 src/components/Post.jsx 并更改类型,如下所示:
Post.propTypes = { _id: PropTypes.object.isRequired, -
确保 Docker 和 MongoDB 容器正常运行!
-
按照以下步骤运行开发服务器:
$ npm run dev -
前往 http://localhost:3000 并点击列表中的任意帖子;您将看到帖子成功加载。如果帖子不存在(例如,如果您更改了 ID 中的单个数字),将显示“帖子未找到!”信息。如果发生任何其他错误(例如,无效的 ID),将显示“加载帖子时出错!”信息:
图 17.4 – 显示帖子以及未找到/错误组件
注意
如果您的数据库中还没有帖子,您可以通过使用前面章节中的博客应用创建一个新的帖子,或者等待我们在本章末尾使用 Next.js 实现创建帖子功能。
在实现用于获取帖子的 RSC(React Server Components)之后,我们的博客应用现在已连接到数据库。然而,目前它只能显示帖子;用户还无法与该应用进行交互。让我们继续通过添加服务器操作(Server Actions)来使我们的博客应用变得交互式。
使用服务器操作进行注册、登录和创建新帖子
到目前为止,我们只从服务器上的数据库获取数据并发送给客户端,但为了实现用户交互,我们需要能够从客户端将数据发送回服务器。为了能够做到这一点,React 引入了一种称为服务器操作的模式。
"use server"指令,然后要么将它们导入到客户端组件中,要么通过 props 将它们传递给客户端组件。虽然常规 JavaScript 函数不能传递给客户端组件(因为它们不可序列化),但服务器操作可以。
注意
您可以通过在文件开头添加**"use server"指令来定义一个充满服务器操作的整个文件。这将告诉打包器该文件中的所有函数都是服务器操作;它不定义文件内的组件为服务器组件(为了强制在服务器上执行某些操作,请使用如上所述的server-only**包,而不是使用服务器组件)。然后您可以从这样的文件中导入函数到客户端组件中。
在客户端组件中,我们可以使用useFormState钩子,它的签名与useState类似,但允许我们执行服务器操作(在服务器上)并在客户端获取结果。useFormState钩子的签名如下:
const [state, formAction] = useFormState(fn, initialState)
注意
在 React 19 版本中,useFormState钩子将被重命名为useActionState。有关更多信息,请参阅react.dev/reference/react/useActionState。
如我们所见,我们传递一个函数(服务器操作)和一个初始状态。钩子随后返回当前状态和一个formAction函数。状态最初设置为初始状态,并在调用formAction函数后更新为服务器操作的结果。在服务器端,服务器操作的签名如下:
function exampleServerAction(previousState, formData) {
"use server"
// …do something…
}
如我们所见,服务器操作函数接受previousState(最初将从客户端设置为initialState)和一个formData对象(这是一个来自 XMLHttpRequest API 网络标准的常规formData对象)。formData对象包含表单字段中提交的所有信息。这使得我们能够轻松地提交表单以在服务器上执行操作并将结果返回给客户端。
现在,让我们开始使用服务器操作来实现我们博客应用中的注册页面。
实现注册页面
用户与博客应用交互需要采取的第一个操作是注册,因此让我们从实现这个功能开始。按照以下步骤开始:
-
我们首先实现客户端组件。编辑src/components/Signup.jsx,将其标记为客户端组件,然后导入useFormState钩子和PropTypes:
'use client' import { useFormState } from 'react-dom' import PropTypes from 'prop-types' -
注册组件现在需要接受一个注册操作,我们将在稍后服务器端定义:
export function Signup({ signupAction }) { -
定义一个useFormState钩子,它接受一个服务器操作和一个初始状态(在我们的情况下,是一个空对象),并返回当前状态和一个操作:
const [state, formAction] = useFormState(signupAction, {}) -
现在,我们可以在**标签中添加action**,如下所示:
return ( <form await formAction() inside an onClick handler function. -
此外,如果我们从服务器收到state.error消息,我们可以在“注册”按钮下方显示一个错误消息:
<input type='submit' value='Sign Up' /> {state.error ? <strong> Error signing up: {state.error}</strong> : null} </form> ) } -
我们不要忘记为注册组件定义propTypes。注册操作是一个函数:
Signup.propTypes = { signupAction: PropTypes.func.isRequired, } -
现在,我们可以开始实现实际的服务器操作。编辑src/app/signup/page.js,并从next/navigation导入redirect函数(在成功注册后导航到登录页面),以及createUser和initDatabase函数:
import { redirect } from 'next/navigation' import { createUser } from '@/data/users' import { initDatabase } from '@/db/init' import { Signup } from '@/components/Signup' -
然后,在注册页面组件外部,定义一个新的async函数,该函数接受前一个状态(在我们的情况下,这是我们定义的初始状态,即空对象,因此我们可以忽略它)和一个formData对象:
async function signupAction(prevState, formData) { -
我们需要给函数加上**'use server'**指令,将其转换为服务器操作:
'use server' -
然后,我们可以初始化数据库并尝试创建用户:
try { await initDatabase() await createUser({ username: formData.get('username'), password: formData.get('password'), })如您所见,服务器操作建立在现有的 Web API 之上,并使用
FormDataAPI 进行表单提交。我们可以简单地使用name属性调用.get(),它将包含相应输入字段中提供的值。 -
如果有错误,我们返回错误消息(然后将在注册客户端组件中显示):
} catch (err) { return { error: err.message } } -
否则,如果一切顺利,我们重定向到登录页面:
redirect('/login') } -
在定义服务器操作后,我们可以将其传递给注册组件,如下所示:
export default function SignupPage() { return <Signup signupAction={signupAction} /> }或者,客户端组件可以直接从文件中导入
signupAction函数。只要函数有'use server'指令,它就会在服务器上执行。在这种情况下,我们只需要在这个特定页面上使用该函数,因此将其定义在页面上并传递给组件更有意义。 -
运行开发服务器,如下所示:
$ npm run dev -
再次访问**http://localhost:3000/signup**并尝试输入用户名和密码。它应该成功并重定向到登录屏幕(变化微妙,但提交按钮从**注册**变为**登录**)。
图 17.5 – 当用户名已存在时显示错误
当然,这个错误信息并不非常友好,所以我们可以做一些工作来改进这里的错误信息。但到目前为止,这已经足够作为一个示例来展示服务器操作是如何工作的。
如您所见,RSCs 和服务器操作使实现与数据库交互的功能变得简单。作为额外的奖励,通过<form>提交的所有服务器操作即使在禁用 JavaScript 的情况下也能正常工作——尝试通过禁用 JavaScript 重复步骤 15和16来试试!
实现登录页面和 JWT 处理
现在用户可以注册,我们需要一种方式让他们登录。这也意味着我们需要实现创建和存储 JWT 的功能。现在,由于我们对 Next.js 中的服务器-客户端交互有了更多的控制,我们可以将 JWT 存储在 cookie 中而不是内存中。这意味着用户会话将在他们刷新页面时持续存在。
让我们开始实现登录页面和 JWT 处理:
-
我们首先实现客户端组件。编辑src/components/Login.jsx并将其转换为客户端组件:
'use client' -
然后,导入useFormState钩子和PropTypes:
import { useFormState } from 'react-dom' import PropTypes from 'prop-types' -
接受loginAction作为 props。我们将使用它来定义useFormState钩子:
export function Login({ loginAction }) { const [state, formAction] = useFormState(loginAction, {}) -
将从钩子返回的formAction传递给
**<form>**元素:return ( <form action={formAction}> -
现在,我们可以在组件末尾显示潜在的错误:
<input type='submit' value='Log In' /> {state.error ? <strong> Error logging in: {state.error}</strong> : null} </form> ) } -
最后,定义propTypes,如下所示:
Login.propTypes = { loginAction: PropTypes.func.isRequired, } -
现在,我们可以创建loginAction服务器操作。编辑src/app/login/page.js并从 Next.js 导入cookies和redirect函数,以及从我们的数据层导入loginUser和initDatabase函数:
import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { loginUser } from '@/data/users' import { initDatabase } from '@/db/init' import { Login } from '@/components/Login' -
在LoginPage组件外部定义一个新的loginAction,在其中我们尝试使用给定的用户名和密码进行登录:
async function loginAction(prevState, formData) { 'use server' let token try { await initDatabase() token = await loginUser({ username: formData.get('username'), password: formData.get('password'), }) -
如果失败,我们返回错误信息:
} catch (err) { return { error: err.message } } -
否则,我们设置一个有效期 24 小时的AUTH_TOKENcookie(与创建的 JWT 的有效期相同),并使其安全和httpOnly:
cookies().set({ name: 'AUTH_TOKEN', value: token, path: '/', maxAge: 60 * 60 * 24, secure: true, httpOnly: true, })
注意
httpOnly属性确保 cookie 不能被客户端 JavaScript 访问,从而减少我们应用中跨站脚本攻击的可能性。secure属性确保 cookie 在网站的 HTTPS 版本上设置。为了提高开发体验,这不会应用于 localhost。
-
在设置 cookie 后,我们重定向到主页:
redirect('/') } -
最后,我们将loginAction传递给Login组件:
export default function LoginPage() { return <Login loginAction={loginAction} /> } -
前往**http://localhost:3000/login**并尝试输入一个不存在的用户名;你会得到一个错误。然后,尝试输入你之前注册时使用的相同用户名和密码。它应该可以成功并重定向你到主页。
检查用户是否已登录
你可能已经注意到,在用户登录后,导航栏没有改变。我们仍然需要检查用户是否已登录,然后相应地调整导航栏。现在让我们来做这件事:
-
编辑src/app/layout.js并从 Next.js 导入cookies函数,从我们的数据层导入getUserInfoByToken函数:
import { cookies } from 'next/headers' import { getUserInfoByToken } from '@/data/users' import { Navigation } from '@/components/Navigation' -
将RootLayout转换为async函数:
export default async function RootLayout({ children }) { -
获取AUTH_TOKENcookie 并将其值传递给getUserInfoByToken函数以获取user对象,替换我们之前定义的示例user对象:
const token = cookies().get('AUTH_TOKEN') const user = await getUserInfoByToken(token?.value) -
如果你之前还打开了主页,它应该会自动热重载并显示你的用户名和注销按钮。
我们已经将user?.username传递给Navigation组件,所以这就完成了!
实现注销
现在我们可以根据用户是否登录显示不同的导航栏,我们终于可以看到注销按钮了。然而,它现在还不工作。我们现在将实现注销按钮:
-
编辑src/app/layout.js并在RootLayout组件外部定义一个logoutAction服务器操作:
async function logoutAction() { 'use server' -
在这个操作中,我们简单地删除了AUTH_TOKENcookie:
cookies().delete('AUTH_TOKEN') } -
按如下方式将logoutAction传递给Navigation组件:
<Navigation username={user?.username} logoutAction={logoutAction} /> -
编辑src/components/Navigation.jsx并在UserBar和注销表单中添加logoutAction:
export function UserBar({ username, logoutAction }) { return ( <form action={logoutAction}> -
将操作添加到UserBar组件的propTypes中,如下所示:
UserBar.propTypes = { username: PropTypes.string.isRequired, logoutAction: PropTypes.func.isRequired, } -
然后,将logoutAction作为 props 添加到Navigation组件,并传递给UserBar组件:
export function Navigation({ username, logoutAction }) { return ( <> <Link href='/'>Home</Link> {username ? ( <UserBar username={username} logoutAction={logoutAction} /> ) : ( <LoginSignupLinks /> )} </> ) } -
最后,更改Navigation组件的propTypes,如下所示:
Navigation.propTypes = { username: PropTypes.string, logoutAction: PropTypes.func.isRequired, } -
点击Logout按钮以看到导航栏变回显示登录和注册链接。
现在,我们的用户可以最终成功登录和注销。让我们继续实现帖子创建。
实现帖子创建
我们博客应用中缺少的最后一个功能是帖子创建。我们可以使用服务器操作和 JWT 来验证用户身份,并允许他们创建帖子。按照以下步骤实现帖子创建:
-
这次,我们首先实现服务器操作。编辑src/app/create/page.js并导入cookies、redirect、createPost、getUserIdByToken和initDatabase函数:
import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { createPost } from '@/data/posts' import { getUserIdByToken } from '@/data/users' import { initDatabase } from '@/db/init' import { CreatePost } from '@/components/CreatePost' -
在CreatePostPage组件内部,从 cookie 中获取令牌:
export default function CreatePostPage() { const token = cookies().get('AUTH_TOKEN') -
仍然在CreatePostPage组件内部,定义一个服务器操作:
async function createPostAction(formData) { 'use server'这次我们不会使用
useFormState钩子,因为我们不需要在客户端处理操作的 state 或 result。因此,服务器操作没有(prevState, formData)签名,而是有(``formData)签名。 -
在服务器操作中,我们从令牌中获取userId值,然后初始化数据库连接并创建一个新的帖子:
const userId = getUserIdByToken(token?.value) await initDatabase() const post = await createPost(userId, { title: formData.get('title'), contents: formData.get('contents'), }) -
最后,我们将重定向到新创建的帖子的ViewPost页面:
redirect(`/posts/${post._id}`) } -
如果用户未登录,我们现在可以显示一个错误消息:
if (!token?.value) { return <strong>You need to be logged in to create posts!</strong> } -
否则,我们渲染CreatePost组件,并将createPostAction传递给它:
return <CreatePost createPostAction={createPostAction} /> } -
现在,我们可以调整CreatePost组件。这次我们不需要将其转换为客户端组件,因为我们不会使用useFormState钩子。编辑src/components/CreatePost.jsx并导入PropTypes:
import PropTypes from 'prop-types' -
然后,将createPostAction作为属性传递给表单元素:
export function CreatePost({ createPostAction }) { return ( <form action={createPostAction}> -
最后,定义propTypes,如下所示:
CreatePost.propTypes = { createPostAction: PropTypes.func.isRequired, } -
前往**http://localhost:3000**,再次登录,然后点击**创建帖子**链接。输入标题和一些内容,然后点击**创建**按钮;你应该会被重定向到新创建的博客帖子的**查看帖子**页面!
摘要
在本章中,我们学习了 RSCs(React Server Components),为什么引入它们,它们的优点是什么,以及它们如何融入我们的全栈架构。然后,我们通过在应用程序中引入数据层来安全地实现 RSCs。之后,我们使用 RSCs 从数据库中获取数据并渲染组件。最后,我们学习了服务器操作,并为我们博客应用程序添加了交互功能。现在,我们的博客应用程序再次完全功能正常!
在下一章,第十八章,高级 Next.js 概念和优化,我们将深入探讨 Next.js 的工作原理以及在使用它时如何进一步优化我们的应用程序。我们将学习关于缓存、图像和字体优化,以及如何定义 SEO 优化的元数据。
第十八章:高级 Next.js 概念和优化
现在我们已经了解了 Next.js 和 React 服务器组件(RSCs)的基本功能,让我们更深入地探讨 Next.js 框架。在本章中,我们将学习 Next.js 中的缓存工作原理以及如何利用它来优化我们的应用程序。我们还将学习如何在 Next.js 中实现 API 路由。然后,我们将学习如何通过添加元数据来优化 Next.js 应用程序以适应搜索引擎和社交媒体。最后,我们将学习如何在 Next.js 中最优地加载图片和字体。
在本章中,我们将涵盖以下主要主题:
-
在 Next.js 中定义 API 路由
-
Next.js 中的缓存
-
搜索引擎优化(SEO)与 Next.js
-
Next.js 中优化的图片和字体加载
技术要求
在我们开始之前,请安装来自 第一章 为全栈开发做准备 和 第二章 了解 Node.js 和 MongoDB 的所有要求。
那些章节中列出的版本是本书中使用的版本。虽然安装较新版本可能不会有问题,但请注意,某些步骤可能会有所不同。如果你在使用本书中提供的代码和步骤时遇到问题,请尝试使用 第一章 和 2 中提到的版本。
你可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch18。
本章的 CiA 视频可在以下网址找到:youtu.be/jzCRoJPGoG0。
在 Next.js 中定义 API 路由
在上一章中,我们使用 RSCs 通过数据层访问我们的数据库;为此不需要 API 路由!然而,有时公开外部 API 仍然是有意义的。例如,我们可能希望允许第三方应用程序查询博客文章。幸运的是,Next.js 也提供了一个名为路由处理器(Route Handlers)的功能来定义 API 路由。
路由处理器也定义在 src/app/ 目录下,但是在一个 route.js 文件中而不是 page.js 文件中(一个路径只能是路由或页面,所以文件夹中只能放置这些文件中的一个)。我们不需要导出一个页面组件,而是需要导出处理各种类型请求的函数。例如,要处理 GET 请求,我们必须定义并导出以下函数:
export async function GET() {
Next.js 支持以下 HTTP 方法用于路由处理器:GET、POST、PUT、PATCH、DELETE、HEAD 和 OPTIONS。对于不支持的方法,Next.js 将返回 405 Method Not Allowed 响应。
Next.js 支持原生的Request(developer.mozilla.org/en-US/docs/Web/API/Request)和Response(developer.mozilla.org/en-US/docs/Web/API/Response)网络 API,但将它们扩展为NextRequest和NextResponse API,这使得处理 cookie 和头部信息变得更容易。我们在上一章中使用了 Next.js 的cookies()函数来轻松创建、获取和删除 JWT 的 cookie。headers()函数使得从请求中获取头部信息变得容易。这些函数可以在 RSCs 和路由处理器中以相同的方式使用。
为列出博客文章创建 API 路由
让我们先定义一个用于列出博客文章的 API 路由:
-
按照以下步骤将现有的ch17文件夹复制到新的ch18文件夹:
$ cp -R ch17 ch18 -
在 VS Code 中打开ch18文件夹。
-
为了使 API 路由更容易与我们的应用页面区分开来,创建一个新的**src/app/api/**文件夹。
-
在**src/app/api/文件夹内,创建一个新的src/app/api/v1/**文件夹,以确保我们的 API 在将来可能对 API 进行更改时进行了版本控制。
-
接下来,为**/****api/v1/posts路由创建一个src/app/api/v1/posts/**文件夹。
-
创建一个新的src/app/api/posts/route.js文件,其中我们从数据层导入initDatabase函数和listAllPosts函数:
import { initDatabase } from '@/db/init' import { listAllPosts } from '@/data/posts' -
然后,定义并导出一个GET函数。这个函数将处理对**/****api/v1/posts**路由的 HTTP GET 请求:
export async function GET() { -
在其中,我们必须初始化数据库并获取所有文章的列表:
await initDatabase() const posts = await listAllPosts() -
使用Response网络 API 生成 JSON 响应:
return Response.json({ posts }) } -
确保 Docker 和 MongoDB 容器运行正常!
-
按照以下步骤启动 Next.js 应用:
$ npm run dev -
现在,前往**http://localhost:3000/api/v1/posts**查看返回的 JSON 格式的文章,如下所示:
图 18.1 – 由 Next.js 路由处理器生成的 JSON 响应
现在,第三方应用也可以通过我们的 API 获取文章!让我们继续学习更多关于 Next.js 中的缓存知识。
Next.js 中的缓存
到目前为止,我们一直都在使用 Next.js 的 dev 模式。在 dev 模式下,Next.js 所做的大多数缓存都被关闭,以便我们能够使用热重载和始终更新的数据来开发我们的应用。然而,一旦我们切换到生产模式,静态渲染和缓存默认开启。静态渲染意味着如果一个页面只包含静态组件(例如“关于我们”或“版权声明”页面,这些页面只包含静态内容),它将被静态渲染并作为 HTML 或作为静态文本/JSON 为路由提供服务。此外,Next.js 会尽可能缓存数据和服务器端渲染的组件,以保持应用性能。
Next.js 有四种主要的缓存类型:
-
数据缓存:用于在用户请求和部署之间存储数据的服务器端缓存。这是持久的,但可以进行验证。
-
请求记忆化:如果函数在单个请求中多次调用,则为函数的返回值提供服务器端缓存。
-
完整路由缓存:Next.js 路由的服务器端缓存。此缓存是持久的,但可以进行验证。
-
路由缓存:一种客户端缓存,用于存储路由以减少导航时的服务器请求,适用于单个用户会话或基于时间的。
前两种缓存类型(数据缓存和请求记忆化)主要适用于在服务器端使用 fetch() 函数,例如从第三方 API 获取数据。然而,最近,也可以通过使用 unstable_cache() 函数将这些两种类型的缓存应用于任何函数。尽管这个名字听起来不稳定,但这个函数已经可以在生产环境中安全使用。它之所以被称为“不稳定”,是因为当发布新的 Next.js 版本时,API 可能会改变并需要代码更改。有关更多信息,请参阅nextjs.org/docs/app/api-reference/functions/unstable_cache。
注意
或者,可以使用 React 的 cache() 函数来记忆化函数的返回值,但 Next.js 的 unstable_cache() 函数更灵活,允许我们通过路径或标签动态重新验证缓存。我们将在本节的后面部分学习更多关于缓存重新验证的内容。
完整路由缓存是一个额外的缓存,确保当数据没有变化时,我们甚至不需要在服务器端重新渲染页面,这样 Next.js 可以直接返回预渲染的静态 HTML 和 RSC 有效负载。然而,验证数据缓存也会使相应的完整路由缓存失效并触发重新渲染。
路由缓存是一种客户端缓存,主要用于用户在页面之间导航时,允许我们立即显示他们已经访问过的页面,而无需再次从服务器获取。
此外,如果 Next.js 检测到某个页面或路由只包含静态内容,它将预渲染并存储为静态内容。静态内容不能再进行验证,因此我们需要小心并确保我们应用中的所有动态内容都被 Next.js 视为“动态”的,而不是意外地被检测为“静态”内容。
注意
在这本书中,我们称这个过程为 静态渲染。然而,在其他资源中,它也可能被称为“自动静态优化”或“静态站点生成”。
在以下情况下,Next.js 将退出静态渲染并考虑页面或路由为动态:
-
当使用动态函数,如 cookies()、headers() 或 searchParams
-
当设置 export const dynamic = 'force-dynamic' 或 export const revalidate = 0
-
当路由处理器处理非 GET 请求时
想要更深入地了解不同类型的缓存信息,请查看 Next.js 关于缓存的文档:nextjs.org/docs/app/building-your-application/caching。
现在,让我们通过查看我们的路由在生产构建中的应用行为来探索静态渲染在实际中的工作方式。
探索 API 路由中的静态渲染
在本章中,我们实现了一个用于获取博客文章的路由处理器。现在,让我们探索这个路由在开发和生产模式下的行为:
-
编辑src/app/api/v1/posts/route.js,并在响应中添加一个currentTime值,使用Date.now(),如下所示:
return Response.json({ posts, currentTime: Date.now() }) -
在**http://localhost:3000/api/v1/posts**上刷新页面几次;你会看到**currentTime**总是最新的时间戳。
-
使用Ctrl + C退出 Next.js 开发服务器。
-
按照以下步骤构建 Next.js 应用以用于生产并启动它:
$ npm run build $ npm start -
在**http://localhost:3000/api/v1/posts**上刷新页面几次。现在,**currentTime**一点都没有变化!即使我们重启 Next.js 服务器,currentTime仍然不会改变。GET /api/v1/posts路由的响应在构建时是静态渲染的。
对于路由和页面,静态渲染的工作方式相似,因此页面也将默认进行静态渲染。这意味着 RSC(React Server Components)本身不需要服务器;它们也可以在构建时运行。如果我们想要有动态的页面/路由,我们才需要一个 Node.js 服务器。这意味着我们可以在 Next.js 中创建一个博客或网站,并导出一个静态包,这样我们就可以将其托管在简单的 Web 服务器上。
注意
通过在next.config.js文件中指定**output: 'export'**选项,可以将 Next.js 应用导出为静态包。
有趣的是,如果我们创建一个新的博客文章,我们的主页确实会更新。然而,这种情况只因为RootLayout使用了cookies()来检查用户是否登录,使得我们博客应用上的所有页面都是动态的(因此不是静态渲染)。这也可以通过查看npm run build的输出看到:
图 18.2 – 在构建输出中查看哪些路由是静态和动态渲染的
如图 18**.2所示,/api/v1/posts路由是“作为静态内容预渲染”,而所有其他路由则是“使用 Node.js 按需服务器渲染。”
注意
如果我们想在博客中静态渲染一些页面,我们必须确保用户栏在这些页面上不可见。例如,我们可以为所有带有用户栏的页面创建一个 路由组 (nextjs.org/docs/app/building-your-application/routing/route-groups),并使用一个包含用户栏的单独布局。然后,我们可以从根布局中移除用户栏。这样,我们就可以创建一个静态渲染的关于页面,同时保持博客的其他部分动态。
正如我们所见,在 Next.js 中,页面和路由默认是静态渲染的(如果可能)。然而,在我们的 API 路由的情况下,这并不是我们想要的!我们希望能够从 API 动态获取帖子。当我们刚开始用 Next.js 开发应用程序时,静态渲染和缓存可能会让人困惑,但它成为了一个强大的工具,可以帮助我们优化应用程序。
现在,让我们学习如何正确处理缓存,以便在需要时使我们的页面和路由动态化,同时在可能的情况下保持它们被缓存。
使路由动态化
要使路由动态化,我们需要在它上面设置 export const dynamic = 'force-dynamic' 标志。按照以下步骤操作:
-
编辑 src/app/api/v1/posts/route.js 并添加以下代码:
export const dynamic = 'force-dynamic' -
退出当前运行的 Next.js 服务器。
-
按照以下步骤构建 Next.js 应用程序以进行生产并启动它:
$ npm run build $ npm start -
在 http://localhost:3000/api/v1/posts 上刷新页面几次。现在,API 路由的行为与开发服务器上的行为相同!
不幸的是,我们现在已经完全禁用了缓存,因此我们也没有使用缓存带来的任何好处。接下来,我们将学习如何为特定函数打开缓存。
在数据层中缓存函数
要从我们的数据层缓存函数,我们可以使用 Next.js 的 unstable_cache() 函数。unstable_cache(fetchData, keyParts, options) 函数接受三个参数:
-
fetchData: 第一个参数是要调用的函数。该函数也可以有参数。
-
keyParts: 第二个参数是一个唯一键的数组,用于在缓存中标识函数。传递给第一个参数中函数的参数也将自动添加到这个数组中。
-
options: 第三个参数是一个包含缓存选项的对象,其中我们可以指定 标签 以在以后重新验证缓存,以及一个 重新验证 超时,在经过一定秒数后自动重新验证缓存。
现在,让我们为所有合适的函数启用这个缓存。按照以下步骤开始:
-
编辑 src/data/posts.js 并导入 unstable_cache() 函数,将其别名为 cache():
import { unstable_cache as cache } from 'next/cache' -
将 listAllPosts 函数用 cache() 包装,如下所示:
export const listAllPosts = cache( async function listAllPosts() { return await Post.find({}) .sort({ createdAt: 'descending' }) .populate('author', 'username') .lean() }, ['posts', 'listAllPosts'], { tags: ['posts'] }, posts) and the function name (listAllPosts) to uniquely identify the function in our data layer. Additionally, we added a posts tag, which we are going to use later to revalidate the cache when new posts are created. -
接下来,包装 getPostById 函数:
export const getPostById = cache( async function getPostById(postId) { return await Post.findById(postId).populate('author', 'username').lean() }, ['posts', 'getPostById'], ) -
你可能会注意到,在获取帖子时现在出现了错误,因为 MongoDB 中的ObjectId被缓存序列化为字符串。编辑src/components/Post.jsx并调整propType,如下所示:
Post.propTypes = { _id: PropTypes.string.isRequired, -
编辑src/data/users.js并在其中导入unstable_cache:
import { unstable_cache as cache } from 'next/cache' -
包装getUserInfoById函数:
export const getUserInfoById = cache( async function getUserInfoById(userId) { const user = await User.findById(userId) if (!user) throw new Error('user not found!') return { username: user.username } }, ['users', 'getUserInfoById'], ) -
停止当前运行的 Next.js 服务器。
-
在生产环境中重新构建并启动应用。你会注意到在创建新帖子后,它不再更新主页(或 API 路由)了:
$ npm run build $ npm start那是因为我们的帖子现在被缓存了!
-
这个缓存即使在开发模式下也能工作。按照以下步骤停止 Next.js 服务器并重新启动:
$ npm run dev -
创建一个新的帖子;你会看到主页和 API 路由列表中都没有新创建的帖子。
现在缓存已经配置好了,让我们学习如何处理缓存重新验证(导致缓存中的数据更新)。
通过 Server Actions 重新验证缓存
处理过时数据的最佳方式是在新数据到来时重新验证缓存,例如通过 Server Actions。为此,我们有两种选择:
-
使用revalidatePath函数在特定路径上重新验证所有路由段
-
使用revalidateTag函数通过特定的标签(从而可能重新验证多个路径)进行重新验证
重新验证意味着下次从缓存的函数请求数据时,该函数将被调用,并将返回新数据并将其缓存(而不是返回之前缓存的旧数据)。这两个函数都会重新验证数据缓存,因此也会重新验证完整的路由缓存和客户端路由缓存。
按照以下步骤在创建新帖子后调用revalidateTag函数:
-
编辑src/app/create/page.js并导入revalidateTag函数:
import { revalidateTag } from 'next/cache' -
在createPostAction内部,在创建新帖子后,对posts标签调用revalidateTag函数:
async function createPostAction(formData) { 'use server' const userId = getUserIdByToken(token?.value) await initDatabase() const post = await createPost(userId, { title: formData.get('title'), contents: formData.get('contents'), }) revalidateTag('posts') redirect(`/posts/${post._id}`) } -
现在,创建一个新的帖子并转到主页。你会看到新创建的帖子出现在列表中!API 路由现在也会显示新创建的帖子。
当数据通过 Server Actions 更改时重新验证缓存是更新缓存的最直接方式。然而,有时我们会从第三方 API 获取数据,在这种情况下无法进行重新验证。我们现在将探讨这种情况。
通过 Webhook 重新验证缓存
如果数据来自第三方源,我们可以通过 Webhook 重新验证缓存。Webhooks 是可以用作回调的 API。例如,当数据发生变化时,第三方源会调用我们的 API 端点,让我们知道我们需要重新获取数据。
集成第三方 API
在我们开始实现 Webhook 之前,让我们将第三方 API 集成到我们的应用中。在这个例子中,我们将使用 WorldTimeAPI (worldtimeapi.org/),但你可以自由选择任何你喜欢的 API。
让我们开始实现一个从第三方 API 获取数据的页面:
-
在 src/app/time/ 文件夹中创建一个新的文件夹。在其内部,创建一个新的 src/app/time/page.js 文件。
-
编辑 src/app/time/page.js 并定义一个异步页面组件:
export default async function TimePage() { -
在组件内部,从 WorldTimeAPI 获取当前时间并将响应解析为 JSON:
const timeRequest = await fetch('https://worldtimeapi.org/api/timezone/UTC') const time = await timeRequest.json() -
渲染当前时间戳:
return <div>Current timestamp: {time?.datetime}</div> } -
如果你通过浏览器访问 http://localhost:3000/time 页面,你会看到它显示了当前时间。然而,当刷新时,时间永远不会更新。这是因为使用 fetch 的请求默认被缓存,类似于我们在数据层函数中添加 unstable_cache() 后发生的情况。
实现钩子
现在,让我们在我们的应用程序中创建一个 Webhook API 端点,当被调用时,重新验证第三方数据的缓存:
-
在 src/app/api/v1/webhook/ 文件夹中创建一个新的文件夹。在其内部,创建一个新的 src/app/api/v1/webhook/route.js 文件。
-
编辑 src/app/api/v1/webhook/route.js 并导入 revalidatePath 函数:
import { revalidatePath } from 'next/cache' -
现在,定义一个新的 GET 路由处理器,它在 /time 页面上调用 revalidatePath,然后返回一个表示成功的响应:
export async function GET() { revalidatePath('/time') return Response.json({ ok: true }) } export const dynamic = 'force-dynamic'通常,Webhooks 被定义为
POST路由处理器(因为它们会影响应用程序的状态),但为了简化通过在浏览器中访问页面来触发 Webhook,我们将其定义为GET路由处理器。POST路由将放弃静态渲染,但GET路由不会,因此我们需要指定force-dynamic。 -
在浏览器中访问 **http://localhost:3000/api/v1/webhook**,然后再次访问 **http://localhost:3000/time**;你应该看到时间已经更新了!在现实世界中,我们会将我们的 Webhook URL 添加到提供 API 的第三方网站界面中。
注意
或者,我们可以在请求中添加一个标签,通过在 fetch() 函数中传递 next.tags 选项,如下所示:fetch('worldtimeapi.org/api/timezon…', { next: { tags: ['time'] } })。然后,我们可以通过调用 revalidateTag('time') 来重新验证缓存。
如我们所见,使用 Webhooks 重新验证缓存效果很好。然而,有时我们甚至无法向第三方 API 添加 Webhook。让我们探讨当我们无法控制第三方 API 时应该做什么。
定期重新验证缓存
如果我们对第三方数据源完全没有控制权,我们可以告诉 Next.js 定期重新验证缓存。现在让我们设置一下:
-
编辑 src/app/time/page.js 并调整 fetch() 函数,向其中添加 next.revalidate 选项:
const timeRequest = await fetch('https://worldtimeapi.org/api/timezone/UTC', { next: { revalidate: 10 }, })在这种情况下,我们告诉 Next.js 在下次请求 API 时重新验证数据缓存,如果自上次请求以来至少过去了 10 秒。
注意
使用 unstable_cache(),我们可以在第三个参数中传递 revalidate 选项。对于路由和页面,我们可以指定 export const revalidate = 10,这将重新验证相应的路由/页面。
- 在浏览器中刷新 http://localhost:3000/time 页面。你会看到时间更新。再次刷新页面;时间将不会再次更新。如果你在至少 10 秒后刷新,时间将再次更新。
现在,我们已经了解了定期重新验证缓存的方法,让我们学习如何退出缓存。
退出缓存
有时,你可能希望完全退出某些请求的缓存。为此,将以下选项传递给 fetch 函数:
fetch('<URL>', export const dynamic = 'force-dynamic' to opt out of the full route cache (the data may still be cached though!).
Now that we’ve learned how to use the cache in Next.js to optimize our app, let’s learn about SEO with Next.js.
SEO with Next.js
In *Chapter 8*, we learned about SEO in full-stack apps. Next.js provides functionality for SEO out of the box. Let’s explore this functionality now, starting with adding dynamic titles and meta tags.
Adding dynamic titles and meta tags
In Next.js, we can statically define metadata by exporting a metadata object from a `page.js` file, or we can dynamically define metadata by exporting a `generateMetadata` function. We have already added static metadata to the root layout, as can be seen in `src/app/layout.js`:
导出 const metadata = {
title: '全栈 Next.js 博客',
description: '关于 React 和 Next.js 的博客',
}
Now, let’s dynamically generate metadata for our post pages:
1. Edit **src/app/posts/[id]/page.js** and define the following function outside of the page component:
```
导出异步函数 generateMetadata({ params }) {
const id = params.id
```js
2. Fetch the post; if it does not exist, call **notFound()**:
```
const post = 等待 getPostById(id)
if (!post) notFound()
```js
3. Otherwise, return a title and description:
```
return {
title: `${post.title} | 全栈 Next.js 博客`,
description: `由 ${post.author.username} 撰写`,
}
}
```js
That’s all there is to it! Next.js will set the title and meta tags appropriately for us.
Note
Metadata is inherited from layouts. So, it is possible to define defaults for metadata in the layout and then selectively override it for specific pages.
Now that we have successfully added a dynamic title and meta tags, let’s continue by creating a `robots.txt` file so that search engines know they are allowed to index our blog app.
Creating a robots.txt file
Next.js has two ways of creating a `robots.txt` file:
* Creating a static **robots.txt** file in **src/app/robots.txt**
* Creating a dynamic **robots.txt** file by creating a **src/app/robots.js** script, which returns a special object that is turned into a **robots.txt** file by Next.js
Note
If you need a refresher on what a **robots.txt** file is and how search engines work, please check out *Chapter 8*.
We are only going to create a static `robots.txt` file as there is no need for a dynamic file for now. Follow these steps to get started:
1. Create a new **src/app/robots.txt** file.
2. Edit **src/app/robots.txt** and add the following contents to allow all crawlers to index all pages:
```
User-agent: *
Allow: /
```js
Now that we have created a `robots.txt` file, let’s create meaningful URLs.
Creating meaningful URLs (slugs)
Now, we are going to create slugs for our blog posts, similar to what we did in *Chapter 8*. Let’s get started:
1. Rename the **src/app/posts/[id]/** folder to **src/app/posts/[...path]/**. This turns it into a catch-all route, matching everything that comes after **/posts**.
2. Edit **src/app/posts/[...path]/page.js** and adjust the code to get the first part of the URL (the **id** value) from the **path** param:
```
导出默认异步函数 ViewPostPage({ params }) {
等待初始化数据库()
const [id] = params.path
const post = 等待 getPostById(id)
```js
3. Also, adjust the code for the **generateMetadata** function:
```
导出异步函数 generateMetadata({ params }) {
const [id] = params.path
```js
With that, our router has been set up to accept an optional slug in the URL.
4. Install the **slug** npm package:
```
$ npm install slug@8.2.3
```js
5. Edit **src/components/Post.jsx** and import the **slug** function:
```
导入 slug 从 'slug'
```js
6. Adjust the link to the blog post by adding the slug, as follows:
```
<Link href={`/posts/${_id}/${slug(title)}`}>{title}</Link>
```js
7. Open a link from the post list; you will see that the URL now contains the slug.
Now that we’ve made sure our URLs are meaningful, we’ll wrap up this section by creating a sitemap for our blog app.
Creating a sitemap
As we learned in *Chapter 8*, a sitemap contains a list of URLs that are part of an app so that crawlers can easily detect new content and crawl the app more efficiently, making sure that all content on our blog is found.
Follow these steps to set up a dynamic sitemap in Next.js:
1. First, define a **BASE_URL** for our app as an environment variable. Edit **.env** and add the following line:
```
BASE_URL=http://localhost:3000
```js
2. Create a new **src/app/sitemap.js** file, where we import the **initDatabase**, **listAllPosts**, and **slug** functions:
```
导入 { initDatabase } 从 '@/db/init'
导入 { listAllPosts } 从 '@/data/posts'
导入 slug 从 'slug'
```js
3. Define and export a new asynchronous function that will generate the sitemap:
```
导出默认异步函数 sitemap() {
```js
4. First, we list all the static pages:
```
const staticPages = [
{
url: `${process.env.BASE_URL}`,
},
{
url: `${process.env.BASE_URL}/create`,
},
{
url: `${process.env.BASE_URL}/login`,
},
{
url: `${process.env.BASE_URL}/signup`,
},
{
url: `${process.env.BASE_URL}/time`,
},
]
```js
5. Then, we get all the posts from the database:
```
等待初始化数据库()
const posts = 等待 listAllPosts()
```js
6. Generate an entry for each post by building the URL and adding a **lastModified** timestamp:
```
const postsPages = posts.map((post) => ({
url: `${process.env.BASE_URL}/posts/${post._id}/${slug(post.title)}`,
lastModified: post.updatedAt,
}))
```js
7. Finally, return **staticPages** and **postsPages** in an array:
```
return [...staticPages, ...postsPages]
}
```js
8. Go to **http://localhost:3000/sitemap.xml** in your browser; you will see that Next.js generated the XML for us from the array of objects!
Note
It is best practice to add the sitemap to the **robots.txt** file, but we would need to turn it into a dynamic **robots.js** file so that we can provide the full URL to the sitemap (using the **BASE_URL** environment variable). Doing this is left as an exercise for you.
Now that we’ve optimized our blog app for search engines, let’s learn about optimized image and font loading in Next.js.
Optimized image and font loading in Next.js
Loading images and fonts in an optimized way can be tedious, but Next.js makes it very simple by providing the `Font` and `Image` components.
The Font component
Often, you’ll want to use a specific font for your page to make it unique and stand out. If your font is on Google Fonts, you can have Next.js automatically self-host it for you. No requests will be sent to Google by your browser if you use this feature. Additionally, the fonts will be loaded optimally with zero layout shift.
Let’s find out how Google Fonts can be self-hosted with Next.js:
1. We are going to load the **Inter** font by importing it from **next/font/google**. Edit **src/app/layout.js** and add the following import:
```
导入 { Inter } 从 'next/font/google'
```js
2. Now, load the font, as follows:
```
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
```js
`Inter` is a variable font, so we don’t need to specify the weight that we want to load. If the font isn’t a variable font, don’t forget to specify the weight. The `display: 'swap'` property means that the font gets an extremely small block period to be loaded. If it does not load by then, a fallback font will be used. Once the font has been loaded, it will be swapped in.
3. Specify the font in the **<html>** tag, as follows:
```
<html lang='en' className={inter.className}>
```js
4. Go to **http://localhost:3000/** in your browser; you will see that our blog app is now using the **Inter** font! See the following screenshot for reference:

Figure 18.3 – Our blog app rendered with the Inter font
As you can see, it’s very simple to use self-hosted Google Fonts with Next.js!
Note
If you want to use a font that is not on Google Fonts, use the **localFont** function from **next/font/local**. This allows you to load a font from a file in your project. For more information on the **Font** component, check out the Next.js docs: [`nextjs.org/docs/app/building-your-application/optimizing/fonts`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts).
Next, we are going to learn about the `Image` component, which allows us to easily load images in an optimized way.
The Image component
Images make up a large portion of the download size of your web application, and can thus have a big impact on the `Image` component, which extends the `<img>` element by doing the following:
* Automatically serving resized images for each device and resolution
* Automatically preventing layout shift when images are loading
* Only loading images when they enter the viewport (“lazy loading”), with optional blurred placeholder images
* Offering on-demand resizing for images, even if they are stored remotely
Using the `Image` component is simple – just import it and load your images as you would with the `<img>` element. Let’s try it out now:
1. Get an image to be used as a logo for your blog. Any image can be used, but make sure it is a non-vector format (such as PNG). For vector formats, resizing is not necessary, so you will not see any effect.
2. Save the image as a **src/app/logo.png** file.
3. Edit **src/app/layout.js** and import the **Image** component and the logo:
```
导入 Image 从 'next/image'
导入 logo 从 './logo.png'
```js
4. Above the **<nav>** element, render the **<Image>** component, as follows:
```
return (
<html lang='en' className={inter.className}>
<body>
<Image
src={logo}
alt='全栈 Next.js 博客 Logo'
width={500}
height={47}
/>
<nav>
<Navigation username={user?.username} logoutAction={logoutAction} />
</nav>
```js
It is important to specify the width and height of the image so that Next.js can infer the correct aspect ratio and prevent layout shift when the image loads in.
5. Go to **http://localhost:3000/** in your browser; you will see the logo being displayed properly! See the following screenshot for reference:

Figure 18.4 – Using the Image component to display a logo for our blog
If you inspect the image in the browser, you will see that it has the `srcset` property with different sizes provided so that the browser can choose which one to load depending on the screen resolution.
Note
In this example, we loaded a local image, but the **Image** component also supports loading images from a remote server, and it will still resize them properly! To use external URLs, allow the remote server by using the **images.remotePatterns** setting in the **next.config.js** file, then simply pass a URL instead of a local file to the **Image** component.
Summary
In this chapter, we learned how to define API routes in Next.js. Then, we learned about caching, how to revalidate the cache, and how to opt out of the cache. Next, we learned about SEO in Next.js by adding metadata to our pages, creating meaningful URLs, defining a `robots.txt` file, and generating a sitemap. Finally, we learned about the `Font` and `Image` components, which allowed us to load fonts and images easily and optimally in our app.
There are still many more features that Next.js offers that we have not covered yet in this book, such as the following:
* **Internationalization**: Allows us to configure the process of routing and rendering content for multiple languages
* **Middleware**: Allows us to run code before requests are completed, similar to how middleware works in Express
* **Serverless Node.js and Edge runtimes**: Allow us to scale our apps even more by not running a full Node.js server
* **Advanced routing**: Allows us to model complex routing scenarios, such as parallel routes (displaying two pages at once)
In the next chapter, *Chapter 19*, *Deploying a Next.js App*, we are going to learn how to deploy a Next.js app using Vercel and a custom deployment setup.