跳到主要內容

Web Day 24 HTMX 動態介面實作

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 字串的最新值。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...