
我用原生 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 ~~~