一句话结论:脚手架真正值钱的不是那几千行代码,而是「把工程约定变成 CI 里会失败的检查」这套手艺。代码会因为换语言、换框架而作废,手艺不会。
写在前面:脚手架是怎么烂掉的
先说个大家都见过的场景。
你兴冲冲搭了个项目基座,写了三份文档:README、CONTRIBUTING、docs/规范.md。里面写得清清楚楚——"控制器不许直接操作数据库"、"新增依赖要去清单里登记"、"前端不能硬编码颜色"。
三个月后你回来看:
- 有人图省事在
app.go里直接gorm.Open了,反正能跑。 deps.yaml里躺着三个早就删掉的依赖,没人知道。- 前端一个组件里写了个
#3b82f6,主题换肤那天它没跟着变。 - 日志功能"接好了",但打包出来前端日志一条都看不到——因为启动时少接了一行
Logger,不报错、不崩溃,就是默默丢了。
问题不在于规矩写得不清,而在于规矩是"软"的。 软约束一定会被违反,尤其是在 AI 参与生成代码的今天——AI 不会读你的 CONTRIBUTING.md,它只会照着最省事的方式写。
所以我做这个基座时,给自己定了一条最高的原则:
凡是能被机器验证的约定,就不要留在文档里当口号,把它编译成一条会变红的检查。
这篇就聊我是怎么用 Wails v2 把这条原则落地的。技术栈是 Go + Vue3,但方法论跟语言无关,你做 Electron、做 Tauri 一样能搬。
一、为什么是 Wails,不是 Electron
选型没什么好纠结的,就三条:
- 包体。Electron 打包一个 Chromium 进去,100MB 起步。Wails 用系统自带的 WebView(macOS 是 WKWebView,Windows 是 WebView2),产物就几 MB。
- 后端是 Go。我要的是能直接读写本地文件、跑 SQLite、做系统集成的"真桌面程序",Go 干这些比 Node 舒服。
- 通信零成本。Wails 会自动把 Go 结构体的方法生成为前端可调用的 TS 绑定,不用自己写 IPC 协议,也不用起 HTTP 服务。
代价也要说清楚:没有 Electron 那种成熟的生态和调试体验,某些系统能力(后面会讲)Wails v2 有坑。
# 一条命令就能跑起来
make dev # wails dev,前端热更新
make build # 编译当前平台产物
make package # 打 dmg / NSIS 安装包
二、基座的骨架
分层很朴素,但每一层的边界我都要能用机器验证:
对应到 Wails 的形态:
- 绑定方法层(
app.go):前端能直接调用的方法都在这里。它不许碰数据库,必须经 service 层——这样业务逻辑才好测,也让"前端能调到什么"这件事一目了然。 - service 层:真正的业务逻辑,可以访问数据库。
- model 层:纯数据结构,是叶子,不许 import 任何人。
规矩写出来容易,问题是怎么让它"不听话就报错"。这就是下一节。
三、核心方法:把约定编译成会红的检查
护栏不是写在文档里,而是写在 internal/guard/ 包里,以 go test 的形式运行,make test 的时候一起跑。用 go/parser + go/ast 解析源码做结构化断言。
3.1 最重要的铁律:护栏必须"感知自己瞎了"
这条是整篇文章里最想让你记住的,也是我从真实事故里长出来的教训。
护栏靠正则或 AST 匹配代码。只要代码写法一变,匹配就会失效,护栏从"拦违规"退化成"永远绿"——它不报错了,但它也没在干活了,还给你一种"很安全"的错觉。这种"瞎掉"的护栏比没有护栏更危险。
所以铁律是:
解析到 0 个结果时必须报错,不能当作"通过"静默放行。
代码长这样(internal/guard/layer_test.go):
func TestBindingsDoNotTouchDB(t *testing.T) {
files, err := parseDir(projectRoot())
if err != nil {
t.Fatalf("解析项目根目录失败: %v", err)
}
f, ok := files[bindingsFile]
if !ok {
// 关键:找不到目标文件 → 报错,而不是"跳过这项检查"
t.Fatalf("护栏找不到 %s——写法可能已变更,请同步更新护栏解析规则", bindingsFile)
}
for _, imp := range collectImports(f) {
if imp == "gorm.io/gorm" || strings.HasPrefix(imp, "gorm.io/") ||
strings.Contains(imp, "internal/database") {
t.Errorf("%s 越界:绑定方法层不得直接 import %s,应经 service 层", bindingsFile, imp)
}
}
}
看到那句报错文案了吗——"写法可能已变更,请同步更新护栏解析规则"。每个护栏里都带这句话。它的潜台词是:护栏失效是一件必须有人处理的事,不是可以忽略的噪音。
3.2 双向校验:防止"僵尸条目"
单向校验只能防"漏登记",防不住反向的漂移。所以凡是清单类的护栏,我都做双向:
- 正向:真实存在的东西,必须登记在册。
- 反向:登记在册的东西,必须真实存在。
模型注册就是这么干的。每个内嵌 BaseModel 的结构体都必须登记进 AllModels()(这是迁移的唯一真相):
// 正向:每个带 BaseModel 的结构体都必须登记
for s := range structsWithBase {
if !registered[s] {
t.Errorf("模型 %s 内嵌 BaseModel 但未登记进 AllModels()", s)
}
}
// 反向:每个登记项都必须真实存在
for s := range registered {
if !structsWithBase[s] {
t.Errorf("AllModels() 登记了 %s,但 model 包中无对应结构体(僵尸条目/拼写错误)", s)
}
}
反向校验这一条,专治那种"删了模型忘了删注册"的烂账。
3.3 依赖登记制
新增依赖不能只 go get / npm install,必须在 deps.yaml 里登记,附一句为什么用它:
go:
- module: github.com/glebarez/sqlite
version: v1.11.0
reason: 纯 Go SQLite 驱动(无 CGO),跨平台交叉编译零痛苦。
护栏照样双向校验:go.mod 的每个直接依赖(非 indirect)和前端 package.json 的 dependencies,都得在清单里;反过来,清单里的每一项也必须真实存在。
这么做的收益不只是"依赖干净"。强制你写理由,等于强制你在引入一个库之前先想一遍"它值不值得"。
3.4 前端镜像一致性——最容易悄悄漂移的地方
这是我最喜欢的一个护栏,因为它解决的是一个特别隐蔽的问题。
Go 侧的错误码、事件动作名、分页上下界,是"单一真相"。但前端为了编译期能用上一份类型,不得不在 TS 里留一份镜像:
// frontend/src/lib/invoke.ts —— 与 Go 侧 internal/apperr 保持一致(此处为镜像)
export const ErrorCode = {
NotFound: 'not_found',
Validation: 'validation',
Conflict: 'conflict',
Internal: 'internal',
} as const
镜像本身没问题,没人看管的镜像才是问题:Go 那边加了个错误码,前端没跟上,前端就在按一份过期的真相分流,而且不会报任何错。
所以 parity_test.go 会去解析 Go 常量名(Code*)和 TS 对象(ErrorCode),逐项比对:
错误码:Go 侧 CodeNotFound = "not_found",但前端镜像 invoke.ts 的 ErrorCode 中缺失
——请在 invoke.ts 中补上(镜像已漂移)
而且这里有个我想强调的设计纪律:
镜像只许复制常量与类型,不许复制逻辑。
一件规则如果有了两个实现,比一个常量有两份更糟——两个实现会在边界条件上悄悄分叉。所以页码夹取、参数校验这些逻辑,唯一地活在 Go 侧;前端只负责把原始值发过去、把归一化后的值收回来。
3.5 接线静默失效——最阴的一类问题
有一类 bug 特别讨厌:它不报错、不崩溃,只是某条链路默默断掉了。
典型例子:Wails 的 options.App.Logger 如果没接,前端的日志和 Wails 自身的内部错误就只会写 stdout——打包成 GUI 应用之后,stdout 是没有人能看到的。日志功能"接通了",但实际什么都没记下来。
删除这一行,编译通过,运行正常,测试全绿。只有等你真出事要看日志的那天,才发现日志是空的。
这类问题没有症状,所以只能靠检查兜住:
// main.go 的 options.App 必须设置 Logger,否则前端日志静默丢失
var requiredWailsOptions = []struct {
field string
reason string
}{
{
field: "Logger",
reason: "不设置它,前端日志与 Wails 内部错误只写 stdout(打包后无人可见),日志会静默丢失",
},
}
同一个文件里还兜了另一件事:构建时用 -ldflags -X 注入版本号的目标符号,必须真实存在。因为链接器对不存在的符号是静默忽略的——你把变量改个名,构建成功、没有警告、版本号悄悄退回 dev,而且从此永远是 dev。
经验总结一句话:凡是"坏了也没有症状"的地方,都要有一条会红的检查。
四、管道契约:一个真实的坑
跨端通信最容易出问题的不是网络,是"两端对同一种数据的理解不一致"。所以我把错误、分页、事件三样东西定成了管道契约。
4.1 错误协议:为什么我要返回 JSON 字符串
Go 侧的业务错误统一用 apperr 构造,带机器可读的 code 和给人看的 message:
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
}
func NotFound(message string) *Error { return &Error{Code: CodeNotFound, Message: message} }
func Validation(message string) *Error { return &Error{Code: CodeValidation, Message: message} }
看起来平平无奇,但这里踩过一个真实的坑,值得单独讲:
Wails 允许你自定义错误格式化器(ErrorFormatter)。我一开始很自然地返回了对象:
// ❌ 错误示范
func Format(err error) any {
return Wrap(err) // 返回 *Error 对象
}
结果前端拿到的是 new Error(payload),对象被强转成了字符串 "[object Object]",code、message 全部丢失。这是 Wails v2 的实测行为。
改成返回 JSON 字符串就好了:
// ✅ 正确做法:返回字符串,前端自行 JSON.parse 还原
func Format(err error) any {
ae := Wrap(err)
if ae == nil {
return ""
}
b, _ := json.Marshal(ae)
return string(b)
}
前端再配一个归一化函数,把可能出现的各种形状统一成 { code, message },解析失败就退化成 internal:
export function normalizeError(raw: unknown): AppError {
const text = raw instanceof Error ? raw.message : String(raw)
try {
const parsed = JSON.parse(text)
if (parsed && typeof parsed.code === 'string' && typeof parsed.message === 'string') {
return parsed as AppError
}
} catch { /* 落到兜底 */ }
return { code: ErrorCode.Internal, message: text || '未知错误' }
}
下游永远拿不到 [object Object],这就是契约的价值——它不保证一切顺利,但保证出问题时形状是稳定的。
4.2 顺带做的一件事:慢调用埋点
同一条 invoke() 里,我还埋了耗时统计:超过 50ms 的绑定调用记一条告警,启动时汇总一行 IPC 概况。
刻意不逐条记录——绝大多数调用就几毫秒,逐条记会让日志量随调用次数线性增长,把真正要看的告警淹掉。一行汇总足以回答"这次启动的 IPC 代价有多大"。
五、事件与分页:把"约定"变成可校验的形状
事件统一用 <domain>:<action> 命名,进度类 payload 必须带 done/total,结束类必须带 ok。
前端不许手写字符串字面量 'asset:progress',必须用 eventName(domain, action) 拼——这样拼错单词的代价从"运行时静默无人接收"变成"编译期类型报错"。
组件里订阅事件必须用 useEvent 组合式函数,随组件卸载自动解绑。原因很实在:Wails 的事件订阅会累积,你漏解绑一次,来回切路由就会触发多次。
分页同理:列表方法必须返回 page.Result[T],不许返回裸切片;分页参数必须先经 page.Request.Normalized() 归一化再查库。
前端这份职责被明确划走了:前端不得自行实现归一化(页码夹取、页大小上下界),那是 Go 的职责。前端发原始值,收归一化值。规则只有一处实现,就不会有两个地方各夹一次、结果不一致。
六、黄金范例 + 代码生成器:让新模块长在纪律里
新加一个业务模块,手写"model + service + 绑定方法 + 前端页面"要复制粘贴一堆样板,还容易漏掉注册。所以有个生成器:
make gen name=asset
它从 _example/ 模板生成四段代码,并自动注入到 AllModels()、app.go、路由、菜单里。生成器用了几个我觉得很值得抄的手法:
- 锚点注入。目标文件里放
【gen:routes】这类注释锚点,生成器靠它定位插入位置。护栏也断言锚点存在——锚点被删了两边一起红。这是刻意的耦合。 - fail-fast 前置校验。动任何文件之前,先把"锚点在不在""资源是不是已被占用"全查一遍。否则中途失败会留下"后端生成了、前端没注入"的半拉子状态,重跑又被幂等检查拦住,非常难收拾。
- 幂等 + 拒绝覆盖。目标文件已存在就直接报错,绝不覆盖你写的业务代码。
// TODO锚点。生成的文件带// TODO: 业务逻辑,人和 AI 都只填锚点处。
还有一条容易被忽视的规矩:_example/ 是唯一范例,历史模块不是范例。
真实的业务模块会越写越具体、越写越乱,拿它当模板会学到一堆坏味道。所以模板必须单独维护,保持最小、干净。
七、统一入口:别让人和 AI 去记脚本路径
所有操作都收敛到 make <target>:
| 命令 | 作用 |
|---|---|
make dev | 启动开发态(前端热更新) |
make build | 编译当前平台产物 |
make package | 打包真安装包(dmg / NSIS) |
make test | 跑 Go 测试(含架构护栏) |
make lint | gofmt + 护栏 + go vet + ESLint + vue-tsc |
make smoke | 冒烟测试 |
make gen | 生成新模块 |
两个细节值得一提:
make lint 里的 vue-tsc 类型检查不能省。 开发态的 vite 只剥离类型、不做检查,缺了这一步,类型错误会一路漂到打包才炸。
Makefile 在干净检出时不能乱喷错误。 根包用 //go:embed 嵌了 frontend/dist,而这个目录不入库。如果哪个变量用了立即展开,会导致任何 make 目标(包括 make help)都先报一句难懂的编译错误。所以要有前置检查,明确告诉你"先跑 npm run build",而不是抛一句 Go 的原始报错。
冒烟测试的目标也很朴素:把"能跑"变成可以观察到的事实,而不是嘴上说"我测过了"。构建 → 启动 → 断言 → 清理,trap cleanup EXIT 保证不残留。
八、打包发布:版本号只有一个真相
版本号这件事,坑特别多。我的处理原则是四处同源:
wails.json的info.productVersion是唯一真相;- wails 用它渲染 macOS 的
Info.plist、Windows 的 exe 版本资源与 NSIS 注册表; - 构建脚本把同一个值用
-ldflags注入到应用内(日志首行、GetAppInfo)。
所有构建都走同一个 scripts/build.sh,它是唯一的版本注入点。macOS 构建完还会回读产物的 Info.plist 校验版本真的生效了。
自动发布也很省事,打个 tag 就行:
git tag v0.1.0 && git push origin v0.1.0
CI 会先跑静态检查,再由 macOS / Windows 两条腿分别打包 dmg 和 NSIS 安装器,最后建 Release 挂上产物。
九、说点难听的:已知限制
一个负责任的脚手架应该把坑写清楚,而不是装看不见。这个基座有几条硬限制:
- macOS 没有系统托盘。Wails v2 的 NSApplication delegate 和所有 systray 库都冲突,所以 macOS 上关闭即退出,托盘只在 Windows/Linux 提供。
- 没有 headless 模式。冒烟测试只定位为本地验证,CI 里只编译不启动 GUI。
- Linux 产物编译不在 CI 矩阵里。Wails 在 Linux 上按 webkit2gtk 版本做 cgo 链接,默认找 4.0,而 Ubuntu 24.04+ 只提供 4.1,必须带
-tags webkit2_41。这属于"取决于 runner 装了什么"的环境耦合,维护成本高于收益,所以矩阵只保留 macOS / Windows(Linux 的 Go 层行为仍由静态检查腿覆盖)。 - 窗口位置不持久化,只记住尺寸和最大化。Wails v2 没有创建期位置选项,运行期设置会和窗口显示产生竞态(能看到跳动),还得自己夹取屏幕边界,否则窗口会还原到已经拔掉的显示器上。
- 版本号只认数字点分格式(如
0.1.0),因为 Windows 的 NSIS 不接受非数字版本。
十、小结:方法论比代码更值钱
回头看,这个基座里真正让我觉得"赚到了"的,不是那几千行 Go 和 Vue,而是这几条可以跨语言、跨框架迁移的原则:
- 把约定编译成会红的检查。 软约束一定会被违反,硬约束不会。
- 护栏必须能感知自己瞎了。 解析到 0 个结果就报错,别让护栏退化成"永远绿"。
- 清单类规则一律双向校验。 正向防漏登记,反向防僵尸条目。
- 单一真相 + 受管镜像。 一条规则一个出处;必须复制的只复制常量,不复制逻辑。
- 凡是"坏了也没症状"的地方,都得有一条检查兜住。 接线、注入、镜像漂移——这三类是重灾区。
- 护栏和治理要先于业务。 早期只有一两条也没关系,越早立,后面每个模块都长在纪律里;等写了几十个模块再回头补,就来不及了。
代码会过时——Go 版本会变,Vue 会变,说不定哪天 Wails v3 就不长这样了。但"把工程约定变成机器可验证的事实"这套思路,换个技术栈照样能用。
如果你的项目还靠 code review 和自觉来守护架构边界,我建议从一条最小的护栏开始——比如"渲染层禁止 import Node 能力"——先跑通,再慢慢加。护栏这东西,是典型的复利投资。