桌面应用最容易被低估的,不是某一个技术点,而是开局那一整组琐碎但绕不开的问题。
窗口要怎么做,启动时不能白屏,本地数据放在哪里,子进程怎么回收,对话框要不要走系统原生,语言和主题怎么切,配置文件要不要允许用户迁移。每件事单独看都不大,但如果一开始都随手处理,后面一定会在维护阶段还回来。
这篇文章记录的是一个基于 Wails v3 的桌面端脚手架。它的底层思路很直接:界面仍然用 Web 技术写,能力层交给 Go;Windows 上跑 WebView2,macOS 上跑 WKWebView,Linux 上跑 webkitgtk。前端资源通过 //go:embed 打进二进制,前后端之间用 Wails v3 生成的绑定互通。
它不是一个只用来展示“能跑起来”的 Demo,而是把桌面端早期最容易欠债的部分先收口:启动骨架、SQLite 持久化、设置页、原生对话框、子进程、工具层、i18n、主题和协作规范。
软件打开后的首页很克制:左侧是固定导航,主区域给业务页面留空间,默认只放了一个后端往返调用示例。这个页面本身不复杂,但它说明了脚手架的定位:先把窗口、布局、导航、服务调用和基础状态跑通,再让业务功能接进来。

下面这张图先看整体边界。WebView 负责渲染和交互,Go 负责系统能力和数据边界,中间通过 Wails v3 的绑定层连接。
不把 WebView 当普通浏览器写
WebView 看起来像浏览器,但桌面应用不能完全按浏览器页面的习惯写。
第一,它没有“另一个标签页”帮你分担压力。同步计算、过重的初始化、卡住的渲染都会落在同一个窗口里。页面卡住时,用户感受到的不是“网页慢”,而是“软件卡死”。
第二,存储边界不一样。localStorage 在 WebView 里当然能用,但桌面应用的本地状态往往会越写越重:用户偏好、token、缓存、路径、最近打开记录、长字符串,甚至二进制转出来的 base64。它们一开始塞进去很方便,等到需要迁移、加密、备份或让用户自己选择存储位置时,就会变成麻烦。
第三,桌面应用依赖原生壳。窗口按钮、文件选择器、系统对话框、子进程、日志和配置目录,都不属于浏览器本身。前端如果绕过这一层,体验会很快变得“不像一个本地软件”。
所以这个脚手架的第一个判断是:把 WebView 当成一个受限的渲染环境,而不是一个完整浏览器。前端负责界面、交互和状态呈现;涉及系统能力、持久化和权限边界的部分,都下沉到 Go 服务层。
为什么选 Wails v3,而不是 Electron 或 Tauri
现在做 Web 技术栈桌面应用,常见选择大致是 Electron、Tauri,以及 Wails 这类使用系统 WebView 的方案。它们都能做出可用的软件,差别主要在长期维护成本。
Electron 的优点是生态成熟,坑也被踩得差不多了。但代价也明显:它把 Chromium 和 Node.js 一起带进应用,包体和常驻内存都有一个不低的起步成本。做界面密集、计算轻量的产品时这不是问题;但如果应用本身偏工具型,需要稳定调用本地文件、子进程、数据库、加密或后台服务,Node.js 这一侧很容易继续引入 native addon 或命令行包装,工程复杂度会往外扩散。
Tauri 走的是更轻的路线:系统 WebView + Rust 后端。包体和运行内存都比 Electron 小很多,安全模型也更细。但实际做项目时,Rust 依赖链、跨平台编译、系统库绑定和 -sys crate 会带来额外门槛。它适合愿意投入 Rust 工程体系的团队,但对一个希望快速落地桌面工具的人来说,上手成本并不低。
Wails v3 的位置刚好在中间。它和 Tauri 一样使用系统自带 WebView,但后端选择 Go。这个选择的价值不在于“Go 一定比 Rust 或 Node.js 更好”,而在于它把桌面工具常见的系统能力放在一个足够稳、足够直接、跨平台编译也相对轻的语言里:
Go 的
GOOS/GOARCH很适合做跨平台二进制构建。标准库已经覆盖 HTTP、文件、加密、子进程、并发和上下文取消。
后端 service 就是普通 Go struct,Wails v3 可以生成前端绑定,前端仍然按 service / hook / provider 的方式组织代码。
代价也有:Go 的 UI 生态不如前端丰富,语言抽象没有 Node.js 那么“手边什么都有”,某些极限性能场景也不如 Rust。但 WebView 桌面应用多数时候瓶颈不在后端语言性能,而在启动、IPC、状态管理、渲染和本地能力边界。把 Go 放在能力层,WebView 放在界面层,是一个比较务实的平衡点。
启动:先让窗口有东西可看
桌面应用的白屏比网页白屏更刺眼。用户打开网页时还能接受“网络在加载”,但双击一个本地软件后,窗口如果空着几百毫秒,就很容易被理解成卡死。
脚手架在 index.html 里内联了一段 CSS 和静态骨架,让 WebView 拿到 HTML 的瞬间就能画出第一帧。这段骨架不是 React 的一部分,而是 #root 的兄弟节点。原因很简单:createRoot.render() 会清空根节点,如果骨架放在 #root 内部,React mount 时会把它直接干掉。
React 启动后,main.tsx 在两层 requestAnimationFrame 之后给骨架加上 .fade-out,触发 160ms 的淡出动画;如果 transitionend 没触发,还有 800ms 的兜底移除。这样第一帧来得快,正式界面接管时也不会硬闪。
启动时序大致是这样:
WebView 先渲染骨架,Go 后端并行打开 SQLite、执行 PRAGMA、AutoMigrate 和完整性自检。React mount 后用默认值先出界面,再由 Provider 异步向后端拉真实状态。后端返回后,Provider 替换默认状态,骨架淡出。
这个方案接受了一个现实:用户看到的第一帧不一定是最终状态。语言、主题和偏好可能会在几百毫秒后更新。但对桌面应用来说,“窗口立刻出现”通常比“等到所有状态都准备好再出现”更重要。
持久化:别把 localStorage 当数据库
前端写持久化时,很容易先写成这样:
const [pref, setPref] = useState(() => readLocalStorage());
const update = (next) => {
setPref(next);
writeLocalStorage(next);
};
同步读、同步写、第一帧就是最终值,开发体验很好。但放到桌面应用里,这种写法很快会碰到几类问题:没有迁移,没有加密,没有备份,没有用户可控路径,也不适合放越来越大的数据。
脚手架把跨会话状态统一放进 SQLite。前端不直接读写本地存储,而是走异步 Provider:
Provider 的模式很固定:
mount 时先用默认值渲染,不阻塞首屏。
useEffect调后端 Service 拉取真实数据。校验通过后更新状态。
用户修改时走 Service 写入数据库;写失败时捕获错误,不让 UI 因回滚产生抖动。
这一步真正解决的不是“换了一个存储介质”,而是把状态层的约束放回后端:schema、迁移、完整性检查、加密、备份、路径切换和原子写入都可以集中处理。前端多付出一点异步状态管理成本,换来的是持久化边界更清楚。
设置页里的数据存储页面就是这个设计的外显。用户可以看到当前数据库文件位置、默认位置、占用空间,以及各表的空间占比和字节用量;也可以把数据库文件迁移到自己信任的位置。

迁移流程也不是简单复制一个 .db 文件。后端会先执行 wal_checkpoint(TRUNCATE) 让 WAL 落盘,关闭当前连接,复制 .db / .db-wal / .db-shm,在新位置重新打开并跑完整性自检。通过后替换当前 Holder,失败则回到旧路径。业务 Service 只从 Holder.Current() 取当前活跃连接,不直接长期持有 *DB,所以路径切换对业务层是透明的。
设置页:语言、主题和本地文件位置
设置页不是“等业务做完再补”的附属页面。对桌面应用来说,它本身就是工程边界的一部分:用户是否能切换语言,是否能跟随系统主题,是否能控制本地文件存储位置,都会影响软件是否像一个可长期使用的桌面产品。
语言切换走注册式 i18n。公用文案放在 i18n/locales/<code>.ts,页面级文案放在对应页面自己的 lang/<code>.ts 里,再由页面调用注册函数合并到全局 locale。这样新增页面时不用把所有文案都塞进一个大文件,也不容易在多人协作时互相踩。

设置页提供“跟随系统、简体中文、English”三种入口。选择“跟随系统”时会参考浏览器 / 操作系统语言;用户显式选中某个语言后,就以用户选择为准。语言文件切换会立即生效,不需要重启应用。
主题系统采用类似的 registry。默认提供明亮、黑暗、黑曜三套预设,也支持从数据库里 hydrate 用户自定义主题。设置页里的颜色项可以实时预览,保存后会持久化,下次启动仍然保留。

这里刻意没有把主题写成几组散落的 CSS 变量。预设主题、用户自定义主题、跟随系统、显示 Logo、菜单 Tooltip 这些设置都会落到统一的偏好模型里。前端拿到的是一个已经解析好的主题状态,而不是到处散落的判断。
本地文件存储同样放在设置页里。它不是简单展示一个路径,而是把数据库文件位置、默认位置、迁移动作、重置动作、占用空间和表级统计放在一起。用户能看见数据在哪里,也能把数据移走,这一点对桌面软件很重要。
Service 注册中心和零手写迁移文件
加表是高频操作,脚手架不希望每次都写一堆迁移文件。它把模型集中在一个 AllModels 数组里,每个模型用 ModelDescriptor 描述:
GORM struct 指针。
显式表名,避免推断不一致。
i18n key,用于设置页展示。
Clearable标记,表示这张表是否可以被设置页当作可重建缓存清空。
新增表时,流程压缩成三步:定义 struct,追加 ModelDescriptor,重启。AutoMigrate 负责建表、加列和索引;设置页的空间占比和字节用量也会自动纳入新表。
Clearable 不是“能不能删除数据”的粗暴开关,而是“这张表是否可以被用户当作缓存清理”。例如临时行为偏好可以清,用户输入、凭据、视觉一致性相关的状态就不应该被一键清掉。这样设置页不会给用户一个危险的“全部清空”幻觉。
子进程:让父进程死掉时,子进程也一起结束
桌面工具经常要启动子进程:调用命令行工具,跑本地编解码器,启动某个 daemon,或者执行一段外部任务。直接 exec.Command(...).Start() 很容易留下一个老问题:主程序崩了,子进程还活着。
Windows 上的解法是 JobObject,配合 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE。每个子进程放进独立 Job,父进程退出时 handle 关闭,系统会连带结束 Job 里的所有进程,包括孙进程。Unix 上则用 setpgid 建独立进程组,停止时对整个进程组发信号。
这个设计关注的不是“正常退出时能不能 kill 子进程”,而是“异常退出时还有没有内核级兜底”。脚手架默认假设父进程可能被 OOM、被 SIGKILL、被开发时的 Ctrl+C 中断。只有依赖系统机制,才能保证子进程不会逃逸。
如果子进程能力要开放给前端,还必须加白名单。命令名、允许的参数正则、允许的工作目录前缀,都要明确配置。前端通过 Wails 绑定调用 Subprocess.Run,参数校验通过后才会落到真正的 exec.Command。stdout / stderr 通过事件流回到前端,同时接入统一日志。
工具层:把标准库包装成项目约束
Go 标准库很好用,但业务代码直接散着用,长期也会失控。脚手架在 internal/utils/ 下收了几类常用能力:
httpx:统一的*http.Client,复用连接池,默认超时由 context 控制,JSON helper 会把 4xx / 5xx 解成结构化错误。procx:子进程封装,处理 JobObject、进程组、事件流和日志接入。cryptox:AES-GCM 对称加密,master key 放用户配置目录,并预留 key rotation 版本位。logx:基于log/slog,控制台和文件双输出,按大小 rotate。filex:原子写入,使用.tmp + fsync + rename替代直接os.WriteFile写配置。
这些封装不是为了“多包一层”,而是为了让业务代码少做重复且容易写错的决定。项目规范里明确写了:业务代码不直接用 net/http.DefaultClient,不直接 exec.Command().Start(),不明文存 token,不用 log.Printf,配置写入走原子写。
原生对话框:别让桌面应用露出网页味
window.alert() 和 window.confirm() 在 WebView 里也能弹出来,但它们不是一个好的桌面体验。按钮样式、标题栏、目录选择、快捷键和系统行为都会显得割裂。
脚手架把 Wails v3 的 Dialogs.* 封装成 NativeDialogs:
openFile / openFiles / openDirectory:系统文件选择器。saveFile:系统“另存为”。confirm:二选一确认。info / warning / error:消息提示。
例如数据存储页面更改数据库位置时走 saveFile,重置默认位置时走 confirm。所有用户可见文案仍然走 i18n,包括按钮、过滤器名称和提示文本。
规范写进项目,而不是只写在口头上
这套脚手架里,规范文档和代码一样重要。
很多桌面端问题不是技术不会做,而是团队里每个人都按自己的习惯做。一个人用 localStorage,一个人直接 os.WriteFile,一个人把 token 明文放配置,一个人用 emoji 当图标。每个点都能解释,但合在一起会让项目很难维护。
所以脚手架把规范写进 .claude/skills/、根目录 CLAUDE.md 和 frontend/CLAUDE.md。这些文档不是口号,而是把规则、反例、原因和例外场景写清楚,让人和 AI 协作时都能按同一套边界工作。
常见铁律包括:
跨会话状态只走 SQLite,禁止
localStorage / sessionStorage。业务 Service 持有
*storage.Holder,不能长期 capture*storage.DB。HTTP 走
httpx,子进程走procx,加密走cryptox,日志走logx,配置文件走filex.WriteAtomic。所有人类可见字符串必须经
t(),包括aria-label、placeholder和对话框文案。UI 图标使用项目统一图标源,避免 emoji、Unicode 符号和额外第三方图标包混用。
这些规则单独看像是限制,合起来的效果是减少架构分叉。业务开发者在脚手架上加功能时,不需要每次重新判断“这类状态到底放哪”“这个子进程怎么停”“这个提示要不要翻译”,照着边界走就行。
结尾
这个脚手架最后沉淀下来的不是某个单点技术,而是一套提前划好的边界:
WebView 只负责渲染和交互。
Go 服务层负责系统能力、存储和权限边界。
首屏用 HTML 骨架兜住体验。
状态进入 SQLite,通过异步 Provider 回到前端。
设置页覆盖语言、主题和本地文件存储。
子进程绑定 JobObject / 进程组。
工具层和规范文档把重复决策收口。
桌面应用的工程化很少靠某个“大招”解决。更多时候,是把那些以后一定会出问题的小选择,尽早变成项目默认值。
代码与共建
整套脚手架基于 Wails v3,开源在 GitHub:
协议是 MIT,可以 fork、修改和商用。
它现在还不是一个“完成态”的框架,更像是一个已经把基础边界跑通的桌面端起点。窗口控制、设置页交互、跨平台打包、AI 协作规范和更多业务模板都还能继续完善。如果你正在用 Wails v3 做桌面产品,也欢迎提 Issue 或 PR 一起把它推得更完善。

参与讨论
(Participate in the discussion)
参与讨论