我用原生 JS 做了一个「会呼吸」的博客:Canvas 水境 + 合成音效 + 会破裂的留言气泡(vibe coding)

我用原生 JS 做了一个交互式的个人博客:Canvas 水境 + 合成音效 + 会破裂的留言气泡

摘要:本文复盘我的纯前端项目「沉浸式水境博客」------一个没有框架、没有构建、没有一张音频图片素材的个人博客。内容包括:全屏翻页与场景联动的整体设计、Canvas 实时绘制的水波/游鱼/气泡/涟漪、完全用 Web Audio 合成的 7 种音效、以及一个会把留言变成气泡送上海面再「啪」地挤破的留言板。最后记录了部署 GitHub Pages 时用户站点与项目站点的命名大坑。全文代码均可直接参考,线上地址与开源仓库见文内。

一、为什么要做一个「会呼吸」的博客

大多数个人博客长这样:顶部导航栏、中间文章列表、底部备案号。信息架构没问题,但它不像「我的地方」,更像一个 Word 文档。

我想做的是一个有空间感的个人站点:打开它就像潜进一片水域------首页是海面,有沙滩、椰子树和海鸥;往下翻页就是往下潜水,文章、作品、关于沉在「水」里;给我留言的人,留言会变成一枚气泡,从海底摇摇晃晃浮上来,到海面「啪」地挤破。

约束也给自己定死了三条:

零框架零构建------不引 React/Vue,不上 Webpack,HTML + CSS + 原生 JS 三个文件打完收工;

零素材------不用任何 mp3/wav/视频,所有音效运行时合成,所有画面运行时绘制;

内容真实------站内只放真实写过的文章和项目,不编数据撑场面。

最终效果:

线上地址:https://yoyudev.github.io/YoyuDev-IPH/

开源仓库:https://github.com/YoyuDev/YoyuDev-IPH

二、整体设计:一页一屏,翻页即潜水

站点由 5 个全屏页面组成:首页 / 文章 / 作品 / 关于 / 留言。翻页方式覆盖滚轮、触摸、键盘和右侧圆点导航,页面变换靠整屏位移 + 一段较长的缓动完成:

复制代码
.stage {
  position: relative;
  height: 100vh;
  transition: transform .95s cubic-bezier(.22, .9, .28, 1);
  will-change: transform;
}

这里有个体验上的关键决策:翻页不只是内容换了一屏,背景场景也要跟着走。我用一个 surfaceAmt 变量(1 = 完整海面,0 = 完全水下)把背景 Canvas 和翻页状态绑在一起------离开首页时 surfaceAmt 从 1 平滑过渡到 0,沙滩缓缓下沉、海鸥飞离画面、水位漫上来;回到首页再反向播放一遍。翻页 thus 变成了「潜水」,这是整个站点沉浸感的来源。

每页内容的入场也错峰出现:kicker、标题、正文、按钮分别延迟 0.05s ~ 0.62s 依次浮现,避免整屏元素同时弹出来那种「PPT 感」。

三、Canvas 水境:一个 <canvas> 画完全部背景

背景是一个 position: fixed 的全屏 Canvas,用 requestAnimationFrame 驱动,每帧重绘以下内容:

三层水波 :不同振幅、速度、透明度的正弦波叠出纵深感;

气泡 :持续从深处上浮,带轻微左右摆动;

游鱼 :一小组自主游动的鱼,路径用噪声扰动,不会走出画面;

涟漪 :用户点击任意位置产生一圈扩散的水波纹;

沙滩与椰子树 :只在首页(海面状态)绘制,岸线由 shoreAt 计算,树冠位置由 crownPos 提供,椰子挂在树冠坐标下。

可交互的场景元素有三个,全部是 Canvas 内自己算命中检测(DOM 选择器对 canvas 里的东西无能为力,只能根据点击坐标和实体位置做距离判断):

元素 点击后的行为

椰子(5 颗) 从树上掉落,落地反弹一下,播放低频「咚」

海鸥(3 只) 掉头飞反方向,扇翅频率临时加快

游鱼 停下来,原地吐一串泡泡

为了方便自动化验证(我希望每次改完场景都能回归测试),我在 JS 里暴露了一个调试钩子:

复制代码
window.__scene = { coconuts, gulls, fishes, surfaceAmt, crownPos };

测试脚本就可以合成 PointerEvent 点击坐标,然后直接读 __scene.coconutsn.st(0 = 在树上 / 1 = 掉落中 / 2 = 已落地)来断言行为,而不是靠肉眼看截图。

四、Web Audio:一个音频文件都没有的音效系统

站点有 7 种音效,全部由 Web Audio API 在运行时合成:翻页水泡声(blub)、点击(pop)、打开文章(ding)、海鸥鸣叫(gull)、入水扑通声(waterWhoosh)、椰子落地(bonk)、留言气泡破裂(bubbleBurst)。

合成的基本套路是「振荡器定音高 + 增益包络定音量走势」,以气泡破裂声为例(简化版):

复制代码
function bubbleBurst() {
  const t = actx.currentTime;
  // ① 高频正弦急速下滑:那声「啪」
  const o = actx.createOscillator(), g = actx.createGain();
  o.type = 'sine';
  o.frequency.setValueAtTime(1150, t);
  o.frequency.exponentialRampToValueAtTime(420, t + .08);
  g.gain.setValueAtTime(.0001, t);
  g.gain.exponentialRampToValueAtTime(.18, t + .004);   // 快速起音
  g.gain.exponentialRampToValueAtTime(.0001, t + .12);  // 短促收尾
  o.connect(g); g.connect(actx.destination);
  o.start(t); o.stop(t + .12);
// ② 60ms 高通白噪声:水膜破碎的质感
  const dur = .06;
  const buf = actx.createBuffer(1, actx.sampleRate * dur, actx.sampleRate);
  const data = buf.getChannelData(0);
  for (let i = 0; i < data.length; i++) {
    data[i] = (Math.random() * 2 - 1) * (1 - i / data.length); // 线性衰减
  }
  const src = actx.createBufferSource(); src.buffer = buf;
  const hp = actx.createBiquadFilter();
  hp.type = 'highpass'; hp.frequency.value = 1800;
  src.connect(hp); hp.connect(actx.destination);
  src.start(t);
}

单个正弦波听起来像「哔」,但下滑的音高 + 极短的包络 + 一点噪声叠起来,人耳就会解读成「什么东西破了」。其余音效同理:海鸥叫是带颤音的下滑哨音重复两次,椰子落地是低频正弦快速衰减,入水声是低频扑通 + 一串上浮的短泡泡声。

两个必须处理的浏览器现实:

自动播放限制:Chrome 等浏览器禁止页面在用户交互前出声,所以所有音效先经 ensureAudio() 门控------首次 pointerdown/keydown 时才创建 AudioContext,之前静默丢弃;

音效开关:右上角常驻开关按钮,关掉后所有合成函数第一行就 return,不浪费任何计算。

五、留言板:让每条留言变成气泡,浮上海面再挤破

这是我最喜欢的一个模块。需求一句话:「留言不要列表显示,要像水泡一样从海底浮到海面,然后消失。」

5.1 上浮:CSS 变量随机化 + 关键帧摆动

每条留言提交后生成一枚半透明气泡 DOM(圆角 + 内高光渐变 + backdrop-filter 毛玻璃),三个随机量用 CSS 变量注入,避免所有气泡整整齐齐排队上浮的假感:

复制代码
b.style.setProperty('--x', rand(6, 58) + '%');      // 横向出生点
b.style.setProperty('--dur', rand(9, 15) + 's');    // 上浮总时长
b.style.setProperty('--drift', rand(18, 42) + 'px'); // 左右摆动幅度

上浮动画的关键帧------注意摆动方向在中途交替,模拟水流:

复制代码
@keyframes bubbleUp {
  0%   { transform: translate(0, 0) scale(.86); opacity: 0; }
  9%   { opacity: 1; }
  25%  { transform: translate(var(--drift, 28px), -24vh) scale(1); }
  50%  { transform: translate(calc(var(--drift, 28px) * -1), -48vh) scale(1); }
  75%  { transform: translate(var(--drift, 28px), -72vh) scale(1); }
  100% { transform: translate(0, -92vh) scale(1); opacity: 1; }
}

5.2 破裂:一段动画结束触发另一段

第一版我把上浮终点写成了 translateY(-118vh) + opacity: 0,结果气泡是漂出屏幕外才消失的------「破裂」根本看不见。修正思路:让气泡停在屏幕内的海面高度(-92vh)保持可见,上浮动画结束后再切一段 0.35s 的「胀破」动画:

复制代码
@keyframes bubblePop {
  0%   { transform: translate(0, -92vh) scale(1);   opacity: 1; }
  40%  { transform: translate(0, -92vh) scale(1.26); opacity: .62; }
  100% { transform: translate(0, -92vh) scale(1.5);  opacity: 0; }
}

两段动画的交接用 animationend 事件按动画名分发:

复制代码
b.addEventListener('animationend', e => {
  if (e.animationName === 'bubbleUp') {        // 浮到海面:破裂
    bubbleBurst();                              // 播破裂音效
    spawnDroplets(b);                           // 迸出 7 颗小水珠
    b.style.animationName = 'bubblePop';
    b.style.animationDuration = '.35s';
    b.style.animationTimingFunction = 'cubic-bezier(.2, .8, .3, 1)';
  } else {                                     // bubblePop 结束:移除
    b.remove();
  }
});

水珠是 7 个 6px 的径向渐变小圆点,随机角度和距离向外飞散后自清理。这样一条留言的完整生命是:提交 → 海底出生 → 摇摆上浮 9~15 秒 → 海面胀破 → 水珠飞散 → DOM 移除,全程无内存残留。

5.3 持久化与诚实的边界

留言和点赞保存在 localStorage(键 yoyu-messages,上限 40 条),进入留言页时历史留言会按 1.1s 间隔依次「放归」成气泡。但必须说清楚边界:这是纯静态站,没有后端,所以留言只存在于访客自己的浏览器里,不同访客互不可见,点赞数也不是全局统计。我在代码顶部预留了接口常量,日后接后端或 Giscus 时改一行即可:

复制代码
const REPO_URL = 'https://github.com/YoyuDev/YoyuDev-IPH';  // 头部 GitHub 图标指向
const MESSAGES_API = '';  // 留言接口地址;留空 = 仅浏览器本地存储

另外 localStorage 在某些沙盒环境(如嵌套 iframe、隐私模式策略)下读写会直接抛 SecurityError,所以统一包了一层:

复制代码
const store = {
  get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
  set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
};

六、部署 GitHub Pages:用户站点 vs 项目站点的坑

站点是纯静态,部署 GitHub Pages 本来只需要三步,但我差点在这里翻车。

坑:仓库名会决定你的站点地址。 GitHub Pages 有两类站点:

用户站点:仓库名必须是 <username>.github.io,部署后地址是根路径 https://<username>.github.io/;

项目站点:任意其他仓库名,部署后地址带子路径 https://<username>.github.io/<repo>/。

我的作品集已经占用了用户站点仓库 yoyudev.github.io。如果这个博客也叫同样的名字,push 上去会直接覆盖作品集首页。所以博客作为项目站点部署,最终地址是 https://yoyudev.github.io/YoyuDev-IPH/ 。

这一步能无痛过关,得益于一个早就养成的习惯:HTML 里所有资源全部用相对路径(assets/blog.css、assets/avatar.png),放到子路径下不会出现资源 404。

完整部署流程:

复制代码
# 1. 建仓库(GitHub 网页上建,Public,不要勾选 README/.gitignore)
# 2. 本地初始化并推送
cd YoyuDev-IPH
git init -b main
echo ".idea/" > .gitignore
git add .
git commit -m "feat: 一只游鱼沉浸式博客"
git remote add origin https://github.com/YoyuDev/YoyuDev-IPH.git
git push -u origin main
3. 仓库 Settings → Pages → Source 选 "Deploy from a branch"
Branch 选 main + / (root) → Save
4. 等 1~2 分钟,Actions 里跑完 "pages build and deployment" 即上线

之后每次更新就是 git add . && git commit -m "update" && git push,Pages 自动重建。

七、踩坑小结

动画「消失」≠ 动画「可见地结束」。气泡第一版淡出发生在屏幕外,用户看到的只是「没有破裂」。做生命周期类动画时,终点状态一定要停在视口内,再接续下一段动画;

localStorage 不是处处可用。沙盒 iframe / 部分隐私模式下会抛异常,try/catch 封装应该是默认动作;

开发期缓存坑。改了 JS/CSS 刷新没变化,十有八九是浏览器缓存,本地静态服务器记得加 Cache-Control: no-store;

音频必须等手势。AudioContext 在首次用户交互前创建/播放会被浏览器拦掉,把 ensureAudio() 挂在首次 pointerdown 上;

Canvas 内的交互要自己算命中,但配合 window.__scene 这类调试钩子,反而比 DOM 交互更容易写自动化断言;

仓库名即地址。<user>.github.io 是用户站点专用名,被占用后重名会互相覆盖,普通项目一律用项目站点 + 相对路径。

八、写在最后

这个站总共三个源文件:index.html、blog.css、blog.js,没有框架、没有构建、没有素材文件,但给了每一个访问者一个明确的记忆点------「那个留言会变成气泡浮上去的博客」。

对我自己而言,它也是一次把玩浏览器底层能力的机会:Canvas 逐帧绘制、Web Audio 声音合成、CSS 关键帧接力、GitHub Pages 部署,每个点单独都不难,拼成一个完整且克制的作品才是有意思的地方。

在线逛逛:https://yoyudev.github.io/YoyuDev-IPH/(记得开声音,点一下椰子)

源码:https://github.com/YoyuDev/YoyuDev-IPH

完整作品集:https://yoyudev.github.io/

最后求Star~~~ 求Star ~~~

相关推荐
山间小僧1 小时前
Agent Memory(三)团队记忆:以 TencentDB Agent Memory 为例
后端·agent
只睡四小时2 小时前
Local-First 数据契约:一张表装下 4 类数据
前端·javascript·架构设计·local-first·数据契约
云浪13 小时前
Go 并发实战:从零实现一个高并发安全的任务调度器
javascript·后端·go
诸神缄默不语15 小时前
如何用AI Agent连接Lighthouse(腾讯云轻量应用服务器)MCP实现云服务器智能运维
agent
威哥爱编程15 小时前
HarmonyOS 7 Agent 开发实战 07:多 Agent 任务分解与反思
agent·harmonyos
威哥爱编程16 小时前
HarmonyOS 7 Agent 开发实战 06:Agent 记忆与上下文管理
agent·harmonyos
进击的明明16 小时前
当AI开始“边想边干”:深入聊聊ReAct,以及如何用Python手搓一个简单Agent
python·面试·agent
qinglongcn16 小时前
为什么 Agent 循环是 for{} 而不是 DAG——架构选择的深层原因
agent·ai编程
小七-七牛开发者17 小时前
周一上线 | DeepSeek 开始卷“许愿式编程”,Agent 手机来了,Character.AI 申请破产
ai·大模型·agent·claude·codex·deepseek·ai coding