从零构建桌面音乐播放器 – Electron + React + TypeScript 实践

传统桌面音乐播放器要么功能单一只能播本地文件,要么界面还停留在十年前的风格。作为开发者兼音乐爱好者,我希望有一个既能管理本地音乐、又能从B站一键提取音频、界面还好看的播放器。在比较了WPF、Qt和Electron之后,最终选择了 Electron + React + TypeScript 的技术栈,花了两周时间从零构建了 FishMusic(MusicWave)。本文记录整个项目的架构设计、核心技术实现和踩坑经验。

一、技术选型:为什么是 Electron?

桌面应用的开发路线主要有三条:原生(C++/WinUI)、托管(C# WPF / Java Swing)、Web壳(Electron / Tauri)。最终选择 Electron 基于几个考量:

维度WPF (.NET)Qt (C++)ElectronTauri
UI 开发效率
生态/组件库极丰富
文件系统能力强(Node.js)强(Rust)
FFmpeg 集成需封装需封装npm 直装需Rust绑定
打包体积大(~150MB)小(~10MB)
跨平台仅Windows
桌面框架技术选型对比

关键决策因素:FishMusic 的核心功能依赖 FFmpeg(音频转码)和 HTTP 请求(B站 API 调用),Node.js 生态中 fluent-ffmpegnet.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_ridwts 参数。签名原理:

// 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,欢迎体验和贡献。

留下评论

您的邮箱地址不会被公开。 必填项已用 * 标注