Web Day 23 前端整合的選擇:HTMX 與 SPA 的取捨
執行需求:CPU 可跑。今天開始進入「前端整合」這個區塊。我們在前二十二天把 FastAPI 的核心能力(路由、資料、測試、日誌)都練過一輪,接下來要把 API 接到瀏覽器上。這個區塊的重點不是「教你寫前端」,而是「讓你看到後端怎麼與前端對接」。今天的任務只有一個:盤點選項,看清每條路的成本與適合的場景。
引言
很多後端工程師一聽到「前端整合」就開始焦慮——「我不會 React」「我只寫過 jQuery」「我沒時間學整套前端工具鏈」。但其實,「前端」這件事在 2025 年已經分成兩條明顯分歧的路:一條是「伺服器回傳 HTML,瀏覽器只負責顯示」的傳統模型,由 HTMX 2.0 重新發揚光大;另一條是「伺服器回傳 JSON,瀏覽器用 JavaScript 拼介面」的 SPA(Single Page Application)模型,由 React 19 與 Next.js 15 為代表。兩條路都有成熟生態圈,都能在 2025 年 7 月這個時間點找到豐富的套件與教學。
這篇文章的目的,是幫你回答三個問題:第一,HTMX 跟 SPA 各自的成本與產出是什麼?第二,什麼場景適合選哪一條路?第三,這個系列後續六天(Web Day 24 到 Web Day 29)會走哪一條、為什麼這樣選?我們會先比較兩種架構的核心概念,接著用同一個小範例(待辦清單)分別實作兩個版本,最後整理選型決策樹。貫穿專案「預約管理系統」會在後面的 Web Day 35 開始建構,今天先把工具選好。
兩種架構的核心差異
HTMX 與 SPA 不是「前端框架」對「另一個前端框架」的競爭,它們根本是兩種不同的系統分工方式。先看一張對照表,把心智模型建立起來。
| 維度 | HTMX 2.0(伺服器渲染) | SPA(Next.js 15 / React 19) |
|---|---|---|
| 伺服器回傳 | HTML 片段 | JSON |
| 前端打包需求 | 不需要 bundler | 需要 bundler(webpack / Turbopack) |
| 互動狀態管理 | 由伺服器負責 | 由前端(React state)負責 |
| SEO 友善度 | 預設友善(HTML 已含內容) | 需 SSR / SSG 才能友善 |
| 初次載入速度 | 快,HTML 直接渲染 | 較慢,需下載整包 JS |
| 互動流暢度 | 依賴伺服器回應時間 | 本地操作不需打伺服器 |
| 後端模板 | Jinja2 / 任何 HTML 模板 | JSON + 前端組件 |
| 離線運作 | 不行 | 可(透過 Service Worker) |
HTMX 2.0 在 2024 年底發布穩定版,它的設計哲學是「讓 HTML 變成超媒體」。傳統 HTML 只有 <a> 與 <form> 能發出 HTTP 請求,HTMX 把這個能力擴展到任何元素、任何 HTTP 方法(GET、POST、PUT、DELETE)、任何事件(點選、變更、捲動到某處)。伺服器收到請求後回傳 HTML 片段,HTMX 自動把片段塞回頁面對應位置,開發者不需要寫 JavaScript 也能做出「按按鈕載入新資料」「表單送出後原地更新」的互動。
SPA 模型則把整個畫面包進一個 JavaScript 應用:第一次載入時伺服器回傳一份「空的 HTML + 一大包 JS」,JS 在瀏覽器內跑起來後,用 React 19(或 Vue、Svelte 等)的組件系統拼出畫面,後續所有畫面更新都在本地完成,只有資料存取才需要打伺服器(這時伺服器只回 JSON)。Next.js 15 預設用 React Server Components,把 HTML 渲染責任拉回伺服器,但又允許互動部分保留為 client component,是兩種架構之間的混合方案。
HTMX 的關鍵屬性
為了讓後續 Web Day 24 的實作更有感,今天先把 HTMX 的關鍵屬性(hx-* 開頭)盤一次。這些屬性都掛在普通 HTML 元素上,不需要寫 JavaScript:
| 屬性 | 作用 | 常見搭配 |
|---|---|---|
| hx-get / hx-post / hx-put / hx-delete | 指定觸發的 HTTP 方法與路徑 | 任何元素 |
| hx-target | 回傳片段要塞進哪個 CSS 選擇器 | #id、.class |
| hx-swap | 塞入的方式(innerHTML、outerHTML、beforeend 等) | outerHTML 適合替換整個元素 |
| hx-trigger | 什麼事件觸發(click、change、keyup、revealed) | delay 500ms 防抖 |
| hx-include | 額外帶哪些表單欄位一起送 | 搜尋條件與表單混合 |
| hx-confirm | 送出前彈出確認對話框 | 刪除等危險操作 |
這些屬性背後的設計原則是「駭入 HTML」。原本 HTML 是 markup 語言,只能描述「這是什麼」(標題、段落、表格),HTMX 把「這要怎麼互動」也寫進 HTML。對後端工程師來說,這代表你不需要懂 React 的渲染生命週期、虛擬 DOM、狀態提升,只要會寫 Jinja2 模板就能做出現代化的互動介面。我們會在 Web Day 24 用這些屬性實作一個完整的預約後台預覽,今天先做心理建設。
用一個待辦清單看差異
抽象的比較不容易感受差異,我們用具體範例說明。情境:使用者想新增一筆待辦,看到更新後的清單;可以刪除某一筆;刪除後清單立即更新。先看 HTMX 2.0 的版本(伺服器端 FastAPI):
# main_htmx.py
# HTMX 版本的待辦清單(伺服器回傳 HTML 片段)
import uuid
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="templates")
# 假資料:實際上線會用 SQLModel 接資料庫(Web Day 6 已示範)
TODOS: list[dict] = []
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
# 回傳完整 HTML 頁面
return templates.TemplateResponse(
request, "index_htmx.html", {"todos": TODOS}
)
@app.post("/todos", response_class=HTMLResponse)
def create_todo(content: str = Form(...)):
# HTMX 表單送出後,伺服器只回傳新增的 <li> 片段
todo = {"id": str(uuid.uuid4()), "content": content}
TODOS.append(todo)
# 用 Jinja2 模板渲染單筆條目,HTMX 會自動 append 到清單末尾
return templates.TemplateResponse("partial_todo.html", {"todo": todo})
@app.delete("/todos/{todo_id}", response_class=HTMLResponse)
def delete_todo(todo_id: str):
# HTMX 的 DELETE 請求;回傳空字串表示把整個元素從 DOM 移除
TODOS[:] = [t for t in TODOS if t["id"] != todo_id]
return ""
搭配兩個 Jinja2 模板。index_htmx.html 是完整頁面(內含一個空清單與表單),partial_todo.html 是單筆的 HTML 片段:
<!-- templates/index_htmx.html -->
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<title>HTMX 待辦清單</title>
<!-- HTMX 2.0:單一檔案、CDN 引入即可 -->
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfz1gfQXltEFVcsSwPNyBucRoO3ZrAaZEzVcqkcxgYTz32Lpna1eAypVdsT3tbA"
crossorigin="anonymous"></script>
</head>
<body>
<h1>待辦清單</h1>
<ul id="todo-list">
{% for todo in todos %}
{% include "partial_todo.html" %}
{% endfor %}
</ul>
<form hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend">
<input name="content" placeholder="新增待辦" required>
<button type="submit">新增</button>
</form>
</body>
</html>
<!-- templates/partial_todo.html -->
<li id="todo-{{ todo.id }}">
{{ todo.content }}
<button hx-delete="/todos/{{ todo.id }}"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML">刪除</button>
</li>
整份 HTMX 版本只有兩個 HTML 模板與一支 FastAPI 程式,沒有一行 JavaScript。表單送出後,HTMX 把伺服器回傳的 <li> 片段加到清單末尾;刪除按鈕送出 DELETE 請求,伺服器回空字串,HTMX 把對應的 <li> 從 DOM 移除。我們用 curl 驗證一下新增端點(伺服器端)會回什麼:
# 啟動伺服器(在另一個終端機)
uvicorn main_htmx:app --reload --port 8000
# 用 curl 模擬 HTMX 的表單送出
curl -s -X POST http://127.0.0.1:8000/todos \
-d "content=買牛奶"
# 輸出:<li id="todo-abc123">買牛奶<button hx-delete="/todos/abc123" ...>刪除</button></li>
curl -s -X POST http://127.0.0.1:8000/todos \
-d "content=回覆信"
# 輸出:<li id="todo-def456">回覆信<button hx-delete="/todos/def456" ...>刪除</button></li>
伺服器回的是純 HTML 片段,HTMX 在瀏覽器收到後會把它塞進 #todo-list 末尾。這驗證了 HTMX 的本質:「瀏覽器是渲染引擎,伺服器是內容來源」,瀏覽器不負責組裝畫面,只負責接收伺服器給的片段並放到對應位置。
對照 Next.js 15(React 19)版本:
// app/page.jsx
// Next.js 15 App Router:client component 處理互動
"use client";
import { useState } from "react";
export default function TodoPage() {
const [todos, setTodos] = useState([]);
const [content, setContent] = useState("");
async function addTodo(e) {
e.preventDefault();
// 伺服器只回 JSON,不渲染 HTML
const res = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
const todo = await res.json();
setTodos([...todos, todo]);
setContent("");
}
async function deleteTodo(id) {
await fetch(`/api/todos/${id}`, { method: "DELETE" });
setTodos(todos.filter((t) => t.id !== id));
}
return (
<div>
<h1>待辦清單</h1>
<ul>
{todos.map((t) => (
<li key={t.id}>
{t.content}
<button onClick={() => deleteTodo(t.id)}>刪除</button>
</li>
))}
</ul>
<form onSubmit={addTodo}>
<input
value={content}
onChange={(e) => setContent(e.target.value)}
required
/>
<button type="submit">新增</button>
</form>
</div>
);
}
SPA 版本把「狀態」放進 React 的 useState:新增時送 fetch、更新 todos 陣列、React 重渲染畫面;刪除時送 DELETE、過濾陣列、畫面更新。伺服器端只需要一支簡單的 JSON API:
# api_frontend.py
# 對應 Next.js 前端的 FastAPI JSON API
import uuid
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
TODOS: list[dict] = []
class TodoIn(BaseModel):
content: str
@app.get("/api/todos")
def list_todos():
return TODOS
@app.post("/api/todos", status_code=201)
def create_todo(payload: TodoIn):
todo = {"id": str(uuid.uuid4()), "content": payload.content}
TODOS.append(todo)
return todo
@app.delete("/api/todos/{todo_id}", status_code=204)
def delete_todo(todo_id: str):
TODOS[:] = [t for t in TODOS if t["id"] != todo_id]
return None
兩種版本功能等價,但成本結構不同。HTMX 版本的後端多寫了兩個 Jinja2 模板、前端完全沒打包步驟;SPA 版本的後端更簡潔(純 JSON)、但前端需要 Node.js 環境、Next.js 工具鏈、React 狀態邏輯、CORS 設定。哪一邊比較划算,要看你的團隊、需求與長期維護成本。
我們也可以用 httpx 程式化測試 JSON API 的行為(這是貫穿後面測試篇章的同一套工具,Web Day 16、17 會詳細展開):
# test_spa_api.py
# 驗證 FastAPI 對 SPA 的 JSON API
import httpx
BASE = "http://127.0.0.1:8000"
with httpx.Client(base_url=BASE, timeout=5.0) as client:
r = client.post("/api/todos", json={"content": "買牛奶"})
r.raise_for_status()
todo = r.json()
print(todo["id"], todo["content"])
# 輸出(範例):abc123 買牛奶
todo_id = todo["id"]
r = client.delete(f"/api/todos/{todo_id}")
print(r.status_code)
# 輸出:204
這個測試跟 Web Day 16 的 TestClient 寫法幾乎相同,只是把測試用的 TestClient(app) 換成真實的 httpx.Client 連到 127.0.0.1:8000。好處是可以對 HTMX 版本與 SPA 版本共用同一套測試基礎設施。
除了「新增 / 刪除」這類一來一回的操作,HTMX 也能做「定時重新整理」與「捲動到位置才載入」。下面示範「輪詢伺服器取得最新待辦」——伺服器提供一個只回部分 HTML 片段的端點,HTMX 每 5 秒自動重抓一次:
# 加上輪詢端點(接續 main_htmx.py)
import time
@app.get("/todos/recent", response_class=HTMLResponse)
def recent_todos(since: float = 0.0):
# 只回傳時間戳記大於 since 的待辦,避免每次都回整個清單
fresh = [t for t in TODOS if t["created_at"] > since]
return templates.TemplateResponse(
"partial_todos.html", {"todos": fresh}
)
搭配 hx-trigger="every 5s" 就能讓瀏覽器每 5 秒打一次 /todos/recent:
<!-- 自動更新區塊 -->
<div hx-get="/todos/recent"
hx-trigger="every 5s"
hx-swap="afterbegin">
</div>
這個模式在「預約管理系統」會反覆出現:後台想看最新的訂位、通知、未讀訊息時,HTMX 輪詢比寫 WebSocket 簡單。Web Day 28 才會進入 WebSocket 領域,今天先確認這條路存在。我們也用 pytest 為這個端點寫個基本測試,避免改版時把輪詢邏輯改壞:
# test_polling.py
# 驗證 /todos/recent 只回時間戳記大於 since 的條目
from fastapi.testclient import TestClient
from main_htmx import app
client = TestClient(app)
# 先新增一筆待辦
r = client.post("/todos", data={"content": "寫測試"})
assert r.status_code == 200
# 抓取 since=0 的所有待辦
r = client.get("/todos/recent", params={"since": 0.0})
assert r.status_code == 200
assert "寫測試" in r.text
# since 設成未來時間,應該回空
r = client.get("/todos/recent", params={"since": time.time() + 1000})
assert "<li" not in r.text # 沒有任何 <li> 元素
這個測試展示 pytest + TestClient(Web Day 16 的標準做法)如何驗證 HTMX 端點。測試不需要真的開瀏覽器,只要驗證「伺服器回的字串包含預期的 HTML 片段」即可。
最後順手展示 HTMX 的 loading indicator。當使用者送出請求時,加上一個 spinner 可以避免「我按了沒反應嗎?」的焦慮:
<button hx-post="/todos"
hx-target="#todo-list"
hx-swap="beforeend"
hx-indicator="#spinner">
新增
</button>
<img id="spinner" class="htmx-indicator" src="/static/spinner.gif">
HTMX 會在送出請求時自動加上 htmx-request class,可以搭配 CSS 控制 spinner 的顯示:
/* 在 static/style.css */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request button[type="submit"] {
opacity: 0.6;
pointer-events: none;
}
後端對應的端點只要在處理時間稍長時回傳延遲(例如讀寫資料庫、呼叫外部 API),使用者就會看到 spinner 旋轉。這個模式不需要寫 JavaScript,是 HTMX 最常被低估的功能之一。底下用 httpx 模擬瀏覽器送出表單、驗證伺服器回應的時間戳記格式正確(這是我們常在 production 加的契約測試):
# test_contract.py
# 驗證伺服器回應的 HTML 結構符合 HTMX 預期
import re
import httpx
with httpx.Client(base_url="http://127.0.0.1:8000", timeout=5.0) as client:
r = client.post("/todos", data={"content": "買牛奶"})
assert r.status_code == 200
# 確認回傳的 HTML 含 <li> 與 hx-delete 屬性
assert re.search(r'<li id="todo-[a-z0-9]+">', r.text)
assert 'hx-delete="/todos/' in r.text
print("HTMX 契約測試通過")
# 輸出:HTMX 契約測試通過
這個契約測試比純單元測試多做一件事:它把 HTMX 的 HTML 結構當成 API 的一部分看待。如果哪天有人把 <button hx-delete> 改成 <form>,這個測試會立刻失敗,提醒團隊「HTMX 的片段是介面契約」。這種測試在多頁應用很有價值,因為它把「瀏覽器對 HTML 的依賴」寫成可自動驗證的規格。
選型決策樹
把「HTMX 還是 SPA」換成幾個可回答的問題,能幫助你快速決策。第一個問題是「頁面內容是否需要即時根據使用者狀態變化?」如果答案是「是」,SPA 通常比較自然——React 的狀態機制(hooks、context、Redux)能優雅處理複雜互動;如果只是「按按鈕載入下一頁片段」,HTMX 就夠了。第二個問題是「SEO 重要嗎?」如果你做的是內部後台、客戶管理系統,SEO 完全不重要;如果是內容網站、電商首頁,HTML 友善度直接影響搜尋排名。第三個問題是「團隊熟悉度」:你的團隊熟 Python 模板還是 JavaScript?強迫不熟 React 的人寫 client component,通常會比強迫不熟 Jinja2 的人寫模板付出更高的學習成本。
本系列後續六天的安排如下:Web Day 24 會用一整篇實作 HTMX 的常見模式(按鈕載入片段、表單送出、刪除元素、輪詢更新);Web Day 25 與 Web Day 26 會用兩篇分別示範 Next.js 15 的「讀取」與「寫入」,只做最小串接、不教完整前端;Web Day 27 處理前端認證的 token 儲存與自動登出;Web Day 28 介紹 WebSocket 即時更新;Web Day 29 進入「上線」前的設定管理。這樣的安排是刻意設計:先把 HTMX 走到能動,再讓 Next.js 走到能讀寫,最後用 WebSocket 補上即時更新的需求。貫穿專案「預約管理系統」會在 Web Day 35 之後用 HTMX 做後台(管理者介面、需要密集互動但 SEO 不重要)、用 Next.js 做對外預約頁(需要 SEO 友善、客戶互動簡單)。
常見錯誤與踩雷
第一個常見的踩雷是「以為 HTMX 不能用於大型應用」。HTMX 2.0 沒有狀態管理、沒有組件系統,但它搭配 Jinja2 模板片段、View Component、甚至是 HTMX 的 OOB(Out of Band)交換,能處理多區塊同時更新的複雜互動。實際上大型內部系統(如 GitHub 的部分後台、一些 CMS)就用 HTMX 做得很順暢。問題不在 HTMX,而在於「單一狀態改變需要同步更新畫面上多個區塊」這種場景,HTMX 需要多個端點配合,比 React 的單一狀態樹麻煩一些。
第二個常見的踩雷是「以為 Next.js 不需要學 React」。雖然 Next.js 15 的 Server Components 能讓你少寫一些 client code,但只要你需要互動(表單、即時更新、動畫),就一定要碰 hooks 與狀態邏輯。把 Next.js 當成「不寫 JS 的後端框架」會撞牆——你終究要懂 React 的渲染生命週期。
第三個常見的踩雷是「混用兩種架構」。例如「主介面用 HTMX,裡面嵌入一個 React 元件做圖表」。這在技術上可行(用 iframe 或 web component 隔離),但會帶來狀態同步、除錯、CSS 隔離等額外成本。除非有強烈的歷史包袱,建議在同一個應用內只選一條路。
第四個是「以為 SPA 比較潮所以比較好」。在內部工具場景,HTMX 的開發速度往往快 SPA 三到五倍。除非使用者明確需要「無刷新切換」「離線運作」「複雜前端狀態」,否則 HTMX 是更務實的選擇。
第五個常見的踩雷是「CORS 沒設好」。如果你用 SPA 模式而忘了設定 CORS(Web Day 15 會展開),瀏覽器會擋住 fetch 請求,只有伺服器看得見 201/204、瀏覽器卻說「No 'Access-Control-Allow-Origin' header is present」。對應排查:先用 curl 確認伺服器本身正常,再用瀏覽器的開發者工具看 network 分頁。
效能與實務提醒
在效能面上,HTMX 的初次載入明顯佔優:瀏覽器拿到完整 HTML 就開始渲染,不需要等 JS 下載完成。但後續互動會多一次 HTTP 往返,使用者每次點按鈕都要等伺服器回應。SPA 初次載入慢(需要下載整包 JS),但後續互動幾乎都在本地完成。對「內容網站」這類以讀取為主的應用,HTMX 的總體體驗通常較好;對「互動工具」這類需要頻繁操作的應用,SPA 的操作流暢度較高。Web Day 28 會介紹 WebSocket,它能讓 HTMX 也達到「部分即時更新」的效果。
實務上的提醒:如果你選 HTMX,後端一定要有完整的測試覆蓋(Web Day 16、17),因為前端能做的事很少,很多錯誤都會直接反映在伺服器回傳的 HTML;如果你選 Next.js,前端的狀態邏輯單測要用 React Testing Library 或 Vitest(Day 16 的延伸),不要只測後端。這系列對 SPA 的測試著墨較少(不是重點),但你自己做專案時務必記得。
最後一個提醒:選 HTMX 不代表放棄前端生態。HTMX 可以與 Alpine.js(輕量 JS 框架,15 KB)搭配,做一些 HTMX 做不到的本地互動(如 modal 切換);也可以單獨使用 Hyperscript(HTMX 團隊出品)寫幾行宣告式腳本。但這些都是加分題,先把 HTMX 純粹用好再考慮。這個系列只走純 HTMX,不混 Alpine / Hyperscript,避免不必要的複雜度。
小結
今天我們把「前端整合」的兩條路線攤開來:HTMX 2.0 走「伺服器回 HTML、瀏覽器只顯示」的路線,後端多寫模板但前端零打包;SPA(Next.js 15 / React 19)走「伺服器回 JSON、前端拼畫面」的路線,前端互動流暢但需要 bundler 與狀態管理。我們用同一個待辦清單範例分別實作兩種版本,凸顯兩者的成本結構。選型沒有標準答案,請依「互動複雜度、SEO 需求、團隊熟悉度」三個維度決定。後續六天的安排是:Web Day 24 實作 HTMX、Web Day 25–26 實作 Next.js 串接、Web Day 27 處理認證、Web Day 28 介紹 WebSocket、Web Day 29 進入上線準備。貫穿專案「預約管理系統」會在 Web Day 35 之後用 HTMX 做後台、用 Next.js 做對外頁。今天先把地圖畫清楚,明天我們就動手寫第一個完整的 HTMX 介面。
結語
今天的重點是「先選路,再上路」。我們比較了 HTMX 2.0 與 SPA(Next.js 15 / React 19)的核心差異、實作了等價範例、給出選型決策樹,並說明本系列後續六天的安排。讀完這篇你應該能回答:HTMX 與 SPA 在後端回傳內容、互動模式、開發成本上有什麼差別?什麼場景適合選哪一條路?為什麼這個系列要先教 HTMX、再教 Next.js?
明天,我們會用一整篇的篇幅把 HTMX 的常見模式走一遍:在 FastAPI 上架設 HTMX 介面、實作「按按鈕載入片段」「表單送出後原地更新」「刪除元素」「輪詢伺服器取得最新資料」四種互動,並且把「待辦清單」延伸成「預約管理系統的後台預覽」。HTMX 的程式碼量會比 Next.js 少很多,但概念需要一次想透;請留一段完整的時間跟著做。
延伸資源
- HTMX 2.0 官方 Docs(2024):
https://htmx.org/docs/,核心概念、hx-* 屬性清單、AJAX 細節。 - Next.js 15 官方 Docs(2025-07):
https://nextjs.org/docs,App Router、Server Components、Client Components 的差異。 - React 19 官方 Docs(2025):
react.dev,hooks、useState、useEffect、Suspense 的現行用法。 - Jinja2 官方 Docs(2025):
https://jinja.palletsprojects.com/,模板語法、巨集、include 與 extends 的搭配。 - FastAPI 模板支援(0.116,2025):
https://fastapi.tiangolo.com/advanced/templates/,Jinja2Templates 的初始化與環境變數注入。
留言
張貼留言