架构与原理

核心洞察只有一句: 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="{&quot;start&quot;: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 + MUIgotsx
列表页渲染45–60 ms~30 µs
吞吐~31 req/s(4 VM)~28k req/s
客户端运行时react-dom 62 KB gz6 KB
调试没有断点delve / pprof / go test
npm 生态纯 JS 包可用不可用, 库用方言写