架构与原理
核心洞察只有一句: SSR 是一次同步的单趟求值。 没有重渲染、没有 effect、setter 永远不会被调用。所以服务端不需要 React 运行时, 只需要组件的"渲染切片"——而它的语义小到可以编译成 Go。
编译流水线
1. 解析
自己写的 TSX 子集 parser(词法 + 递归下降 + JSX 模式), 约 900 行 Go。
2. 类型检查
局部推断 + 边界标注: props、宿主签名、hooks、内建方法表。出子集就报错。
3. 响应性判定
哪个表达式读到了 signal/memo。这个判定同时驱动两个后端, 所以 hydrate 标记一定对得上。
4. Go 后端
组件 → Go 函数, JSX → gotsx.El/Text/If/Nodes, hooks → 单趟语义, host:* → 直接调用。gofmt 校验语法。
5. JS 后端
组件 → 函数, useState → G.signal, 依赖 signal 的 const → G.memo, JSX → G.el/t/text/cond/each。
6. go build
生成的 Go 和你的 main.go、host 包一起编成一个二进制; 客户端 JS 直接作为 ES module 服务。
hooks 在 Go 里是什么
同一份 Counter.client.tsx 的 Go 输出
gen/islands_Counter_client_gen.go
func Counter(props CounterProps) gotsx.Node {
return gotsx.Island("Counter", props, Counter_ssr(props))
}
func Counter_ssr(props CounterProps) gotsx.Node {
var n float64 = props.Start // useState → initial value
setN := func(any) {} // setter → empty function
var double float64 = (n * 2)
return gotsx.El("button", nil,
gotsx.Dyn(gotsx.Num(n)), gotsx.Text(" ×2 = "), gotsx.Dyn(gotsx.Num(double)),
gotsx.If((n > 4), func() gotsx.Node { return gotsx.El("b", nil, gotsx.Text(" 🔥")) }))
}useState(start)→var n float64 = props.Start; setter 是空函数。useEffect、事件处理器、async 函数 → 不生成。- 响应式的
{n}→gotsx.Dyn(带标记); 静态文本 →gotsx.Text。 - 岛的外壳
gotsx.Island(name, props, inner)把 props 序列化进属性, 并打开标记模式。
走位 hydrate
服务端输出的 HTML(节选)
岛内部的标记
<gotsx-island name="Counter" props="{"start":0}">
<button>
<!--$-->0<!--/--> ×2 = <!--$-->0<!--/-->
<!--[--><!--]-->
</button>
</gotsx-island>客户端拿到同一份结构的 JS:
gen/client/Counter.js
export default function Counter({ start }) {
const [n, setN] = G.signal(start);
const double = G.memo(() => (n() * 2));
G.effect(() => { console.log(n()); });
return G.el("button", { onClick: () => setN((n() + 1)) }, () => [
G.text(() => n()), G.t(" ×2 = "), G.text(() => double()),
G.cond(() => (n() > 4), () => G.el("b", null, () => [G.t(" 🔥")]))]);
}G.el 认领下一个元素, G.t 认领下一个文本节点, G.text 认领 <!--$-->…<!--/--> 之间的文本并绑 effect, G.cond / G.each 认领 <!--[-->…<!--]--> 区块。子节点用 thunk 延迟到父元素认领之后再执行, 顺序就是源码顺序。没有 diff, 没有重建, 已有 DOM 原样复用。
为什么能这么简单: 两端的结构来自同一个编译器、同一个响应性判定。React 需要 diff 是因为它不知道服务端到底渲染了什么。
宿主模块与围栏
- 宿主模块是 Go 值;
hostgen反射它生成host.d.ts(给编辑器)和host.json(给编译器, 含 Go 名和数值类型)。 - 方言能做的严格等于 Go 暴露的。Go 是唯一的真相源: 路由、数据、权限、动作都在 Go。
- 围栏在类型检查阶段: 客户端 import host、客户端 import 服务端组件、服务端出现 await、未知 prop、any 上取成员——全是编译错误。
SPA 跳转
点击链接 → fetch 新页 HTML → idiomorph 把 body morph 过去 → 岛按 DOM 同一性存活(同名同 props 不动, props 变了重建, 消失了卸载)。表单 GET 同理; 后退/前进走 popstate; 并发导航只认最后一次; 非 HTML 响应退回整页加载。
和 goja 路线的对比
同一台机器, 同一类页面
| 项 | goja + React + MUI | gotsx |
|---|---|---|
| 列表页渲染 | 45–60 ms | ~30 µs |
| 吞吐 | ~31 req/s(4 VM) | ~28k req/s |
| 客户端运行时 | react-dom 62 KB gz | 6 KB |
| 调试 | 没有断点 | delve / pprof / go test |
| npm 生态 | 纯 JS 包可用 | 不可用, 库用方言写 |