BFF 的兩種組合:htmx 回 partial,SvelteKit 才走 JSON

BFF 講久了會被當成一個架構名詞,其實它就是一條 call chain 上的一站。瀏覽器打進來,BFF 負責聚合多個下游、把回應裁剪成前端要的結構、把密鑰跟下游位址藏在背後,然後才往內部服務發請求。外部請求永遠不直接碰資料層,這是這層存在的理由,不是為了多一層而多一層。

到了 2025 年底,Fastify v5、SvelteKit 2、htmx 2 都已經是成熟的東西,可以放心拿來搭。比較少人講清楚的是:同一個 BFF 後面,前端那一端其實有兩種接法,而且不該二選一,要看互動本身決定。

一條 call chain 長什麼樣

先把骨架擺出來。瀏覽器 → BFF → 下游服務,這條鏈上 BFF 做三件事:聚合、裁剪、藏。聚合是把好幾個下游的回應併成一份;裁剪是只回前端真正用得到的欄位,把內部結構擋掉;藏是讓密鑰、下游 URL、認證細節都留在 BFF 後面,前端完全看不到。

往下游那一段不是裸打。內部呼叫用 HMAC 簽一個 header,例如 X-Internal-Auth,下游驗簽才回應。不需要外部 PKI,內部服務共用一把 secret 就夠,重點是讓下游能拒絕「不是從 BFF 來」的請求。這樣即使有人摸到內網,也沒辦法直接跟資料層對話。

// BFF 往下游:簽一個 HMAC header 再打
const sig = createHmac('sha256', INTERNAL_SECRET)
  .update(`${ts}.${body}`)
  .digest('hex')
await fetch(`${DOWNSTREAM}/orders`, {
  method: 'POST',
  headers: { 'X-Internal-Auth': `${ts}.${sig}` },
  body,
})

這條鏈到 BFF 為止都一樣,差別在 BFF 回給瀏覽器的那一段:要嘛回 HTML,要嘛回 JSON。

組合 A:BFF + htmx,直接回 partial HTML

後台管理那種畫面,互動模式其實很固定:點分頁、改篩選、按一下展開某列。這些動作的共通點是,狀態天然就在 server,前端不需要自己維護一份。這時候讓 BFF 直接回 partial HTML 最省力,省掉一整層 JSON 契約。

htmx 2 的做法很直白,按鈕掛 hx-get 指到 BFF 的 route,BFF 聚合完下游、把資料塞進模板、回一段 HTML 片段,htmx 換掉目標節點就結束。沒有前端 model、沒有序列化再反序列化、沒有「後端改欄位前端忘了改型別」這種事,因為根本沒有共享的型別契約要維護。

<!-- 後台表格分頁,點了就換 tbody -->
<tbody id="rows"
  hx-get="/bff/orders?page=2&status=paid"
  hx-trigger="click from:#next"
  hx-target="#rows"
  hx-swap="outerHTML">
  ...
</tbody>

BFF 那端對應的 route,做的事情跟前面那條 call chain 一模一樣,只是最後吐的是 HTML 不是 JSON。聚合、裁剪、HMAC 簽章往下游,全都在這裡發生。前端拿到的是已經渲染好的結果,瀏覽器不用算。

這套的好處是邊界少一層。壞處也很實在:互動一旦複雜起來,例如要在 client 端做樂觀更新、拖拉排序、離線暫存,partial HTML 就頂不住了,那是 client 狀態的地盤。所以準則不是「htmx 比較潮」,而是「這個互動到底要不要 client 端狀態」。後台表格不要,那就 htmx。

組合 B:BFF + SvelteKit,需要 client 互動才走 JSON

換個場景。交易紀錄查詢頁,使用者要切時間區間、即時看圖表、排序欄位還要記住捲動位置,這種互動的狀態活在 client,回 partial HTML 反而綁手綁腳。這時候走 SvelteKit 2 的 server load 跟 form actions 比較對。

差別在哪?SvelteKit 的 server load 本身就是一個跑在 server 的聚合點,它可以扮演 BFF 的角色:在 load 裡面打多個下游、簽 HMAC、把結果裁剪成頁面要的欄位,回傳的是結構化資料給元件用。首次進頁是 SSR,把第一份資料直接渲染進 HTML;之後的互動才透過 +server.ts 的 JSON endpoint 補資料,client 拿 JSON 自己更新狀態。

// +page.server.ts:load 就是 BFF 聚合點
export const load = async ({ fetch, url }) => {
  const range = url.searchParams.get('range') ?? '7d'
  const [trades, summary] = await Promise.all([
    signedFetch(fetch, `/internal/trades?range=${range}`),
    signedFetch(fetch, `/internal/summary?range=${range}`),
  ])
  return { trades: await trades.json(), summary: await summary.json() }
}

這裡 JSON 契約是有代價的:你多維護一份前後端共享的欄位定義,後端改欄位、前端得跟著調。但換來的是 client 端能自由地維護狀態、做即時互動,這在高互動頁面是值得的交換。重點是別把這個成本套到不需要的地方,後台表格用 SvelteKit 走 JSON 不是錯,只是平白多扛一層契約。

兩種組合共用同一條 call chain 跟同一套 HMAC 下游保護,前端那段才分岔。判斷準則一句話:這個互動的狀態該活在 server 還是 client。活在 server,htmx 回 partial;活在 client,SvelteKit 走 JSON。先問這句,再決定接法,不要反過來先選框架再勉強拼互動。

重點整理

  • BFF 在 call chain 上做三件事:聚合下游、裁剪回應、藏住密鑰與下游位址,外部請求不直接碰資料層。
  • 內部呼叫用 HMAC 簽 header(如 X-Internal-Auth),下游驗簽才回,不需要外部 PKI。
  • 後台表格這種狀態在 server 的互動,用 htmx 回 partial HTML,省掉一層 JSON 契約。
  • 高互動頁面狀態在 client,用 SvelteKit server load 當聚合點、需要時走 JSON endpoint。
  • 判斷準則只有一句:這個互動的狀態該活在 server 還是 client,先問再選接法。

觀點 Sheng,內文 Claude 協助 · 列入 20260613 blog 翻新計劃,新漆未乾。