1. 项目缘起与整体设计思路1.1 为什么要自己动手做一个游戏下载管理器经常折腾单机游戏的朋友对FitGirl这个名号肯定不陌生。她的高压版资源以体积小、安装稳定著称但问题也随之而来资源分散在各个页面里每次找游戏、对版本、看配置要求、复制下载链接一套流程走下来少说十几分钟。更麻烦的是下载完还得手动整理安装包、记录安装密码、核对校验信息时间一长自己都忘了哪个包对应哪个游戏。我最初的诉求很简单能不能有一个自己的小工具把常玩的游戏资源集中管理起来点一下就能看到下载地址、安装说明、配置需求最好还能记录我下过哪些、装过哪些。市面上的下载管理器要么太重要么不支持自定义数据源索性自己用Electron搭一个。选Electron的理由很直接——我会写JavaScript、HTML、CSS用这套技术栈能快速做出跨平台的桌面应用Windows、macOS、Linux一套代码搞定不用为每个平台单独学一套UI框架。这个项目的定位不是“爬虫”也不是“破解工具”而是一个个人游戏资源信息管理器。它做的事情是把你自己整理好的游戏信息名称、版本、大小、下载链接、安装说明存进本地数据库通过一个干净的界面展示出来支持搜索、筛选、标记状态。所有数据来源都是你自己手动录入或从公开页面复制的工具本身不主动抓取任何内容。这一点必须先说清楚避免误解。适合谁来参考这篇内容如果你满足以下任意一条这篇分享对你就很有价值会一点前端基础想入门Electron桌面开发想给自己做一个专属的资源管理工具对主进程与渲染进程通信、本地数据持久化这些概念感兴趣或者单纯想看看一个完整的桌面小应用是怎么从零搭起来的。不需要你是资深工程师只要写过HTML和JavaScript跟着思路走就能复现。1.2 技术选型背后的取舍逻辑技术栈定的是Electron 原生JavaScript HTML CSS没有上Vue或React。这个决定我想重点解释一下因为很多人会问“为什么不用框架”。第一这个工具的核心逻辑是数据展示和本地存储交互复杂度不高。用原生JS操作DOM完全够用引入框架反而增加构建配置的负担。第二Electron本身已经带了Chromium和Node.js如果再叠加Vue的构建工具链打包体积和调试复杂度都会上升。第三对于想学习Electron主进程与渲染进程通信原理的人来说原生写法能让你更清楚地看到IPC进程间通信的每一步而不是被框架的封装遮住细节。当然如果你后续想扩展成多页面、多模块的复杂应用那时候再引入Vue也不迟。Electron打包Vue项目是完全可行的只是在这个阶段属于过度设计。我的原则是能用简单方案解决的问题不要提前引入复杂度。数据存储方面我用的是Node.js内置的fs模块配合JSON文件没有上SQLite。原因同样简单游戏条目数量级在几百条以内JSON读写性能完全够用而且JSON文件可以直接用文本编辑器打开修改调试起来非常方便。如果数据量涨到几千条以上再考虑迁移到SQLite也不迟。界面布局采用经典的左侧导航加右侧内容区结构。左侧放分类全部游戏、已下载、待下载、收藏右侧是游戏卡片列表和搜索栏。CSS方面用了Flexbox做整体布局卡片用Grid排列鼠标移入有轻微的阴影和位移反馈。这些细节后面会具体讲。2. 核心细节解析与实操要点2.1 Electron的主进程与渲染进程到底怎么分工这是整个项目最核心的概念也是新手最容易绕晕的地方。我用一个生活化的类比来解释把Electron应用想象成一家餐厅。主进程是后厨它掌握所有“重资源”——文件系统、数据库、系统托盘、窗口创建。渲染进程是前厅服务员它负责跟顾客用户打交道展示菜单、接收点单但它不能直接进后厨拿东西必须通过一个“传菜窗口”跟后厨沟通。这个传菜窗口就是IPC通信。具体到代码层面主进程运行在Node.js环境里可以require(fs)、require(path)可以创建BrowserWindow。渲染进程运行在Chromium环境里本质上就是一个网页能操作DOM、能写CSS但默认不能访问文件系统。两者通过ipcMain和ipcRenderer收发消息。这里有个关键点渲染进程不能直接调用Node.js的API。很多新手会尝试在渲染进程里写require(fs)然后报错说require is not defined。解决办法有两种一是通过preload脚本配合contextBridge暴露安全的API二是开启nodeIntegration不推荐有安全风险。我采用的是第一种方案这也是官方推荐的做法。preload脚本是一个特殊的JavaScript文件它在渲染进程加载页面之前执行运行在一个既有Node.js能力又有DOM访问权限的中间环境里。通过contextBridge.exposeInMainWorld我可以把特定的函数暴露给渲染进程的window对象渲染进程调用这些函数时实际上是在通过IPC向主进程发消息。举个例子渲染进程想读取游戏列表它调用window.api.getGames()这个函数在preload里被定义为ipcRenderer.invoke(get-games)。主进程用ipcMain.handle(get-games, ...)接收请求读取JSON文件返回数据。整个过程清晰、安全、可控。注意ipcRenderer.invoke和ipcMain.handle是成对的用于请求-响应模式。如果需要主进程主动推送消息给渲染进程比如下载进度更新要用webContents.send和ipcRenderer.on。两种模式不要混用。2.2 数据模型设计与JSON存储的实操细节游戏条目的数据结构我反复调整过几版最终定下来的字段如下{ id: unique-string, name: 游戏名称, version: v1.0.0, size: 35.2 GB, originalSize: 70.4 GB, downloadLinks: [ { label: 主链接, url: https://example.com/... } ], installNotes: 安装密码fitgirl, requirements: { os: Windows 10 64位, cpu: Intel Core i5-4460, ram: 8 GB, gpu: GTX 960, storage: 40 GB }, status: wishlist, tags: [动作, 开放世界], addedAt: 1690000000000, updatedAt: 1690000000000 }status字段用枚举值管理wishlist想玩、downloading下载中、downloaded已下载、installed已安装、completed已通关。这样左侧导航的筛选就变成了简单的数组过滤。JSON文件的读写有几个坑必须注意。第一写入时要用临时文件加改名的方式避免写入过程中程序崩溃导致文件损坏。具体做法是先写games.json.tmp写完后fs.renameSync覆盖原文件。第二读取时要处理文件不存在的情况首次启动时返回空数组而不是抛异常。第三编码统一用UTF-8中文游戏名不会乱码。const fs require(fs); const path require(path); const dataDir path.join(app.getPath(userData), data); const dataFile path.join(dataDir, games.json); function readGames() { try { if (!fs.existsSync(dataFile)) return []; const raw fs.readFileSync(dataFile, utf-8); return JSON.parse(raw); } catch (err) { console.error(读取失败, err); return []; } } function writeGames(games) { if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); const tmp dataFile .tmp; fs.writeFileSync(tmp, JSON.stringify(games, null, 2), utf-8); fs.renameSync(tmp, dataFile); }app.getPath(userData)返回的是系统分配给应用的专属数据目录Windows下大概是C:\Users\用户名\AppData\Roaming\你的应用名。把数据放这里的好处是卸载重装不会丢也不会污染项目目录。2.3 界面布局与CSS关键实现整体布局用Flexbox最外层body设为display: flex; height: 100vh; overflow: hidden;左侧导航固定宽度240px右侧内容区flex: 1并设置overflow-y: auto。这样左侧不动、右侧独立滚动符合桌面应用的操作习惯。游戏卡片列表用CSS Gridgrid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px;。这行代码的意思是每张卡片最小280px根据容器宽度自动决定一行放几张间距16px。实测在1080p屏幕上一般一行放4张2K屏放5到6张自适应效果很好。卡片悬停效果用了transform: translateY(-4px)配合box-shadow过渡鼠标移入时卡片轻微上浮。这里有个细节过渡要加在卡片本身而不是悬停状态上否则鼠标移出时动画会瞬间消失。.card { transition: transform 0.2s ease, box-shadow 0.2s ease; border-radius: 8px; background: #1e1e2e; padding: 16px; } .card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }搜索框的实时过滤用input事件监听每次输入都重新渲染列表。数据量小的时候没问题如果条目多了要做防抖。防抖的实现很简单用一个变量存setTimeout的返回值每次输入先clearTimeout再重新设置。状态标签的颜色区分想玩用蓝色下载中用橙色已下载用绿色已安装用紫色已通关用灰色。用CSS类名切换不要内联样式方便统一调整。3. 实操过程与核心环节实现3.1 从零初始化项目与依赖安装第一步建一个空文件夹比如叫game-manager进去执行npm init -y生成package.json。然后安装Electronnpm install electron --save-dev这里建议锁定版本比如electron28.0.0避免不同版本API差异导致代码跑不起来。安装完成后在package.json里加一行main: main.js再配一个启动脚本start: electron .。项目目录结构如下game-manager/ ├── main.js # 主进程入口 ├── preload.js # 预加载脚本 ├── renderer/ │ ├── index.html # 主界面 │ ├── style.css # 样式 │ └── app.js # 渲染进程逻辑 ├── package.json └── data/ # 开发时的数据目录打包后改用userDatamain.js里创建窗口的核心代码const { app, BrowserWindow, ipcMain } require(electron); const path require(path); let mainWindow; function createWindow() { mainWindow new BrowserWindow({ width: 1200, height: 800, minWidth: 900, minHeight: 600, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false } }); mainWindow.loadFile(renderer/index.html); } app.whenReady().then(createWindow); app.on(window-all-closed, () { if (process.platform ! darwin) app.quit(); });contextIsolation: true和nodeIntegration: false是安全基线必须这么设。preload指向预加载脚本的绝对路径。3.2 IPC通信的完整实现链路preload.js的内容const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(api, { getGames: () ipcRenderer.invoke(get-games), addGame: (game) ipcRenderer.invoke(add-game, game), updateGame: (id, patch) ipcRenderer.invoke(update-game, id, patch), deleteGame: (id) ipcRenderer.invoke(delete-game, id), openExternal: (url) ipcRenderer.invoke(open-external, url) });主进程里对应的处理器ipcMain.handle(get-games, () readGames()); ipcMain.handle(add-game, (event, game) { const games readGames(); game.id Date.now().toString(36) Math.random().toString(36).slice(2, 8); game.addedAt Date.now(); game.updatedAt Date.now(); games.push(game); writeGames(games); return game; }); ipcMain.handle(update-game, (event, id, patch) { const games readGames(); const idx games.findIndex(g g.id id); if (idx -1) return null; games[idx] { ...games[idx], ...patch, updatedAt: Date.now() }; writeGames(games); return games[idx]; }); ipcMain.handle(delete-game, (event, id) { let games readGames(); games games.filter(g g.id ! id); writeGames(games); return true; });渲染进程里调用就非常直观了async function loadGames() { const games await window.api.getGames(); renderList(games); }openExternal用shell.openExternal(url)实现点击下载链接时用系统默认浏览器打开而不是在应用内跳转。这样既安全又符合用户习惯。提示ipcRenderer.invoke返回的是Promise渲染进程里记得用await或.then处理。如果主进程处理器抛异常Promise会reject渲染进程要加try-catch否则控制台会报未捕获的Promise错误。3.3 搜索、筛选与状态管理的落地搜索逻辑放在渲染进程因为数据已经全部加载到内存里了。维护一个全局变量allGames存全量数据一个currentFilter存当前筛选条件一个keyword存搜索词。每次这三个值有变化就重新计算显示列表。let allGames []; let currentFilter all; let keyword ; function applyFilters() { let list allGames; if (currentFilter ! all) { list list.filter(g g.status currentFilter); } if (keyword.trim()) { const kw keyword.trim().toLowerCase(); list list.filter(g g.name.toLowerCase().includes(kw) || (g.tags || []).some(t t.toLowerCase().includes(kw)) ); } renderList(list); }搜索框监听let debounceTimer; searchInput.addEventListener(input, (e) { clearTimeout(debounceTimer); debounceTimer setTimeout(() { keyword e.target.value; applyFilters(); }, 200); });200毫秒的防抖在实测中手感最好既不会觉得卡顿也不会每敲一个字就重绘。左侧导航的点击事件切换currentFilter然后调applyFilters。状态切换用卡片上的下拉菜单或按钮组点击后调window.api.updateGame(id, { status: newStatus })成功后更新本地allGames里对应条目的状态再重新应用筛选。这里有个优化点不要每次都重新从主进程拉全量数据本地更新即可减少IPC往返。3.4 添加游戏表单与数据校验添加游戏的表单用一个模态框实现。字段包括名称必填、版本、大小、下载链接支持多条、安装说明、配置需求、标签。提交前做基本校验名称不能为空下载链接必须是合法的URL格式。function validateGame(data) { const errors []; if (!data.name || !data.name.trim()) errors.push(游戏名称不能为空); if (data.downloadLinks) { for (const link of data.downloadLinks) { try { new URL(link.url); } catch { errors.push(链接格式错误${link.url}); } } } return errors; }URL校验用new URL()构造函数比正则表达式可靠得多。如果抛异常说明格式不合法。校验通过后调window.api.addGame(data)成功后关闭模态框、刷新列表、弹出提示。标签输入用逗号分隔的文本框提交时split(,)再map(trim)再filter(Boolean)去掉空项。这个处理链看起来很基础但实际用起来很顺手用户输入“动作, 开放世界, ”这种带尾逗号的情况也能正确处理。4. 常见问题与排查技巧实录4.1 开发过程中踩过的典型坑问题一渲染进程报错“require is not defined”。这是新手最常见的问题原因是在渲染进程里直接用了Node.js的API。解决办法就是前面说的所有Node.js操作放主进程渲染进程通过preload暴露的window.api调用。如果你确实需要在渲染进程里用某个Node模块把它包装成IPC方法。问题二ipcRenderer.invoke没有返回Promise一直pending。检查主进程里是否用了ipcMain.handle而不是ipcMain.on。on是单向的不会返回结果。另外检查频道名称是否完全一致大小写敏感。问题三打包后数据文件找不到。开发时用__dirname相对路径没问题打包后__dirname指向的是asar包内部不可写。必须改用app.getPath(userData)。这个问题我在第一次打包后才发现数据全丢了后来加了迁移逻辑才解决。问题四中文游戏名在JSON里显示为乱码。读写时都要指定utf-8编码。fs.readFileSync(file, utf-8)和fs.writeFileSync(file, data, utf-8)两个都不能省。问题五窗口关闭后应用没退出。macOS上这是正常行为Windows上要监听window-all-closed事件并调app.quit()。如果还不行检查是否有隐藏窗口或托盘图标没销毁。问题六CSS样式在打包后失效。检查index.html里的引用路径是否用了相对路径打包后绝对路径会失效。统一用./style.css这种相对路径。4.2 常见问题速查表问题现象可能原因排查方向解决方案require未定义渲染进程直接调Node API检查报错文件位置改用preload暴露的APIIPC无响应用了on而非handle检查主进程代码改用ipcMain.handle数据丢失打包后路径不可写检查存储路径改用app.getPath中文乱码编码未指定检查读写代码统一加utf-8参数样式失效路径问题检查link标签改用相对路径应用不退出未处理关闭事件检查app监听加window-all-closed处理搜索卡顿无防抖检查input监听加setTimeout防抖卡片布局错乱Grid兼容问题检查CSS加auto-fill和minmax4.3 几个提升体验的实操心得第一个心得给下载链接加复制按钮。用户经常需要把链接复制到下载工具里直接点击打开浏览器反而不是最高频的操作。用navigator.clipboard.writeText(url)实现复制复制成功后按钮文字临时变成“已复制”1.5秒后恢复。这个小细节用户反馈非常好。第二个心得数据导出与导入功能。JSON文件虽然可以直接编辑但普通用户不会去翻AppData目录。加一个“导出数据”按钮用dialog.showSaveDialog让用户选保存位置把JSON写过去。导入同理。这样换电脑时数据迁移就很简单。第三个心得窗口尺寸和位置记忆。用electron-store或者自己写一个配置文件记录窗口的width、height、x、y下次启动时恢复。实现方式是监听close事件时保存createWindow时读取。用户体验提升明显尤其是多显示器用户。第四个心得列表虚拟滚动。如果游戏条目超过500条一次性渲染所有卡片会卡。可以用IntersectionObserver做懒加载或者简单点做分页。我的做法是每页显示50条底部加“加载更多”按钮。数据量不大的话不用做但知道这个扩展方向有备无患。第五个心得错误日志落盘。主进程里用try-catch包住所有IPC处理器出错时把错误信息追加写到userData/logs/error.log。用户遇到问题时让他把这个文件发过来排查效率比问“你点了什么”高十倍。4.4 后续可扩展的方向这个工具目前是纯本地的后续如果想做多设备同步可以把JSON数据放到用户自己的云盘目录里比如某个同步文件夹应用读写那个路径即可不需要自己搭服务器。另一个方向是加一个简单的统计面板显示各状态游戏数量、总占用空间、最近添加等用CSS画柱状图就行不需要引入图表库。还有一个实用的扩展是安装清单生成。选中几个游戏一键生成一个文本清单包含游戏名、版本、大小、安装密码方便批量操作时对照。这个功能实现起来就是字符串拼接但实际用起来很省事。最后再分享一个小技巧Electron的开发者工具里CtrlShiftI打开DevTools在Console里可以直接访问window.api调试IPC调用非常方便。主进程的日志则输出在启动应用的终端里两边对照着看大部分问题都能快速定位。
