让评论"活"起来:涂鸦评论系统的设计与实现
传统的博客评论系统,大多是位于文章底部的线性列表。它们整洁、有序,但也有些……无聊。评论与它们所讨论的内容在物理上是分离的,缺乏一种直接的、情境化的互动感。我一直在思考:能不能让评论系统本身也成为一种有趣的体验?
于是,一个想法诞生了:一个涂鸦式(Graffiti-style)的评论系统。在这个系统里,评论不再被禁锢于页面底部,而是像一张张便利贴,可以被"贴"在页面的任何地方,直接指向它们所评论的图片、段落或某个特定的UI元素。这不仅让互动更有趣,也为读者提供了更丰富的上下文。
本文将详细介绍我如何设计并实现这个系统,从混合存储架构到独特的锚点定位系统,再到实践中遇到的问题和未来的改进方向。

架构设计:实时性与持久性的平衡
为了兼顾评论的即时反馈和低成本的持久化存储,我设计了一套"混合存储"架构。它由前端组件、后端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. 评论创建流程
这是涂鸦系统的核心交互:
- 提交: 用户通过
chat-widget输入内容并提交 - 锚点检测: 前端JS检测视口中心附近的"锚点元素"
- KV存储: 将评论内容、锚点信息、相对坐标等数据发送到Worker API
- 即时渲染: 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; });
这个策略确保了:
- 页面快速展示大部分(固化)评论
- 最新的评论也能被近乎实时地加载出来
- 即使后端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' // 特殊锚点:整个页面
};
每条评论的位置由两部分定义:
- 锚点 (Anchor): 评论所附着的目标元素ID
- 相对偏移 (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的灵活性和静态站点的性能优势。而基于元素锚点的定位系统,则真正实现了评论与内容的深度融合,创造了一种全新的互动体验。
它目前还不完美,但整个架构展现了良好的可扩展性。从一个有趣的个人项目出发,它证明了我们不必拘泥于传统的设计模式,通过一些创造性的思考,完全可以打造出更具生命力和人情味的网络产品。
希望这次分享能给你带来一些启发。欢迎在涂鸦墙上留下你的想法!