外观
从前端调用 Go
前端通过绑定的服务调用 Go:这些 Go 值的导出方法可以被页面调用, 并且可以通过 channel 将值流式传回。Go 通过带类型的事件触达页面。 mygo generate 将它们变成一个 TypeScript 客户端,因此调用、channel 和事件 在两侧都受编译器检查,并在你的编辑器中有文档提示。
这就是网页访问 Go 的方式。原生 UI本身就是 Go:它的视图 直接调用你的代码,不需要以上这些。
绑定服务
在 App.Run 之前,将一个值传给 mygo.Bind:
go
// Notes 存储用户的笔记。
type Notes struct {
mu sync.Mutex
items []Note
}
// Note 是一条笔记。
type Note struct {
ID int64 `json:"id"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
// Add 创建一条笔记并返回它。
func (n *Notes) Add(title string) (Note, error) {
if title == "" {
return Note{}, errors.New("a note needs a title")
}
n.mu.Lock()
defer n.mu.Unlock()
note := Note{ID: int64(len(n.items) + 1), Title: title}
n.items = append(n.items, note)
return note, nil
}
// List 返回所有笔记。
func (n *Notes) List() []Note {
n.mu.Lock()
defer n.mu.Unlock()
return slices.Clone(n.items)
}
func main() {
mygo.Bind(&Notes{})
// ...
}服务以其类型命名,即 Notes,该值的每个导出方法都可调用, 这里是 Notes.add(title) 和 Notes.list()。 mygo.BindAs("Store", v) 显式命名,用于类型名冲突的情况。 当名称被占用两次,或方法使用了 JSON 无法编码的类型(如 channel 或函数)时, Bind 会 panic,因此错误在启动时就会暴露。
方法可以:
- 接受任意多个可被 JSON 编码的参数,包括可变参数, 并可选地在最前面接受一个
context.Context; - 接受
*mygo.Channel[T]参数,在运行期间向调用者流式传输值 (见 channel); - 不返回内容、返回一个值、返回一个错误,或同时返回一个值和一个错误。
每次调用都在自己的 goroutine 上运行,因此方法可以阻塞,例如在 对话框或网络请求上,并且多个调用可以同时运行:如上所示,用互斥锁 保护共享状态。方法中的 panic 会被 recover、记录日志, 并像错误一样报告给调用者。
上下文与调用方窗口
第一个参数为 context.Context 的方法会收到一个上下文,当调用它的页面 发生导航离开或其窗口关闭时,该上下文会被取消:把它传递给耗时的工作。 mygo.CallerWindow(ctx) 返回发起调用的窗口,例如用于把对话框附加到它上面; mygo.CallerPage(ctx) 返回页面,可区分原生 UI 窗口中各个 webview 的页面 (Web view):
go
// Export 询问将笔记保存到哪里,并写入该位置。返回所选路径,用户取消时返回 ""。
func (n *Notes) Export(ctx context.Context) (string, error) {
path, err := mygo.Dialog.Save(mygo.SaveDialogOptions{
Parent: mygo.CallerWindow(ctx), // macOS 上作为该窗口的 sheet
DefaultPath: "notes.json",
})
if err != nil || path == "" {
return "", err
}
// ...
return path, nil
}在 TypeScript 一侧,上下文不是一个参数: Notes.export(): Promise<string>。
生成的客户端
mygo generate 将客户端写入 mygo.config.ts 中 bindings 指定的路径, 新项目为 src/mygo.ts。每当 Go 代码变化时 mygo dev 会重新生成它, mygo build 则在构建前端之前生成,因此你很少需要手动运行。 没有绑定服务、也没有声明事件的应用——例如与网站 package.json 并存的 原生 UI 应用——不会得到客户端:什么都不写。对于上面的服务,它写出:
ts
// Code generated by `mygo generate`. DO NOT EDIT.
import { call, event } from "mygo-runtime";
/** Note is a note. */
export interface Note {
id: number;
title: string;
body?: string;
}
/** Notes stores the user's notes. */
export const Notes = {
/** Add creates a note and returns it. */
add(title: string): Promise<Note> {
return call("Notes.Add", title);
},
/** List returns all notes. */
list(): Promise<Note[]> {
return call("Notes.List");
},
// ...
} as const;方法名转为小驼峰(GetUserByID → getUserByID,URLFor → urlFor),参数保留其 Go 名称,Go 文档注释变成 TSDoc。 客户端导入 mygo-runtime,前端依赖它。
为了生成客户端,mygo generate 会构建应用,并在设置了 MYGO_GENERATE 的环境下运行它:此时 App.Run 写出客户端后直接返回,不打开窗口。 因此 main 会一直运行到 App.Run;把那时绝不能发生的操作—— 例如迁移数据库——放到应用就绪之后,即 App.WhenReady 中。
类型
值在 Go 和 JavaScript 之间以 JSON 传递,遵循 Go 的 encoding/json/v2 规则,客户端声明与之匹配的类型:
| Go | TypeScript |
|---|---|
bool | boolean |
string | string |
int、uint8、float64 及其他数字 | number |
[]byte | string,base64 编码 |
time.Time | string,RFC 3339 |
time.Duration | number,以纳秒计 |
[]T、[N]T | T[];nil 切片到达时为 [] |
map[string]T | Record<string, T>;数字键则为 Record<number, T> |
| 结构体 | 同名 interface |
*T | T | null |
any 及其他 interface | unknown |
| 带常量的命名字符串或数字类型 | 其取值的联合 |
带 MarshalJSON 方法的类型 | unknown |
带 MarshalText 方法的类型 | string |
结构体字段以其 json 标签命名,没有标签的字段保留其 Go 名称 Title:请为字段加上 json:"title" 这样的标签。带 omitempty 或 omitzero 标签的字段是可选的(body?: string),- 则让字段不出现。JavaScript 数字在 2⁵³ 以内是精确的, 因此更大的 ID 请用 string 选项编码为字符串,即 json:"id,string", 它们在 TypeScript 中便成为 string。
带常量的命名字符串类型会变成联合:
go
// Filter 选择列出哪些笔记。
type Filter string
const (
FilterAll Filter = "all"
FilterDone Filter = "done"
)ts
/** Filter selects which notes are listed. */
export type Filter = "all" | "done";错误
返回非 nil 错误的方法会使 Promise 以 CallError 被拒绝, 其 message 就是错误的文本:
ts
import { isCallError } from "mygo-runtime";
try {
await Notes.add("");
} catch (err) {
if (isCallError(err)) showError(err.message); // "a note needs a title"
else throw err;
}err.method 给出失败的方法名,即 "Notes.Add"。
事件
事件把值从 Go 传到页面。在包级别声明它们并带上载荷的类型, 以便 mygo generate 找到它们:
go
// Progress 在导出过程中发送。
var Progress = mygo.NewEvent[ExportProgress]("progress")
// ExportProgress 表示导出的进度。
type ExportProgress struct {
Done int `json:"done"`
Total int `json:"total"`
}Emit 把一个事件发送到某个窗口的页面,Broadcast 发送到每个窗口:
go
Progress.Emit(win, ExportProgress{Done: 3, Total: 10})
Progress.Broadcast(ExportProgress{Done: 10, Total: 10})在页面 DOM 就绪之前发出的事件,会在其就绪后送达。 当窗口已关闭时 Emit 返回错误,从而结束为窗口供数的循环:
go
go func() {
for t := range time.Tick(time.Second) {
if Tick.Emit(win, t) != nil {
return // 窗口已关闭
}
}
}()客户端把事件列在 events 之下,以驼峰命名("progress" → events.progress,"notes:changed" → events.notesChanged):
ts
import { events } from "./mygo";
const off = events.progress.on(({ done, total }) => {
bar.value = done / total;
});
// 稍后
off();
events.progress.once((p) => console.log("first progress", p));事件名必须唯一;以 mygo: 开头的名称被保留。
Channel
一个随时间产生值的方法——例如命令打印的行、模型回答的 token 或下载的进度——通过 *mygo.Channel[T] 参数把它们流式传给调用者, 就像分批到达的响应:
go
// Tail 运行一个命令并发送它打印的行。
func (Shell) Tail(ctx context.Context, command string, lines *mygo.Channel[string]) error {
cmd := exec.CommandContext(ctx, "sh", "-c", command)
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(out)
for scanner.Scan() {
if err := lines.Send(scanner.Text()); err != nil {
return err // 页面停止监听
}
}
return cmd.Wait()
}客户端把该参数的类型声明为 mygo-runtime 的 Channel, 由页面创建并传入,然后进行迭代:
ts
import { Channel } from "mygo-runtime";
import { Shell } from "./mygo";
const lines = new Channel<string>();
const done = Shell.tail("ping -c 3 example.com", lines);
for await (const line of lines) output.append(line + "\n");
await done; // 若 Tail 返回错误则被拒绝也可以不迭代,而是传入一个函数 new Channel<string>((line) => output.append(line)),或设置 lines.onmessage。
- 值按顺序到达,全部在调用的 Promise 落定之前送达。
- 方法返回或调用
Close时,channel 关闭:循环在取完先前 发送的值后结束,并调用onclose。 - 页面可用
lines.close()或跳出循环来停止流:此时Send以mygo.ErrChannelClosed失败,方法的上下文被取消,在本例中会终止该命令。 导航离开和关闭窗口同样如此。 - 当页面尚有超过 1 MiB 的值未取走时,
Send会等待, 因此比页面快的方法不会把值堆积在内存中。在主线程上—— 例如在事件监听器中——它从不等待。
一个 channel 只服务一次调用。channel 只能作参数:Bind 会拒绝 返回 channel,或在其他值内部包含 channel 的方法。
用事件还是 channel?事件会发给页面的每个监听者,只要页面存活; channel 承载一次调用的回答,并随之结束。
不使用生成的客户端
mygo-runtime 按名称调用方法并订阅事件,没有构建步骤的页面 可直接通过 window.mygo 访问同样的函数:
ts
import { call, on } from "mygo-runtime";
const note = await call<Note>("Notes.Add", "Groceries");
on<ExportProgress>("progress", (p) => console.log(p));html
<script>
mygo.call("Notes.Add", "Groceries").then((note) => console.log(note));
// mygo.channel() 创建一个 channel。
const lines = mygo.channel((line) => console.log(line));
mygo.call("Shell.Tail", "ls", lines);
</script>缺少的参数在 Go 中以零值到达。
谁可以调用
只有应用自己的页面可以调用 Go 方法:其前端的页面、自定义协议的页面 (见前端)、file: 和 about: 页面,以及开发构建中 从 localhost 提供的页面(如开发服务器)。显示其他网站的窗口 (例如 https://example.com)会阻止它们调用 Go, 除非 PageOptions.TrustedOrigins 列出了它们的来源:
go
mygo.NewWindow(mygo.WindowOptions{
URL: "https://app.example.com",
Page: mygo.PageOptions{TrustedOrigins: []string{"https://app.example.com"}},
})"*" 信任所有来源,包括页面导航到的任何网站:在显示你无法控制 内容的窗口中应避免使用。嵌入页面中的 frame 无论其来源如何,都不能调用 Go; 只有窗口的顶层页面可以。
受信任页面还能做什么,以及如何显示非应用自身的内容: 见信任。
从 Go 到页面
事件是告知页面变化的方式。要在页面中运行代码——通常用于自动化或测试—— 使用 Page.Eval,它返回表达式的值,会等待 Promise,并从 JSON 解码; 或用 mygo.EvalAs[T] 解码为 Go 类型:
go
title, err := mygo.EvalAs[string](win.Page(), "document.title")
count, err := mygo.EvalAs[int](win.Page(), "const items = document.querySelectorAll('li'); return items.length")语句作为 async 函数体运行,因此 return 产生返回值。 脚本抛出异常时返回一个 *mygo.EvalError。