“HTML5 本地音乐播放器”这个题目,看上去像是个周末练手的小项目,但我前后改了四五版,最后一版才敢说“能日常用”。原因很简单:它踩的坑几乎全在细节里——本地文件怎么读、Blob URL 什么时候释放、切歌时 play() 抛出的异常怎么处理、进度条拖动的时候时间为什么跳。这些问题随便一个没处理好,用起来就是“能响但不好用”。这篇文章就把一个纯前端、零依赖、双击 html 就能跑的本地音乐播放器拆开讲清楚,从需求判断、架构取舍到具体代码实现,再到我自己实际用了两个月之后总结的几个坑。适合写过一点 JavaScript、想搞清楚 Audio API 和 File API 到底怎么配合的人,也适合完全没碰过前端音频、想抄一份能直接用的代码的人。
1. 从“本地文件被锁在浏览器外”说起:这个播放器到底要解决什么
1.1 一个真实使用场景引出的技术选型
我最早做这个东西的动机特别朴素:手头有一堆下载下来、命名混乱的音频文件,散在几个文件夹里,想临时听一下,又不想为一个临时需求装一个完整的桌面播放器软件。就想写个网页,双击打开,选几个文件,能顺着听、能拖进度、能调音量就行。
这个需求听起来简单,但它把问题问得很清楚:我需要的是“浏览器直接读本地磁盘上的文件”,而不是“从网络下载音频再播放”。这两件事在实现上完全不同。后者给一个 URL 交给<audio src="...">就完事了;前者要先拿到File对象,再想办法把它变成一个audio元素认得的地址。
HTML5 里正好有一对组合能解决这件事:<input type="file">负责让用户主动把文件“交”给页面,URL.createObjectURL()负责把File对象包装成一个浏览器内部可访问的临时地址。这两个东西配合起来,就实现了“不经过服务器、不经过上传、纯本地播放”。
我后来把这个判断写在了项目注释第一行:这个播放器不是音频解码器,它只是一个“文件选择器 + 播放控制器”的薄壳。想清楚这一点,后面所有取舍都有了依据——解码、缓冲、设备切换全部交给浏览器内核,我只管状态和交互。这也意味着性能上限基本由浏览器决定,我的工作重点应该放在“交互不出错”上,而不是“怎么把音频播得更快”。
1.2 Audio 元素和 File API 拼起来的能力边界
在动手写第一行代码之前,我列了一张表,把“浏览器能给什么”和“浏览器不给什么”分清楚。这一步很多人会跳过,结果写到一半才发现某个需求根本做不到,白折腾。
| 能力 | 是否可行 | 关键依赖 | 备注 |
|---|---|---|---|
| 读取本地音频文件内容 | 可行 | File对象 | 必须由用户主动选择,不能自动扫描目录 |
| 播放本地音频 | 可行 | URL.createObjectURL()+<audio> | 地址仅在当前页面会话有效 |
| 获取音频时长 | 可行 | loadedmetadata事件 | 需要先加载元数据,不是同步可得 |
| 获取内嵌封面、歌手、专辑 | 部分可行 | 手动解析文件头 | 需要自己写解析逻辑,或用第三方库 |
| 记住上次播放位置 | 可行 | localStorage | 只能记位置,不能记住文件本身 |
| 刷新后恢复播放列表 | 部分可行 | 文件名 + 重新选择 | 文件内容无法跨会话持久化 |
| 修改音频文件的标签信息 | 不可行 | — | 浏览器没有写入本地文件的权限 |
| 后台播放、系统媒体控制 | 部分可行 | MediaSession API | 兼容性和平台行为差异较大 |
这张表最有价值的一行是“刷新后恢复播放列表”。很多人第一反应是“存localStorage不就行了”,但localStorage只能存字符串,而且是几 MB 级别的容量,装不下一个音频文件。所以真实可行的方案只有两种:要么刷新后让用户重新选一次文件(体验差但简单可靠),要么用 IndexedDB 把Blob存进去(技术上可行,但大文件会撑爆配额,而且写入本身很慢)。
我最后的做法是“折中”:localStorage里只存文件名列表和每首歌的播放进度,刷新之后显示一个“检测到上次的播放列表,请重新选择文件”的提示,用户重新选中同名文件后,进度自动恢复。这个体验不算完美,但比“什么都忘了”强很多,而且实现成本极低。
这里有一个容易被忽略的判断:不要为了“看起来更完整”去做 IndexedDB 存 Blob。音频文件动辄几 MB 到几十 MB,浏览器给 IndexedDB 的配额虽然不小,但用户清理缓存时会被一起清掉,而且写入过程会明显卡顿。对于“临时听一下”这个场景,投入产出比极低。
2. 选文件这一步就定死了架构:input[type=file] 与 Blob URL 的处理
2.1 multiple、accept 和那个必须清空的 value
文件选择的 HTML 部分简单到只有一行,但这一行里有三个属性值得说清楚:
<input type="file" id="picker" accept="audio/*,.mp3,.flac,.m4a,.wav,.ogg" multiple hidden> <label for="picker" class="btn">选择本地音乐</label>multiple是必须的,谁也不想一首一首选。accept有两个作用:一是让系统文件对话框默认过滤出音频文件,减少误选;二是给用户一个心理预期。但要注意,accept只是“建议”,不是“强制”——用户完全可以在对话框里手动切换成“所有文件”,选中一个.txt。所以后面必须做类型校验,不能假定进来的都是音频。
我用的是accept="audio/*"加几个常见扩展名的组合写法。原因是一些系统对 MIME 类型的识别不准,尤其是.flac和.m4a这种,光写audio/*有时不会在过滤器里显示出来,补上扩展名更保险。
hidden加<label for>的组合是我推荐的做法。直接用<input type="file">的默认样式,在不同浏览器里长得完全不一样,而且在移动端会显示成“选择文件”加一坨文字,很丑。用 label 关联之后,可以随便给 label 加样式,点击行为完全一致。
然后是最关键的一行,很多人第一次写都会漏掉:
picker.addEventListener('change', (e) => { const files = Array.from(e.target.files); // ...处理文件 e.target.value = ''; // 必须清空 });为什么要清空?因为change事件的触发条件是“选中的文件发生了变化”。如果用户第一次选了 A 目录下的三首歌,听完了想重新选同样的三首歌,此时input.value还是那三个文件,浏览器认为“没变化”,change就不会触发,用户会觉得“点了没反应”。
清空value之后,下一次选择无论选什么,都算“从空到有”,事件一定触发。这个坑我在早期版本里被反馈过两次,加上这一行之后彻底消失。
2.2 createObjectURL 与 FileReader 的取舍
拿到File对象之后,怎么让它变成audio能播的东西?常见的有两条路:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| Blob URL | URL.createObjectURL(file) | 几乎零开销,大文件也瞬间完成 | 必须手动释放,否则内存泄漏 |
| Data URL | FileReader.readAsDataURL(file) | 得到一个自包含的字符串 | 需要完整读入并 base64 编码,内存翻倍,大文件明显卡顿 |
我一开始图省事用的是FileReader,因为网上很多示例都是这么写的。结果选了一个 40MB 左右的音频文件,页面直接卡住两秒多,内存占用肉眼可见地涨。原因很清楚:readAsDataURL要把整个文件读进内存,再 base64 编码,编码后体积还会膨胀约三分之一。对于一个只需要“播一下”的场景,这完全是浪费。
换成createObjectURL之后,选文件是瞬间完成的,因为浏览器根本没有读内容,只是登记了一下“这个 Blob 对应这个内部地址”,真正的读取发生在audio开始加载的时候,而且是流式的。这个差异在大文件上非常明显。
所以结论很明确:音频、视频这类大二进制文件,一律用 Blob URL,不要用 Data URL。Data URL 适合的场景是几 KB 的小图片或者需要嵌入到 CSS 里的图标。
2.3 Blob URL 的生命周期管理
createObjectURL有一个非常讨厌的特性:它创建出来的地址会一直占用着对应的文件资源,直到页面关闭或者你主动 revoke。也就是说,如果你每次选文件都创建新的 URL,却从不释放旧的,那么用户反复选几次之后,内存里会堆积一堆已经没用的 Blob。
在一个“选一次听一天”的播放器里,这可能不明显。但如果播放列表支持删除单曲,这个问题就会立刻暴露——删掉的歌,它的 Blob 还挂在内存里。
我的处理方式很直接,给每条 track 记录它的 URL,在移除的时候统一释放:
function removeTrack(id) { const idx = tracks.findIndex(t => t.id === id); if (idx === -1) return; const [removed] = tracks.splice(idx, 1); URL.revokeObjectURL(removed.url); // 关键 if (idx === currentIndex) { audio.pause(); audio.removeAttribute('src'); audio.load(); // 断开与旧源的关联 } renderList(); }audio.removeAttribute('src')后面那行audio.load()也很关键。只把src置空,某些浏览器会保留内部的缓冲数据和解码器状态;调一次load()会强制重新走一遍资源选择流程,把旧的连接彻底断开。这个小细节在反复切歌几百次之后,内存曲线会明显不一样。
经验提示:如果你要做“清空播放列表”的功能,记得遍历所有 track 逐个 revoke,而不是只清数组。数组清空只是让引用消失,Blob 本身要等垃圾回收,而浏览器对 Blob 的回收策略并不积极。
3. 把 Audio 元素当成状态机来用:播放、暂停、切歌的竞态处理
3.1 几个必须搞清楚的属性
<audio>元素暴露的属性不少,但真正天天要用的就那么几个,我整理成了一张对照表,写代码的时候直接查:
| 属性 / 方法 | 含义 | 使用注意 |
|---|---|---|
paused | 当前是否暂停 | 只读,不能直接赋值改状态 |
currentTime | 当前播放位置(秒) | 可读写,赋值即为 seek |
duration | 总时长(秒) | 未加载完时是NaN,不要直接参与计算 |
volume | 音量,0 到 1 | 超出范围会抛异常 |
muted | 是否静音 | 和volume独立,静音不改volume值 |
readyState | 资源就绪程度,0 到 4 | 判断能否 seek 的重要依据 |
play() | 开始播放 | 返回 Promise,必须处理 |
load() | 重新加载资源 | 重置状态,会触发一系列事件 |
duration是NaN这件事我一开始没注意,导致进度条在元数据加载完成之前,宽度算出来是NaN%,直接导致样式错乱。后来加了一个保护:
function safeDuration() { return Number.isFinite(audio.duration) ? audio.duration : 0; }readyState也值得单独提一句。它的取值含义是:0 表示没有任何信息,1 表示有元数据,2 表示有当前帧数据,3 表示有足够未来数据,4 表示数据充足。想在拖动进度条的时候做“能不能 seek”的判断,readyState >= 1是最低门槛。低于这个值,你设置currentTime会被忽略,而且不报错,非常容易让人困惑。
3.2 play() 返回的 Promise 与自动播放策略
现代浏览器里,audio.play()返回的是一个 Promise。这不是为了好看,而是因为浏览器会拦截没有用户手势的自动播放。如果页面一加载就想自动播,play()会 reject,抛出一个NotAllowedError。
这个机制对本地播放器影响不大,因为播放动作总是由用户点击触发的。但有一种情况会踩到:切歌之后自动播放下一首。如果这个“自动”是紧接着用户点击触发的(比如点了“下一首”按钮),那没问题,仍然算用户手势的延续。但如果是从ended事件里触发的自动连播,某些浏览器会认为已经脱离了用户手势上下文。
我实测下来的情况是:只要用户在这次会话里有过一次明确的播放操作,后续ended触发的自动连播基本都能成功。但为了保险,我还是把play()的异常都接住了:
async function safePlay() { try { await audio.play(); } catch (err) { if (err.name === 'AbortError') return; // 被新的一次加载打断,正常 if (err.name === 'NotAllowedError') { showHint('需要点击一次播放按钮激活音频'); return; } console.warn('播放失败', err); } }AbortError这个分支特别重要,下一节展开说。
3.3 切歌竞态与 AbortError
这是我在这个项目里踩得最深的一个坑,值得完整讲一遍。
场景是这样的:用户快速连点“下一首”,比如一秒内点了三次。第一次点击触发play(),音频开始加载;还没加载完,第二次点击把src换掉了;此时第一次的play()返回的 Promise 会以AbortErrorreject,因为它的加载过程被src的变更打断了。
如果不处理这个 reject,控制台会刷出一堆未捕获的 Promise 异常。更糟的是,如果代码里在catch里做了“播放失败”的提示,用户会看到三次“播放失败”的红字,但实际上音乐正在正常播放。
正确的处理方式是:AbortError直接忽略,因为它代表“这次播放请求已经被更新的一次覆盖了”,属于正常流程。
let loadToken = 0; async function playIndex(index) { const token = ++loadToken; const track = tracks[index]; currentIndex = index; audio.src = track.url; audio.load(); try { await audio.play(); } catch (err) { if (err.name === 'AbortError') return; if (token !== loadToken) return; // 已经被后续操作覆盖 showHint('这首好像播不了'); } }loadToken这个自增计数器是我后来加的保险。有些浏览器在竞态下不一定抛AbortError,而是抛别的错误,此时用 token 判断“这次请求是不是还有效”,可以避免误报。
还有一个相关的坑:切歌时要不要先pause()?我的建议是要。虽然直接换src也会停止当前播放,但先pause()可以让状态变更更明确,同时避免某些浏览器在切换瞬间把两首歌的音频混在一起(我在一个旧版本上确实遇到过几百毫秒的重叠)。顺序是pause()→ 换src→load()→play()。
4. 播放列表的数据结构:一条 track 该存哪些字段
4.1 track 对象字段设计
数据结构设计得好,后面所有功能都好写。我最终的 track 对象是这样:
{ id: crypto.randomUUID(), // 唯一标识,用于增删查找 name: 'xxx', // 展示用标题,从文件名解析 filename: 'xxx.mp3', // 原始文件名,用于进度恢复 file: File, // 原始 File 对象 url: 'blob:...', // Blob URL duration: 218.4, // 时长,元数据加载后填充 failed: false, // 是否加载失败 }几个设计决定值得解释:
为什么用crypto.randomUUID()而不是数组下标做 id?因为数组下标在删除元素之后会变。如果用下标做 key,删掉第二首之后,原来的第三首下标变成了 2,React 或手写 DOM 更新会认为“第二首变了”,导致 UI 状态错乱、播放位置串。用一个稳定 id 就没这个问题。crypto.randomUUID()在主流浏览器里都可用,而且在非 HTTPS 的本地文件环境下也能用(这点很多人会踩坑,误以为只有安全上下文才支持)。
为什么同时存name和filename?name是给人看的,去掉扩展名、去掉开头的曲目编号;filename是给程序用的,恢复播放进度的时候按原始文件名匹配,比按name匹配准确得多。
failed字段是必需的。有些文件虽然扩展名是.mp3,但实际编码浏览器不支持(比如某些特殊编码的 wav),加载会触发error事件。如果不标记,用户点了没反应,也不知道为什么。标记之后在列表里显示成灰色,鼠标悬停给出提示,体验好很多。
4.2 顺序、单曲、随机三种模式的实现差异
播放模式看起来只是三个 if 分支,但实现上有细微差别,尤其是随机模式。
| 模式 | 下一首逻辑 | 容易出错的地方 |
|---|---|---|
| 顺序播放 | (current + 1) % length | 最后一首播完要不要循环,要明确 |
| 单曲循环 | current不变,currentTime = 0重播 | 不能重新设置src,否则会重新加载 |
| 随机播放 | 从剩余曲目里随机取一个 | 要记录历史,否则“上一首”没意义 |
单曲循环这里有个实现细节:很多人会写成“重新调用playIndex(current)”,结果每次循环都重新加载一遍文件,卡顿感明显。正确做法是监听ended事件,直接audio.currentTime = 0; audio.play(),不碰src:
audio.addEventListener('ended', () => { if (mode === 'single') { audio.currentTime = 0; safePlay(); return; } const next = pickNext(); if (next !== -1) playIndex(next); });随机模式我用了“洗牌加游标”的方案,而不是每次真随机。原因是真随机在歌少的时候会反复播同一首,用户体验很差。洗牌之后顺序播放,播完一轮再重新洗,这样每一轮里每首歌都恰好出现一次。这个做法在音乐播放器里很常见,实现也简单:维护一个打乱的下标数组,加一个游标指针。
4.3 列表渲染:别每次都重建 DOM
播放列表的渲染,最省事的写法是每次数据变化就清空容器重新innerHTML。一开始我也这么干,直到列表里塞了三百多首歌,每次切歌都重新渲染一遍,明显能感觉到卡顿。
后来改成了“初次全量渲染 + 后续增量更新”。具体做法是:渲染时为每个列表项保存一个 DOM 引用到track.el,切歌时只改两个元素的 class:
function markCurrent(newIndex) { const prev = tracks[currentIndex]?.el; const next = tracks[newIndex]?.el; prev?.classList.remove('playing'); next?.classList.add('playing'); next?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }scrollIntoView加block: 'nearest'很重要。不加的话,列表很长时,切歌会把整个页面滚动一下,非常讨厌。加了这个参数,只有在目标不可见时才会滚动,而且是最小幅度滚动。
behavior: 'smooth'属于锦上添花,如果用户连续快速切歌,平滑滚动会有跟不上的感觉,可以改成auto。我后来就改回了auto,快速切歌时更跟手。
5. 进度条、音量、时间文本:三个最容易写出 bug 的控件
5.1 timeupdate 的刷新频率问题
<audio>原生提供了timeupdate事件,用来通知当前播放位置变化。这个事件用起来很顺手,但它有一个致命特性:触发频率由浏览器决定,大约是每秒 4 次。
每秒 4 次意味着进度条每 250ms 才动一下。对于一个跨度几百像素的进度条,这个刷新率是能看出“跳格”的。尤其是在宽屏上,每次跳动能有十几个像素,看起来很不顺滑。
解决方式是改用requestAnimationFrame驱动:
let rafId = null; function startProgressLoop() { cancelAnimationFrame(rafId); const tick = () => { if (!audio.paused) { renderProgress(audio.currentTime, safeDuration()); rafId = requestAnimationFrame(tick); } }; rafId = requestAnimationFrame(tick); } audio.addEventListener('play', startProgressLoop); audio.addEventListener('pause', () => cancelAnimationFrame(rafId)); audio.addEventListener('seeking', () => { // seek 期间也要更新,否则拖动后进度条回跳 renderProgress(audio.currentTime, safeDuration()); });requestAnimationFrame跟着屏幕刷新率走,一般是 60Hz,肉眼完全看不出跳动。同时它是页面隐藏时自动暂停的,不会在后台白白消耗 CPU,比setInterval更合适。
seeking那个监听器是补漏的。seeking事件在 seek 过程中会持续触发,如果不在这里更新 UI,就必须等 seek 完成后的seeked才刷新,中间会有一段时间进度条位置和实际不一致。
5.2 拖拽进度条的中间态
这是整个播放器里最容易写错的一块。错误的做法是:pointermove里直接设置audio.currentTime。这样做的后果是,浏览器会不停地打断当前的播放缓冲去 seek,出现杂音、卡顿,甚至直接卡死。
正确做法是引入一个“拖拽中”的状态,拖动过程中只更新 UI 显示,不碰currentTime;松手的时候一次性 seek:
let dragging = false; let dragRatio = 0; function ratioFromEvent(e, el) { const rect = el.getBoundingClientRect(); const ratio = (e.clientX - rect.left) / rect.width; return Math.min(1, Math.max(0, ratio)); } bar.addEventListener('pointerdown', (e) => { if (!Number.isFinite(audio.duration)) return; dragging = true; bar.setPointerCapture(e.pointerId); dragRatio = ratioFromEvent(e, bar); renderProgress(dragRatio * audio.duration, audio.duration); }); bar.addEventListener('pointermove', (e) => { if (!dragging) return; dragRatio = ratioFromEvent(e, bar); renderProgress(dragRatio * audio.duration, audio.duration); }); bar.addEventListener('pointerup', (e) => { if (!dragging) return; dragging = false; bar.releasePointerCapture(e.pointerId); audio.currentTime = dragRatio * audio.duration; });用pointer系列事件而不是mouse系列,是因为前者同时覆盖鼠标、触摸、手写笔,移动端不用写第二套逻辑。setPointerCapture保证手指移出进度条范围之后,pointermove仍然能被接收,不会出现“拖着拖着就没反应了”。
还有一个细节:拖拽期间应该暂停
requestAnimationFrame的渲染循环,否则 rAF 每帧都在用audio.currentTime覆盖拖拽中的显示,进度条会来回跳。可以在 rAF 的 tick 里加一句if (dragging) return;,简单有效。
5.3 音量控制与记忆
音量本身简单,audio.volume = 0.7就完事。但有两个细节:
第一,音量不要直接用滑块的像素值。人耳对音量的感知是对数的,线性映射会让“前半段几乎听不出变化,后半段猛一下变大”。我用了平方映射:
function sliderToVolume(v) { return v * v; } // v 是 0 到 1 function volumeToSlider(vol) { return Math.sqrt(vol); }这样滑块在中间位置时,实际音量是 0.25 左右,听感上更接近“一半”。
第二,静音和音量要分开存。用户把音量调到 0.3,然后点静音,再点取消静音,音量应该回到 0.3 而不是 1。所以内部维护两个值:lastVolume和muted,静音时只改audio.muted,不动volume。
let lastVolume = 0.7; muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; if (!audio.muted) audio.volume = lastVolume; muteBtn.textContent = audio.muted ? '取消静音' : '静音'; }); volBar.addEventListener('input', (e) => { const v = sliderToVolume(e.target.value / 100); audio.volume = v; audio.muted = false; lastVolume = v; saveVolume(v); });音量记忆用localStorage一行就够,键名带个前缀避免和其他页面冲突:localStorage.setItem('np.volume', v)。读取的时候记得做Number.isFinite校验,因为用户可能手动改过存储值,读出来是"abc",直接赋给volume会抛异常。
6. 标题、时长、封面:不引第三方库能走多远
6.1 从文件名里抠标题
大部分下载下来的音频文件名是很恶心的,比如01. 某某某 - 某某.mp3、[某某组]某某某.flac。直接拿去当标题显示,列表会非常难看。
我做了一层轻量清洗:
function parseTitle(filename) { let base = filename.replace(/\.[^.]+$/, ''); // 去掉开头的曲目编号:01. / 01_ / 01- / [01] base = base.replace(/^\s*[\[\(]?\d{1,3}[\]\)]?[\s._\-]+/, ''); // 去掉常见后缀括号:[320k] (Live) 等 base = base.replace(/[\s]*[\[\(【][^\]\)】]{1,12}[\]\)】]\s*$/g, ''); return base.trim() || filename; }这段正则不追求完美,只求覆盖 80% 的情况。最后那个|| filename是兜底——如果清洗之后变成空字符串(比如文件名就叫01.mp3),还是回退到原文件名,不然列表里出现空白项,看着像 bug。
这里有个判断:不要试图用正则去解析“歌手 - 歌名”的分隔符。因为分隔符可能是-、–、—、_、|,而且歌名本身可能包含这些字符。解析错了反而更难看。我现在只做“去掉编号和括号后缀”这种低风险操作。
6.2 时长读取的时机与并发控制
列表里显示每首歌的时长,体验会好很多。但时长不是选完文件就有的,必须等元数据加载完成。
最直觉的做法是选完文件马上给每首都创建一个Audio去读时长。这个做法在文件少的时候没问题,文件一多就出事——同时创建几十个Audio元素,每个都要去读文件头,浏览器会明显卡顿,甚至触发资源限制。
我加了并发控制,同时最多读 3 个:
async function readDurations(items, concurrency = 3) { let cursor = 0; async function worker() { while (cursor < items.length) { const item = items[cursor++]; item.duration = await probeDuration(item.url); updateDurationText(item); } } await Promise.all(Array.from({ length: concurrency }, worker)); } function probeDuration(url) { return new Promise((resolve) => { const probe = new Audio(); probe.preload = 'metadata'; const done = (val) => { probe.src = ''; probe.remove(); resolve(val); }; probe.addEventListener('loadedmetadata', () => done(probe.duration), { once: true }); probe.addEventListener('error', () => done(NaN), { once: true }); probe.src = url; setTimeout(() => done(NaN), 8000); // 超时兜底 }); }这里有几个点值得强调:
preload = 'metadata'是关键。设成'auto'会预加载整个文件,几十首歌一起加载直接卡死。设成'none'又不会触发loadedmetadata。必须是'metadata'。
读取完成后要把src置空,让浏览器释放资源。只remove()元素不一定够,因为src指向的 Blob 引用还挂着。
8 秒超时兜底是必要的。某些损坏的文件既不会触发loadedmetadata,也不会立刻触发error,可能一直挂着。有了超时,至少不会让整个流程卡住。
一个实测经验:在同时读取大量文件时长的时候,
loadedmetadata的触发顺序是乱的,不能假定按顺序回来。所以每条记录必须自己带 id,回来的时候按下标或 id 回填,不能靠“第几个完成的就填第几个”。
6.3 真要读 ID3 标签时的取舍
如果文件名实在太乱,或者你确实想显示封面,就得上 ID3 解析了。这里的选择很清楚:
| 方案 | 实现成本 | 能拿到什么 | 适合场景 |
|---|---|---|---|
| 只用文件名 | 极低 | 标题 | 大部分场景够用 |
| 自己解析 ID3v2 头 | 中等 | 标题、歌手、专辑 | 想控制体积,只读少量字段 |
| 引入第三方解析库 | 低 | 完整标签、封面 | 对体验要求高,不介意体积 |
ID3v2 标签一般放在文件开头,格式是:ID3三字节标识 + 版本 + 标志位 + 4 字节的同步安全整数(表示标签总长度)。读到这个长度之后,就可以用file.slice(0, 10 + size)只切出标签部分来解析,不需要读整个文件。这一点对性能很重要——一个 40MB 的文件,标签部分可能只有几百 KB。
我自己的选择是不做。理由是:这个播放器定位是“临时听听本地文件”,为了封面和歌手去引入解析逻辑,增加了出错的概率,收益有限。如果你的场景是“做一个长期使用的本地曲库”,那值得做,而且建议直接用成熟的解析库,别自己写,ID3 有太多版本和编码问题。
7. 我在实际用下来遇到的几个坑
7.1 内存泄漏的三个来源
这个播放器我放在后台标签页里挂了一整天,回来一看内存占用了 1G 多。排查下来有三个来源:
第一个是 Blob URL 没有释放,前面已经说过了。除了删除曲目时释放,清空列表、关闭页面时也应该处理。页面卸载时的beforeunload里遍历释放一次,虽然浏览器最终会回收,但主动释放更稳妥。
第二个是残留的事件监听器。我早期版本每次切歌都会给audio加一堆监听器,加完不删。切了几百次之后,同一个事件上有几百个回调在排队执行。解决方式是所有监听器只在初始化时绑定一次,切歌只改状态不绑事件。如果是那种必须绑在具体元素上的监听器,记得在元素移除前往removeEventListener。
第三个是列表项的 DOM 引用。我把track.el存在了数组里,删曲目的时候只splice了数组,但那个 DOM 元素如果还被别处引用着,就不会被回收。后来改成删的时候顺手el.remove()并置空引用。
排查内存问题最实用的方法是浏览器的内存快照对比:操作前拍一张,操作一百次再拍一张,看哪类对象数量在涨。比盯着代码猜快得多。
7.2 移动端上的三个差异
桌面浏览器跑得很顺的代码,挪到手机上会有几处不一样:
第一,文件选择器的行为完全不同。桌面端是文件对话框,移动端通常会弹出“相册 / 文件 / 音乐”的选择菜单,不同系统的选项名称和可访问范围都不一样。有些系统上,音频文件被归在“文件”里,有些在“音乐”里,用户不一定找得到。这点没法从代码层面解决,只能在提示文案上写清楚“请从文件管理器中选取音频文件”。
第二,accept的过滤不像桌面那么严格。移动端可能不显示任何过滤,所有文件都能选。所以类型校验必须做,而且要做得宽容——宁可放过一个可疑文件让它加载失败,也不要误判把正常文件挡在外面。我用的是“扩展名白名单 + MIME 前缀检查”的双重判断,只要满足其一就放行。
第三,音频焦点和后台播放。手机上切到别的应用,音频可能会被暂停。这是系统行为,audio元素会收到pause事件。如果你希望它在后台继续播,需要在MediaSession上设置元数据,并且页面要有正确的媒体会话声明。这块各平台行为差异较大,我的建议是:如果不是必须,别强求后台播放,按默认行为走,用户切回来的时候手动点继续就行,反而更符合预期。
7.3 刷新之后列表没了怎么办
前面提过,localStorage存不下文件内容。所以刷新之后,播放列表必然是空的。
我的处理方案是“轻量恢复”:
// 保存 function persist() { const data = tracks.map(t => ({ filename: t.filename, name: t.name, pos: t.id === tracks[currentIndex]?.id ? audio.currentTime : 0, })); localStorage.setItem('np.list', JSON.stringify(data.slice(0, 200))); // 限制条数 }限制 200 条是有意的。localStorage一般只有 5MB 左右,如果文件名很长,几千条就能撑爆,setItem会抛QuotaExceededError,导致整个保存流程中断。限制条数加上try/catch,能保证不崩。
恢复的时候,读出来文件名列表,在界面上显示一个提示条:“上次有 N 首曲目,请重新选择文件以恢复”。用户选完文件之后,按文件名匹配,把进度设回去:
function restoreProgress(newTracks) { const saved = JSON.parse(localStorage.getItem('np.list') || '[]'); const map = new Map(saved.map(s => [s.filename, s.pos])); for (const t of newTracks) { const pos = map.get(t.filename); if (pos && pos > 1) t.resumeAt = pos; } }pos > 1那个判断是为了跳过“刚开头几秒”的位置,那种位置恢复意义不大,反而容易让人以为播放器出问题。
如果你确实需要“刷新后完全恢复”,那唯一的路是 File System Access API 里的showOpenFilePicker,它返回的句柄可以存进 IndexedDB,下次直接复用,不用再选文件。但这个 API 的兼容性目前仍然有限,而且需要用户显式授权,不是一个能无脑用的方案。我的态度是:先让基础体验跑通,再考虑这种锦上添花的能力,否则很容易陷入“为了一个边缘功能重构整个架构”的困境。
最后分享一个我自己觉得最实用的小改动:给整个播放器加一个全局的键盘快捷键处理,空格播放暂停、左右方向键快退快进 5 秒、上下键调音量。代码不到二十行,但用起来的手感提升非常明显——尤其是听长音频的时候,不用每次都去精确点那个小进度条。加的时候注意在输入框里要屏蔽掉,判断一下e.target.tagName,不然用户在搜索框里打空格会变成暂停播放。