<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <description>feedId:259981957098517504+userId:125528261992773632</description>
  <author>
    <name>Canmi</name>
    <email>t@canmi.net</email>
  </author>
  <generator uri="https://feedsmith.dev">feedsmith</generator>
  <icon>https://cdn.ffoni.com/favicon.svg</icon>
  <id>tag:canmi.net,2023:atom</id>
  <link href="https://canmi.net/atom.xml" rel="self"/>
  <link href="https://canmi.net/" rel="alternate"/>
  <subtitle>One developer's journey through code, circuits, pixels — and life in between.</subtitle>
  <title>サボり記</title>
  <updated>2026-04-14T01:54:50.646Z</updated>
  <entry xml:lang="zh">
    <content type="html">
      <![CDATA[<p>这篇文章可能不适合所有人阅读，要理解它在说什么你至少得是一个准前端开发者或全栈开发者，对 <code>SSR</code>、<code>SSG</code>、<code>ISR</code> 这些概念有基本的认知且至少用过 <strong>Next.js</strong></p>
<h2 id="preface">前言</h2>
<p>可能是我的天生就对底层原理有很强的好奇心，再加上有时候一直执着于问清楚为什么不能这么做；我猜可能是这样子的，所以有时候我才能想出一些独特的想法：这个就是其中之一，早在 25 年 11 月的时候它就冒出来了，当时跟身边很多朋友聊了聊，但因为手头项目的时间冲突，一直拖到了 <strong>2026</strong> 年新年前后才真正动手做了一版出来。之后又搁置了——因为我跑去建站了。博客虽然也没完全搭好，但总算有了一个还算能记录东西的地方，所以我想把这件事拎出来重新说一说。</p>
<p>首先先声明一点：我不是在说 <code>SSR</code> 不好。我只是觉得，从我看到的趋势来说，太多人在滥用它，大方向在随波逐流的逃避一个问题。而我也跟不少人讨论过这个，得到的回应大多不太确定；我也说过我的构想，但对方总是担心这里面会存在各种问题、各种边界，而且说实话我也不太想每次都把整套思路从头到尾完整讲一遍。所以今天索性把它详细写下来，试着把一些疑问和技术原理讲清楚吧。</p>
<h2 id="abused-ssr">被滥用的 SSR</h2>
<p>早在我刚入门 <strong>Web</strong> 前端开发的时候，第一个接触到的全栈框架应该就是 <strong>Next.js。</strong> 我承认这是一个非常优秀的框架，尽管它有一些性能问题，但这些性能问题是实打实的 <strong>DX</strong> 体验换来的，这一点无可厚非。但随着我慢慢深入理解前端的一些概念和知识，我发现它可能越来越不适合我了——因为我很喜欢写 Rust，也很喜欢研究底层，玩过嵌入式，所以对性能有一种很特别的执着；执着也不是什么都要极限，而是，这里本来就有性能明显更好，但是努力也许很小的办法可以实现，为什么大家都不这样做？所以每当用起 Next 的时候我就会想：为什么哪怕一个很简单的页面，大多数东西都是固定的，只有一个地方需要变，这整个页面就要重新渲染一遍？为什么？在我的理解中，哪怕后端不是用 Rust 写的，哪怕不追求极限，就算跑在 JavaScript 解释器上，真正改变的那一点数据也应该是一个相对来说很小的开销，用一种很低成本的方式实现"渲染"，而不是重新渲染整个页面。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>以至于后面，它已经成为了我心里的坎，变成了我不得不思考的问题，但是讽刺的是当我看完前端这近 10 年的发展，<strong>Vue</strong>、<strong>Svelte</strong>、<strong>Solid</strong> 和他们的框架 <strong>Next.js</strong>, <strong>TanStack</strong> <strong>Start</strong>, <strong>Remix</strong>, <strong>Nuxt</strong>, <strong>Sveltekit</strong> 和 <strong>Soild</strong> <strong>Start</strong> 后我觉得似乎大家都跑偏了，尤其是 <strong>React</strong> 的 <strong>RSC</strong> 这是一个错误，但是它的存在其实也是有理由的，这点后面有机会也许再拿出来详细说说...</p>
<p>在我看来，<code>RSC</code> 和一众 <code>SSR</code> 框架其实都在滥用 <code>render</code> 这件事。<code>SSG</code> 倒是做到了我想要的风格——页面在构建时就生成好了，请求时不需要重新渲染——但它没有真正的动态能力(x)。而 ISR 呢，看起来是在 SSG 和 SSR 之间找了个平衡，做到了一定的灵活性，但它本质上并没有达到我的期望；我觉得 ISR 的出现其实就是为了弥补 SSR 性能差的问题，包括后续的各种 CDN 缓存策略之类的操作，它们其实都算是一种 <strong>workaround</strong>，而不是真正优雅的解决方案。</p>
<p>当然 SSR 本身不是一个错误，它是一个很好的东西，解决了很多真实的场景。但问题在于，这些框架把 SSR 推到了一个极度默认选项的位置上，导致开发者真正用到 SSR 的时候，可能 95% 的场景其实根本不需要它。页面上大部分内容都是固定的，真正动态的部分少之又少，但每次请求还是要把整棵组件树跑一遍。</p>
<p>不过呢近些年 React 倒是有所改观，<strong>React 19.2</strong> 有一个 <a href="https://react.dev/blog/2025/10/01/react-19-2#partial-pre-rendering">PPR</a> 但是这个东西其实运行时还有昂贵的 <code>renderToString()</code> 也算是一个 workaround 不是真正的解。所以我就想，既然大部分东西在构建时就已经确定了，那为什么不把渲染这件事直接放到编译期去做呢？</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<h2 id="compile-time-rendering">编译期渲染?</h2>
<p>把渲染放到编译期听起来是一件很荒谬的事情，因为 SSR 存在的核心理由就是有些值、有些条件，不到运行时那个点上你根本解不出来，你不知道它是什么，做不了判断。所以我想大多数人第一时间否决我的想法也不是没有道理，可以理解。</p>
<p>但其实我想到了一个算得上精巧的 <code>Pipeline</code> 来实现这件事，这也是为什么我需要把它叫做一种 <code>Protocol</code> 另外就是我一直觉得全栈框架混淆前后端边界是一件很蠢的事情（虽然不得不承认近几年 Next.js 在这方面 DX 做的很好，这让很多初学者觉得写一个全栈的应用是一个很简单的事情，但是实际上很多安全隐患也是由此而来的，扯远了）</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>所以在组件里写数据获取也是一样，我更倾向于一种明确界限的方式——组件就是纯组件，数据获取应该被彻底抽出来。而一旦你接受了这个前提，纯组件和数据分离之后，事情就变得有意思了：你会发现数据本身其实是可以被归类的。</p>
<p>这里也要感谢 <strong>TypeScript</strong> 的给我的灵感，其实所谓的 "值" 本身在编译期是什么并不重要，重要的是它的类型；一个 <code>slot</code> 里要填什么内容，只要它不是那种 <code>Open String</code> (有无限种可能的字符串)，它就可以被包裹为有限可能性的类型。比如你在写一个 dashboard，需要条件渲染某块内容，无非就是 User 或 Admin 等等但是总归是可以定义完的类型；真实世界中几乎所有需要条件渲染、需要做逻辑判断的地方，都可以被归纳为几种确定的可能性。</p>
<p>那正好这些可能性可以用一个很好的办法来描述，那就是 <strong>JTD</strong>！</p>
<h2 id="jtd-spec">JTD 规范</h2>
<p><strong>JTD (JSON Type Definition)</strong> <a href="https://www.rfc-editor.org/rfc/rfc8927.html">RFC 8927</a> 定义的中它有八种 <strong>Schema Form</strong>: Empty、Ref、Type（ boolean、string、timestamp 以及各种精度的数字类型）、Enum、Elements、Properties、Values、Discriminator，再加上任意 schema 都可以标记为 <code>nullable</code>。JTD 的好处在于它是跨语言的，<strong>JavaScript</strong>、<strong>Rust</strong>、<strong>Go</strong> 几乎所有语言都能找到对应的类型映射，这就让它天然地成为了前后端之间的桥梁，并且载体是 <strong>JSON</strong>，本来就是<strong>前后端最小公约</strong>。真实世界中那些网站 <strong>95%</strong> 的应用，无非就是显示字符串、数字字段、或者用布尔值做判断，它们全都落在这个范围内。明确了这一点后，实际上我们可以操作的空间就很大了，当然 JTD 也不是完美的，其实也有意外，比如 <strong>Markdown</strong> 这类东西，但这个我们后面再详细说，这里其实才是我真正认可 <code>SSR</code> 有意义的地方。</p>
<h2 id="sentinel">Sentinel</h2>
<p>先说好办的，既然我们已经知道了每一个动态值都有类型，那我们就可以在编译期做一件事把 <strong>React</strong> 组件用 <code>renderToString()</code> 跑一遍，但不是用真实数据，而 Mock 出来用 <code>Sentinel</code> 那么什么意思呢？</p>
<pre><code>{ user: { name: "Alice", age: 30 } }</code></pre>
<p>假设你的数据长这样，那么我们可以把它替换成</p>
<pre><code>{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }</code></pre>
<p>这些 <code>%%SEAM:...%%</code> 就是 <strong>Sentinel</strong>，它们占住了每一个动态值的位置。React 拿着这些 <strong>Sentinel</strong> 数据跑完 <code>renderToString()</code> 之后，输出的 <strong>HTML</strong> 里这些位置就被标记出来了。接下来构建管线会把这些哨兵转换成 HTML 注释形式的 slot 标记</p>
<pre><code>&lt;!-- Sentinel --&gt;
&lt;span&gt;%%SEAM:user.name%%&lt;/span&gt;

&lt;!-- Slot --&gt;
&lt;span&gt;&lt;!--seam:user.name--&gt;&lt;/span&gt;</code></pre>
<p>到这一步你可能已经看出来了这些 <code>slot</code> 就是 <strong>"有类型标记的插槽"</strong>。这个时候服务端运行时要做的事情变得极其简单，那就拿到真实数据，往这些孔里填值，纯粹的字符串替换。完全不需要 <code>renderToString()</code>，不需要 JavaScript 运行时，不需要 <code>vDOM</code>。任何能解析 <strong>HTML</strong> 注释、能做<strong>字符串替换</strong>的语言都能当后端，Rust、Go、TypeScript 都行，这就是为什么它是一个协议而不是一个框架。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>这个时候你可能会想到条件渲染怎么办？比如某个字段为 null 的时候一整块内容都不应该出现，但是其实这个也不需要运行时的 JavaScript 来判断，反知是协议可以定义这个情况</p>
<pre><code>&lt;!--seam:if:user.avatar--&gt;
&lt;img src="&lt;!--seam:user.avatar--&gt;" /&gt;
&lt;!--seam:endif:user.avatar--&gt;</code></pre>
<p>条件渲染如此，列表渲染也一样</p>
<pre><code>&lt;!--seam:each:messages--&gt;
&lt;li&gt;&lt;!--seam:$.text--&gt;&lt;/li&gt;
&lt;!--seam:endeach--&gt;</code></pre>
<p>甚至模式匹配</p>
<pre><code>&lt;!--seam:match:status--&gt;
&lt;!--seam:when:active--&gt;&lt;span class="green"&gt;Active&lt;/span&gt;
&lt;!--seam:when:disabled--&gt;&lt;span class="red"&gt;Disabled&lt;/span&gt;
&lt;!--seam:endmatch--&gt;</code></pre>
<p>这就是注释的艺术，这里选择了注释是因为它正好是合法的 HTML 字符，仅此而已。那这些条件块和循环块是怎么在 <strong>build-time</strong> 确定并生成的呢？其实也是一个算得上很巧的办法，我们虽然没有 vDOM 但是可以两次渲染 HTML做 Diff；拿条件渲染来说，第一次用完整的数据 Sentinel 渲染，第二次把某个 <code>nullable</code> 字段设为 <code>null</code> 再渲染一遍，两次输出一比对，消失的那块 HTML 就是这个字段控制的条件块，直接用 <code>&#x3C;!--seam:if:...--></code> 包起来就好了。数组也是同理。</p>
<h2 id="maybe-ctr">也许 CTR?</h2>
<p>那肯定又有人要担心了：条件渲染的组合不会指数爆炸吗？比如 3~5 个变量控制条件渲染，每个变量有 10 种可能，乘起来确实是一个看起来很大的数字。但实际上这个数字只是看起来大唬人而已嘛。首先，很多字符串类型的值我们并不需要真的穷举内容，我们只关心它有值还是没值 <code>nullable</code> 这里实际上就是 2 种；其次，真正需要做条件判断的字段，它的类型一定是可穷举的，就比如（你不会拿一个 <code>open string</code> 来做 <code>if</code> 判断吧？）；再说了，就算真的要穷举所有组合，在现代 <strong>CPU</strong> 上跑完可能也就几 <code>ms</code>，而且这完全是编译期的重，就像 <strong>Rust</strong> 编译一样，编译期付出比较重的代价，但运行时就变成了纯粹的字符串替换，这笔生意怎么算都是划算的吧，更何况这里没有任何特别的魔法。但是这里也可以埋一个小点：后面我其实引入了一个很小的嵌入式 <strong>JavaScript</strong> 执行环境来做一些复杂的推导，但这算是一种妥协，后面会解释原因，以及理论上是可以不要的。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>这就是我说的 <strong>CTR (Compile-Time Rendering)</strong> 至于限制，其实和 SSR 的限制是一模一样的，比如 <code>&#x3C;!--seam:path--></code> 做文本插入时会自动做 HTML 转义（<code>&#x26;</code>、<code>&#x3C;</code>、<code>></code> 等），如果你需要插入原始 HTML 就得显式用 <code>&#x3C;!--seam:path:html--></code>；缺失的数据路径在文本 <code>slot</code> 中会变成空字符串，在属性 <code>slot</code> 中会跳过注入；<code>each</code> 块如果拿到的不是数组就直接跳过。这些行为和你在 SSR 框架中遇到的边界情况本质上没有区别，我只是把 SSR 中的渲染这一步拎到了编译期去执行，仅此而已。而 <strong>CTR</strong> 在 <strong>build-time</strong> 对所有条件变量的类型值做笛卡尔积遍历，使用 Mock 出来的任意符合类型的值一种 or 用户 Override 特殊 Mock 值（一般是不需要手动写生成 HTML 遍体所需要的 Mock 值的，但是可以手动给，不如就是自动给一个符合你这个类型的值进来），这样每种组合渲染一次，diff 出所有条件块和循环块的边界，最终得到一个完整展开的 HTML 骨架。从数学上说，只要 runtime 传入的数据符合这些类型定义，它就一定能被正确注入到这个骨架里，所有可能的分支路径在编译期已经被穷举过了。</p>
<h2 id="consistency">一致性?</h2>
<p>那一致性该如何保证呢，其实答案就是 <strong>JTD</strong> 这个 <strong>Contract，</strong> 前后端虽然分离了但只要都遵守同一份 <strong>JTD schema</strong>，数据的类型就是对齐的，不存在不一致的问题。</p>
<p>但我比其他框架多了一个挑战，既然后端解放了 <strong>JavaScript</strong> 运行时，它就不必和前端是一体的了，你完全可以用 <strong>Rust</strong> 或者 <strong>Go</strong> 来写后端。那在 <strong>TypeScript</strong> 的全栈框架里，前后端一致性可以直接靠类型来保证；其他语言呢？实际上这里我用了 <strong>codegen</strong> 无论后端用什么语言写，都以后端作为基准，把前端可以用到的变量和类型直接 <strong>codegen</strong> 成 <strong>TypeScript</strong> 给前端导入。这样虽然前后端的界线被划清楚了，但它们实际上可以像 <strong>TypeScript</strong> 全栈框架一样活在一个文件夹里，符合 <strong>Monorepo</strong> 习惯，可以直接互相调用，不用手写 API 或者 <code>gen</code> <strong>OpenAPI</strong> 之类的东西，实际上这里我用了一个私有路径 <code>/_seam/</code> 作为框架默认端点（和 Nuxt 一样可以配置的），这样跑了 <code>JTD Typed-RPC</code> 就解决了数据传输和 CORS 的问题。</p>
<h2 id="ctr-x-ssr">CTR x SSR</h2>
<p>然后就是 <code>raw HTML slot</code> ，这个其实就是回到了那 <strong>5%</strong> 的 <strong>Edge Case</strong>，比方说 <code>Markdown</code>、<code>Rich Text</code> 这类东西，编译期根本不可能知道它的值，而且<strong>要用类型去约束它的代价非常大</strong>。当然你可以说，把协议拓展一下，把 Markdown 的所有语法都列举出来，运行时再做解析，但那个工作量基本等于你重写了一个 Markdown 渲染器？而我们只约束最小的那几种类型，和重写整个渲染引擎的工作量完全不是一回事，这也回到了我最开始说的哲学，<strong>用更小的代价、更优雅的方式来解决问题</strong>。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>所以好消息就是 <code>raw HTML slot</code> 其实可以让 <code>CTR</code> 和 <code>SSR</code> 可以共存，它意味着你一个页面大部分 UI 都用 <code>CTR</code> 来做，几乎没有运行时成本；然后最核心的那一篇 <strong>Markdown</strong> 文章，你用 <code>SSR</code> 来渲染。由于后端已经解放了，你可以选择用 TypeScript 导入你原来那套 <code>SSR</code> 的渲染方法，也可以思路打开！后端用 Rust 那当然是用 Rust 的 Markdown 编译器来渲染，反正最后只要给出一个 HTML 字符串，插入这个 <code>raw slot</code>，它就能显示。用了 <code>CTR</code> 不意味着不能用 <code>SSR</code>，两者完全可以共存。</p>
<h2 id="ppr-ideal-vs-reality">PPR 的理想和现实</h2>
<p>这样理解下来，<strong>CTR</strong> 本质上就是 <strong>PPR(Partial Prerendering)</strong> 最理想的情况，所有能缓存的东西全部在编译期渲染完了，零运行时开销，只有真正变化的那一点数据才有成本。那和 <code>React 19.2</code> 的 PPR 不同的是什么呢？答案是我更彻底，更激进 <code>PPR</code> 就算在最理想的情况下，所有静态部分都缓存好了，只有一小段动态内容需要更新，但哪怕这个动态内容只是一个简单的字符串变了，React 还是得重新跑一遍 <code>renderToReadableStream()</code>，这个代价是不小的。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>而 <strong>CTR</strong> 把这个界限划的很清楚：如果是简单类型比如 字符串、数字 和 布尔值，那都是直接做字符串替换；只有 Markdown 这种穷举代价极大的复杂类型，才会走真正的 <strong>render</strong> 流程。而且这里的 <strong>render</strong> 已经不是传统意义上 <strong>SSR</strong> 的概念了，它可以是<strong>任何编程语言的渲染方式</strong>。</p>
<h2 id="rsc-still-needed">RSC 也不能少</h2>
<p>那再说说 <strong>RSC(React Server Component)</strong> 之前说过 <strong>RSC</strong> 可能是一个错误，<s>那是因为它模糊了太多前后端的边界</s>，<s>带来了<strong>不少安全隐患</strong></s> <s>和 众多 CVE</s> 但其实这更多的错事因为 <strong>Next.js</strong> 和其他的 SSR 流，<strong>用户根本没有机会不用</strong>，变成了你要动态就要 <strong>SSR</strong>，但是实际上我们这样子的字符串替换也是完全走得通的。另外不得不承认的就是 <strong>RSC</strong> 赋予了服务端一个很重要的能力，那就是渲染任意 React 组件，也就是执行任意代码。</p>
<p>不过你都要执行任意代码了，想用类型把它穷举出来基本不可能，就算可能那工作量也不见得比写一个 React 编译器小。所以 RSC 这种能力是有它存在的理由的。<strong>CTR</strong> 和 <strong>RSC</strong> 能不能共存？答案是可以的，并且完全不冲突，但这属于框架层面要解决的事情，不是协议本身的范围，但是我暂时还没实现，不过这个似乎可以去抄 <strong>TanStack Start</strong>，只要多发一个 <code>html</code> 一个 <code>js</code> 包，理论实现还是很容易的，至少都是清晰可见的工作量。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>再说回 <code>raw HTML slot</code> 这个解决主要还是 <code>Markdown</code>、<code>Rich Text</code> 这类场景，渲染出来的 <strong>HTML</strong> 通过 <code>dangerouslySetInnerHTML</code> 注入，水合后这块区域不参与交互，是 <strong>"死的"</strong>；这部分内容应该被最小化，你想给文章加边框、加样式，那些应该用 React 组件来写，而不是混在这段 HTML 里。这种做法的缺点是它不能在水合之后发生改变，但它确实把这种前提下的 "SSR" 成本压到了非常低，接近于 Zero Cost 了。在真正需要 SSR 的场景里，大概 60% 都是这种静态 HTML 注入的情况；剩下 40% 才需要 RSC 那种服务端执行任意组件的能力。</p>
<h2 id="ui-framework-agnostic">前端 UI 无关性</h2>
<p>最后就是很诱人的协议无关性，我们本质上只需要抓住 <code>renderToString</code> 这一个关键点，前面用什么 UI 框架其实和协议没有关系。那这样的话，和 <strong>Astro</strong> 有什么区别呢？不过放心，我肯定造的不是另外一个 <strong>Astro</strong> 表面上看我和 <strong>Astro</strong> 的岛屿概念有一点像，但实际上区别很大。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>我并没有在单个页面里水合多个运行时，Humm，其实我觉得你真正需要这个的场景非常有限，不同技术栈之间的组件状态通信会变得非常昂贵，它更像是一种过渡方案，从一个技术栈迁移到另一个，一次性换不完所以先过渡。其次，<strong>Astro</strong> 本质上是 <strong>MPA</strong>，而我们可以做到水合前是 <strong>MPA</strong>、水合后变成 <strong>SPA</strong>，就像 <strong>Next.js</strong> 一样有客户端路由，可以做跨页面动画，这是 <strong>Astro</strong> 求之不得的。</p>
<h2 id="astro-vs-ssg">Astro 与 SSG</h2>
<p>另外就是 Astro 的设计本来就是在跨多个技术栈的场景才真正有用，如果你用它是为了速度快的话，只用它的一个框架，比如只引入 <code>React</code> 不要 <code>Vue</code>，那它所说的 <strong><s>"速度快"</s></strong> 我觉得是一个伪命题，首屏加载出来都是 HTML 没错，但你想要任何交互它就要水合，水合的成本就是下载整个 <strong>React Runtime</strong>，这和我们的水合没有任何本质区别。当然，后续我们也可以走通 <strong>Island</strong> 的概念，添加一个 <strong>shell router</strong> 来实现<strong>跨 UI 框架的 SPA 导航</strong>，但这是后面的 Roadmap，至少我现在不急。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>最后就是传统 SSG 的对比，我做到了和 SSG 一样的事情，本质上就是把编译期能确定的全部渲染好；但我们更加<strong>动态</strong>，因为那些简单类型的 <code>slot</code> 值是完全可以在运行时替换的。<strong>你可以理解为，我们把 SSG 渲染成了一种 MPA 的入口，水合前是 MPA，水合后变成 SPA，同时保留了 "动态" 能力。</strong></p>
<h2 id="farewell-hydration-mismatch">也许告别水合错误?</h2>
<p>最后就是我很讨厌的 <strong>hydration mismatch</strong>，我相信你也不喜欢；但是归根结底这其实就是 <code>DOM</code> 状态不一致，而传统框架比如 <strong>Next.js</strong> 的 <strong>App Router</strong> 试图把整个应用都用 React 包裹起来，一旦用户侧有浏览器注入什么标记，就有概率出现水合错误。我们和传统 SSR 的限制其实是一样的，但是我在你用 TS 的时候也包裹了一个 叫做 <code>__root</code> 的水合 <code>div</code> 这样让水合区域不再覆盖 <strong>metadata</strong> 区域，换来了更加耐用的水合，另外 React 19 内置了对 <code>&#x3C;title></code>、<code>&#x3C;meta></code>、<code>&#x3C;link></code> 等文档元数据标签的支持。即使你水合的只是页面中的某个 <code>&#x3C;div></code>（而不是整个 <code>&#x3C;html></code>），在组件里直接渲染 <code>&#x3C;title>My Page&#x3C;/title></code>，React 也会自动把它提升 <strong>hoist</strong> 到 <code>&#x3C;head></code> 中去。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>回到 <strong>hydration mismatch</strong>，已知我们在编译期已经预知了每个 <code>slot</code> 的类型，所以可以多加一道 CTR 等价性检查，那就是用基于类型定义推导出来的 mock 数据填入完全展开的 HTML，再调用传统的 <code>renderToReadableStream()</code> 跑一遍，然后比对两者的 DOM-Tree 语意是否等价，不关心格式 fmt 差异，它们可能有格式上的细微差距，但只要 DOM 结构上严格完全等价<s>其实是可以做到告别</s> <strong><s>hydration mismatch</s></strong> <s>的</s>；为什么传统 SSR 做不到？因为它们是在运行时才做这件事，而 CTR 结构决定了你在编译期就要把这些约束全部落实到位。我们当然也有 <code>any</code> 的逃生入口，但是至少和 TypeScript 一样，你用了 <code>any</code> 就要主动承担后果，CLI 编译的时候会 warn 你，<code>any</code> 逃生 open string 可能会导致 mismatch 的。</p>
<h2 id="serverless">探索 Serverless</h2>
<p>当然还有 <strong>Serverless</strong> 的可能性也不能少，最近几年 <strong>Serverless</strong> 的体验可以说是非常好了，我评价为"除了费钱没啥缺点了"，然而 CTR 天然非常适合这个场景，我们运行时做的事情很轻很小，Serverless 跑起来会非常快，响应时间和开销都会得到明显提升。剩下提升不了也就是渲染 Markdown 那种场景，但那完全应该在业务逻辑上做优化比方说把 Markdown 预渲染好存起来，每次请求不用重新渲染，就像我现在这个网站做的一样。这是业务层面的问题，不是框架能解决的，但框架能帮你把除此之外的那些简单逻辑的开销压到几乎为零。和传统 SSR 对比，它不再是一个量级的开销，有多小呢，大概就是 几百 us ~ 1ms 之间的事情了，这是传统 SSR 想都不敢想的。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>那如果后端是其他语言怎么办，就拿 Cloudflare Workers 举例，其实很多 Serverless 都支持 WASM，其他语言编译成 WASM BIN 之后一样可以作为后端。本质上我就是把前端编译成纯静态资源，类似 CSR 一样，但这堆纯静态资源配合一个私有桥 <code>/_seam/</code>，就能实现和那些真正的全栈框架一样的动态能力，自然也完全兼容 Serverless 了。</p>
<h2 id="seamjs">SeamJS</h2>
<p>所以你是不是已经被 <strong>Seam</strong> 和 <strong>SeamJS</strong> 的关系绕晕了，其实这两很简单，Seam 是协议，它定义的是怎么用 <code>Sentinel</code> 标记动态位置、怎么转换成 <code>slot</code> 标记、怎么做条件块和循环块的 <code>diff</code> 检测、以及运行时怎么基于 <strong>AST</strong> 做数据注入。协议本身是语言无关的，任何能解析 <strong>HTML</strong> 注释、能做字符串替换的后端都能实现它。<strong>SeamJS</strong> 是框架，是基于这套协议做的一个具体实现 它把 <strong>Vite</strong>、<strong>TanStack Router</strong>、<strong>TanStack Query</strong> 这些轮子缝在一起，然后补它们覆盖不到的地方，比如我这套 <strong>skeleton</strong> 提取、注入引擎、<strong>CLI</strong> 之类的。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>但说实话，<strong>SeamJS</strong> 现在还是一个非常基础的样子。跑起来是没什么问题的，但真的拿它去写项目的话还需要打磨很久，我也在更多的思考一些架构上的改革，比如数据传输通道的抽象，但我并不确定在后续版本中还会不会坚持这个方向？框架层面上，我肯定会做出一个 TypeScript 的全栈框架，以及一个 Rust 作为后端的旗舰框架；至于 Go 相关的实习后续版本中移除掉，我觉得自己的精力维护不过来了。</p>
<pre>::github
repo = "canmi21/seam"
align = "left"</pre>
<h2 id="beyond-web">不只是 Web 框架</h2>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>那这个东西做出来有什么用呢？实际上它不只局限于 Web。比如说我把 <code>Transport</code> 通道也给抽象掉之后，是可以后续移植到类似于 <strong>Electron</strong> 或者 <strong>Tauri</strong> 之类的 Desktop 环境的只要把 <code>HTTP</code> 传输管线换成 <code>IPC</code> 通信，跑的还是一样的 Seam 协议，取而代之的就是那些 Electron 应用开屏的时候再也不用放 loading 了，很多东西能够直接像 SSR 一样本地渲染出来，这就是 CTR 的魔法！</p>
<h2 id="no-js-runtime-cost">No JS Runtime 的代价</h2>
<p>那为什么最后 SeamJS 还是加入了 JS Runtime 呢？因为在落实这套方案的时候我遇到了一个问题，那就是 CTR 的理想对首屏数据的约束是非常严格的，数据必须是一个可以完全推导出来的确定结构体，甚至不能有任何计算逻辑，只能是条件。这就变得非常苛刻了，可想而知的就是作为框架的开发体验来说，传统写 React 的人肯定是指望在组件里做一些计算、拿到几个值就能用，如果要严格按照 CTR 的约束来达到一个 <code>ready-to-display</code> 的状态，过程会非常繁琐，甚至出现一个组件写 2次。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>但是其实有解，因为 Web 原因，前端只能跑在 JavaScript 上，所以理所当然组件只能跑 JS，所以后端就必须要有 JS 执行能力才能解决这个问题，TypeScript 全栈项目本身的运行时就能用来解决；而 Rust 里面只需要嵌入一个 QuickJS 这样非常微小的 JS Runtime 就好了，注意这里的 JavaScript Runtime 是标准的 JS 子集，完全不是 Bun 或 Node 那种带完整操作系统 API 的运行时，真的就只是用来做推导、做 derive。</p>
<p><em>[Diagram: Rendering as a Protocol — view at https://canmi.net/architecture/compile-time-rendering]</em></p>
<p>这样就可以在组件里还是写一些计算逻辑，这部分逻辑会在后端拿到数据之后跑一段小 JS，把它推导为 <code>ready-to-display</code> 的状态，然后再发回给前端做首屏的水合。这样 CTR 满意了，拿到了严格结构的派生数据，用户 DX 开行了，组件只写一遍。当然如果你愿意严格遵守纯类型约束的话，后端确实可以做到 <strong>No JS Runtime</strong>，<s>这个承诺其实还是成立的</s>。但无论怎么说，新增的这个很小的 JavaScript Runtime，它的性能开销、内存占用和体积都远远比 Node 之类的东西小得多，基本上就是 200-300KB 的事情，却带来了非常大的灵活性。</p>
<h2 id="outlook">未来展望</h2>
<p>说了那么多，什么时候能吃上呢：大概吧，还要很久很久，真的不是我想鸽子，而是其实我觉得现阶段这个玩意绑架我太多太多了。我只能拿来开发的话，基本上应用写不了，写什么都被框架先绊脚，更何况我最近主要其实都在写这个网站什么的了。所以我想通了，不如网站先写上一个规模，这样就知道我的需求到底是什么；后面也许再来写 SeamJS 就有一个 TODO List 了，一个一个实现好这个功能就能用了，<s>后续还能再水一个 迁移 和 Benchmark</s> ？好了，大饼画完了，理念就在这，先别管今天能不能用，至少思路上原型已经跑通了，晚安 💤</p>]]>
    </content>
    <id>https://canmi.net/architecture/compile-time-rendering</id>
    <link href="https://canmi.net/architecture/compile-time-rendering"/>
    <published>2026-04-13T19:18:28.488Z</published>
    <summary>从 SSR、SSG、ISR、RSC 的渲染谱系出发，提出 CTR (Compile-Time Rendering) 与 Seam 协议——把组件结构和数据视为两个独立发布物、运行时拼接。围绕 JTD、Sentinel、SeamJS 讨论把 UI 当协议而非可执行代码的可能。</summary>
    <title>Rendering as a Protocol</title>
    <updated>2026-04-14T01:54:50.646Z</updated>
  </entry>
  <entry xml:lang="zh">
    <content type="html">
      <![CDATA[<p>我喜欢 Rust 已经很久了，它的优点很明显，同时缺点也很明显。内存 Safe 无需 GC、零成本抽象、并发安全、几乎现阶段最好的工具链和配套外围建设、超强的覆盖面(上到 Web 云服务器，下到单片机)，还有错误处理设计非常好。缺点嘛，学习陡峭？难以理解 Ownship，Borrow 之类的模型约束；写起来很慢？</p>
<p>长久以来，我们都认为这是 Rust 的缺点，但是实际上前两者都是可以通过锻炼思维和认知来解决的，而编译速度慢却一直拖慢着我的工作流，这一切在早几年似乎很好，AI 席卷而来之前我可有的就是耐心，常常一个算法一个功能写到凌晨，可，现在呢？我很难说我没有变，我开始变得一点都没有耐心了，一点都不想思考 Rust 代码怎么写，一点都等不了 Cargo 那个漫长的进度条，尤其是 ld 部分还没有进度，每次就干卡着，这真的很难受。</p>
<p>这一起难道就没有办法嘛？其实是有的，在大约一个月前，我尝试了一些办法让我的 Rust 开发流程更快，包括工作流优化之类的但是其实每次等的时间都卡在 Cargo 上，如果不能解决这个就不治本，那正好借这个机会来稍微说一下关于 Rust 开发中 Cargo.toml 配置调优相关的内容和配套外围配置吧。</p>
<h2 id="backend-theory">后端理论</h2>
<p>Rust 设计中，LLVM 其实有点偷懒，但也选的很好，不像 Go 一样自己维护 To 机器码的过程让 LLVM 大生态来做成熟的优化其实很好。整个 Rust 的编译流程大致可以说是源码跑一边词法分析、语法分析生成 AST，再经过宏展开、HIR 降级拍平！然后进入常规类型检查、亿点点借用检查，最终生成 MIR 了到这里为止，都是 Rust 编译器自己的前端在干活。而 MIR 之后的事情，就交给了 LLVM，比如把 MIR 翻译成 LLVM IR，然后 LLVM 跑一遍它那套优化 pass（从 O0 ～ O3 不等），最后生成目标平台的机器码，再交给链接器拼成 BIN 可执行文件。</p>
<p><em>[Diagram: 不许 Cargo 再摸鱼了 — view at https://canmi.net/development/rust-cargo-cranelift-tuning]</em></p>
<p>那么到底问题出在哪呢，答案其实已经很简单了，LLVM 是一个很重的基础设施，重在生产环境下是对的，编译时间换执行时间是很好的哲学，它桥接了上层 Rust，还有 C、C++、Swift 等一大堆语言。这意味着它的优化管线是为"生成最优机器码"设计的，而不是为"快速完成编译"设计的。那几十个优化 pass 在 release 模式下跑一遍是非常很耗时的，即便是 debug 模式下的 O0，LLVM 依然要走完 IR 生成和机器码生成的全流程，而这个流程本身就不轻量。再加上 Rust 的泛型单态化会在编译期展开大量代码，交给 LLVM 的 IR 体量远比你源码看起来的要大得多，LLVM 要处理的工作量自然也随之膨胀。</p>
<h2 id="cranelift">Cranelift</h2>
<p>所以本质上，编译慢的很大一部分时间不是花在 Rust 自己的前端而是花在了 LLVM 这个"重型后端"上。那么问题来了，有没有可能丢掉它嘛，<s>答案是有的喵</s>，有一个叫做 Cranelift 的后端就是一个为了这个需求做的。它最早是 Cretonne，2016 年启动，由 <a href="https://bytecodealliance.org">Bytecode Alliance</a> 开发，最初是为 <a href="https://github.com/bytecodealliance/wasmtime">Wasmtime</a> 包的饺子，才设计的代码生成后端；后被 Rust 官方收编作为可选的 codegen 后端。</p>
<p><a href="https://github.com/bytecodealliance/wasmtime/tree/main/cranelift">A low-level retargetable code generator.</a></p>
<p>那所以 Cranelift 到底应该快在哪呢，最好的阶段就是开发时期，这个时候也许你根本不需要接近完美的、极限优化执行效率的机器码，也许我们只需要一个快速的反馈而已，只要这个不是那么好的机器码和 LLVM 正经算出来的机器码语意等价就行了，早期的 Cranelift 其实做不到，因为有很多 Edge Case，虽然现在还是有，但是已经改善了很多了，现在出现 Cranelift 可以跑但是 LLVM 坏掉的概率差不多和你写 rust 触发 rustc ICE 差不多了。LLVM 为了生成极致优化的机器码，会跑几十个优化 pass，比如循环展开、向量化、常量传播、消除死代码之类的…… 很多很多，一层一层磨，而 Cranelift 的设计哲学完全不同，它大幅削减了这些优化步骤，只做最基本的寄存器分配和指令选择，用一趟线性扫描就完成代码生成，不反复迭代。同时它的 IR 设计也更轻量，是专门为快速从上层 IR 翻译到机器码设计的，不像 LLVM IR 那样承载着几十年的通用化包袱。</p>
<p><em>[Diagram: 不许 Cargo 再摸鱼了 — view at https://canmi.net/development/rust-cargo-cranelift-tuning]</em></p>
<p>那么好处说完了，代价呢？首先显而易见的是 Cranelift 生成的代码运行时性能比 LLVM 差，根据场景不同大概会慢 10%~30%。但想开了其实就会发现，这在开发阶段根本不重要，我要那么快干嘛，你是跑分还是 CI release 啊，我就 tm 要跑 cargo build 快点，看看逻辑出没出来，而且我打赌，你写的大部分程序不可能 80% 以上的时间占满计算资源，肯定是要有业务来的，你开发的时候业务能有多大？cpu 怕不是全程都在摸鱼呢，反倒是我要看效果、验证逻辑。那编译时间换运行时效率，这笔账在开发循环里是不是应该倒过来算嘛。</p>
<h2 id="codegen-units">代码生成单元</h2>
<p>另外说说 <code>codegen-units</code>，这其实这就是一个控制编译器把 crate 拆成多少个最小单元给后端并行处理的参数，默认情况下，debug 模式是 256 个，release 模式是 16 个。数字越大，并行度越高，编译越快——因为可以同时利用多个 CPU 核心来跑后端代码生成。但代价是优化效果变差，因为每个单元是独立优化的，LLVM（or Cranelift）看到的上下文变小了，跨单元的内联和优化机会就少了。</p>
<p><em>[Diagram: 不许 Cargo 再摸鱼了 — view at https://canmi.net/development/rust-cargo-cranelift-tuning]</em></p>
<p>但是在真实的 Release 下，一般来说都应该使用 <code>codegen-units = 1</code> 这样另一个极端的参数，只有这样才能让产物得到最大化的优化，毕竟你都用 Rust 了，编译时间换运行时性能难道不是理所当然的嘛。</p>
<h2 id="opt-level">优化等级</h2>
<p>还有一个进程被忽略但是可以调的配置就是 <code>opt-level</code>，默认情况下应该是 "3"，而作为 Release，更常见的做法是开到 "z", 这就是在不修改代码的前提下，可以获得的免费产物体积优化，为什么不开呢；但是呢，开发模式下这里应该配置为 "0"，完全不优化才能获得最快的速度。</p>
<p>另外还有一个小技巧就是 <code>Cargo.toml</code> 其实可以给依赖和自己的代码设置不同的优化等级：</p>
<pre><code>[profile.dev.package."*"]
opt-level = 3</code></pre>
<p>这样就可以给第三方依赖用 O3 编译，这样只会增加第一次冷编译的时间，后续就是增量编译的。外部依赖一般不会频繁更新，这里用 O3 自己业务代码用 O0 可以获得一般情况下权衡速度和体积的良好 dev 模式体验，但是 Release 模式下我还是建议 O3 或者 Z 拉满没啥好说的。</p>
<h2 id="link-time-optimization">链接段优化</h2>
<p>LTO（Link-Time Optimization）是另外一个很吃内存和编译性能的东西，而且这是大部分项目 Release 时候都推荐开的，正常编译时，每个 crate 是独立优化的，编译后端看不到 crate 之间的调用关系，某些跨 crate 的内联和死代码消除做不到，而这种情况的占比非常多；LTO 就是把这个边界打破，让优化器在链接时拿到所有 crate 的 IR，做一次全局优化。但是千万不要无脑 profile 上直接挂 <code>lto = true</code> 这样会让你的 Rust 编译慢到怀疑人生，好好检查一下使用 release profile 的时候挂上就行了。</p>
<h2 id="strip-debug-symbols">干掉调试符号</h2>
<p>和其他东西一样 Rust 的 debuginfo 和 symbols 就是存在于这里的，release 模式开启 <code>strip</code> 可减少非常可观的体积（<s>通常是50MB vs 5MB</s>）这样的逆天区别，除掉 symbols 也更加安全，毕竟大部分时候前端 source code 都是从 map 里面不小心 push 上 npm 泄漏的 :(</p>
<p>所以 dev profile 下没什么好说的，你总不能 strip 掉 debuginfo 吧，那样你就没法用 <code>gdb</code> / <code>lldb</code> 正常调试了，backtrace 里也看不到有意义的函数名。但是注意，ArchLinux 打包的时候会默认帮助你 strip，但是这里就会和我们有一点小摩擦，那就是我们已经 strip 掉了，后面可能会报错。这个如果写 AUR 的时候可以注意一下显式跳过这一步。</p>
<p><em>[Diagram: 不许 Cargo 再摸鱼了 — view at https://canmi.net/development/rust-cargo-cranelift-tuning]</em></p>
<h2 id="abort-on-panic">放弃 panic!</h2>
<p>在正常的业务中，<code>panic</code> 应该是不能存在的，正常业务代码 runtime 遇到错误的时候，但凡是设计了容错的 Err 都应该被安全的返回，而不是暴力 panic 掉，就和 React ErrorBoundary 一样，应该被正常当作错误处理，panic 只有在代码坚信进入了不可能的状态，且无法挽回的时候才应该触发。我个人的哲学是放弃 panic，为什么呢？因为实际上业务代码可以作为 3层测试，第一层 happy path，第二层错误路径（无限种可能中的某一种），第三层才是 fuzz or 碰撞测试穷举出来的 Edge Case。工程学上来说，正确路径可以做到 100% 测试，错误路径可以一类 n 种变体中取 1种；碰撞测试纯粹是时间问题，大部分时候不值得，等你用户体量真的上来了，要做的时候你自己就会知道，通常我一律不做。</p>
<p>那么对于好的代码来说，就应该覆盖 1类正确路径的测试 和 至少一种错误路径的测试，而这一类的错误路径只要覆盖一个就可以测试被 Err 返回的情况，就可以写 fallback 逻辑，就自然的可以 <code>thiserror</code> 枚举 or <code>anyhow</code> 拦截拍平打印记录。总之就是你的代码会迫使你写出处理这些情况的机制，如果做到了这一层的话，<code>panic</code> 对你来说就没用了，可以理解为"理论上不可能"，但是仅仅也是理论上，实际世界里面还有 OS 错误，内存错误，宇宙射线单电子 bit 反转，奇奇怪怪的溢出... 你永远不可能覆盖满这种情况，那为什么不要 panic 呢，那是因为这些情况其实都大概率，90% 的概率不是你的代码问题，<code>panic</code> 的本质就是发生时，Rust 会沿着调用栈一层层往回走，逐个调用每一帧里的析构函数（drop），清理资源。这个过程需要编译器生成额外的 <code>unwind</code> 表，就是给你找出你业务逻辑错误的地方，如果错误本身都大概率不是你的，这个信息有什么价值，存在还会增加编译产物的体积，也会给链接器增加工作量，关掉才是正解；你的代码测试到位了，足够自信建议配置 <code>panic = abort</code>。</p>
<h2 id="benchmark">实际测试</h2>
<p>那么多说无益，实际来看看这一些组合下来的提升吧，这里拿一个我前几个月做的小玩具项目做为参考</p>
<pre><code>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 CSS                       5          152          113           16           23
 Go                       77        14556        12409          584         1563
 JavaScript               23         2506         1999          232          275
 JSON                     93         1780         1780            0            0
 Just                      1          408          275           71           62
 Makefile                  1           15            8            3            4
 Shell                    16         1440         1075          166          199
 SVG                       1            9            9            0            0
 TOML                     20          502          419            3           80
 TSX                      75         3257         2718          132          407
 TypeScript              390        36643        30350         1627         4666
─────────────────────────────────────────────────────────────────────────────────
 HTML                      2           41           32            7            2
 |- CSS                    1           37           37            0            0
 (Total)                               78           69            7            2
─────────────────────────────────────────────────────────────────────────────────
 Markdown                 95         5344            0         3578         1766
 |- BASH                   3            8            8            0            0
 |- Go                     2           21           21            0            0
 |- HTML                   1           25           15           10            0
 |- JSON                   6          317          317            0            0
 |- Rust                   1           10            9            0            1
 |- TSX                    1           11            9            0            2
 (Total)                             5736          379         3588         1769
─────────────────────────────────────────────────────────────────────────────────
 Rust                    181        31654        26951         1032         3671
 |- Markdown              86          581            0          564           17
 (Total)                            32235        26951         1596         3688
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                   980        99317        78554         8025        12738
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</code></pre>
<p>这个项目里面 Rust 的部分差不多有 32K SCoL Rust，不包含依赖体积。</p>
<pre>::github
repo = "canmi21/seam"
align = "left"
ref = "a7f34cb47df324c84de7725296c9ef539e05b791"</pre>
<p>这个仓库实际上是一个有很多子项目的 monorepo，但是其中可以找出2个典型的例子，首先看看 Skeleton 这个包特点就很明显，代码量大，依赖极少，这个包会是整个项目里面 codegen 占比最高的，这个就会很适合 Cranelift 发挥，另外就是这个包也很干净，干净指的是纯 Safe Rust。</p>
<pre>::cargo
crate = "seam-skeleton"</pre>
<p>反观下面这个 CLI 包就不怎么合适，依赖多倒不是什么问题，理论上这样更能体现效率，但是这里有一个经典的二选一，可以在左上角看到 <code>ring</code> 这个依赖标记为可选了；那是因为项目本来用的是 <code>aws-lc-rs</code>，这两玩意都是 Rust 中的加密算法 Backend；但是为什么要可选呢，那是因为 <code>ring</code> 是纯 Rust 写的，而另外一个是 Asm 汇编 FFI 进来的，专为 x86 arm64 等主流 cpu 架构汇编加速，但是败也在这里，因为 Cranelift 的魔法只局限于纯 Rust，一旦你引入 Unsafe Code，or FFI C、ASM 之类的，此时实际上 Cranelift 兼容性极差。</p>
<pre>::cargo
crate = "seam-cli"</pre>
<p>但是这个也不是无解，其实可以用 cfg 选后端，Cranelift 模式下给 <code>ring</code> 来编译就好了。在按照上述描述配置好 dev 和 release profile 后，就可以简单跑一下 CLI 的编译对比，在这个情况下，开发至少比发布快 3倍，这还都是冷编译，没有增量的情况下；并且使用 O0 和关闭 LTO，dev profile 在后续的增量更新中都应该比 release 快几十倍不止。因为 LTO 之类的魔法实际上是通过拍平各个 crate 的边界得来的，那么都变成一个整体后，修改一处代码，当前一起都要重新编译不能被正确增量更新。</p>
<p><img src="https://cdn.canmi.net/image/44b6081deaf0242ca3bf83d62a3b6c95e59f61b36451640b8544ccd60c8fc78d.png" alt="" /></p>
<p><img src="https://cdn.canmi.net/image/51c50b801dfb31ac8a4bfb87c9207674c31b82d9bdbeae78a9da2612815a9d31.png" alt="" /></p>
<p>综合结果看下来这两玩意其实差不不大，谈不上质变但是绝对能明显感知，主要原因是 Apple Silicon 太猛了，M 系列芯片的单核性能、内存带宽都强的离谱，编译这种重计算+重 I/O 的任务刚好吃到这些优势。如果用 Linux 跑跑通常来说差距会更大，但是如果一台 macbook 刷 Asahi Linux 之类的肯定是 Linux 赢。</p>
<h2 id="legacy-linker">老旧的链接器</h2>
<p>在 Linux 上，Rust 默认用的是 GNU ld（bfd），对，就是那个最老最慢的那个。单线程并且处理符号解析和重定位都是串行扫描，项目一大就明显拖后腿了，Linux 上可以试试 <a href="https://github.com/rui314">@Rui Ueyama</a> 写的 <a href="https://github.com/rui314/mold">mold</a> 配置起来很简单<code>.cargo/config.toml</code> 里面加一行的事情。</p>
<pre><code>[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]</code></pre>
<p><img src="https://cdn.canmi.net/image/95afe816370ff0e755029a432aba63f02ff5ad67fab4e64bc4e95a9ed5db05db.png" alt="" /></p>
<p>提升还是很明显的，但是很遗憾 macOS 上只有 sold 这个商业项目，但是好消息是 macOS 上的 lld or Apple 自带的 ld64 本身已经不慢了，尤其是 Xcode 15 之后那个新链接器，速度提升超级明显，不过这个玩意唯一的不好就是 macOS 无人值守 CI 和更新后每次都要 sudo Accept 一个条款，导致我好几次 CI 定时任务挂掉。</p>
<h2 id="musl">MUSL</h2>
<p>我本人是一个 MUSL 厨，非常讨厌 GNU glibc，但是开发我还是推荐你使用 <code>x86_64-unknown-linux-gnu</code> 为什么呢，因为快，MUSL 最大的卖点是能生成完全静态链接的二进制——不依赖系统上的任何动态库，编译出来的东西拷到任何 Linux 机器上就能跑，特别适合容器和嵌入式，但是慢也慢在静态链接，因为链接器需要把所有东西都打包进去，链接阶段的工作量比动态链接大不少。</p>
<p>不过好消息是如果你用 macOS 的话，那就完全不用担心了，<code>aarch64-apple-darwin</code> 就是目前唯一的选择了，macOS 从设计上就不支持完全静态链接从编译速度的角度来说，这反而是好事。所以 dev profile 在 linux 上 gnulibc，macOS 上 libSystem，release profile 在 macOS 和 linux 上都可以 musl，因为 macOS 默认选择 linker 和 ar 的问题，唯一值得注意的就是可能需要配置一下 <code>.cargo/config.toml</code></p>
<pre><code>[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
ar = "x86_64-linux-musl-ar"</code></pre>
<p>顺便就是 zig 千万不要用，nightly rust 上的 zbuild 目前问题很大，需要交叉编译的时候，host 是 linux x86 推荐 <a href="https://github.com/cross-rs/cross">cross</a> 那个配 docker 来编译的就很好，环境全齐，如果你喜欢 glibc 这个也很好用，因为 glibc 只能向后兼容一般的参考都是 debian 发行大版本 -2 的 debian 上 glibc 的版本来编译，否则太新的 glibc 会让一大堆机器跑不动，这里并不是因为用了新 feature，而就是单纯判断一个字符串的版本就来气你。host 是 mac 请使用 rustup 原生 <code>target + cargo build --release </code></p>
<h2 id="upx-considered-harmful">UPX 是个坏东西</h2>
<p>还有一个值得讨论的点就是关于 <a href="https://github.com/upx/upx">UPX</a> 的使用；我知道我说我喜欢 musl，但是又追求小体积实际上很冲突，听起来没道理...</p>
<pre><code>                 ooooo     ooo  ooooooooo.  ooooooo  ooooo
                 `888'     `8'  `888   `Y88. `8888    d8'
                  888       8    888   .d88'   Y888..8P
                  888       8    888ooo88P'     `8888'
                  888       8    888           .8PY888.
                  `88.    .8'    888          d8'  `888b
                    `YbodP'     o888o       o888o  o88888o


                    The Ultimate Packer for eXecutables
   Copyright (c) 1996-2026 Markus Oberhumer, Laszlo Molnar &amp; John Reiser
                           https://upx.github.io</code></pre>
<p>但是实际上 upx 的存在让这个问题得到了解决，UPX 的原理实际上是把你的 BIN 压缩后包上一个解压 stub，运行时先在内存里解压再执行，这样就可以把二进制体积压缩到原来的 30%~50% 左右这样体积优势就会非常明显，但也正是因为这样，UPX + GNU glibc 动态链接就会有大问题，因为 glibc 动态链接的二进制里有一些特殊的 section 和动态加载机制，UPX 压缩之后可能会破坏这些结构，导致运行时找不到动态库 <code>segment fault</code>；但是反观 musl，这个就非常适合 UPX，本来就是全静态，内部稳定，UPX 后解压和压缩都很干净。所以 musl 的 release CI 其实可以多加一步 UPX 是非常加分的。</p>
<p>另外就是 UPX 也不能滥用，UPX 只适合长任务 or 低频任务，长任务是因为 UPX 启动的时候需要内存解压一次，这个过程虽然是全自动的但是需要一点冷启动时间，这个影响很少，但是在高频路径上还是很致命。所以长任务启动一次后台挂就很适合这个，但是我并不推荐无脑给 docker 镜像这种长服务的包塞 UPX，因为实际上这是内存换磁盘空间的行为，属于血亏，而且容器 Layer 的压缩实际和 UPX 是一样的，减少了分发的时候下载体积。反而推荐的环境是某些 CLI 的用途；节省超多磁盘空间并且启动解压时间几乎无感，而且 musl 天然也适合做 CLI，不然我就要点名批评 AUR 助手了，go 写的那个玩意但是没有静态链，导致我曾经遇到过 ArchLinux 上 yay 坏掉了，找不到动态库，本身又是系统管理器，无解只能另外 LiveISO 启动最后 chroot 救砖。</p>
<h2 id="summary">总结</h2>
<p>最后这些好用的配置虽然可以帮助你快速区分 dev 和 release profile，在项目代码上来后只会越来越明显，但是我还是建议你多写一个 CI 来跑 Rust Stable LLVM Build，最好再加上你的各种测试一起跑，这样本地 nightly 的 rust 也不用切换了，Edge Case 也坑不到你，push 上去有问题就被 CI 拦了，这样就是目前最舒服的 DX 模式了；那这次就说到这里了，下次有空再摸吧</p>]]>
    </content>
    <id>https://canmi.net/development/rust-cargo-cranelift-tuning</id>
    <link href="https://canmi.net/development/rust-cargo-cranelift-tuning"/>
    <published>2026-04-05T14:48:43.726Z</published>
    <summary>从 Rust 编译流程谈起，逐项介绍 Cargo.toml 与链接器层面的编译速度与产物体积调优手段——Cranelift 后端、codegen-units、opt-level、LTO、strip、panic=abort、lld 与 MUSL，附实测对比。</summary>
    <title>不许 Cargo 再摸鱼了</title>
    <updated>2026-04-07T06:44:02.055Z</updated>
  </entry>
  <entry xml:lang="zh">
    <content type="html">
      <![CDATA[<p>我是一个很宅的人，也不知道从什么时候起，「出门」对我来说已经是一个很陌生的词汇了。我常常一个月只在必要的时候才出门，即使出门也是两点一线的生活，还常常坐车。仔细想想，我已经很久没有真正离开过室内了。在经历了 AI 时代的泡沫和焦虑后，我染上了一个坏习惯——常常凌晨写代码，似乎已经和生活的世界不在一个"时区"了。今天早上也是挨到了十二点多才起床，一天只吃两餐，醒来就坐到书房，打开电脑开始写。写代码本身是一件很好玩的事情，但 AI 时代的写代码已经不是了。盯着屏幕盯到下午，什么都写不出来，没有设计灵感，觉得一切都不完美。</p>
<h2 id="outside">外面？</h2>
<p>这个时候，脑子里突然冒出一个久违的念头「外面」。不是什么具体的地方，就是一种很模糊的感觉，好像记忆里有过那么一种东西：阳光、风、草地，但又说不清楚了。说不清楚这件事本身让我有点在意。正好今天天气也不错，Claude 也到了 Session Limit，于是我合上笔记本，带着耳机，随手抓了一瓶水就出了门。对，我带着 MacBook Air 出去了。想的大概就是换个地方接着写，也许还能找点灵感？</p>
<p>出了小区大门，沿着街道走到主路的路口，两栋大楼之间的风突然灌进来。我站在那里愣了一下，「风」!? 多久没有感受过这种东西了；不是空调的风，不是电扇的风，是那种没有规律的、带着温度又些许柔和的、从很远的地方吹过来的风，有声音的风；伴随着城市街道嘈杂的风。当时我戴着 AirPods 听着歌户外的感觉竟然让我下意识的关掉了降噪，我就这样左手拿水，右手夹着笔记本，沿着主路往江边走。这次出门我没有盯着手机屏幕看，也不知道为什么，就是想先走到江边再说。</p>
<h2 id="unseen">看不见</h2>
<p>走了大概一两百米就到了江边。人行道旁边的石栏杆有一点宽度，刚好能放下一台笔记本。我其实不是真的想在这里写代码——就是到了江边嘛，很久没出来了，先拿手机拍了张照。拍完发现好像户外的感觉好像还行于是顺手把 MacBook 掏出来架在栏杆上，解锁屏幕后就是一堆编辑器和终端。不知道为什么，我当时就想发一条推，大概就是那种有人在雪山上、在钓鱼的时候打开 MacBook，屏幕上全是 IDE 和 Terminal，然后配一句 "what can stop you coding like this"，但是我很快放弃了，因为 25款 MBA 的 LCD 屏幕激发亮度真的太低了...</p>
<p><img src="https://cdn.canmi.net/image/f5039ce164385888c22f4eb23c717653a9f4a23bfd2eac2aace2b299c67ba496.png" alt="" /></p>
<p>打开屏幕的那一刻我就知道完了。果果的屏幕亮度在户外根本不够看。稍微尝试了一下各种角度，背光、顺光，甚至把自己调整到和太阳在同一条线上——怎么调都看不清。挺可怜的，说实话。在室内觉得自己还是个程序员，结果到了户外发现连屏幕都点不亮。算了，这代码不写也罢，但是我还是不死心，合上笔记本，决定去马路对面的树荫里碰碰运气。离开栏杆之前，我又往江面看了一眼。远看其实还行，水面在多云伴随阳光的天气下看起来还是那种记忆里的样子。但稍稍低头把视线下压一点看向岸边看，就能发现水面上浮着一层东西——水藻、树叶、零零碎碎的漂浮物。不多，但江水已经不清了，水面比记忆中的静，但岸边很显然已经没人搭理了，「早几年疫情的时候我其实就出来看过，自那时候起其实就这样了」<s>但是每次走过还是有些许感慨</s>。</p>
<p><img src="https://cdn.canmi.net/image/138bdfd35bf2fade8f94d01f5e9282ac05e800aece1dd1824c5d3f0c4fd93185.png" alt="" /></p>
<h2 id="first-memories">回忆初</h2>
<p>走过马路，对面的绿化带里有一小片铺了石砖的地方，就在街道和广场的拐角处；远看着像个还不错的休息区只是远远就看见不知什么时候顶已经被拆没了，走近了发现更不对了，早就已经不是那么回事。这地方我小时候来过，但现在棚子的顶已经拆没了，只剩下几根混泥土柱子立在那里，墙壁开裂了，地上的石板旁其实有一点树，但是低下早已围了一圈落叶，没人扫也没人来。能坐的地方倒是还有，只是石板上蒙着一层灰，看着就不太想碰。我还是随便找了点东西垫着，挑了一块看起来最干净的坐下来，又打开了电脑还是想写，也许这也是 AI 时代的好处吧，Vibe coding 的时候其实并不需要真正坐下来好好写了，就语音输入法 Typeless、Wispr Flow 就行了，有个 CLI 里面的 Agent 帮你写就完了，我已经懒到不想打一个字了，我也早丢了保守派的底线，「bypass permissions」就好了，写代码也开始三心二用了。</p>
<p><a href="https://www.typeless.com">Typeless</a></p>
<p>扯远了，但是还是不行，MBA 的 LCD 屏幕终于第一次让我感受到了硬伤，到这里我就彻底放弃在户外写代码这件事了，也许命明年的我就会放弃轻薄的重量了，也许下一台如果还是笔记本的话就会是 MBP 了。到这里其实我已经打算回去写了，偶尔出来透透气就够了，所以走都走出来了，那就沿江马路换一边走回去吧。但走着走着，就有点不想回去了。不是有什么目的地，就是一种很久没体验过的东西，室外本身好像对我还能有一点吸引力，我也说不上来的那种。可当我真的试着去感受的时候，又觉得周围的一切和记忆中的已经脱节了，哪哪都不对，早就已经和模糊的记忆对不上了...</p>
<h2 id="forest-path">林中小道</h2>
<p>沿着江边的马路换了一边往回走，拐进了一条靠近居民楼的小道。说是林间小道，其实算不上，树种得稀稀拉拉的，旁边隔墙就是楼。小道是柏油路，岔路口有一小段带亭子的木栈道拼起来的，柏油的那段已经开裂了，裂缝里长出了草，路面上散着小树枝和落叶。同一段路上能看出两种颜色，一半明显翻新过，但那个"新"看起来也至少是三五年前的事了，接缝处照样顽强地钻出了几棵小草？应该不算「棵」了，是几排杂草。</p>
<p><img src="https://cdn.canmi.net/image/16df52dd297ec28bc167615fef7020835d78a97395414562a8915721155ef2ba.png" alt="" /></p>
<p>走进木栈的亭子前面停了下来。亭子的地板是木头铺的，有几块已经缺了，踩上去的木板会翘，发出那种让人不太放心的声响；钉子大概早就断在里面了。木道的顶是透明的，可能是玻璃也可能是亚克力？但上面一大半早就已经积满了东西，不太透光了。周围本来是草坪，或者说是大约一米的灌木丛，但是现在已经长到快两米高，站在里居然真有一种身处丛林的错觉——如果不是视线还能瞟见楼房的话。靠近主路那一侧的植被还能看出曾经被修剪成方形的痕迹，但边缘已经长圆了，时间让它慢慢回到了一棵植物本来该长成的样子。</p>
<p><img src="https://cdn.canmi.net/image/e9722e16bc3c1f55a63d34fd738dd15823c7d8e1806d62ddb0f9c2aa1c59d108.png" alt="" /></p>
<p>我蹲下来，在缺失的木板中间长出的那丛草上摸了一下。看着和普通的草没什么区别，但摸上去是干的，土也不太好的样子，只是它还是从木板的缝隙里长出来了。这里平时大概也不会有什么人来吧... 其实走在这些地方的时候大脑早就放空了，没什么具体的念头，只是觉得过了很久很久，那些平时怎么都想不起来的东西和细节，模糊的记忆好像走到那个地方就会自己冒出来一样，变得无比清晰。然后你就会感慨，这里变了，那里也变了。记忆中的那个地方早就已经不存在了，时间带走了这一切。</p>
<p><img src="https://cdn.canmi.net/image/4e0ef9dbeab966ec70deae669df9cbc0dc0fd1f20f409619a4a6accd4ff1637a.png" alt="" /></p>
<h2 id="inside-the-city">城中</h2>
<p>从小道走出来，已经离开了江边街道的区域，沿着一段施工工地继续往前走。城市的楼房我早就看腻了，尤其是这种二三线城市的居民楼，密密麻麻的，但是又没有高楼大厦的感觉，毫无美感。所以我刻意不去拍它们，我想拍点天，但我发现这件事比想象中难。站在人行道上，把手机举得很高，避开街道、避开路灯，镜头里还是会贴进来一点树影和对面的居民楼。它们建得太密了，你很难在这座城市里找到一块干净的天空。今天的天气其实很舒服。有阳光，但不算很大，更像是多云吧，抬头看 云并不密，稀稀疏疏地散在那里，有一种烟雾缭绕的感觉，但又留出了大片大片的净空。云很好看。就是那种你平时在「室内」绝对不会注意到的好看，但是也不是那种日漫里面的喜欢的天，「那种东西大概现实的日本里面也不存在吧」</p>
<p><img src="https://cdn.canmi.net/image/ac09fccc9ce2c5395e009cb9dffbbf5506a9d420386a48b3183d5ca2fc000809.png" alt="" /></p>
<p>再往前走，就走进了一片施工工地。说是工地，其实上面已经没有工人了。这片区域两极分化得很严重，一半的楼已经建好，甚至开始招商，有了租户，另一半只剩下骨架，围栏拆了一半，剩下的围着，但看样子也没人打算继续动工了。这几年中国的房产行情不太好，房价一直在跌，大概这片楼盘建好之前，价格就已经跌得比施工进度还快了。建筑商要么跑了，要么就是耗在那里不动了。但也正因为这样，没铺路的那一段被围栏挡着，平时没什么人走进来，反而留下了一大片草地。这是我今天出门以来第一次看到大面积的草。远看的时候心里还有一点点好心情，这才是记忆里该有的样子，但是走近了又不对。草不绿了，夹杂着很多枯黄的叶子，不知道从哪里吹来的，因为附近根本没有树。草本身也长得歪歪扭扭的，摸上去又硬又干；但是终究我还是躺了下去。</p>
<p><img src="https://cdn.canmi.net/image/7062c3ee86a608b77b92fcfeef02075bc3895893cd8b09bb691e96c87eebae21.png" alt="" /></p>
<p>躺下的那一刻就知道不行。地硬得像水泥，只是表面铺了一层草而已，完全没有缓冲。正常躺草地你不会希望太湿，但也不该这么干、这么硬。土已经彻底板结了，隔几米看和正常草坪没什么区别，但身体不会骗你。甚至连蚊虫都没有<s>大概连虫子都不愿意来这种地方了</s> 躺在那里，闭上眼，听到的也不是什么自然的声音。全是城市的喧嚣,燃油车的引擎声，虽然这几年电车多了不少但马路上还是躲不开；远处行人走路的脚步声；还有就是这种小城市的车似乎都不太守规矩，时不时就传来一声喇叭，突兀又刺耳。</p>
<h2 id="city-noise">城市喧嚣</h2>
<p>睁开眼，噪音还是噪音。抱着笔记本躺了一会儿也累了，随手放到旁边，水也快喝完了，最后一点倒掉扔进了垃圾桶随手丢了，这一路上手机收到了很多条消息，但我一直没怎么看，拍完照就塞回口袋了。现在觉得是时候看一眼了，但是好几个月前换的 17P 用的已经是 eSIM 了，最近也换了 CMHK 和 3HK 的卡漫游到大陆只有 LTE，连 5G 的影子都没有，苹果的信号又出奇的烂。</p>
<p><img src="https://cdn.canmi.net/image/59f1b40d65fe4b4ac5d48dd5837d59fed6ab54f43f134da9daa1db4001babbb6.png" alt="" /></p>
<p>明明在市区，信号就两格，推特刷不出来，音乐也卡了，加载不动，干脆摘了 Air Pods 放回盒子不听了。通知中心倒是能看到有消息，但点进去什么都加载不出来，这下手机也没得玩了。今天出门，笔记本屏幕不够亮有电却写不了代码；手机有信号但刷不出内容，仿佛已经在互联网时代短暂的断连了。电子产品在户外一个比一个不争气。那就回去吧。</p>
<p><img src="https://cdn.canmi.net/image/57e8e4c5cd8abdf69a9f407acd497582a87f1b37cfb5e2c34870d4f10967d9ca.png" alt="" /></p>
<p>回去的时候走了一条完全不同的路，从小区后门绕进去，经过地下车库。小时候经常在车库里玩，虽然后来搬过一次家，不知怎么又搬回来了。车库是我成长记忆里唯一不常去的地方，样子变没变我看不出来，但我记得它的味道。小时候车库里有一种很清新的气息，有点像薄荷，浓浓的但没有香气，只剩下一个"清"字可以形容，再夹着一点地下室特有的阴凉。我其实还挺喜欢那个味道的。但是现在走进去，那股清气没了。只剩下阴冷的湿气，闷闷地贴在空气里。车开过来的时候带起一点风，大概是从入口灌进来的，但那个风也不对，什么都不对，哪哪都不对了，我已经想想不出来那个样子了，但是绝对就是你在那就能感受到的不同。</p>
<h2 id="one-hour">一小时</h2>
<p>坐电梯上了楼，开门回了家 窗帘是白天就拉上的 <s>因为我早就习惯了和我生活的地方不在一个时区</s>，屋里的光不是阳光，是灯管发出的那种亮白色，均匀、稳定、没有温度 出门大概不到一个小时。但走在外面的时候，总觉得过了很久很久，像做了一场很长的「梦」，里面什么都变了，又什么都说不清楚。然后就醒了。从地下车库的电梯里走出来后我就又回到了这张桌前，回到了这块屏幕前。阳光的温度有时候确实让人觉得燥热，但没有它的时候又会想念它的感觉，室内的一切虽然温度湿度刚好，但是坐久了却会生出一种莫名的疲惫。</p>
<p>一切都好像不真实，恍惚之间，又回去了，又好像已经早就回不去了</p>]]>
    </content>
    <id>https://canmi.net/mirror/less-than-an-hour</id>
    <link href="https://canmi.net/mirror/less-than-an-hour"/>
    <published>2026-04-04T10:31:17.297Z</published>
    <summary>笔者抱着 MacBook 出门，想去江边换个地方写代码，却被屏幕亮度、信号和早已变样的街道击退。一篇久居室内后再出门的回忆与脱节随笔，约一小时的城市漫游。</summary>
    <title>外面的世界</title>
    <updated>2026-04-05T14:49:06.040Z</updated>
  </entry>
  <entry xml:lang="zh">
    <content type="html">
      <![CDATA[<p>这个博客，我想了两年</p>
<p>说"想"其实不太准确，更像是一个执念。在早期我还不会写前后端，不会写嵌入式代码的时候就有了，当时还在玩电子，偶然间看到了 <a href="https://github.com/sakura-ushio">@ushio</a> 的 B站视频 和 网站，窝觉得很好玩我也要。后来花了小半年时间，从零学会了写 C 和一点点 JS CSS 用 Hexo 搭了一个静态博客部署在 Github Pages，也算是有了自己的小天地。</p>
<p><a href="https://hexo.io">Hexo: A fast, simple &amp; powerful blog framework.</a></p>
<p><a href="https://sakura-ushio.icu">ushio</a></p>
<p>再后来，看到了 <a href="https://twitter.com/__oQuery">@Innei</a> 的小站，设计很精致、功能也很全，当时的我看了一眼我就想要，甚至还赞助支持了，虽然后面没用上但是这钱也花的不怨 因为我摸到了 React 源码，就和我最初看到 C 一样，不过就是代码嘛，有什么大不了的，总有一天我要自己写一个。</p>
<p><a href="https://innei.in">静かな森</a></p>
<p>那是 2024 年，<s>然后 24, 25, 26年 到了现在</s></p>
<p>从那之后就停不下来了</p>
<h2 id="out-of-control">失控</h2>
<p>一开始只是想给 <a href="https://x.com/colmugx">@colmugx</a> Hexo 主题 <a href="https://github.com/colmugx/hexo-theme-nlvi">Nlvi</a> 加个深浅色切换，用 Dark Reader 的 JS 糊了一个，丑，不满意。然后学 CSS @media，再到 JS SPA 切换，TailwindCSS .dark class, 再到 SSR next-theme，最后手搓 Cookie + Head Sync Inline 脚本解决 FOUC 闪烁，我折腾了四五种方案。</p>
<p><a href="https://github.com/colmugx/hexo-theme-nlvi">🎨 A simple theme for hexo.</a></p>
<p>但这只是冰山一角。看到 React 之后，我从石器时代的 Hexo 和 Vanilla JS 一路走到了 Next.js 13 Page Router，又拐去看 Vue、研究 SPA 路由、理解 index.html fallback，项目新建了无数次。买了服务器，折腾 Docker、Nginx、NAS，嫌 1Panel 丑手写了 Canopy 面板 研究 NAS 存储阵列写了 RFS，搞 SSL 被国内环境折磨的 acme.sh 抽风逼到用 Rust 写了个反向代理：Vane &#x26; Lazyacme，本来只是一个晚上三小时写出来的八百行垃圾，结果群友一怂恿，写了四个月，HTTP/3、Zero-Copy、Layer 模型、Flow 引擎全往里塞。后面还有 Seam，一个全栈框架，也是天坑，以后再说吧。</p>
<h2 id="over-engineering">过度工程</h2>
<p>然后 AI 时代来了，焦虑又翻了一倍</p>
<p>最讽刺的是，这些东西本来都应该是有了博客之后，边记录边做的。结果博客鸽了又鸽，想法越来越多，却没有一个地方能记录它们。一边是内心在谴责；说好了 Vane 要做完，就不能，停另一边是越做越远的无力感。我一直都记得我要做什么，但就是做不出来。仔细想想我其实已经很久都没有坐下来写过什么东西了，现在回头看，这一切都指向同一个东西——过度工程。</p>
<p>我当时其实并不不觉得自己在跑偏。Vane？反正博客以后也要部署，总得有个反向代理吧。Seam？反正迟早要写全栈，不如先把我理想中的框架搞好。每一步都有理由，每一步都是"以后用得上"。但问题是，"以后"永远不会来，因为我从未开始...</p>
<h2 id="failure">失败</h2>
<p>做博客实际上不需要一个反向代理，不需要一个自研框架。而当时的我把它们当成了前置条件，作为了那个永远 Blocking 但是却合理的理由，但它们从来都不是。真正的前置条件只有一个：坐下来，把第一篇文章写出来。</p>
<p>其实博客不是没动过，恰恰相反，我做了三次</p>
<p><a href="https://vercel.com">Vercel</a></p>
<p>第一次还是 Next.js 13 Page Router 那会。那时候我蒙懂 SPA 是什么，SSR、SSG、ISR 这些概念一个都不清楚，但看别人说 SSR 好，那我也要用。结果写着写着发现自己根本不理解这些架构在解决什么问题，做不下去了。后来呢，转 Vue 2 纯 SPA，为什么选 Vue？说来惭愧，觉得名字好听，反正也新，试试呗。也没撑多久。第三次回到 React，从单 JSX 开始，学了 TypeScript，会了 TSX，理解了 CJS 和 ESM 的区别，爱上了 Reactive，用上了 Lucide、Framer Motion、Radix UI 这些日后离不开的库——这次终于像点样子了。但我还是失败了，因为本质上我只是在模仿，别人用什么我就拿来用，看起来像那么回事，实际上根本不理解为什么要这样做。再加上 AI 的幻觉加持，很多东西都能跑起来一个 MVP，但 Scale Up 的时候全是坑。</p>
<p><a href="https://remix.run">Remix</a></p>
<p>后来又学了 React(<s>Remix</s>) Router Loader、Next.js 15 App Router、RSC，但这时候日常已经很忙了，没什么时间写代码，倒也没机会第四次失败。现在想起来，我的网站从 Next.js 13 写到了 Next.js 15 ，前脚刚迁移完成，还没上线，Next.js 16 beta-1 就出了。</p>
<h2 id="letting-go">放手吧</h2>
<p>说"想开了"其实不准确，是我累了。</p>
<p><img src="https://cdn.canmi.net/image/8531b6a61c000d330539e0dc488cc158a3b37bab2f8b31c9b6d242d6717f4bb1.png" alt="" /></p>
<p>AI 时代的节奏太快了。模型一个接一个迭代，新技术一轮接一轮冒出来，我越来越焦虑，好像停下来就要被超越。最疯的时候一天写 16个 小时代码，Token 一天就能烧 $1.9k USD <s>(大概2月的时候吧)</s>，人快废了。但让这一切停下来的不是什么顿悟，就是单纯的累了。在越来越紧的节奏下，最后一根稻草压下来，我反而释然了，想开了，摆烂了；这样，也挺好的。</p>
<p>我开始放手。放弃曾经执着的东西，学会点到为止，明天的事明天再说。Vane 没做完？先放着。Seam 还差很远？以后再来。博客不完美？先上线。开始完全 Vibe Coding，没问题就不管，有问题再说。</p>
<p>奇怪的是，放手之后质量并没有变差。我想这大概是因为之前那些"古法"编程的日子没有白费；早在 GPT-2 时代我就开始用 AI 了，但那时候我还在老老实实地写每一行代码、理解每一个概念。那段经历给了我底子，与其说后来 Vibe Coding 不如说是 Context Coding，该有的判断力还在，并没有因为放手就出什么低级错误或者说是事故。</p>
<p>是之前所有的折腾，给了我现在放手的资本</p>
<h2 id="back-to-origin">原点</h2>
<p>最终的方案反而很简单：All in Cloudflare。R2 对象存储、D1 Edge 数据库、KV 做缓存、Worker 跑 SSR，全部 Serverless，没有服务器。</p>
<p><a href="https://workers.cloudflare.com">Cloudflare Worker</a></p>
<p>这个答案讽刺吗？太讽刺了。因为在到达这个"简单"的终点之前，我走了一整圈弯路。曾经计划自建，所以要管服务器、折腾 Docker、管容器，于是写了 Canopy 做管理面板，写了 Lazycert 管证书，写了 Vane 做反向代理，写了 Twig 做资源监控埋点。一堆项目，就为了伺候一台垃圾服务器。</p>
<p>存储也是一样。我嫌对象存储贵，想用自己服务器的硬盘，于是写了 RFS 一种 vfs 为了原子去重合并单文件，甚至复活了 80-90年代 的注册表理念，最后还通过 FUSE 挂载起来了。结果呢，花了更多时间造了一个不那么好的轮子，服务器还空跑了一年，完全就是彻底的亏本生意。</p>
<p><a href="https://vercel.com">Vercel</a></p>
<p>最后绕了一大圈，还是回到了 Serverless，Vercel 的概念好，但我不喜欢 Next.js；而且 Vercel 的魔法太多了，还有就是那个中间件设计的提前编译跑 Edge 上，最后还是割裂，甚至出了几个 CVE，还不如要么全部源站一起。而 Cloudflare Worker 就很好，达到了另外一种极端，小于 10MB 的产物编译成 WASM，Edge 上能跑真正的业务逻辑，这才是我想要的。除了费钱以外没什么缺点——但说起来也不算缺点，毕竟省下的时间就是钱。</p>
<h2 id="less-is-more">Less is more</h2>
<p>这句话我听过无数遍，但花了两年才真正理解它的含义。</p>
<p>回归工程的本质：「Make it function, make it correct, then make it exceptional.」我终于走上了正轨，让它 functional 了。现在的博客简陋吗？简陋。不完整吗？确实不完整。但总比没有好。如果你连上线都没有，一直在做那个"也许需要"的东西，一直在追求看不见的完美，那最后什么都拿不出来。这不是妥协，这是一种工程上的取舍，反正我是想开了。所以我想开了。不再为一个细节执着，反而走出了门。手机相册里已经很久没有户外的照片了，随手拍了一下路边的——已经很久没出门了嘛 (</p>
<p><img src="https://cdn.canmi.net/image/b0e8e8202342dc9e84ca97035ede29b36dd268520dca3dff1e764df87d20ebac.png" alt="" /></p>
<p>回头看之前走的弯路，后悔吗？不后悔。有些事你不自己试试，也许永远想不明白。更何况在 AI 时代，想走我当年的老路已经基本没有机会了，太多工作流已经被改变。另外这个项目是 <a href="https://github.com/canmi21/taki">Taki</a>，100% AI Coding，但这不意味着不能长期维护，不意味着没有人味。其实有没有 AI 不重要，再说了，我喜欢的也不是写代码本身，而是 Building 一个东西...</p>]]>
    </content>
    <id>https://canmi.net/milestone/less-is-more</id>
    <link href="https://canmi.net/milestone/less-is-more"/>
    <published>2026-03-24T08:49:57.449Z</published>
    <summary>笔者用两年把博客拖成反向代理、自研框架与 vfs 的副产物，在 AI 焦虑里学会放手、All in Cloudflare 上线了第一篇。一段从 Next.js 13 鸽到 16 的过度工程自白。</summary>
    <title>Less is more</title>
    <updated>2026-04-01T10:36:51.601Z</updated>
  </entry>
</feed>
