上一轮说"接下来该多写文章",结果手痒又开了新一轮改造——给博客加视觉层:Three.js 粒子首页、404 彩蛋小游戏、背景音乐播放器。这一轮的收获不是功能本身,而是三个教科书级的坑:一个让五千颗粒子凭空消失的坐标系错误,一次对无障碍设置的误读,和一颗埋在部署脚本里的暗雷。
一、粒子 Hero:字形采样 + 手写弹簧物理
需求很克制:首页右上角那个巨大的「刊」字水印,改成数千个粒子组成的形态,鼠标划过时散开、松手弹回。不要炫彩,保持黑白报刊风。
实现思路是经典的「文字→粒子」三步:
// 1. 离屏 canvas 画大字 const ctx = cv.getContext("2d")!; ctx.font = `900 ${fontSize}px "PingFang SC", sans-serif`; ctx.fillText("刊", x, y); // 2. 扫描像素,按步进间隔收集 alpha > 128 的点作为目标位置 for (let y = 0; y < h; y += gap) for (let x = 0; x < w; x += gap) if (img[(y * w + x) * 4 + 3] > 128) targets.push(x, y); // 3. 每帧:速度 += (目标-当前) × 刚度;速度 ×= 摩擦系数 vx += (tx - px) * SPRING; vx *= FRICTION;
不用物理引擎,5000 个粒子的 CPU 更新在 60fps 下毫无压力。Three.js 只负责一件事:BufferGeometry + Points 批量绘制。
坑一:粒子全部渲染到了画面外
本地 build 通过、组件无报错、部署上线——然后我自己的浏览器里什么都没有。
排查用了最直接的手段:headless Chrome 加 CDP 协议,真实执行线上页面的 JS 后检查 DOM:
// 结果:document.querySelectorAll('canvas').length === 0
canvas 压根没出现。顺着查下去发现了两处叠加的错误:
第一处,Y 轴坐标系冲突。字形采样的坐标来自 canvas(向下为正),我存目标点时写了 h - y(变成向上为正);但正交相机的可视域设的是 new THREE.OrthographicCamera(0, w, 0, -h)——可视范围是 y ∈ [-h, 0],向下为负。两边一组合,所有粒子的世界坐标都落在画面上方之外:组件每一帧都在兢兢业业地渲染,只是渲染在我永远看不见的地方。
第二处,鼠标斥力的坐标没有做同样的翻转——就算粒子可见,交互区域也是镜像错位的。
修复本身只要一行(out.push(x, -y)),但它教会我的事更值钱:两个模块各自正确,拼起来可能互相抵消。坐标系约定必须在数据产生的那一刻就统一,而不是靠下游"翻译"。
坑二:我对 prefers-reduced-motion 的误读
修完坐标系,还是有用户(包括我自己)看不到。CDP 一查,matchMedia('(prefers-reduced-motion: reduce)') 返回 true——系统开了「减弱动态效果」,而我的代码在这种情况下直接跳过了整个粒子初始化。
这是对无障碍语义的粗暴理解。「减弱动态效果」的用户多是前庭功能障碍或晕动症人群,大幅位移动画会引发真实的眩晕——规范要避开的是自发的持续运动,不是把整个视觉内容砍掉。正确的分层是:
| 用户 | 表现 |
|---|---|
| 普通 | 粒子飞入聚形 + 指针斥力交互 |
| reduced-motion | 粒子直接就位成静态字形,省略飞入动画 |
| WebGL 不可用 | 回退到原来的静态水印字 |
后来又补了一步:斥力位移由用户主动引发,通常视为可接受,于是交互对所有用户开放——只有开场动画有差异。
诊断方法论沉淀
这次排查形成了固定套路:--headless=new --dump-dom 拿真实执行后的 DOM;需要看变量和报错就连 CDP 端口用 WebSocket 发 Runtime.evaluate;WebGL 渲染路径用 --use-angle=swiftshader 软件渲染验证(headless 默认禁 GPU,会误伤)。比"让我再改改试试"快十倍。
二、404 彩蛋:把错误页面变成游乐场
顺手给 404 页塞了个 Phaser 小游戏《接住 Bug》:托盘接 🐛 和 ☕、躲 💣,速度渐增,最高分存 localStorage。
工程上只有一个决策值得一提:Phaser 通过动态 import() 只在 404 页加载,主站 bundle 一字节没涨;碰撞检测手写 AABB 而不用 Arcade Physics——几十行的事没必要拖一个物理引擎。精灵全是 emoji,零美术资源。
SEO 语义也没丢:HTTP 状态码仍是标准的 404,游戏只是内容增强。
三、暗雷:nginx 配置有两个"事实源"
最值得警惕的问题不在前端。背景音乐功能的静态路由加到服务器 nginx 上,本机 curl 验证 200——几小时后却 404 了。
翻日志发现时间线:路由失效的时刻,恰好是一次普通的网页部署。翻开部署脚本,看到了这行:
# deploy.sh 第 [3] 步 cp "$CONF_SRC" "$CONF_DST" # staging 里的 conf 覆盖线上配置
也就是说 web 每次 CI 部署都会用仓库里的 nginx 配置覆盖服务器的。而我改的是服务器上的文件——手工修改注定在下一次部署时被静默回滚。更危险的是,仓库里那份还是旧版单 server 结构,之前修过的裸域归一化配置同样随时会被抹掉(那可是刚出过事故的地方)。
修复:以定稿配置同步仓库源头,单一事实源归位。这条已经写进了项目的铁律清单——基础设施配置必须先改源头仓库,直接改运行环境等于给未来埋雷。
四、背景音乐的"开关"设计
背景音乐播放器本身没什么技术含量(原生 AudioElement + 右下角悬浮控件,挂在根 layout 实现翻页不断播),但它的开关设计我很满意:曲单是一个静态 JSON 文件,列表为空时播放器自动整体隐藏。想要音乐,传音频文件 + 往 JSON 里加一行;不想要,清空列表——不需要部署任何东西。
功能可以休眠,但基础设施常在。
收尾
四轮改造下来,这个博客有了:完整的 SEO 收录链路、加密异地备份、每日推送自动化、粒子 Hero、彩蛋游戏和求职入口。技术侧真的可以停了——剩下的坑都是写文章时才会挖出来的新坑,比如这一篇里的坐标系问题,就是我一边修一边想着"这段必须记下来"。
那就先发这篇。