Rendering as a Protocol
这篇文章可能不适合所有人阅读,要理解它在说什么你至少得是一个准前端开发者或全栈开发者,对 SSR、SSG、ISR 这些概念有基本的认知且至少用过 Next.js
前言
可能是我的天生就对底层原理有很强的好奇心,再加上有时候一直执着于问清楚为什么不能这么做;我猜可能是这样子的,所以有时候我才能想出一些独特的想法:这个就是其中之一,早在 25 年 11 月的时候它就冒出来了,当时跟身边很多朋友聊了聊,但因为手头项目的时间冲突,一直拖到了 2026 年新年前后才真正动手做了一版出来。之后又搁置了——因为我跑去建站了。博客虽然也没完全搭好,但总算有了一个还算能记录东西的地方,所以我想把这件事拎出来重新说一说。
首先先声明一点:我不是在说 SSR 不好。我只是觉得,从我看到的趋势来说,太多人在滥用它,大方向在随波逐流的逃避一个问题。而我也跟不少人讨论过这个,得到的回应大多不太确定;我也说过我的构想,但对方总是担心这里面会存在各种问题、各种边界,而且说实话我也不太想每次都把整套思路从头到尾完整讲一遍。所以今天索性把它详细写下来,试着把一些疑问和技术原理讲清楚吧。
被滥用的 SSR
早在我刚入门 Web 前端开发的时候,第一个接触到的全栈框架应该就是 Next.js。 我承认这是一个非常优秀的框架,尽管它有一些性能问题,但这些性能问题是实打实的 DX 体验换来的,这一点无可厚非。但随着我慢慢深入理解前端的一些概念和知识,我发现它可能越来越不适合我了——因为我很喜欢写 Rust,也很喜欢研究底层,玩过嵌入式,所以对性能有一种很特别的执着;执着也不是什么都要极限,而是,这里本来就有性能明显更好,但是努力也许很小的办法可以实现,为什么大家都不这样做?所以每当用起 Next 的时候我就会想:为什么哪怕一个很简单的页面,大多数东西都是固定的,只有一个地方需要变,这整个页面就要重新渲染一遍?为什么?在我的理解中,哪怕后端不是用 Rust 写的,哪怕不追求极限,就算跑在 JavaScript 解释器上,真正改变的那一点数据也应该是一个相对来说很小的开销,用一种很低成本的方式实现“渲染”,而不是重新渲染整个页面。
以至于后面,它已经成为了我心里的坎,变成了我不得不思考的问题,但是讽刺的是当我看完前端这近 10 年的发展,Vue、Svelte、Solid 和他们的框架 Next.js, TanStack Start, Remix, Nuxt, Sveltekit 和 Soild Start 后我觉得似乎大家都跑偏了,尤其是 React 的 RSC 这是一个错误,但是它的存在其实也是有理由的,这点后面有机会也许再拿出来详细说说...
在我看来,RSC 和一众 SSR 框架其实都在滥用 render 这件事。SSG 倒是做到了我想要的风格——页面在构建时就生成好了,请求时不需要重新渲染——但它没有真正的动态能力(x)。而 ISR 呢,看起来是在 SSG 和 SSR 之间找了个平衡,做到了一定的灵活性,但它本质上并没有达到我的期望;我觉得 ISR 的出现其实就是为了弥补 SSR 性能差的问题,包括后续的各种 CDN 缓存策略之类的操作,它们其实都算是一种 workaround,而不是真正优雅的解决方案。
当然 SSR 本身不是一个错误,它是一个很好的东西,解决了很多真实的场景。但问题在于,这些框架把 SSR 推到了一个极度默认选项的位置上,导致开发者真正用到 SSR 的时候,可能 95% 的场景其实根本不需要它。页面上大部分内容都是固定的,真正动态的部分少之又少,但每次请求还是要把整棵组件树跑一遍。
不过呢近些年 React 倒是有所改观,React 19.2 有一个 PPR 但是这个东西其实运行时还有昂贵的 renderToString() 也算是一个 workaround 不是真正的解。所以我就想,既然大部分东西在构建时就已经确定了,那为什么不把渲染这件事直接放到编译期去做呢?
编译期渲染?
把渲染放到编译期听起来是一件很荒谬的事情,因为 SSR 存在的核心理由就是有些值、有些条件,不到运行时那个点上你根本解不出来,你不知道它是什么,做不了判断。所以我想大多数人第一时间否决我的想法也不是没有道理,可以理解。
但其实我想到了一个算得上精巧的 Pipeline 来实现这件事,这也是为什么我需要把它叫做一种 Protocol 另外就是我一直觉得全栈框架混淆前后端边界是一件很蠢的事情(虽然不得不承认近几年 Next.js 在这方面 DX 做的很好,这让很多初学者觉得写一个全栈的应用是一个很简单的事情,但是实际上很多安全隐患也是由此而来的,扯远了)
所以在组件里写数据获取也是一样,我更倾向于一种明确界限的方式——组件就是纯组件,数据获取应该被彻底抽出来。而一旦你接受了这个前提,纯组件和数据分离之后,事情就变得有意思了:你会发现数据本身其实是可以被归类的。
这里也要感谢 TypeScript 的给我的灵感,其实所谓的 “值” 本身在编译期是什么并不重要,重要的是它的类型;一个 slot 里要填什么内容,只要它不是那种 Open String (有无限种可能的字符串),它就可以被包裹为有限可能性的类型。比如你在写一个 dashboard,需要条件渲染某块内容,无非就是 User 或 Admin 等等但是总归是可以定义完的类型;真实世界中几乎所有需要条件渲染、需要做逻辑判断的地方,都可以被归纳为几种确定的可能性。
那正好这些可能性可以用一个很好的办法来描述,那就是 JTD!
JTD 规范
JTD (JSON Type Definition) RFC 8927 定义的中它有八种 Schema Form: Empty、Ref、Type( boolean、string、timestamp 以及各种精度的数字类型)、Enum、Elements、Properties、Values、Discriminator,再加上任意 schema 都可以标记为 nullable。JTD 的好处在于它是跨语言的,JavaScript、Rust、Go 几乎所有语言都能找到对应的类型映射,这就让它天然地成为了前后端之间的桥梁,并且载体是 JSON,本来就是前后端最小公约。真实世界中那些网站 95% 的应用,无非就是显示字符串、数字字段、或者用布尔值做判断,它们全都落在这个范围内。明确了这一点后,实际上我们可以操作的空间就很大了,当然 JTD 也不是完美的,其实也有意外,比如 Markdown 这类东西,但这个我们后面再详细说,这里其实才是我真正认可 SSR 有意义的地方。
Sentinel
先说好办的,既然我们已经知道了每一个动态值都有类型,那我们就可以在编译期做一件事把 React 组件用 renderToString() 跑一遍,但不是用真实数据,而 Mock 出来用 Sentinel 那么什么意思呢?
{ user: { name: "Alice", age: 30 } }
假设你的数据长这样,那么我们可以把它替换成
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }
这些 %%SEAM:...%% 就是 Sentinel,它们占住了每一个动态值的位置。React 拿着这些 Sentinel 数据跑完 renderToString() 之后,输出的 HTML 里这些位置就被标记出来了。接下来构建管线会把这些哨兵转换成 HTML 注释形式的 slot 标记
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>
<!-- Slot -->
<span><!--seam:user.name--></span>
到这一步你可能已经看出来了这些 slot 就是 "有类型标记的插槽"。这个时候服务端运行时要做的事情变得极其简单,那就拿到真实数据,往这些孔里填值,纯粹的字符串替换。完全不需要 renderToString(),不需要 JavaScript 运行时,不需要 vDOM。任何能解析 HTML 注释、能做字符串替换的语言都能当后端,Rust、Go、TypeScript 都行,这就是为什么它是一个协议而不是一个框架。
这个时候你可能会想到条件渲染怎么办?比如某个字段为 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 字符,仅此而已。那这些条件块和循环块是怎么在 build-time 确定并生成的呢?其实也是一个算得上很巧的办法,我们虽然没有 vDOM 但是可以两次渲染 HTML做 Diff;拿条件渲染来说,第一次用完整的数据 Sentinel 渲染,第二次把某个 nullable 字段设为 null 再渲染一遍,两次输出一比对,消失的那块 HTML 就是这个字段控制的条件块,直接用 <!--seam:if:...--> 包起来就好了。数组也是同理。
也许 CTR?
那肯定又有人要担心了:条件渲染的组合不会指数爆炸吗?比如 3~5 个变量控制条件渲染,每个变量有 10 种可能,乘起来确实是一个看起来很大的数字。但实际上这个数字只是看起来大唬人而已嘛。首先,很多字符串类型的值我们并不需要真的穷举内容,我们只关心它有值还是没值 nullable 这里实际上就是 2 种;其次,真正需要做条件判断的字段,它的类型一定是可穷举的,就比如(你不会拿一个 open string 来做 if 判断吧?);再说了,就算真的要穷举所有组合,在现代 CPU 上跑完可能也就几 ms,而且这完全是编译期的重,就像 Rust 编译一样,编译期付出比较重的代价,但运行时就变成了纯粹的字符串替换,这笔生意怎么算都是划算的吧,更何况这里没有任何特别的魔法。但是这里也可以埋一个小点:后面我其实引入了一个很小的嵌入式 JavaScript 执行环境来做一些复杂的推导,但这算是一种妥协,后面会解释原因,以及理论上是可以不要的。
这就是我说的 CTR (Compile-Time Rendering) 至于限制,其实和 SSR 的限制是一模一样的,比如 <!--seam:path--> 做文本插入时会自动做 HTML 转义(&、<、> 等),如果你需要插入原始 HTML 就得显式用 <!--seam:path:html-->;缺失的数据路径在文本 slot 中会变成空字符串,在属性 slot 中会跳过注入;each 块如果拿到的不是数组就直接跳过。这些行为和你在 SSR 框架中遇到的边界情况本质上没有区别,我只是把 SSR 中的渲染这一步拎到了编译期去执行,仅此而已。而 CTR 在 build-time 对所有条件变量的类型值做笛卡尔积遍历,使用 Mock 出来的任意符合类型的值一种 or 用户 Override 特殊 Mock 值(一般是不需要手动写生成 HTML 遍体所需要的 Mock 值的,但是可以手动给,不如就是自动给一个符合你这个类型的值进来),这样每种组合渲染一次,diff 出所有条件块和循环块的边界,最终得到一个完整展开的 HTML 骨架。从数学上说,只要 runtime 传入的数据符合这些类型定义,它就一定能被正确注入到这个骨架里,所有可能的分支路径在编译期已经被穷举过了。
一致性?
那一致性该如何保证呢,其实答案就是 JTD 这个 Contract, 前后端虽然分离了但只要都遵守同一份 JTD schema,数据的类型就是对齐的,不存在不一致的问题。
但我比其他框架多了一个挑战,既然后端解放了 JavaScript 运行时,它就不必和前端是一体的了,你完全可以用 Rust 或者 Go 来写后端。那在 TypeScript 的全栈框架里,前后端一致性可以直接靠类型来保证;其他语言呢?实际上这里我用了 codegen 无论后端用什么语言写,都以后端作为基准,把前端可以用到的变量和类型直接 codegen 成 TypeScript 给前端导入。这样虽然前后端的界线被划清楚了,但它们实际上可以像 TypeScript 全栈框架一样活在一个文件夹里,符合 Monorepo 习惯,可以直接互相调用,不用手写 API 或者 gen OpenAPI 之类的东西,实际上这里我用了一个私有路径 /_seam/ 作为框架默认端点(和 Nuxt 一样可以配置的),这样跑了 JTD Typed-RPC 就解决了数据传输和 CORS 的问题。
CTR x SSR
然后就是 raw HTML slot ,这个其实就是回到了那 5% 的 Edge Case,比方说 Markdown、Rich Text 这类东西,编译期根本不可能知道它的值,而且要用类型去约束它的代价非常大。当然你可以说,把协议拓展一下,把 Markdown 的所有语法都列举出来,运行时再做解析,但那个工作量基本等于你重写了一个 Markdown 渲染器?而我们只约束最小的那几种类型,和重写整个渲染引擎的工作量完全不是一回事,这也回到了我最开始说的哲学,用更小的代价、更优雅的方式来解决问题。
所以好消息就是 raw HTML slot 其实可以让 CTR 和 SSR 可以共存,它意味着你一个页面大部分 UI 都用 CTR 来做,几乎没有运行时成本;然后最核心的那一篇 Markdown 文章,你用 SSR 来渲染。由于后端已经解放了,你可以选择用 TypeScript 导入你原来那套 SSR 的渲染方法,也可以思路打开!后端用 Rust 那当然是用 Rust 的 Markdown 编译器来渲染,反正最后只要给出一个 HTML 字符串,插入这个 raw slot,它就能显示。用了 CTR 不意味着不能用 SSR,两者完全可以共存。
PPR 的理想和现实
这样理解下来,CTR 本质上就是 PPR(Partial Prerendering) 最理想的情况,所有能缓存的东西全部在编译期渲染完了,零运行时开销,只有真正变化的那一点数据才有成本。那和 React 19.2 的 PPR 不同的是什么呢?答案是我更彻底,更激进 PPR 就算在最理想的情况下,所有静态部分都缓存好了,只有一小段动态内容需要更新,但哪怕这个动态内容只是一个简单的字符串变了,React 还是得重新跑一遍 renderToReadableStream(),这个代价是不小的。
而 CTR 把这个界限划的很清楚:如果是简单类型比如 字符串、数字 和 布尔值,那都是直接做字符串替换;只有 Markdown 这种穷举代价极大的复杂类型,才会走真正的 render 流程。而且这里的 render 已经不是传统意义上 SSR 的概念了,它可以是任何编程语言的渲染方式。
RSC 也不能少
那再说说 RSC(React Server Component) 之前说过 RSC 可能是一个错误,那是因为它模糊了太多前后端的边界,带来了不少安全隐患 和 众多 CVE 但其实这更多的错事因为 Next.js 和其他的 SSR 流,用户根本没有机会不用,变成了你要动态就要 SSR,但是实际上我们这样子的字符串替换也是完全走得通的。另外不得不承认的就是 RSC 赋予了服务端一个很重要的能力,那就是渲染任意 React 组件,也就是执行任意代码。
不过你都要执行任意代码了,想用类型把它穷举出来基本不可能,就算可能那工作量也不见得比写一个 React 编译器小。所以 RSC 这种能力是有它存在的理由的。CTR 和 RSC 能不能共存?答案是可以的,并且完全不冲突,但这属于框架层面要解决的事情,不是协议本身的范围,但是我暂时还没实现,不过这个似乎可以去抄 TanStack Start,只要多发一个 html 一个 js 包,理论实现还是很容易的,至少都是清晰可见的工作量。
再说回 raw HTML slot 这个解决主要还是 Markdown、Rich Text 这类场景,渲染出来的 HTML 通过 dangerouslySetInnerHTML 注入,水合后这块区域不参与交互,是 "死的";这部分内容应该被最小化,你想给文章加边框、加样式,那些应该用 React 组件来写,而不是混在这段 HTML 里。这种做法的缺点是它不能在水合之后发生改变,但它确实把这种前提下的 "SSR" 成本压到了非常低,接近于 Zero Cost 了。在真正需要 SSR 的场景里,大概 60% 都是这种静态 HTML 注入的情况;剩下 40% 才需要 RSC 那种服务端执行任意组件的能力。
前端 UI 无关性
最后就是很诱人的协议无关性,我们本质上只需要抓住 renderToString 这一个关键点,前面用什么 UI 框架其实和协议没有关系。那这样的话,和 Astro 有什么区别呢?不过放心,我肯定造的不是另外一个 Astro 表面上看我和 Astro 的岛屿概念有一点像,但实际上区别很大。
我并没有在单个页面里水合多个运行时,Humm,其实我觉得你真正需要这个的场景非常有限,不同技术栈之间的组件状态通信会变得非常昂贵,它更像是一种过渡方案,从一个技术栈迁移到另一个,一次性换不完所以先过渡。其次,Astro 本质上是 MPA,而我们可以做到水合前是 MPA、水合后变成 SPA,就像 Next.js 一样有客户端路由,可以做跨页面动画,这是 Astro 求之不得的。
Astro 与 SSG
另外就是 Astro 的设计本来就是在跨多个技术栈的场景才真正有用,如果你用它是为了速度快的话,只用它的一个框架,比如只引入 React 不要 Vue,那它所说的 "速度快" 我觉得是一个伪命题,首屏加载出来都是 HTML 没错,但你想要任何交互它就要水合,水合的成本就是下载整个 React Runtime,这和我们的水合没有任何本质区别。当然,后续我们也可以走通 Island 的概念,添加一个 shell router 来实现跨 UI 框架的 SPA 导航,但这是后面的 Roadmap,至少我现在不急。
最后就是传统 SSG 的对比,我做到了和 SSG 一样的事情,本质上就是把编译期能确定的全部渲染好;但我们更加动态,因为那些简单类型的 slot 值是完全可以在运行时替换的。你可以理解为,我们把 SSG 渲染成了一种 MPA 的入口,水合前是 MPA,水合后变成 SPA,同时保留了 “动态” 能力。
也许告别水合错误?
最后就是我很讨厌的 hydration mismatch,我相信你也不喜欢;但是归根结底这其实就是 DOM 状态不一致,而传统框架比如 Next.js 的 App Router 试图把整个应用都用 React 包裹起来,一旦用户侧有浏览器注入什么标记,就有概率出现水合错误。我们和传统 SSR 的限制其实是一样的,但是我在你用 TS 的时候也包裹了一个 叫做 __root 的水合 div 这样让水合区域不再覆盖 metadata 区域,换来了更加耐用的水合,另外 React 19 内置了对 <title>、<meta>、<link> 等文档元数据标签的支持。即使你水合的只是页面中的某个 <div>(而不是整个 <html>),在组件里直接渲染 <title>My Page</title>,React 也会自动把它提升 hoist 到 <head> 中去。
回到 hydration mismatch,已知我们在编译期已经预知了每个 slot 的类型,所以可以多加一道 CTR 等价性检查,那就是用基于类型定义推导出来的 mock 数据填入完全展开的 HTML,再调用传统的 renderToReadableStream() 跑一遍,然后比对两者的 DOM-Tree 语意是否等价,不关心格式 fmt 差异,它们可能有格式上的细微差距,但只要 DOM 结构上严格完全等价其实是可以做到告别 hydration mismatch 的;为什么传统 SSR 做不到?因为它们是在运行时才做这件事,而 CTR 结构决定了你在编译期就要把这些约束全部落实到位。我们当然也有 any 的逃生入口,但是至少和 TypeScript 一样,你用了 any 就要主动承担后果,CLI 编译的时候会 warn 你,any 逃生 open string 可能会导致 mismatch 的。
探索 Serverless
当然还有 Serverless 的可能性也不能少,最近几年 Serverless 的体验可以说是非常好了,我评价为“除了费钱没啥缺点了”,然而 CTR 天然非常适合这个场景,我们运行时做的事情很轻很小,Serverless 跑起来会非常快,响应时间和开销都会得到明显提升。剩下提升不了也就是渲染 Markdown 那种场景,但那完全应该在业务逻辑上做优化比方说把 Markdown 预渲染好存起来,每次请求不用重新渲染,就像我现在这个网站做的一样。这是业务层面的问题,不是框架能解决的,但框架能帮你把除此之外的那些简单逻辑的开销压到几乎为零。和传统 SSR 对比,它不再是一个量级的开销,有多小呢,大概就是 几百 us ~ 1ms 之间的事情了,这是传统 SSR 想都不敢想的。
那如果后端是其他语言怎么办,就拿 Cloudflare Workers 举例,其实很多 Serverless 都支持 WASM,其他语言编译成 WASM BIN 之后一样可以作为后端。本质上我就是把前端编译成纯静态资源,类似 CSR 一样,但这堆纯静态资源配合一个私有桥 /_seam/,就能实现和那些真正的全栈框架一样的动态能力,自然也完全兼容 Serverless 了。
SeamJS
所以你是不是已经被 Seam 和 SeamJS 的关系绕晕了,其实这两很简单,Seam 是协议,它定义的是怎么用 Sentinel 标记动态位置、怎么转换成 slot 标记、怎么做条件块和循环块的 diff 检测、以及运行时怎么基于 AST 做数据注入。协议本身是语言无关的,任何能解析 HTML 注释、能做字符串替换的后端都能实现它。SeamJS 是框架,是基于这套协议做的一个具体实现 它把 Vite、TanStack Router、TanStack Query 这些轮子缝在一起,然后补它们覆盖不到的地方,比如我这套 skeleton 提取、注入引擎、CLI 之类的。
但说实话,SeamJS 现在还是一个非常基础的样子。跑起来是没什么问题的,但真的拿它去写项目的话还需要打磨很久,我也在更多的思考一些架构上的改革,比如数据传输通道的抽象,但我并不确定在后续版本中还会不会坚持这个方向?框架层面上,我肯定会做出一个 TypeScript 的全栈框架,以及一个 Rust 作为后端的旗舰框架;至于 Go 相关的实习后续版本中移除掉,我觉得自己的精力维护不过来了。
那这个东西做出来有什么用呢?实际上它不只局限于 Web。比如说我把 Transport 通道也给抽象掉之后,是可以后续移植到类似于 Electron 或者 Tauri 之类的 Desktop 环境的只要把 HTTP 传输管线换成 IPC 通信,跑的还是一样的 Seam 协议,取而代之的就是那些 Electron 应用开屏的时候再也不用放 loading 了,很多东西能够直接像 SSR 一样本地渲染出来,这就是 CTR 的魔法!
No JS Runtime 的代价
那为什么最后 SeamJS 还是加入了 JS Runtime 呢?因为在落实这套方案的时候我遇到了一个问题,那就是 CTR 的理想对首屏数据的约束是非常严格的,数据必须是一个可以完全推导出来的确定结构体,甚至不能有任何计算逻辑,只能是条件。这就变得非常苛刻了,可想而知的就是作为框架的开发体验来说,传统写 React 的人肯定是指望在组件里做一些计算、拿到几个值就能用,如果要严格按照 CTR 的约束来达到一个 ready-to-display 的状态,过程会非常繁琐,甚至出现一个组件写 2次。
但是其实有解,因为 Web 原因,前端只能跑在 JavaScript 上,所以理所当然组件只能跑 JS,所以后端就必须要有 JS 执行能力才能解决这个问题,TypeScript 全栈项目本身的运行时就能用来解决;而 Rust 里面只需要嵌入一个 QuickJS 这样非常微小的 JS Runtime 就好了,注意这里的 JavaScript Runtime 是标准的 JS 子集,完全不是 Bun 或 Node 那种带完整操作系统 API 的运行时,真的就只是用来做推导、做 derive。
这样就可以在组件里还是写一些计算逻辑,这部分逻辑会在后端拿到数据之后跑一段小 JS,把它推导为 ready-to-display 的状态,然后再发回给前端做首屏的水合。这样 CTR 满意了,拿到了严格结构的派生数据,用户 DX 开行了,组件只写一遍。当然如果你愿意严格遵守纯类型约束的话,后端确实可以做到 No JS Runtime,这个承诺其实还是成立的。但无论怎么说,新增的这个很小的 JavaScript Runtime,它的性能开销、内存占用和体积都远远比 Node 之类的东西小得多,基本上就是 200-300KB 的事情,却带来了非常大的灵活性。
未来展望
说了那么多,什么时候能吃上呢:大概吧,还要很久很久,真的不是我想鸽子,而是其实我觉得现阶段这个玩意绑架我太多太多了。我只能拿来开发的话,基本上应用写不了,写什么都被框架先绊脚,更何况我最近主要其实都在写这个网站什么的了。所以我想通了,不如网站先写上一个规模,这样就知道我的需求到底是什么;后面也许再来写 SeamJS 就有一个 TODO List 了,一个一个实现好这个功能就能用了,后续还能再水一个 迁移 和 Benchmark ?好了,大饼画完了,理念就在这,先别管今天能不能用,至少思路上原型已经跑通了,晚安 💤