我以前常開玩笑說,前端開發的歷史可以透過追溯「狀態到底歸誰」這場爭論來重演。事實上,這兩派多年來一直來回拉扯。我們一開始是在文件式網頁的世界,狀態放在伺服器上。後來加進了 JavaScript 和 Applet,讓用戶端也能有帶狀態的東西。但當時的開發者兩邊都不太喜歡,於是試著用伺服器端語言把狀態跨到兩邊,接著就有了 ASP.NET 之類的東西。
三十多年後的今天,我還坐在這裡,這件事依然在持續。要把你的 React 應用換成 HTMX?也許吧。也許事情沒那麼簡單。雖然這看起來像無止境的迴圈,我倒不覺得它是無解的。即使它已經折騰這麼久,我其實認為答案就在我們眼前。
統一模型的關鍵,在於理解各自的責任。而這些責任和網路本身一樣古老。每次我們把它們對錯位置,複雜度就會暴增。每次我們忽略拼圖的一塊,它就會回頭咬我們一口。每種解法都只跨在這個解空間的一部分上,但我們一直沒能把整個範圍都涵蓋住。
我的觀察是,所有前端架構都可以歸結為三種責任的分層。有些比較顯著,有些比較不明顯,但它們都以某種形式存在。
URL 是網頁體驗的基礎,而它屬於瀏覽器。協調是用戶端的責任,從 SPA 到 HTML partial 的每種方案都同意這點。瀏覽器中的 JavaScript 是我們應用程式的根。
相對地,內容屬於伺服器,不管它是標記還是 JSON。伺服器才是權威來源。它掛在我們的導航之下。
可供性則把我們帶回瀏覽器。像是本地 UI 狀態、進行中的工作、樂觀更新。我們需要在比往返伺服器更快的時間內,提供使用者回饋。
所有網頁應用架構都遵循這種用戶端 -> 伺服器 -> 用戶端的分層。使用者導覽或執行動作,內容從伺服器回來,然後由用戶端把它安定下來。這就是單頁應用(SPA)在載入後的架構,但 HTMX 也是如此。LiveView 也是,只要伺服器 -> 用戶端的協定夠強大。
這裡講的好像是廢話。但網路通常把寫入對應到導航,而伺服器只推送讀取。變更會透過第 1 層,經由表單、動作、失效處理,以及內容請求來傳遞。第 2 層,也就是內容,從不寫入,只負責發佈。
正是這種不對稱,讓它具備可組合性。若把它打破,就會變成兩個有狀態系統互相對抗。這也是為什麼像樂觀更新這類可供性(第 3 層)若被視為覆蓋層,就能很自然地疊在上面。用戶端不會直接寫入伺服器內容,因為它不擁有這些內容。

我試過很多次把解空間映射成四象限圖,但始終沒找到正確的軸線。不過現在我看見了,所有解法其實都能放進同一張圖裡。
當然這只是粗略的定位,解法對應的是範圍,不是單點,而且像 React 這樣的東西也可以作為像 Zero 這種同步引擎的前端。這裡的 React 代表的是傳統 SPA + JSON API。不過多數框架會固定傳輸和可供性的權重,所以它們不太會移動。
但如果用我們對責任的視角來看:
只要稍微引導一下,每一種方案都能表達上述所有模式。它們之間的差異在於傳輸方式、視窗長度,以及你要送出多少可供性程式碼。所以問題最後會落在效率與撰寫體驗上。也就是說,如果每個責任都可調整,那麼單一框架就能全包。
我前面講的內容,其實和基礎平台一直以來的運作方式沒什麼不同,但我們能不能用一種方式把過去 30 年的整個光譜都涵蓋進來?
我打算用 Solid 2.0,透過這三種責任來描繪一幅圖景。
Solid 的路由器在 render tree 裡不擁有任何東西。沒有 <Link>(或 <A>)元件。相反地,我們的 JSX 編譯器會為它建立的每個 a[href] 和 form[action] 元素註冊權責宣告,而伺服器串流過來的內容也會被同樣的權責掃過,套用到 runtime 所渲染的任何子樹上。
// lib/users.ts
export const renameUser = action(
async (id: string, formData: FormData) => {
"use server";
updateUser(id, { name: String(formData.get("name")) });
}
);
// routes/users/[id].tsx — 純 HTML。編譯器會接管
// 錨點與表單。路由器會攔截兩者。水合前後都可運作。
<a href={`/users/${id}`}>{user().name}</a>
<form action={renameUser.with(id)} method="post">
<input name="name" value={user().name} />
<button>Rename</button>
</form>
注意:這段標記中沒有任何元件。
SolidJS 路由器會消耗這些權責宣告來管理 aria-current、作用中 class,以及攔截行為。這代表伺服器渲染的標記,即使完全沒有用戶端元件,也能完整參與用戶端導覽。所以它不只支援「No JS」,也支援有 JavaScript 但沒有互動式元件的情境。
表單和按鈕也以同樣方式委派給伺服器函式動作。你正確地提升預載與失效處理,單次請求的變更(single-flight mutations)意味著一次往返就能讓所有受影響的 UI 同步完成,不管那些 UI 是由 JSON 驅動的用戶端元件,還是由伺服器擁有的標記。
Solid 在第 2 層的強大協定建立於 "use server" 函式之上。Solid 使用 Seroval 在邊界間進行序列化,不只處理純資料,也處理 async 結構,例如 Promise 與 async iterator。伺服器與用戶端之間的溝通,關注的是資料本身,包括那些還沒 settle 的資料。
export async function report(id: string) {
"use server";
return {
title: await getTitle(id),
// AsyncIterable<string> —— 原封不動跨過網路
progress: watchProgress(id),
};
}
// client:iterator 最新產出的值只是一次反應式讀取
const r = createMemo(() => report(props.id));
const progress = createMemo(() => r().progress);
<h1>{r().title}</h1>
<p>{progress()}</p>
不只如此,反應式圖也會跨過網路傳遞。在伺服器端,任何餵給用戶端的運算式,在整個 response 存續期間都保持開放綁定。當 Promise settle 或 iterator 產出新值時,伺服器就會重新執行該運算式,並把新值送到用戶端替換或變形。
到目前為止,我展示的例子都是圍繞原始回應,也就是在 hydration 過程中會發生什麼。即使內容區塊在初次可用時就開始串流進來,只要請求仍然開著,我們就能持續在原地更新串流。
但在事後的伺服器函式請求中,也可以是同樣的情況。我們可以送回本來會是靜態的標記、「Server Components」,並且讓它們的反應式圖完全不送到用戶端,同時仍然做這些精準的串流更新。我們很快就會公布 Server Component 功能的預覽版(不過如果你現在去看,已經可以在我們的 repo 裡看到完整可運作的 範例 了)。
export async function countdown(n: number) {
"use server";
const ticks = tick(n); // AsyncIterable<number>,每秒一次
// 回傳函式代表回傳一個 Server Component
return () => {
const count = createMemo(() => ticks);
return <p>Counted to {count()} of {n}</p>;
};
}
// client — 這裡完全不會送出任何元件程式碼
const Counter = dynamic(() => countdown(10));
<Counter />
由於通訊是單向的,而且只有一套撰寫模型,所以傳輸方式是可插拔的。今天我們使用 Request/Response,因此它會存活到請求結束,並依需要重新連線。把它換成像 SSE 或 socket 這種持久連線,同一個 server component 就會變成不終止的 live component。
// request/response — 圖的左側
export const getAuction = GET(async () => {
"use server";
return read();
});
// persistent — 圖的右側
export const liveAuction = live(async function* () {
"use server";
while (true) { yield read(); await changed(); }
});
// 消費端不在乎拿到的是哪一種
const [auction] = createOptimisticStore(() => liveAuction(), {});
這種設計之所以安全,是 LiveView 的失敗模式給我們的啟示:伺服器的 live graph 必須是持久化狀態可重新推導出的投影。 它永遠不是權威真相。重新連線不依賴 session、事件重播,或任何會隨程序死亡的東西。
這是 SolidJS 一直很擅長的領域。但到了 2.0,async 成為圖的一等公民。從伺服器內容通道到達的值,和在本地計算出的值,對消費端來說是無法區分的。
樂觀更新是一等級原語。它不只是伺服器函式或查詢快取函式庫裡順手附帶的東西。樂觀更新代表任何在 async 過程中預測出的未提交狀態,不管 async 的來源是什麼。它是一個通用概念。它不只是代表某些人會說的「對使用者說謊」,它不必偽裝成不可見的成功。它代表任何預測性的短暫可供性。最後它不是 settle 成真實,就是被回復,但它絕不會活得比自己的交易還久。它避免系統不同步。
const [auction, setAuction] = createOptimisticStore(() => liveAuction(), {});
const placeBid = action(function* (amount: number) {
// overlay — settle 時回復
setAuction(a => { a.highBid = amount });
// mutation - 寫入往上送
yield bid(amount);
// 等到真實結果回來
yield until(() => auction.highBid >= amount);
});
這一切之所以成立,是因為我們絕不會水合超過一次。啟動之後,伺服器就不應該再渲染用戶端元件,因為用戶端狀態早已偏離伺服器所能知道的一切。這是 HTML Partial 和 Islands 方案裡一個關鍵的天真破口,尤其當它們共享用戶端狀態時。等到狀態更新之後才水合,基本上就是 hydration mismatch 的配方。雖然我們可以在初始串流過程中做保護,但這不符合應用程式整個生命週期的現實。
這種規則只有在系統強制執行時才成立。伺服器內容的每一塊都由產生它的呼叫來鍵控,也就是函式與其參數,這和查詢快取會使用的 key 一樣。透過任何傳輸進來的內容,只會寫到那個地址的 store,而掛載中的 UI 則從它綁定的地址讀取。網路和 DOM 之間沒有直接連線。所以 hover 預載不會覆蓋你正在看的頁面,重新抓取會在原地變形,而其中由用戶端擁有的區段仍會存活,過期回應也無法通過版本檢查。
在這個統一模型下,初次載入也會以同樣的方式處理,而且不會支付兩次序列化的成本。用戶端元件會接管伺服器渲染的節點,而啟動時不需要任何請求。唯一被序列化的記錄,是用戶端真的需要的值。只屬於伺服器的內容,要嘛以 HTML 送出,要嘛以資料送出,絕不兩者都送。頁面標記就是有效載荷。對 Server Components 而言,這代表永遠只有一份副本。檢視原始碼並搜尋任何內容,你都只會找到一次。

我曾把這件事定位成「直面前端的存在危機」,但現在它已經是個已解的問題了。
https://www.youtube.com/watch?v=https://youtu.be/nzbV0YgSBuo?si=9xD2U4spvoi1bzEo

這之所以令人興奮,是因為有了上面這些元件之後,你就能完全自由地在整個解空間與中間的每個位置間移動。
如果你從左下角開始,那會像是一個沒有任何用戶端元件的 SolidJS 應用,只透過綁定到 <form action> 的伺服器 "use server" 動作,來交換 server component 區塊。伺服器標記會串流並變形,而導覽則完全不需要任何用戶端元件。
左上角則是我們把第 3 層一路擴張到主導地位,於是你得到 SPA,沒有 server component,也沒有任何伺服器渲染。只有 JSON API。
然後我們可以沿著上方繼續穿越到右側,透過持久化的 "use server" 函式和我們細粒度的樂觀層。當用戶端狀態會在可用時被串流進來時,Solid 的 live 和 until 運算子有助於縮小差距。我還是會用專門的同步引擎,但光靠這些基礎原語就已經能做出相當接近的效果。
最後,右下角更接近我們一開始的地方。只不過這次我們的 server component 帶有反應式綁定,而且連線是持久的。沒有用戶端元件,只有同樣的 "use server" 動作協定負責寫回,而讀取則透過網路串流細粒度的標記片段。在這個世界裡,甚至 server component 也可以是樂觀的,視你怎麼寫動作而定,而 until 再次提供我們完成確認的機制。
最驚人的是,這一切都不需要 Solid 2.0 使用者已經不熟悉的東西。Async Signals 和 use server。有了它們,所有這些體驗都能用幾乎相同的模式,在同一個框架裡,甚至在同一個應用程式裡完成。

一切又回到了起點。那些一直都在我們眼前的幾塊拼圖。
瀏覽器擁有導航與可供性。伺服器擁有內容。通訊是一個迴圈,寫入以導航的形式上行,而內容則以單向讀取的形式下行。而傳輸方式與回應視窗應該是可設定的,且不必改變撰寫的形狀。
從 HTMX 到 LiveView,到 islands,到 SPA,全都是這張圖上的座標。它們之間的差異在於效率與易用性,而不是表達能力。
讓這件事超越單純分類練習的關鍵在於,所有難題都已經被建好了。我們只序列化並水合一次。反應式圖會自己序列化更新。內容按位址儲存,所以不可能表現出過期水合。
Signals 能夠在所有面向上充當這個共同表示法,對我來說並不意外。同步與非同步。伺服器與用戶端。有狀態與無狀態。
這個目標大得有點離譜。RSC 只想提供這張圖的左半邊,而很多人仍覺得它不夠完整。HTML-over-the-wire 陣營則想提供下半邊,但忽略用戶端就等於忽略了一整項責任。
然而,這卻是我想生活在其中的未來。一個你選框架不需要先選教派的未來。以我的角度來看,這個未來其實已經到來了。
原文出處:https://dev.to/playfulprogramming/the-grand-unifying-architecture-of-frontend-bhk