今天剛好是我搬到美國整整一個月;落地北卡、日常安頓好之後,周邊的地區和比較近的景點也差不多逛完了,人就閒了下來。可我偏偏不是個閒得住的人,一閒下來就會給自己找點事做 重新撿起部落格不就算一件嘛,只不過美國這邊的物流不太方便,板子暫時畫不了,那就只能回來寫點軟體摸摸魚,順便把幾個月前丟下的坑撿起來填上?
不過在開始吐槽別人之前,可能還是得先補一點 seam & SeamJS 的背景知識(x 這東西到底是什麼 畢竟上一篇隔了好幾個月,你們大概率已經忘光了,而 GitHub 上的 README.md 我又暫時懶得改
將渲染視為一種協定
使用者介面應被描述,而不是執行。
簡單說,Seam1 首先是一套協定,而不是又一個 SSR runtime。這表示建置期編出一份帶 slot 的 HTML 骨架,執行期或請求期只往這些 slot 裡填值;if / each / match 這些同樣是寫死在骨架裡的協定節點。所以請求進來的時候,沒有任何一個 UI 元件被執行過,伺服器端要做的只有這一件事。那它自然也就不必是 Node 或者 Bun 了,Rust / Go 之類的後端填一份 HTML 不也一樣行嗎,能有什麼難的呢?! 但其實後面也沒有完全幹掉 JS Runtime,我的意思是可以不要,前提是你能接受一部分限制 但無論如何,整體效能提升絕對是指數級的,因為要達到大多數 SSR 的效果,已經不再需要在執行期 / 請求期跑 UI render 了……
請求時的 模型? 2
大概從今年下半年開始,我把本站從 TanStack Start React 完全 port 成了 SvelteKit Svelte。全部遷移完之後,我發現它確實很好:ssr 的效能開銷差不多降了一個數量級,水合開銷非常小,跑起來很舒服,框架「編譯」的哲學也非常棒、非常輕量。但是 Kit 的部分總有一些讓我覺得不盡如人意的地方。其中大部分可以靠外掛解決畢竟是 Vite 拼出來的,可總還是有一些部分動不了;不過這大概率也是我自己的問題,因為 seam 想改的地方,可能和任何一個現存的 meta 框架都有本質衝突,說到底是我在挑戰它們的執行模型(每個請求都跑一遍 render 函式)、資料邊界(load 和 UI 糊在一起)、後端能力(死死綁在 JS runtime 上)。
偽 SSR 3
這裡不用想也知道一定有人會問:這不就是 Astro / Qwik / Marko 嗎。,seam 在語言形狀上離 Marko 最近,在預設少 JS 這點上離 Astro 最近,在不 replay hydrate 這點上離 Qwik 最近;但問題是,他們的 request-time 仍然是在跑 UI 程式,island 各自的 render、Qwik 那一次 SSR、Marko 編譯出來的 JS 樣板函式,都還是每個請求都要執行一段 renderer。
那 {#each} 一千條本質上我也還是在拼 HTML,所以我不是不生成文件4,差別在於我拼的是已經自我包含的協定,而不是重新生成元件 Tree。Seam 要的是 AST → IR → 帶 slot 的 HTML,請求時只做協定解譯,再加上動態資料區域的 injection,因此不必再跑 UI render 那部分函式了。所以任意後端都能填,把最後一層 residual 化,也就解決了 vendor lock in JS5 的問題。所以其實我和上面那些既有方案的大方向根本不在同一條線上(x)
接入
Seam 會做 hydration,但既不是傳統那種,也不是 Qwik 那種。傳統 hydration 是客戶端再 render 一遍,然後跟 DOM 對照;Qwik 是 SSR 出 HTML 加上元件狀態,客戶端 resume,不 replay;但 Seam 的 request-time 連 UI renderer 其實都不必跑:hydration 骨架、首屏和 hydrate 的資料都來自同一次 injection。客戶端拿到的是已經對好的 DOM 加上同一份資料,依照編譯期的 IR 把事件和 client state 掛上去 adopt,不是 replay,當然也不是 resume 一棵元件樹。
正因如此,「兩邊各渲染一遍再對拍」這類 mismatch6 已經從預設狀態變成了非法狀態。窩並不是在宣稱實作已經做到 100%,而是說這類偏差在模型裡根本不合法:一旦出現,那就是我 compiler / injector 實作裡的 bug,可以被 fix,典型的例子就是(骨架沒過 HTML parser、跳脫字元對不上、IR 和骨架不同步)。
當然 skeleton 本身必須是 parse-stable7 的;在 table 裡亂塞 comment,那是實作沒處理好,別算到我的模型頭上。建置期算的是水合對應(誰是靜態的、誰是 slot、綁定什麼),不是把資料提前算死,但它確實把這個 slot 的資料型別固定了下來。資料值本身在請求期仍然可以任意替換;只不過 HTML 和 payload 必須是同一份。
是同一張圖嗎?
Astro 雖然首屏同樣是文件,之後也能 ClientRouter,但差別在於它的動態部分依舊是 islands 各自 render,ClientRouter 本質上更接近 document morph,而不是水合之後仍然活著的同一張元件圖。所以 Astro 的技術棧本來就不適合做這件事,它本身沒有錯,只是並非設計給你這樣用的。你當然可以說還有 View Transitions API8 能用,那我就要反問你了:View Transitions API 能當飯吃嗎,能讓你跨頁面套一層 Motion 動畫9嗎……扯得有點遠了;
反正水合之前誰都動不了,首幀10 在 JS 起來之前沒有動畫,本來就不該怪任何人;Seam 也只保證 injection 對、document 對、client 入口對,真正的約束在水合之後:client 導航是把資料打進那張還活著的圖裡 (layout 保持不變),而不是再走一遍 injection 去換 document。總之 Seam 在水合之後接管的是同一張靜態元件圖。
這裡其實還有 Marko 6 已經很接近我的想法了(模板 AST、編譯期細粒度、resume 而不是 replay);但跟 Marko 相比,技術上還能劃出來的界線主要有兩條:一條是 request-time 跑的仍然是 JS 編譯產物,整條鏈路都被綁死在 JS runtime 上;另一條是作者用的語言是 Marko,不是 Svelte。我相信大多數人聊前端的時候,到了 2026 年也只會把 React、Vue、Svelte、Solid 當成比較主流的 UI Stack 吧,如果我不提,很多人甚至可能根本不知道 Marko 是什麼。那麼建立在 Svelte 之上,我其實算不上是「把原理複製一份再加個 backend」,我更傾向於解釋成:借一套有人寫、有人用、有人不斷修 bug 不斷打磨的 UI 來用,全都是大家熟悉的東西。
另外還有一點,Qwik 的「不水合」省掉的是客戶端的 replay,但並沒有省掉 伺服器端的那一次 SSR11(?),所以很可惜。目前沒有任何一個框架能滿足我,也沒有哪個框架給我足夠的 API 讓我有做加法的可能,這也是為什麼幾個月前我會去做 SeamJS 這個東西;那麼今天其實就是來重新規劃它的 roadmap。
現在的我非常認同「Svelte: for building, not frameworking.12」
React
先說 React 吧。撇開被 Vercel Next.js 商業綁定、以及它有自己的 roadmap 不談,首先它 CVE 滿天飛,光這一點就注定你每時每刻都得盯著社群媒體,看有沒有新的漏洞被回報和公開。其他家的路由方案基本上都處於不可用的狀態,尤其是 Remix13。但 2026 年有一個非常特別的選項,那就是 TanStack Start。我很早就在用 TanStack Router 了,對此可以給出的評價是:撇開命名風格與資料結構做得不太美觀14不談,從功能性來說,這是我唯一認可的選項。新是新了點,但它解決了 Next.js 16 的 next-server 進程至今仍未解決的冷啟動頁面首次存取速度問題。Next.js 其實一直莫名其妙存在這個卡頓問題,並不是伺服器效能不夠;我實際上會把原因歸結為:要嘛是啟動階段的載入還沒完成,要嘛是 runtime 還在編譯某些內容。而且這種結構注定它不太適合 Cloudflare Worker 那種多地實例、就近啟動的原則,只有 Vercel 那種傳統的 Node 常駐行程15才不會碰到這個問題(?)有點扯遠了,關於 Next.js 以後可以單獨寫一整篇來吐槽。但 TanStack16 真的讓我覺得已經成熟到可以商用了,至少我已經把它用在某個專案的銷售站上了。但這僅僅是僅有的兩個選項之一(x)
Vue
Vue 的話,我個人從 Vue 2 遷移到 Vue 3 的時候,就已經不太認同它的語法等機制了,我心中的 Vue 永遠是 Vue 2。不可否認,Vue 3 有很多很棒的 feature,但對比 Vue 2,它的核心對我來說感覺變了,而且變得很大。另外從商業角度來考量,Vue 是不是越來越被 meta 層徹底綁死、被生態控制了?它的處境感覺和 Svelte 非常相似,都是開源,但基本上很難有第三方站出來給你做第二個實作。這也是為什麼 TanStack Start 加入後的這輪洗牌對 Next.js 的打擊真的很大,必須表揚 Tanner Linsley。還有一點也是 Vue 不可忽略的問題:早年的 Vue 確實是一種革新,但任何 UI Stack 在生態起來之後都會遇到生態問題,也就是為了相容性而做出一定的妥協? 這個問題在 React 上真的非常明顯,現在的 Vue 也不例外。開始出現這種內容17的時候,我一般就會把它歸為 Slop 了,但也不能說它不好,因為當 Nuxt 成為下一個「企業級」框架之後,首要的考量已經不再是技術正不正確、優不優雅,而是某種向後相容加穩定性(x
所以 Evan You 其實並沒有做錯什麼,甚至可以說,總得有人站出來當這個 ,而他恰好就是最合適的人選,只是做了最合適的事而已……
Solid
SolidJS 這個框架很好,核心也夠穩定,但沒人願意替它寫生態。Solid 其實就是早年的 Rust,只是 Rust 已經撐過來了;Rust 在程式領域同樣面臨生態稀少、什麼都得自己造輪子的處境,但 Rust 的工具鏈能讓你順暢地把輪子造出來、先讓最小可用單元跑起來,造輪子的難度其實並不算高?可惜的是 SolidJS 還沒撐過這個階段,就撞上了 AI 寒冬;尤其進入 26 年之後會變得格外艱難。Solid 2.0 的核心已經很好、也很穩定,但外圍始終沒滾起來,想法非常正確,只是生不逢時;就算有那麼幾個外圍專案,也只散落在一兩個方向上 (例如 TanStack 有一個 Solid 版的實驗性 Router),拼不出一套完整的生態。這讓它在當下與未來都處在一個尷尬的位置:沒有完整生態,而在如今 Agent 爆炸的時代,雪球沒滾起來,Agent 就不會優先選擇它;如果連 AI 都不偏好它,就代表它得到貢獻的機會更少——尤其是在連 Linus Torvalds 都開始 的年代。我覺得 Solid 很遺憾,想法很好、技術路線很正確、實作也很優雅,但真的可惜,生不逢時……
「或許以前的我不會覺得這是個問題,什麼都自己寫也無所謂,但很遺憾,現在的我不這麼想了」
平衡點
那麼在這條路上,恰好踩在最完美平衡點上的其實只剩 Svelte 了。它的生態比 SolidJS 更好 (出生的年代也更好),效能也沒差多少,甚至有時候得益於它的編譯器,很多時候還比 SolidJS 更快更優?! 當然這裡一定會有人說:當頁面數量漲到一定量級,或者使用者瀏覽得越多,Svelte 的資源開銷18對比 SolidJS 會越來越大?網路請求會越來越多?嗯,理論上這確實是事實,但我覺得得結合真實網頁來看:99.9% 的個人網站或內容網站,訪客基本上都不會停留太久,停留很久甚至可以算是非常罕見的情況。至少在我這個站上,目前深度連點的行為極少,所以「翻很多頁 = Svelte 更虧」對我並不構成問題。但這不是我選 Svelte 的理由;核心原因還是 markup AST。
另外還有 Dashboard 這類會被一頁頁點開的東西,這條統計對它幫不上忙,但話說回來,Dashboard 的使用者比起真正的內容讀者終究還是少數。所以「瀏覽極多頁面導致開銷變大」,我認為本質上是個偽命題;至少現在是,也許以後我會有不一樣的看法,但在如今這個快節奏的資訊時代,我很難不承認這類使用者極少;就算真有人因為感興趣而深度探索、並轉化成長期讀者,這種探索也大概率只發生在第一次造訪,之後他們不過是來看看新發的文章而已;可以說從統計學的回報來看,這種極端情況並不成正比。因此我更願意相信:大部分場景下 Svelte 能帶來更好的綜合效能。
Svelte 相較於上述三個框架,除了處境與定位更合適之外,其實也很接近寫裸 HTML 的感覺;甚至在早年 Svelte 3 剛普及、.svelte 副檔名剛定下來的時候,官方 VSCode 擴充功能還沒推出,官方推薦的就是這種做法,以至於現在你還能在 Svelte 的擴充功能頁面 找到這句話:
如果你在 VSCode 設定裡加過
"files.associations": {"*.svelte": "html" },請把它刪掉。
另外 Svelte 還給了我不少好處。首先,它不會把 UI 推向 JSX 加上一堆 JS function19;其次,<style> 是編譯期產物,而不是 runtime,前者讓結構留在 markup AST 裡,後者讓 CSS 能直接進入 IR,而 Tailwind 剛好兩邊都貼得上(
黑箱
就像上一篇寫過的,Seam 首先是一套協定,而不是又一個 SSR 執行環境。建置期產出帶 slot 的 HTML skeleton,請求期只做 injection;if / each / match 同樣是協定節點20
只可惜我先前的 UI 選了 React 來動刀,很不幸的是,想在 React 上實作擷取頁面結構並產生變體非常困難,而改編譯器的工作量不亞於重寫一個……
不改的話,還可以把 React 編譯器當成一個黑盒,然後真的跑一堆 render + diff 把結構猜出來用;nullable / enum 這類有限的決策還能窮舉,可一旦碰上 price > 10 這種謂詞,型別的取值空間根本乘不完。V1 裡遇到這種情況只會讓你手動提供 mock,比如按 price < 10 / = 10 / > 10 切三刀;
但問題也正是出在這裡(?) 要做到這種精確的切 3 刀,就要求你知道切點在 10,可是 React 編譯器對我來說是不透明的,我們並不知道條件是什麼,於是 TypeSafe21 的代價就變成:使用者在替編譯器做事。
理論上這件事不該丟給使用者,否則結果就是 escape 滿天飛22,而 CTR23 涵蓋到的仍然只有那麼點 nullable / enum,結構發現等於沒做,那它還剩多少意義我就不知道了。
但這次並不是要改協定本身。我原先說的 skeleton、slot、injection,我覺得其實可以保留,後端也依然可以不跑 UI 程式碼;真正要改的是協定前面的那一層,也就是回答頁面究竟是怎麼被編譯成這些節點的。
那就不得不感謝 Svelte 送給我的最大的禮物—— markup
因為 markup 的結構根本不用我來猜!!!
看得見的 AST
僅憑「標記語言真的就是標記語言」這一點,頁面結構就完全有機會成為編譯器讀得懂的東西,而不是一堆執行期 JS 跑出來的元件樹。我就是想說,好好的介面,為什麼非得能被任意執行繪製呢?我並不是要否定這個觀點, 但我想說的是,至少 97% 以上的時間裡,任意執行繪製根本用不到,詳情可以參考這裡
舉個反例,React 裡的
function Card({ user }) {
return user
? <div className="card">
<Avatar user={user} />
<span>{user.name}</span>
</div>
: null
}本質上還是執行一個 JavaScript 函式,看看它回傳什麼。哪怕編譯器再聰明,它面對的基本模型仍然是:JS execution → JSX expression → element tree。而 Svelte 裡面就很不一樣。
{#if user}
<div class="card">
<Avatar {user} />
<span>{user.name}</span>
</div>
{/if}它交給編譯器的,直接就是
Component
├── IfBlock
│ └── Element div
│ ├── Component Avatar
│ └── Element span
│ └── Expression user.name這對一般的 Kit 應用來說已經很好用了,但對 Seam 這種想把 server rendering 拆成 compile-time skeleton + CTR + SSR fallback 的東西來說,意義就遠遠不只好用一點了。
因為這棵樹同時給了我兩層資訊:第一層是 值的動態 → slot,但這個在 V1 裡早就能做到了;更重要的其實是第二層,它給了我 結構的動態 → 協定層的控制流節點。與其去猜 React 編譯器那個黑盒,Svelte 的編譯器乾脆把正經的前端產物攤開給我讀了,比如 {user.name} 屬於前者,{#if user} 才是後者。
另外,舊協議裡的 if / each / match 仍然有效。但之前舊文裡那套笛卡兒積,我覺得得加個限定:它成立的前提其實是分支空間,而不是值空間。nullable / enum / bool 這類有限決策,組合數是有限的,compile-time 花錢窮舉在數學上站得住;但 JTD 的 string / number / timestamp 本身不可列舉,price > 10、inventory < 5、items.length === 0 這類謂詞切出來的分支,光靠型別值的笛卡兒積根本發現不了;sentinel 裡隨便填一個數進去,永遠只會走同一側。
如果真要把它們收斂成有限決策,本來就得先把 derive 轉成 bool / enum,而這一步並不是 JTD 白送的(笑)。所以 V1 只在 nullable / enum 上 sound,對任意欄位的值空間並不成立;真正的工程難題更在於,那些區塊是 diff 猜出來的,IR 無法解釋自己。
下降
但在 Svelte 時代,這些節點可以直接從 AST 生成。這麼做的原因並不是為了換個技術棧省下那幾毫秒,更多是為了讓控制流從觀測24變成 lowering——結構發現交給 AST,有限的決策用協定節點來表達,不必再乘出 N 份 HTML;型別只用來約束 payload,而不用來發現樹。於是最後在 Svelte 裡,values 在這個 Layer 上看起來就是下面這樣 ↓
STATIC
<div class="card">
DYNAMIC
{user.name}
STATIC
</div>這類 DYNAMIC 很多時候並不需要 React 那種級別的「真 · 渲染25」,保留 slot 就夠了。
再細一點,Svelte 甚至可以把單個 element 拆成這樣
structure: static
attributes: static
text node #0: dynamic這才是 Seam 最需要、也最喜歡的東西。在 React 裡想知道 attributes 是不是 static,基本上只能暴力窮舉:把所有可能的輸入都跑一遍,attribute 沒變就是 static,變了就給它一個 slot。
結構分支更慘,連「這裡到底有沒有這塊 DOM」都得靠渲染兩遍再 diff,而 Svelte 的 IfBlock 把後面這件事從可觀測變成了可生成,這才是質變。
但其實這樣說也不完全對,或者說太簡化了,說得再細一點:單個 element 還可以繼續往下拆;而 attributes 也不一定一開始就是 static,具體取決於你怎麼寫。
class="card" → attributes: static
class:active={on} → name static, value slot
{...rest} → opaque, escape hatch
text node {user.name} → slot比如這裡第一行進 skeleton 是沒有爭議的,但第二行才是 Svelte 真正好用的地方:class: / style: 把 attr 這個名字留在 AST 裡,只有值單獨變成 slot,不必把整個 element 都打成動態。第三行和 React 的 {...props} 沒有本質區別,Seam 就把它當成明確的 hatch,不用再假裝去分析它了;React 裡字面量 className="card" 其實也不需要窮舉,需要窮舉的是「換一組 props 之後這個 attr 會不會變」。Svelte 恰好少掉的就是最後這一種猜測——只要名字還在樹上,就不用渲染兩次才知道 🫠
總之 React 會逼著 Seam 把很多東西重新當成 執行期問題 來處理,例如 ↓
const Wrapper = cond ? A : B;
return Wrapper({
children: foo.map(renderItem)
});或者說
return foo && bar
? getLayout()(data)
: something();當然,現代的 React Compiler 能分析出其中不少東西,但真要魔改它,就代表之後得一直維護這份改動,這種開銷可以說是極其龐大,不論是精力還是時間……如果改的是 Svelte,這邊我就不打算 fork compiler 了,理論上 svelte/compiler 已經把 AST 暴露出來了。
Seam 要做的是讀這棵樹,然後產生自己的 IR;官方那套 DOM / SSR 程式碼產生其實都可以留著當後備,理論上只需要維護的是 IR lowering,不是整個 Svelte,所以大方向一致,衝突會少——但一致的是「結構在編譯期可見」,不是執行模型;正因如此 Svelte / Kit 的 SSR 仍然是每個請求都跑一遍產生出來的 render 函式;但是我更希望在我的 Seam 裡,request-time 不跑這個函式,只解釋協定(slot + if / each / match)
在選擇 Svelte 這件事上其實還有另一層考量:在 Next.js 和 React 事實上已經完全26被 Vercel 掌控、成為市場框架的前提下,如果 Seam 承諾相容 React 語意,那我的架構最終就必須允許 arbitrary JavaScript → determine tree structure。
於是 Seam 的 IR 就必須寫得非常保守(x),最後大概率會變成:能靜態分析的就最佳化,不能分析的就走 SSR。這樣雖然同樣是 CTR + SSR 的混合頁面,但問題恰恰出在「不能分析」的那一塊上,因為 React 的世界裡「無法確定」的地方實在太多了……
如果不去改 React 編譯器,那就只能停留在跟 V1 一樣的處境裡:把編譯器當成黑盒,靠猜它輸出什麼產物。這種做法可以說非常脆弱,也不優雅,而且極容易到處冒出邊界情況。
反過來做
所以如果我現在說 Seam 的元件語言就是 Svelte,那麼編譯器甚至可以反過來做:先預設走 structure known,局部用 expression dynamic,明確的地方用 escape hatch,再進 runtime,實在處理不了的最後才退回 SSR。
<script>
let { product } = $props();
</script>
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
<button>Buy</button>
</article>Seam 完全可以理解成類似下面的樣子
<article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<button>Buy</button>
</article>那麼理論上 slot0 = product.name,slot1 = product.price。這就是我先前一直在做的殘值化(residualization),核心想法是整頁不必進伺服器端渲染,只計算真正動態的那部分殘值。
插槽與分支
但從 React 換到 Svelte,真正有收益的其實是下一層;也就是結構發生變化時,也不必再靠渲染兩次來猜,因為結構在編譯期就已經確定,直接生成對應的 IR 就行了
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
{#if product.available}
<button>Buy</button>
{:else}
<p>Sold out</p>
{/if}
</article><article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<!--seam:if:product.available-->
<button>Buy</button>
<!--seam:else-->
<p>Sold out</p>
<!--seam:endif-->
</article>值仍然是 slot,分支則是舊協議裡的 if 區塊。但在 React 這一版裡,得多次 diff 才拿得到這塊;而在 Svelte 裡,這裡可以直接從 IfBlock 推導產生。
逃逸
這裡當然不是說 JSX 做不到。只是真要寫起來,幾乎沒人會把結構放在分支樹上一眼就能看見的地方,基本上都會寫成下面這個樣子(笑)
const price = formatPrice(product);
const body = product.available
? getAvailableView(product)
: getSoldOutView(product);
return <Layout>{body}</Layout>;而且還有很重要的一點:就算你寫得出這樣的程式碼,只要 React 的主力群體不這樣寫,訓練出來的 LLM 也不這樣寫,那麼遷移成本和後期開發成本絕對會呈指數成長。
但是 Svelte 這裡情況又不一樣了
UI structure ≈ template AST,JavaScript ≈ values + behavior,這在這裡已經是家常便飯了
這甚至是 Svelte 官方最推薦的做法。這個區別對 Seam 抽取 IR 來說是結構性的。但也請不要理解成 Svelte 沒有動態結構,它其實有對應的逃逸口,而且還是主要語法之一
{@render children()}
<svelte:component this={layout} />
<svelte:element this={tag}>
{@html html}Wrapper = cond ? A : B 在這裡並沒有憑空消失,而是從「任意 JS 運算式」收斂成了這幾個節點。對 Seam 來說就很簡單了:預設走 AST lowering 就好;靜態上看得見的 hole,裡面是誰就是誰。最常見的是 {@render} 當作 compose(layout、Card 包住 children 都屬於這一類),而 <svelte:component> / <svelte:element> / 動態 {@render} 才是明確的 hatch,會進入 runtime 或 SSR fallback;至於 {@html},可以直接沿用我先前設計好的 raw HTML slot。
所以真正的優勢並不是「結構永遠已知」,而是「未知的結構有一份參考清單」(x
在 React 裡,未知的是整個 JS;在 Svelte 裡,未知的只是上面那幾個 Escape 留下的開口,僅此而已。用上之後,CTR 需要分析的範圍一定會明顯縮小。
CTR
能用 Svelte 並不代表就一定能用 CTR,不過我這裡有一個參考標準 ↓
這樣靜態參照才 compose;仍然走下面的 hatch。由此可以得出,CTR 的單位是靜態元件圖,而不是單一檔案。大部分情況下都是預設的 structure known,少部分明確標為 escape hatch,平常幾乎遇不到 SSR fallback;其他不在表上的,一律當作 opaque 處理就好,根本不用猜。CTR 只是這個子集上的 execution model,就算我換到 Svelte,也並不打算擔保整個 Svelte 的範圍27(笑)
CSS
另一個巨大的收益其實是 CSS。在 Svelte 裡可以這樣寫:
<div class="card">...</div>
<style>
.card { border-radius: 8px; }
</style>這裡 Seam 編譯一個 component 的時候,同時已經掌握了 markup dependency + script dependency + style dependency,而且 style 是一份靜態產物。於是就可以非常激進地判斷出
甚至可以更進一步,把流程做成這樣 ↓
上述整個流程完全不需要 JS runtime 參與。
不用 CSS-in-JS
CSS-in-JS 我同樣不接受,倒不是因為 React 逼著我去相容下面這樣的寫法
const Button = styled.button`
color: ${p => p.primary ? "red" : "black"};
`;或者
<div css={theme => ({
color: theme.colors.primary
})}>只是上面這套結構,其實在 V1 裡就已經能用 Tailwind 繞開了。不接 CSS-in-JS 的主要原因是:一旦接上,CSS 就重新變成 JS 的執行結果,於是樣式執行期、註冊表、SSR 收集、雜湊、hydration 一致性、主題上下文、插入、排序、去重,全都得請回來。一般框架吞得下這些;但 Seam 她不一樣啊!我要消滅的正是這類伺服器端與執行期的工作,總不至於為了對生態客氣,就把剛趕走的東西再請回來吧(完全沒有道理呢
再說,CSS-in-JS 出現的那個年代,要解決的正是今天 Tailwind CSS 和 Motion 在做的事。如果現在 Tailwind CSS 和 Motion 已經足夠解決,甚至解決得更好更優雅,那又何必開倒車呢?就像我前面說的:「任何東西在生態成熟之後都會出現妥協28,很少會有 breaking 等級的重構來帶來革新」。那麼既然 Seam 的生態還沒起來,為什麼不激進到底呢?
但我不確定「激進」這個詞用在這裡對不對。說它對,是因為相對於傳統框架,我確實把伺服器端那套沉重的 JS 執行環境(Node 或 Bun)給丟掉了;說它不對,是因為像 CSS-in-JS 這種東西,就算徹底丟掉其實也沒什麼問題,我更願意把它看作。曾經最知名的那幾個 CSS-in-JS 函式庫:Stitches 在 2023 年 6 月官方宣布不再積極維護,styled-components 在 2025 年 3 月也進入了休眠狀態,Emotion 基本上也是同樣的處境。
那這裡就不得不誇一下 Svelte 的 <style> 了,CSS → compile-time artifact 這條路徑在 Svelte 裡基本上已經毫無爭議 (至少我是這麼認為的,如果你不同意,那就算你對)
只要不是 JS → execute → generate CSS → collect → serialize → hydrate 這樣一路走下來,差距就會非常大;更重要的是,CSS 相依說不定可以直接進入 Seam IR 🤔
比如元件編譯:
ComponentIR {
skeleton,
dynamic_slots,
css,
client_behavior,
server_dependencies
}請注意,這並不是另外建立一套渲染模型:在 V1 裡,skeleton 加上 dynamic_slots 編譯出來的,就是舊協定裡的那份 HTML,只不過 skeleton 加 dynamic_slots 是怎麼來的,對我而言更透明了。
我們假設按下面這樣處理 ↓
<script>
let { name } = $props();
</script>
<div class="user">Hello {name}</div>
<style>
.user { font-weight: 500; }
</style>那麼最終一定會達到
ComponentIR
HTML: <div class="user svelte-x">Hello SLOT(0)</div>
CTR: slot(0) = props.name
CSS: .user.svelte-x { ... }
Client: none
SSR: none但請注意,你在上面看到的只是葉節點,而且是相當理想的一個切片:沒有子元件,沒有結構分支,也沒有客戶端的部分。頁面層級的 skeleton 是由靜態元件圖組合出來的;子樹裡一旦出現 $state 或 hatch,中介表示裡的客戶端 / 伺服器端渲染就不會是 none 了。
但這已經非常接近我想讓 Seam 達到的理想狀態:一個 Svelte 元件不再對應「一個需要執行的 renderer」, 而是變成一個 Svelte 5 編譯器可以拆開的資源。光是做到這一點,對我來說就已經是非常大的概念性突破了。
歸屬權
另外,「不推行全域狀態」對 Seam 的意義也比開發體驗更深。我相信你在 Next.js 專案裡,或者任何 React 全端框架裡,一定已經看過下面這一坨東西了 ↓
<ThemeProvider>
<AuthProvider>
<QueryClientProvider>
<I18nProvider>
<RouterProvider>
<App />
</RouterProvider>
</I18nProvider>
</QueryClientProvider>
</AuthProvider>
</ThemeProvider>當然這並不是沒有解決辦法,這種「金字塔」式的 Provider 巢狀其實可以改寫成 Compose Providers,大致上就是變成下面這個樣子。
具體做法就是把原本層層巢狀的那堆 Provider 丟進一個陣列,再定義一個組合函式,最後只吃這一層就好。但這對我來說其實是一種自欺欺人,本質上只是把巢狀"藏起來"了,並沒有真的消除它。也就是把視覺上的巢狀變成邏輯上的巢狀而已,某種意義上就是,純粹的自我安慰。
const AppProviders = composeProviders([
ThemeProvider,
AuthProvider,
QueryClientProvider,
I18nProvider,
RouterProvider,
]);
function composeProviders(providers: React.FC<{ children: React.ReactNode }>[]) {
return ({ children }: { children: React.ReactNode }) =>
providers.reduceRight(
(acc, Provider) => <Provider>{acc}</Provider>,
children
);
}
function Root() {
return (
<AppProviders>
<App />
</AppProviders>
);
}React 專案最後真的非常容易、甚至可以說 100% 會變成這樣,於是就產生了一個很討厭的接縫問題,也就是:「元件的輸入到底是什麼?」表面上你可以說是 <ProductCard product={product}/>,好像只依賴 product 本身?可實際上呢
depends on:
product
theme
locale
queryClient
router
auth
featureFlags
...於是 component dependency graph 又會變成隱式的。對 traditional SSR 來說問題不大,頂多就是「把整棵 React tree 跑一遍」;但 Seam 最後真正想問的是 ProductCard 能不能獨立做 CTR,到這一步就真的非常麻煩了。甚至可以說要連整個 React 編譯器的行為、使用者的 DX 和使用習慣一起改掉,那又何必呢……
用 Next.js 的人基本上已經不在乎效能29了,這句話我可以很負責任地說;他們更多是在為 Vercel 的周邊設施買單,如果你用了 Next.js 卻不託管在 Vercel 上,那真是一點好處都撈不到 😡
局部所有權
反觀 Svelte,它非常容易形成局部的所有權;
<script>
let { product } = $props();
let quantity = $state(1);
</script>從 Svelte 和 Seam Compiler 的角度來看,這樣非常舒服。
畢竟 「輸入: product + 本地使用者端狀態: quantity」所有權真的已經非常清楚了
product.name → server CTR
quantity → client
button onclick → client
rest → static但這只是預設行為,其實算不上保證;因為 getContext('theme') 和 .svelte.ts 裡的 rune module 仍然是隱式輸入,另外 QueryClient 也不會憑空消失。我覺得 Seam 的態度會和前面那份 hatch 名單一樣:看得見就標進 IR(記為 server 依賴或 client 所有權),分析不出來就當作 opaque,總之還是不要假裝「Svelte 裡沒有 Provider 這回事」比較好。不過也未必,也許我再稍微研究一下,是能找到 Provider 的處理方案的
React 時代裡 Seam 最大的問題是 IR 走的是 Component → execute renderer → get output,那麼我最後所謂的 seam compiler 實際上就變成了某種意義上提前發生的 SSR,或者完全可以說成是
「通用 SSR 編排層」
協定照舊
所以從現在開始可以說 UI 就是 Svelte,但 seam 協定本身仍然是協定,仍然以 slot、injection 為主,JTD 到底合不合適,還需要我進一步斟酌;從執行決策上看,現在已經是完全分層的了,槽已經 residual 化,Rust / Go / TS server 只需要照著填就行,完全不需要理解元件程式碼,真正的零 JS;與此同時,如果元件裡還有純衍生,那麼可以考慮加入一個 generated JS / QuickJS runtime,(注意這裡跑的是資料,不是 UI)
只有標了 hatch 的才會 SSR fallback,那麼後端在不需要真正 SSR 的情況下就可以不跑 UI 程式碼,自然也就不存在被綁死在 Node 或 Bun 上的問題,不過呢(Go Server 計畫停止維護,因為我個人心力實在不夠 如果有好心人有時間,可以接手長期維護,並不是架構不再支援後端無關性了)
換句話說,我只砍掉了 UI 這一層,誰的活我都接,這種費力不討好的事情(?)不變的那部分仍然可以充當「Rendering Protocol」,後端不限於 JS/TS。
Runes 語意
還有很重要的一點是,Svelte 5 的 runes 對 Seam 非常友善,儘管 Svelte 5 開始變得更像 JS 了一些。
let count = $state(0);
let doubled = $derived(count * 2);但此 JS 非彼 JS30,它和 useState / useMemo / useContext / useEffect 的差別在於:runes 仍然是 compiler-recognized semantics,所以 Seam 能夠知道 $state 歸 client 所有,$derived 是衍生值,$effect 屬於 client runtime;這和 const foo = someLibraryHook() 根本不在同一個量級上。
資料來源
這裡有一個很重要的點:「認得出來」其實不等於「能 CTR」,尤其不要一看到 $derived 就預設它會進 skeleton。上面這個 count * 2 就是反例之一,因為它依賴的是 $state,所以它屬於 client 端的衍生產物。可以看看真正能 CTR 的 $derived 長什麼樣:
let { product } = $props();
let price = $derived(formatPrice(product.price));product 來自 $props,而 formatPrice 還必須是純函式,而且是可見的;接到前面那張表上,就是這個樣子。
$state / $effect / onclick → client
$derived
deps ⊆ server data, pure, visible → CTR derive
deps include $state → client
impure or opaque callee → QuickJS or SSR fallback
markup → follow the table above; runes are not a free CTR pass畫出來看可能比較好理解(?)
總之,Runes 的友善之處在於所有權被標在語法層面,而不是因為 $derived 自帶 CTR 資格。判斷的唯一標準,其實還是下面這條路徑呈現的資料從哪來、純不純、看不看得見(笑)
然後在旁邊再加上一條 <style> → static CSS dependency graph,這樣整個世界觀就非常一致了:HTML 就是結構,CSS 是樣式產物,JS 一定是計算與行為。
而不是像現代 React 那樣,很容易一路滑向 全都塞進 JS31。或許對 React 自己來說,這未必是壞事,但很遺憾—— 一切皆 JavaScript 恰好是最不利於我做激進的編譯期分解的那種世界
到底會 Ship 什麼? 32
說了這麼多,最後真正會落地的內容有哪些呢(笑)。首先毫無疑問的,一定是把 UI 堆疊換成 Svelte,並且可以預期,做完之後編譯器的可觀測性大概率會明顯提高。主要原因還是 Svelte 強制並鼓勵把 structure 留在 markup AST 裡,而不是埋進任意的 JS 控制流中,這樣會直接擴大 skeleton / CTR 的可分析空間,也讓 CSS 從 runtime concern 重新變回 build artifact。
這代表 Seam 有機會做元件層級的 CSS 相依、關鍵 CSS、搖樹優化、CSS 延遲載入,而且不必額外維護一套 server / client 的 style runtime。
還有就是 元件歸屬 會徹底變得乾淨;少一點 Provider / Context / global runtime dependency 意味著我有機會徹底回答清楚:哪個值來自 伺服器,哪個地方可以走 CTR 傳遞,誰需要 client boundary(哪些狀態屬於使用者端),以及這個 component 是不是真的需要 hydrate……
在這三者裡面,前兩者可以直接決定 CTR 到底只是一種最佳化手段,還是能真正成為 SeamJS 的主要 執行模型33,這也就是為什麼對我來說放棄 React 不一定真的是技術上的妥協,反過來也可以是結構上的一次破壞性重構。總之我還是那句話
任何東西在生態成熟之後都會出現妥協,
很少會靠 breaking 式的重構來完成革新
既然我還沒真正做起來,那我當然有的是機會推翻重來,暫時也不必考慮遷移成本;我也真的希望有一天,能在無數次推翻重來之後,找到真正屬於我的位置。
哪怕最後只有我一個人在用,這也是屬於我的一次 「實驗 🧪」