事件系统 Events
本教程共 42 篇 · 第 14 篇 · 更新于 2026-08-03
14. 事件系统 Events
本节目标
- 分清「绑定调用」和「事件」各自解决什么问题
- 掌握
EventsEmit/EventsOn/EventsOnce/EventsOnMultiple/EventsOff/EventsOffAll六个 API - 在 React 的
useEffect里正确注册监听并在卸载时清理 - 用事件实现后台任务的进度推送
- 避开事件命名冲突、重复注册、内存泄漏这几个坑
14-1 为什么光有绑定还不够
第 12、13 章讲的绑定,通信方向只有一个:前端喊一声,Go 应一声。这叫「请求—响应」模型。
但桌面应用里有大量场景是反过来的。文件下载到了 30%,Go 得告诉界面更新进度条;后台监听的目录多了个文件,Go 得通知列表刷新;某个定时任务失败了,Go 得弹个提示。这些时机前端根本预测不到,没法用轮询硬扛。
Wails 的答案是事件系统。它是一套统一的消息总线,Go 和 JavaScript 两侧都能发(emit)也都能收(on),可选地携带数据。数据会自动转换成接收方语言的本地类型。
Note事件总线(event bus)是一种解耦模式:发送方只管往总线上扔一个带名字的消息,不关心谁在听;接收方按名字订阅,不关心是谁发的。
一句话对比:绑定适合「要一个结果」,事件适合「通知一件事」。
14-2 六个核心 API
Go 侧在 github.com/wailsapp/wails/v2/pkg/runtime 包里,前端侧在 window.runtime 上(或者从 wailsjs/runtime/runtime 里 import)。两边 API 名字一致,只差 Go 版本第一个参数是 context。
EventsEmit —— 发出事件
Go: EventsEmit(ctx context.Context, eventName string, optionalData ...interface{})
JS: EventsEmit(eventName: string, ...optionalData: any)
EventsOn —— 注册监听,返回一个取消函数
Go: EventsOn(ctx context.Context, eventName string, callback func(optionalData ...interface{})) func()
JS: EventsOn(eventName: string, callback: (optionalData?: any) => void): () => void
EventsOnce —— 只触发一次
Go: EventsOnce(ctx context.Context, eventName string, callback func(optionalData ...interface{})) func()
JS: EventsOnce(eventName: string, callback: (optionalData?: any) => void): () => void
EventsOnMultiple —— 最多触发 counter 次
Go: EventsOnMultiple(ctx context.Context, eventName string, callback func(optionalData ...interface{}), counter int) func()
JS: EventsOnMultiple(eventName: string, callback: (optionalData?: any) => void, counter: number): () => void
EventsOff —— 注销指定事件的监听器,可以一次注销多个:
Go: EventsOff(ctx context.Context, eventName string, additionalEventNames ...string)
JS: EventsOff(eventName: string, ...additionalEventNames: string[])
EventsOffAll —— 注销全部监听器
Go: EventsOffAll(ctx context.Context)
JS: EventsOffAll()
Tip
EventsOn系列都返回一个取消函数,调用它就等于注销这个监听器。在 React 里这个返回值特别顺手,直接当useEffect的清理函数用。
14-3 Go 发、前端收
最常见的方向。Go 侧在业务方法里调 EventsEmit:
package main
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type App struct {
ctx context.Context
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// Auth 模拟一次登录校验,成功后广播事件
func (a *App) Auth(token string) {
// 校验逻辑省略
runtime.EventsEmit(a.ctx, "auth:success", map[string]interface{}{
"userId": "user-123",
"name": "码上学",
})
}
React 侧在组件里监听:
import { useEffect, useState } from "react";
import { EventsOn, EventsOff } from "../wailsjs/runtime/runtime";
import { Auth } from "../wailsjs/go/main/App";
type AuthPayload = { userId: string; name: string };
export default function LoginPanel() {
const [user, setUser] = useState<string>("");
useEffect(() => {
EventsOn("auth:success", (data: AuthPayload) => {
setUser(data.name);
});
// 组件卸载时注销,避免重复注册
return () => {
EventsOff("auth:success");
};
}, []);
return (
<div>
<button onClick={() => Auth("demo-token")}>登录</button>
{user && <p>欢迎,{user}</p>}
</div>
);
}
也可以用 EventsOn 的返回值来清理,写起来更紧凑:
useEffect(() => {
const unsubscribe = EventsOn("auth:success", (data: AuthPayload) => {
setUser(data.name);
});
return unsubscribe;
}, []);
两种写法的区别:EventsOff("auth:success") 会注销该事件名下的所有监听器,返回值方式只注销当前这一个。多个组件监听同一事件时,一定要用返回值方式,否则一个组件卸载会把别人的监听器也干掉。
Warning
useEffect忘了写清理函数,是 Wails + React 项目里最高发的 bug。React 18 的严格模式在开发环境会把 effect 执行两次,不清理就会收到两遍事件,界面表现为「数字跳两格」「列表加两条」这类灵异现象。
14-4 进度推送:最典型的应用场景
后台跑一个耗时任务,同时把进度推给界面。这是事件系统的教科书用法。
Go 侧开一个 goroutine,边干活边发事件:
import (
"fmt"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// StartImport 启动一个模拟的导入任务
func (a *App) StartImport(total int) {
go func() {
for i := 1; i <= total; i++ {
// 真实业务处理放这里
time.Sleep(200 * time.Millisecond)
runtime.EventsEmit(a.ctx, "import:progress", map[string]interface{}{
"current": i,
"total": total,
"percent": i * 100 / total,
"message": fmt.Sprintf("正在处理第 %d 条", i),
})
}
runtime.EventsEmit(a.ctx, "import:done", map[string]interface{}{
"count": total,
})
}()
}
注意 StartImport 本身立刻返回,不阻塞。前端调用它不会卡住界面,真正的活在 goroutine 里干。
React 侧同时监听进度和完成两个事件:
import { useEffect, useState } from "react";
import { EventsOn } from "../wailsjs/runtime/runtime";
import { StartImport } from "../wailsjs/go/main/App";
export default function ImportProgress() {
const [percent, setPercent] = useState(0);
const [message, setMessage] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
const offProgress = EventsOn("import:progress", (data: any) => {
setPercent(data.percent);
setMessage(data.message);
});
const offDone = EventsOn("import:done", (data: any) => {
setDone(true);
setMessage(`导入完成,共 ${data.count} 条`);
});
return () => {
offProgress();
offDone();
};
}, []);
return (
<div>
<button onClick={() => StartImport(20)} disabled={!done && percent > 0}>
开始导入
</button>
<progress value={percent} max={100} />
<p>{message}</p>
</div>
);
}
Tip进度事件不要发得太密。一个循环跑十万次、每次都 emit,会把 IPC 通道打爆,界面反而更卡。实际项目里按百分比节流,比如每变化 1% 才发一次。
14-5 前端发、Go 收
反方向也支持。前端调 EventsEmit,Go 侧用 runtime.EventsOn 监听。
前端发:
import { EventsEmit } from "../wailsjs/runtime/runtime";
function notifyThemeChange(theme: string) {
EventsEmit("ui:theme-changed", theme);
}
Go 侧监听(注册时机放在 OnDomReady 比较稳):
func (a *App) domReady(ctx context.Context) {
runtime.EventsOn(ctx, "ui:theme-changed", func(optionalData ...interface{}) {
if len(optionalData) == 0 {
return
}
theme, ok := optionalData[0].(string)
if !ok {
return
}
// 持久化用户的主题偏好
a.saveTheme(theme)
})
}
回调参数是 ...interface{},也就是一个变长的空接口切片。取值前要先判长度、再做类型断言,两步都不能省——数据是从 JavaScript 过来的,Go 这边没有编译期保证。
Warning前端发过来的数字统统是 JavaScript 的 number,在 Go 侧断言时会变成
float64而不是int。写optionalData[0].(int)会断言失败。正确写法是先断言成float64再转int。
不过这个方向用得比 Go→前端少得多。前端要「让 Go 做点事」,多数时候用绑定方法更直接,还能拿到返回值和错误。事件适合那种「广播一下,谁想听谁听」的场景。
14-6 一次性与限次监听
有些事件只需要处理第一次。比如应用启动完成的信号:
import { EventsOnce } from "../wailsjs/runtime/runtime";
EventsOnce("app:ready", (data: any) => {
console.log("应用已就绪,版本", data.version);
});
触发一次后监听器自动注销,不用手动清理。
EventsOnMultiple 则是限定次数版本,第三个参数是最多触发几次:
import { EventsOnMultiple } from "../wailsjs/runtime/runtime";
// 最多提示三次网络异常,之后就不再打扰用户
EventsOnMultiple("net:error", (msg: any) => {
showToast(msg);
}, 3);
Go 侧对应的写法是 runtime.EventsOnce(ctx, name, cb) 和 runtime.EventsOnMultiple(ctx, name, cb, 3)。
常见误区
事件名随手写,全局撞车。项目一大,update、refresh 这种名字几乎必然冲突。用「模块:动作」的命名约定,比如 import:progress、auth:success、window:resized。更稳妥的做法是把事件名抽成常量,Go 和 TS 各维护一份。
在渲染函数里注册监听。React 组件每次渲染都会执行函数体,把 EventsOn 写在 useEffect 外面,每渲染一次就多一个监听器。必须放进 useEffect 并给空依赖数组。
EventsOff 误伤他人。它按事件名注销所有监听器。共享事件名的多个组件里,用 EventsOn 返回的取消函数来清理。
在 OnStartup 里 emit。此时前端还没加载,事件发出去没人接。要推首屏数据请用 OnDomReady。
把事件当 RPC 用。发一个 get:user 事件,再监听 user:result 事件拿结果——这是在手工重造 Promise。这种「要结果」的场景就该用绑定方法。
忘记 goroutine 里的 panic。后台任务里一旦 panic,整个应用会崩。耗时 goroutine 里加上 defer recover(),捕获后 emit 一个错误事件给前端提示。
小结
事件系统补上了绑定的另一半:Go 主动通知前端。六个 API 里,EventsEmit 和 EventsOn 覆盖九成场景,EventsOnce/EventsOnMultiple 处理限次订阅,EventsOff/EventsOffAll 负责清理。
React 里的标准姿势就一条:useEffect 里注册,把 EventsOn 的返回值当清理函数返回。
场景上记住分工——要结果用绑定,发通知用事件。进度推送、后台任务状态、系统级变更这类,都是事件的主场。
下一章回到前端调 Go 这个方向,把 Promise、类型映射和错误处理这几件事讲透。