# Rendering as a Protocol

> This is the agent view of https://canmi.net/architecture/compile-time-rendering, generated 2026-10-11T18:59:35Z. The same page for humans is the address without `.md`.
> Every page on this site has its agent view at its own address with `.md` appended, so ask for that address directly.

> The text below is in its original language, Chinese (zh), as written.

You are here: [サボり記](https://canmi.net/homepage.md) › architecture › Rendering as a Protocol

## About this article

> From the published corpus, as of 2026-10-01T14:46:52Z.

| Field | Value |
| --- | --- |
| Title | Rendering as a Protocol |
| Subtitle | UI should be described, not executed. |
| Short title | Rendering as a Protocol |
| Short subtitle | UI should be described, not executed. |
| Description | 从 SSR、SSG、ISR、RSC 的渲染谱系出发，提出 CTR (Compile-Time Rendering) 与 Seam 协议——把组件结构和数据视为两个独立发布物、运行时拼接。围绕 JTD、Sentinel、SeamJS 讨论把 UI 当协议而非可执行代码的可能。 |
| Kind | TechArticle, in architecture |
| Author | Canmi <t@canmi.icu> |
| Language | Chinese (zh), the original |
| Published | 2026-04-13T19:18:28Z |
| Last changed | 2026-04-14T01:54:50Z |
| Length | 7918 words |
| Page | https://canmi.net/architecture/compile-time-rendering |
| This document | https://canmi.net/architecture/compile-time-rendering.md |

## Reads

> Live, counted 2026-10-11T18:59:35Z; held up to five minutes.

5491

## Other languages

> Each is a translation of this article, served as text/html, under the title and subtitle its page shows.

| Language | Title | Subtitle | Page |
| --- | --- | --- | --- |
| German (de-DE) | Rendering als Protokoll | Oberflächen beschreibt man, führt sie nicht aus. | https://canmi.net/architecture/compile-time-rendering?lang=de |
| English (en-US) | Rendering as a Protocol | UI should be described, not executed. | https://canmi.net/architecture/compile-time-rendering?lang=en |
| Spanish (es-ES) | El renderizado como protocolo | La interfaz de usuario debe describirse, no ejecutarse. | https://canmi.net/architecture/compile-time-rendering?lang=es |
| French (fr-FR) | Le rendu comme protocole | Une interface se décrit, elle ne s'exécute pas. | https://canmi.net/architecture/compile-time-rendering?lang=fr |
| Japanese (ja-JP) | プロトコルとしてのレンダリング | ユーザーインターフェースは実行ではなく記述されるべきだ。 | https://canmi.net/architecture/compile-time-rendering?lang=ja |
| Korean (ko-KR) | 프로토콜로서의 렌더링 | 사용자 인터페이스는 실행되지 않고 기술되어야 한다. | https://canmi.net/architecture/compile-time-rendering?lang=ko |
| Chinese (China) (zh-CN) | 将渲染视为一种协议 | 用户界面应通过描述来定义，而非通过执行来实现。 | https://canmi.net/architecture/compile-time-rendering?lang=zh |
| Chinese (Taiwan) (zh-TW) | 將渲染視為一種協定 | 使用者介面應被描述，而不是執行。 | https://canmi.net/architecture/compile-time-rendering?lang=tw |

## Summary

> Written by a model (openai) from the article, as the page shows it.

从 Next.js 默认 SSR、为少量动态数据整树重渲染的开销出发，追问能否保留逐请求数据、SPA 水合与跨语言后端，却把多数 React 渲染移出运行时。它权衡 SSG、ISR/CDN 缓存和 React 19.2 PPR，也审视 RSC 与 Astro；限制包括 JTD 类型契约、Markdown 等开放内容、条件组合、hydration mismatch、Serverless 成本和 DX，原型尚未成熟。

## Contents

- [前言](https://canmi.net/architecture/compile-time-rendering#preface)
- [被滥用的 SSR](https://canmi.net/architecture/compile-time-rendering#abused-ssr)
- [编译期渲染?](https://canmi.net/architecture/compile-time-rendering#compile-time-rendering)
- [PPR 的落差](https://canmi.net/architecture/compile-time-rendering#ppr-ideal-vs-reality)
- [还有 RSC](https://canmi.net/architecture/compile-time-rendering#rsc-still-needed)
- [UI 无关性](https://canmi.net/architecture/compile-time-rendering#ui-framework-agnostic)
- [Astro 与 SSG](https://canmi.net/architecture/compile-time-rendering#astro-vs-ssg)
- [水合错误?](https://canmi.net/architecture/compile-time-rendering#farewell-hydration-mismatch)
- [Serverless](https://canmi.net/architecture/compile-time-rendering#serverless)
- [Seam 与 SeamJS](https://canmi.net/architecture/compile-time-rendering#seamjs)
- [不只是 Web 框架](https://canmi.net/architecture/compile-time-rendering#beyond-web)
- [No JS Runtime 的代价](https://canmi.net/architecture/compile-time-rendering#no-js-runtime-cost)
- [未来展望](https://canmi.net/architecture/compile-time-rendering#outlook)

## Article

这篇文章可能不适合所有人阅读，要理解它在说什么你至少得是一个准前端开发者或全栈开发者，对 `SSR`、`SSG`、`ISR` 这些概念有基本的认知且至少用过 **Next.js**

### 前言

可能是我的天生就对底层原理有很强的好奇心，再加上有时候一直执着于问清楚为什么不能这么做；我猜可能是这样子的，所以有时候我才能想出一些独特的想法：这个就是其中之一，早在 25 年 11 月的时候它就冒出来了，当时跟身边很多朋友聊了聊，但因为手头项目的时间冲突，一直拖到了 **2026** 年新年前后才真正动手做了一版出来。之后又搁置了——因为我跑去建站了。博客虽然也没完全搭好，但总算有了一个还算能记录东西的地方，所以我想把这件事拎出来重新说一说。

首先先声明一点：我不是在说 `SSR` 不好。我只是觉得，从我看到的趋势来说，太多人在滥用它，大方向在随波逐流的逃避一个问题。而我也跟不少人讨论过这个，得到的回应大多不太确定；我也说过我的构想，但对方总是担心这里面会存在各种问题、各种边界，而且说实话我也不太想每次都把整套思路从头到尾完整讲一遍。所以今天索性把它详细写下来，试着把一些疑问和技术原理讲清楚吧。

### 被滥用的 SSR

早在我刚入门 **Web** 前端开发的时候，第一个接触到的全栈框架应该就是 **Next.js。** 我承认这是一个非常优秀的框架，尽管它有一些性能问题，但这些性能问题是实打实的 **DX** 体验换来的，这一点无可厚非。但随着我慢慢深入理解前端的一些概念和知识，我发现它可能越来越不适合我了——因为我很喜欢写 Rust，也很喜欢研究底层，玩过嵌入式，所以对性能有一种很特别的执着；执着也不是什么都要极限，而是，这里本来就有性能明显更好，但是努力也许很小的办法可以实现，为什么大家都不这样做？所以每当用起 Next 的时候我就会想：为什么哪怕一个很简单的页面，大多数东西都是固定的，只有一个地方需要变，这整个页面就要重新渲染一遍？为什么？在我的理解中，哪怕后端不是用 Rust 写的，哪怕不追求极限，就算跑在 JavaScript 解释器上，真正改变的那一点数据也应该是一个相对来说很小的开销，用一种很低成本的方式实现"渲染"，而不是重新渲染整个页面。

> [diagram: 页面渲染管线 将单一字段的更改与完整的传统 SSR 重建进行对比。左侧，一个页面包含 “Nav”，“Hero section”，“Username”，“Sidebar”，“Content block”，以及 “Footer”；“Username” 被标记为 “Changed”，而其他则标记为 “Static (unchanged)”，并有一条指向 “Traditional SSR” 的箭头。右侧，“renderToString()” 处理 “Entire component tree”，包括 “Nav”，“Hero”，“User”，“Side”，“Content”，和 “Footer”，归纳为 “All re-rendered (~100-300ms)”，尽管有备注 “Only 1 field changed” 和 “but the entire page was rebuilt”。 — https://canmi.net/architecture/compile-time-rendering]

以至于后面，它已经成为了我心里的坎，变成了我不得不思考的问题，但是讽刺的是当我看完前端这近 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](https://react.dev/blog/2025/10/01/react-19-2#partial-pre-rendering) 但是这个东西其实运行时还有昂贵的 `renderToString()` 也算是一个 workaround 不是真正的解。所以我就想，既然大部分东西在构建时就已经确定了，那为什么不把渲染这件事直接放到编译期去做呢？

> [diagram: 从左到右的光谱比较渲染方法从 “Static” 到 “Dynamic”： “SSG” (“No dynamic”), “ISR” (“Workaround”), “PPR” (“Workaround”), “SSR” (“~100-300ms”), 和 “RSC” (“Arbitrary”). 进度下面的右向箭头将中间框定为 “Runtime cost”，对比 SSG 的静态速度与日益动态的方法；下面， “What if?” 引向 “SSG's speed + SSR's dynamic=???” — https://canmi.net/architecture/compile-time-rendering]

### 编译期渲染?

把渲染放到编译期听起来是一件很荒谬的事情，因为 SSR 存在的核心理由就是有些值、有些条件，不到运行时那个点上你根本解不出来，你不知道它是什么，做不了判断。所以我想大多数人第一时间否决我的想法也不是没有道理，可以理解。

但其实我想到了一个算得上精巧的 `Pipeline` 来实现这件事，这也是为什么我需要把它叫做一种 `Protocol` 另外就是我一直觉得全栈框架混淆前后端边界是一件很蠢的事情（虽然不得不承认近几年 Next.js 在这方面 DX 做的很好，这让很多初学者觉得写一个全栈的应用是一个很简单的事情，但是实际上很多安全隐患也是由此而来的，扯远了）

> [diagram: 并排比较对比“Traditional”“Component + data coupled”和“Seam”“Component + data separated”。左侧，一个“Dashboard component”包含 `fetch("/api/user")` 和 `if (role==="admin") ...`，导致“Value unknown until runtime”和“Infinite possibilities?”；右侧，一个“Pure component”标注“UI only,no data fetching”与位于“JTD schema contract”下的“Data (typed)”分离，该数据划分为“string”“boolean”“enum”“int32”。这些已类型化的选项被归类为“Finite,enumerable → compile-time rendering possible”，对比有限的编译时可能性与运行时未知的值。 — https://canmi.net/architecture/compile-time-rendering]

所以在组件里写数据获取也是一样，我更倾向于一种明确界限的方式——组件就是纯组件，数据获取应该被彻底抽出来。而一旦你接受了这个前提，纯组件和数据分离之后，事情就变得有意思了：你会发现数据本身其实是可以被归类的。

这里也要感谢 **TypeScript** 的给我的灵感，其实所谓的 "值" 本身在编译期是什么并不重要，重要的是它的类型；一个 `slot` 里要填什么内容，只要它不是那种 `Open String` (有无限种可能的字符串)，它就可以被包裹为有限可能性的类型。比如你在写一个 dashboard，需要条件渲染某块内容，无非就是 User 或 Admin 等等但是总归是可以定义完的类型；真实世界中几乎所有需要条件渲染、需要做逻辑判断的地方，都可以被归纳为几种确定的可能性。

那正好这些可能性可以用一个很好的办法来描述，那就是 **JTD**！

#### JTD 规范

**JTD (JSON Type Definition)** [RFC 8927](https://www.rfc-editor.org/rfc/rfc8927.html) 定义的中它有八种 **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` 那么什么意思呢？

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

假设你的数据长这样，那么我们可以把它替换成

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

这些 `%%SEAM:...%%` 就是 **Sentinel**，它们占住了每一个动态值的位置。React 拿着这些 **Sentinel** 数据跑完 `renderToString()` 之后，输出的 **HTML** 里这些位置就被标记出来了。接下来构建管线会把这些哨兵转换成 HTML 注释形式的 slot 标记

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

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

到这一步你可能已经看出来了这些 `slot` 就是 **"有类型标记的插槽"**。这个时候服务端运行时要做的事情变得极其简单，那就拿到真实数据，往这些孔里填值，纯粹的字符串替换。完全不需要 `renderToString()`，不需要 JavaScript 运行时，不需要 `vDOM`。任何能解析 **HTML** 注释、能做**字符串替换**的语言都能当后端，Rust、Go、TypeScript 都行，这就是为什么它是一个协议而不是一个框架。

> [diagram: 水平的构建时管道从“Real data”流向“Sentinel”，使用“%%SEAM:name%%”，随后经过“renderToString()” / “React render”，最终生成包含“<!--seam:name-->”的“HTML skeleton”。在下方，前后对比将“%%SEAM:user.name%%”转换为“<!--seam:user.name-->”；结果描述为“Typed slots — any language can inject data via string replacement”。 — https://canmi.net/architecture/compile-time-rendering]

这个时候你可能会想到条件渲染怎么办？比如某个字段为 null 的时候一整块内容都不应该出现，但是其实这个也不需要运行时的 JavaScript 来判断，反知是协议可以定义这个情况

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

条件渲染如此，列表渲染也一样

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

甚至模式匹配

```html
<!--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** 执行环境来做一些复杂的推导，但这算是一种妥协，后面会解释原因，以及理论上是可以不要的。

> [diagram: 一个两行的对比将“Worst case math”与“Reality”进行比较。最坏情况那一行计算“5 variables”乘以“x 10 values each”等于“10⁵=100,000”，再乘以“~1ms each”“renderToString()”，得到“~100s build time”；Reality那一行从“Most are nullable / bool”“2 states,not 10”流向“2⁵=32 combos”“x ~1ms each”，最后得到“~32ms — one-time build cost — https://canmi.net/architecture/compile-time-rendering]

这就是我说的 **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 渲染器？而我们只约束最小的那几种类型，和重写整个渲染引擎的工作量完全不是一回事，这也回到了我最开始说的哲学，**用更小的代价、更优雅的方式来解决问题**。

> [diagram: 页面布局到后端的流水线对比了主要的客户端渲染与一个服务器渲染的内容区域：“Nav — CTR slot injection”，“Sidebar CTR”，“Footer — CTR slot injection”，以及 “User info — CTR slot injection” 包围中心的 “Markdown” “raw HTML slot” “SSR” 面板。图例将 CTR 标识为 “CTR (~0.1ms)” 并将服务器渲染路径标记为 “SSR (raw HTML slot)”，其中 Markdown 面板指向 “Raw HTML slot backend”。该后端可以使用 “TypeScript — your existing SSR”， “Rust — pulldown-cmark / comrak”， 或 “Go — goldmark”，每个都导致 “→ HTML string → insert slot”。 — https://canmi.net/architecture/compile-time-rendering]

所以好消息就是 `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()`，这个代价是不小的。

> [diagram: 一个两行的对比展示了 PPR 的单一流水线与 CTR 的分支路径的区别。PPR 从 “Static parts” （带有 “Cached ✓”）通过 “Any dynamic change” （“Even a simple string”）到 “renderToReadableStream()”（“Still heavy”）；CTR 从 “Static parts”（“Build-time ✓”）开始，并分支到 “string / number / bool” → “String replace (~0.1ms)” 适用于 “95% of cases”，或 “Markdown / Rich Text” → “Render (any language)” 适用于 “5% edge cases”。摘要为：“95% string replacement — 5% actual render,in any language”。 — https://canmi.net/architecture/compile-time-rendering]

而 **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` 包，理论实现还是很容易的，至少都是清晰可见的工作量。

> [diagram: 嵌套组件布局通过两个向右的箭头分支到两个 SSR 选项。一个大的 “React component” 容器显示 “Border,style,layout — interactive ✓”，并包含 “dangerouslySetInnerHTML”，以及 “Markdown / Rich Text — dead HTML” 和 “No interaction after hydration”；旁边，“When you need SSR” 对比 “60% — raw HTML slot” 与 “~zero cost,static inject” 以及 “40% — RSC” 与 “Server execution,TODO”。 — https://canmi.net/architecture/compile-time-rendering]

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

### UI 无关性

最后就是很诱人的协议无关性，我们本质上只需要抓住 `renderToString` 这一个关键点，前面用什么 UI 框架其实和协议没有关系。那这样的话，和 **Astro** 有什么区别呢？不过放心，我肯定造的不是另外一个 **Astro** 表面上看我和 **Astro** 的岛屿概念有一点像，但实际上区别很大。

> [diagram: 并排比较 Astro 与 Seam。 在 “Astro” 下， “Single page” 包含三个运行时——“React runtime”， “Vue runtime”， 和 “Svelte runtime”——注释写着 “Multiple runtimes hydrated”， “MPA — always MPA”， 和 “No client routing”。 在 “Seam” 下， “Single page,one runtime” 将 React 作为选定选项与 Vue、Svelte 和 Solid 并列，标注为 “Pick one per page”；下方，一根箭头从 “MPA before” 指向 “SPA after”，随后显示 “Client routing + transitions ✓”。 — https://canmi.net/architecture/compile-time-rendering]

我并没有在单个页面里水合多个运行时，Humm，其实我觉得你真正需要这个的场景非常有限，不同技术栈之间的组件状态通信会变得非常昂贵，它更像是一种过渡方案，从一个技术栈迁移到另一个，一次性换不完所以先过渡。其次，**Astro** 本质上是 **MPA**，而我们可以做到水合前是 **MPA**、水合后变成 **SPA**，就像 **Next.js** 一样有客户端路由，可以做跨页面动画，这是 **Astro** 求之不得的。

### Astro 与 SSG

另外就是 Astro 的设计本来就是在跨多个技术栈的场景才真正有用，如果你用它是为了速度快的话，只用它的一个框架，比如只引入 `React` 不要 `Vue`，那它所说的 **~~"速度快"~~** 我觉得是一个伪命题，首屏加载出来都是 HTML 没错，但你想要任何交互它就要水合，水合的成本就是下载整个 **React Runtime**，这和我们的水合没有任何本质区别。当然，后续我们也可以走通 **Island** 的概念，添加一个 **shell router** 来实现**跨 UI 框架的 SPA 导航**，但这是后面的 Roadmap，至少我现在不急。

> [diagram: 一个两行的比较对比了 SSG 和 CTR 的请求时路径。 在 SSG 中，“Request”流向“Static HTML”（“Built at deploy time”），然后流向“Same page every time”（“Hello,Alice — always Alice”），并带有注释“Fast,but frozen — no per-request data”。 在 CTR 中，“Request”流向“HTML skeleton”（“Built once,reused”），该结果与“Fresh data”（“Per request”）合并，生成“Dynamic page”（“Hello,Bob — ~0.1ms”）；注释为“Same speed as SSG + different data every request”。 — https://canmi.net/architecture/compile-time-rendering]

最后就是传统 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>` 中去。

> [diagram: 一个两行对比将传统 SSR 与 “CTR — build-time equivalence check.” 进行对照。传统 SSR 把 “Server HTML” （“Runtime generated”） 与 “Client DOM” （“React hydration”） 对立，导致 “Mismatch discovered” “At runtime — too late”；CTR 则比较 “CTR skeleton” “+ mock data filled” 与 “renderToReadableStream()”，使用 “Same mock data”，随后将两者发送至 “DOM-tree diff” “At build time — before deploy”。该差异检查可能产生 “Match ✓ safe” 或 “Mismatch → CLI warns”，并附注 “Escape hatch:any — like TypeScript,you accept the risk”。 — https://canmi.net/architecture/compile-time-rendering]

回到 **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 想都不敢想的。

> [diagram: 一个从左到右的流水线标题为“Traditional SSR”，描绘“Every request”从“Request”流向“Build tree”（“Component tree”），然后是“renderToString()”（“~100-300ms”），最后是“HTML response”。一个括号将构建树和渲染步骤组合为“Per-request cost”，对比每个请求所需的重复工作与请求和 — https://canmi.net/architecture/compile-time-rendering]

> [diagram: CTR（Compile-Time Rendering）的两阶段从左到右的流水线，分为 “Build time (once)” 和 “Request time”。一次性构建路径运行 “renderToString()” 并使用 “Sentinel data”，随后进行 “Diff & detect” 以处理 “if / each / match”，生成包含 “Typed slot markers” 的 “HTML skeleton”，这些归为 “One-time build cost”。请求路径从 “Request” 开始，经过约 “~0.1-1ms” 的 “Slot injection”，随后是 “HTML response”，最终进入 “Client hydration”，此时 “React takes over”，其中注入到响应的部分归为 “Per-request cost”。 — https://canmi.net/architecture/compile-time-rendering]

那如果后端是其他语言怎么办，就拿 Cloudflare Workers 举例，其实很多 Serverless 都支持 WASM，其他语言编译成 WASM BIN 之后一样可以作为后端。本质上我就是把前端编译成纯静态资源，类似 CSR 一样，但这堆纯静态资源配合一个私有桥 `/_seam/`，就能实现和那些真正的全栈框架一样的动态能力，自然也完全兼容 Serverless 了。

### Seam 与 SeamJS

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

> [diagram: 一个两层比较将 “Seam — protocol” 描述为 “Language agnostic”，与 “SeamJS — framework” 的 “Concrete implementation” 进行对比。Seam 包括 Sentinel、Slot markers、Diff detection 和 AST injection，可与 “Any backend:Rust,Go,TypeScript,...” 一起使用；一条向下的箭头指向 SeamJS，后者将 Vite、TanStack Router 和 TanStack Query 组合为 “Existing tools stitched”，并使用 Skeleton 和 CLI 作为 “Custom built”。 — https://canmi.net/architecture/compile-time-rendering]

但说实话，**SeamJS** 现在还是一个非常基础的样子。跑起来是没什么问题的，但真的拿它去写项目的话还需要打磨很久，我也在更多的思考一些架构上的改革，比如数据传输通道的抽象，但我并不确定在后续版本中还会不会坚持这个方向？框架层面上，我肯定会做出一个 TypeScript 的全栈框架，以及一个 Rust 作为后端的旗舰框架；至于 Go 相关的实习后续版本中移除掉，我觉得自己的精力维护不过来了。

> [repository: canmi21/seam]

### 不只是 Web 框架

> [diagram: 两个并行的从左到右的流水线对比网页和桌面传输，同时保持相同的起始协议和类似的即时结果。上面的路径是“Seam protocol” → “HTTP” → “Browser” / “Serverless,CDN” → “Full SSR-like page” / “Data ready on arrival”；下面的路径是“Seam protocol” → “IPC” → “Electron” / “Tauri” → “No loading screen” / “Instant render”。它们下方的注释为“Same protocol,swap transport”，强调仅传输方式和平台不同 — https://canmi.net/architecture/compile-time-rendering]

那这个东西做出来有什么用呢？实际上它不只局限于 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次。

> [diagram: 一个两行比较对比 “Strict mode — no JS Runtime” 与 “With JS Runtime — derive mode”。在第一条从左到右的流水线中，“Raw data”（“From DB / API”）流向“Must be ready-to-display”（“No compute,only conditions”），然后流向“Component written 2x”（“Tedious DX”）；在第二条中，同样的原始数据流经“JS derive()”（“Compute logic”），随后是“ready-to-display”（“Typed struct”），最后是“CTR inject”（“Write once ✓” — https://canmi.net/architecture/compile-time-rendering]

但是其实有解，因为 Web 原因，前端只能跑在 JavaScript 上，所以理所当然组件只能跑 JS，所以后端就必须要有 JS 执行能力才能解决这个问题，TypeScript 全栈项目本身的运行时就能用来解决；而 Rust 里面只需要嵌入一个 QuickJS 这样非常微小的 JS Runtime 就好了，注意这里的 JavaScript Runtime 是标准的 JS 子集，完全不是 Bun 或 Node 那种带完整操作系统 API 的运行时，真的就只是用来做推导、做 derive。

> [diagram: 一个标题为“JS Runtime size comparison”的三列大小比较，对比了 QuickJS、Bun 和 Node.js。QuickJS 标记为 “~200-300KB” 和 “JS subset,derive only”，而 Bun 为 “~50MB”，并标注 “Full runtime + OS APIs”，Node.js 为 “~100MB”，并标注 “Full runtime + OS APIs + npm”，强调运行时规模逐渐增大且功能更完整。 — https://canmi.net/architecture/compile-time-rendering]

这样就可以在组件里还是写一些计算逻辑，这部分逻辑会在后端拿到数据之后跑一段小 JS，把它推导为 `ready-to-display` 的状态，然后再发回给前端做首屏的水合。这样 CTR 满意了，拿到了严格结构的派生数据，用户 DX 开行了，组件只写一遍。当然如果你愿意严格遵守纯类型约束的话，后端确实可以做到 **No JS Runtime**，~~这个承诺其实还是成立的~~。但无论怎么说，新增的这个很小的 JavaScript Runtime，它的性能开销、内存占用和体积都远远比 Node 之类的东西小得多，基本上就是 200-300KB 的事情，却带来了非常大的灵活性。

### 未来展望

说了那么多，什么时候能吃上呢：大概吧，还要很久很久，真的不是我想鸽子，而是其实我觉得现阶段这个玩意绑架我太多太多了。我只能拿来开发的话，基本上应用写不了，写什么都被框架先绊脚，更何况我最近主要其实都在写这个网站什么的了。所以我想通了，不如网站先写上一个规模，这样就知道我的需求到底是什么；后面也许再来写 SeamJS 就有一个 TODO List 了，一个一个实现好这个功能就能用了，~~后续还能再水一个 迁移 和 Benchmark~~ ？好了，大饼画完了，理念就在这，先别管今天能不能用，至少思路上原型已经跑通了，晚安 💤

## Works cited

- [canmi21/seam](https://github.com/canmi21/seam)

## Elsewhere on this site

- Homepage: https://canmi.net/homepage.md
- Index for LLMs: https://canmi.net/llms.txt
- Full text for LLMs: https://canmi.net/llms-full.txt
- Sitemap: https://canmi.net/sitemap.xml
- Atom feed: https://canmi.net/atom.xml

## Structured data

> The JSON-LD graph the page for humans carries.

```json
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebSite",
      "@id": "https://canmi.net/#website",
      "name": "サボり記",
      "alternateName": "Canmi",
      "description": "Notes on the software and hardware I build.",
      "url": "https://canmi.net",
      "author": {
        "@type": "Person",
        "@id": "https://canmi.net/about#person",
        "name": "Canmi",
        "url": "https://canmi.net"
      },
      "publisher": {
        "@type": "Person",
        "@id": "https://canmi.net/about#person",
        "name": "Canmi",
        "url": "https://canmi.net"
      }
    },
    {
      "@type": "Person",
      "@id": "https://canmi.net/about#person",
      "name": "Canmi",
      "alternateName": [
        "Canmi Wu",
        "canmi21",
        "canmi",
        "canmimua"
      ],
      "jobTitle": "Systems Engineer",
      "url": "https://canmi.net",
      "email": "mailto:t@canmi.icu",
      "image": "https://avatars.githubusercontent.com/u/72544151",
      "identifier": {
        "@type": "PropertyValue",
        "propertyID": "GitHub user ID",
        "value": "72544151"
      },
      "sameAs": [
        "https://github.com/canmi21",
        "https://twitter.com/canmi21",
        "https://nya.one/@canmi",
        "https://bsky.app/profile/canmi.net",
        "https://t.me/canmi21"
      ]
    },
    {
      "@type": "TechArticle",
      "@id": "https://canmi.net/architecture/compile-time-rendering#article",
      "headline": "Rendering as a Protocol",
      "alternativeHeadline": "UI should be described, not executed.",
      "description": "从 SSR、SSG、ISR、RSC 的渲染谱系出发，提出 CTR (Compile-Time Rendering) 与 Seam 协议——把组件结构和数据视为两个独立发布物、运行时拼接。围绕 JTD、Sentinel、SeamJS 讨论把 UI 当协议而非可执行代码的可能。",
      "abstract": "从 Next.js 默认 SSR、为少量动态数据整树重渲染的开销出发，追问能否保留逐请求数据、SPA 水合与跨语言后端，却把多数 React 渲染移出运行时。它权衡 SSG、ISR/CDN 缓存和 React 19.2 PPR，也审视 RSC 与 Astro；限制包括 JTD 类型契约、Markdown 等开放内容、条件组合、hydration mismatch、Serverless 成本和 DX，原型尚未成熟。",
      "citation": [
        {
          "@type": "SoftwareSourceCode",
          "name": "canmi21/seam",
          "url": "https://github.com/canmi21/seam",
          "codeRepository": "https://github.com/canmi21/seam",
          "programmingLanguage": "TypeScript"
        }
      ],
      "image": "https://cdn.monoflake.com/object/41b74b0ed33e80a4bf4054349b372ea3.png",
      "datePublished": "2026-04-13T19:18:28Z",
      "dateModified": "2026-04-14T01:54:50Z",
      "inLanguage": "zh",
      "url": "https://canmi.net/architecture/compile-time-rendering",
      "mainEntityOfPage": "https://canmi.net/architecture/compile-time-rendering",
      "articleSection": "architecture",
      "wordCount": 7918,
      "author": {
        "@type": "Person",
        "@id": "https://canmi.net/about#person",
        "name": "Canmi",
        "url": "https://canmi.net"
      },
      "publisher": {
        "@type": "Person",
        "@id": "https://canmi.net/about#person",
        "name": "Canmi",
        "url": "https://canmi.net"
      },
      "isPartOf": {
        "@id": "https://canmi.net/#website"
      },
      "workTranslation": [
        {
          "@id": "https://canmi.net/architecture/compile-time-rendering?lang=de#article"
        },
        {
          "@id": "https://canmi.net/architecture/compile-time-rendering?lang=en#article"
        },
        {
          "@id": "https://canmi.net/architecture/compile-time-rendering?lang=es#article"
        },
        {
          "@id": "https://canmi.net/architecture/compile-time-rendering?lang=fr#article"
        },
        {
          "@id": "https://canmi.net/architecture/compile-time-rendering?lang=ja#article"
        },
        {
          "@id": "https://canmi.net/architecture/compile-time-rendering?lang=ko#article"
        },
        {
          "@id": "https://canmi.net/architecture/compile-time-rendering?lang=zh#article"
        },
        {
          "@id": "https://canmi.net/architecture/compile-time-rendering?lang=tw#article"
        }
      ]
    }
  ]
}
```
