← Back to Blog
EN中文

让评论"活"起来:涂鸦评论系统的设计与实现

传统的博客评论系统,大多是位于文章底部的线性列表。它们整洁、有序,但也有些……无聊。评论与它们所讨论的内容在物理上是分离的,缺乏一种直接的、情境化的互动感。我一直在思考:能不能让评论系统本身也成为一种有趣的体验?

于是,一个想法诞生了:一个涂鸦式(Graffiti-style)的评论系统。在这个系统里,评论不再被禁锢于页面底部,而是像一张张便利贴,可以被"贴"在页面的任何地方,直接指向它们所评论的图片、段落或某个特定的UI元素。这不仅让互动更有趣,也为读者提供了更丰富的上下文。

本文将详细介绍我如何设计并实现这个系统,从混合存储架构到独特的锚点定位系统,再到实践中遇到的问题和未来的改进方向。

preview

架构设计:实时性与持久性的平衡

为了兼顾评论的即时反馈和低成本的持久化存储,我设计了一套"混合存储"架构。它由前端组件、后端API(Cloudflare Worker)和数据存储层三部分组成。

┌─────────────────────────────────────────────────────────────────┐
│                     前端组件 (Browser)                           │
├─────────────────────────────────────────────────────────────────┤
│  chat-widget.js    │  graffiti-wall.js    │  comments.js        │
│  (认证 + 提交)      │  (涂鸦渲染 + 拖拽)    │  (传统列表展示)      │
└─────────────┬───────────────────────┬──────────────────────────┘
              │                       │
              ▼                       ▼
┌──────────────────────────┐  ┌──────────────────────────┐
│ Cloudflare Worker API    │  │   静态 JSON 文件          │
│  - Cloudflare KV (实时)   │  │ (blog/comments/*.json)   │
│  - 身份认证              │  │ - 构建时生成              │
│  - 评论 CRUD             │  │ - 永久固化                │
└──────────────────────────┘  └──────────────────────────┘

1. 前端组件层

负责所有用户交互和渲染:

  • chat-widget.js: 核心交互入口。一个悬浮的聊天气泡,点击后展开,处理用户身份认证(邮箱OTP)和评论提交。
  • graffiti-wall.js: 涂鸦墙的核心。负责将评论数据渲染为页面上的"便利贴",并实现拖拽重定位功能。
  • comments.js: 兜底方案。对于不支持或加载失败的涂鸦墙,仍然提供一个传统的评论列表展示。

2. 后端 API 层 (Cloudflare Worker)

无服务器API,部署在全球边缘网络,提供低延迟的接口:

  • 身份认证: 处理OTP验证码的生成、发送和校验
  • 评论 CRUD: 提供评论的创建、读取接口,数据实时写入KV
  • 位置更新: 当用户拖拽评论时,接收新的位置信息并更新到KV

3. 数据存储层

混合模式是整个架构的精髓:

  • Cloudflare KV: 一个全球分布的键值存储。我用它来存放"热数据"——即用户刚刚提交、还未被固化的新评论。它的读写速度快,能提供即时的用户反馈。
  • 静态 JSON 文件: 博客部署时,通过CI/CD流程从KV中拉取近期评论,合并到项目源码的comments.json文件中。这个文件是评论的"冷数据"或"固化数据",它随着网站静态资源一同分发,加载速度极快且无需API请求。

这种架构的优势在于:新评论可以立即出现(来自KV),而绝大部分历史评论则通过静态文件高效加载(来自CDN),实现了性能、实时性和成本的最佳平衡。

核心流程剖析

1. 用户认证流程

为了降低评论门槛,我没有引入复杂的第三方登录,而是采用了一次性密码(OTP)的邮箱验证方式:

用户输入邮箱 → 后端生成6位OTP → 存入KV(5分钟TTL) → 发送邮件

用户输入验证码 → 后端校验OTP → 校验通过 → 设置HttpOnly Cookie(7天)

首次用户额外设置昵称 → 存入user:email记录

Session通过一个7天有效期的HttpOnly Cookie维护,兼顾了便利性和安全性。

2. 评论创建流程

这是涂鸦系统的核心交互:

  1. 提交: 用户通过chat-widget输入内容并提交
  2. 锚点检测: 前端JS检测视口中心附近的"锚点元素"
  3. KV存储: 将评论内容、锚点信息、相对坐标等数据发送到Worker API
  4. 即时渲染: API返回成功后,前端立即将这条新评论渲染到涂鸦墙上
// 评论提交的数据结构
{
  text: "这张照片拍得真棒!",
  pageUrl: "/gallery/",
  anchorId: "album-beijing-2024",      // 锚点元素ID
  anchorType: "gallery",                // 锚点类型
  anchorOffsetX: 45.5,                  // 相对X偏移 (0-100%)
  anchorOffsetY: 23.2,                  // 相对Y偏移 (0-100%)
  rotation: -3.5                        // 旋转角度
}

3. 数据固化流程(CI/CD)

为了让评论永久保存,我设计了一个在CI/CD流程中运行的"固化"脚本:

定时任务/手动触发

pull-comments.mjs: 使用Wrangler从KV拉取所有comment:*键

保存到 .cache/comments.json (按pageUrl分组)

merge-comments.mjs: 与已有的静态JSON合并去重

生成最终的 blog/comments/*.json 文件

提交到Git → 触发部署

7天的KV过期策略确保了只有在CI/CD流程中断超过一周的极端情况下,评论数据才可能丢失。

4. 评论加载流程

为了实现最佳加载性能,前端会并行请求静态JSON和实时KV数据:

const [staticComments, liveComments] = await Promise.all([
  fetch('/blog/comments/gallery/.json').then(r => r.json()),
  fetch('/api/comments?page=/gallery/').then(r => r.json())
]);

// 使用Map进行高效去重,以id为键
const byId = {};
staticComments.forEach(c => { byId[c.id] = c; });
liveComments.forEach(c => { byId[c.id] = c; });  // 新评论覆盖旧的

// 静态评论标记为不可编辑
staticComments.forEach(c => { byId[c.id].isOwner = false; });

这个策略确保了:

  1. 页面快速展示大部分(固化)评论
  2. 最新的评论也能被近乎实时地加载出来
  3. 即使后端API临时故障,用户依然能看到历史评论

锚点定位系统:让评论不再"漂移"

如果只是简单地用页面百分比来定位,当浏览器窗口大小变化或页面布局调整时,评论就会偏离原来的位置。为了解决这个问题,我设计了一套基于元素锚点的定位系统。

锚点类型

const ANCHOR_CONFIG = {
  anchorSelectors: {
    gallery: '.album-section[id][data-anchor-type="gallery"]',
    food: '.food-card[id][data-anchor-type="food"]',
    blog: '.post-item[id][data-anchor-type="blog"]',
    home: '[id][data-anchor-type="home"]'
  },
  pageAnchorId: 'page'  // 特殊锚点:整个页面
};

每条评论的位置由两部分定义:

  1. 锚点 (Anchor): 评论所附着的目标元素ID
  2. 相对偏移 (Relative Offset):
    • anchorOffsetX: 相对于锚点元素宽度的百分比 (0-100)
    • anchorOffsetY: 相对于锚点元素高度的百分比 (0-100)

位置计算

当渲染评论时,JS会执行以下计算:

calculateAnchorPosition(anchorId, offsetX, offsetY) {
  // 页面级锚点:直接使用百分比
  if (anchorId === 'page') {
    return { x: offsetX, y: offsetY, unit: '%' };
  }

  // 元素级锚点:计算像素位置
  const anchorEl = document.getElementById(anchorId);
  const anchorRect = anchorEl.getBoundingClientRect();
  const wallRect = this.layer.getBoundingClientRect();
  const scrollY = window.scrollY;

  const pixelX = anchorRect.left - wallRect.left + (anchorRect.width * offsetX / 100);
  const pixelY = anchorRect.top + scrollY - wallRect.top + (anchorRect.height * offsetY / 100);

  return { x: pixelX, y: pixelY, unit: 'px' };
}

拖拽时的锚点检测

在拖拽评论时,前端会实时计算评论与所有潜在锚点的距离:

findNearestAnchor(clientX, clientY) {
  const anchors = this.getAnchorElements();
  let nearest = null;
  let minDistance = 100; // snapDistance阈值

  for (const anchor of anchors) {
    // 计算到锚点边缘的距离
    const distance = this.calculateDistanceToEdge(clientX, clientY, anchor.rect);

    if (distance < minDistance) {
      minDistance = distance;
      // 计算在元素内的相对偏移
      nearest = {
        id: anchor.id,
        type: anchor.type,
        offsetX: ((clientX - anchor.rect.left) / anchor.rect.width) * 100,
        offsetY: ((clientY - anchor.rect.top) / anchor.rect.height) * 100
      };
    }
  }

  // 超出所有锚点范围时,使用页面级锚点
  if (!nearest) {
    return { id: 'page', type: 'page', offsetX: ..., offsetY: ... };
  }

  return nearest;
}

拖拽时会高亮显示最近的锚点,辅助用户精确定位。这套系统保证了即使页面响应式布局发生变化,评论也能始终附着在它应该在的位置。

更多实现细节

OTP 验证码

  • 6位纯数字,存储在KV中,5分钟有效
  • 为防止暴力破解,记录尝试次数,超过5次则需重新请求

颜色系统

为了让涂鸦墙五彩斑斓,我预设了10种柔和的"彩色铅笔"色:

const PENCIL_COLORS = [
  '#C41E3A', // 深红
  '#2E6B8A', // 钢蓝
  '#6B8E23', // 橄榄绿
  '#B5651D', // 棕褐
  '#7B4F9E', // 淡紫
  // ...
];

// 根据ID哈希确定性选择颜色
function pickColor(id) {
  let h = 0;
  for (let i = 0; i < id.length; i++) {
    h = ((h << 7) - h + id.charCodeAt(i)) | 0;
  }
  return PENCIL_COLORS[Math.abs(h) % PENCIL_COLORS.length];
}

旋转角度

为了模拟手写便利贴的随意感,每条评论在创建时会被赋予一个 -10+10 度之间的随机旋转角度。

动画效果

新评论会有一个放大弹出的动画效果,并自动滚动到其位置:

@keyframes graffiti-flash {
  0%   { opacity: 0; transform: scale(0.3); filter: blur(4px); }
  15%  { opacity: 1; transform: scale(1.5); filter: blur(0); }
  30%  { transform: scale(1.0); }
  100% { opacity: 0.5; transform: scale(1); }
}

数据一致性处理的思考

混合存储架构引入了数据一致性的挑战。

KV的最终一致性

Cloudflare KV是最终一致性的。用户A提交评论后,立即刷新页面,请求可能被路由到还未同步到该数据的边缘节点,导致评论"消失"。

解决方案:在评论成功提交后,将该评论同时存入浏览器的sessionStorage。加载时,优先从sessionStorage读取,从而保证用户在当前会话中总能看到自己刚提交的内容。

静态JSON优先

一旦评论被固化到静态JSON中,它就成为"只读"的权威来源。固化后的评论不可拖拽编辑,确保其永久性。

7天过期策略

KV中的评论7天后自动过期。这是一个GC(垃圾回收)机制,防止因CI/CD长时间故障导致未固化评论无限期堆积。

目前的局限与问题

这个系统并非完美,目前还存在一些待解决的问题:

安全问题

  • 缺少CSRF防护。虽然使用HttpOnly Cookie,但POST端点仍有风险
  • 评论提交没有速率限制,存在被机器人刷屏的风险

数据一致性

  • 拖拽重定位操作只更新KV。如果评论在下次CI/CD运行前被固化,位置将回退
  • 如果CI/CD流程中断超过7天,KV中的评论会因过期而丢失

性能问题

  • 使用MutationObserver监听整个document.body的DOM变化
  • 窗口resize时,全量重新计算所有评论的位置,缺乏节流处理

功能缺失

  • 没有删除和编辑评论的功能
  • 缺少举报和审核机制

未来的改进计划

针对上述问题,我制定了一个分阶段的改进计划:

P0 (关键):

  • 添加速率限制:对评论提交API按IP或用户ID进行速率限制
  • 实现删除功能:允许用户删除自己的评论

P1 (重要):

  • 使用Promise.allSettled替代Promise.all来加载评论,增强容错性
  • 添加错误监控:集成Sentry等监控服务

P2 (中等):

  • resize事件的监听函数进行节流处理
  • 将MutationObserver监听范围从body缩小到特定的内容区域

总结

构建这个涂鸦评论系统是一次非常有趣的探索。混合存储架构巧妙地结合了Serverless的灵活性和静态站点的性能优势。而基于元素锚点的定位系统,则真正实现了评论与内容的深度融合,创造了一种全新的互动体验。

它目前还不完美,但整个架构展现了良好的可扩展性。从一个有趣的个人项目出发,它证明了我们不必拘泥于传统的设计模式,通过一些创造性的思考,完全可以打造出更具生命力和人情味的网络产品。

希望这次分享能给你带来一些启发。欢迎在涂鸦墙上留下你的想法!