我以前常開玩笑說,前端開發的歷史可以透過追溯「狀態到底歸誰」這場爭論來重演。事實上,這兩派多年來一直來回拉扯。我們一開始是在文件式網頁的世界,狀態放在伺服器上。後來加進了 JavaScript 和 Applet,讓用戶端也能有帶狀態的東西。但當時的開發者兩邊都不太喜歡,於是試著用伺服器端語言把狀態跨到兩邊,接著就有了 ASP.NET 之類的東西。

三十多年後的今天,我還坐在這裡,這件事依然在持續。要把你的 React 應用換成 HTMX?也許吧。也許事情沒那麼簡單。雖然這看起來像無止境的迴圈,我倒不覺得它是無解的。即使它已經折騰這麼久,我其實認為答案就在我們眼前。

統一模型的關鍵,在於理解各自的責任。而這些責任和網路本身一樣古老。每次我們把它們對錯位置,複雜度就會暴增。每次我們忽略拼圖的一塊,它就會回頭咬我們一口。每種解法都只跨在這個解空間的一部分上,但我們一直沒能把整個範圍都涵蓋住。


核心責任

我的觀察是,所有前端架構都可以歸結為三種責任的分層。有些比較顯著,有些比較不明顯,但它們都以某種形式存在。

1. 導航(用戶端)

URL 是網頁體驗的基礎,而它屬於瀏覽器。協調是用戶端的責任,從 SPA 到 HTML partial 的每種方案都同意這點。瀏覽器中的 JavaScript 是我們應用程式的根。

2. 內容(伺服器)

相對地,內容屬於伺服器,不管它是標記還是 JSON。伺服器才是權威來源。它掛在我們的導航之下。

3. 可供性(用戶端)

可供性則把我們帶回瀏覽器。像是本地 UI 狀態、進行中的工作、樂觀更新。我們需要在比往返伺服器更快的時間內,提供使用者回饋。


沿著這個形狀前進

所有網頁應用架構都遵循這種用戶端 -> 伺服器 -> 用戶端的分層。使用者導覽或執行動作,內容從伺服器回來,然後由用戶端把它安定下來。這就是單頁應用(SPA)在載入後的架構,但 HTMX 也是如此。LiveView 也是,只要伺服器 -> 用戶端的協定夠強大。

這裡講的好像是廢話。但網路通常把寫入對應到導航,而伺服器只推送讀取。變更會透過第 1 層,經由表單、動作、失效處理,以及內容請求來傳遞。第 2 層,也就是內容,從不寫入,只負責發佈。

正是這種不對稱,讓它具備可組合性。若把它打破,就會變成兩個有狀態系統互相對抗。這也是為什麼像樂觀更新這類可供性(第 3 層)若被視為覆蓋層,就能很自然地疊在上面。用戶端不會直接寫入伺服器內容,因為它不擁有這些內容。


重新檢視這條光譜

Unified Quadrants

我試過很多次把解空間映射成四象限圖,但始終沒找到正確的軸線。不過現在我看見了,所有解法其實都能放進同一張圖裡。

當然這只是粗略的定位,解法對應的是範圍,不是單點,而且像 React 這樣的東西也可以作為像 Zero 這種同步引擎的前端。這裡的 React 代表的是傳統 SPA + JSON API。不過多數框架會固定傳輸和可供性的權重,所以它們不太會移動。

但如果用我們對責任的視角來看:

  • HTMX:第 1 層極小化,第 2 層大多由 HTML 承擔,第 3 層幾乎沒有。
  • LiveView:第 2 層能力擴展,第 3 層仍幾乎不存在。讀取視窗被拉長到整個 session 的長度。
  • DataStar:第 2 層有更多機制,再加上伺服器填充的 Signals,能與用戶端進行更緊密的互動。
  • Astro(+ View Transitions):導航(view transition) -> 伺服器(markup) -> 用戶端(islands)。三個部分都明確展開。
  • Server Components(Next.js):類似 Astro,但保留了共享的用戶端狀態。
  • SPA + JSON(React):第 3 層增長到與第 1 層合併,而第 2 層則委派給 JSON API。
  • 同步引擎(Zero):第 2 層變成對複寫資料儲存的持久讀取,第 3 層則擴展為保存它的完整本地副本。對整個資料集做樂觀處理。

只要稍微引導一下,每一種方案都能表達上述所有模式。它們之間的差異在於傳輸方式、視窗長度,以及你要送出多少可供性程式碼。所以問題最後會落在效率與撰寫體驗上。也就是說,如果每個責任都可調整,那麼單一框架就能全包。


現代化的詮釋

我前面講的內容,其實和基礎平台一直以來的運作方式沒什麼不同,但我們能不能用一種方式把過去 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、事件重播,或任何會隨程序死亡的東西。


可供性:一次水合的 async 感知圖

這是 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 而言,這代表永遠只有一份副本。檢視原始碼並搜尋任何內容,你都只會找到一次。

serialized once

我曾把這件事定位成「直面前端的存在危機」,但現在它已經是個已解的問題了。

https://www.youtube.com/watch?v=https://youtu.be/nzbV0YgSBuo?si=9xD2U4spvoi1bzEo


穿越這條光譜

Unified Quadrants

這之所以令人興奮,是因為有了上面這些元件之後,你就能完全自由地在整個解空間與中間的每個位置間移動。

如果你從左下角開始,那會像是一個沒有任何用戶端元件的 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。有了它們,所有這些體驗都能用幾乎相同的模式,在同一個框架裡,甚至在同一個應用程式裡完成。


結論

A Möbius strip: one surface that appears to have two sides

一切又回到了起點。那些一直都在我們眼前的幾塊拼圖。

瀏覽器擁有導航與可供性。伺服器擁有內容。通訊是一個迴圈,寫入以導航的形式上行,而內容則以單向讀取的形式下行。而傳輸方式與回應視窗應該是可設定的,且不必改變撰寫的形狀。

從 HTMX 到 LiveView,到 islands,到 SPA,全都是這張圖上的座標。它們之間的差異在於效率與易用性,而不是表達能力。

讓這件事超越單純分類練習的關鍵在於,所有難題都已經被建好了。我們只序列化並水合一次。反應式圖會自己序列化更新。內容按位址儲存,所以不可能表現出過期水合。

Signals 能夠在所有面向上充當這個共同表示法,對我來說並不意外。同步與非同步。伺服器與用戶端。有狀態與無狀態。

這個目標大得有點離譜。RSC 只想提供這張圖的左半邊,而很多人仍覺得它不夠完整。HTML-over-the-wire 陣營則想提供下半邊,但忽略用戶端就等於忽略了一整項責任。

然而,這卻是我想生活在其中的未來。一個你選框架不需要先選教派的未來。以我的角度來看,這個未來其實已經到來了。


原文出處:https://dev.to/playfulprogramming/the-grand-unifying-architecture-of-frontend-bhk


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝57   💬2  
752
🥈
NewsData
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登