绑定进阶
本教程共 42 篇 · 第 13 篇 · 更新于 2026-08-03
13. 绑定进阶
本节目标
- 把业务拆成多个结构体绑定,并让每个结构体都拿到 context
- 弄清
OnStartup/OnDomReady/OnBeforeClose/OnShutdown的触发时机与用途 - 让绑定方法返回结构体,并在 React 里用生成的 TypeScript 模型接住
- 用
EnumBind把 Go 枚举同步到前端 - 用构造函数注入依赖,避免 App 结构体变成大杂烩
13-1 一个 App 结构体不够用
第 12 章里所有方法都堆在 App 上。项目一大,这个结构体会迅速膨胀成几千行——文件操作、配置读写、网络请求全挤在一起。
拆开的思路和写 Web 后端一样:按职责分成多个「服务」结构体,每个单独绑定。
// service_file.go
package main
import (
"context"
"os"
)
type FileService struct {
ctx context.Context
}
func NewFileService() *FileService {
return &FileService{}
}
// SetContext 由 OnStartup 统一调用
func (f *FileService) SetContext(ctx context.Context) {
f.ctx = ctx
}
func (f *FileService) ReadTextFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
再写一个配置服务:
// service_config.go
package main
import "context"
type ConfigService struct {
ctx context.Context
settings map[string]string
}
func NewConfigService() *ConfigService {
return &ConfigService{settings: make(map[string]string)}
}
func (c *ConfigService) SetContext(ctx context.Context) {
c.ctx = ctx
}
func (c *ConfigService) Set(key, value string) {
c.settings[key] = value
}
func (c *ConfigService) Get(key string) string {
return c.settings[key]
}
13-2 让每个结构体都拿到 context
这里有个新手常踩的坑:OnStartup 只能挂一个函数,但现在有三个结构体都需要 context。
解决办法是把 OnStartup 写成匿名函数,在里面挨个分发:
func main() {
app := NewApp()
fileSvc := NewFileService()
configSvc := NewConfigService()
err := wails.Run(&options.App{
Title: "多服务示例",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: func(ctx context.Context) {
app.SetContext(ctx)
fileSvc.SetContext(ctx)
configSvc.SetContext(ctx)
},
OnShutdown: app.shutdown,
Bind: []interface{}{
app,
fileSvc,
configSvc,
},
})
if err != nil {
log.Fatal(err)
}
}
NoteWails 给到的 context 是全应用共享的同一个实例,分发给多个结构体不会有副作用。运行时 API(
runtime.EventsEmit、runtime.MessageDialog等)第一个参数要的就是它。
绑定后,前端的命名空间会按结构体名分开:
import { ReadTextFile } from "../wailsjs/go/main/FileService";
import { Get, Set } from "../wailsjs/go/main/ConfigService";
对应的全局路径分别是 window.go.main.FileService.ReadTextFile 和 window.go.main.ConfigService.Get。
Warning不同结构体里如果有同名方法,它们在前端属于不同命名空间,不会冲突。但 import 时要注意重名,用
import { Get as GetConfig }起别名。
13-3 四个生命周期钩子
options.App 里有四个回调,构成了 Wails 应用的完整生命周期:
| 钩子 | 签名 | 触发时机 |
|---|---|---|
OnStartup | func(ctx context.Context) | 前端已创建,但 index.html 还没加载 |
OnDomReady | func(ctx context.Context) | index.html 及其资源全部加载完成 |
OnBeforeClose | func(ctx context.Context) bool | 用户点关闭按钮或调用 runtime.Quit 时 |
OnShutdown | func(ctx context.Context) | 前端已销毁,进程即将退出 |
顺序是:启动 → OnStartup → 加载前端 → OnDomReady → 运行中 → OnBeforeClose → OnShutdown → 退出。
OnStartup 用来初始化资源。打开数据库、读配置文件、起后台 goroutine 都放这儿。
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
db, err := sql.Open("sqlite3", "app.db")
if err != nil {
log.Printf("数据库打开失败: %v", err)
return
}
a.db = db
}
Warning
OnStartup里拿到了 context,但此时窗口还在另一个线程初始化,调用运行时 API 不保证生效。想在启动阶段发事件或弹对话框,请放到OnDomReady里。这是官方明确提示的一个坑。
OnDomReady 用来推首屏数据。前端此时已经挂载好监听器,Go 推事件过去能被稳定接住:
func (a *App) domReady(ctx context.Context) {
runtime.EventsEmit(ctx, "app:ready", map[string]interface{}{
"version": "1.0.0",
})
}
OnBeforeClose 用来拦截退出。返回 true 会阻止关闭,返回 false 则正常退出。注意这个布尔值的语义是「是否阻止」,很容易写反:
func (a *App) beforeClose(ctx context.Context) (prevent bool) {
if !a.hasUnsavedChanges {
return false // 没有未保存内容,放行
}
choice, err := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.QuestionDialog,
Title: "确认退出",
Message: "有未保存的修改,确定要退出吗?",
})
if err != nil {
return false
}
return choice != "Yes" // 用户选 No 就阻止退出
}
OnShutdown 用来收尾。关数据库连接、刷盘、清临时文件:
func (a *App) shutdown(ctx context.Context) {
if a.db != nil {
a.db.Close()
}
}
这四个钩子不是必填项,缺省时 Wails 什么都不做。实际项目里 OnStartup 和 OnShutdown 几乎一定会用到,OnDomReady 在需要推首屏数据时才配,OnBeforeClose 则只在有「未保存内容」这类场景下才有意义。
还有一点容易忽略:OnStartup 如果返回错误,应用会直接终止。但 Wails v2 的签名里 OnStartup 没有 error 返回值,所以初始化失败时你只能自己决定是记日志继续跑,还是主动 log.Fatal 退出。数据库连不上这种致命错误,建议直接退出并给出清晰提示,别让用户面对一个功能全废的空窗口。
13-4 返回结构体给前端
绑定方法可以直接返回 Go 结构体,Wails 会自动生成对应的 TypeScript 类。
type Address struct {
Street string `json:"street"`
Postcode string `json:"postcode"`
}
type Person struct {
Name string `json:"name"`
Age uint8 `json:"age"`
Address *Address `json:"address"`
}
func (a *App) GetPerson(id int) (*Person, error) {
if id <= 0 {
return nil, errors.New("无效的 id")
}
return &Person{
Name: "码上学",
Age: 28,
Address: &Address{
Street: "示例路 1 号",
Postcode: "100000",
},
}, nil
}
wails dev 会在 frontend/wailsjs/go/models.ts 里生成:
export namespace main {
export class Address {
street: string;
postcode: string;
static createFrom(source: any = {}) { /* ... */ }
}
export class Person {
name: string;
age: number;
address?: Address;
static createFrom(source: any = {}) { /* ... */ }
}
}
React 里这样用:
import { useEffect, useState } from "react";
import { GetPerson } from "../wailsjs/go/main/App";
import { main } from "../wailsjs/go/models";
export default function PersonCard() {
const [person, setPerson] = useState<main.Person | null>(null);
useEffect(() => {
GetPerson(1)
.then(setPerson)
.catch((err) => console.error("获取失败:", err));
}, []);
if (!person) return <p>加载中…</p>;
return (
<div>
<h3>{person.name}({person.age} 岁)</h3>
<p>{person.address?.street}</p>
</div>
);
}
反过来,前端也能把对象传回 Go。任何形状匹配的 JS 对象都会被转换成对应的 Go 结构体:
const p = new main.Person();
p.name = "小明";
p.age = 20;
await SavePerson(p);
Warning结构体字段必须有合法的
json标签,否则不会出现在生成的 TS 类型里。匿名嵌套结构体当前还不支持,遇到了就拆成具名类型。
13-5 用 EnumBind 同步枚举
Go 没有原生枚举,习惯用常量模拟。这类值传到前端后往往退化成裸字符串,容易写错。
Wails v2 提供了 EnumBind 来解决这个问题。做法是:定义类型和常量,再补一个「所有取值」的数组,元素带 Value 和 TSName 两个字段。
type Weekday string
const (
Sunday Weekday = "Sunday"
Monday Weekday = "Monday"
Tuesday Weekday = "Tuesday"
)
var AllWeekdays = []struct {
Value Weekday
TSName string
}{
{Sunday, "SUNDAY"},
{Monday, "MONDAY"},
{Tuesday, "TUESDAY"},
}
然后挂到 wails.Run 的 EnumBind 上:
err := wails.Run(&options.App{
Title: "枚举示例",
Bind: []interface{}{
app,
},
EnumBind: []interface{}{
AllWeekdays,
},
})
生成的 models.ts 里会多出一个枚举定义,前端就能用 main.Weekday.MONDAY 这样的写法,享受编辑器补全和类型检查。
13-6 依赖注入:别让 App 变成垃圾桶
服务多了以后,App 很容易变成什么都往里塞的容器。更清爽的做法是显式注入依赖。
type App struct {
ctx context.Context
fileSvc *FileService
cfgSvc *ConfigService
}
func NewApp(fileSvc *FileService, cfgSvc *ConfigService) *App {
return &App{fileSvc: fileSvc, cfgSvc: cfgSvc}
}
// ExportConfig 组合两个服务完成一件事
func (a *App) ExportConfig(path string) error {
content := a.cfgSvc.Serialize()
return a.fileSvc.WriteTextFile(path, content)
}
main.go 里按依赖顺序创建:
fileSvc := NewFileService()
cfgSvc := NewConfigService()
app := NewApp(fileSvc, cfgSvc)
这样每个结构体只做一件事,App 退化成一层薄薄的协调层。测试的时候也方便——直接构造服务实例调用,不用起 Wails 进程。
Tip有的服务只在 Go 内部被复用,不需要暴露给前端。那就别把它放进
Bind,只注入给需要的结构体即可。Bind里的每一项都会生成前端代码,放得越少前端 API 面越干净。
要不要引入专门的依赖注入框架?桌面应用这个体量,手写构造函数就够了。服务之间的依赖关系一般是一层或两层,在 main.go 里从底层往上创建,顺序一目了然。真到了十几个服务互相引用的规模,再考虑抽一个容器结构体统一管理生命周期。
顺带说一个组织代码的小习惯:把每个服务放进独立文件,文件名和服务名对应,比如 service_file.go、service_config.go。这样看目录就知道应用有哪些能力,找方法也快。绑定的结构体越多,这种命名约定的收益越明显。
常见误区
在 OnStartup 里调用运行时 API 没反应。窗口还没初始化完。把这类逻辑挪到 OnDomReady。
OnBeforeClose 返回值写反。返回 true 是「阻止关闭」,不是「允许关闭」。参数名官方写作 prevent,照着这个名字理解就不会错。
忘了给新结构体分发 context。新增一个服务时只加进了 Bind,没在 OnStartup 里调 SetContext。运行时 API 调用会因为 context 为 nil 而 panic。
给未导出字段加 json 标签。internal string \json:“internal”“ 这种写法没用,Go 的 JSON 序列化本身就跳过小写字段。要暴露就改成大写。
指望 EnumBind 做运行时校验。它只生成类型定义,帮你在编译期发现拼写错误。Go 侧收到非法值仍然需要自己判断。
小结
绑定进阶的核心是「拆分」和「时机」两件事。
拆分:按职责划分多个服务结构体,统一在 OnStartup 里分发 context,用构造函数注入依赖,让 App 保持轻薄。
时机:OnStartup 初始化资源,OnDomReady 才能安全调运行时,OnBeforeClose 拦截退出(注意布尔语义),OnShutdown 清理收尾。
数据层面,返回结构体会自动生成 TypeScript 模型,前后端可以共用同一套数据定义;枚举则通过 EnumBind 同步过去。
下一章进入事件系统——绑定解决的是「前端主动喊 Go」,事件解决的是「Go 主动喊前端」。