点开歌单就卡一下:个人站播放器的几次性能翻车与止血
从 View Transition 卡顿到 IndexedDB、构建 seed 和悬停预取——一次「小页面」里叠出来的性能账本。
歌单卡顿多半是胖 HTML + 大图列表 + 进页预拉音频叠在一起;轻量壳、IndexedDB 与构建 seed 后二次进入和冷启动都会好一截。
个人站加了个 /music:ImageKit 自托管音频和封面,前端自己画列表、歌词轴、进度条。功能做完那天还挺得意——PC 双栏、移动端胶囊底栏,氛围背景还能跟着封面糊一整页。
然后我从首页点了一下顶栏的「歌单」。
页面没崩,但也谈不上「切过去了」。主线程像被谁按住了半秒到一秒,View Transition 糊在半空,列表迟迟不出来。再切回来、再点进去,照样卡。第二次、第三次进页,歌曲本身也要转很久才「准备好」。
这不是「再加个 loading」能糊弄过去的体感问题。下面按翻车顺序记一笔:现象、误判、真正的锅,以及后来叠上去的几层方案。
先说结论:卡顿不是单一 bug#
最后拆下来,卡顿其实叠了好几层,每一层都「合理」,叠在一起就难受:
| 层 | 症状 | 根因(简化) |
|---|---|---|
| 路由切换 | 点导航瞬间掉帧 | ClientRouter / View Transition 要吃掉一份过重的 HTML + 同步 JS |
| 首屏数据 | 进页后空白很久 | 构建期把整份歌单(含歌词)打进页面,或运行时再拉一整份 JSON |
| 列表渲染 | 列表出来了仍顿 | 63 张列表封面走了 w-1400 大图;DOM / 频谱节点偏多 |
| 媒体预热 | 「加载歌曲中」很长 | 进页就给 <audio> 赋 src 并 load(),还和歌词/canplay 绑在一起 |
| 冷启动 | 清缓存后第一次最慢 | 没有任何本地命中,全程等 ImageKit |
优化也不是一锤定音,而是先止血、再换架构、再抠首访。有几招上一轮刚加完,下一轮发现方向反了,又改回去——这也写进文章里,免得以后自己忘。
第一现场:导航一切就卡#
站点用了 Astro 的 ClientRouter(View Transitions)。从别的页面点到 /music,浏览器不是整页硬刷新,而是:
- 预取 / 拉取目标文档
- 做过渡动画
- 换 DOM、跑新页面的脚本
这套机制对「轻页面」很友好。对「63 首歌 + 全量 LRC 嵌在 HTML 里」就不友好了。
当时的数据路径大致是:
构建时 fetch favorites.json → 再把每首歌的 .lrc 也拉一遍 → 整包 ResolvedPlaylist 塞进页面 → 客户端 mount 时 JSON.parse + 渲染列表 + 挂播放器体积和主线程工作量都砸在切换那一瞬间。你在别的页 hover「歌单」时,Astro 或许已经开始 prefetch 文档,但文档本身太胖,预取反而变成「提前卡」。
第一轮止血(治标)#
先做了一组不动架构的小刀:
- 导航链接加
data-astro-prefetch="hover",并统一 hover 策略 - 播放器挂载延后到 View Transition 之后、
requestIdleCallback再跑,避免和过渡抢主线程 - 曲目 meta 与歌词拆开,歌词解析延后
- 列表项去掉常驻频谱 DOM,只在当前播放曲注入
- 氛围图只预热邻近几首,而不是 63 首全预热
- 列表加一点
content-visibility,减轻离屏布局代价
体感有缓解,但没有根治。只要 HTML 里还躺着整份歌单和歌词,ClientRouter 的交换成本就在那。
这也是后来把「idle 延后挂载」再改掉的原因:治切换卡顿时延后是对的;但当你已经把页面变轻、又靠 seed 首屏出列表时,再 idle 反而白白晚几百毫秒。优化是有时效的,换了约束就要重估。
第二轮:别把仓库扛在 HTML 上#
真正见效的一刀是改数据形态:
/music 变成轻量壳。页面里不再嵌入 63 首 + 歌词正文。
客户端负责:
- 向 ImageKit 拉
music/favorites.json - 轻量 resolve(拼好音频 / 封面 URL,不预拉全部
.lrc) - 写入 IndexedDB,TTL 七天
- 渲染列表;歌词按曲懒加载,并回写缓存
加载优先级变成:
IndexedDB(≤7 天) → 构建注入的 seed(后面第三轮才有) → 悬停 / 页内预取的 Promise → 现场 fetch ImageKit过期时用 stale-while-revalidate:先把旧缓存甩到屏幕上,再在后台刷新。网络差的时候,至少不会对着空白「加载中」干瞪眼。
和第一轮对比:
| 旧 | 新 | |
|---|---|---|
| HTML | 胖(曲目 + 歌词) | 瘦壳 |
| 歌词 | 构建期全拉 | 播放时再拉 |
| 二次进入 | 仍解析大包 HTML | 多数时候读 IDB |
| ClientRouter | 交换成本高 | 交换成本明显下降 |
决策上有意接受一个代价:你在 ImageKit 改了歌单,用户端最多约一周内可能仍看到旧列表(清站点数据可强制刷新)。个人站这个 trade-off 可以接受。
第三轮:缓存命中了,为什么还是慢?#
IDB 通了之后,二次进入不再打远程 JSON,但还是有人觉得「进页后要等很久」。继续抠,发现两个很丢人的细节。
列表封面误用了展示图尺寸#
ImageKit 预设里,display 是大约 w-1400 的阅读/展示图。列表单元格却只有 48×48。
结果:六十多张「缩略图」每一张都在拉近乎全宽封面。带宽、解码、内存一起加班。
修法很直接:加 thumb 预设(约 96×96),列表走 coverThumb,舞台和大氛围图继续用 display。缓存版本升到 v2,逼一次重新写入。
进页就预拉音频#
当时为了「点播放更快」,进页会对第一首执行 audio.src = …; audio.load(),有一段时间还和 canplay、歌词展示缠在一起。
个人站的产品决策其实是:进页不自动播放。那就不该在用户还没按播放时,就去抢 CDN 上的 mp3。
现在的行为是:
- 进页:准备封面、标题、歌词(若有)
- 不给
<audio>挂 src - 用户点播放 / 点列表曲目:再请求音频
「加载歌曲中」如果还出现,更多是歌词或首帧封面,而不是整首 mp3 堵在首屏路径上。
第四轮:首次进入怎么办?#
IDB 救的是「第二次及以后」。清掉站点数据、换设备、或刚部署后的第一次访问,仍然要等网络——除非你在用户点进来之前就把数据准备好。
于是又叠了三招,专门打冷启动:
1. 构建 / SSR 注入轻量 seed#
构建时(或 SSR 时)拉一份不含歌词正文的歌单,resolve 成客户端同款结构,塞进:
<script type="application/json" data-music-playlist-seed> …轻量 playlist…</script>冷启动时:IDB 空 → 直接用 seed 渲染 → 后台再向 ImageKit 刷新。用户感知从「进页再等一轮 RTT」变成「文档到了列表就能画」。
约束很清楚:
- seed 故意不含歌词,避免再把 HTML 喂胖
- 构建环境必须能访问 ImageKit;访问失败就退回纯客户端拉取,不堵构建
2. 导航悬停 / 聚焦预取#
顶栏「歌单」在 pointerenter / focusin 时就开始 fetch(favorites.json),结果挂在 window.__MUSIC_PL_PREFETCH 上。用户从犹豫到点击的那几百毫秒,正好拿来换 RTT。
这和 Astro 的文档 prefetch 是两层东西:一个预取页面壳,一个预取歌单数据。
3. 资源 preload + 尽快挂载#
页面 <head> 里对歌单 JSON、首曲 thumb / display 封面做 rel=preload。播放器挂载去掉多余的 idle 等待,有 seed 时尽快 renderPlaylist。
现在的完整路径#
用一张更直白的图串起来:
其他页悬停「歌单」 └─ fetch favorites.json(HTTP 缓存 / 进行中 Promise)
点击进入 /music(轻量 HTML) ├─ preload:JSON + 首曲封面 ├─ 构建 seed(若有)已在文档里 └─ mountMusicPlayer(尽快) ├─ IndexedDB 命中且未过期 → 直接渲染 ├─ 否则 seed → 渲染 + 写入 IDB + 后台刷新 ├─ 否则消费悬停预取 / 现场 fetch └─ 列表 thumb 封面;不预拉音频 └─ 用户播放 → 拉 mp3;按需拉 .lrc 并回写 IDB对应到文件,大致是:
src/pages/music/index.astro— 壳、seed、preloadsrc/lib/music-cache.ts— IDB、resolve、seed / 预取消费src/scripts/music-player.ts— 渲染与播放,进页不挂音频src/components/Header.astro— 悬停预取src/lib/imagekit.ts—thumb/display分流
更细的增删改记在仓库的 docs/changelog/2026-08-music-player-ui.md,锁定决策在 docs/decisions.md。这篇博客只保留「为什么这么改」的叙事。
几条可复用的教训#
1. 先问「卡在哪一层」。
导航卡、首屏空、列表顿、点播放慢,对策完全不同。一把梭「加 loading」或「全面延后 hydrate」,很容易治好 A 弄坏 B。
2. View Transition / ClientRouter 对文档体积极敏感。
SPA 式切换省的是整页重载,不省「解析和交换一份巨型 HTML」的成本。大数据适合客户端按需拉,或构建期只打真正首屏需要的薄 seed。
3. 缓存要分层,且默认信任本地。
HTTP 缓存、IndexedDB、构建 seed、悬停预取,解决的时间尺度不一样。TTL + SWR 比「每次进页都 no-cache」更符合个人站的更新频率。
4. 媒体 URL 的尺寸是性能 bug 的高发区。
同一张封面,列表、舞台、氛围模糊底,不该共用一个 w-1400。预设多写两个字,少烧一整屏带宽。
5. 产品决策能直接减负载。
「进页不自动播放」不只是体验选择,它允许你把 mp3 移出关键路径。性能优化有时是删掉不必要的诚实。
6. 优化会过期。
为胖页面设计的 idle 挂载,在瘦页面 + seed 之后会变成负优化。每次架构一变,把上一轮的「聪明延迟」重新过一遍账单。
还没做、也不急着做的#
- 列表虚拟滚动:六十多首还在可接受范围,上几百首再考虑
- Service Worker 离线整库:个人站收益有限,复杂度偏高
- 强制「清缓存刷新歌单」的按钮:现在靠 TTL 和清站点数据就够用
如果以后曲库涨到明显卡列表滚动,优先虚拟化 DOM,而不是再把歌词塞回 HTML——那条路已经验证走过,不太想再走一遍。
写这篇的时候,歌单页已经能从别的栏目点进去而不再「愣住」。它大概永远不会变成 Spotify,但至少不该让人怀疑浏览器卡死。性能优化很少一次做对;把翻车和回滚也写下来,下次才会少踩同一个坑。
常见问题
- 为什么点「歌单」会卡一下?
- ClientRouter 要交换目标页 HTML;若把整份歌单和歌词打进文档,主线程会在切换瞬间被拖住。
- 第二次进页还会打 ImageKit 吗?
- IndexedDB 命中且未过期(默认 7 天)时不拉远程 JSON;过期则先用旧缓存再后台刷新。
- 内容里要自己拼 ImageKit 的 ?tr= 吗?
- 不用。影集和歌单封面由站点按预设自动追加;音频不要加 tr。