从SwiftUI到Electron:跨平台软著代码整理工具实战指南
2026/9/15 5:53:02 网站建设 项目流程

软著申报季,别人在等材料,我在等工具跑完。把源码拖进窗口,点一下“整理”,一整套符合要求的 txt 文档就摆在桌面上了。这个《软著代码整理工具》一开始是用 SwiftUI 写的,后来因为要给 Windows 上的同事用,我干脆用 Electron 重写了一遍。整个过程不算复杂,但坑确实不少,尤其是从 macOS 原生切换到 Web 技术栈那一套思维转变,可能很多人都会卡住。今天把这轮迁移里从需求拆解、界面设计、核心清洗逻辑到打包发布的完整过程都写出来,希望对以后要做类似桌面工具的你有帮助。

先说清楚这东西是干什么的。软件著作权申请时,官方要求提交源程序文档,一般要“前后各连续 30 页,每页 50 行”,不足 60 页的全部提交,页眉标注软件名称与版本号,页码标注在右上角。手动操作的话,你得从海量源码里挑出有代表性的部分、剔除空行和注释、按页面格式排版,再合并导出。一次申报动辄几百个文件,手动整理几乎是个体力活。这个小工具要解决的就是这件事:自动扫描源码目录,按规则过滤文件,清洗无效行,再自动切页导出,把一小时的手工劳动压缩到几秒钟。

这篇文章适合谁看?一是要报软著但不想手工排版的开发者,二是正打算把原生应用或纯网页应用往 Electron 上迁移的人,三是在 electron 打包、文件读写、菜单配置上踩坑的人。文中不会有太多官方文档里照搬的内容,更多是实际开发中一次次试出来的经验和教训。

1. 为什么把 SwiftUI 版本推倒重来

1.1 最初的 SwiftUI 原型

第一版用 SwiftUI 写的时候,整个思路其实是偏“macOS 原生工具”的设计。主窗口左边是目录树和文件筛选区,右边是一个分页预览,底部是一条处理进度和导出按钮。核心逻辑拆成了几个类:SourceScanner负责递归扫描目录,CodeCleaner负责剥离注释和空行,PageComposer负责按行数分页和拼页眉页脚。UI 上用ObservableObject做状态管理,通过NSOpenPanel选择目录,再用FileManager读取文件内容。

那个版本在 macOS 上跑得很顺,界面响应也快,劣势在跨平台环节暴露了:团队里负责材料提交的同事用的是 Windows,没法装 macOS 应用。为了一次整理脚本能直接给全组用,就必须重新考虑一套能跨 Windows 和 macOS 的方案。

1.2 迁移的真正导火索

有人可能觉得,软著材料整理嘛,写个命令行脚本也能搞定,为什么非得做个带界面的工具?这个问题在我做 SwiftUI 版时也被问过几次。最直接的答案是:提交材料的人不一定是开发者,你要让同事在终端里敲python organize.py --path ... --outdir ...,他们会直接把源码甩给你让你手动来;但如果你给的是双击就能打开的图形工具,他们自己就能操作。所以从需求上讲,这个工具天然需要一个尽量简单、友好的图形界面,而 Electron 是能在不付出太多额外成本的情况下快速复制 UI 的成熟方案。

另一个原因是生态。SwiftUI 版本的代码整理规则写死在 Swift 里,改一条规则要重新 build;而 Electron 版本把核心清洗逻辑放到 JS 层,配合热更新或直接改配置文件就能调整,对非固定的软著格式要求友好很多。

1.3 SwiftUI 与 Electron 的选型对比

做迁移前,我把两种方案列了个表,从几个关键维度做了对照:

维度SwiftUI 原生Electron + Vue 3
跨平台支持仅 macOSWindows / macOS / Linux
UI 开发效率中,Swift 语法 + 预览调试高,熟悉 Web 技术栈即可
打包安装包体积小(几 MB)较大(一般 80-150 MB)
内存占用较高(Chromium 引擎)
文件系统、子进程等能力强,Node.js 生态更丰富
分发方式不方便(非商店安装繁琐)打包 dmg/exe 直接发给同事

说实话,如果只给自己用,我大概率继续用 SwiftUI。但考虑到“给同事用”这个目标,Electron 带来的跨平台部署便利远超它多占用的那点磁盘和内存。而且整个工具处理的是纯文本文件,用 Node 操作本地文件系统的体验也很顺畅,不存在性能瓶颈问题。

这里我个人的建议是:不要被“Electron 很吃内存”这种话一票否决,先看使用场景。像这类轻量工具,用户打开用完就关,并不会长期驻留后台,内存占用问题基本可以忽略,跨平台带来的收益反而是实打实的。

2. 软著代码整理的核心流程与规则设计

2.1 从源码目录到申报文档的完整链路

无论用 SwiftUI 还是 Electron,这个工具的核心逻辑链路是一样的,从头到尾分五个阶段:

  1. 选择源码根目录,扫描全部文件。
  2. 按扩展名、目录名过滤掉不需要的文件。
  3. 清洗代码,去掉空行和注释行。
  4. 截取头部 30 页和尾部 30 页(按每页 50 行计算)。
  5. 拼接所有文件,插入页眉和页码,导出为 txt 文档。

看起来简单,但每一步都有细节上的选择要做,尤其是“注释行”怎么定义,直接决定了清洗结果的可用性。

2.2 目录遍历与文件过滤策略

扫描目录用的是 Node 的fs.readdir递归实现,这不是什么复杂操作,但必须有明确的忽略规则,否则项目里一个node_modules就能让你的整理结果变成几千页没用的文件。

我定义了一个shouldIgnore函数,默认忽略这些目录和文件:

  • 目录:node_modules.git.svndistbuildcoveragePodsDerivedData
  • 文件:*.lock*.png*.jpg*.ico*.pdf.DS_Store

同时按源码类型过滤扩展名。比如这次整理的 Electron 项目,就只保留.js.ts.vue.css.html.json。软著材料通常要求源程序语言对应相关文件,所以我把扩展名配置放到设置项,让用户自己增删。

这里有几个容易踩的坑:一是node_modules这种目录必须忽略,而且要连同“目录名匹配”一起判断,不能只靠文件扩展名;二是文件过大时不能一次性readFile整份进内存,尤其是遇到几个 MB 级别的源码文件,建议用流式读取或限制单文件大小;三是路径分隔符,在 Windows 上一定要用path.join,不要自己拼/\\

2.3 注释清洗的边界情况与实现细节

去掉空行容易,line.trim() === ''就完事了。去掉注释行则复杂得多,不是简单if(line.startsWith('//')) continue就能解决的。

我遇到过最典型的几个情况:

  • 块注释跨多行,例如/* ... */中间的内容不能当有效代码。
  • 行内注释,例如const a = 1; // 注释,需要保留代码部分。
  • URL 里包含//,例如http://example.com,不能误判为注释。
  • 字符串里包含//,例如const s = "abc//def",同样不能误判。

所以完整清洗逻辑不能只按行判断,需要维护一个“是否处于块注释中”的状态机,逐行扫描。大体思路是:

let inBlockComment = false for (line of lines) { if (inBlockComment) { if (line.includes('*/')) { inBlockComment = false } continue } const noBlock = removeBlockCommentPart(line) // 去掉本行中的块注释片段 if (lineIncludesUnterminatedBlockComment(noBlock)) inBlockComment = true const cleaned = removeLineComment(noBlock) // 只在非字符串位置处理 // if (cleaned.trim() !== '') outputLines.push(cleaned.trimEnd()) }

实测下来,这种状态机方案对绝大多数源码都能正确处理。最开始我用的是“字符串匹配 + 正则硬切”,结果碰到一个文件里有一行const url = 'http://example.com/path';,整行被切掉了一半,导出文档直接少了很多有效代码。从那以后就长记性了,处理注释必须带上下文,不能只看单行。

2.4 页眉和页码的生成

软著材料要求的页眉格式,一般是“软件名称 + 版本号”,右上角是页码。这里要注意,页眉是每一页都要重复的内容,不是只在第一页出现。

在纯文本导出时,我的实现方式是对每一页 50 行,在第 1 行位置插入页眉,在第 51 行插入分页符。比如:

软件著作权代码整理工具 V1.0 第 1 页 第 1 行源码内容 第 2 行源码内容 ... 第 50 行源码内容 软件著作权代码整理工具 V1.0 第 2 页 ...

这样导出后的 txt 文件直接用记事本打开就能看到清晰的页面划分,不需要额外装编辑软件。如果你后续要做 doc 或 pdf 版本,页眉页码需要交给模板引擎处理,但纯文本方案最省事,也是软著审核最容易通过的格式之一。

开始用 Electron 重写后,我把这一整条清洗链路写在主进程里,UI 只负责传参数和展示结果,这样既方便测试,也避免了渲染进程直接操作文件系统带来的安全问题。

3. Electron 端实操实现:从 Vue 3 界面到跨平台打包

3.1 为什么选择 Electron + Vue 3 + Vite 组合

技术栈选型上,我用的是 Electron + Vue 3 + Vite,包管理器用 pnpm。选择 Vue 而不是 React,纯粹是因为项目里其他成员更熟 Vue,而且vue-routerpinia这套组合对小工具来说足够轻;Vite 做渲染进程的构建很快,配合 electron 开发时的热更新体验要远好于早期 webpack 那套方案。至于 pnpm,主要原因是磁盘占用小、安装速度快,而且在对 electron 这类二进制依赖的管理上,它的pnpm approve-builds机制比 npm 更能避免意外执行安装脚本。

项目结构大致是这样的:

electron-code-organizer/ ├─ electron/ │ ├─ main.ts // 主进程 │ ├─ preload.ts // 预加载脚本 │ └─ organizer/ // 核心整理逻辑 │ ├─ scanner.ts │ ├─ cleaner.ts │ └─ composer.ts ├─ src/ │ ├─ views/ │ │ ├─ HomeView.vue │ │ └─ PreviewView.vue │ ├─ stores/ │ └─ router/ ├─ electron-builder.yml └─ package.json

这个结构把主进程逻辑和渲染进程页面完全分开,代码不至于乱成一锅粥。如果你也想复用这套结构,可以直接照抄目录思想,但具体文件命名可以按自己的习惯调整。

3.2 通过 preload 脚本解决文件系统访问的安全问题

Electron 里有个很容易犯的错误:在渲染进程直接开启nodeIntegration: true,然后用require('fs')读写文件。这样确实能跑,但等于把整个操作系统的能力暴露给了网页代码,以后只要页面里引入了任何不可信的脚本或内容,攻击者就能直接读写磁盘。对工具类应用来说,这个风险不值得冒。

标准做法是关闭渲染进程的 Node 集成,通过contextBridge在 preload 里暴露必要的 API。我实际写的 preload 代码类似这样:

import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('api', { selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'), scanFiles: (rootPath: string) => ipcRenderer.invoke('organizer:scan', rootPath), organize: (options: OrganizeOptions) => ipcRenderer.invoke('organizer:run', options), readPreview: (filePath: string, start: number, end: number) => ipcRenderer.invoke('file:readRange', filePath, start, end), })

主进程里用ipcMain.handle注册对应的处理函数,比如选择目录:

ipcMain.handle('dialog:selectFolder', async () => { const result = await dialog.showOpenDialog({ properties: ['openDirectory'], }) if (result.canceled) return null return result.filePaths[0] })

这样渲染进程里只能调用window.api.selectFolder()等几个受控方法,不能直接触碰 Node API,安全性和可维护性都会好很多。实际开发时把这套 IPC 设计成“需要的接口最小集”,尽量不要一股脑把整个fs模块暴露出去。

3.3 自定义菜单栏与快捷键配置

Electron 默认菜单是一个英文的通用菜单,放在工具里很不协调。我改成了三组菜单:文件、工具、帮助,并把最常用的操作绑定到快捷键。

菜单配置的关键点在于Menu.buildFromTemplate+Menu.setApplicationMenu,模板写法如下:

const template: Electron.MenuItemConstructorOptions[] = [ { label: '文件', submenu: [ { label: '打开源码目录', accelerator: 'CmdOrCtrl+O', click: () => mainWindow?.webContents.send('menu:openFolder') }, { type: 'separator' }, { label: '导出文档', accelerator: 'CmdOrCtrl+E', click: () => mainWindow?.webContents.send('menu:export') }, { type: 'separator' }, { role: 'quit', label: '退出' }, ], }, ... ]

这里有个细节,菜单点击事件如果直接写在主进程里调用函数,会与渲染进程当前状态脱节,因为你不知道页面上有没有弹出预览、目录树处于什么状态。所以我用webContents.send把菜单事件转发给渲染进程,渲染进程里的监听器再去调用对应 action。这种“主进程发事件、渲染进程做处理”的模式,比直接共享全局变量清晰得多。

在 macOS 上还要注意,默认的app菜单(就是最左边带应用名那个)如果被覆盖掉,会导致 Cmd+Q、Cmd+C/V 等系统操作失效。所以我在模板最前面保留了一份role齐全的 app 菜单,不然后续会收到一堆同事反馈“怎么连复制粘贴都不行”。

3.4 pnpm + electron-builder 打包配置与常见用法

打包是这轮迁移里花时间最多、也最容易踩坑的一步。我用的是 electron-builder,配置写在electron-builder.yml里,核心部分如下:

appId: com.example.codeorganizer productName: 代码整理工具 directories: output: release files: - dist/**/* - electron/** - package.json mac: target: [dmg] category: public.app-category.developer-tools win: target: [nsis] nsis: oneClick: false allowToChangeInstallationDirectory: true perMachine: false

打包前需要先用 Vite 把渲染进程构建到dist目录,再把electron/main编译到dist-electron或直接让 electron-builder 读取源码。我这边习惯用两个脚本配合:

{ "scripts": { "dev": "vite", "build:renderer": "vite build", "build:electron": "tsc -p electron/tsconfig.json", "pack:mac": "pnpm build:renderer && pnpm build:electron && electron-builder --mac", "pack:win": "pnpm build:renderer && pnpm build:electron && electron-builder --win" } }

electron-builder 在工作时会去下载对应平台的二进制文件,国内环境下这一步非常慢,经常卡在“download electron-vXX.zip”上。解决办法是把下载镜像环境变量指到国内备源,比如在项目根目录的.npmrc里加配置,或者在打包命令前临时指定镜像地址。需要提醒的是,这属于正常开发流程里的环境优化,不涉及任何网络访问限制问题。

还有一个坑在于asar打包。默认情况下源码会打进app.asar,文件内容会变成只读。如果你的工具需要读取和写入用户选择的目录,那没啥问题,但如果你尝试写入__dirname下的资源文件,就会失败。我在开发时把模板配置放在了应用目录下,结果打包后写模板直接“EACCES”,排查了半天才发现是 asar 的只读问题。后来把用户可变的文件路径都挪到了app.getPath('userData')或用户显式选择的目录,问题才解决。

4. 从原生到 Web 技术栈,迁移中踩过的典型坑

4.1 路径分隔符与 Windows 兼容

SwiftUI 版本里,我处理路径大多用URLString拼接,到了 Windows 上这套完全不一样。最典型的问题是 Windows 路径是反斜杠\,而不少代码在拼接时会混用正斜杠/,导致路径解析失败。

解决的办法很简单:所有路径操作一律通过 Node 的path模块,拼接用path.join(root, relativePath),不要自己拼字符串。输出到日志或界面上时,可以用path.normalize统一格式,避免用户看到奇怪的双反斜杠。测兼容性时,最好在 Windows 上把整个流程从选目录、扫描、清洗到导出完整跑一遍,别只在 macOS 上验证后就发出去。

4.2 大目录扫描与 UI 卡顿

Electron 的主进程如果直接用同步readdirSync去遍历庞大的目录树,整个应用都会卡住,让你以为崩了。SwiftUI 版本里我用的是后台队列,到了 Electron 里对应的套路是“主进程异步 + 渲染进程展示进度”。

我在实现时用fs.promises.readdir配合Promise.allSettled来并行读取,但并行度不能无限大,否则文件句柄会打满。更稳妥的做法是维护一个简单并发池,每次只并发 20 个目录扫描任务。同时,把扫描进度通过webContents.send('scan-progress', { current, total })实时推给界面,让用户知道程序还在跑。

对于单个超大文件,读取时要限制大小。我在扫描器里加了一个配置:超过 2 MB 的文件直接跳过,并记录到“忽略列表”里,用户在界面上可以看到哪些文件没被纳入,避免莫名其妙少了代码。

4.3 源码文件编码识别与中文项目支持

很多开发者写的源码是 UTF-8,但我也遇到过大量历史项目用 GBK 或 GB2312 编码,尤其是 Windows 上的旧项目。直接用fs.readFile以 UTF-8 解析时会得到乱码,清洗后导出更是完全不能用。

处理方式分两步:先做编码探测,再转码。编码探测我用的是jschardet,检测到非 UTF-8 文件后,用iconv-lite转成 UTF-8 字符串再做清理逻辑。这里的成本是探测需要读一定量的字节,我一般只取文件开头 4 KB 做检测,对性能和准确率有比较好的平衡。

转码示例:

import jschardet from 'jschardet' import iconv from 'iconv-lite' import { readFileSync } from 'fs' function readText(filePath: string): string { const buffer = readFileSync(filePath) const detected = jschardet.detect(buffer) const encoding = detected.encoding === 'GB2312' || detected.encoding === 'GBK' ? 'gbk' : 'utf-8' return iconv.decode(buffer, encoding) }

另外一个问题是 BOM。UTF-8 with BOM 在读取时会在开头多出一个\uFEFF字符,如果打印到导出文件里,第一行看起来没问题,但实际会出现隐藏字符,影响后续处理。读取后我会统一replace(/^\uFEFF/, '')去掉 BOM。

4.4 菜单触发与 IPC 事件时序

前面提到菜单用webContents.send向渲染进程发事件,这里有个容易踩的时序问题:如果用户在界面还没加载完时就去点菜单,渲染进程里的监听器还没注册,事件就丢了,表现为“点了没反应”。

我的处理方式:主进程发送菜单事件后,如果窗口处于加载中,先不发送,等did-finish-load事件完成后再发送。更省事的做法是把“当前 UI 是否就绪”作为一个全局状态放在主进程里,渲染进程加载完后主动ipcRenderer.send('renderer-ready'),主进程收到这个信号后再允许菜单事件投递。这样虽然多了一步,但不会再出现静态菜单在启动时点了无效的尴尬情况。

5. 常见问题排查与避坑速查

把这段时间被问到的、以及自己在调整时遇到的问题整理成了一张速查表,方便后来人直接对症下药:

现象可能原因处理方法
打包后打开应用白屏渲染进程资源路径配置错误检查mainWindow.loadFile路径是否指向dist/index.html,不要用开发时的本地服务地址
安装包被杀毒软件误报electron-builder 打包产物没有代码签名Windows 上可购买代码签名证书;仅内部分发时可提醒用户添加信任
菜单点击没有响应IPC 事件在渲染进程监听器注册前发出等待renderer-ready信号,或把菜单动作改成ipcRenderer.invoke的主动调用模型
导出文件是乱码源码编码不是 UTF-8用 jschardet 探测 + iconv-lite 转码,读取时去掉 BOM
打开目录时没有权限读取macOS 的沙盒权限或 Windows 的路径访问限制确保 app 没有开启 sandbox,或正确配置dialog.showOpenDialog权限
打包过程一直卡住或下载失败electron 二进制下载慢或失败在 .npmrc 或环境变量中配置 electron 二进制镜像,再重试
文件扫描一次扫出几万个文件未忽略node_modules等目录完善shouldIgnore规则,限制单文件大小
打包后模板或资源文件修改无效asar 包内文件只读用户可变文件放到app.getPath('userData'),不要依赖__dirname

关于白屏问题再展开一句:开发时我会在main.ts里区分环境,开发环境用process.env.VITE_DEV_SERVER_URL加载本地服务,生产环境用loadFile加载构建产物。曾经有段时间我把开发环境的判断写反了,导致打包后一直去连本地 5173 端口,结果所有用户打开都是白屏。这种低级错误最好写成自动化判断,并每次打包后自己先双击安装包完整跑一遍主流程再分发。

6. 一点个人体会

从 SwiftUI 到 Electron,表面上是换了一套 UI 技术栈,实质上是把“给一个人用的工具”变成“给一个团队用的工具”。SwiftUI 版本让我把 macOS 原生的窗体、权限、文件面板摸得比较透,Electron 版本则让我重新认识了跨平台工程化的复杂度,尤其是打包和 IPC 设计这两块,几乎贯穿了后期所有迭代。

如果之后再有人问我这类小工具该选什么方案,我会先问:使用群体是你自己,还是包含 Windows 用户?如果是前者,直接用你最熟悉的方案,SwiftUI、Tauri、PyQt 都行;如果是后者,Electron 依然是最稳的选择。它不完美,体积大、内存高,但对“快速交付一个能用的跨平台桌面工具”来说,它的生态和踩坑资料是最全的。这个代码整理工具目前已经用满一个申报季,产出的文档顺利通过审核,说明整套流程是真实可靠的。接下来我准备给它加一个按目录结构生成代码树的功能,这样整理出来的文档看起来会更完整,到时再来分享新版本的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询