第23章 博客评论功能实现

用户交互功能开发

作者:zyyc-0336  |  日期:2026-06-12

数据结构示例代码

js
// 一条评论长这样
const comment = {
  id: "1718200000000",
  author: "小明",
  content: "这篇文章写得很清楚,学到了!",
  postId: "my-first-post",
  createdAt: "2026-06-12T10:00:00Z",
};

逐行讲解

💡 新手易错提醒:

不要把所有文章的评论混在一起存!一定要用 postId 把评论和文章关联起来,否则文章 A 的评论会出现在文章 B 下面。

23.2 前端评论表单组件

概念解释

评论表单就是一个 HTML 表单,让用户填写昵称和评论内容,点提交后发送给后端。在 Next.js 中,我们用 React 组件来构建这个表单。

表单组件用到的 API

API / 方法作用说明
useState管理表单状态存储输入框的值和提交状态
fetch()发送网络请求把评论数据发给后端 API
onChange监听输入变化实时更新 state 中的值
onSubmit监听表单提交点击提交按钮时触发
preventDefault()阻止默认行为防止表单提交后页面刷新

完整评论表单组件

jsx
// 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>
  );
}

逐行讲解

  1. 'use client':标记这是客户端组件(需要交互功能)
  2. useState:创建三个状态——昵称、评论内容、加载状态
  3. e.preventDefault()必须加! 否则表单提交会刷新页面
  4. fetch('/api/comments'):向后端发送 POST 请求
  5. JSON.stringify():把 JS 对象转成 JSON 字符串
  6. onCommentAdded:回调函数,让父组件知道新评论已提交
  7. disabled={loading}:提交中禁用按钮,防止重复点击

💡 新手易错提醒:

23.3 后端 API:获取评论(GET)

概念解释

GET 请求就是"读取数据"。用户打开文章页面时,前端发一个 GET 请求给后端,后端返回这篇文章的所有评论。就像去图书馆查某本书的所有书评。

API Route 用到的方法

方法作用说明
GETHTTP 方法用于获取数据
request.nextUrl.searchParams获取查询参数从 URL 中读取 ?postId=xxx
NextResponse.json()返回 JSON 响应把数据以 JSON 格式返回给前端

完整 GET API 代码

js
// 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);
}

逐行讲解

  1. let comments = [...]:用数组模拟数据库,实际项目换成数据库操作
  2. request.nextUrl.searchParams.get('postId'):从 URL ?postId=xxx 中取值
  3. NextResponse.json({ error }, { status: 400 }):返回错误信息和状态码
  4. .filter():只保留匹配 postId 的评论
  5. .sort():按创建时间倒序,最新的评论在最前面

💡 新手易错提醒:

23.4 后端 API:新增评论(POST)

概念解释

POST 请求就是"提交数据"。用户填完评论点提交,前端把评论内容发给后端,后端保存起来。就像在图书馆的留言簿上写下新的书评。

POST 请求流程图

用户填写表单 → 前端 fetch(POST) → 后端接收数据 → 保存到数据库 → 返回成功

完整 POST API 代码

js
// 在同一个 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 }
    );
  }
}

逐行讲解

  1. await request.json():把请求体的 JSON 字符串解析成 JS 对象
  2. const { author, content, postId } = body:解构赋值,提取需要的字段
  3. Date.now().toString():生成唯一 ID(毫秒级时间戳转字符串)
  4. new Date().toISOString():生成 ISO 格式的当前时间
  5. comments.push(newComment):把新评论加到数组末尾
  6. status: 201:HTTP 状态码,表示"已成功创建"

💡 新手易错提醒:

23.5 防刷机制:时间戳验证

概念解释

防刷就是防止恶意用户疯狂提交评论。最简单的方案是时间戳验证:记录用户上次评论的时间,如果距离上次评论不到 30 秒,就拒绝提交。就像游戏里的技能冷却时间。

防刷方案对比

方案实现难度效果适用场景
前端倒计时⭐ 简单弱(可绕过)个人博客
后端时间戳验证⭐⭐ 中等中小型项目
Redis + IP 限流⭐⭐⭐ 复杂大型项目
验证码(reCAPTCHA)⭐⭐⭐ 复杂很强公开网站

后端时间戳验证代码

js
// 在 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 }
    );
  }
}

逐行讲解

  1. const lastCommentTime = {}:对象存储每个用户的最后评论时间
  2. const cooldown = 30 * 1000:30 秒冷却时间(单位毫秒)
  3. now - lastTime < cooldown:距离上次评论不足 30 秒
  4. Math.ceil(...):向上取整,显示还需要等多少秒
  5. status: 429:HTTP 状态码,表示"请求过多"
  6. lastCommentTime[author] = now:成功评论后更新时间

💡 新手易错提醒:

23.6 完整评论组件(表单+列表)

概念解释

把评论表单和评论列表组合在一起,形成一个完整的评论区。用户可以看到所有评论,也可以发表新评论。

组件结构

组件职责说明
CommentSection评论区容器管理数据获取和状态
CommentForm评论表单用户输入和提交
CommentList评论列表展示所有评论

完整评论区组件

jsx
// 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>
  );
}

逐行讲解

  1. useEffect(() => { fetchComments() }, [postId]):页面加载或文章切换时自动获取评论
  2. form.author.value:直接从 form 元素取值,比 useState 更简洁
  3. form.reset():清空表单所有输入框
  4. fetchComments():提交成功后重新获取列表,新评论自动出现
  5. key={comment.id}:React 列表渲染必须提供唯一 key
  6. toLocaleString('zh-CN'):把日期转成中文格式显示

💡 新手易错提醒:

23.7 完整 API Route

完整代码(GET + POST 合并)

js
// 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 });
  }
}

使用方式

在任意页面引入评论区组件:

jsx
// 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>
  );
}

💡 新手易错提醒:

23.8 本章复盘

知识点总结

知识点核心内容关键代码
数据结构评论包含 id、author、content、postId、createdAtconst comment = { id, author, ... }
表单组件useState 管理状态,fetch 提交数据e.preventDefault()
GET 接口读取评论列表,用 postId 筛选searchParams.get('postId')
POST 接口新增评论,验证字段,返回 201await request.json()
防刷机制记录最后评论时间,30 秒冷却status: 429
完整组件表单+列表组合,useEffect 加载数据fetchComments()

从零到一的完整流程

设计数据结构 → 编写 GET API → 编写 POST API → 添加防刷 → 构建前端组件 → 测试联调

下一步学习建议

优先级主题说明
🔴 高接入数据库用 SQLite/Prisma 替代内存数组
🔴 高用户认证用 NextAuth.js 实现登录评论
🟡 中评论回复支持楼中楼嵌套回复
🟡 中富文本评论支持 Markdown 或表情
🟢 低评论审核敏感词过滤、人工审核队列

📌 本章关键收获:

  1. 评论功能 = 数据结构 + 前端表单 + 后端 API + 防刷机制
  2. Next.js API Route 用 route.js 文件,导出 GET/POST 函数
  3. request.json() 解析请求体,NextResponse.json() 返回响应
  4. 防刷最简单的方案是后端时间戳验证(30 秒冷却)
  5. 开发时用内存存储,生产环境必须用数据库

本章复盘——博客评论功能核心要点

要点说明
数据结构评论包含用户名、内容、时间戳
API RouteGET 获取评论,POST 提交评论
前端表单受控组件 + 表单验证
防刷机制时间戳验证,30秒冷却

下一章预告(第24章):我们将学习昼夜模式与毛玻璃效果——CSS 变量 + JS 切换主题,backdrop-filter 实现毛玻璃。

本文档由 zyyc-0336 编写,最后更新:2026-06-14