nsgo 是做什么的?
一个 Go UI 引擎:应用逻辑、响应式图、样式、布局和文本均在 Go 中运行;每个平台使用自身的原生视图进行绘制。应用可使用 Go 或编译为 goja 的 TSX 编写。设计、命名和行为均移植自 NSH(Rust)。
铁律:阅读 API,切勿猜测
NSGo 是预发布版本,没有稳定 API,且网络上几乎没有公开文档,因此回忆的标识符几乎总是错误的。Go 在遇到一个错误的字段名时就会编译失败。在编写应用之前,请从模块本身获取真相:
go doc github.com/oneo-me/nsgo/core # 或:go doc -all ./core
go doc github.com/oneo-me/nsgo/base
在仓库的检出目录中,ARCHITECTURE.md、docs/architecture/*.md(25 个主题文档)和 examples/ 是权威的;examples/gallery 练习了每个元素、组件和服务。如果你既没有模块也没有仓库,请说明情况 — 不要用其他框架(Fyne、Gio、Wails)来替代用户的实际请求。
设置
需要 Go 1.26.0+(go.mod 中声明为 go 1.26.0)。
| 操作系统 | 原生目标 | 备注 |
|---|---|---|
| macOS | ✅ | 完整工具链:使用 Go + Xcode 构建/签名每个目标 |
| Windows | ✅ | 原生 + Web 构建和运行 |
| Linux | ✅ | 需要 GTK 4.14+、Pango、Cairo;WebKitGTK 6.0 和 GStreamer 用于 Web 视图/视频 |
只需向应用添加一次模块及其工具 — go tool nsgo 随后无需安装即可运行:
go get -tool github.com/oneo-me/nsgo/cmd/nsgo
NSGo 不发布任何带标签的版本,因此模块解析为一个伪版本(v0.0.0-2026…-46c7d9c)。某些地区镜像对未打标签的模块会返回 404;如果 go get 找不到,请固定代理:
GOPROXY=https://proxy.golang.org,direct GOSUMDB=off go get -tool github.com/oneo-me/nsgo/cmd/nsgo
只有在 sum.golang.org 不可达时才需要 GOSUMDB=off。如果使用本地检出,请指向它并整理:
go mod edit -replace github.com/oneo-me/nsgo=/path/to/nsgo && go mod tidy
创建项目
mkdir counter && cd counter
go mod init counter
go get -tool github.com/oneo-me/nsgo/cmd/nsgo # 库和工具
go tool nsgo init -name counter . # 写入 nsgo.yaml, app.go, app_test.go
go mod tidy
go tool nsgo dev . # 运行;每次保存时重建
go tool nsgo test . # 无头模式:2 passed
init 需要一个名称。 在已有目录中直接运行 go tool nsgo init 会失败,报错 "." is not an app name,因为默认名称来自目录 — 而 . 就是 .。请传入 -name,或者在要创建的目录中执行:
| 命令 | 效果 |
|---|---|
go tool nsgo init -name Counter . | 写入当前目录,应用名为 Counter |
go tool nsgo init Counter | 创建 ./Counter/ 目录,其中包含应用 |
go tool nsgo init -template tsx Counter | 创建 ./Counter/ 作为 TSX 应用 |
init 会打印下一步(next: go tool nsgo dev Counter)— 请信任它而非记忆。
-template 可以是 go(默认)、tsx、mixed。应用名称可以包含字母、数字、空格、- 和 _;bundle id 变为 dev.nsgo.<name>。传入 -module 也会写入 go.mod。
| 模板 | 文件 |
|---|---|
go | nsgo.yaml, app.go, app_test.go |
tsx | nsgo.yaml, main.go, App.tsx, app.test.tsx, tsconfig.json, .gitignore |
mixed | 同上,且 App.tsx 导入自己的 Go 代码以及一个 native.go |
nsgo.yaml 指定应用名称及其目标:
name: Hello
id: dev.oneo.nsgo.hello
version: 0.1.0
dev:
target: native # native | web | ios | ios_simulator | android
hot_reload: true
fps: false
build:
output: dist
targets: [macos_arm64, windows_x64, ios_simulator, android_arm64, web]
其他有效字段:build_number, appearance, theme, i18n, icon.{apple,png}, dev.icon, android.permissions, sign.{profile,keystore}, web.host。此外,resources/ 存放发布文件(加载为 asset:),assets/ 存放仅项目文件(图标),locales/ 存放 en.yaml, de.yaml… 采用 ICU MessageFormat。
你得到的应用(-template go)
import (
"fmt"
"github.com/oneo-me/nsgo"
"github.com/oneo-me/nsgo/base"
"github.com/oneo-me/nsgo/core"
)
func Root(cx *core.Scope) core.Node {
count := core.NewSignal(cx, 0)
return core.View{Style: core.Style{FlexGrow: 1, Padding: core.All(24), Gap: 16}, Children: []core.Node{
core.Text{ContentFunc: func() string { return fmt.Sprintf("Pressed %d times", count.Get()) }},
base.Button{
Label: "Press me",
OnPress: func(core.PressEvent) { count.Update(func(n int) int { return n + 1 }) },
Style: core.Style{Background: core.RGB(0x0969da), CornerRadius: 8, Padding: core.Symmetric(10, 16)},
Children: []core.Node{core.Text{Content: "Press me", Style: core.Style{Color: core.RGB(0xffffff)}}},
},
}}
}
func main() { nsgo.Run(Root) }
nsgo.Run 接收一个根视图(func(cx *core.Scope) core.Node)或一个应用函数(func(app *core.Scope)),后者通过 desktop 打开自己的窗口。可选参数:WindowSize(w, h), Appearance(...), HideDock(), FPS(bool), WindowMaterial(...), Fonts(paths...), Component(name, factory)。
心智模型
- 信号(Signals).
core.NewSignal[T comparable](cx, v)→.Get(),.Set(v),.Update(fn)。NewMemo,NewEffect。响应式图属于引擎协程,从不加锁;其他协程通过Scope.Post进入该图。 - 元素是结构体;零值是默认值(类似
http.Server)。你只需设置想要改变的部分。 - 每个响应式字段都有一对双胞胎:
Content+ContentFunc func() string,Style+StyleFunc,Class+ClassFunc。XFunc优先于X;同时设置两者会在挂载时 panic。没有静态形式的字段只接受函数(Disabled func() bool,Selected,Expanded)。 - 组件是任何实现了
Render(cx *core.Scope) core.Node的类型;其字段就是 props。core.Func适配一个普通函数。 - 区域(Regions) —
Show,Each,Dynamic,Catch— 只重建自己的范围。Each是一个泛型函数,而非方法。 - 样式:类型化的
core.Style值,或在Class中使用 Tailwind 类("flex-row gap-4 p-4 bg-white dark:bg-zinc-900")。base的组件故意不带样式 — 只包含行为、状态和可访问性。
动效
两种时钟。提交 = Go 重渲染一个区域(信号,Show/Each/Dynamic)。帧 = 平台的动画器运行 AnimatedStyle 字段和着色器统一变量,不触发提交。所有动效应属于帧时钟;在渲染期间读取的值会重渲染子树并重启其中的所有动画。
| 需求 | 使用 |
|---|---|
| 状态变化(悬停、按下、选中、调整大小) | Style.Transition — 一个时长,core.EaseOut,以及一个 Properties 位掩码;完全没有响应式图,且在减少动效时跳过 |
| 标准入场/退场/注意/循环 | core.Motion + 一个 core.MotionPreset(共 38 个,都尊重减少动效) |
| 自定义编排、重叠、保持、交换 | AnimatedStyle + AnimatedNode 图(Timing, Spring, Interpolate, Sequence, Stagger, Loop) |
| 逐像素 / 无限场 / 3D | core.Shader 统一变量作为节点 — 零次提交 |
| 程序化绘制 | core.Canvas Draw(每次变化重新记录)或 d.Bind(平台重放,不重新记录) |
spec := core.FadeInUp.Spec() // 或你自己的 core.MotionSpec
m := core.NewMotion(cx, &spec) // nil = 在静止时构建,稍后 Play
return core.View{Style: core.Style{Animated: m.Style()}, Children: []core.Node{card()}}
五条比 API 更重要的规则:(1) 永远不要在渲染中读取信号或动画值 — 只使用闭包或 AnimatedStyle;(2) 重新设置样式而非重建;(3) 有限动画 保持 其最后一个值,因此结束时的帧必须是可读的(如果循环回到起点,一旦稳定,看起来就是空白);(4) 动画元素需要一个已测量的父元素 — 全绝对定位的 View 尺寸为 0×0 并裁剪所有内容;(5) AnimatedStyle 字段是 AnimatedNode,而不是数字(core.Constant(1),而不是 1),并且它没有 Width/Height/Perspective。
在编写任何动画之前,阅读 references/animation.md:预设表格、有效的缓动/时长值、编排方案、着色器和画布动效、3D(光线步进)、测试以及动效 bug 的症状→原因表。
开发循环、构建、测试
| 任务 | 命令 |
|---|---|
| 运行,保存时重建 | go tool nsgo dev <dir> |
| 在浏览器中运行 | go tool nsgo dev -target web <dir> |
| 在模拟器/设备上运行 | `go tool nsgo dev -target ios_simulator |
| 跟踪每次提交 | ... -trace |
| 发布包 | go tool nsgo build <dir>(-target all, -output DIR, -run, -device D) |
| 嵌入宿主应用 | ... build -embed → XCFramework(iOS)/ AAR(Android)/ nsgo.js(Web);桌面使用 nsgo.NewView |
| 测试 | go tool nsgo test <dir>(-run REGEXP, -junit FILE, `-target macos |
| 类型检查 TSX | go tool nsgo check <dir> |
| 重新生成消息目录 | go tool nsgo i18n <dir> |
标志会覆盖它们所命名的 nsgo.yaml 值。build.targets 和 -target 接受 all,或类似 windows_x64, macos_arm64, linux_x64, ios, web 的名称。build 在每个目标下写入一个目录,位于 -output(默认 dist)下,命名为 <name>-<version>-<target>/。桌面测试目标仅在该操作系统上运行(Windows 上使用 -target windows,Mac 上使用 -target macos);web, ios_simulator 和 android 从 Mac 运行。
测试的行为类似于用户。nsgotest 以无头模式运行,不显示窗口;-target 在真实窗口中运行相同的测试,使用原生输入和截图(app.Screenshot("name"))。
func TestMain(m *testing.M) { nsgotest.Main(m) } // 将主线程交给平台
func TestPress(t *testing.T) {
app := nsgotest.Start(t, Root)
app.Press("text:Press me")
app.Find("text:Pressed 1 times")
}
选择器采用 Playwright 语法:id:, label:, text:(子字符串;text:"Save" 或裸字符串 = 完整文本),role:<role> <name>, view:<n>, /regexp/flags,以及 >> 用于下降。步骤:Press, PressDown/PressUp, LongPress, Key, KeyPress, Type, Focus, Hover, Scroll, ScrollTo, Drag, Touch, Back, Resize, Settle, Advance, WaitFor, WaitForGone, Exists, Query, Texts, Dump。完整列表:go doc github.com/oneo-me/nsgo/nsgotest。
TSX 应用具有相同的结构:App.tsx 从 "nsgo" 导入 mount, signal, Text, View(组件从 "nsgo/base" 导入),并以 mount(App) 结尾;main.go 嵌入源文件并调用 nsgo.Run(compiler.App(src, "App.tsx"))。*.test.tsx 文件作为 Go 测试运行。请参阅 references/api.md。
常见错误
| 错误 | 现实 |
|---|---|
| 猜测字段名 | NSGo 是预发布版本,几乎没有公开文档,因此回忆的标识符是错误的。先运行 go doc — 一个错误名称就会导致编译错误 |
同时设置 Content 和 ContentFunc | 挂载时 panic。选择其一 |
NewSignal(cx, []string{...}) | T 必须是 comparable。使用 NewSignalFunc 配合你自己的 equal |
在作用域外使用 NewSignal | 信号需要一个 cx;响应式图是协程本地的,因此 cx 是唯一的句柄。从其他协程使用 Scope.Post |
期望 Button.Label 渲染 | Label 是可访问的名称(role:/label: 选择器匹配的内容)。可见文本来自 Children |
导入 internal/... | 应用只能导入 nsgo, core, base, mobile, desktop, i18n, icons, tsx, nsgotest |
在 Windows 或 Linux 上运行 go build ./... | 失败:internal/platform/apple/edge.go 缺少其 //go:build darwin 标签,并导入了仅 darwin 可用的 purego/objc。构建你自己的包(go build .),或者在检出中添加标签 |
JustifyContent: core.AlignCenter | 两个枚举:AlignItems 接受 core.AlignStart/Center/End/Stretch/Baseline,JustifyContent 接受 core.JustifyStart/Center/End/SpaceBetween/...。混用会导致编译错误 |
core.Shader{OnMouseMove: ...} | 着色器有 OnPress, OnLayout, Style — 没有指针移动处理器。将 OnMouseMove func(Point) / OnHoverOut func() 放在包裹它的 core.View 上 |
单独信任 MustCompileShader | Kage 在运行时编译,因此糟糕的着色器会在启动时 panic,而不是在构建时。使用 core.CompileShader 测试每个源并报告其错误 |
从不滚动且不显示滚动条的 Scroll | Scroll 默认为 FlexShrink: 1,但 View 父容器并非如此:只有 FlexGrow: 1 的列会增长到其内容高度,滚动视图继承该高度,因此没有溢出。给该列添加 FlexGrow: 1, FlexShrink: 1(React Native 的 flex: 1)。断言它:滚动视图的框架必须短于窗口 |
| 寻找永久滚动条 | 指示器是一个叠加层:原生绘制一个圆角拇指,在滚动时淡入,保持 700ms,然后在 300ms 内淡出;HideIndicator 会移除它。在 Web 上,它是浏览器自身的滚动条(overflow-y: auto, scrollbar-width: auto) |
仅使用 Left 固定的叠加层 | 具有一个水平边缘的绝对定位视图会占据其包含块剩余的宽度,而不是其内容宽度:在一个 26pt 的圆形锚点内,Left: 34 的工具提示只剩下很少的空间,并换行到每行一个字符。给它一个显式的 Width |
| 插值颜色内的 Alpha | 由 core.Interpolate 提供的 AnimatedStyle.Background,如果使用 Colors: {RGBA(0xffffff, 0.12), ...},则会绘制成接近纯白色,而不是 12%。为插值填充使用不透明颜色,并为 Style.Background 保留 alpha |
MotionPreset.Name() 作为标签 | 返回 camelCase(fadeIn, zoomIn, breathe),而不是 Go 常量的名称 |
| 动画“跳回起点” | 在渲染期间读取了信号或动画值,或在 Dynamic 的渲染内部构建了 NewAnimatedValue:范围会重建,并且值会重启。请在 […] 内部阅读 |
[…](内容已截断)