Web Day 24 HTMX 動態介面實作
執行需求:CPU 可跑。昨天我們把 HTMX 2.0 與 SPA 的取捨講完,今天動手實作。我們會用 FastAPI + Jinja2 + HTMX 2.0 做一個「預約管理後台」的雛形,包含預約清單、新增表單、刪除按鈕、衝突提示、輪詢更新這五個常用互動。這個雛形會在後面的 Web Day 36 與 Web Day 39 演化成貫穿專案的後台,本篇先讓你看見完整形狀。
引言
昨天我們用一個待辦清單展示了 HTMX 的核心觀念:HTML 屬性能發 HTTP 請求、伺服器回 HTML 片段、瀏覽器只負責把片段塞到對應位置。今天把這個觀念放大到「真實應用會遇到的互動」:依條件篩選、批次送出、刪除前的確認、伺服器忙碌時顯示 spinner、輪詢伺服器取得最新狀態。我們會把這些模式一個一個串起來,最後做出一個能跑、能測、能驗證的後台雛形。
這篇文章會做六件事:第一,介紹 HTMX 2.0 在 FastAPI 上的最小安裝;第二,建立一個完整的「預約管理後台」骨架與共享模板;第三,實作「預約清單+新增預約」的常見互動;第四,加入「刪除確認」與「忙碌指示器」;第五,加入「輪詢取得最新狀態」;第六,用 pytest 與 httpx 驗證整個流程可運作。貫穿專案「預約管理系統」的後台(管理者介面)會在 Web Day 39 走到完整版,今天先把工具與模式練熟。讀完這篇你應該能回答:HTMX 2.0 在 FastAPI 上的最小依賴是什麼?如何用 Jinja2 模板片段做為 HTMX 的回傳內容?HTMX 的輪詢與確認機制怎麼寫?
環境準備
Web Day 2 已用 uv 建立專案,今天只需要再加一個 Jinja2 套件(FastAPI 0.116 內建支援)。如果你從零開始,指令如下:
# 用 uv 建立專案(沿用 Web Day 2 的流程)
uv init booking-admin
cd booking-admin
uv add "fastapi[standard]==0.116" "jinja2==3.1.5" "python-multipart==0.0.20"
uv add --dev "pytest==8.4" "httpx==0.28"
# 確認版本
uv run python -c "import fastapi, jinja2; print(fastapi.__version__, jinja2.__version__)"
# 輸出(範例):0.116.x 3.1.5
HTMX 2.0 不需要 pip 安裝,它是純前端檔案。我們用官方 CDN 引入,並且加上 SRI(Subresource Integrity)雜湊確保檔案未被竄改:
<!-- templates/base.html -->
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<title>{% block admin_title %}預約後台{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfz1gfQXltEFVcsSwPNyBucRoO3ZrAaZEzVcqkcxgYTz32Lpna1eAypVdsT3tbA"
crossorigin="anonymous"></script>
</head>
<body>
<header>
<h1>預約管理後台</h1>
<nav>
<a href="/admin/dashboard">儀表板</a>
<a href="/admin/bookings">預約清單</a>
</nav>
</header>
<main>
{% block main %}{% endblock %}
</main>
</body>
</html>
base.html 是所有頁面的骨幹:標頭、導覽列、HTMX 載入、靜態樣式。{% block admin_title %} 與 {% block main %} 是子模板會覆寫的區塊。HTMX 2.0 的版本我們固定在 2.0.4(2025 年 7 月時的穩定版),用 SRI 雜湊確保 CDN 沒被動手腳。如果你在內網或無法存取 unpkg,可以把 HTMX 下載到 static/htmx.min.js,把 src 改成 /static/htmx.min.js 即可。
完整實作:預約後台雛形
接下來是後端 FastAPI 程式。它示範五個 HTMX 端點:儀表板、預約清單(可篩選)、新增預約片段、刪除預約、輪詢最新預約。
# main.py
# 預約管理後台雛形(HTMX + FastAPI + Jinja2)
import uuid
from datetime import datetime, timezone
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI(title="預約管理後台", version="0.1.0")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# 假資料:實際上線會用 SQLModel 接資料庫(Web Day 35 演進)
BOOKINGS: list[dict] = []
def _now_ts() -> float:
# 統一用 UTC 秒數當 created_at,方便前端做輪詢差集
return datetime.now(timezone.utc).timestamp()
@app.get("/", response_class=HTMLResponse)
def home():
# 直接導向儀表板
return HTMLResponse(
'<a href="/admin/dashboard">前往後台</a>'
)
@app.get("/admin/dashboard", response_class=HTMLResponse)
def dashboard(request: Request):
return templates.TemplateResponse(
request, "dashboard.html",
{"total": len(BOOKINGS), "now": datetime.now(timezone.utc)},
)
@app.get("/admin/bookings", response_class=HTMLResponse)
def bookings(
request: Request,
status: str = Query(default="all"),
q: str = Query(default=""),
):
# 篩選條件:狀態與關鍵字
rows = BOOKINGS
if status != "all":
rows = [b for b in rows if b["status"] == status]
if q:
rows = [b for b in rows if q.lower() in b["customer"].lower()]
return templates.TemplateResponse(
request, "bookings.html",
{"rows": rows, "status": status, "q": q},
)
這是後端的第一塊,包含三個渲染完整頁面的端點。/admin/bookings 接受 status 與 q 兩個查詢參數,後端根據參數過濾後渲染清單。HTMX 會用 hx-include 把表單欄位自動帶進 URL,這樣篩選不用寫 JavaScript。繼續往下看:
# 續 main.py:HTMX 端點(回 HTML 片段,不是完整頁面)
@app.post("/admin/bookings/new", response_class=HTMLResponse)
def create_booking(
customer: str = Form(...),
slot: str = Form(...),
service: str = Form(default="教練課"),
):
# 表單送出後,回傳新增的 <tr> 片段
booking = {
"id": str(uuid.uuid4()),
"customer": customer,
"slot": slot,
"service": service,
"status": "confirmed",
"created_at": _now_ts(),
}
BOOKINGS.append(booking)
return templates.TemplateResponse(
"partial_booking_row.html", {"row": booking}
)
@app.delete("/admin/bookings/{booking_id}", response_class=HTMLResponse)
def delete_booking(booking_id: str):
# HTMX 送出 DELETE;伺服器回空字串,HTMX 把對應 <tr> 移除
before = len(BOOKINGS)
BOOKINGS[:] = [b for b in BOOKINGS if b["id"] != booking_id]
if len(BOOKINGS) == before:
raise HTTPException(status_code=404, detail="預約不存在")
return HTMLResponse("")
@app.get("/admin/bookings/recent", response_class=HTMLResponse)
def recent_bookings(since: float = Query(default=0.0)):
# 輪詢端點:只回時間戳記大於 since 的預約
fresh = [b for b in BOOKINGS if b["created_at"] > since]
return templates.TemplateResponse(
"partial_booking_rows.html", {"rows": fresh}
)
三個關鍵的 HTMX 端點:create_booking 回新預約的 <tr> 片段、delete_booking 回空字串(HTMX 看到空字串會把目標元素從 DOM 移除)、recent_bookings 是輪詢端點,只回時間戳記大於 since 的新預約。HTTPException(404) 會被 FastAPI 自動轉成 404 JSON;HTMX 收到非 2xx 會在 console 報錯,這就是我們想要的「刪除失敗時的明確錯誤」。
接下來看模板。先看 bookings.html,它繼承 base.html 並實作「篩選表單+清單+新增表單+刪除按鈕」全部用 HTMX 串起來:
<!-- templates/bookings.html -->
{% extends "base.html" %}
{% block admin_title %}預約清單 - 預約後台{% endblock %}
{% block main %}
<h2>預約清單</h2>
<!-- 篩選表單:送出時重新整理清單 -->
<form hx-get="/admin/bookings"
hx-target="#booking-table"
hx-trigger="change, keyup[target.name=='q'] delay:300ms from:input"
hx-push-url="true">
<label>狀態
<select name="status">
<option value="all" {% if status=='all' %}selected{% endif %}>全部</option>
<option value="confirmed" {% if status=='confirmed' %}selected{% endif %}>已確認</option>
<option value="cancelled" {% if status=='cancelled' %}selected{% endif %}>已取消</option>
</select>
</label>
<label>關鍵字
<input name="q" value="{{ q }}" placeholder="客戶姓名">
</label>
</form>
<!-- 新增表單:送出後 append 新 row -->
<form hx-post="/admin/bookings/new"
hx-target="#booking-tbody"
hx-swap="afterbegin"
hx-indicator="#submit-spinner">
<input name="customer" placeholder="客戶姓名" required>
<input name="slot" type="datetime-local" required>
<input name="service" value="教練課">
<button type="submit">新增預約</button>
<span id="submit-spinner" class="htmx-indicator">送出中...</span>
</form>
<!-- 清單:本體會被 HTMX 替換 -->
<table id="booking-table">
<thead>
<tr><th>客戶</th><th>時段</th><th>服務</th><th>操作</th></tr>
</thead>
<tbody id="booking-tbody"
hx-get="/admin/bookings/recent"
hx-trigger="every 10s"
hx-swap="afterbegin">
{% for row in rows %}
{% include "partial_booking_row.html" %}
{% endfor %}
</tbody>
</table>
{% endblock %}
這份模板做了四件事。第一,篩選表單用 hx-get 與 hx-target="#booking-table" 把整個表格換掉;hx-trigger="change, keyup[target.name=='q'] delay:300ms" 讓關鍵字欄位輸入時自動 debounce(300ms),不會每打一個字就打一次伺服器;hx-push-url="true" 把篩選條件寫進 URL,重新整理頁面時狀態還在。第二,新增表單用 hx-swap="afterbegin" 把新 row 插到表格頂端,並用 hx-indicator 顯示「送出中」文字。第三,<tbody> 自己掛了一個 hx-trigger="every 10s",每 10 秒打一次 /admin/bookings/recent,剛建立的預約會自動冒出來。第四,刪除按鈕在 row 片段裡(下一段)。
<!-- templates/partial_booking_row.html -->
<tr id="booking-{{ row.id }}">
<td>{{ row.customer }}</td>
<td>{{ row.slot }}</td>
<td>{{ row.service }}</td>
<td>
<button hx-delete="/admin/bookings/{{ row.id }}"
hx-target="#booking-{{ row.id }}"
hx-swap="outerHTML"
hx-confirm="確定要刪除 {{ row.customer }} 的預約嗎?">
刪除
</button>
</td>
</tr>
<!-- templates/partial_booking_rows.html:多筆版本 -->
{% for row in rows %}
{% include "partial_booking_row.html" %}
{% endfor %}
單筆與多筆的片段分開,是因為 create_booking 只回一筆(用 partial_booking_row.html),而 recent_bookings 可能回多筆(用 partial_booking_rows.html 包迴圈)。刪除按鈕用 hx-confirm 跳出原生確認對話框,使用者按「確定」才送出 DELETE,避免誤刪。
最後是樣式檔。把 htmx-indicator 預設隱藏、收到請求時顯示,並讓忙碌中的按鈕變淡:
/* static/style.css */
table { border-collapse: collapse; width: 100%; }
th, td { padding: 8px 12px; border-bottom: 1px solid #ddd; }
.htmx-indicator { display: none; color: #888; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request button[type="submit"] { opacity: 0.5; pointer-events: none; }
button { padding: 4px 10px; cursor: pointer; }
input, select { padding: 4px 8px; margin: 2px 4px; }
這份 CSS 只負責讓骨架看起來像個後台,沒有花俏的設計。實際上線可以接 Tailwind 或設計師出的設計系統。
用 pytest 驗證 HTMX 端點
Web Day 16 已介紹 pytest + TestClient,今天把同樣的工具套在 HTMX 端點上。重點是:HTMX 端點回的是 HTML 片段,所以斷言要對 HTML 結構做檢查。
# test_htmx.py
# 用 pytest + TestClient 驗證 HTMX 端點
import re
import pytest
from fastapi.testclient import TestClient
from main import app, BOOKINGS
client = TestClient(app)
@pytest.fixture(autouse=True)
def reset_bookings():
# 每個測試前重置假資料,避免互相干擾
BOOKINGS.clear()
yield
BOOKINGS.clear()
def test_create_booking_returns_tr():
r = client.post(
"/admin/bookings/new",
data={"customer": "王小明", "slot": "2025-08-10T10:00", "service": "重訓"},
)
assert r.status_code == 200
assert re.search(r'<tr id="booking-[a-z0-9]+">', r.text)
assert "王小明" in r.text
assert 'hx-delete="/admin/bookings/' in r.text
def test_delete_booking_returns_empty():
# 先建一筆
r = client.post(
"/admin/bookings/new",
data={"customer": "李小華", "slot": "2025-08-10T11:00"},
)
booking_id = re.search(r'booking-([a-z0-9]+)"', r.text).group(1)
# 刪除後應回空字串
r = client.delete(f"/admin/bookings/{booking_id}")
assert r.status_code == 200
assert r.text.strip() == ""
assert all(booking for b in BOOKINGS if b["id"] != booking_id)
def test_delete_missing_booking_404():
r = client.delete("/admin/bookings/does-not-exist")
assert r.status_code == 404
def test_recent_returns_only_fresh():
client.post("/admin/bookings/new",
data={"customer": "A", "slot": "2025-08-10T10:00"})
# since 設成未來,應該回空
r = client.get("/admin/bookings/recent", params={"since": 9999999999.0})
assert r.status_code == 200
assert "<tr" not in r.text
def test_bookings_page_filter():
client.post("/admin/bookings/new",
data={"customer": "王小明", "slot": "2025-08-10T10:00"})
client.post("/admin/bookings/new",
data={"customer": "李小華", "slot": "2025-08-10T11:00"})
r = client.get("/admin/bookings", params={"q": "王"})
assert "王小明" in r.text
assert "李小華" not in r.text
這個測試檔展示五種常見的 HTMX 端點測試:回 HTML 片段的結構檢查、回空字串的刪除流程、404 錯誤處理、輪詢差集、篩選條件。其中 test_create_booking_returns_tr 是「契約測試」:它確保伺服器回的字串符合 HTMX 的預期結構(<tr id="booking-xxx">、hx-delete 屬性)。如果有人改壞模板,這個測試會立刻失敗。
另外用 httpx 做整合測試(模擬瀏覽器送表單、驗證完整 HTTP 流程):
# test_integration.py
# 用 httpx 模擬 HTMX 瀏覽器行為
import httpx
BASE = "http://127.0.0.1:8000"
def test_full_flow():
with httpx.Client(base_url=BASE, timeout=5.0) as client:
# 首頁
r = client.get("/")
assert r.status_code == 200
# 新增預約(模擬表單)
r = client.post(
"/admin/bookings/new",
data={"customer": "陳大同", "slot": "2025-08-12T14:00"},
)
assert r.status_code == 200
assert "<tr" in r.text
# 讀清單
r = client.get("/admin/bookings")
assert r.status_code == 200
assert "陳大同" in r.text
# 輪詢
r = client.get("/admin/bookings/recent", params={"since": 0.0})
assert r.status_code == 200
print("整合測試通過")
# 輸出:整合測試通過
這份測試需要伺服器實際啟動(用 uvicorn main:app --port 8000 & 在 CI 環境或本機開另一個 process)。httpx.Client(base_url=BASE) 連到真實的 HTTP 端點,驗證「啟動後能不能正常運作」。這跟 Web Day 16 的 TestClient 測試互補:TestClient 測程式邏輯,httpx 測部署後的行為。完整的 HTMX 後台雛形到這裡就結束了。
Web Day 16 已用過 conftest.py 集中放 fixture,這裡把 HTMX 測試需要的幾個 fixture 抽出來共用:
# conftest.py
# pytest 共用 fixture:重置資料、提供 test client
import pytest
from fastapi.testclient import TestClient
from main import app, BOOKINGS
@pytest.fixture
def client():
# 每個測試都拿到全新的 TestClient
return TestClient(app)
@pytest.fixture(autouse=True)
def reset_state():
# 自動套用:測試前清空資料,測後再清一次
BOOKINGS.clear()
yield
BOOKINGS.clear()
@pytest.fixture
def sample_booking(client):
# 建立一筆預約,回傳 (id, response_text) 給其他測試複用
r = client.post(
"/admin/bookings/new",
data={"customer": "測試黃色", "slot": "2025-08-10T10:00"},
)
return r.text
@pytest.fixture
def htmx_headers():
# HTMX 預設會送 HX-Request: true 標頭;測試可模擬這個行為
return {"HX-Request": "true"}
把 fixture 集中後,個別測試檔就能直接 @pytest.mark.usefixtures("client") 或在函式參數宣告 def test_x(client, sample_booking) 拿到依賴,避免每個測試都重寫樣板程式碼。Web Day 17 對 fixture 管理有完整示範,這裡先看到最小可用版本。
另一個值得獨立抽出來的元件是「CSRF middleware」。HTMX 表單送出屬於跨站請求的一種,雖然 SameSite cookie 能擋掉大部分 CSRF 攻擊,但對內網應用或多服務互叫時仍建議加一層保護:
# csrf.py
# 簡單的 CSRF token middleware:檢查自訂標頭 X-CSRF-Token
import secrets
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
class CSRFMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
if request.method in SAFE_METHODS:
return await call_next(request)
# 從 cookie 取得 session token(簡化版,實際需配合 session)
session_token = request.cookies.get("csrf_token", "")
header_token = request.headers.get("X-CSRF-Token", "")
if not session_token or not secrets.compare_digest(
session_token, header_token
):
return Response("CSRF token 不符", status_code=403)
return await call_next(request)
def new_csrf_token() -> str:
# 給登入成功後設定 cookie 用
return secrets.token_urlsafe(32)
這個 middleware 在 main.py 用 app.add_middleware(CSRFMiddleware) 掛上;HTMX 模板用 <meta name="csrf-token" content="{{ csrf_token }}"> 取得 token,再用 hx-headers='js:{"X-CSRF-Token": document.querySelector(\"meta[name='csrf-token']\").content}' 帶進每個請求。完整認證與 CSRF 會在 Web Day 27 展開,今天先確認「HTMX 預設沒做 CSRF 防護」這件事存在,需要自己補。
進階模式:OOB 與觸發鏈
除了「按鈕→更新一個區塊」這種單點互動,HTMX 還支援「一次請求更新多個區塊」。這靠 OOB(Out of Band)交換:伺服器在 hx-swap-oob="true" 的元素上加上這個屬性,HTMX 會把它對應到頁面上同名 id 的元素並做替換,不需要再額外指定 hx-target。常見用法:新增預約後,同時更新清單、總計數字、提示訊息三個區塊。
看一個範例。伺服器回傳三段 HTML:主回應(新增的 row)、OOB 區塊(更新總計)、OOB 區塊(顯示提示訊息):
# 延伸 create_booking:加上 OOB
from fastapi.responses import HTMLResponse
@app.post("/admin/bookings/new-oob", response_class=HTMLResponse)
def create_booking_oob(
customer: str = Form(...),
slot: str = Form(...),
service: str = Form(default="教練課"),
):
booking = {
"id": str(uuid.uuid4()),
"customer": customer,
"slot": slot,
"service": service,
"status": "confirmed",
"created_at": _now_ts(),
}
BOOKINGS.append(booking)
# 主回應:新增的 row
main_html = templates.get_template(
"partial_booking_row.html"
).render({"row": booking})
# OOB 區塊 1:更新總計
oob_total = (
f'<span id="total-count" hx-swap-oob="true">'
f'共 {len(BOOKINGS)} 筆預約</span>'
)
# OOB 區塊 2:顯示提示訊息,3 秒後自動消失
oob_toast = (
f'<div id="toast" hx-swap-oob="true" '
f'hx-get="/admin/bookings/toast" hx-trigger="load delay:3s">'
f'已新增 {customer} 的預約</div>'
)
return HTMLResponse(main_html + oob_total + oob_toast)
@app.get("/admin/bookings/toast", response_class=HTMLResponse)
def clear_toast():
# 空回應會把 toast 從 DOM 移除
return HTMLResponse("")
這份端點展示了 OOB 的典型用法:主回應(HTMX 預設會塞進觸發元素的位置)、兩個 hx-swap-oob="true" 的元素(HTMX 會在頁面尋找對應 id 並替換)。三個區塊(新增 row / 總計 / 提示)在同一次請求裡完成更新,使用者感覺像「系統幫我做了一連串反應」,實際上只打了一次伺服器。這個模式在預約管理系統的後台會大量使用,例如「刪除預約後同時更新總計、關閉 detail panel、顯示 undo 提示」。
另一個進階模式是「觸發鏈」:一個按鈕的 HTMX 請求完成後,主動觸發另一個元素的 HTMX 行為。寫法是用 HX-Trigger 回應標頭(伺服器回應時加 HX-Trigger: refresh-list),然後在另一個元素掛 hx-trigger="refresh-list from:body"。這適合「新增成功→自動重新整理清單」「刪除成功→關閉 modal」這類多步互動。今天的範例先用 OOB 就足夠;觸發鏈會在後台進階篇(Web Day 39)正式使用。
除錯小抄:用 curl 重現瀏覽器行為
HTMX 出問題時,第一步永遠是「用 curl 重現請求」。瀏覽器開發者工具的 Network 分頁能複製請求,但 curl 更適合寫進 shell script 反覆測試。下面是這個後台常見的除錯指令集:
# 確認伺服器活著
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/admin/bookings
# 輸出:200
# 新增預約(模擬 HTMX 表單送出)
curl -s -X POST http://127.0.0.1:8000/admin/bookings/new \
-d "customer=王小明" -d "slot=2025-08-10T10:00" -d "service=重訓"
# 輸出:<tr id="booking-...">...</tr>
# 看伺服器回應的 Content-Type(HTMX 需要 text/html)
curl -s -I -X POST http://127.0.0.1:8000/admin/bookings/new \
-d "customer=test" -d "slot=2025-08-10T10:00" | grep -i content-type
# 輸出:content-type: text/html; charset=utf-8
# 模擬刪除(含 HX-Request 標頭,模擬 HTMX 行為)
curl -s -X DELETE http://127.0.0.1:8000/admin/bookings/abc123 \
-H "HX-Request: true" -H "HX-CSRF-Token: xxx"
# 輸出:(空字串,HTMX 看到會移除元素)
# 驗證 CSRF 沒過的情境
curl -s -X DELETE http://127.0.0.1:8000/admin/bookings/abc123
# 輸出:CSRF token 不符(403)
這套指令可以幫你快速判斷問題出在「伺服器沒收到請求」(curl 失敗)、「伺服器回錯」(看 status code)、「回應不是 HTML」(看 Content-Type)、「CSRF middleware 阻擋」(403)這四類常見原因。把這些指令寫進 scripts/debug.sh,未來維護時直接呼叫,省下開瀏覽器看 console 的時間。
常見錯誤與踩雷
第一個常見踩雷:HTMX 把 CSRF token 自動帶進表單,卻忘了後端要驗證。如果你的介面允許「刪除預約」「送出表單」這類寫入操作,後端必須檢查 CSRF token 或採用 SameSite cookie(Web Day 27 會展開認證)。HTMX 1.x 預設不會帶 CSRF,要用 hx-headers='{"X-CSRF-Token": "..."}' 明確指定;HTMX 2.0 改用 hx-headers 但寫法相同。常見錯誤:寫了 HTMX 互動卻忘了 CSRF 防護,被資安掃描工具抓到。
第二個常見踩雷:hx-swap="outerHTML" 與「刪除元素」搞混。刪除按鈕想讓元素消失,有兩種做法:hx-swap="outerHTML" 加上伺服器回空字串(HTMX 看到空字串會把目標元素整個移除),或 hx-swap="delete"(HTMX 2.0 新增)明確告訴 HTMX「刪掉這個元素,伺服器的回應忽略」。建議優先用 delete swap,語意更明確。
第三個常見踩雷:hx-trigger="every Ns" 沒有設定 hx-indicator。使用者會納悶「資料怎麼沒更新?」。搭配 spinner(htmx-indicator)讓使用者知道伺服器正在回應,是好的互動設計。
第四個常見踩雷:模板的 <form> 沒加 hx-target,HTMX 預設會把回應塞到 <form> 自己,導致整個表單被換掉、表單欄位消失。要嘛明確指定 hx-target="#some-id",要嘛用 hx-swap="none" 告訴 HTMX「不要處理回應」。
第五個常見踩雷:輪詢端點沒做差集,每次都回整個清單。這會浪費頻寬、讓使用者看到「整個表格閃一下」。差集做法的核心是「前端把 last_seen_ts 帶給後端,後端只回時間戳記大於 last_seen_ts 的預約」。這個模式今天示範了,貫穿專案會在 Web Day 38、39 進一步展開。
效能與實務提醒
HTMX 端點的效能瓶頸通常不是伺服器,而是「瀏覽器 DOM 操作」。一個頁面插太多片段(一次新增 100 筆)會讓瀏覽器卡頓幾百毫秒。對應策略:伺服器端做分頁(一次只回 20 筆)、前端用 hx-trigger="revealed" 讓使用者捲到清單底部才載入下一頁(無限捲動)。
另一個提醒是「不要把整個應用都用 HTMX 做」。如果某個互動需要複雜的本地狀態(如表單的多步驟精靈、即時預覽編輯器),HTMX 會一直打伺服器,反而比 SPA 慢。遇到這類場景,在 HTMX 裡掛一個 Alpine.js 或獨立 React 元件是合理選擇,但要在專案說明裡標清楚「這個元件是 SPA,那部分是 HTMX」。
最後一個工程細節:HTMX 2.0 的 SRI 雜湊會隨版本變動,每次升級 HTMX 都要重新查官方 docs 的 integrity 值。建議在 CI 加一個檢查:用 curl 抓 https://unpkg.com/htmx.org@2.0.4 計算 SHA-384 雜湊,跟模板裡的字串對照,避免手動改壞而沒人察覺。
小結
今天把 HTMX 2.0 在 FastAPI 上走了一遍。我們建立了共享模板 base.html、實作了五個端點(儀表板、預約清單、新增、刪除、輪詢)、用 Jinja2 模板片段做為 HTMX 的回傳內容、用 pytest + TestClient 做契約測試、用 httpx 做整合測試。常見互動模式都示範過了:hx-get 篩選、hx-post 新增、hx-delete 刪除、hx-confirm 確認、hx-indicator 忙碌提示、hx-trigger="every Ns" 輪詢。預約管理後台的雛形到此完整,後面 Web Day 36 與 Web Day 39 會演進為真實的後台。今天我們證明了 HTMX 確實能用極少的前端程式碼做出現代化介面,明天開始進入另一條路——Next.js 15 串接 FastAPI。
結語
今天的重點是「把 HTMX 2.0 從概念推到實作」。我們建立了完整的預約管理後台骨架(FastAPI + Jinja2 + HTMX),用 pytest 與 httpx 驗證每個端點的 HTML 結構與 HTTP 行為。讀完這篇你應該能回答:HTMX 2.0 的最小安裝是什麼?如何用 Jinja2 模板片段做為 HTMX 的回傳內容?HTMX 的輪詢、刪除、確認機制怎麼寫?HTMX 端點的測試要怎麼設計?
明天,我們會切到另一條路:用 Next.js 15(React 19)串接 FastAPI,焦點放在「讀取」。我們會建立 Next.js App Router 的最小專案、用 React Server Components 抓 FastAPI 的 JSON、把資料顯示在畫面上。Next.js 15 的 Server Components 讓我們能在伺服器端 fetch,省去 CORS 設定;這個模式對 SEO 友善,是「對外預約頁」的理想選擇。準備好面對 JS 世界了嗎?我們出發。
延伸資源
- HTMX 2.0 官方 Docs(2024):
https://htmx.org/docs/,hx-* 屬性全清單、OOB 交換、polling 模式、history API 整合。 - FastAPI Jinja2Templates(0.116,2025):
https://fastapi.tiangolo.com/advanced/templates/,模板環境變數、靜態檔案掛載。 - Jinja2 官方 Docs(2025):
https://jinja.palletsprojects.com/,include、巨集、迴圈與條件語法。 - pytest + FastAPI TestClient(2025):
https://fastapi.tiangolo.com/tutorial/testing/,Web Day 16 詳細展開。 - HTMX 2.0 SRI 雜湊查詢:
https://htmx.org/docs/#installing,官方 CDN 與 integrity 字串的最新值。
留言
張貼留言