这篇要解决的问题
开山篇《Wails 入门》把 Wails 装起来跑通了第一个窗口,这一篇往里走一层,讲清四个日常每天都碰的机制:Go 方法怎么暴露给前端(Bind)、两侧怎么互发消息(Events)、前端资产怎么打进二进制(embed 与 AssetServer)、开发与发布两种模式各发生了什么。写作时 v2 线最新稳定版是 2.9.2,v3 还在 alpha,本文全部基于 v2 已发布行为,v3 特有的能力一律不涉及。要随查随用的 API 清单,按”签名 + 语义 + 坑”组织的速查见下一篇《Wails 开发参考字典》。
一、项目结构与资产嵌入
wails init -n demo -t vue-ts 生成的目录长这样,四个部分各司其职:
demo/
├── main.go # 入口:wails.Run(...),窗口参数、生命周期、绑定都在这
├── app.go # 模板把绑定方法写在 App 结构体上
├── wails.json # 项目配置:前端构建命令、产物文件名、dev 服务器参数
├── frontend/ # 前端工程,按模板可以是 Vue/React/Svelte/vanilla
│ └── dist/ # 前端构建产物——它会被整个嵌进 Go 二进制
├── wailsjs/ # CLI 自动生成的绑定模块,前端 import 它来调 Go
│ ├── go/ # 每个绑定方法对应一个导出函数,附带 TS 类型声明
│ └── runtime/ # 事件、窗口、剪贴板等运行时 API 的 JS 封装
└── build/ # 应用图标、平台清单文件与打包脚本;build/bin 放最终产物
资产嵌入是 Wails 产物能做成单个可执行文件的原因,机制就是 Go 1.16 起的 embed,在 v2 里由 AssetServer 选项接住:
//go:embed all:frontend/dist
var assets embed.FS
// main.go 的 wails.Run 里:
AssetServer: &assetserver.Options{
Assets: assets, // 页面对静态文件的请求都由这个内嵌文件系统响应
},
语义要吃准:wails build 时前端先按 wails.json 里的 frontend:install / frontend:build 命令构建,产物塞进 dist,再由 embed 打进二进制——所以改了前端代码必须重新 build,产物里的页面才会更新。反模式点名:绕开 wails.json 直接手写 npm 命令再 wails build,构建顺序乱了之后经常出现”二进制里还是旧页面”的灵异现象,让 CLI 统一编排这两步。
另一个高频报错:go:embed all:frontend/dist 在 dist 不存在或没有任何文件时编译直接失败。刚 clone 别人的 Wails 项目还没跑前端构建、或手动清了 dist,都会撞上它。解法不是删掉 embed 注释,而是跑一次 wails build(它会先把前端构建好)。
二、Bind:Go 方法怎么变成前端函数
绑定规则一共五条,每条都决定一次排查方向:绑的是结构体实例的导出方法(首字母大写);参数与返回值走 JSON 序列化;返回值是 (T, error) 时,error 非 nil 在前端就是 Promise reject;方法签名的第一个参数若是 context.Context,由 Wails 注入,不会出现在 JS 签名里;前端调用的函数在 wailsjs/go/main/App.js,由 CLI 在 wails dev / wails build 时自动生成,不要手写。
// ListOpts 的字段名(json tag)决定前端传参的字段名
type ListOpts struct {
Keyword string `json:"keyword"`
Limit int `json:"limit"`
}
type Item struct {
Title string `json:"title"`
Done bool `json:"done"`
}
// Search 会被暴露给前端。ctx 是 Wails 注入的运行时上下文,
// 只在需要调事件、窗口等运行时 API 时才声明,JS 侧签名里没有它
func (a *App) Search(ctx context.Context, opts ListOpts) ([]Item, error) {
if opts.Limit <= 0 || opts.Limit > 500 {
opts.Limit = 50 // 边界校验放 Go 侧,前端传什么都兜得住
}
// 查询逻辑省略;结构体列表会按 JSON 序列化回前端
return items, nil
}
import { Search } from '../wailsjs/go/main/App';
// 看起来是本地函数,实际跨了一次 JS 到 Go 的绑定调用
// models.ts 里有自动生成的类型声明,参数和返回值都有提示
const items = await Search({ keyword: '发票', limit: 20 });
绑定方法的合理写法是薄:做参数校验、调业务包、翻译错误,十行以内收尾。业务逻辑留在独立的 Go 包里,才能用普通的 go test 测——绑定层本身依赖运行时上下文,是最难测的那一层,把它做薄就等于把测试难度降下来。
两个反模式点名。其一,把错误塞进返回值字段让前端自己判断成败:(result, error) 的语义是框架白送的,另造 {code, msg} 包装只会让前端写两套判断。其二,绑定方法签名里用 interface{} 收参数:JSON 边界上它解码成什么全凭猜,类型系统等于没用——参数老老实实定义 struct,类型提示和校验都是自动的。跨 JSON 边界还有一条容易忽略:超过 253 的整数会丢精度,雪花算法生成的 ID 这类大数用 string 传。
三、事件:两侧互发消息
Bind 解决”前端主动调 Go”,事件解决反向——”Go 主动通知前端”,以及前端到前端的广播。长任务的进度推送是典型场景:Go 在后台跑,每完成一段就 emit 一条,前端订阅同名事件刷新 UI,而不是让前端定时轮询绑定方法。
// Go 侧:处理完一段就发,载荷跟在事件名后面,个数不限
runtime.EventsEmit(a.ctx, "task:progress", done, total)
// 前端侧:EventsOn 返回取消函数,组件卸载时调它,防止重复监听越积越多
import { EventsOn, EventsOnce } from '../wailsjs/runtime';
const off = EventsOn('task:progress', (done, total) => {
bar.style.width = ((done / total) * 100) + '%';
});
EventsOnce('task:done', () => notify('完成')); // 只触发一次的监听
反方向也能走:前端 EventsEmit,Go 用 runtime.EventsOn 收——UI 状态变化通知后端这类低频场景可用,但高频数据仍应走绑定方法,事件通道没有请求-响应语义,发出去就收不到回执。
三个坑按踩到的概率排序。第一,JSON 边界的不对称:Go 发出去的数字到 JS 是 number,但 JS 发来的数字在 Go 侧按 JSON 解码语义是 float64,回调里直接断言 int 会失败——先收 float64 再转。第二,事件名是裸字符串,两侧各写一遍容易拼错,拼错的症状是”监听在但永远不触发”,用常量或统一在一个文件里定义能省很多排查时间。第三,OnDomReady 触发前前端还没挂载,此时 emit 的事件没人收,直接丢——需要”初始状态”就用绑定方法去拉,事件只负责增量通知,别把首次加载也寄托在事件上。
四、生命周期回调与运行时上下文
options.App 上挂了四个生命周期回调,触发时机和典型用途是固定的:
| 回调 | 触发时机 | 典型用途 |
|---|---|---|
| OnStartup | 窗口创建、前端资源加载之前 | 把 ctx 存进结构体,做初始化(读配置、连服务) |
| OnDomReady | 前端 DOM 加载完成 | 此时向前端发事件才是安全的 |
| OnBeforeClose | 用户点了关闭,返回 true 可阻止 | 做”点关闭改隐藏”的常驻应用 |
| OnShutdown | 应用退出前 | 落盘、关文件、释放资源 |
OnStartup 的 ctx 是一切运行时 API 的钥匙:窗口操作、事件、对话框都要传它,惯例是存到 App 结构体的字段里。反模式点名:自己 context.Background() 造一个传给 runtime 方法——这个 context 里没有 Wails 的窗口句柄,调用不报错但不生效,症状是”事件发了没人收、窗口调了没反应”,检查 ctx 来源就能定位。
五、dev 与 build:两种模式
wails dev 和 wails build 不只是”跑起来”和”打包”的区别,两者的编译参数和前端来源都不一样:
| 维度 | wails dev | wails build |
|---|---|---|
| Go 编译参数 | -tags dev,禁优化便于断点调试 | -tags desktop,production,-ldflags “-w -s” 瘦身 |
| 前端来源 | 模板的 dev server(Vite 等)供页面,热更新 | frontend:build 的产物经 embed 打进二进制 |
| 调试能力 | 可用 DevTools,还能开浏览器访问 dev 服务器 | 默认关闭,需选项显式开启 |
| 产物位置 | 不产出二进制 | build/bin 下的单文件可执行程序 |
代码里要区分模式时不用 build tag 也行,运行时判断更直观:runtime.Environment(ctx).BuildType 返回 "dev" 或 "production",用它开关日志级别、调试面板这类行为。build tag 方式(//go:build dev 分文件写两份常量)适合编译期就要裁剪的代码,比如只在 dev 编译的 mock 数据源。
最佳实践
绑定层薄、业务进包、错误走 error 返回值;事件做通知、绑定做查询,两件事别混用;ctx 只从 OnStartup 拿,全程序就这一个来源。这四条守住,Wails 项目的 Go 侧就是普通的 Go 代码——这也是它对 Go 团队最大的友好之处:没有新的框架方言要学,学到手的绑定与事件机制知识可迁移,Go 语言本身的地基(函数、结构体、并发)见《第一个 Go 程序》与《Go 函数与方法》。
常见问题
Q1: 前端 import 绑定函数报 undefined
多半是 wailsjs 目录还没生成或没更新:绑定模块由 CLI 在 wails dev / wails build 时生成,装完依赖直接在前端里 import 是拿不到的。先跑一次 wails dev,再检查 Bind 数组里是否真的传了那个实例。
Q2: JS 发来的数字在 Go 里断言失败
跨 JSON 边界所有数字都是 float64,回调签名写 func(data ...interface{}) 时先收 float64 再转 int。要强类型就在绑定方法签名里直接声明具体类型,让框架替你转。
练习与实践
练习一
[ ] 给 Search 加一个非法参数分支:keyword 为空时返回 error,在前端 console 观察到 Promise reject
[ ] 用 EventsOnce + EventsOn 实现一个”进度条跑完自动解绑”的任务监听,并在组件卸载时调用取消函数
[ ] 写一段代码,在 dev 模式下额外打印 Environment 的 Platform 与 Arch 字段
完整程序:绑定、事件与模式判断
在 wails init -n demo -t vanilla 生成的项目里,用下面的文件整体替换模板的 app.go 与 main.go(前端不用动,模板页面上的按钮已经会调 Greet),然后 wails dev 运行:点按钮验证绑定,开 DevTools 的 console 验证事件(每 3 秒一条),window 上点击”关闭”验证 OnBeforeClose 拦截。
package main
import (
"context"
"embed"
"log"
"time"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
//go:embed all:frontend/dist
var assets embed.FS
type App struct {
ctx context.Context
}
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
// Greet:前端点击按钮后调用的绑定方法
func (a *App) Greet(name string) string {
return "Hello, " + name + "!"
}
// PingAll:演示 Go 主动向前端广播事件
func (a *App) PingAll() {
runtime.EventsEmit(a.ctx, "app:ping", time.Now().UnixMilli())
}
// BuildInfo:演示运行时判断模式
func (a *App) BuildInfo() string {
env := runtime.Environment(a.ctx)
return env.BuildType + "/" + env.Platform
}
// BeforeClose 演示拦截关闭:返回 true 则窗口不关
func (a *App) BeforeClose(ctx context.Context) bool {
return false // 改成 true 试试"点了关闭但应用还在"的效果
}
func main() {
app := &App{}
err := wails.Run(&options.App{
Title: "wails-essentials",
Width: 640,
Height: 480,
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: app.startup,
Bind: []interface{}{app},
OnBeforeClose: app.BeforeClose,
})
if err != nil {
log.Fatal(err)
}
}
两个扩展实验:把 BuildInfo 的返回值在 dev 和 wails build 产物里各看一次,体会两种模式的实际差异;在 PingAll 里把事件载荷改成一个 struct,观察前端收到的字段名与 json tag 的关系。goroutine 事件推送的并发细节见《Go 并发基础》。
版本核实(以官方文档为准):写作时 Wails v2 线最新稳定版为 2.9.2(2024 年 9 月发布),v3 处于 alpha 阶段;本文涉及的 embed 资产嵌入、Bind 绑定规则、EventsOn/EventsEmit/EventsOnce、OnStartup/OnDomReady/OnBeforeClose/OnShutdown、Environment().BuildType、dev 与 build 的编译参数均为 v2.9.x 已发布行为,细节以 wails.io 当前文档为准。