第23章 博客评论功能实现
用户交互功能开发
作者:zyyc-0336 | 日期:2026-06-12
数据结构示例代码
// 一条评论长这样
const comment = {
id: "1718200000000",
author: "小明",
content: "这篇文章写得很清楚,学到了!",
postId: "my-first-post",
createdAt: "2026-06-12T10:00:00Z",
};
逐行讲解
id:每条评论的"身份证号",用 Date.now() 生成,保证唯一
author:评论者给自己起的名字
content:评论的正文内容
postId:标识这条评论属于哪篇文章(一个页面可能有多篇文章)
createdAt:评论创建的时间,用 ISO 字符串方便排序和显示
💡 新手易错提醒:
不要把所有文章的评论混在一起存!一定要用 postId 把评论和文章关联起来,否则文章 A 的评论会出现在文章 B 下面。
概念解释
评论表单就是一个 HTML 表单,让用户填写昵称和评论内容,点提交后发送给后端。在 Next.js 中,我们用 React 组件来构建这个表单。
表单组件用到的 API
| API / 方法 | 作用 | 说明 |
useState | 管理表单状态 | 存储输入框的值和提交状态 |
fetch() | 发送网络请求 | 把评论数据发给后端 API |
onChange | 监听输入变化 | 实时更新 state 中的值 |
onSubmit | 监听表单提交 | 点击提交按钮时触发 |
preventDefault() | 阻止默认行为 | 防止表单提交后页面刷新 |
完整评论表单组件
// components/CommentForm.jsx
'use client';
import { useState } from 'react';
export default function CommentForm({ postId, onCommentAdded }) {
const [author, setAuthor] = useState('');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault(); // 阻止表单默认刷新行为
// 简单验证
if (!author.trim() || !content.trim()) {
alert('昵称和评论内容不能为空!');
return;
}
setLoading(true);
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
author: author.trim(),
content: content.trim(),
postId,
}),
});
if (!res.ok) throw new Error('提交失败');
// 提交成功后清空表单
setAuthor('');
setContent('');
// 通知父组件刷新评论列表
if (onCommentAdded) onCommentAdded();
} catch (error) {
alert('评论提交失败,请稍后再试');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} style={{ marginBottom: '24px' }}>
<div style={{ marginBottom: '12px' }}>
<input
type="text"
placeholder="你的昵称"
value={author}
onChange={(e) => setAuthor(e.target.value)}
style={{ padding: '8px', width: '200px' }}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<textarea
placeholder="写下你的评论..."
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
style={{ padding: '8px', width: '100%', maxWidth: '500px' }}
/>
</div>
<button type="submit" disabled={loading}>
{loading ? '提交中...' : '发表评论'}
</button>
</form>
);
}
逐行讲解
'use client':标记这是客户端组件(需要交互功能)
useState:创建三个状态——昵称、评论内容、加载状态
e.preventDefault():必须加! 否则表单提交会刷新页面
fetch('/api/comments'):向后端发送 POST 请求
JSON.stringify():把 JS 对象转成 JSON 字符串
onCommentAdded:回调函数,让父组件知道新评论已提交
disabled={loading}:提交中禁用按钮,防止重复点击
💡 新手易错提醒:
- 忘记写
'use client' → 报错 "useState is not defined"
- 忘记
e.preventDefault() → 页面刷新,评论丢失
- 没有
finally { setLoading(false) } → 失败后按钮一直显示"提交中"
概念解释
GET 请求就是"读取数据"。用户打开文章页面时,前端发一个 GET 请求给后端,后端返回这篇文章的所有评论。就像去图书馆查某本书的所有书评。
API Route 用到的方法
| 方法 | 作用 | 说明 |
GET | HTTP 方法 | 用于获取数据 |
request.nextUrl.searchParams | 获取查询参数 | 从 URL 中读取 ?postId=xxx |
NextResponse.json() | 返回 JSON 响应 | 把数据以 JSON 格式返回给前端 |
完整 GET API 代码
// app/api/comments/route.js
import { NextResponse } from 'next/server';
// 用内存数组模拟数据库(实际项目用数据库)
let comments = [
{
id: '1',
author: '小红',
content: '这篇文章太棒了!',
postId: 'my-first-post',
createdAt: '2026-06-11T08:00:00Z',
},
];
// GET:获取某篇文章的所有评论
export async function GET(request) {
const postId = request.nextUrl.searchParams.get('postId');
if (!postId) {
return NextResponse.json(
{ error: '缺少 postId 参数' },
{ status: 400 }
);
}
// 筛选出属于这篇文章的评论,按时间倒序排列
const postComments = comments
.filter((c) => c.postId === postId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return NextResponse.json(postComments);
}
逐行讲解
let comments = [...]:用数组模拟数据库,实际项目换成数据库操作
request.nextUrl.searchParams.get('postId'):从 URL ?postId=xxx 中取值
NextResponse.json({ error }, { status: 400 }):返回错误信息和状态码
.filter():只保留匹配 postId 的评论
.sort():按创建时间倒序,最新的评论在最前面
💡 新手易错提醒:
- 用
let 而不是 const 声明数组,因为后面 POST 要往里加数据
searchParams.get() 返回的是字符串,比较时注意类型一致
- 开发时用内存数组,重启服务器数据会丢失,生产环境必须用数据库
概念解释
POST 请求就是"提交数据"。用户填完评论点提交,前端把评论内容发给后端,后端保存起来。就像在图书馆的留言簿上写下新的书评。
POST 请求流程图
用户填写表单 → 前端 fetch(POST) → 后端接收数据 → 保存到数据库 → 返回成功
完整 POST API 代码
// 在同一个 app/api/comments/route.js 文件中追加
// POST:新增一条评论
export async function POST(request) {
try {
const body = await request.json();
const { author, content, postId } = body;
// 验证必填字段
if (!author || !content || !postId) {
return NextResponse.json(
{ error: '昵称、内容和 postId 不能为空' },
{ status: 400 }
);
}
// 创建新评论对象
const newComment = {
id: Date.now().toString(), // 用时间戳作为唯一 ID
author,
content,
postId,
createdAt: new Date().toISOString(), // 当前时间的 ISO 字符串
};
// 保存到数组(实际项目保存到数据库)
comments.push(newComment);
return NextResponse.json(newComment, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
逐行讲解
await request.json():把请求体的 JSON 字符串解析成 JS 对象
const { author, content, postId } = body:解构赋值,提取需要的字段
Date.now().toString():生成唯一 ID(毫秒级时间戳转字符串)
new Date().toISOString():生成 ISO 格式的当前时间
comments.push(newComment):把新评论加到数组末尾
status: 201:HTTP 状态码,表示"已成功创建"
💡 新手易错提醒:
- 忘记
await request.json() → 拿到的是 Promise 而不是数据
- 忘记做字段验证 → 空评论也能提交成功
- 用
Date.now() 做 ID 在高并发下可能重复,生产环境用 UUID
概念解释
防刷就是防止恶意用户疯狂提交评论。最简单的方案是时间戳验证:记录用户上次评论的时间,如果距离上次评论不到 30 秒,就拒绝提交。就像游戏里的技能冷却时间。
防刷方案对比
| 方案 | 实现难度 | 效果 | 适用场景 |
| 前端倒计时 | ⭐ 简单 | 弱(可绕过) | 个人博客 |
| 后端时间戳验证 | ⭐⭐ 中等 | 中 | 中小型项目 |
| Redis + IP 限流 | ⭐⭐⭐ 复杂 | 强 | 大型项目 |
| 验证码(reCAPTCHA) | ⭐⭐⭐ 复杂 | 很强 | 公开网站 |
后端时间戳验证代码
// 在 POST 函数开头添加防刷逻辑
// 记录每个用户最后一次评论的时间(模拟,实际用数据库/Redis)
const lastCommentTime = {};
export async function POST(request) {
try {
const body = await request.json();
const { author, content, postId } = body;
// 防刷检查:同一个人 30 秒内只能评论一次
const now = Date.now();
const lastTime = lastCommentTime[author] || 0;
const cooldown = 30 * 1000; // 30 秒冷却
if (now - lastTime < cooldown) {
const waitSeconds = Math.ceil((cooldown - (now - lastTime)) / 1000);
return NextResponse.json(
{ error: `评论太频繁,请 ${waitSeconds} 秒后再试` },
{ status: 429 }
);
}
// 验证必填字段
if (!author || !content || !postId) {
return NextResponse.json(
{ error: '昵称、内容和 postId 不能为空' },
{ status: 400 }
);
}
// 创建并保存评论
const newComment = {
id: Date.now().toString(),
author,
content,
postId,
createdAt: new Date().toISOString(),
};
comments.push(newComment);
// 更新最后评论时间
lastCommentTime[author] = now;
return NextResponse.json(newComment, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
逐行讲解
const lastCommentTime = {}:对象存储每个用户的最后评论时间
const cooldown = 30 * 1000:30 秒冷却时间(单位毫秒)
now - lastTime < cooldown:距离上次评论不足 30 秒
Math.ceil(...):向上取整,显示还需要等多少秒
status: 429:HTTP 状态码,表示"请求过多"
lastCommentTime[author] = now:成功评论后更新时间
💡 新手易错提醒:
- 用
author 做 key 不够安全(可以改名绕过),生产环境用 IP 或用户 ID
- 内存存储重启会丢失,生产环境用 Redis 或数据库
- 前端也要做防刷(按钮禁用),但不能只依赖前端
概念解释
把评论表单和评论列表组合在一起,形成一个完整的评论区。用户可以看到所有评论,也可以发表新评论。
组件结构
| 组件 | 职责 | 说明 |
CommentSection | 评论区容器 | 管理数据获取和状态 |
CommentForm | 评论表单 | 用户输入和提交 |
CommentList | 评论列表 | 展示所有评论 |
完整评论区组件
// components/CommentSection.jsx
'use client';
import { useState, useEffect } from 'react';
export default function CommentSection({ postId }) {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
// 获取评论列表
const fetchComments = async () => {
try {
const res = await fetch(`/api/comments?postId=${postId}`);
if (!res.ok) throw new Error('获取失败');
const data = await res.json();
setComments(data);
} catch (error) {
console.error('获取评论失败:', error);
} finally {
setLoading(false);
}
};
// 页面加载时获取评论
useEffect(() => {
fetchComments();
}, [postId]);
// 提交评论
const handleSubmit = async (e) => {
e.preventDefault();
const form = e.target;
const author = form.author.value.trim();
const content = form.content.value.trim();
if (!author || !content) {
alert('昵称和评论内容不能为空!');
return;
}
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author, content, postId }),
});
if (!res.ok) {
const err = await res.json();
alert(err.error || '提交失败');
return;
}
form.reset(); // 清空表单
fetchComments(); // 刷新评论列表
} catch (error) {
alert('网络错误,请稍后再试');
}
};
if (loading) return <p>加载评论中...</p>;
return (
<div style={{ marginTop: '40px', borderTop: '1px solid #eee', paddingTop: '24px' }}>
<h3>💬 评论区 ({comments.length})</h3>
{/* 评论表单 */}
<form onSubmit={handleSubmit} style={{ marginBottom: '24px' }}>
<div style={{ marginBottom: '8px' }}>
<input name="author" placeholder="你的昵称" />
</div>
<div style={{ marginBottom: '8px' }}>
<textarea name="content" placeholder="写下你的评论..." rows={3} />
</div>
<button type="submit">发表评论</button>
</form>
{/* 评论列表 */}
{comments.length === 0 ? (
<p style={{ color: '#999' }}>还没有评论,来抢沙发吧!</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{comments.map((comment) => (
<li key={comment.id} style={{ borderBottom: '1px solid #f0f0f0', padding: '12px 0' }}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
{comment.author}
<span style={{ fontWeight: 'normal', color: '#999', marginLeft: '12px', fontSize: '12px' }}>
{new Date(comment.createdAt).toLocaleString('zh-CN')}
</span>
</div>
<div>{comment.content}</div>
</li>
))}
</ul>
)}
</div>
);
}
逐行讲解
useEffect(() => { fetchComments() }, [postId]):页面加载或文章切换时自动获取评论
form.author.value:直接从 form 元素取值,比 useState 更简洁
form.reset():清空表单所有输入框
fetchComments():提交成功后重新获取列表,新评论自动出现
key={comment.id}:React 列表渲染必须提供唯一 key
toLocaleString('zh-CN'):把日期转成中文格式显示
💡 新手易错提醒:
- 忘记在
useEffect 依赖数组中加 postId → 切换文章时评论不更新
- 列表没有
key → 控制台警告,且可能导致渲染异常
- 没有
loading 状态 → 数据加载时页面空白
完整代码(GET + POST 合并)
// app/api/comments/route.js
import { NextResponse } from 'next/server';
// 模拟数据库
let comments = [];
const lastCommentTime = {};
// GET:获取评论
export async function GET(request) {
const postId = request.nextUrl.searchParams.get('postId');
if (!postId) {
return NextResponse.json({ error: '缺少 postId' }, { status: 400 });
}
const postComments = comments
.filter((c) => c.postId === postId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return NextResponse.json(postComments);
}
// POST:新增评论(含防刷)
export async function POST(request) {
try {
const body = await request.json();
const { author, content, postId } = body;
// 防刷检查
const now = Date.now();
const lastTime = lastCommentTime[author] || 0;
const cooldown = 30 * 1000;
if (now - lastTime < cooldown) {
const waitSeconds = Math.ceil((cooldown - (now - lastTime)) / 1000);
return NextResponse.json(
{ error: `评论太频繁,请 ${waitSeconds} 秒后再试` },
{ status: 429 }
);
}
// 字段验证
if (!author || !content || !postId) {
return NextResponse.json(
{ error: '昵称、内容和 postId 不能为空' },
{ status: 400 }
);
}
// 内容长度限制
if (content.length > 500) {
return NextResponse.json(
{ error: '评论内容不能超过 500 字' },
{ status: 400 }
);
}
const newComment = {
id: Date.now().toString(),
author: author.slice(0, 20), // 昵称最多 20 字
content,
postId,
createdAt: new Date().toISOString(),
};
comments.push(newComment);
lastCommentTime[author] = now;
return NextResponse.json(newComment, { status: 201 });
} catch (error) {
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
}
}
使用方式
在任意页面引入评论区组件:
// app/posts/my-first-post/page.jsx
import CommentSection from '@/components/CommentSection';
export default function PostPage() {
return (
<article>
<h1>我的第一篇文章</h1>
<p>文章内容...</p>
<CommentSection postId="my-first-post" />
</article>
);
}
💡 新手易错提醒:
- 文件路径必须是
app/api/comments/route.js,不能改名
- 导出的函数名必须是
GET、POST(大写),否则 Next.js 不识别
- 开发时用
npm run dev 启动,API 才能正常工作
知识点总结
| 知识点 | 核心内容 | 关键代码 |
| 数据结构 | 评论包含 id、author、content、postId、createdAt | const comment = { id, author, ... } |
| 表单组件 | useState 管理状态,fetch 提交数据 | e.preventDefault() |
| GET 接口 | 读取评论列表,用 postId 筛选 | searchParams.get('postId') |
| POST 接口 | 新增评论,验证字段,返回 201 | await request.json() |
| 防刷机制 | 记录最后评论时间,30 秒冷却 | status: 429 |
| 完整组件 | 表单+列表组合,useEffect 加载数据 | fetchComments() |
从零到一的完整流程
设计数据结构 → 编写 GET API → 编写 POST API → 添加防刷 → 构建前端组件 → 测试联调
下一步学习建议
| 优先级 | 主题 | 说明 |
| 🔴 高 | 接入数据库 | 用 SQLite/Prisma 替代内存数组 |
| 🔴 高 | 用户认证 | 用 NextAuth.js 实现登录评论 |
| 🟡 中 | 评论回复 | 支持楼中楼嵌套回复 |
| 🟡 中 | 富文本评论 | 支持 Markdown 或表情 |
| 🟢 低 | 评论审核 | 敏感词过滤、人工审核队列 |
📌 本章关键收获:
- 评论功能 = 数据结构 + 前端表单 + 后端 API + 防刷机制
- Next.js API Route 用
route.js 文件,导出 GET/POST 函数
request.json() 解析请求体,NextResponse.json() 返回响应
- 防刷最简单的方案是后端时间戳验证(30 秒冷却)
- 开发时用内存存储,生产环境必须用数据库