「整活角」开发实录 — 从零到盲盒的完整历程
记录整活角从三面板 Grid 迭代到五池盲盒的全部过程
整活角是 The Heartless Corner 首页的一个趣味交互组件。每次刷新会随机展示猫图、猫 meme 梗图、语录、精神状态或摸鱼统计中的一种。用户可以自定义每种内容出现的概率。
核心体验一句话概括:让路过的人会心一笑,同时不喧宾夺主。
这篇文章记录整活角从构思到发布的全过程——技术选型、架构设计、遇到的坑、以及最终的解决方案。
技术栈一览
整活角分三层,每层独立选型:
本体(用户看到的部分)
纯 Vanilla JS,零依赖。通过 Astro 的 is:inline 指令注入到 BaseLayout.astro,不经打包、不经网络请求,直接出现在 HTML 里。CSS 约 780 行,BEM 命名,原生写法。
选纯 Vanilla 的核心原因:整活角只需要 DOM 操作 + fetch + 定时器,没有状态管理、路由、组件通信的复杂度。引入 React 反而会让一个 800 行脚本变成 800KB bundle。
后端(数据追踪)
Cloudflare Workers(通过 functions/ 目录) + Neon PostgreSQL。用户每次点击「换一批」静默 POST 到 /api/admin/fun/track,用 ctx.waitUntil() 确保 Worker 返回响应后仍在后台完成数据库写入,用户端零延迟。
管理面板
React 19 + Chart.js。CMS 后台用堆叠柱状图展示每个模式的 7 天趋势,4 张指标卡显示今日/累计数据。
功能架构
用户访问首页
├── 脚本检测 pathname === '/'(仅首页注入,不污染其他页面)
├── 并行预加载 5 种内容(猫图 API 竞速 + 梗图预实例化 + 语录 API + 精神状态 + 摸鱼)
├── 500ms spinner 后按权重随机选一种渲染
├── 用户点击"换一批" → 800ms 防抖 → 权重重随机 → POST 统计数据
└── Astro ViewTransition 导航后重新注入
权重系统
设置面板(齿轮按钮展开):
| 模式 | 默认权重 | 可关闭 |
|---|---|---|
| 🐱 猫猫图 | 15% | ✓ |
| 🤣 梗图 | 10% | ✓ |
| 💬 语录 | 30% | ✓ |
| 🧠 精神状态 | 30% | ✓ |
| 🐟 摸鱼数据 | 15% | ✓ |
总和始终 = 100%。调节任一滑块时其余按比例重分配,不会出现 101% 或 99% 的精度误差。
版本演进
整活角经历了 5 个大版本迭代:
v1 — 三面板 Grid
最早的设计是三个并排卡片(猫图 / 语录 / 精神状态),参考 macOS Sonoma 小组件美学,940px 宽。看起来很美,用起来很糟——手机上三列挤成一团,每个面板有独立刷新按钮,交互完全混乱。美感 > 可用性的典型反面教材。
v2 — 双模式盲盒
砍掉三面板,改成单卡片随机展示猫图或梗图。这是架构上的关键转折点——从”展示一切”变成”每次只展示一个”。加入 252 张本地猫 meme GIF,TheCatAPI + Cataas 双 API 竞速。设置面板 v1 诞生。
v3 — 五池权重
把语录、精神状态、摸鱼也纳入权重池。实现互斥调节算法(总和恒 100%)。从 localStorage v2 格式自动迁移到 v3。
v4 — 当前形态
统一的 DOM 容器,通过 renderMode() 切换内容。架构上真正干净了——增加新模式只需加一个 case + 渲染函数。
回头看
每次迭代的核心动力都是同一条原则:更少的视觉噪音,更简单的交互模型。 v1 的教训一直在提醒我——功能不是越多越好,而是越聚焦越好。
核心实现
API 竞速
猫图没有专门的”随机猫图 API”——TheCatAPI 和 Cataas 各有利弊。选哪个?两个都用,选先返回的。
var ctrl = new AbortController();
var raceDone = false;
// 两个 fetch 同时发出
fetch(THE_CAT_API, { headers: { 'x-api-key': KEY }, signal: ctrl.signal });
fetch(CATAAS_API, { signal: ctrl.signal });
// 哪个先回来就用哪个,AbortController 取消另一个
// 1.5s 都没回来 → 降级为本地猫图
这比 Promise.race 更好的地方是可以立即 abort 慢的那个,而不是等它完成再丢弃结果。
梗图预实例化
252 张猫 meme GIF 放在 /public/images/memes/。传统做法是渲染时创建 <img> 设 src,依赖 onload 事件处理可见性——但已缓存的图片 onload 会同步触发,在事件绑定之前,导致图片永远不可见。
解决方案:预加载阶段用 new Image(url) 把图片塞进浏览器缓存,渲染阶段直接 img.src = url + CSS fadeIn,不用 opcity 过渡。
GIF 掉帧
梗图 GIF 在页面滚动时帧率骤降,像幻灯片。原因是浏览器不把 GIF 放在独立合成层,滚动时降帧省资源。一行 CSS 解决:
.hwg-meme__media {
will-change: transform;
transform: translateZ(0); /* 强制新建 GPU 合成层 */
}
踩过的坑
我把 7 个比较典型的坑列出来——这些都是在实际部署后才发现,本地开发环境完全测不出来的问题。
1. 内容不可见(opacity 继承陷阱)
DOM 正常注入,CSS 正常加载,但卡片内一片空白。排查了半天发现:.hwg-content 初始 opacity: 0,renderMode() 对子元素调了 fadeIn() 但忘了对父容器调用。CSS 的 opacity 取值是祖先 x 自身——子元素 opacity:1,父容器 opacity:0,最终 = 0×1 = 0。
2. 缓存图片的竞态条件
上面提到的 onload 同步触发问题。img.src = url 在缓存命中时,onload 在 img.onload = fn 绑定之前就已经执行完。解法是不依赖 onload,直接用 CSS fadeIn。
3. 中文文件名 → 404
252 张猫 meme 上传后,带中文文件名的(如 猫猫震惊.gif)在 Cloudflare Pages 全部 404。URL 编码 (%E7%8C%AB...) 与实际文件系统路径不匹配。
全部重命名为 meme-001.gif ~ meme-252.gif,纯 ASCII 路径一劳永逸。这件事让我记住了:静态托管平台的文件名,只用 [a-z0-9_-\.]。
4. Video 标签方案回滚
尝试将 GIF 转为 <video> 以获得更好的帧率。失败了——object-fit 行为不一致、移动端 playsinline 不可靠、252 个文件转码成本太高。回滚为 <img> + GPU 合成层,仅对 .mp4 / .webm 来源的外部内容用 video。
5. 权重精度漂移
5 个默认 20% 的权重调节几轮后变成 99% 或 101%。原因是 Math.round() 浮点累积误差。修复:最后一项 = 100 - 前四项之和 精确兜底。
6. Cloudflare CDN 缓存 HTML
部署后访问仍是旧版本,F5 无效。Cloudflare CDN 默认缓存 HTML,_headers 文件需要显式声明 Cache-Control: no-cache。同时依赖 Cloudflare Pages 在 Git push 时自动 rebuild,而非手动触发。
7. Astro ViewTransition 后脚本失效
Astro 的 ViewTransition API 让页面导航像 SPA 一样平滑,但 <script is:inline> 只在首次加载执行,导航到其他页面再回来时整活角消失。通过监听 astro:page-load 事件重新注入解决。
资源清单
| 资源 | 数量 | 说明 |
|---|---|---|
| 猫 meme GIF | 252 张 | /public/images/memes/,纯 ASCII 文件名 |
| 本地猫图兜底 | 5 张 | /public/images/cats/,API 全部失败时使用 |
| 语录池 | 10 条(经典 + 轮换) | 每月自动替换 5 条非经典语录 |
| 精神状态文案 | 16 条 | 每次随机取一条,打字机逐字呈现 |
摸鱼评级
纯娱乐——每进站重置,不持久化。
| 点击数 | 称号 |
|---|---|
| < 10 | 摸鱼新手 |
| 10-29 | 初入鱼塘 |
| 30-59 | 摸鱼学徒 |
| 60-99 | 摸鱼达人 |
| 100-199 | 资深渔夫 |
| ≥ 200 | 摸鱼之神 |
最后
整活角看起来是博客里最「不正经」的部分,但它的工程复杂度并不低——从 API 竞速到 GPU 合成层优化,从 PostgreSQL 埋点到 Chart.js 趋势图。这大概就是整活的本质:用正经技术在博客里做不正经的事。
如果你对某一部分的技术细节想了解更多,或者发现了 bug,欢迎在评论区留言。