← All Posts

点开歌单就卡一下:个人站播放器的几次性能翻车与止血

从 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,浏览器不是整页硬刷新,而是:

  1. 预取 / 拉取目标文档
  2. 做过渡动画
  3. 换 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 首 + 歌词正文。

客户端负责:

  1. 向 ImageKit 拉 music/favorites.json
  2. 轻量 resolve(拼好音频 / 封面 URL,预拉全部 .lrc
  3. 写入 IndexedDB,TTL 七天
  4. 渲染列表;歌词按曲懒加载,并回写缓存

加载优先级变成:

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、preload
  • src/lib/music-cache.ts — IDB、resolve、seed / 预取消费
  • src/scripts/music-player.ts — 渲染与播放,进页不挂音频
  • src/components/Header.astro — 悬停预取
  • src/lib/imagekit.tsthumb / 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。