返回首页

「整活角」开发实录 — 从零到盲盒的完整历程

记录整活角从三面板 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: 0renderMode() 对子元素调了 fadeIn() 但忘了对父容器调用。CSS 的 opacity 取值是祖先 x 自身——子元素 opacity:1,父容器 opacity:0,最终 = 0×1 = 0。

2. 缓存图片的竞态条件

上面提到的 onload 同步触发问题。img.src = url 在缓存命中时,onloadimg.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 GIF252 张/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,欢迎在评论区留言。