首页 / Wails 入门教程 / 事件系统 Events

Wails 入门教程

事件系统 Events

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

Wails桌面开发Events运行时React

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)

常见误区

事件名随手写,全局撞车。项目一大,updaterefresh 这种名字几乎必然冲突。用「模块:动作」的命名约定,比如 import:progressauth:successwindow: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 里,EventsEmitEventsOn 覆盖九成场景,EventsOnce/EventsOnMultiple 处理限次订阅,EventsOff/EventsOffAll 负责清理。

React 里的标准姿势就一条:useEffect 里注册,把 EventsOn 的返回值当清理函数返回。

场景上记住分工——要结果用绑定,发通知用事件。进度推送、后台任务状态、系统级变更这类,都是事件的主场。

下一章回到前端调 Go 这个方向,把 Promise、类型映射和错误处理这几件事讲透。