Electron 音乐播放器 SPlayer:简约设计与音频内核实践
发布时间:2026/10/1 5:32:04来源:尧图网络
我平时写代码或者整理东西的时候习惯挂着音乐但挂在浏览器标签页里的播放器有几个绕不开的毛病标签一多就被系统挂起切歌要等页面响应有时候浏览器一刷新播放队列就归零了。我想要的东西其实很朴素——一个常驻在托盘旁边的窗口打开就是歌单点一下就响其他什么都不要。SPlayer 这个项目就是从这个念头长出来的它是一款走简约路线的网易云音乐播放器目标是把听歌这件事的操作成本压到最低同时把内存和启动时间控制在一个能接受的范围里。如果你也属于音乐必须一直响着的那类人或者你正在琢磨自己写一个桌面端播放器这篇文章里关于选型、音频内核、状态管理和打包踩坑的部分应该都能直接用上。功能我刻意砍得很狠只保留三件事找到歌、把歌放出来、记住我上次听到哪儿。剩下的社交、直播、评论区、每日推荐弹窗一律不要。1. 从打开要等三秒说起SPlayer 想解决的真实问题先说清楚需求边界因为播放器这个品类特别容易失控。网上随便找一个开源播放器项目翻它的 Issue 列表你会发现一半以上的功能请求都不是播放本身而是歌词特效、频谱动画、桌面歌词、听歌识曲、歌曲下载、歌单同步、多设备漫游。这些功能每一个单独看都很合理但堆在一起的结果就是启动变慢、内存翻倍、主界面信息密度爆炸最后你又回到了那个我就想听首歌而已的起点。我给 SPlayer 定的第一条规则是主进程路径上不许有网络请求阻塞首屏。窗口先渲染出来歌单从本地缓存里读读到什么先显示什么后台再去同步增量同步失败也不影响你点播放。这条规则听起来简单但它直接决定了后面所有的架构选择——你不用等接口返回才能看到界面也不用给每个列表都写一套骨架屏。第二条规则是播放状态只有一份。很多播放器写着写着就出现界面显示在播、实际没声音或者声音在放、按钮是暂停态的情况根因几乎都是同一个多个组件各自维护了一份 isPlaying。SPlayer 里所有跟播放相关的状态都收敛到一个 store 里UI 只读不写要改状态只能调 action。第三条规则是不做格式破解。扫描本地曲库的时候加密格式的文件直接跳过因为它本来就不是给第三方客户端准备的。这个决定会损失一部分用户但省下来的时间和后续风险是值得的。需求是否做理由播放/暂停/上下首做核心无争议歌单列表与本地曲库做找歌的入口播放队列与随机/单曲循环做影响听歌体验的关键项桌面歌词悬浮窗不做单独一个窗口进程成本高歌曲下载不做版权与合规问题社交/评论/直播不做与简约定位冲突均衡器做简化版Web Audio 原生支持成本低频谱可视化做可关顺手加的不影响主链路这张表贴在我项目 README 的第一屏作用不是给别人看的是给我自己看的。每次想加功能之前先翻一眼如果新想法不在表里就先问自己它是让播放这件事更快还是只是让它看起来更热闹。1.1 目标用户到底是谁我复盘过身边用这个播放器的人大概分成三类。第一类是长时间把音乐当背景音的人写代码、做设计、写文档音乐不能断所以最在意的是稳定性跟资源占用第二类是本地曲库党手里攒了几千首从各处收集来的无损文件需要的是快速扫描、准确读标签、按专辑和歌手分类第三类是纯粹想折腾的开发者他们其实是在找一个能改的项目骨架。这三类人的共同点很明确都不需要花哨的视觉。所以 SPlayer 的默认主题就是深灰底加一个主色没有毛玻璃、没有渐变边框、没有开屏动画。你要是不信可以试着把任何一个播放器的开屏动画删掉看看使用体感是不是反而提升了——大部分情况下是的。1.2 简约不等于功能少而是路径短这里得掰扯一个概念。很多人把简约理解成功能砍到最少其实不对。简约的核心是从意图到结果的路径足够短。我要听上周循环的那首歌需要几步如果是打开播放器、点最近播放、滚动找到、点击四步如果播放器记住了上次的播放队列并且恢复到了上次的位置那就是零步——打开就在放。所以 SPlayer 花了不少精力在状态恢复上播放队列持久化、当前曲目持久化、播放进度每五秒落一次盘、窗口位置和大小也存下来。这些功能用户根本看不见但它们才是简约真正的落点。2. 技术栈取舍Electron、Tauri 还是纯 Web选运行时这一步我前前后后折腾了差不多两周最后选了 Electron这里把权衡过程完整写下来因为这个决策几乎决定了项目后面所有的形态。方案优势硬伤ElectronChromium 内核统一音频解码行为一致生态成熟包体积大、空载内存高Tauri体积小、内存低、Rust 侧能力强依赖系统 WebViewLinux 上 WebKitGTK 对部分音频格式支持不稳纯 Web零安装、开发最快拿不到托盘、全局快捷键、本地文件扫描、系统媒体控制原生 Qt / Flutter性能天花板高音频生态要自己搭UI 开发效率低关键点是音频解码的一致性。Electron 里跑的是和 Chrome 同一套 ChromiumMP3、AAC、FLAC、Opus、WAV 这些主流格式的解码表现是确定的我在 Windows、macOS、Linux 上测出来的行为基本一致。Tauri 在 Windows 上用的是 WebView2同样是 Chromium 系问题不大但到了 Linux不同发行版下的 WebView 实现差异就出来了同一段 FLAC 在一台机器上能播换一台就报NotSupportedError。对于一个我只想安安静静听歌的工具来说这种不可预测性是致命的。代价我也认了。Electron 的包体积和内存是硬伤但它是可以优化的第八章会讲而解码不一致的问题基本上无解只能自己引入 WASM 解码器重写一遍链路投入产出比太低。2.1 前端框架与状态管理前端我用了 Vue 3 配合 Vite状态管理用 Pinia。选 Vue 而不是 React 的原因很实际模板语法写播放列表这种重复结构更省事而且学习成本低如果有人想 fork 这个项目改自己的版本不至于被 Hooks 的依赖数组绕晕。依赖清单大致是这样能省的我全删了{ dependencies: { electron-updater: ^6.x, better-sqlite3: ^11.x, music-metadata: ^7.x, pinia: ^2.x, vue: ^3.4.x, vue-router: ^4.x }, devDependencies: { electron: ^31.x, electron-builder: ^24.x, vite: ^5.x, vitejs/plugin-vue: ^5.x, electron-rebuild: ^3.x } }注意这里没有 UI 组件库。我试过引入完整的组件库结果光是按需引入的配置就写了一百多行最后真正用到的组件不到六个索性全部手写。一个播放条、一个列表、一个滑块、两个弹窗加起来不到八百行样式代码比配置构建工具快多了。2.2 主进程与渲染进程的职责切分Electron 最容易写乱的地方就是职责不分什么都往渲染进程塞。SPlayer 的划分线很清晰主进程文件系统扫描、SQLite 读写、凭据加解密、全局快捷键注册、系统媒体控制、自动更新。渲染进程所有 UI、所有音频播放逻辑、所有状态管理。预加载脚本只暴露一层白名单 IPC 接口不做任何业务逻辑。音频播放放在渲染进程里是因为 Web Audio API 和 HTMLMediaElement 都只能在渲染环境里用。数据库放在主进程里是因为 better-sqlite3 是原生模块在渲染进程里加载会踩沙箱的坑。这条界线一旦定下来后面加功能的时候往哪边放就很清楚了涉及系统能力的进主进程涉及界面和声音的留在渲染进程。3. 把简约落到像素上界面骨架怎么搭定了简约接下来就是把它翻译成具体的尺寸和颜色。我见过太多项目在 PPT 上喊着简约代码里写满border-radius: 16px加三层阴影。这里给出 SPlayer 的实际规则你可以直接抄。布局是经典的三段式但砍掉了左侧一级导航顶部 48 像素的标题栏自绘隐藏系统标题栏中间是内容区底部固定 72 像素的播放条。内容区本身分两栏左边 240 像素固定宽度的歌单/曲库切换右边自适应列表。窗口最小尺寸定在 900×560再小布局就会挤。为什么不做左侧一级导航因为 SPlayer 只有曲库和歌单两个大模块用两个 Tab 就够了专门给两个 Tab 留一条 64 像素宽的竖栏属于浪费。颜色只有一个主色加一套中性灰阶深色为默认。灰阶从 #1a1a1a 到 #f5f5f5 分七档全局通过 CSS 变量引用切主题的时候只改变量表不改组件。:root { --bg-0: #141414; --bg-1: #1c1c1c; --bg-2: #242424; --text-1: #f2f2f2; --text-2: #a3a3a3; --text-3: #6b6b6b; --accent: #ec4141; --radius-sm: 6px; --radius-md: 10px; --dur-fast: 120ms; --dur-base: 180ms; --ease-out: cubic-bezier(0.22, 1, 0.36, 1); }动效只有两个时长120 毫秒用于 hover 和按钮反馈180 毫秒用于列表项切换和面板展开。超过 200 毫秒的过渡在音乐播放器里是灾难——你会觉得整个界面黏糊糊的。3.1 长列表必须做虚拟滚动这一条是硬性要求不是优化项。本地曲库党手里动辄三五千首歌如果直接v-for渲染第一次进曲库页的时候会明显卡一下滚动的时候帧率掉到 30 以下。SPlayer 用的是固定行高的窗口化方案每行 56 像素容器高度除以行高算出可视行数上下各多渲染 5 行做缓冲。自己实现的话大概三十行const ROW 56; const BUFFER 5; function visibleRange(scrollTop, viewportH, total) { const start Math.max(0, Math.floor(scrollTop / ROW) - BUFFER); const end Math.min(total, Math.ceil((scrollTop viewportH) / ROW) BUFFER); return { start, end }; }配合一个撑开总高度的占位元素把真实行用transform: translateY(n * 56px)定位。注意不要用top做偏移translateY走的是合成层滚动的时候不掉帧这个差别在低配机器上非常明显。有个坑提前说虚拟列表和滚动到当前播放曲目会打架。因为目标元素可能根本不在 DOM 里scrollIntoView直接失效。解决办法是反推位置——知道目标索引直接scrollTop index * ROW - viewportH / 2 ROW / 2让它自己滚过去。3.2 空状态和加载状态不能省简约风格的项目最容易忽略状态设计。曲库为空的时候显示什么扫描中的时候显示什么搜索无结果的时候显示什么我的做法是每种状态给一句人话加一个操作按钮比如曲库为空时显示还没有添加音乐文件夹加一个选择文件夹的按钮。别小看这一块它决定了一个新用户打开软件后能不能自己走完第一步。4. 音频播放内核从audio标签到可用的播放器中间件最朴素的播放方式就是new Audio(url)能跑但很快会遇到三个问题没法做均衡器、切换音源的时候有爆音、系统媒体控制拿不到。SPlayer 的音频链路是这样的HTMLMediaElement负责取流和解码通过createMediaElementSource接进 Web Audio 的图里然后串一个GainNode做音量、串一串BiquadFilterNode做均衡、最后接AnalyserNode做频谱再连到destination。const ctx new AudioContext(); const el new Audio(); el.crossOrigin anonymous; const source ctx.createMediaElementSource(el); const gain ctx.createGain(); const analyser ctx.createAnalyser(); analyser.fftSize 512; const bands [60, 170, 350, 1000, 3500, 10000]; const filters bands.map((freq, i) { const f ctx.createBiquadFilter(); f.type i 0 ? lowshelf : i bands.length - 1 ? highshelf : peaking; f.frequency.value freq; f.Q.value 1; f.gain.value 0; return f; }); source.connect(filters[0]); filters.reduce((prev, cur) (prev.connect(cur), cur)); filters[filters.length - 1].connect(gain); gain.connect(analyser); analyser.connect(ctx.destination);这里有个必须注意的点AudioContext 必须等用户交互之后才能 resume。浏览器和 Chromium 的自动播放策略会挂起上下文如果你在组件挂载时直接建好就播会得到一个AudioContext was not allowed to start的警告然后什么声音都没有。我的做法是在第一次点击播放按钮的时候才创建整个图并把创建过程抽成一个懒加载的单例。4.1 切歌爆音和双声道叠音的根源这是我在这个项目里花时间最多的一个 bug第八章有完整的排查过程。简单说结论audio.pause()之后立刻设置新的src并调用play()如果上一个play()返回的 Promise 还在 pending 状态两个音源会在极短的时间内同时存在听起来就是一瞬间的双声道叠音或者噗的一声爆音。正确的做法是把切换拆成两步先pause()并把src置空等emptied事件之后再设置新的src。同时用一个递增的 requestId 标记每次切歌请求只有最新的那个请求的异步回调才被采纳。let reqId 0; async function playTrack(track) { const myId reqId; audio.pause(); audio.src ; await new Promise((r) { if (audio.readyState 0) return r(); audio.addEventListener(emptied, r, { once: true }); }); if (myId ! reqId) return; audio.src track.url; try { await audio.play(); } catch (e) { if (e.name ! AbortError) console.error(播放失败, e); } }那个AbortError的过滤很重要。快速连点下一首的时候前一次的play()Promise 会被后一次的pause()打断抛出一个AbortError这不是真的错误不该往日志里写更不该弹提示。4.2 无缝衔接双元素预加载真正意义上的无缝播放gapless用单个HTMLMediaElement做不到因为切换音源一定有一段缓冲时间。可行的方案是准备两个audio元素A 在播的时候把 B 的src设成下一首并preloadauto等 A 的ended事件触发立刻 B 播放、A 静音复位如此循环。代价是内存占用翻倍以及两个元素都要接进 Web Audio 的图里均衡器和频谱需要做切换。所以 SPlayer 把它做成一个开关默认关闭只在专辑连续曲目这种真正需要的场景下建议开启。4.3 系统媒体控制与全局快捷键桌面播放器不能没有媒体键支持。Chromium 提供了 Media Session API一套代码就能让系统音量面板、锁屏界面、蓝牙耳机的播放键都认你这个播放器navigator.mediaSession.metadata new MediaMetadata({ title: track.name, artist: track.artist, album: track.album, artwork: [{ src: coverUrl, sizes: 512x512, type: image/webp }], }); navigator.mediaSession.setActionHandler(play, () store.play()); navigator.mediaSession.setActionHandler(pause, () store.pause()); navigator.mediaSession.setActionHandler(nexttrack, () store.next()); navigator.mediaSession.setActionHandler(previoustrack, () store.prev());这里有个体感上的大坑当机器上同时开着多个播放器时媒体键的归属是不确定的。系统和浏览器、其他桌面播放器之间会互相抢焦点表现就是按耳机的播放键结果响的是另一个软件。这个问题没有完美的解法但可以做两件事降低冲突概率一是只在窗口聚焦或者刚刚有过播放行为时才注册 handler暂停超过一定时间后调用navigator.mediaSession.playbackState none释放二是提供一个设置项允许用户关掉全局快捷键注册把按键让给别人。5. 曲库与歌单本地元数据、封面缓存和状态同步数据层这块我试过三种方案最后选了 SQLite理由如下表方案优点缺点JSON 文件零依赖好调试上万首时全量读写改一条要重写整个文件IndexedDB渲染进程直接用主进程访问麻烦扫描任务没法在主进程做SQLitebetter-sqlite3同步 API 快、支持索引和全文检索原生模块打包要重新编译曲库规模一旦上千JSON 那种读全量、改内存、写全量的模式就不行了。我在一台 SSD 机器上测过八千首歌的 JSON 文件大概 6MB每次写入要 80 毫秒左右界面会有肉眼可见的卡顿。SQLite 加上索引之后同样的操作在 2 毫秒以内。表结构很简单核心就三张CREATE TABLE IF NOT EXISTS track ( id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, title TEXT, artist TEXT, album TEXT, duration INTEGER, bitrate INTEGER, format TEXT, cover_hash TEXT, mtime INTEGER, added_at INTEGER ); CREATE TABLE IF NOT EXISTS playlist ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER, sort_order INTEGER ); CREATE TABLE IF NOT EXISTS playlist_track ( playlist_id TEXT NOT NULL, track_id TEXT NOT NULL, position INTEGER NOT NULL, PRIMARY KEY (playlist_id, track_id) ); CREATE INDEX IF NOT EXISTS idx_track_artist ON track(artist); CREATE INDEX IF NOT EXISTS idx_track_path ON track(path);5.1 增量扫描别每次都全量读标签读音频标签是 IO 密集加 CPU 密集的操作一首歌几百毫秒八千首要跑很久。必须做增量以文件路径为主键比对mtime和文件大小只有变化过的文件才重新读标签。import { parseFile } from music-metadata; async function scanFolder(dir, db) { const files await walk(dir, [.mp3, .flac, .m4a, .wav, .ogg]); const seen new Set(); for (const file of files) { seen.add(file.path); const row db.prepare(SELECT mtime, size FROM track WHERE path ?).get(file.path); if (row row.mtime file.mtime row.size file.size) continue; const meta await parseFile(file.path, { duration: true }); // 写入或更新 } // 清理已经不存在于磁盘上的记录 const stale db.prepare(SELECT path FROM track).all() .filter((r) !seen.has(r.path)); // 批量删除 }扫描过程要放在主进程里跑通过 IPC 把进度推给渲染进程别让界面卡住。我建议每处理 200 个文件推一次进度太频繁的 IPC 反而拖慢整体速度。注意加密格式的文件在walk阶段就要过滤掉不要进入解析流程。解析器对这类文件会抛异常一旦几千个文件连续抛错日志会被刷爆。5.2 封面缓存用哈希命名加淘汰策略封面不要每次都从曲库里现读也别把 base64 塞进数据库。我的做法是把内嵌封面抽出来按内容哈希命名存到用户数据目录的covers/下数据库里只存哈希值import { createHash } from node:crypto; import { writeFile } from node:fs/promises; import { join } from node:path; async function cacheCover(buffer, coverDir) { const hash createHash(sha1).update(buffer).digest(hex) .webp; const target join(coverDir, hash); await writeFile(target, buffer); return hash; }同一个专辑的多首歌共享同一张封面哈希去重之后目录体积能小一个数量级。再配一个简单的淘汰启动时统计目录大小超过 300MB 就按最后访问时间删掉最老的一批。这个策略我用了很久从来没出现过封面丢失导致的界面空白。5.3 搜索就用 SQLite 的 LIKE够用最开始我想上 FTS5 全文索引折腾了一下午发现中文分词是个麻烦事。后来改成最朴素的方案把标题、歌手、专辑拼成一个搜索字段用小写匹配加LIKE %keyword%。八千首的数据量下一次查询大概 15 毫秒输入框防抖 200 毫秒体感完全够。过度设计在这个环节是纯浪费。6. 登录态与凭据安全把敏感数据关进系统保险箱播放器一旦涉及个人账号凭据存储就是必须严肃对待的事。这里先明确底线SPlayer 只服务于你自己的账号、你自己的使用场景不支持账号共享不提供任何内容的下载和二次分发也不提供绕过版权限制的能力。在这个前提下唯一需要认真处理的技术问题就是——登录凭证怎么存。最容易犯的错就是往localStorage里塞。Electron 应用虽然看起来像个本地软件但渲染进程本质上是个浏览器环境localStorage是明文的任何注入脚本或者开发者工具都能读走。同理写进 JSON 配置文件、写进 SQLite 明文列都是不合格的。正确做法是用 Electron 提供的safeStorage它底层调用的是各个系统自己的密钥保护机制Windows 上是 DPAPImacOS 上是 KeychainLinux 上是 libsecret。数据经过系统级加密之后再落盘密钥由操作系统保管应用本身拿不到。import { safeStorage } from electron; function saveCredential(key, value) { if (!safeStorage.isEncryptionAvailable()) { throw new Error(当前系统没有可用的加密后端); } const encrypted safeStorage.encryptString(value); db.prepare(INSERT OR REPLACE INTO credential (k, v) VALUES (?, ?)) .run(key, encrypted); } function readCredential(key) { const row db.prepare(SELECT v FROM credential WHERE k ?).get(key); if (!row) return null; return safeStorage.decryptString(row.v); }6.1 Linux 上的 libsecret 陷阱safeStorage.isEncryptionAvailable()在 Linux 上可能返回 false尤其是精简安装的发行版或者容器环境里没有装gnome-keyring/kwallet的时候。这个时候千万不要静默降级成明文存储那是把安全问题从能用变成出事故。SPlayer 的处理方式是检测到加密不可用时直接提示用户当前系统缺少密钥服务登录态将不会保存并且只在内存里保留本次会话的凭证退出即清空。用户会觉得有点麻烦但比在磁盘上留一份明文密码要好得多。6.2 日志脱敏是基本功开发期调试接口的时候很容易手一抖把整个请求头或者登录返回体打进日志。发布版本里必须加一层脱敏把所有可能的敏感字段替换掉再输出。const SENSITIVE [cookie, token, authorization, password, session]; function redact(obj) { const seen new WeakSet(); const walk (v) { if (v null || typeof v ! object) return v; if (seen.has(v)) return [circular]; seen.add(v); if (Array.isArray(v)) return v.map(walk); const out {}; for (const [k, val] of Object.entries(v)) { out[k] SENSITIVE.some((s) k.toLowerCase().includes(s)) ? [redacted] : walk(val); } return out; }; return walk(obj); }这三十行代码帮我避免过至少一次尴尬。发布之前建议全局搜一遍console.log确认没有把整个 headers 对象直接打出来的地方。7. 打包分发与体积控制从 200MB 往回收Electron 的体积问题是可以治的但不能治好。第一步得先接受现实空壳 Electron 应用在 Windows 上大概 150MB 到 180MBmacOS 上因为有 Universal 二进制会更夸张。我们要做的不是把它变回 20MB而是把不该带的都去掉。第一刀砍 locales。Chromium 会带几十种语言的本地化文件一个播放器根本不需要。在 electron-builder 的配置里只保留中英文。第二刀砍 asar 里的源码映射和测试文件。生产包里带上.map文件既增大体积又泄露源码结构构建配置里关掉 sourcemap通过files字段做白名单。第三刀是压缩。electron-builder 支持compression: maximum配合 7z 压缩率明显提升代价是打包时间从一分钟变成五六分钟只在正式发布时开。{ build: { appId: com.example.splayer, asar: true, compression: maximum, files: [ dist/**, dist-electron/**, !**/*.map, !**/test/**, !**/*.md ], electronLanguages: [zh-CN, en-US], win: { target: [nsis, portable] }, mac: { target: [dmg], category: public.app-category.music }, linux: { target: [AppImage, deb], category: Audio } } }优化项优化前Windows优化后默认语言包约 180MB约 165MB移除 sourcemap 与文档约 165MB约 152MB开启 maximum 压缩约 152MB约 118MB装完之后还要盯内存。空载状态下 SPlayer 的常驻内存大概在 90MB 到 130MB播放时上浮 20MB 左右。如果发现空载超过 200MB多半是某个渲染进程里有定时器在跑或者有大量 DOM 没被回收用开发者工具的内存快照对比两次就能定位。8. 踩坑排查链路实录三个让我通宵的问题前面几章提了一些结论这一章把排查过程完整铺开因为怎么找到问题比答案是什么更有参考价值。8.1 双声道叠音从错觉到状态机现象是快速点两下下一首会有极短的一瞬间两个声音叠在一起大概几十毫秒。一开始我怀疑是硬件或驱动问题换了三台机器都能复现排除环境因素。第二步我在play和pause的入口都打了时间戳日志发现两个play()调用的间隔只有 30 毫秒而第一个play()返回的 Promise 在第二个调用之后才 resolve。也就是说两次播放请求确实同时在飞。第三步我在音频节点后面加了一个AnalyserNode把实时音量打出来波形上清楚看到切换瞬间出现了两个不同频率的峰值。到这里基本可以确认不是听感错觉是两条音轨真的同时在输出。根因是play()是异步的而我当时的代码是同步地暂停旧的、设置新的、开始播中间没有任何等待。修复方案就是 4.1 节那段代码把切换拆成暂停并清空 → 等待 emptied → 设置新源 → 播放并且用递增的 requestId 做幂等只有最后一次请求的结果被采纳。修完之后还做了一件事给状态机加了单元测试因为这类竞态问题一旦修好就很难再复现不写测试的话下次重构十有八九会把它带回来。8.2 封面加载 403请求头里的玄机扫描完之后一部分曲目的封面在列表里显示不出来控制台报 403。奇怪的是同一张专辑里有些能显示、有些不能。排查的方法是先把出错的封面 URL 复制出来在浏览器地址栏里直接打开——能正常显示。这就排除了链接本身失效的可能问题一定出在请求特征上也就是请求头。对比正常请求和失败请求之后发现失败的那批请求缺少Referer或者User-Agent是 Electron 默认的那一长串。解决办法是在主进程里统一拦截出站请求给图片资源补上合理的请求头并且把 UA 收敛成一个常规值。session.defaultSession.webRequest.onBeforeSendHeaders((details, cb) { const headers { ...details.requestHeaders }; if (!headers[Referer]) headers[Referer] https://example.com/; headers[User-Agent] Mozilla/5.0 (Windows NT 10.0; Win64; x64); cb({ requestHeaders: headers }); });这里要提醒一句拦截所有请求改 UA 是有副作用的某些接口会因为 UA 变化而返回不同的数据结构。稳妥的做法是只对静态资源域名做处理用details.url做前缀判断。8.3 打包后启动崩溃原生模块 ABI 不匹配开发环境一切正常打包安装后一启动就白屏主进程日志里有一行NODE_MODULE_VERSION 115 vs 108。这个报错的意思很直白better-sqlite3 是针对 Node 的 ABI 编译的而 Electron 内置的 Node 版本 ABI 不一样。修复方式是在开发依赖里装上 electron-rebuild在postinstall钩子里跑一遍{ scripts: { postinstall: electron-rebuild -f -w better-sqlite3, build: vite build electron-builder } }但光这样还不够CI 上同样要在构建前执行一次否则打出来的包里引用的还是旧的二进制。另外提醒一点如果你用了多平台构建每个平台的原生模块必须在本平台编译跨平台交叉编译的原生模块基本都会在运行时报错。稳妥的做法是用三台机器或者三个 CI runner 分别构建各自的产物。我在这个项目上的一点实际体会做完这一版之后我最大的感受是播放器这类工具的难点从来不在功能而在状态。界面画起来很快接口调通也不难真正消耗时间的是那些偶发一次、难以复现的竞态和边界情况——快速切歌时的音频叠音、扫描进行中删除文件导致的记录残留、网络断开后播放队列恢复失败、多显示器拔插之后窗口跑到屏幕外面。这些东西没有任何教程会告诉你只能自己一个一个踩过去。如果让我给准备动手写播放器的人一条建议那就是先把状态机画出来再写代码。什么状态可以转到什么状态哪些转移是异步的、可能被打断的哪些操作必须串行。等这个图画清楚了你会发现前面那些看起来玄学的 bug绝大多数都能在代码写出来之前就被规避掉。剩下的小部分靠日志和波形图也能定位得非常快。
网站建设高端定制企业官网