小站的背景一直太"干净"了,干净得像一间还没布置的出租屋。这轮给它加了一层会呼吸的星海——纯前端、不依赖任何后端,也不需要构建期的额外产物。

三条纪律

粒子背景最怕两件事:抢戏,和费电。所以这片星海有三条纪律:

  1. 低透明度:星星的存在感来自"偶尔扫到",而不是"一直闪耀",桌面端整层 opacity: .5,移动端再降到 .35
  2. 相位错开:每颗星有独立的闪烁相位与速度,避免整齐划一的假感
  3. 密度降级:粒子数按视口宽度折算,prefers-reduced-motion 时完全静态化
const alpha = baseAlpha * (0.5 + 0.5 * Math.sin(phase + t * 0.0004));

一行正弦,就是整片星海的呼吸。密度是桌面 width / 12、移动 width / 22,半径 0.6 ~ 2.2,基础透明度 0.25 ~ 0.8;另外还藏了一点私货——半径大于 1.8 的星星偶尔会闪一下十字星辉:

if (s.r > 1.8 && Math.sin(s.phase * 2.3) > 0.92) {
  ctx.fillRect(s.x - s.r * 3, s.y - 0.4, s.r * 6, 0.8);
  ctx.fillRect(s.x - 0.4, s.y - s.r * 3, 0.8, s.r * 6);
}

正文必须比星星重要

氛围层最容易犯的错,是透过卡片浮在文字后面,读久了眼睛累。解法很朴素:把内容层整体抬到粒子之上。

.prose,
.expressive-code {
  position: relative;
  z-index: 1;
}

于是星星只在页边、页头与卡片间隙里闪,正文阅读区始终是干净的。

Astro 特有的坑:swup 切页

静态站用了 swup 做无刷新切页,容器会被整体重建。如果每次都新建一个 canvas 循环,几页之后就会有七八个 requestAnimationFrame 在后台空转。

处理方式是让实例表挂在 window 上,切页事件到来时先回收已经脱离文档的实例,再补挂新的:

for (let i = fields.length - 1; i >= 0; i--) {
  if (!fields[i].canvas.isConnected) {
    fields[i].destroy();
    fields.splice(i, 1);
  }
}
mountAll();

验证办法很直接:连点四五篇文章,回到首页,检查 window.__starParticleFields.length 是不是仍然只有 1。实测是 1,控制台零报错。

动效的功力不在于加了多少,而在于它安静下来时,页面依然好读。