首页 / Wails 入门教程 / 绑定进阶

Wails 入门教程

绑定进阶

本教程共 42 篇 · 第 13 篇 · 更新于 2026-08-03

Wails桌面开发Bind生命周期Go

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)
	}
}
Note

Wails 给到的 context 是全应用共享的同一个实例,分发给多个结构体不会有副作用。运行时 API(runtime.EventsEmitruntime.MessageDialog 等)第一个参数要的就是它。

绑定后,前端的命名空间会按结构体名分开:

import { ReadTextFile } from "../wailsjs/go/main/FileService";
import { Get, Set } from "../wailsjs/go/main/ConfigService";

对应的全局路径分别是 window.go.main.FileService.ReadTextFilewindow.go.main.ConfigService.Get

Warning

不同结构体里如果有同名方法,它们在前端属于不同命名空间,不会冲突。但 import 时要注意重名,用 import { Get as GetConfig } 起别名。

13-3 四个生命周期钩子

options.App 里有四个回调,构成了 Wails 应用的完整生命周期:

钩子签名触发时机
OnStartupfunc(ctx context.Context)前端已创建,但 index.html 还没加载
OnDomReadyfunc(ctx context.Context)index.html 及其资源全部加载完成
OnBeforeClosefunc(ctx context.Context) bool用户点关闭按钮或调用 runtime.Quit
OnShutdownfunc(ctx context.Context)前端已销毁,进程即将退出

顺序是:启动 → OnStartup → 加载前端 → OnDomReady → 运行中 → OnBeforeCloseOnShutdown → 退出。

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 什么都不做。实际项目里 OnStartupOnShutdown 几乎一定会用到,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 来解决这个问题。做法是:定义类型和常量,再补一个「所有取值」的数组,元素带 ValueTSName 两个字段。

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.RunEnumBind 上:

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.goservice_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 主动喊前端」。