
做本地音乐播放器这件事最容易被低估的从来不是解码也不是界面皮肤而是歌单。歌单队列保存、排序、M3U8 导入导出这几件事看着像是增删改查的体力活真上手做进 Folia 这样的本地播放器里你会发现它牵扯到数据模型设计、持久化时机、排序稳定性、路径换算、字符编码五六个不同层面的问题任何一个没处理好用户第二天打开就发现歌单空了、顺序乱了、导入一半就报错。我前后在这个模块上返工了三轮第一版把队列直接塞进 localStorage结果几百首歌的歌单每次拖拽都全量序列化页面卡得像幻灯片第二版换了存储又因为排序用了不稳定的比较器导致同专辑曲目每次刷新顺序都变第三版才把 M3U8 的往返一致性做对。这篇就把这套东西完整拆一遍从需求边界、数据模型、排序算法到 M3U8 的格式细节和容错策略尽量把每一处“为什么这么选”讲透。适合正在做本地播放器、想做歌单系统、或者单纯被 M3U8 导入导出折磨过的朋友前端基础一般也能看懂代码部分可以直接抄。1. 先想清楚歌单在本地播放器里到底解决什么问题1.1 本地播放器和在线平台的根本差异在线音乐平台的歌单本质上是一个服务端资源 ID 列表你只需要存一串 ID剩下的元数据、封面、时长全部由接口回填客户端几乎不承担任何一致性责任。本地播放器完全反过来文件在你自己的硬盘上浏览器或客户端拿到的可能只是一个File对象或者一个文件句柄路径随时可能因为用户移动文件夹而失效元数据要么靠读标签要么靠文件名猜。这就决定了本地歌单的第一性原理不是“存一份 ID 列表”而是“存一份足够自描述、能在文件系统发生变化后依然可修复的引用集合”。我在 Folia 里最初就犯了这个错把歌单存成[{id: a1b2c3}]这种形式id 对应内存里的曲目对象。结果用户刷新页面内存清空id 全成了孤儿歌单显示一片空白。后来改成每条记录都带上name、path或相对路径、duration、artist、album这些冗余字段即使文件句柄失效至少还能把歌单渲染出来标一个“文件缺失”让用户自己重新指向目录。这个冗余设计看起来“不范式”但在本地场景下是必须的因为它把歌单从“强依赖运行时状态”变成了“可离线重建的清单”。再往深一层本地播放器还要面对一个在线平台不存在的问题同一个文件可能被用户用不同路径引用多次。比如D:\Music\专辑A\01.mp3和通过软链接进来的D:\Playlist\01.mp3指向同一个物理文件。如果不做去重用户会看到歌单里出现两首一模一样的歌点哪首都能播但删除的时候只删掉一条剩下那条变成幽灵。我的处理方式是在入库时对规范化后的绝对路径做一次哈希作为trackKey歌单里存trackKey而不是路径字符串本身这样路径变了只要重新扫描能对上 key引用就能自动修复。1.2 队列、歌单、播放列表三个概念必须分开这三个词在日常语境里混着用但在代码里如果混在一起后面所有逻辑都会纠缠。我在 Folia 里把它们拆成三个独立实体这个划分是后面所有设计能站住脚的前提。播放队列queue是“现在正在播的这一串”它有明确的位置指针当前播放到第几首有播放模式顺序、单曲循环、随机它的生命周期跟着这次播放会话走关掉页面理论上可以丢弃但用户往往希望下次打开还能接着听所以要持久化。歌单playlist是用户主动创建、命名的长期集合它没有播放指针只有成员和顺序可以有多个可以嵌套引用一个歌单里引用另一个歌单的曲目它的核心诉求是稳定和可编辑。M3U8 文件是一种交换格式它既不是队列也不是歌单而是一份“快照导出”。这个区分很关键M3U8 导出的是某一时刻的成员和顺序导入之后应该生成一个新的歌单或者追加到现有歌单末尾而不是覆盖播放队列。把这三者分开之后数据结构就清晰了队列是一份带currentIndex的 trackKey 数组歌单是一份带id、name、createdAt的 trackKey 数组M3U8 是这两者的一种序列化视图。我见过不少实现把队列直接当成“临时歌单”结果用户点“清空队列”把正在播的歌单也一起清了这类投诉在实际使用里非常高频。1.3 存储方案选型为什么最后落到 JSON 加 IndexedDB先说结论Folia 用的是IndexedDB 存主数据 localStorage 存轻量偏好导出时序列化成 JSON交换时走 M3U8。这个组合不是拍脑袋是踩过坑之后收敛出来的。第一版我全用 localStorage理由很简单API 同步、写起来爽。问题在容量和性能localStorage 通常只有 5MB 左右一首歌的元数据算 200 字节5000 首歌就是 1MB加上歌单冗余字段很快就顶到上限更要命的是它同步阻塞主线程每次拖拽排序都要JSON.stringify整个歌单再写回歌单超过 300 首之后拖一下就掉帧。换成 IndexedDB 之后解决了容量和异步问题但引入了新的复杂度它是事务型的写操作要开事务而且不同浏览器的配额策略不一致。我的做法是把“曲目库”和“歌单”分成两个 object store曲目库以trackKey为主键歌单以playlistId为主键歌单里只存trackKey数组加少量快照字段。这样拖拽排序时只需要更新歌单那一条记录不用碰曲目库单次写入的数据量从几百 KB 降到几 KB实测拖拽 800 首的歌单基本无感。至于 localStorage我留给了“上次播放位置”“音量”“当前播放模式”这类几十字节的小状态。这类数据要求同步读取、启动即用放 IndexedDB 反而要等一个异步回调才能恢复界面体验上不划算。这个分工原则可以记一下小、同步、启动必需 → localStorage大、异步、可延迟 → IndexedDB。2. 队列保存把“不丢”和“不乱”拆成两件事2.1 最小可用的数据模型长什么样我先把最终收敛出来的结构贴出来后面所有讨论都基于它。这是一个队列记录的形态歌单记录只是把currentIndex换成name和createdAt。// 队列持久化记录 { version: 3, // 结构版本号用于迁移 updatedAt: 1723000000000, // 最后写入时间戳 currentIndex: 12, // 当前播放位置 repeatMode: all, // off | one | all shuffle: false, shuffleOrder: null, // 随机模式下的索引映射 items: [ { trackKey: sha1:9f2c..., // 规范化路径的哈希 name: 某首曲子, artist: 某作者, album: 某专辑, duration: 245.6, // 秒浮点 path: Music/专辑A/01.mp3,// 尽量存相对路径 addedAt: 1722900000000 } ] }几个字段值得单独说。version是我认为最容易被省掉、但后期最救命的一个。结构一旦上线后面必然要加字段、改语义如果没有版本号老用户的数据在新版本里可能直接被判为非法而丢弃。有了版本号启动时先读版本低于当前版本就走迁移函数链高于当前版本就提示“数据来自更新版本请升级后再试”避免降级写入把数据写坏。path存相对路径而不是绝对路径是为了应对这类场景用户把整个音乐文件夹从 D 盘挪到 E 盘绝对路径全废但相对路径只要基准目录还能被用户重新指定就能批量修复。Folia 的做法是让用户配置一个“音乐库根目录”队列里存相对于这个根目录的路径路径失效时提示用户重新选根目录然后一次性把整个队列修复掉而不是让用户一首一首重新添加。addedAt这个字段是为了后面的排序准备的很多人一开始觉得没用就省了等到用户想按“添加顺序”排序时才发现无从下手只能靠数组下标凑合一旦中间有删除就全乱。提示duration一定要存浮点秒数而不是格式化后的04:05字符串。字符串排序会得到04:05 10:00 1:30:00这种看似对、实际在跨小时场景下会错的结果而且重新计算时长要重读音轨代价太高。2.2 持久化时机防抖、原子写和断电保护“什么时候写”这个问题比“写什么”更容易出事。我第一版是每次状态变化就写播放进度每 250ms 更新一次位置队列也跟着写一次IndexedDB 事务日志瞬间被刷爆浏览器直接开始报事务超时。后来改成三个层次的写入策略问题才算解决。第一层是播放位置节流写。currentIndex变化、播放进度变化这类高频事件用一个 2000ms 的节流器合并只在最后一次真正落库。理由是即使丢了两秒内的进度用户最多是下次打开时从上一首的开头开始损失可接受而每 250ms 写一次带来的性能代价是不可接受的。第二层是结构变化立即写。添加、删除、移动曲目、切换播放模式这类操作属于“用户明确期待它被记住”的动作不能等。这里我用的是一个微任务合并同一个事件循环内的多次变更合并成一次写跨事件循环的变更立即写。第三层是页面卸载兜底写。监听visibilitychange当页面进后台时强制刷一次未落库的变更。这里有个坑要提醒beforeunload在现代浏览器里并不可靠尤其是移动端切后台时可能根本不触发而visibilitychange到hidden的时机相对稳。我实测下来只用beforeunload的方案在移动端丢数据的概率相当高。原子写这部分IndexedDB 本身的事务已经提供了原子性一次put要么全成要么全败不需要额外处理。但如果你用的是别的存储方案比如自己读写文件就必须自己做“写临时文件 → fsync → 重命名”这套流程否则写到一半断电文件就成半截 JSON 了下次启动直接解析失败。这是我在另一个桌面端项目里真实踩过的坑用户反馈“歌单突然变空”日志里是一堆Unexpected end of JSON input。至于校验和回滚我的做法是在写入成功后把上一版数据留在一个backup键里只保留一份。读取时如果主数据解析失败自动回退到 backup 并给用户一个提示。这个机制救过我两次一次是用户手动改了 IndexedDB 里的数据导致结构不对一次是我自己发版时迁移函数写错。代价是存储翻倍但队列数据本身不大完全值得。2.3 版本迁移加字段不炸老数据迁移这块我建议一开始就定好规则不要等到第三次改结构时手忙脚乱。Folia 的规则是每次改结构version加一迁移函数写成v1→v2、v2→v3这样的单步函数链式调用每步迁移必须幂等重复执行结果一致迁移前必须把原始数据完整备份迁移失败自动回滚。举例说v1 到 v2 我把id字段换成了trackKey迁移函数要做的是遍历所有条目用path重新算哈希填进去同时保留id作为 fallback 一段时间。v2 到 v3 我把duration从字符串改成浮点迁移函数要能处理两种输入已经是数字的直接过是04:05格式的要解析。const migrations { 1: (data) { data.items (data.items || []).map(it ({ ...it, trackKey: it.trackKey || hashPath(normalizePath(it.path)), duration: parseDuration(it.duration) })); data.version 2; return data; }, 2: (data) { data.items (data.items || []).map(it ({ ...it, duration: typeof it.duration number ? it.duration : parseDuration(it.duration) })); data.version 3; return data; } }; function migrate(raw) { let data raw; while (data.version CURRENT_VERSION) { const step migrations[data.version]; if (!step) throw new Error(缺少迁移步骤: data.version); data step(data); } return data; }parseDuration这个函数看着简单实际上要处理04:05、1:04:05、245.6、245、245.6s好几种输入我在实际数据里全都见过。写迁移函数的时候不要假设输入是干净的因为用户的历史数据可能来自好几个不同版本甚至可能被第三方工具改过。注意迁移函数里千万不要做任何网络请求或者需要用户交互的操作。迁移是在启动路径上的同步流程任何异步等待都会让启动变慢甚至卡死。需要用户确认的事情迁移完之后再弹窗。3. 排序从拖拽到多级规则稳定性是第一原则3.1 稳定排序为什么是硬要求用户在歌单里看到两首歌的专辑名一样、时长也一样他拖着 A 到 B 前面下次打开发现顺序又回去了这就是排序不稳定的典型症状。JavaScript 的Array.prototype.sort在 ES2019 之后规范上要求稳定但很多人在写比较器的时候自己破坏了稳定性。破坏稳定最常见的写法是这样// 错误示范相等时返回 true/false 而不是 0 items.sort((a, b) a.album b.album);这个比较器返回布尔值true会被转成1false转成0。当a.album b.album时返回false即0看起来好像对了但当a和b反过来比的时候也返回0在某些引擎的排序实现里会导致元素被反复交换。正确的写法必须返回负数、零、正数三种情况function compareByAlbum(a, b) { if (a.album b.album) return 0; return a.album b.album ? -1 : 1; }我在 Folia 里的做法是所有排序比较器都统一走一个包装函数强制把结果规范成-1 | 0 | 1并且在最后一级永远补一个addedAt或者原始索引作为 tiebreaker。这样即使是“所有字段都相等”的极端情况顺序也永远和添加顺序一致用户感知上就是稳定的。function stableCompare(...comparators) { return (a, b) { for (const cmp of comparators) { const r cmp(a, b); if (r ! 0) return r 0 ? -1 : 1; } // 兜底按入库索引保证绝对稳定 return (a.__idx ?? 0) - (b.__idx ?? 0); }; }__idx是在加载时给每条记录临时打的原始下标不落库。这个技巧很实用因为它让“排序结果可复现”这件事有了物理保证而不是依赖引擎实现细节。3.2 多字段排序规则的设计只按一个字段排是不够的用户真实的需求往往是“先按艺术家同艺术家按专辑同专辑按音轨号”。这就需要一个可组合的比较器数组。Folia 的排序面板最终做成了这样几条规则每条都可以单独开关和升降序排序字段数据来源典型用途注意事项手动顺序数组下标用户自定义编排一旦手动拖拽其它排序规则自动失效标题name按曲名找歌中文需考虑拼音否则按 Unicode 码位排会很怪艺术家artist按人归类多艺术家用分隔符拆开后取第一个专辑album按专辑听合辑要特殊处理否则会散成一堆时长duration找短曲/长曲必须用数值比较不能字符串比较添加时间addedAt回到最初顺序时间戳相同要 fallback 到索引播放次数playCount按喜好排需要额外维护计数器中文标题排序这块我单独说一下因为坑很深。直接用比较字符串是按 Unicode 码位比结果就是所有中文都排在所有英文后面而且中文之间是按部首笔画附近但不完全一致用户看着就是“乱”。正确做法是用Intl.Collatorconst collator new Intl.Collator(zh-Hans-CN, { numeric: true, // 让 第2首 排在 第10首 前面 sensitivity: base // 忽略大小写和变音符号 }); const byTitle (a, b) collator.compare(a.name, b.name);numeric: true这个选项特别重要不加的话Track 10会排在Track 2前面用户一眼就能看出来是 bug。sensitivity: base则解决The Beatles和the beatles被分成两组的问题。还有一个隐藏问题Intl.Collator的构造有开销不要在比较器里每次都 new一定要提到外面做单例。我在一次性能排查里发现排序 2000 首曲子时创建了将近 4 万次 Collator耗时占了整个排序的 70%。提到外面之后同样的数据排序从 380ms 降到 45ms。3.3 拖拽排序的落库策略拖拽排序看起来只是“把数组里的元素挪个位置”实际要考虑三件事重排的粒度、写入的频率、以及和其它排序规则的冲突。粒度上我不用“交换两项”这种操作而是用“移动一项从 i 到 j”因为交换在跨越多个位置时会打乱中间所有元素的相对顺序用户拖一次发现中间的歌全乱了。移动的实现很简单function moveItem(arr, from, to) { if (from to) return arr.slice(); const next arr.slice(); const [item] next.splice(from, 1); next.splice(to, 0, item); return next; }用不可变的方式返回新数组配合前面的“结构变化立即写”策略可以保证拖拽结束的那一刻状态已经落库。冲突这块是个容易忽略的设计点如果用户当前处于“按专辑排序”视图然后拖动了一首歌这个拖动应该怎么解释有两种选择一是自动切换到“手动顺序”模式把当前的排序结果固化成手动顺序再应用拖动二是禁止在非手动视图下拖动。我选的是第一种因为用户拖动的意图很明确就是想让它待在那儿切换到手动模式是最符合直觉的。实现上就是在拖动开始时把当前排序结果写回数组然后把sortMode置为manual。写入频率上拖拽过程中会连续触发大量dragover事件如果在这些事件里就写库一次拖拽可能产生几十次写入。我的做法是拖动过程中只更新内存和 DOMdragend时才真正落库。为了体验拖动过程中用一个视觉占位符跟随不需要每次都重新渲染整个列表。提示移动端没有原生拖拽事件得用pointerdown/pointermove/pointerup自己实现。这里有个细节长按 300ms 之后再进入拖拽态否则会和列表滚动冲突用户想滚动结果触发了拖拽体验极差。4. M3U8 导入导出格式细节决定成败4.1 先搞清楚 M3U8 到底是两种东西这一点必须先讲清楚因为我在搜索资料时被绕了很久。M3U8 这个名字在两种完全不同的场景里被使用一种是 HLS 流媒体清单里面的#EXT-X-STREAM-INF、#EXT-X-TARGETDURATION这些标签是用来描述分片流的另一种就是 M3U 播放列表的 UTF-8 版本内容是一行一个音频文件路径可选#EXTINF行描述时长和标题。Folia 导出的、用户之间互相分享的歌单文件绝大多数是后者。两者的区分方法很简单看头部有没有#EXTM3U以及后面跟的是#EXTINF还是#EXT-X-。如果文件里出现任何#EXT-X-开头的行那基本上就是 HLS 清单不要当成歌单导入否则你会得到一堆.ts分片路径播都播不了。我在导入逻辑里加了这条判断遇到疑似 HLS 的文件直接提示“这看起来是流媒体清单不是歌单”避免用户一脸懵。标准的歌单型 M3U8 长这样#EXTM3U #EXTINF:245,某首曲子 - 某作者 Music/专辑A/01.mp3 #EXTINF:198,另一首曲子 - 另一个作者 Music/专辑A/02.mp3#EXTINF的格式是#EXTINF:时长秒数,显示标题注意是英文逗号时长可以是浮点。显示标题里如果本身包含逗号按规范是允许的因为解析时只按第一个逗号切分后面的全归标题。这一点很多实现都写错了用split(,)然后取[1]遇到标题里带逗号的歌就直接截断。4.2 路径相对、绝对、URL 三者的换算这是导入导出里最容易出错、也最影响可用性的部分。同一个歌单文件在导出机器上能播换台机器就全红九成是路径问题。导出时我坚持写相对于歌单文件所在目录的路径理由是这样整个文件夹可以整体拷贝、整体分享对方解压后路径依然有效。实现上就是拿曲目的绝对路径和歌单导出目录做一次relative运算。这里要注意跨盘符的情况从D:\Music到E:\Playlist是算不出相对路径的只能退化成绝对路径同时在文件里加一个注释行提示。导入时的还原逻辑是以 M3U8 文件所在目录为基准把每一行的相对路径拼成绝对路径然后去曲目库里按trackKey查。查不到的话进入“待修复”状态允许用户手动指定新的根目录用文件名做模糊匹配重新关联。function resolveEntry(line, baseDir) { const trimmed line.trim(); if (!trimmed || trimmed.startsWith(#)) return null; // Windows 盘符绝对路径 if (/^[a-zA-Z]:[\\/]/.test(trimmed)) { return { type: absolute, value: normalize(trimmed) }; } // UNC 路径 if (trimmed.startsWith(\\\\)) { return { type: absolute, value: normalize(trimmed) }; } // 各类 URL if (/^[a-z][a-z0-9.-]*:\/\//i.test(trimmed)) { return { type: url, value: trimmed }; } // 相对路径 return { type: relative, value: normalize(join(baseDir, trimmed)) }; }还有一个细节Windows 导出的文件里用的是反斜杠但很多播放器只认正斜杠或者反过来。我的做法是导出时统一转成正斜杠导入时两种都接。正斜杠在 Windows 的 API 里也是被接受的这样兼容性最好。另外路径里的空格和非 ASCII 字符要不要转义我的选择是不转义直接写原字符因为转义之后%20这种反而让用户看不懂而且不同播放器对转义的还原规则不一致。至于file://这种 URL 形式我建议导入时识别并转换导出时不要主动生成。原因很简单file://URL 需要正确的百分号编码中文路径转错一个字符就整个失效而且很多浏览器出于安全策略不允许直接访问file://资源。用户拿到一个全是file://的歌单在浏览器版里基本播不了。4.3 编码UTF-8 与 BOM 的取舍M3U8 的“8”就是指 UTF-8这个没有争议。但 BOM 要不要写各家做法不一样。Windows 上的某些老播放器在读取不带 BOM 的 UTF-8 文件时会按本地代码页解析中文标题直接变乱码。而另一些工具特别是 Unix 系的会把 BOM 当成内容的一部分导致第一行#EXTM3U变成\uFEFF#EXTM3U解析失败。我的策略是导出时不写 BOM导入时自动剥离 BOM。理由是现在主流环境都能正确处理无 BOM 的 UTF-8写 BOM 带来的兼容收益小于它引入的解析风险。剥离 BOM 的代码很简单但不能省function stripBOM(text) { return text.charCodeAt(0) 0xFEFF ? text.slice(1) : text; }读取文件的时候还有一个坑用FileReader.readAsText(file)不指定编码时默认按 UTF-8 读但如果文件实际是 GBK 编码的常见于从某些老软件导出的歌单就会读到一堆替换字符\uFFFD。检测方法是读完之后统计\uFFFD的比例超过一定阈值就尝试用TextDecoder(gbk)重新解码。这个降级逻辑我加进去之后用户反馈的“导入后歌名全是问号”问题基本消失了。换行符也要处理导出用\n导入时\r\n、\r、\n三种都要能识别。解析时不要用split(\n)然后手动 trim那样会把\r留在行尾导致路径匹配失败。稳妥的做法是先统一替换const lines stripBOM(text).replace(/\r\n?/g, \n).split(\n);4.4 导入容错坏行跳过而不是整体失败导入这件事我踩过最难受的坑是一个 3000 行的歌单第 2891 行有个格式错误整个导入直接抛异常用户啥也没得到。这种体验非常糟糕因为用户根本不知道是哪一行错了。现在的策略是逐行解析、错误收集、部分成功。每行解析失败就记一条到errors数组里带上行号、原始内容、失败原因全部解析完之后给出一个汇总报告“成功导入 2987 首跳过 13 首”并且提供一个可展开的详情列表让用户看具体哪些行有问题。function parseM3U8(text, baseDir) { const lines stripBOM(text).replace(/\r\n?/g, \n).split(\n); const entries []; const errors []; let pending null; // 暂存 EXTINF 信息 lines.forEach((raw, i) { const line raw.trim(); if (!line) return; if (line.startsWith(#EXTM3U)) return; if (line.startsWith(#EXTINF:)) { const rest line.slice(8); const commaAt rest.indexOf(,); // 只按第一个逗号切 if (commaAt -1) { errors.push({ line: i 1, raw: line, reason: EXTINF 缺少逗号分隔 }); return; } const dur parseFloat(rest.slice(0, commaAt)); const title rest.slice(commaAt 1).trim(); pending { duration: Number.isFinite(dur) ? dur : null, title: title || null }; return; } // 其它 # 开头的行一律忽略但记录一下方便排查 if (line.startsWith(#)) { if (line.startsWith(#EXT-X-)) { errors.push({ line: i 1, raw: line, reason: 疑似流媒体清单标签 }); } return; } // 这是一条路径 const resolved resolveEntry(line, baseDir); if (!resolved) { errors.push({ line: i 1, raw: line, reason: 路径无法解析 }); return; } entries.push({ ...resolved, raw: line, ...(pending || {}) }); pending null; }); if (pending) { errors.push({ line: lines.length, raw: , reason: 末行 EXTINF 没有对应路径 }); } return { entries, errors }; }这里有个容易漏掉的边界#EXTINF后面没有跟路径就结束了文件。这种情况必须报错否则你会丢掉一条信息却毫无察觉。同理连续两个#EXTINF中间没有路径说明前一个被覆盖了也要记一条警告。导出时我加了几个自定义的扩展注释比如#FOLIA-PLAYLIST:name、#FOLIA-EXPORT-AT:时间戳。这些行以#开头标准播放器会直接忽略不影响兼容性但 Folia 自己导入时能还原歌单名和导出时间用户体验会好很多。这种“在标准格式里塞私有注释”的做法要克制只放非必需的增强信息绝不能把关键数据放在私有注释里否则文件拿到别的播放器就废了。4.5 往返一致性导出再导入必须等值这是我给自己定的验收标准任意歌单导出成 M3U8再导入回来成员、顺序、标题、时长必须完全一致。我把这个写成了自动化测试每次改导入导出代码都跑一遍。做到这件事要注意几个细节。第一duration的精度。导出时如果用toFixed(0)取整再导入回来就丢了小数严格比较会失败。我的做法是导出时保留一位小数导入时容忍 0.1 秒的误差比较时用容差判定而不是全等。第二标题里的特殊字符。如果标题里有换行符真实存在某些标签里的标题就是带换行的导出的文件会被破坏成两行。导出前必须把\r、\n替换成空格。逗号可以保留因为解析时只按第一个逗号切。第三顺序。导出按当前视图顺序写导入按文件顺序读两边都不做排序这样才能保证一致。如果导入时擅自按标题重排一下用户会发现顺序和自己导出时不一样。第四空歌单的处理。空歌单导出后只有一行#EXTM3U导入时应该得到一个空歌单而不是报错。这个边界我在测试里专门加了用例。往返测试项预期行为常见错误成员数量完全一致空行被当成路径计入顺序完全一致导入时自动重排时长误差小于 0.1s导出取整丢失精度标题完全一致按逗号切分截断含换行的标题换行被替换为空格文件结构被破坏空歌单得到一个空歌单抛异常或返回 null重复曲目保留重复被当作重复项去重5. 排查实录那些文档里不会写的问题5.1 常见问题速查现象最可能的原因排查动作刷新后歌单为空只存了运行时 id未存冗余字段检查 IndexedDB 里的实际记录顺序每次打开都变比较器返回布尔值或缺少 tiebreaker打印排序前后的数组对比导入后曲目重复未按 trackKey 去重检查入库前的唯一性校验中文标题乱码文件是 GBK 编码统计替换字符比例尝试二次解码导入一半报错未做逐行容错改成错误收集模式路径全部失效导出用了绝对路径改为相对于歌单目录拖拽后卡顿每次 dragover 都写库改为 dragend 时落库时长排序错乱用字符串比较时长确认存的是数值类型移动端拖不动未实现 pointer 事件检查是否只有 dragstart跨盘符导出失败无法计算相对路径退化为绝对路径并提示5.2 三个印象最深的坑第一个坑是IndexedDB 的事务自动提交。我写过一个循环在一个事务里遍历 500 首曲子逐个put结果只写了前面几十条就停了。原因是 IndexedDB 的事务在一个事件循环 tick 结束后如果没有任何待处理的请求就会自动提交。循环里如果有await别的东西比如读文件元数据事务就提前提交了后面的put全部抛TransactionInactiveError。解决办法是要么把所有put排成同步请求队列一次性发出要么每批开一个新事务。我选了后者每 100 条一批逻辑简单而且不容易出错。第二个坑是文件句柄的失效检测。Folia 用 File System Access API 拿到的FileSystemFileHandle是可以存进 IndexedDB 的但用户移动或删除了文件之后句柄本身还在只有在真正getFile()的时候才会抛错。所以不能靠“句柄存在”判断文件可用必须在播放前做一次轻量的getFile()探测。为了性能我把这个探测做成懒加载列表渲染时不去碰只有用户点播放或者主动点“检查文件”时才做并在结果里标一个状态。第三个坑是同一首歌在两个歌单里的引用同步。用户在一个歌单里把某首歌的标题改了Folia 支持改显示名另一个歌单里还是旧名字。因为我是冗余存储同一个trackKey在不同歌单里有不同的快照。后来加了一条规则显示名等可编辑字段以曲目库为准歌单里的快照只在曲目库查不到时作为 fallback。这样修改就变成了单一数据源不会出现不一致。注意冗余存储和单一数据源这两件事看起来矛盾实际不冲突。冗余的是“文件不在时还能显示”的兜底信息权威数据永远在曲目库里。判断标准是这份数据是“缓存”还是“源”缓存可以冗余源必须唯一。5.3 一个关于导出格式的取舍有用户提过希望导出成 JSON理由是想保留更完整的信息。我一开始拒绝了后来想明白了M3U8 是给别人用的JSON 是给自己用的。所以 Folia 现在两种都支持M3U8 走“交换”路径JSON 走“完整备份”路径。JSON 备份里包含所有字段包括播放次数、添加时间、私有标记导入时能完整还原M3U8 只保留最通用的信息追求的是最大兼容性。这个区分还挺重要的因为很多人做导出功能时容易陷入“既要兼容又要完整”的矛盾最后做出一个四不像的格式。分开之后就清晰了分享给别人用 M3U8自己备份迁移用 JSON各自解决各自的问题代码路径也不打架。5.4 性能上的几个数字最后分享几个实测数据都是在 2000 首歌、800 首队列的规模下测的供参考。全量JSON.stringify一个 800 首的队列并写入 localStorage耗时约 120ms会明显掉帧改成 IndexedDB 只写歌单那一条记录之后单次写入 8ms 左右。排序方面用Intl.Collator单例 稳定的多级比较器2000 首排序耗时 45ms把 Collator 放到比较器里每次都 new同样的数据要 380ms。M3U8 解析 3000 行耗时 30ms 左右其中路径规范化占了将近一半如果对trackKey做一层内存缓存可以降到 18ms。这些优化单个看都不起眼叠在一起就是用户能不能感觉到卡的分界线。我的经验是800 首以内的歌单基本可以不做任何优化超过这个量级写入策略和排序实现就得认真对待了。