这篇文章可能并不适合所有读者。要理解文中内容,你至少需要具备准前端开发者或全栈开发者的水平,对 SSR、SSG、ISR 等概念有基本认识,并且至少使用过 Next.js。
前言
可能是我天生就对底层原理有着强烈的好奇心,有时还会执着地追问为什么不能这么做。我猜正因如此,自己偶尔才能冒出一些独特的想法——这就是其中之一。它早在 2025 年 11 月就已经出现了,当时我也和身边不少朋友聊过,但由于手头项目在时间上发生冲突,直到 2026 年新年前后,我才真正动手做出第一版。之后它又被搁置了——因为我跑去建网站了。虽然博客还没有完全搭好,但总算有了一个勉强能记录东西的地方,所以我想把这件事重新拿出来讲一讲。
首先声明一点:我并不是说 SSR 不好。我只是觉得,就我观察到的趋势而言,太多人正在滥用它,整个行业都在随波逐流地回避一个问题。我也和不少人讨论过这件事,但得到的回应大多含糊不定;我曾讲过自己的构想,对方却总担心其中会遇到各种问题和边界情况。说实话,我也不太想每次都从头到尾把整套思路完整讲一遍。所以今天干脆把它详细写下来,试着把其中的一些疑问和技术原理解释清楚。
被滥用的服务端渲染
早在刚入门 Web 前端开发时,我接触的第一个全栈框架应该就是 Next.js。我承认它是一个非常优秀的框架;尽管存在一些性能问题,但这些性能代价换来了实实在在的 开发者体验(DX),这一点无可厚非。然而,随着我逐渐深入理解前端的概念和知识,我发现它可能越来越不适合我了——我很喜欢写 Rust,也喜欢研究底层,还玩过嵌入式,因此对性能有一种格外强烈的执着。这并不是说凡事都要追求极致,而是:明明有性能显著更好、实现起来或许只需付出很少努力的方案,为什么大家不这么做?所以每当使用 Next 时,我都会想:为什么一个哪怕非常简单、绝大部分内容都固定不变、只有一处需要更新的页面,也要从头到尾重新渲染一遍?为什么?在我看来,哪怕后端不是用 Rust 编写,哪怕不追求极致性能,甚至只是运行在 JavaScript 解释器上,只更新真正发生变化的那一点数据,开销也理应相对很小;所谓“渲染”应该以一种成本很低的方式完成,而不是把整个页面重新渲染一遍。
后来,这甚至成了我心里的一道坎,变成了一个不得不认真思考的问题。讽刺的是,当我回顾前端近十年的发展,审视 Vue、Svelte、Solid,以及 Next.js、TanStack Start、Remix、Nuxt、SvelteKit 和 SolidStart 等框架后,却觉得大家似乎都走偏了。尤其是 React 的 RSC,在我看来就是一个错误;不过,它的出现其实也有其缘由,之后有机会的话,也许可以再拿出来详细谈谈……
在我看来,RSC 和一众 SSR 框架其实都在滥用 render。SSG 倒是实现了我想要的模式——页面在构建阶段就已生成,请求到来时无需重新渲染——但它不具备真正的动态能力(x)。至于 ISR,它看似在 SSG 和 SSR 之间取得了平衡,也提供了一定的灵活性,却仍未从根本上达到我的期望。在我看来,ISR 的出现其实就是为了弥补 SSR 性能不佳的问题,后续出现的各种 CDN 缓存策略等做法也都只是 权宜之计,而不是真正优雅的解决方案。
当然,SSR 本身并没有错;它是一项很好的技术,解决了许多实际问题。真正的问题在于,这些框架将 SSR 置于近乎绝对默认的位置,结果是开发者真正使用 SSR 时,可能有 95% 的场景根本不需要它。页面上的绝大多数内容都是静态的,真正需要动态生成的部分少之又少,但每次收到请求时,整棵组件树仍然要重新运行一遍。
不过,React 近几年倒有所改进:React 19.2 引入了 PPR,但它在运行时仍需付出高昂的 renderToString() 成本,只能算一种权宜之计,并非真正的解决方案。所以我在想,既然大部分内容在构建时就已经确定,为什么不直接在编译期完成渲染呢?
编译时渲染?
把渲染放到编译期,听起来确实很荒谬,因为 SSR 存在的核心理由,就是有些值和条件只有到了运行时才能确定;在那之前,你根本不知道它们是什么,自然也无法作出判断。所以,我想大多数人第一时间否定我的想法也并非毫无道理,完全可以理解。
但我其实想到了一个颇为精巧的 Pipeline 来做到这一点,这也正是我需要称它为一种 Protocol 的原因。另外,我一直觉得全栈框架混淆前后端边界是件很蠢的事(虽然不得不承认,近几年 Next.js 在这方面的开发体验确实做得很好,让许多初学者觉得写一个全栈应用很简单,但实际上,很多安全隐患也正是由此而来——扯远了)。
因此,在组件中获取数据也是同理。我更偏好一种边界清晰的做法:组件只负责成为纯组件,数据获取则应彻底抽离。接受这一前提、将纯组件与数据分离之后,事情就有意思了:你会发现,数据本身其实也可以分类。
这里也要感谢 TypeScript 给我的灵感。其实,所谓的“值”在编译期具体是什么并不重要,重要的是它的类型;一个 slot 里该填什么内容,只要它不是 Open String(即拥有无限种可能的字符串),就可以被包裹成只有有限种可能的类型。比如,你在编写一个仪表盘,需要根据条件渲染某块内容,无非就是用户、管理员等几种情况,总归都能定义成完整的类型;现实世界中,几乎所有需要条件渲染或进行逻辑判断的地方,都可以归纳为几种确定的可能性。
恰好,我们可以用一种很好的方式来描述这些可能性,那就是 JTD!
JTD 规范
RFC 8927 定义的 JTD(JSON 类型定义)包含八种模式形式:空、引用、类型(布尔值、字符串、时间戳及各种精度的数值类型)、枚举、元素、属性、值和判别器;此外,任何模式都可以标记为 nullable。JTD 的优势在于它可以跨语言使用:几乎在所有语言中,包括 JavaScript、Rust 和 Go,都能找到对应的类型映射。这使它天然成为连接前后端的桥梁,而其载体 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 都可以。这正是它是一套协议而非框架的原因。
这时你可能会想到:条件渲染怎么办?比如,当某个字段为 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 执行环境来完成一些复杂的推导,但这算是一种妥协。后面我会解释原因,以及为什么从理论上说它并不是必需的。
这就是我所说的 CTR(编译时渲染)。它的限制其实与 SSR 完全相同:例如,<!--seam:path--> 插入文本时会自动进行 HTML 转义(包括 &、<、> 等字符);如果需要插入原始 HTML,就必须显式使用 <!--seam:path:html-->。数据路径缺失时,文本 slot 会被替换为空字符串,属性 slot 则不会注入;如果 each 块收到的不是数组,也会直接跳过。这些行为与 SSR 框架中的边界情况本质上没有区别,我只是把 SSR 的渲染步骤提前到编译期执行,仅此而已。CTR 会在构建期对所有条件变量的类型取值进行笛卡尔积遍历,既可以自动生成任意符合类型的模拟值,也允许用户提供特殊的模拟值来覆盖默认值。一般无须手动编写生成各种 HTML 变体所需的模拟值,系统会自动传入符合相应类型的值,但你也可以自行指定。随后,每一种组合都会渲染一次,系统通过比较差异确定所有条件块与循环块的边界,最终得到一个完全展开的 HTML 骨架。从数学上讲,只要运行时传入的数据符合这些类型定义,就一定能够正确注入这个骨架,因为所有可能的分支路径都已经在编译期穷举完毕。
如何保证一致性?
那么,如何保证一致性呢?答案其实就是以 JTD 作为契约:前后端虽然彼此分离,但只要双方都遵循同一份 JTD 模式,数据类型就能保持一致,不会出现不匹配的问题。
但与其他框架相比,我还面临一个额外的挑战:既然后端摆脱了 JavaScript 运行时的限制,它就不必再与前端捆绑在一起,完全可以用 Rust 或 Go 编写。在 TypeScript 全栈框架中,前后端的一致性可以直接由类型系统保证;换成其他语言又该怎么办呢?我的做法其实是使用 代码生成:无论后端采用什么语言,都以后端为基准,将前端可用的变量和类型直接生成为 TypeScript 代码,供前端导入。这样一来,前后端虽然边界分明,实际上却仍能像 TypeScript 全栈框架那样共存在同一个文件夹中,符合 单体仓库 的使用习惯;两端可以直接相互调用,无须手写 API,也不需要 gen OpenAPI 之类的工具。具体来说,我为框架设置了一条私有路径 /_seam/ 作为默认端点(和 Nuxt 一样可以配置),这样运行 JTD Typed-RPC 后,数据传输和跨源资源共享的问题就都解决了。
CTR 与 SSR
接下来是 raw HTML slot。这其实又回到了那 5% 的边缘情况:比如 Markdown、Rich Text 之类的内容,编译期根本不可能知道它们的值,而且用类型约束它们的代价非常高。当然,你可以扩展协议,枚举 Markdown 的全部语法,再在运行时解析,但这份工作差不多就等于重写一个 Markdown 渲染器了。相比之下,我们只约束最少的几种类型,工作量与重写整个渲染引擎不可同日而语。这也回到了我一开始所说的理念:以更低的成本、更优雅的方式解决问题。
所以好消息是,raw HTML slot 其实能让 CTR 与 SSR 共存。这意味着,一个页面的大部分用户界面都可以用 CTR 实现,几乎没有运行时成本;最核心的那篇 Markdown 文章则用 SSR 渲染。后端不再受限之后,你既可以用 TypeScript 导入原来那套 SSR 渲染方法,也可以换个思路:既然后端用 Rust,当然也能直接用 Rust 的 Markdown 编译器渲染。反正最后只要生成一个 HTML 字符串,插入 raw slot 就能显示。使用 CTR 并不意味着不能使用 SSR,两者完全可以共存。
PPR 的落差
由此看来,CTR 本质上就是 **PPR(部分预渲染)**最理想的形态:所有可缓存的内容都在编译期完成渲染,实现零运行时开销,只有真正发生变化的那一点数据会产生成本。那么,它与 React 19.2 的 PPR 有什么不同呢?答案是,我做得更彻底,也更激进。PPR 即使在最理想的情况下,所有静态部分都已缓存,只有一小段动态内容需要更新,但哪怕变化的只是一条简单的字符串,React 仍然必须重新执行一遍 renderToReadableStream(),而这个成本并不低。
而 CTR 把这条界限划分得很清楚:字符串、数字和布尔值等简单类型会直接通过字符串替换处理;只有 Markdown 这类枚举成本极高的复杂类型,才会进入真正的渲染流程。而且这里的渲染已不再是传统意义上的 SSR,它可以采用任何编程语言的渲染方式。
还有 RSC
再来说说 RSC(React 服务端组件)。之前提到过,RSC 可能是一个错误,因为它模糊了太多前后端边界,带来了不少安全隐患,还引发了众多 CVE。但这些问题其实更多要归咎于 Next.js 和其他服务端渲染流程:用户根本没有不用它的余地,仿佛只要需要动态内容,就必须采用 SSR。但实际上,像我们这样替换字符串也完全行得通。另外不得不承认,RSC 赋予了服务端一项非常重要的能力:渲染任意 React 组件,也就是执行任意代码。
不过,既然都要执行任意代码了,基本不可能靠类型把所有情况穷举出来;即使能做到,工作量恐怕也不比编写一个 React 编译器小。因此,RSC 的这种能力确实有其存在的理由。CTR 和 RSC 能否共存?答案是可以,而且两者完全不冲突。不过,这是框架层面需要解决的问题,不属于协议本身的范围。我暂时还没有实现,但似乎可以抄一下 TanStack Start:只要多发送一个 html 包和一个 js 包,理论上实现起来还是很容易的,至少所需的工作量清晰可见。
再说回 raw HTML slot,它主要解决的是 Markdown、Rich Text 这类场景:渲染出来的 HTML 通过 dangerouslySetInnerHTML 注入,水合之后这块区域不参与交互,是 "死的"。这部分内容应该尽量少,你想给文章加边框、加样式,那些应该写成 React 组件,而不是混在这段 HTML 里。这种做法的缺点是水合之后它不能再改变,但它确实把这个前提下的 "SSR" 成本压到了非常低,接近于零成本。在真正需要 SSR 的场景里,大概 60% 都属于这种静态 HTML 注入;剩下 40% 才需要 RSC 那种在服务端执行任意组件的能力。
UI 无关性
最后是颇具吸引力的协议无关性。本质上,我们只需要抓住 renderToString 这一个关键点,前端采用什么用户界面框架其实与协议无关。那么,这和 Astro 有什么区别呢?不过请放心,我肯定不会再造一个 Astro。表面上看,我的设计和 Astro 的岛屿概念有些相似,但实际上二者区别很大。
我并没有在同一个页面中水合多个运行时。嗯,其实我觉得真正需要这么做的场景非常有限:不同技术栈的组件之间进行状态通信,成本会非常高。它更像是一种过渡方案——从一个技术栈迁移到另一个技术栈时,无法一次性完成替换,所以先用它来过渡。其次,Astro 本质上是 MPA,而我们可以让应用在水合前是 MPA,水合后则变成 SPA;它可以像 Next.js 一样使用客户端路由,实现跨页面动画,而这正是 Astro 求之不得的。
Astro 与静态站点生成
此外,Astro 的设计只有在跨多个技术栈的场景中才能真正发挥作用。如果你只是为了追求速度而使用它,却只采用其中一种框架,比如只引入 React 而不用 Vue,那么它宣称的 “速度快” 在我看来就是一个伪命题。首屏加载出来的确实都是 HTML,但只要需要任何交互,就必须进行水合,而水合的代价就是下载完整的 React 运行时,这与我们的水合并没有任何本质区别。当然,后续我们也可以采用岛屿架构,加入一个外壳路由器来实现跨 UI 框架的 SPA 导航,但那属于之后的路线图,至少我目前并不着急。
最后再和传统的 SSG 对比一下:我实现了与 SSG 相同的效果,本质上都是把编译时能够确定的内容全部预先渲染出来;但我们的方案更加动态,因为那些简单类型的 slot 值完全可以在运行时替换。你可以把它理解为:我们将 SSG 渲染结果作为 MPA 的入口;水合前它是 MPA,水合后则变成 SPA,同时仍然具备“动态”能力。
水合错误?
最后要说的是我很讨厌的 水合不匹配,相信你也不喜欢。归根结底,它其实就是 DOM 状态不一致。传统框架(如 Next.js 的 App Router)试图用 React 包裹整个应用,一旦用户的浏览器注入了某些标记,就可能发生水合错误。我们面临的限制其实与传统服务端渲染相同,不过当你使用 TypeScript 时,我还会加上一层名为 __root 的水合 div,避免水合区域覆盖 元数据 区域,从而让水合更加稳健。此外,React 19 已内置对 <title>、<meta>、<link> 等文档元数据标签的支持。即使你只水合页面中的某个 <div>,而不是整个 <html>,只要在组件中直接渲染 <title>My Page</title>,React 也会自动将其提升到 <head> 中。
回到水合不匹配这个问题:既然我们在编译期已经知道每个 slot 的类型,就可以再加一道 CTR 等价性检查——根据类型定义推导出模拟数据,将其填入完全展开的 HTML,再调用传统的 renderToReadableStream() 运行一遍,随后比较两者的 DOM 树在语义上是否等价,而不计较格式化差异。它们的格式可能略有不同,但只要 DOM 结构严格且完全等价,其实就可以告别水合不匹配了。为什么传统 SSR 做不到?因为它们只能在运行时执行这一步,而 CTR 的结构决定了这些约束必须在编译期全部落实。当然,我们也提供了 any 这个逃生入口;但就像 TypeScript 一样,一旦使用 any,你就必须自行承担后果:CLI 编译时会警告你,any 这种开放字符串逃生机制可能导致不匹配。
无服务器
当然也不能忽略无服务器架构的可能性。近几年,无服务器架构的使用体验已经非常好了,我的评价是“除了费钱,没什么缺点”。而 CTR 天生就很适合这个场景:运行时执行的任务很轻量,无服务器服务启动得非常快,响应时间和资源开销都会显著降低。剩下难以优化的,也就是渲染 Markdown 之类的场景,但这完全应该从业务逻辑层面着手,例如预先渲染 Markdown 并保存结果,避免每次请求都重新渲染,就像我现在这个网站所做的一样。这是业务层面的问题,不是框架能够解决的;不过,框架可以帮助你把除此之外的简单逻辑开销降到几乎为零。与传统的服务端渲染相比,两者的开销已经不在同一个数量级了。究竟能小到什么程度?大约只需几百微秒到 1 毫秒,这是传统服务端渲染想都不敢想的。
那后端使用其他语言时怎么办呢?以 Cloudflare Workers 为例,其实很多无服务器平台都支持 WASM;把其他语言编译成 WASM 二进制文件后,同样可以用作后端。本质上,我只是把前端编译成纯静态资源,类似于客户端渲染(CSR);但这批纯静态资源只要配合一个私有桥接层 /_seam/,就能获得与真正的全栈框架相同的动态能力,自然也能完全兼容无服务器平台。
Seam 与 SeamJS
所以你是不是已经被 Seam 和 SeamJS 的关系绕晕了?其实这两者很简单。Seam 是协议,它定义的是:怎么用 Sentinel 标记动态位置、怎么转换成 slot 标记、条件块和循环块怎么做 diff 检测,以及运行时怎么基于 AST 做数据注入。协议本身与语言无关,任何能解析 HTML 注释、能做字符串替换的后端都能实现它。SeamJS 是框架,是基于这套协议做出来的一个具体实现:它把 Vite、TanStack Router、TanStack Query 这些现成的轮子在一起,再补上它们覆盖不到的地方,比如我这套 skeleton 提取、注入引擎、CLI 之类的东西。
但说实话,SeamJS 目前还很基础。运行起来没什么问题,但真要用它开发项目,还需要很长时间打磨。我也在进一步思考架构层面的调整,比如抽象数据传输通道,不过我还不确定后续版本是否会继续坚持这个方向。框架方面,我肯定会做一个 TypeScript 全栈框架,以及一个以 Rust 为后端的旗舰框架;至于 Go 相关的实现,我打算在后续版本中移除,因为实在没有足够的精力维护。
Rendering is a protocol, not a render-time computation.
不止是 Web 框架
那么,做出这个东西有什么用呢?其实它并不局限于网页端。例如,把 Transport 通道也抽象出来后,后续就可以移植到 Electron、Tauri 等桌面环境中:只要将 HTTP 传输管线替换为 IPC 通信,运行的依然是同一套 Seam 协议。这样一来,那些 Electron 应用启动时就再也不必显示加载画面了,许多内容都能像服务端渲染一样直接在本地渲染出来——这就是 CTR 的魔法!
不使用 JS 运行时的代价
那么,SeamJS 最后为什么还是加入了 JS 运行时呢?因为在落实这套方案时,我遇到了一个问题:CTR 对首屏数据的理想约束非常严格,数据必须是一个能够完全推导出来的确定结构体,甚至不能包含任何计算逻辑,只能包含条件判断。这样的要求就过于苛刻了。可以想见,从框架的开发体验来看,习惯使用 React 的人肯定希望能在组件里做一些计算,拿到几个值后直接使用;如果要严格遵守 CTR 的约束,达到 ready-to-display 的状态,过程就会非常繁琐,甚至可能需要把同一个组件写两次。
但其实有办法解决:受 Web 平台限制,前端只能运行 JavaScript,因此组件自然也只能以 JavaScript 运行;要解决这个问题,后端就必须具备执行 JavaScript 的能力。TypeScript 全栈项目可以直接利用自身的运行时;Rust 项目则只需嵌入 QuickJS 这样极其轻量的 JavaScript 运行时。需要注意的是,这里的 JavaScript 运行时只支持标准 JavaScript 的一个子集,完全不同于 Bun 或 Node 那种提供完整操作系统 API 的运行时,确实只用于执行推导逻辑、生成派生数据。
这样一来,组件里仍然可以编写一些计算逻辑。后端拿到数据后,会运行一小段 JavaScript,将数据推导成 ready-to-display 状态,再发回前端完成首屏水合。于是 CTR 得到了结构严格的派生数据,开发者也获得了良好的开发体验,而且组件只需编写一次。当然,如果愿意严格遵守纯类型约束,后端确实可以做到 无需 JavaScript 运行时,所以这个承诺其实依然成立。不过无论如何,新增的 JavaScript 运行时非常轻量,其性能开销、内存占用和体积都远小于 Node 等运行时,基本只有 200–300 KB,却能带来极大的灵活性。
未来展望
说了这么多,到底什么时候能 呢?大概还要很久很久。真不是我想 ,而是这东西在现阶段绑架了我太多太多。如果我只能拿它来做开发,那基本上就写不了应用了,写什么都先被框架绊一脚,更何况我最近主要都在写这个网站之类的东西。所以我想通了:不如先把网站做出一定规模,这样才知道我的需求到底是什么;等以后再回来写 SeamJS,手里就有一份 TODO List 了,一个一个把功能实现好,它就能用了。后续还能再水一篇迁移和 Benchmark?好了,,理念就摆在这儿。先别管今天能不能用,至少思路上原型已经跑通了。晚安 💤