这篇文章可能并不适合所有读者。要理解文中内容,你至少需要具备准前端开发者或全栈开发者的水平,对 SSRSSGISR 等概念有基本认识,并且至少使用过 Next.js

前言

可能是我天生就对底层原理有着强烈的好奇心,有时还会执着地追问为什么不能这么做。我猜正因如此,自己偶尔才能冒出一些独特的想法——这就是其中之一。它早在 2025 年 11 月就已经出现了,当时我也和身边不少朋友聊过,但由于手头项目在时间上发生冲突,直到 2026 年新年前后,我才真正动手做出第一版。之后它又被搁置了——因为我跑去建网站了。虽然博客还没有完全搭好,但总算有了一个勉强能记录东西的地方,所以我想把这件事重新拿出来讲一讲。

首先声明一点:我并不是说 SSR 不好。我只是觉得,就我观察到的趋势而言,太多人正在滥用它,整个行业都在随波逐流地回避一个问题。我也和不少人讨论过这件事,但得到的回应大多含糊不定;我曾讲过自己的构想,对方却总担心其中会遇到各种问题和边界情况。说实话,我也不太想每次都从头到尾把整套思路完整讲一遍。所以今天干脆把它详细写下来,试着把其中的一些疑问和技术原理解释清楚。

被滥用的服务端渲染

早在刚入门 Web 前端开发时,我接触的第一个全栈框架应该就是 Next.js。我承认它是一个非常优秀的框架;尽管存在一些性能问题,但这些性能代价换来了实实在在的 开发者体验(DX),这一点无可厚非。然而,随着我逐渐深入理解前端的概念和知识,我发现它可能越来越不适合我了——我很喜欢写 Rust,也喜欢研究底层,还玩过嵌入式,因此对性能有一种格外强烈的执着。这并不是说凡事都要追求极致,而是:明明有性能显著更好、实现起来或许只需付出很少努力的方案,为什么大家不这么做?所以每当使用 Next 时,我都会想:为什么一个哪怕非常简单、绝大部分内容都固定不变、只有一处需要更新的页面,也要从头到尾重新渲染一遍?为什么?在我看来,哪怕后端不是用 Rust 编写,哪怕不追求极致性能,甚至只是运行在 JavaScript 解释器上,只更新真正发生变化的那一点数据,开销也理应相对很小;所谓“渲染”应该以一种成本很低的方式完成,而不是把整个页面重新渲染一遍。

SSR re-renders the entire page for one small changeA page where only the username changes,but traditional SSR rebuilds the entire component tree and re-renders everything.NavHero sectionUsernameSidebarContent blockFooterChangedStatic (unchanged)Traditional SSRrenderToString()Entire component treeNavHeroUserSideContentFooterAll re-rendered (~100-300ms)Only 1 field changedbut the entire page was rebuilt

后来,这甚至成了我心里的一道坎,变成了一个不得不认真思考的问题。讽刺的是,当我回顾前端近十年的发展,审视 VueSvelteSolid,以及 Next.jsTanStack StartRemixNuxtSvelteKitSolidStart 等框架后,却觉得大家似乎都走偏了。尤其是 ReactRSC,在我看来就是一个错误;不过,它的出现其实也有其缘由,之后有机会的话,也许可以再拿出来详细谈谈……

在我看来,RSC 和一众 SSR 框架其实都在滥用 renderSSG 倒是实现了我想要的模式——页面在构建阶段就已生成,请求到来时无需重新渲染——但它不具备真正的动态能力(x)。至于 ISR,它看似在 SSG 和 SSR 之间取得了平衡,也提供了一定的灵活性,却仍未从根本上达到我的期望。在我看来,ISR 的出现其实就是为了弥补 SSR 性能不佳的问题,后续出现的各种 CDN 缓存策略等做法也都只是 权宜之计,而不是真正优雅的解决方案。

当然,SSR 本身并没有错;它是一项很好的技术,解决了许多实际问题。真正的问题在于,这些框架将 SSR 置于近乎绝对默认的位置,结果是开发者真正使用 SSR 时,可能有 95% 的场景根本不需要它。页面上的绝大多数内容都是静态的,真正需要动态生成的部分少之又少,但每次收到请求时,整棵组件树仍然要重新运行一遍。

不过,React 近几年倒有所改进:React 19.2 引入了 PPR,但它在运行时仍需付出高昂的 renderToString() 成本,只能算一种权宜之计,并非真正的解决方案。所以我在想,既然大部分内容在构建时就已经确定,为什么不直接在编译期完成渲染呢?

Rendering approaches spectrum from static to dynamicCompares SSG,ISR,SSR,PPR and RSC on a spectrum with two cost arrows between rows.SSGNo dynamicISRWorkaroundPPRWorkaroundSSR~100-300msRSCArbitraryStaticRuntime costDynamicWhat if?SSG's speed + SSR's dynamic=???

编译时渲染?

把渲染放到编译期,听起来确实很荒谬,因为 SSR 存在的核心理由,就是有些值和条件只有到了运行时才能确定;在那之前,你根本不知道它们是什么,自然也无法作出判断。所以,我想大多数人第一时间否定我的想法也并非毫无道理,完全可以理解。

但我其实想到了一个颇为精巧的 Pipeline 来做到这一点,这也正是我需要称它为一种 Protocol 的原因。另外,我一直觉得全栈框架混淆前后端边界是件很蠢的事(虽然不得不承认,近几年 Next.js 在这方面的开发体验确实做得很好,让许多初学者觉得写一个全栈应用很简单,但实际上,很多安全隐患也正是由此而来——扯远了)。

Component and data separation — values become typed slotsShows how pure components have typed slots,and runtime values collapse into finite JTD types.TraditionalComponent + data coupledDashboard componentfetch("/api/user")if (role==="admin") ...Value unknown until runtimeInfinite possibilities?SeamComponent + data separatedPure componentUI only,no data fetchingData (typed)JTD schema contractstringbooleanenumint32Finite,enumerable → compile-time rendering possible

因此,在组件中获取数据也是同理。我更偏好一种边界清晰的做法:组件只负责成为纯组件,数据获取则应彻底抽离。接受这一前提、将纯组件与数据分离之后,事情就有意思了:你会发现,数据本身其实也可以分类。

这里也要感谢 TypeScript 给我的灵感。其实,所谓的“值”在编译期具体是什么并不重要,重要的是它的类型;一个 slot 里该填什么内容,只要它不是 Open String(即拥有无限种可能的字符串),就可以被包裹成只有有限种可能的类型。比如,你在编写一个仪表盘,需要根据条件渲染某块内容,无非就是用户、管理员等几种情况,总归都能定义成完整的类型;现实世界中,几乎所有需要条件渲染或进行逻辑判断的地方,都可以归纳为几种确定的可能性。

恰好,我们可以用一种很好的方式来描述这些可能性,那就是 JTD

JTD 规范

RFC 8927 定义的 JTD(JSON 类型定义)包含八种模式形式:空、引用、类型(布尔值、字符串、时间戳及各种精度的数值类型)、枚举、元素、属性、值和判别器;此外,任何模式都可以标记为 nullable。JTD 的优势在于它可以跨语言使用:几乎在所有语言中,包括 JavaScriptRustGo,都能找到对应的类型映射。这使它天然成为连接前后端的桥梁,而其载体 JSON 本就是前后端之间的最小公约。现实世界中,网站里 95% 的应用无非是在显示字符串或数字字段,或根据布尔值作出判断,而这些需求全都涵盖在 JTD 的能力范围内。明确这一点后,我们实际上就有了很大的操作空间。当然,JTD 并不完美,也会遇到意料之外的情况,例如 Markdown 这类内容;不过我们稍后会详细讨论。事实上,正是在这里,我才真正认可 SSR 的意义。

哨兵值

先说容易的。既然我们已经知道每个动态值都有类型,就可以在编译期用 renderToString() 运行一遍 React 组件,不过传入的不是真实数据,而是用 Sentinel 模拟出来的数据。这是什么意思呢?

{ user: { name: "Alice", age: 30 } }

假设你的数据是这样的,那么我们可以将它替换为

{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }

这些 %%SEAM:...%% 就是哨兵值,分别占据每个动态值的位置。React 使用这些哨兵值完成 renderToString() 后,输出的 HTML 中相应位置便有了标记。随后,构建管线会将这些哨兵值转换为以 HTML 注释表示的插槽标记。

<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>

<!-- Slot -->
<span><!--seam:user.name--></span>

到这里,你可能已经看出来了:这些 slot 就是**“带类型标记的插槽”。此时,服务端运行时要做的事情变得极其简单:拿到真实数据,把值填进这些插槽即可,本质上就是纯粹的字符串替换。完全不需要 renderToString(),不需要 JavaScript 运行时,也不需要 vDOM。任何能够解析 HTML 注释并执行字符串替换**的语言都可以用作后端,Rust、Go、TypeScript 都可以。这正是它是一套协议而非框架的原因。

Sentinel to Slot build pipeline — horizontalBuild-time pipeline:real data becomes sentinel data,rendered by React,converted to typed slot markers.Build-time pipelineReal dataSentinel%%SEAM:name%%renderToString()React renderHTML skeleton<!--seam:name-->Before<span>%%SEAM:user.name%%</span>After<span><!--seam:user.name--></span>Typed slots — any language can inject data via string replacement

这时你可能会想到:条件渲染怎么办?比如,当某个字段为 null 时,整块内容都不应该出现。但其实这也不需要由运行时的 JavaScript 来判断,反而可以直接在协议中定义这种情况。

<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->

条件渲染是这样,列表渲染也是如此。

<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->

甚至连模式匹配也不例外。

<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->

这就是注释的艺术。之所以选择注释,只是因为它恰好是合法的 HTML 内容,仅此而已。那么,这些条件块和循环块是如何在构建时确定并生成的呢?其实用的也是一个颇为巧妙的办法:虽然没有虚拟 DOM,但我们可以渲染两次 HTML 并比较差异。以条件渲染为例,第一次使用完整的数据哨兵渲染,第二次将某个 nullable 字段设为 null 后再次渲染;比较两次输出,消失的那段 HTML 就是由该字段控制的条件块,直接用 <!--seam:if:...--> 包起来即可。数组也是同理。

也许是 CTR?

那肯定又有人要担心了:条件渲染的组合数量不会指数级爆炸吗?比如有 3~5 个变量控制条件渲染,每个变量都有 10 种可能,组合起来确实是个看起来很大的数字。但实际上,这个数字只是看着吓人而已。首先,许多字符串类型的值根本不需要穷举具体内容,我们只关心它有值还是没值 nullable,实际上只有两种情况;其次,真正需要参与条件判断的字段,其类型一定是可穷举的,比如说,你总不会拿一个 open string 来做 if 判断吧?再说了,即使真的要穷举所有组合,在现代 CPU 上可能也只需要几 ms 就能跑完,而且这部分成本完全发生在编译期。就像编译 Rust 一样,编译时付出较高的代价,运行时却只剩下纯粹的字符串替换,这笔买卖怎么算都划算,更何况这里没有任何特别的魔法。不过,这里也可以先埋个伏笔:后来我确实引入了一个很小的嵌入式 JavaScript 执行环境来完成一些复杂的推导,但这算是一种妥协。后面我会解释原因,以及为什么从理论上说它并不是必需的。

Cartesian product build cost calculationEven worst case 100,000 combinations,each renderToString takes ~1ms,total build time is ~100 seconds — a one-time compile cost.Worst case math5 variablesx 10 values each=10⁵=100,000x~1ms eachrenderToString()=~100s build timeRealityMost are nullable / bool2 states,not 102⁵=32 combosx ~1ms each~32ms — one-time build cost

这就是我所说的 CTR(编译时渲染)。它的限制其实与 SSR 完全相同:例如,<!--seam:path--> 插入文本时会自动进行 HTML 转义(包括 &<> 等字符);如果需要插入原始 HTML,就必须显式使用 <!--seam:path:html-->。数据路径缺失时,文本 slot 会被替换为空字符串,属性 slot 则不会注入;如果 each 块收到的不是数组,也会直接跳过。这些行为与 SSR 框架中的边界情况本质上没有区别,我只是把 SSR 的渲染步骤提前到编译期执行,仅此而已。CTR 会在构建期对所有条件变量的类型取值进行笛卡尔积遍历,既可以自动生成任意符合类型的模拟值,也允许用户提供特殊的模拟值来覆盖默认值。一般无须手动编写生成各种 HTML 变体所需的模拟值,系统会自动传入符合相应类型的值,但你也可以自行指定。随后,每一种组合都会渲染一次,系统通过比较差异确定所有条件块与循环块的边界,最终得到一个完全展开的 HTML 骨架。从数学上讲,只要运行时传入的数据符合这些类型定义,就一定能够正确注入这个骨架,因为所有可能的分支路径都已经在编译期穷举完毕。

如何保证一致性?

那么,如何保证一致性呢?答案其实就是以 JTD 作为契约:前后端虽然彼此分离,但只要双方都遵循同一份 JTD 模式,数据类型就能保持一致,不会出现不匹配的问题。

但与其他框架相比,我还面临一个额外的挑战:既然后端摆脱了 JavaScript 运行时的限制,它就不必再与前端捆绑在一起,完全可以用 RustGo 编写。在 TypeScript 全栈框架中,前后端的一致性可以直接由类型系统保证;换成其他语言又该怎么办呢?我的做法其实是使用 代码生成:无论后端采用什么语言,都以后端为基准,将前端可用的变量和类型直接生成为 TypeScript 代码,供前端导入。这样一来,前后端虽然边界分明,实际上却仍能像 TypeScript 全栈框架那样共存在同一个文件夹中,符合 单体仓库 的使用习惯;两端可以直接相互调用,无须手写 API,也不需要 gen OpenAPI 之类的工具。具体来说,我为框架设置了一条私有路径 /_seam/ 作为默认端点(和 Nuxt 一样可以配置),这样运行 JTD Typed-RPC 后,数据传输和跨源资源共享的问题就都解决了。

CTR 与 SSR

接下来是 raw HTML slot。这其实又回到了那 5%边缘情况:比如 MarkdownRich Text 之类的内容,编译期根本不可能知道它们的值,而且用类型约束它们的代价非常高。当然,你可以扩展协议,枚举 Markdown 的全部语法,再在运行时解析,但这份工作差不多就等于重写一个 Markdown 渲染器了。相比之下,我们只约束最少的几种类型,工作量与重写整个渲染引擎不可同日而语。这也回到了我一开始所说的理念:以更低的成本、更优雅的方式解决问题

CTR and SSR coexistence on a single pageA page where 95% of UI is handled by CTR with near-zero cost,and only the Markdown article area uses SSR via raw HTML slot.Nav — CTR slot injectionSidebarCTRMarkdownraw HTML slotSSRFooter — CTR slot injectionUser info — CTR slot injectionCTR (~0.1ms)SSR (raw HTML slot)Raw HTML slot backendTypeScript — your existing SSRorRust — pulldown-cmark / comrakorGo — goldmark→ HTML string → insert slot

所以好消息是,raw HTML slot 其实能让 CTRSSR 共存。这意味着,一个页面的大部分用户界面都可以用 CTR 实现,几乎没有运行时成本;最核心的那篇 Markdown 文章则用 SSR 渲染。后端不再受限之后,你既可以用 TypeScript 导入原来那套 SSR 渲染方法,也可以换个思路:既然后端用 Rust,当然也能直接用 Rust 的 Markdown 编译器渲染。反正最后只要生成一个 HTML 字符串,插入 raw slot 就能显示。使用 CTR 并不意味着不能使用 SSR,两者完全可以共存。

PPR 的落差

由此看来,CTR 本质上就是 **PPR(部分预渲染)**最理想的形态:所有可缓存的内容都在编译期完成渲染,实现零运行时开销,只有真正发生变化的那一点数据会产生成本。那么,它与 React 19.2 的 PPR 有什么不同呢?答案是,我做得更彻底,也更激进。PPR 即使在最理想的情况下,所有静态部分都已缓存,只有一小段动态内容需要更新,但哪怕变化的只是一条简单的字符串,React 仍然必须重新执行一遍 renderToReadableStream(),而这个成本并不低。

PPR vs CTR — how they handle dynamic contentPPR runs renderToReadableStream for any change. CTR splits into two parallel paths:string replacement for simple types,render for complex types.PPRStatic partsAny dynamic changerenderToReadableStream()Cached ✓Even a simple stringStill heavyCTRStatic partsstring / number / boolString replace (~0.1ms)Markdown / Rich TextRender (any language)Build-time ✓95% of cases5% edge cases95% string replacement — 5% actual render,in any language

CTR 把这条界限划分得很清楚:字符串、数字和布尔值等简单类型会直接通过字符串替换处理;只有 Markdown 这类枚举成本极高的复杂类型,才会进入真正的渲染流程。而且这里的渲染已不再是传统意义上的 SSR,它可以采用任何编程语言的渲染方式

还有 RSC

再来说说 RSC(React 服务端组件)。之前提到过,RSC 可能是一个错误,因为它模糊了太多前后端边界带来了不少安全隐患还引发了众多 CVE。但这些问题其实更多要归咎于 Next.js 和其他服务端渲染流程:用户根本没有不用它的余地,仿佛只要需要动态内容,就必须采用 SSR。但实际上,像我们这样替换字符串也完全行得通。另外不得不承认,RSC 赋予了服务端一项非常重要的能力:渲染任意 React 组件,也就是执行任意代码。

不过,既然都要执行任意代码了,基本不可能靠类型把所有情况穷举出来;即使能做到,工作量恐怕也不比编写一个 React 编译器小。因此,RSC 的这种能力确实有其存在的理由。CTRRSC 能否共存?答案是可以,而且两者完全不冲突。不过,这是框架层面需要解决的问题,不属于协议本身的范围。我暂时还没有实现,但似乎可以抄一下 TanStack Start:只要多发送一个 html 包和一个 js 包,理论上实现起来还是很容易的,至少所需的工作量清晰可见。

Raw HTML slot — dead HTML inside a React component boundaryShows how raw HTML slot injects static HTML via inner-HTML setter,surrounded by interactive React components for styling.React componentBorder,style,layout — interactive ✓dangerouslySetInnerHTMLMarkdown / Rich Text — dead HTMLNo interaction after hydrationWhen you need SSR60% — raw HTML slot~zero cost,static inject40% — RSCServer execution,TODO

再说回 raw HTML slot,它主要解决的是 MarkdownRich Text 这类场景:渲染出来的 HTML 通过 dangerouslySetInnerHTML 注入,水合之后这块区域不参与交互,是 "死的"。这部分内容应该尽量少,你想给文章加边框、加样式,那些应该写成 React 组件,而不是混在这段 HTML 里。这种做法的缺点是水合之后它不能再改变,但它确实把这个前提下的 "SSR" 成本压到了非常低,接近于零成本。在真正需要 SSR 的场景里,大概 60% 都属于这种静态 HTML 注入;剩下 40% 才需要 RSC 那种在服务端执行任意组件的能力。

UI 无关性

最后是颇具吸引力的协议无关性。本质上,我们只需要抓住 renderToString 这一个关键点,前端采用什么用户界面框架其实与协议无关。那么,这和 Astro 有什么区别呢?不过请放心,我肯定不会再造一个 Astro。表面上看,我的设计和 Astro 的岛屿概念有些相似,但实际上二者区别很大。

Astro vs Seam — key architectural differencesAstro hydrates multiple runtimes and stays MPA. Seam picks one runtime per page,hydrates from MPA to SPA.AstroSingle pageReactruntimeVueruntimeSvelteruntimeMultiple runtimes hydratedMPA — always MPANo client routingSeamSingle page,one runtimeReactVueSvelteSolidPick one per pageMPA beforeSPA afterClient routing + transitions ✓

我并没有在同一个页面中水合多个运行时。嗯,其实我觉得真正需要这么做的场景非常有限:不同技术栈的组件之间进行状态通信,成本会非常高。它更像是一种过渡方案——从一个技术栈迁移到另一个技术栈时,无法一次性完成替换,所以先用它来过渡。其次,Astro 本质上是 MPA,而我们可以让应用在水合前是 MPA,水合后则变成 SPA;它可以像 Next.js 一样使用客户端路由,实现跨页面动画,而这正是 Astro 求之不得的。

Astro 与静态站点生成

此外,Astro 的设计只有在跨多个技术栈的场景中才能真正发挥作用。如果你只是为了追求速度而使用它,却只采用其中一种框架,比如只引入 React 而不用 Vue,那么它宣称的 “速度快” 在我看来就是一个伪命题。首屏加载出来的确实都是 HTML,但只要需要任何交互,就必须进行水合,而水合的代价就是下载完整的 React 运行时,这与我们的水合并没有任何本质区别。当然,后续我们也可以采用岛屿架构,加入一个外壳路由器来实现跨 UI 框架的 SPA 导航,但那属于之后的路线图,至少我目前并不着急。

SSG vs CTR at request timeSSG serves the same static HTML every time. CTR serves the same skeleton but injects fresh data per request.SSGRequestStatic HTMLBuilt at deploy timeSame page every timeHello,Alice — always AliceFast,but frozen — no per-request dataCTRRequestHTML skeletonBuilt once,reused+Fresh dataPer requestDynamic pageHello,Bob — ~0.1msSame speed as SSG + different data every request

最后再和传统的 SSG 对比一下:我实现了与 SSG 相同的效果,本质上都是把编译时能够确定的内容全部预先渲染出来;但我们的方案更加动态,因为那些简单类型的 slot 值完全可以在运行时替换。你可以把它理解为:我们将 SSG 渲染结果作为 MPA 的入口;水合前它是 MPA,水合后则变成 SPA,同时仍然具备“动态”能力。

水合错误?

最后要说的是我很讨厌的 水合不匹配,相信你也不喜欢。归根结底,它其实就是 DOM 状态不一致。传统框架(如 Next.jsApp Router)试图用 React 包裹整个应用,一旦用户的浏览器注入了某些标记,就可能发生水合错误。我们面临的限制其实与传统服务端渲染相同,不过当你使用 TypeScript 时,我还会加上一层名为 __root 的水合 div,避免水合区域覆盖 元数据 区域,从而让水合更加稳健。此外,React 19 已内置对 <title><meta><link> 等文档元数据标签的支持。即使你只水合页面中的某个 <div>,而不是整个 <html>,只要在组件中直接渲染 <title>My Page</title>,React 也会自动将其提升<head> 中。

Hydration mismatch — traditional SSR vs CTR equivalence checkTraditional SSR discovers mismatch at runtime. CTR checks DOM equivalence at build time using mock data.Traditional SSRServer HTMLRuntime generatedvsClient DOMReact hydrationMismatch discoveredAt runtime — too lateCTR — build-time equivalence checkCTR skeleton+ mock data filledvsrenderToReadableStream()Same mock dataDOM-tree diffAt build time — before deployMatch ✓ safeMismatch → CLI warnsEscape hatch:any — like TypeScript,you accept the risk

回到水合不匹配这个问题:既然我们在编译期已经知道每个 slot 的类型,就可以再加一道 CTR 等价性检查——根据类型定义推导出模拟数据,将其填入完全展开的 HTML,再调用传统的 renderToReadableStream() 运行一遍,随后比较两者的 DOM 树在语义上是否等价,而不计较格式化差异。它们的格式可能略有不同,但只要 DOM 结构严格且完全等价,其实就可以告别水合不匹配。为什么传统 SSR 做不到?因为它们只能在运行时执行这一步,而 CTR 的结构决定了这些约束必须在编译期全部落实。当然,我们也提供了 any 这个逃生入口;但就像 TypeScript 一样,一旦使用 any,你就必须自行承担后果:CLI 编译时会警告你,any 这种开放字符串逃生机制可能导致不匹配。

无服务器

当然也不能忽略无服务器架构的可能性。近几年,无服务器架构的使用体验已经非常好了,我的评价是“除了费钱,没什么缺点”。而 CTR 天生就很适合这个场景:运行时执行的任务很轻量,无服务器服务启动得非常快,响应时间和资源开销都会显著降低。剩下难以优化的,也就是渲染 Markdown 之类的场景,但这完全应该从业务逻辑层面着手,例如预先渲染 Markdown 并保存结果,避免每次请求都重新渲染,就像我现在这个网站所做的一样。这是业务层面的问题,不是框架能够解决的;不过,框架可以帮助你把除此之外的简单逻辑开销降到几乎为零。与传统的服务端渲染相比,两者的开销已经不在同一个数量级了。究竟能小到什么程度?大约只需几百微秒到 1 毫秒,这是传统服务端渲染想都不敢想的。

Traditional SSR pipelineEvery request rebuilds the component tree and runs renderToString,costing 100-300ms per request.Traditional SSREvery requestRequestBuild treeComponent treerenderToString()~100-300msHTML responsePer-request cost
CTR pipelineRendering happens once at build time producing an HTML skeleton with typed slots. At request time only slot injection runs,costing 0.1-1ms.CTR (Compile-Time Rendering)Build time (once)renderToString()Sentinel dataDiff & detectif / each / matchHTML skeletonTyped slot markersOne-time build costRequest timeRequestSlot injection~0.1-1msHTML responseClient hydrationReact takes overPer-request cost

那后端使用其他语言时怎么办呢?以 Cloudflare Workers 为例,其实很多无服务器平台都支持 WASM;把其他语言编译成 WASM 二进制文件后,同样可以用作后端。本质上,我只是把前端编译成纯静态资源,类似于客户端渲染(CSR);但这批纯静态资源只要配合一个私有桥接层 /_seam/,就能获得与真正的全栈框架相同的动态能力,自然也能完全兼容无服务器平台。

Seam 与 SeamJS

所以你是不是已经被 SeamSeamJS 的关系绕晕了?其实这两者很简单。Seam 是协议,它定义的是:怎么用 Sentinel 标记动态位置、怎么转换成 slot 标记、条件块和循环块怎么做 diff 检测,以及运行时怎么基于 AST 做数据注入。协议本身与语言无关,任何能解析 HTML 注释、能做字符串替换的后端都能实现它。SeamJS 是框架,是基于这套协议做出来的一个具体实现:它把 ViteTanStack RouterTanStack Query 这些现成的轮子在一起,再补上它们覆盖不到的地方,比如我这套 skeleton 提取、注入引擎、CLI 之类的东西。

Seam (protocol) vs SeamJS (framework)Seam is the language-agnostic protocol defining sentinel,slot,diff and injection. SeamJS is a concrete implementation stitching existing tools with custom pipelines.Seam — protocolLanguage agnosticSentinelSlot markersDiff detectionAST injectionAny backend:Rust,Go,TypeScript,...SeamJS — frameworkConcrete implementationViteTanStack RouterTanStack QuerySkeletonCLIExisting tools stitchedCustom built

但说实话,SeamJS 目前还很基础。运行起来没什么问题,但真要用它开发项目,还需要很长时间打磨。我也在进一步思考架构层面的调整,比如抽象数据传输通道,不过我还不确定后续版本是否会继续坚持这个方向。框架方面,我肯定会做一个 TypeScript 全栈框架,以及一个以 Rust 为后端的旗舰框架;至于 Go 相关的实现,我打算在后续版本中移除,因为实在没有足够的精力维护。

Seam canmi21/seam

Rendering is a protocol, not a render-time computation.

TypeScript 39 stars 1 forks MIT 1 open issues Jul 3, 2026

不止是 Web 框架

Seam protocol — swap transport,same resultSame Seam protocol works on Web via HTTP and Desktop via IPC.Seam protocolHTTPBrowserServerless,CDNFull SSR-like pageData ready on arrivalSeam protocolIPCElectronTauriNo loading screenInstant renderSame protocol,swap transport

那么,做出这个东西有什么用呢?其实它并不局限于网页端。例如,把 Transport 通道也抽象出来后,后续就可以移植到 ElectronTauri 等桌面环境中:只要将 HTTP 传输管线替换为 IPC 通信,运行的依然是同一套 Seam 协议。这样一来,那些 Electron 应用启动时就再也不必显示加载画面了,许多内容都能像服务端渲染一样直接在本地渲染出来——这就是 CTR 的魔法!

不使用 JS 运行时的代价

那么,SeamJS 最后为什么还是加入了 JS 运行时呢?因为在落实这套方案时,我遇到了一个问题:CTR 对首屏数据的理想约束非常严格,数据必须是一个能够完全推导出来的确定结构体,甚至不能包含任何计算逻辑,只能包含条件判断。这样的要求就过于苛刻了。可以想见,从框架的开发体验来看,习惯使用 React 的人肯定希望能在组件里做一些计算,拿到几个值后直接使用;如果要严格遵守 CTR 的约束,达到 ready-to-display 的状态,过程就会非常繁琐,甚至可能需要把同一个组件写两次。

Why SeamJS needs a JS Runtime — the derive stepRaw data from backend goes through a small JS derive step to become ready-to-display,then CTR injects it.Strict mode — no JS RuntimeRaw dataFrom DB / APIMust be ready-to-displayNo compute,only conditionsComponent written 2xTedious DXWith JS Runtime — derive modeRaw dataFrom DB / APIJS derive()Compute logicready-to-displayTyped structCTR injectWrite once ✓

但其实有办法解决:受 Web 平台限制,前端只能运行 JavaScript,因此组件自然也只能以 JavaScript 运行;要解决这个问题,后端就必须具备执行 JavaScript 的能力。TypeScript 全栈项目可以直接利用自身的运行时;Rust 项目则只需嵌入 QuickJS 这样极其轻量的 JavaScript 运行时。需要注意的是,这里的 JavaScript 运行时只支持标准 JavaScript 的一个子集,完全不同于 Bun 或 Node 那种提供完整操作系统 API 的运行时,确实只用于执行推导逻辑、生成派生数据。

JS Runtime size comparison — QuickJS vs Node vs BunQuickJS is 200-300KB,compared to Node at ~100MB and Bun at ~50MB.JS Runtime size comparisonQuickJS~200-300KBJS subset,derive onlyBun~50MBFull runtime + OS APIsNode.js~100MBFull runtime + OS APIs + npm

这样一来,组件里仍然可以编写一些计算逻辑。后端拿到数据后,会运行一小段 JavaScript,将数据推导成 ready-to-display 状态,再发回前端完成首屏水合。于是 CTR 得到了结构严格的派生数据,开发者也获得了良好的开发体验,而且组件只需编写一次。当然,如果愿意严格遵守纯类型约束,后端确实可以做到 无需 JavaScript 运行时所以这个承诺其实依然成立。不过无论如何,新增的 JavaScript 运行时非常轻量,其性能开销、内存占用和体积都远小于 Node 等运行时,基本只有 200–300 KB,却能带来极大的灵活性。

未来展望

说了这么多,到底什么时候能 呢?大概还要很久很久。真不是我想 ,而是这东西在现阶段绑架了我太多太多。如果我只能拿它来做开发,那基本上就写不了应用了,写什么都先被框架绊一脚,更何况我最近主要都在写这个网站之类的东西。所以我想通了:不如先把网站做出一定规模,这样才知道我的需求到底是什么;等以后再回来写 SeamJS,手里就有一份 TODO List 了,一个一个把功能实现好,它就能用了。后续还能再水一篇迁移和 Benchmark?好了,,理念就摆在这儿。先别管今天能不能用,至少思路上原型已经跑通了。晚安 💤