传统桌面音乐播放器要么功能单一只能播本地文件,要么界面还停留在十年前的风格。作为开发者兼音乐爱好者,我希望有一个既能管理本地音乐、又能从B站一键提取音频、界面还好看的播放器。在比较了WPF、Qt和Electron之后,最终选择了 Electron + React + TypeScript 的技术栈,花了两周时间从零构建了 FishMusic(MusicWave)。本文记录整个项目的架构设计、核心技术实现和踩坑经验。
一、技术选型:为什么是 Electron?
桌面应用的开发路线主要有三条:原生(C++/WinUI)、托管(C# WPF / Java Swing)、Web壳(Electron / Tauri)。最终选择 Electron 基于几个考量:
| 维度 | WPF (.NET) | Qt (C++) | Electron | Tauri |
|---|---|---|---|---|
| UI 开发效率 | 中 | 低 | 高 | 高 |
| 生态/组件库 | 中 | 中 | 极丰富 | 少 |
| 文件系统能力 | 强 | 强 | 强(Node.js) | 强(Rust) |
| FFmpeg 集成 | 需封装 | 需封装 | npm 直装 | 需Rust绑定 |
| 打包体积 | 中 | 小 | 大(~150MB) | 小(~10MB) |
| 跨平台 | 仅Windows | 好 | 好 | 好 |
关键决策因素:FishMusic 的核心功能依赖 FFmpeg(音频转码)和 HTTP 请求(B站 API 调用),Node.js 生态中 fluent-ffmpeg 和 net.request 可以直接满足这些需求,无需额外的 Native Addon 开发。此外,Ant Design 5 提供了成熟的 UI 组件库,从 Table、Modal 到 Slider 一应俱全,大幅降低了 UI 开发成本。
二、整体架构设计
项目采用经典的 Electron 主进程 + 渲染进程 双层架构,通过 Preload 脚本暴露安全的 IPC 接口:
==========================================================
渲染进程 (Renderer)
React 18 + TypeScript + Ant Design 5 + Zustand
+-- pages/ (Home / Library / Playlists / Settings)
+-- components/ (Layout / PlayerBar / ExtractModal)
+-- stores/ (playerStore / playlistStore / persistence)
+-- hooks/ (useAudioPlayer / useCoverUrl)
----------------------------------------------------------
Preload (contextBridge)
window.electronAPI = {
extractBilibili, downloadAudio, convertToMP3,
cropAudio, getCoverUrl, selectDirectory, ...
}
----------------------------------------------------------
主进程 (Main)
Electron + Node.js
+-- ipc/bilibili.ts (IPC handler)
+-- services/bilibili.ts (B站 API / WBI)
+-- services/downloader.ts (net.request)
+-- services/ffmpeg.ts (fluent-ffmpeg)
+-- store/json-store.ts (JSON 原子读写)
==========================================================
2.1 安全模型:Preload + contextBridge
Electron 应用最大的安全隐患是渲染进程直接访问 Node.js API。FishMusic 遵循 Electron 安全最佳实践:
- contextIsolation: true — 渲染进程与主进程完全隔离,无法直接访问 Node.js
- nodeIntegration: false — 禁止渲染进程使用 require()
- Preload 脚本 — 通过
contextBridge.exposeInMainWorld暴露有限 API,所有参数经过类型校验 - IPC Handler — 主进程注册
ipcMain.handle('channel', handler),渲染进程通过window.electronAPI.methodName(args)调用,底层自动走ipcRenderer.invoke
这种设计确保即使前端代码被 XSS 攻击,攻击者也仅能调用 Preload 暴露的有限方法,无法直接执行系统命令。
三、B站音频提取:完整链路
这是 FishMusic 最核心也最复杂的功能。从用户粘贴链接到获得 MP3 文件,经历了多个步骤。
3.1 WBI 签名算法
B站从2023年起启用了 WBI(Web Interface)签名机制,所有 API 请求必须携带 w_rid 和 wts 参数。签名原理:
// 1. 从 B站 API 获取 img_key 和 sub_key(每24h过期)
const { img_key, sub_key } = await fetch('https://api.bilibili.com/x/web-interface/nav')
// 2. 裁剪密钥:去除非十六进制字符,取第9到第40位
const mixinKey = (img_key + sub_key).replace(/[^a-f0-9]/g, '').slice(8, 40)
// 3. 对请求参数排序、拼接、加盐、取MD5
const params = { ...originalParams, wts: Math.floor(Date.now() / 1000) }
const sorted = Object.keys(params).sort()
.map(k => k + '=' + encodeURIComponent(params[k])).join('&')
const w_rid = md5(sorted + mixinKey)
// 4. 最终请求 URL
const url = `https://api.bilibili.com/x/player/pagelist?${sorted}&w_rid=${w_rid}`
WBI 密钥每24小时轮换,因此应用启动时需要重新获取。实现时加了缓存逻辑,避免短时间内重复请求。如果请求返回 -352 错误码,自动刷新密钥并重试。
3.2 多节点下载与超时切换
B站视频的音频流托管在多个 CDN 节点上,不同运营商的访问速度差异很大。FishMusic 的下载策略:API 返回的 audio_streams 数组按带宽降序排列,优先尝试最快的节点;每个节点15秒连接超时,失败自动切下一个;使用 electron.net.request 而非浏览器 fetch,绕过 CORS 限制;下载进度通过 response.on('data') 实时回传给渲染进程。
3.3 FFmpeg 音频转码
B站下载的音频为 m4s 格式(MPEG-DASH 分段流),需要转码为 MP3。使用 fluent-ffmpeg 封装 FFmpeg:
import ffmpeg from 'fluent-ffmpeg';
import ffmpegStatic from 'ffmpeg-static';
ffmpeg.setFfmpegPath(ffmpegStatic);
export async function convertToMP3(inputPath: string, outputPath: string) {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.audioCodec('libmp3lame')
.audioBitrate(320) // 320kbps 高音质
.output(outputPath)
.on('progress', (info) => {
mainWindow.webContents.send('convert-progress', info.percent);
})
.on('end', resolve)
.on('error', reject)
.run();
});
}
关键点:ffmpeg-static 是预编译的 FFmpeg 二进制包。在 electron-builder.yml 中配置 asarUnpack: ['node_modules/ffmpeg-static/**'],因为 .asar 归档是只读的,ffmpeg 二进制必须解压到文件系统才能执行。
四、音频编辑器:wavesurfer.js 波形可视化
音频编辑是 FishMusic 的一大亮点。核心依赖 wavesurfer.js v7,基于 Web Audio API 的音频波形渲染库。
波形渲染流程:通过 fetch 加载音频文件 → AudioContext.decodeAudioData() 解码为 PCM → 对 PCM 做降采样生成波形成像数据 → Canvas 上绘制波形(X轴时间,Y轴振幅)。
FishMusic 在此基础上扩展了以下功能:
- 分割点管理:在波形上点击添加标记点,存储为时间戳。分割点列表通过 Zustand 管理,UI 中渲染为可拖拽的竖线
- 片段命名:每个分割区间自动生成默认名称(如”片段 1″),用户可自定义
- 独立试听:设置 Audio 的 currentTime 到片段起点,监听 timeupdate 在终点停止
- 批量导出:遍历分割点,调用 FFmpeg 的 -ss(起始时间)和 -t(持续时间)参数裁剪音频
五、状态管理与数据持久化
FishMusic 选择了 Zustand 而非 Redux。理由:零模板代码(Redux 需 action types、creators、reducers、middleware 四层,Zustand 仅需 create((set, get) => ({...})))、TypeScript 原生支持、压缩后仅 1KB、内置 Subscribe + Selector 细粒度订阅。
5.1 原子写入防数据损坏
数据持久化面临经典问题:写入 JSON 文件时如果进程崩溃,文件可能只剩一半,导致下次 JSON.parse 失败、所有歌单数据丢失。解决方案是原子写入:
async function atomicWrite(filePath: string, data: object) {
const tmpPath = filePath + '.tmp';
await fs.promises.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await fs.promises.rename(tmpPath, filePath); // OS保证原子性
}
操作系统保证 rename() 是原子操作:要么成功(新文件完整),要么失败(旧文件保留)。最坏情况是遗留 .tmp 文件,启动时检测并删除即可。
5.2 Debounce 自动保存
自动保存通过 Zustand 的 subscribe 实现。每次状态变化时触发,但通过 debounce 避免频繁写盘:歌单数据 300ms 防抖,设置数据 500ms 防抖。确保用户快速连续操作(如拖拽排序多首歌曲)只会触发一次文件写入。
六、封面图片代理与缓存
B站的图片 CDN 限制了 Referer,浏览器直接请求返回 403。FishMusic 通过主进程代理请求,添加合法 Referer,图片数据转 Blob URL 返回渲染进程:
ipcMain.handle('fetch-cover', async (_, url: string) => {
return new Promise((resolve) => {
const req = net.request({
url,
headers: { Referer: 'https://www.bilibili.com/' }
});
req.on('response', (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks).toString('base64')));
});
req.end();
});
});
渲染进程拿到 Base64 后通过内存缓存(Map,URL作key)存储,结合 LRU 策略限制缓存上限。
七、踩坑记录与经验总结
- ffmpeg-static 的 asarUnpack 坑:Electron 的 asar 是只读归档,ffmpeg 二进制必须在文件系统执行。需在 electron-builder.yml 中配置
asarUnpack,并在代码中通过app.isPackaged判断使用正确路径 - WBI 签名时效性:img_key/sub_key 每24小时过期,需要缓存并自动刷新。长时间不关应用时需要处理签名过期自动重试
- IPC 通信不要传大对象:音频 Buffer 不要通过 IPC 传输,改为传文件路径让主进程直接读写
- Zustand selector 优化:始终使用
useStore(state => state.specificField)而非解构整个 state,避免无关状态变化触发组件重渲染 - Electron 窗口性能:音频波形渲染时需开启
backgroundThrottling: false,防止 Chromium 降低后台窗口帧率导致波形卡顿
FishMusic 从项目初始化到第一个可用版本大约花了两周业余时间。Electron + React 的组合虽然打包体积偏大,但开发效率远超原生方案。项目已开源在 GitHub,欢迎体验和贡献。
