今天正好是我搬到美国整整一个月;落地北卡、日常安顿好之后,周边的地区和比较近的景点也基本逛完了,人就闲下来了。可我偏偏不是个闲得住的人,一闲下来就会给自己找点事做 重新捡起博客不就算一件嘛,只不过美国这边的物流不太方便,板子暂时画不了,那就只能回来写点软件划划水,顺便把几个月前丢下的坑捡起来填上?
不过在开始吐槽别人之前,可能还是得先补一点 seam & SeamJS 的背景知识(x 这玩意到底是个什么东西 毕竟上一篇隔了好几个月,你们大概率已经忘干净了,而 GitHub 上的 README.md 我又暂时懒得改
将渲染视为一种协议
用户界面应通过描述来定义,而非通过执行来实现。
简单说,Seam1 首先是一套协议,而不是又一个 SSR runtime。这意味着构建期编出一份带 slot 的 HTML 骨架,运行期或请求期只往这些 slot 里填值;if / each / match 这些同样是写死在骨架里的协议节点。所以请求进来的时候,没有任何一个 UI 组件被执行过,服务端要做的只有这一件事。那它自然也就不必是 Node 或者 Bun 了,Rust / Go 之类的后端填一份 HTML 不也一样行吗,能有什么难的呢?! 但其实后面也没有完全干掉 JS Runtime,我的意思是可以不要,前提是你能接受一部分限制 但无论如何,整体性能提升绝对是指数级的,因为要达到大多数 SSR 的效果,已经不再需要在运行期 / 请求期跑 UI render 了……
请求时的 模型? 2
大概从今年下半年开始,我把本站从 TanStack Start React 完全 port 成了 SvelteKit Svelte。全部迁移完之后,我发现它确实很好:ssr 的性能开销差不多降了一个数量级,水合开销非常小,跑起来很舒服,框架"编译"的哲学也非常棒、非常轻量。但是 Kit 的部分总有一些让我觉得不尽如人意的地方。这其中大部分可以靠插件解决毕竟是 Vite 拼出来的,可总还是有一些部分动不了;不过这大概率也是我自己的问题,因为 seam 想改的地方,可能和任何一个现存的 meta 框架都有本质冲突,说到底是我在挑战它们的执行模型(每个请求都跑一遍 render 函数)、数据边界(load 和 UI 糊在一起)、后端能力(死死绑在 JS runtime 上)。
伪 SSR 3
这里不用想肯定有人问:这不就是 Astro / Qwik / Marko 嘛。,seam 在语言形状上离 Marko 最近,在默认少 JS 这一点上离 Astro 最近,在不 replay hydrate 这一点上离 Qwik 最近;但问题是,他们的 request-time 仍然是在跑 UI 程序,island 各自的 render、Qwik 那一次 SSR、Marko 编译出来的 JS 模板函数,都还是每个请求都要执行一段 renderer。
那 {#each} 一千条本质上我也还是在拼 HTML,所以我不是不生成文档4,区别在于我拼的是已经自包含的协议,而不是重新生成组件 Tree。Seam 要的是 AST → IR → 带 slot 的 HTML,请求时只做协议解释,再加上动态数据区域的 injection,因此不必再跑 UI render 那部分函数了。所以任意后端都能填,把最后一层 residual 化,也就解决了 vendor lock in JS5 的问题。所以其实我和上面那些已有方案的大方向根本不在一条线上(x)
接入
Seam 会水合,但既不是传统那种,也不是 Qwik 那种。传统水合是客户端再 render 一遍,然后和 DOM 对拍;Qwik 是 SSR 出 HTML 加组件状态,客户端 resume,不 replay;但 Seam 的 request-time 连 UI renderer 其实都不用跑:水合骨架、首屏和 hydrate 数据都来自同一次 injection。客户端拿到的是已经对好的 DOM 加同一份数据,按编译期的 IR 把事件和 client state 挂上去 adopt,不是 replay,也自然不是 resume 一棵组件树。
正因如此,「两边各渲染一遍再对拍」这类 mismatch6 已经从默认状态变成了非法状态。窝并不是在宣称实现已经做到 100%,而是说这类偏差在模型里根本不合法:一旦出现,那就是我 compiler / injector 实现里的 bug,可以被 fix,典型的例子就是(骨架没过 HTML parser、转义对不上、IR 和骨架不同步)。
当然 skeleton 自己必须是 parse-stable7 的;在 table 里乱塞 comment,那属于实现没处理好,别算到我的模型头上。构建期算的是水合映射(谁是静态的、谁是 slot、绑定什么),不是把数据提前算死,但它确实把这个 slot 的数据类型固定了下来。数据值本身在请求期仍然可以随意替换;只不过 HTML 和 payload 必须是同一份。
是同一张图吗?
Astro 虽然首屏同样是文档,之后也能 ClientRouter,但区别在于它的动态部分依旧是 islands 各自 render,ClientRouter 本质上更接近 document morph,而不是水合之后仍然活着的同一张组件图。所以 Astro 的技术栈本来就不适合做这件事,它本身没有错,只是并非设计给你这样用的。你当然可以说还有 View Transitions API8 能用,那我就要反问你了:View Transitions API 能当饭吃吗,能让你跨页面套一层 Motion 动画9吗……扯得有点远了;
反正水合之前谁都动不了,首帧10 在 JS 起来之前没有动画,本来就不该怪任何人;Seam 也只保证 injection 对、document 对、client 入口对,真正的约束在水合之后:client 导航是把数据打进那张还活着的图里 (layout 保持不变),而不是再走一遍 injection 去换 document。总之 Seam 在水合之后接管的是同一张静态组件图。
这里其实还有 Marko 6 已经很接近我的想法了(模板 AST、编译期细粒度、resume 而不是 replay);但和 Marko 相比,技术上还能划出来的界线主要有两条:一条是 request-time 跑的仍然是 JS 编译产物,整条链路都被绑死在 JS runtime 上;另一条是作者用的语言是 Marko,不是 Svelte。我相信大多数人聊前端的时候,到了 2026 年也只会把 React、Vue、Svelte、Solid 当成比较主流的 UI Stack 吧,如果我不提,很多人甚至可能根本不知道 Marko 是什么。那么建立在 Svelte 之上,我其实算不上是「把原理复制一份再加个 backend」,我更倾向于解释成:借一套有人写、有人用、有人不断修 bug 不断打磨的 UI 来用,全都是大家熟悉的东西。
另外还有一点,Qwik 的「不水合」省掉的是客户端的 replay,但并没有省掉 服务端的那一次 SSR11(?),所以很遗憾。目前没有任何一个框架能满足我,也没有哪个框架给我足够的 API 让我有做加法的可能,这也是为什么几个月前我会去做 SeamJS 这个东西;那么今天其实就是来重新规划一下它的 roadmap。
现在的我非常认同「Svelte: for building, not frameworking.12」
React
先说 React 吧。抛开被 Vercel Next.js 商业绑定、以及它有自己的 roadmap 不谈,首先它 CVE 满天飞,光这一点就注定了你每时每刻都要盯着社交媒体,看有没有新的漏洞被上报和公开。其他家的路由方案基本处于不可用状态,尤其是 Remix13。但 2026 年有一个非常特别的选项,那就是 TanStack Start。我很早就在用 TanStack Router 了,对此可以给出的评价是:抛开命名风格和数据结构做得不太美观14不谈,从功能性上来说,这是我唯一认可的选项。新是新了点,但它解决了 Next.js 16 的 next-server 进程至今都没有解决的冷启动页面首次访问速度问题。Next.js 其实一直莫名其妙地存在这个卡顿问题,并不是服务器性能不够;我实际上会把原因归结为:要么是启动阶段的加载还没有完成,要么是 runtime 还在编译某些内容。而且这种结构注定了它不太适合 Cloudflare Worker 那种多地实例、就近启动的原则,只有 Vercel 那种传统的 Node 长驻进程15才不会碰到这个问题(?)有点扯远了,关于 Next.js 以后可以单独写一整篇来吐槽。但 TanStack16 真的让我觉得已经足够成熟到可以商用了,至少我已经把它用在某个项目的销售站上了。但这仅仅是仅有的两个选项之一(x)
Vue
Vue 的话,我个人从 Vue 2 迁移到 Vue 3 的时候,就已经不太认同它的语法等机制了,我心中的 Vue 永远是 Vue 2。不可否认,Vue 3 有很多很棒的 feature,但对比 Vue 2,它的内核对我来说感觉变了,而且变得很大。另外从商业角度来考量,Vue 是不是越来越被 meta 层彻底绑死、被生态控制了?它的处境感觉和 Svelte 非常相似,都是开源,但基本上很难有第三方站出来给你做第二个实现。这也是为什么 TanStack Start 加入后的这轮洗牌对 Next.js 的打击真的很大,必须表扬 Tanner Linsley。还有一点也是 Vue 不可忽略的问题:早年的 Vue 确实是一种革新,但任何 UI Stack 在生态起来之后都会遇到生态问题,也就是为了兼容性而做出一定的妥协? 这个问题在 React 上真的非常明显,现在的 Vue 也不例外。开始出现这种内容17的时候,我一般就会把它归为 Slop 了,但也不能说它不好,因为当 Nuxt 成为下一个「企业级」框架之后,首要的考量已经不再是技术正不正确、优不优雅,而是某种向后兼容加稳定性(x
所以 Evan You 其实并没有做错什么,甚至可以说,总得有人站出来当这个 ,而他恰好就是最合适的人选,只是做了最合适的事而已……
Solid
SolidJS 这个框架很好,核心也足够稳定,但没人愿意为它写生态。Solid 其实就是早年的 Rust,只不过 Rust 已经熬过来了;Rust 在编程领域同样面临生态稀少、什么都得自己造轮子的处境,但 Rust 的工具链能让你顺滑地把轮子造出来、让最小可用单元先跑起来,造轮子的难度其实并不算大?可惜的是 SolidJS 还没熬过这个阶段,就撞上了 AI 寒冬;尤其是进入 26 年之后会变得格外艰难。Solid 2.0 的核心已经很好、也很稳定,但外围始终没滚起来,想法非常正确,只是生不逢时;就算有那么几个外围项目,也只散落在一两个方向上 (比如 TanStack 有一个 Solid 版的实验性 Router),拼不出一套完整的生态。这让它在当下和未来都处在一个尴尬的位置:没有完整生态,而在如今 Agent 爆炸的时代,雪球没滚起来,Agent 就不会优先选择它;如果连 AI 都不偏好它,就意味着它得到贡献的机会更少——尤其是在连 Linus Torvalds 都开始 的年代。我觉得 Solid 很遗憾,想法很好、技术路线很正确、实现也很优雅,但真的可惜,生不逢时……
「或许曾经的我不会觉得这是个问题,什么都自己写也无所谓,但很遗憾,现在的我不这么想了」
平衡点
那么在这条路上,恰好踩在最完美平衡点上的其实只剩 Svelte 了。它的生态比 SolidJS 更好 (出生的年代也更好),性能也没差多少,甚至有时候得益于它的编译器,很多时候还比 SolidJS 更快更优?! 当然这里肯定会有人说:当页面数量涨到一定量级,或者用户浏览得越多,Svelte 的资源开销18对比 SolidJS 会越来越大?网络请求会越来越多?嗯,理论上这确实是事实,但我觉得得结合真实网页来看:99.9% 的个人网站或内容网站,访客基本上都不会停留太久,停留很久甚至可以算是非常罕见的情况。至少在我这个站上,目前深度连点的行为极少,所以「翻很多页 = Svelte 更亏」对我并不构成问题。但这不是我选 Svelte 的理由;核心原因还是 markup AST。
另外还有 Dashboard 这类会被一页页点开的东西,这条统计对它帮不上忙,但话说回来,Dashboard 的用户比起真正的内容读者终究还是少数。所以「浏览极多页面导致开销变大」,我认为本质上是个伪命题;至少现在是,也许以后我会有不一样的看法,但在如今这个快节奏的信息时代,我很难不承认这类用户极少;就算真有人因为感兴趣而深度探索、并转化成长期读者,这种探索也大概率只发生在第一次访问,之后他们不过是来看看新发的文章而已;可以说从统计学的回报来看,这种极端情况并不成正比。因此我更愿意相信:大部分场景下 Svelte 能带来更好的综合性能。
Svelte 相比上述三个框架,除了处境和定位更合适之外,其实还很接近写裸 HTML 的感觉;甚至在早年 Svelte 3 刚普及、.svelte 扩展名刚定下来的时候,官方 VSCode 插件还没出来,官方推荐的就是这么做的,以至于现在你还能在 Svelte 的插件页面 找到这句话:
如果你在 VSCode 设置里加过
"files.associations": {"*.svelte": "html" },请把它删掉。
另外 Svelte 还给了我很多好处。首先,它不会把 UI 推向 JSX 加上一堆 JS function19;其次,<style> 是编译期产物,而不是 runtime,前者让结构留在 markup AST 里,后者让 CSS 能直接进入 IR,而 Tailwind 刚好两边都贴得上(
黑箱
就像上一篇写过的,Seam 首先是一套协议,而不是又一个 SSR 运行时。构建期产出带 slot 的 HTML skeleton,请求期只做 injection;if / each / match 同样是协议节点20
只可惜我之前的 UI 选了 React 来动刀,很不幸的是,想在 React 上实现捕捉页面结构并生成变体非常困难,而改编译器的工作量不亚于重写一个……
不改的话,还可以把 React 编译器当成一个黑盒,然后真的跑一堆 render + diff 把结构猜出来用;nullable / enum 这类有限的决策还能穷举,可一旦碰上 price > 10 这种谓词,类型的取值空间根本乘不完。V1 里遇到这种情况只会让你手动提供 mock,比如按 price < 10 / = 10 / > 10 切三刀;
但问题恰恰就在这里(?) 要做到这种精确的切 3 刀,就要求你知道切点在 10,可 React 编译器对我来说是不透明的,我们并不知道条件是什么,于是 TypeSafe21 的代价就变成了:用户在替编译器干活。
理论上这件事不该丢给用户,否则结果就是 escape 满天飞22,而 CTR23 覆盖到的仍然只有那么点 nullable / enum,结构发现等于没做,那它还剩多大意义我就不知道了。
但这次并不是要改协议本身。我原来说的 skeleton、slot、injection,我觉得其实可以保留,后端也依然可以不跑 UI 代码;真正要改的是协议前面的那一层,也就是回答页面究竟是怎么被编译成这些节点的。
那就不得不感谢 Svelte 送给我的最大的礼物—— markup
因为 markup 的结构根本不用我来猜!!!
可见的 AST
仅凭「标记语言真的就是标记语言」这一点,页面结构就完全有机会成为编译器能读懂的东西,而不是一堆运行时 JS 执行出来的组件树。我就是想说,好好的界面,为什么非得能被任意执行绘制呢?我并不是要否定这个观点, 但我想说的是,至少 97% 以上的时间里,任意执行绘制根本用不上,具体可以参考这里
举个反例,React 里的
function Card({ user }) {
return user
? <div className="card">
<Avatar user={user} />
<span>{user.name}</span>
</div>
: null
}本质上还是执行一个 JavaScript 函数,看看它返回什么。哪怕编译器再聪明,它面对的基本模型仍然是:JS execution → JSX expression → element tree。而 Svelte 里面就很不一样。
{#if user}
<div class="card">
<Avatar {user} />
<span>{user.name}</span>
</div>
{/if}它交给编译器的,直接就是
Component
├── IfBlock
│ └── Element div
│ ├── Component Avatar
│ └── Element span
│ └── Expression user.name这对普通的 Kit 应用来说已经很好用了,但对 Seam 这种想把 server rendering 拆成 compile-time skeleton + CTR + SSR fallback 的东西来说,意义就远不止好用一点了。
因为这棵树同时给了我两层信息:第一层是 值的动态 → slot,但这个在 V1 里早就能做到了;更重要的其实是第二层,它给了我 结构的动态 → 协议层的控制流节点。与其去猜 React 编译器那个黑盒,Svelte 的编译器干脆把正经的前端产物摊开给我读了,比如 {user.name} 属于前者,{#if user} 才是后者。
另外,旧协议里的 if / each / match 仍然有效。但之前旧文里那套笛卡尔积,我觉得得加个限定:它成立的前提其实是分支空间,而不是值空间。nullable / enum / bool 这类有限决策,组合数是有限的,compile-time 花钱穷举在数学上站得住;但 JTD 的 string / number / timestamp 本身不可枚举,price > 10、inventory < 5、items.length === 0 这类谓词切出来的分支,光靠类型值的笛卡尔积根本发现不了;sentinel 里随便填一个数进去,永远只会走同一侧。
如果真要把它们收成有限决策,本来就得先把 derive 转成 bool / enum,而这一步并不是 JTD 白送的(笑)。所以 V1 只在 nullable / enum 上 sound,对任意字段的值空间并不成立;真正的工程难题更在于,那些块是 diff 猜出来的,IR 无法解释自己。
下降
但在 Svelte 时代,这些节点可以直接从 AST 生成。这么做的原因并不是为了换个技术栈省下那几毫秒,更多是为了让控制流从观测24变成 lowering——结构发现交给 AST,有限的决策用协议节点来表达,不必再乘出 N 份 HTML;类型只用来约束 payload,而不用来发现树。于是最后在 Svelte 里,values 在这个 Layer 上看起来就是下面这样 ↓
STATIC
<div class="card">
DYNAMIC
{user.name}
STATIC
</div>这类 DYNAMIC 很多时候并不需要 React 那种级别的「真 · 渲染25」,保留 slot 就够了。
再细一点,Svelte 甚至可以把单个 element 拆成这样
structure: static
attributes: static
text node #0: dynamic这才是 Seam 最需要、也最喜欢的东西。在 React 里想知道 attributes 是不是 static,基本上只能暴力穷举:把所有可能的输入都跑一遍,attribute 没变就是 static,变了就给它一个 slot。
结构分支更惨,连「这里到底有没有这块 DOM」都得靠渲染两遍再 diff,而 Svelte 的 IfBlock 把后面这件事从可观测变成了可生成,这才是质变。
但其实这样说也不完全对,或者说太简化了,说得再细一点:单个 element 还可以继续往下拆;而 attributes 也不一定一上来就 static,具体取决于你怎么写。
class="card" → attributes: static
class:active={on} → name static, value slot
{...rest} → opaque, escape hatch
text node {user.name} → slot比如这里第一行进 skeleton 是没有争议的,但第二行才是 Svelte 真正好用的地方:class: / style: 把 attr 这个名字留在 AST 里,只有值单独变成 slot,不必把整个 element 都打成动态。第三行和 React 的 {...props} 没有本质区别,Seam 就把它当成明确的 hatch,不用再假装去分析它了;React 里字面量 className="card" 其实也不需要穷举,需要穷举的是「换一组 props 之后这个 attr 会不会变」。Svelte 恰好少掉的就是最后这一种猜测——只要名字还在树上,就不用渲染两次才知道 🫠
反正就是 React 会逼着 Seam 把很多东西重新当成 运行时问题 来处理,比如 ↓
const Wrapper = cond ? A : B;
return Wrapper({
children: foo.map(renderItem)
});或者说
return foo && bar
? getLayout()(data)
: something();当然,现代的 React Compiler 能分析出其中不少东西,但真要魔改它,就意味着以后得一直维护这份改动,这种开销可以说是极其巨大的,无论是精力还是时间……如果改的是 Svelte,这边我就不打算 fork compiler 了,理论上 svelte/compiler 已经把 AST 暴露出来了。
Seam 要做的是读这棵树,然后生成自己的 IR;官方那套 DOM / SSR 代码生成其实都可以留着当 fallback,理论上只需要维护的是 IR lowering,不是整个 Svelte,所以大方向一致,冲突会少——但一致的是「结构在编译期可见」,不是执行模型;正因如此 Svelte / Kit 的 SSR 仍然是每个请求都跑一遍生成出来的 render 函数;但是我更希望在我的 Seam 里,request-time 不跑这个函数,只解释协议(slot + if / each / match)
在选择 Svelte 这件事上其实还有另一层考量:在 Next.js 和 React 事实上已经完全26被 Vercel 掌控、成为市场框架的前提下,如果 Seam 承诺兼容 React 语义,那我的架构最终就必须允许 arbitrary JavaScript → determine tree structure。
于是 Seam 的 IR 就必须写得非常保守(x),最后大概率会变成:能静态分析的就优化,不能分析的就走 SSR。这样虽然同样是 CTR + SSR 的混合页面,但问题恰恰出在“不能分析”的那一块上,因为 React 的世界里“无法确定”的地方实在太多了……
如果不去改 React 编译器,那就只能停留在跟 V1 一样的处境里:把编译器当成黑盒,靠猜它输出什么产物。这种做法可以说非常脆弱,也不优雅,而且极容易到处冒出边界情况。
反过来做
所以如果我现在说 Seam 的组件语言就是 Svelte,那么编译器甚至可以反过来做:先默认走 structure known,局部用 expression dynamic,明确的地方用 escape hatch,再进 runtime,实在处理不了的最后才回退到 SSR。
<script>
let { product } = $props();
</script>
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
<button>Buy</button>
</article>Seam 完全可以理解成类似下面的样子
<article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<button>Buy</button>
</article>那么理论上 slot0 = product.name,slot1 = product.price。这就是我之前一直在做的残值化(residualization),核心思想是整页不必进服务端渲染,只计算真正动态的那部分残值。
插槽与分支
但从 React 换到 Svelte,真正有收益的其实是下一层;也就是结构发生变化时,也不必再靠渲染两次来猜,因为结构在编译期就已经确定,直接生成对应的 IR 就行了
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
{#if product.available}
<button>Buy</button>
{:else}
<p>Sold out</p>
{/if}
</article><article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<!--seam:if:product.available-->
<button>Buy</button>
<!--seam:else-->
<p>Sold out</p>
<!--seam:endif-->
</article>值仍然是 slot,分支则是旧协议里的 if 块。但在 React 这一版里,得多次 diff 才能拿到这块东西;而在 Svelte 里,这里可以直接从 IfBlock 推导生成。
逃逸
这里当然不是说 JSX 做不到。只是真要写起来,几乎没人会把结构放在分支树上一眼就能看见的地方,基本上都会写成下面这个样子(笑)
const price = formatPrice(product);
const body = product.available
? getAvailableView(product)
: getSoldOutView(product);
return <Layout>{body}</Layout>;而且还有很重要的一点:就算你能写成这样,只要 React 的大部队不这么写,训练出来的 LLM 也不这么写,那迁移成本和后期开发成本一定是指数级往上涨的。
但是 Svelte 这里情况又不一样了
UI structure ≈ template AST,JavaScript ≈ values + behavior,这在这里已经是家常便饭了
这甚至是 Svelte 官方最推荐的做法。这个区别对 Seam 抽取 IR 来说是结构性的。但也请不要理解成 Svelte 没有动态结构,它其实有对应的逃逸口,而且还是主要语法之一
{@render children()}
<svelte:component this={layout} />
<svelte:element this={tag}>
{@html html}Wrapper = cond ? A : B 在这里并没有凭空消失,而是从「任意 JS 表达式」收敛成了这几个节点。对 Seam 来说就很简单了:默认走 AST lowering 就行;静态上看得见的 hole,里面是谁就是谁。最常见的是 {@render} 当作 compose(layout、Card 包住 children 都属于这一类),而 <svelte:component> / <svelte:element> / 动态 {@render} 才是明确的 hatch,会进入 runtime 或 SSR fallback;至于 {@html},可以直接沿用我先前设计好的 raw HTML slot。
所以真正的优势并不是「结构永远已知」,而是「未知的结构有一份参考清单」(x
在 React 里,未知的是整个 JS;在 Svelte 里,未知的只是上面那几个 Escape 留下的开口,仅此而已。用上之后,CTR 需要分析的范围一定会明显缩小。
CTR
能用 Svelte 并不代表就一定能用 CTR,不过我这里有一个参考标准 ↓
这样静态引用才 compose;仍然走下面的 hatch。由此可以得出,CTR 的单位是静态组件图,而不是单个文件。大部分情况下都是默认的 structure known,少部分明确标为 escape hatch,平时几乎遇不到 SSR fallback;其他不在表上的,一律按 opaque 处理就好,根本不用猜。CTR 只是这个子集上的 execution model,就算我切到 Svelte,也并不打算担保整个 Svelte 的范围27(笑)
CSS
另一个巨大的收益其实是 CSS。在 Svelte 里可以这样写:
<div class="card">...</div>
<style>
.card { border-radius: 8px; }
</style>这里 Seam 编译一个 component 的时候,同时已经掌握了 markup dependency + script dependency + style dependency,而且 style 是一份静态产物。于是就可以非常激进地判断出
甚至可以更进一步,把流程做成这样 ↓
以上整个流程完全不需要 JS runtime 参与。
不用 CSS-in-JS
CSS-in-JS 我同样不接受,倒不是因为 React 逼着我去兼容下面这样的写法
const Button = styled.button`
color: ${p => p.primary ? "red" : "black"};
`;或者
<div css={theme => ({
color: theme.colors.primary
})}>只是上面这套结构,其实在 V1 里就已经能用 Tailwind 绕开了。不接 CSS-in-JS 的主要原因是:一旦接上,CSS 就重新变成 JS 的执行结果,于是样式运行时、注册表、SSR 收集、哈希、hydration 一致性、主题上下文、插入、排序、去重,全都得请回来。普通框架能吞下这些;但 Seam 她不一样啊!我要消灭的恰恰就是这类服务端与运行时的活儿,总不至于为了对生态客气,就把刚赶走的东西再请回来吧(完全没有道理呢
再说,CSS-in-JS 出现的那个年代,要解决的正是今天 Tailwind CSS 和 Motion 在做的事情。如果现在 Tailwind CSS 和 Motion 已经足够解决,甚至解决得更好更优雅,那又何必开倒车呢?就像我前面说的:「任何东西在生态成熟之后都会出现妥协28,很少会有 breaking 级别的重构来带来革新」。那么既然 Seam 的生态还没有起来,为什么不激进到底呢?
但我不确定「激进」这个词用在这里对不对。说它对,是因为相对于传统框架,我确实丢掉了服务端那套沉重的 JS 运行时(Node 或 Bun);说它不对,是因为像 CSS-in-JS 这种东西,就算彻底丢掉其实也没什么问题,我更愿意把它看作。曾经最知名的那几个 CSS-in-JS 库:Stitches 在 2023 年 6 月官方宣布不再积极维护,styled-components 在 2025 年 3 月也进入了休眠状态,Emotion 基本上也是同样的处境。
那这里就不得不夸一夸 Svelte 的 <style> 了,CSS → compile-time artifact 这条路径在 Svelte 里基本上已经毫无争议 (至少我是这么认为的,如果你不同意,那就算你对)
只要不是 JS → execute → generate CSS → collect → serialize → hydrate 这样一路走下来,差距就会非常大;更重要的是,CSS 依赖说不定可以直接进入 Seam IR 🤔
比如组件编译:
ComponentIR {
skeleton,
dynamic_slots,
css,
client_behavior,
server_dependencies
}注意,这并不是另起一套渲染模型:在 V1 里,skeleton 加上 dynamic_slots 编译出来的,就是旧协议里的那份 HTML,只不过 skeleton 加 dynamic_slots 是怎么来的,对我来说更透明了。
我们假设按下面这样处理 ↓
<script>
let { name } = $props();
</script>
<div class="user">Hello {name}</div>
<style>
.user { font-weight: 500; }
</style>那么最终一定会达到
ComponentIR
HTML: <div class="user svelte-x">Hello SLOT(0)</div>
CTR: slot(0) = props.name
CSS: .user.svelte-x { ... }
Client: none
SSR: none但请注意,你上面看到的只是叶子节点,而且是相当理想的一个切片:没有子组件,没有结构分支,也没有客户端部分。页面级的 skeleton 是由静态组件图组合出来的;子树里一旦出现 $state 或 hatch,中间表示里的客户端 / 服务端渲染就不会是 none 了。
但这已经非常接近我想让 Seam 达到的理想状态:一个 Svelte 组件不再对应「一个需要执行的 renderer」, 而是变成一个 Svelte 5 编译器可以拆开的资源。光是做到这一点,对我来说就已经是非常大的概念性突破了。
归属权
另外,“不推行全局状态”对 Seam 的意义也比开发体验更深。我相信你在 Next.js 项目里,或者任何 React 全栈框架里,肯定已经见过下面这一坨东西了 ↓
<ThemeProvider>
<AuthProvider>
<QueryClientProvider>
<I18nProvider>
<RouterProvider>
<App />
</RouterProvider>
</I18nProvider>
</QueryClientProvider>
</AuthProvider>
</ThemeProvider>当然这并不是没有解决办法,这种"金字塔"式的 Provider 嵌套其实可以改写成 Compose Providers,大致就是变成下面这个样子。
具体做法就是把原来层层嵌套的那堆 Provider 丢进一个数组,再定义一个组合函数,最后只吃这一层就行了。但这对我来说其实是一种自欺欺人,本质上只是把嵌套"藏起来"了,并没有真的消除它。也就是把视觉上的嵌套变成了逻辑上的嵌套而已,某种意义上就是,纯粹的自我安慰。
const AppProviders = composeProviders([
ThemeProvider,
AuthProvider,
QueryClientProvider,
I18nProvider,
RouterProvider,
]);
function composeProviders(providers: React.FC<{ children: React.ReactNode }>[]) {
return ({ children }: { children: React.ReactNode }) =>
providers.reduceRight(
(acc, Provider) => <Provider>{acc}</Provider>,
children
);
}
function Root() {
return (
<AppProviders>
<App />
</AppProviders>
);
}React 项目最后真的非常容易、甚至可以说 100% 会变成这样,于是就产生了一个很讨厌的接缝问题,也就是:「组件的输入到底是什么?」表面上你可以说是 <ProductCard product={product}/>,好像只依赖 product 本身?可实际上呢
depends on:
product
theme
locale
queryClient
router
auth
featureFlags
...于是 component dependency graph 又会变成隐式的。对 traditional SSR 来说这问题不大,大不了就是「把整棵 React tree 跑一遍」;但 Seam 最后真正想问的是 ProductCard 能不能独立做 CTR,到这一步就真的非常麻烦了。甚至可以说要连整个 React 编译器的行为、用户的 DX 和使用习惯一起改掉,那又何必呢……
用 Next.js 的人基本上已经不在乎性能29了,这句话我可以很负责任地说;他们更多是在为 Vercel 的配套设施买单,如果你用了 Next.js 却不托管在 Vercel 上,那真是一点好处都捞不着 😡
局部所有权
反观 Svelte,它非常容易形成局部的所有权;
<script>
let { product } = $props();
let quantity = $state(1);
</script>从 Svelte 和 Seam Compiler 的角度看,这样非常舒服。
毕竟 「输入: product + 本地客户端状态: quantity」所有权真的已经非常清楚了
product.name → server CTR
quantity → client
button onclick → client
rest → static但这只是默认行为,其实算不上保证;因为 getContext('theme') 和 .svelte.ts 里的 rune module 仍然是隐式输入,另外 QueryClient 也不会凭空消失。我觉得 Seam 的态度会和前面那份 hatch 名单一样:看得见就标进 IR(记为 server 依赖或 client 所有权),分析不出来就当作 opaque,总之还是不要假装「Svelte 里没有 Provider 这回事」比较好。不过也未必,也许我再稍微研究一下,是能找到 Provider 的处理方案的
React 时代里 Seam 最大的问题是 IR 走的是 Component → execute renderer → get output,那么我最后所谓的 seam compiler 实际上就变成了某种意义上提前发生的 SSR,或者完全可以说成是
「通用 SSR 编排层」
协议照旧
所以从现在开始可以说 UI 就是 Svelte,但 seam 协议本身仍然是协议,仍然以 slot、injection 为主,JTD 到底合不合适,还需要我进一步斟酌;从执行决策上看,现在已经是完全分层的了,槽已经 residual 化,Rust / Go / TS server 只需要照着填就行,完全不需要理解组件代码,真正的零 JS;与此同时,如果组件里还有纯派生,那么可以考虑加入一个 generated JS / QuickJS runtime,(注意这里跑的是数据,不是 UI)
只有标了 hatch 的才会 SSR fallback,那么后端在不需要真正 SSR 的情况下就可以不跑 UI 代码,自然也就不存在被绑死在 Node 或 Bun 上的问题,不过呢(Go Server 计划停止维护,因为我个人精力实在不够 如果有好心人有时间,可以接手长期维护,并不是架构不再支持后端无关性了)
换句话说,我只砍掉了 UI 这一层,谁的活我都接,这种费力不讨好的事情(?)不变的那部分仍然可以充当「Rendering Protocol」,后端不限于 JS/TS。
Runes 语义
还有很重要的一点是,Svelte 5 的 runes 对 Seam 非常友好,尽管 Svelte 5 开始变得更像 JS 了一些。
let count = $state(0);
let doubled = $derived(count * 2);但此 JS 非彼 JS30,它和 useState / useMemo / useContext / useEffect 的差别在于:runes 仍然是 compiler-recognized semantics,所以 Seam 能够知道 $state 归 client 所有,$derived 是派生值,$effect 属于 client runtime;这和 const foo = someLibraryHook() 根本不在一个量级上。
数据来源
这里有一个很重要的点:「认得出来」其实不等于「能 CTR」,尤其不要一看到 $derived 就默认它会进 skeleton。上面这个 count * 2 就是反例之一,因为它依赖的是 $state,所以它属于 client 端的派生产物。可以看看真正能 CTR 的 $derived 长什么样:
let { product } = $props();
let price = $derived(formatPrice(product.price));product 来自 $props,formatPrice 还必须是纯函数,而且可见;接到前面那张表上,就是这个样子。
$state / $effect / onclick → client
$derived
deps ⊆ server data, pure, visible → CTR derive
deps include $state → client
impure or opaque callee → QuickJS or SSR fallback
markup → follow the table above; runes are not a free CTR pass画出来看可能更好理解(?)
总之,Runes 的友好之处在于所有权被标在语法层面,而不是因为 $derived 自带 CTR 资格。判断的唯一标准,其实还是下面这条路径展示的数据从哪来、纯不纯、看不看得见(笑)
然后在旁边再加上一条 <style> → static CSS dependency graph,这样整个世界观就非常一致了:HTML 就是结构,CSS 是样式产物,JS 一定是计算与行为。
而不是像现代 React 那样,很容易一路滑向 全都塞进 JS31。或许对 React 自己来说,这未必是坏事,但很遗憾—— 一切皆 JavaScript 恰好是最不利于我做激进的编译期分解的那种世界
到底会 Ship 什么? 32
说了这么多,最后真正会落地的内容有哪些呢(笑)。首先毫无疑问的,一定是把 UI 栈换成 Svelte,并且可以预期,做完之后编译器的可观测性大概率会明显提高。主要原因还是 Svelte 强制并鼓励把 structure 留在 markup AST 里,而不是埋进任意的 JS 控制流中,这样会直接扩大 skeleton / CTR 的可分析空间,也让 CSS 从 runtime concern 重新变回 build artifact。
这意味着 Seam 有机会做组件级 CSS 依赖、关键 CSS、摇树优化、CSS 懒加载,而不用额外维护一套 server / client 的 style runtime。
还有就是 组件归属 会彻底变得干净;少一点 Provider / Context / global runtime dependency 意味着我有机会彻底回答清楚:哪个值来自 服务端,哪个地方可以走 CTR 传递,谁需要 client boundary(哪些状态属于客户端),以及这个 component 是不是真的需要 hydrate……
在这三者里面,前两者可以直接决定 CTR 到底只是一种优化手段,还是能真正成为 SeamJS 的主要 执行模型33,这也就是为什么对我来说放弃 React 不一定真的是技术上的妥协,反过来也可以是结构上的一次破坏性重构。总之我还是那句话
任何东西在生态成熟之后都会出现妥协,
很少会靠 breaking 式的重构来完成革新
既然我还没真正做起来,那我当然有的是机会推翻重来,暂时也不必考虑迁移成本;我也真的希望有一天,能在无数次推翻重来之后,找到真正属于我的位置。
哪怕它最后只有我一个人在用,这也是属于我的一次 「实验 🧪」