{% extends "base.html" %} {% from "_parts.html" import code, defs, files, lesson, prose, stage, stage_with %} {% from "ui/button.html" import button, button_group %} {% from "ui/data.html" import badge, card, code_block, link %} {% from "ui/form.html" import field_row %} {% from "ui/layout.html" import grid, page_header, row, stack %} {% from "ui/table.html" import cell, table %} {# 「上手」的中文版。id、控制項與腳本掛點跟英文版逐字相同:learn.js 只有一支, 兩個語言共用,所以這裡只有文字不同。 #} {% set sections = [ {"id": "wiring", "num": "01", "title": "接線:config、mount、templates"}, {"id": "exchange", "num": "02", "title": "htmx 的請求與回應"}, {"id": "targets", "num": "03", "title": "hx-target 與 hx-swap"}, {"id": "triggers", "num": "04", "title": "hx-trigger:打字、捲動、輪詢"}, {"id": "partials", "num": "05", "title": "partial:一份檔案,整頁與局部共用"}, {"id": "oob", "num": "06", "title": "一次回應,換掉好幾塊:hx-swap-oob"}, {"id": "broadcast","num": "07", "title": "一個 id,好幾份 fragment:HX-Trigger"}, {"id": "pending", "num": "08", "title": "請求進行中的那段時間"}, {"id": "patterns", "num": "09", "title": "九種 htmx 模式,以及對應的 handler"}, {"id": "messages", "num": "10", "title": "訊息:三層"}, {"id": "errors", "num": "11", "title": "表單被退回的時候"}, {"id": "knob", "num": "12", "title": "換品牌:品牌 token 與狀態 token"}, {"id": "gate", "num": "13", "title": "fjkit check:封閉詞彙表"}, ] %} {% block content %} {% call stack(8) %} {% set intro %} 十三課:一支 fjkit app 怎麼接、htmx 實際送出什麼、表單被退回時會怎樣、換品牌怎麼換, 以及那個會讓 build 失敗的指令。 底下每個示範都會實際執行。 {% endset %} {{ page_header("在寫路由的地方,把介面一起寫完", intro) }} {# ============================================================== 01 #} {% set t01 %}接觸點只有三個;其餘寫的都是你的 app,不是前端。{% endset %} {% call lesson("wiring", "01", "接線:config、mount、templates", t01) %} {% call prose() %}
一支 fjkit app 只在三個地方接觸 kit。FjkitConfig 收下所有設定。
mount_fjkit() 從安裝好的套件供應 stylesheet 與 vendored JS,
並在每個 process 建立一次 Jinja environment。@render 指名路由要用的模板。
底下四個檔案可以逐一點開。沒有 package.json,也不需要開著 watcher。
repo 裡的 examples/fjkit-demo 就是同樣這四個檔案,放在一支能跑的 app 裡。
第二個分頁值得配一張圖。base.html 只有四個 block,是因為它 extends 的
ui/shell.html 已經拿走其餘部分:<head>、
第一次繪製前就套用深色模式的那支腳本、指向 stylesheet 與三支 vendored 腳本的連結,
以及 header/main/footer 這副骨架。底下就是那個檔案畫出來的樣子。
用品牌色標起來的區塊是你要填的 block,其餘由外殼負責,你不去翻它就不會看到。
底下四個詞在這一頁有固定的意思,後面每一課都靠它們。
{% endcall %} {% set w_markup %}HTML 文字——模板產生、瀏覽器還沒解析的那串東西,例如<div id="board">…</div>。後面說某條路由「有 markup 在等著」,意思是有一份模板準備把 handler 的回傳值變成 HTML,而不是變成 JSON{% endset %}
{% set w_shell %}指 ui/shell.html,也就是上面那張圖:包在你的頁面外面的那份文件——<head>、header、<main>、footer 與 toaster{% endset %}
{% set w_fragment %}一段不成文件的 HTML:沒有 <!doctype>、沒有 <head>,只有那幾個元素。htmx 的回應裝的就是它{% endset %}
{% set w_partial %}渲染出 fragment 的那份模板。檔名以底線開頭,不 extends 外殼,整頁與 htmx 端點共用同一份——第 05 課{% endset %}
{{ defs("這一頁固定用法的四個詞", [
("markup(標記)", w_markup),
("外殼 shell", w_shell),
("fragment(片段)", w_fragment),
("partial", w_partial),
]) }}
{% endcall %}
{# ============================================================== 02 #}
{% set t02 %}伺服器用 HTML 回應一次 swap。這中間沒有序列化,也沒有要同步的前端狀態。{% endset %}
{% call lesson("exchange", "02", "htmx 的請求與回應", t02, tag="htmx") %}
{% call prose() %}
一個元素說明要發哪個請求,以及答案要放在哪裡。伺服器回一段 HTML, 內容就是它本來會放進整頁的那段標記。htmx 把那段放進指名的元素裡。
沒有東西被序列化,前端不重新渲染,狀態也不會同時存在兩個地方。 按下底下的按鈕會發出真正的請求: 這一頁載了 htmx,並由瀏覽器裡的一支 mock server 扮演 FastAPI 那一側。
{% endcall %} {% call stage("一次來回", "前端唯一會變的,只有 hx-target 指名的那棵子樹。") %} {% include "_diagram.html" %} {% endcall %} {% set fire %} {% call row(gap=2) %} {{ badge("idle", variant="outline", id="dg-stage-label") }} {{ button("送出請求", variant="primary", size="sm", icon_name="arrow-right", icon_end=true, id="dg-run") }} {% endcall %} {% endset %} {% call stage_with("試一次", fire, "底下這段來回是真正的 htmx 請求。") %} {% call stack(4) %} {% endcall %} {% endcall %} {% call prose() %}
路由靠 HX-* 標頭分辨這是局部更新還是整頁載入。
其餘部分是一個普通的 POST,兩端都沒有前端框架參與。
{{ link("hx-target", "https://htmx.org/attributes/hx-target/") }} 挑元素, {{ link("hx-swap", "https://htmx.org/attributes/hx-swap/") }} 決定怎麼處理它。 htmx 最常見的誤解出在這一對上,所以底下用一塊真的板子試。 每一種組合都會發出真的請求,換進去的也是真的回應。
outerHTML 換掉目標元素本身,所以回應必須包含那層外框。
innerHTML(預設)換掉它裡面的內容,所以回應不能包含外框。
搞混這兩個會得到一塊板子裡面又一塊板子。
多數時候不必寫 trigger。button 在 click 時發,form 在 submit 時發,
input 與 select 在 change 時發。
{{ link("hx-trigger", "https://htmx.org/attributes/hx-trigger/") }} 用於其餘情況:
邊打邊搜、延後載入與輪詢都靠它,不需要寫 JavaScript。
底下三個示範都會實際執行。
建置狀態,由伺服器重新整理。
partial 是名字以底線開頭的模板。它不 extends 外殼,自己宣告 import,
並把自己包在會被換掉的那個元素裡。整頁用
{% raw %}{% include %}{% endraw %} 放進它,htmx 端點直接回它。
所以 fjkit app 裡沒有「更新那一列」這條程式路徑:
每一次異動都重新渲染整塊板子的 partial 再換進去。
板子的樣子只有一份定義,第一次繪製與之後每個請求都用它。
檔案在 repo 的 examples/fjkit-demo/app/templates/tasks/。
上面兩個 handler 指的是同一份檔案,而容易誤會的正是這個檔名。
_board.html 不是一筆任務。它是整塊板子——新增表單、篩選列、
統計卡,以及表格裡的每一列,全部包在那個所有控制項都指著的
<div id="board"> 裡。開頭的底線意思是「這不是一頁」,不是「這很小」。
所以在第三列按下 Advance,送出的是一個 POST,換回來的是整塊板子:八列全部重新渲染, 其中一列現在寫著 Doing。這就是 fjkit 的取捨——多渲染一些其實沒變的 HTML, 換到的是「板子長什麼樣」只有一份定義。底下是那三個檔案,以及裡面每一個 htmx 屬性。
{% endcall %} {{ files([ {"id": "part-page", "label": "tasks/page.html", "lang": "jinja", "source": "{# tasks/page.html —— 回應整頁的 GET #} {% extends \"base.html\" %} {% from \"ui/layout.html\" import page_header %} {% block content %} {{ page_header(\"Tasks\", \"這頁上每個控制項都在換 #board。\") }} {# 第一次繪製。POST /tasks 回的也是這一份檔案。 #} {% include \"tasks/_board.html\" %} {% endblock %}", "note": "extends 外殼、寫一個標題,然後 include partial。關於板子的事,沒有第二份描述。"}, {"id": "part-board", "label": "tasks/_board.html", "lang": "jinja", "source": "{# tasks/_board.html —— 整塊板子:新增表單、篩選列、統計卡與每一列 #} {% from \"ui/button.html\" import button, button_group %} {% from \"ui/table.html\" import table %} {% from \"tasks/macros.html\" import task_row, TASK_COLUMNS %} {# 外框寫在檔案裡,因為 hx-swap=\"outerHTML\" 換掉的就是它。 #}_board.html 渲染的是表單、篩選、統計與全部的列。單獨一筆任務是 macros.html 裡的 task_row()——寫成 macro,因為重複出現的東西是 macro,而 partial 是拿來回應請求的{% endset %}
{% set wh_wrapper %}partial 的第一行就是 <div id="board">。必須如此,因為每個控制項都寫 hx-swap="outerHTML":被換掉的就是那層外框,回應得把它帶回來{% endset %}
{% set wh_context %}兩邊都需要 context 裡一模一樣的名字——tasks、stats、status_filters。router 在 board() 裡建一次,兩個 handler 都回傳它{% endset %}
{% set wh_filter %}每個列上的動作都帶著 filter_query,所以在篩到 Doing 的板子上推進一筆任務,換回來的板子還是篩在 Doing{% endset %}
{{ defs("一份檔案為什麼夠用", [
("它是整塊板子,不是一列", wh_notrow),
("外框寫在檔案裡", wh_wrapper),
("同一份 context,只建一次", wh_context),
("篩選條件會跟著走", wh_filter),
]) }}
{% set wh_cost %}每次都是整塊板子。那是幾 KB 的 HTML 交給瀏覽器解析,換掉的是它本來要算的差異——以及一份要跟著同步的前端模型{% endset %}
{% set wh_when %}當板子大到你不想整塊重畫時,答案是切出一份有自己 id 的較小 partial,不是換一個更聰明的前端。第 06 課與第 07 課講的都是這個答案{% endset %}
{% set wh_focus %}outerHTML 會連焦點所在的輸入框外層一起換掉。htmx 會依 id 還原焦點,所以要撐過 swap 的輸入框要有穩定的 id{% endset %}
{% set wh_stream %}這裡用的是 templates.page()。只有在列數由使用者決定時,才改用 templates.stream(){% endset %}
{{ defs("代價是什麼", [
("線上多送的位元組", wh_cost),
("什麼時候不划算", wh_when),
("焦點", wh_focus),
("很大的板子", wh_stream),
]) }}
{% endcall %}
{% set board_busy %}{{ badge("處理中…", variant="info", id="demo-board-busy") }}{% endset %}
{% call stage_with("板子示範——新增、推進、刪除", board_busy,
"每個控制項都 target #demo-board,而每個回應都是整塊板子的 partial。") %}
{% call stack(4) %}
{% endcall %}
{% endcall %}
{% call grid(cols=2, gap=4) %}
{% set trap_page %}從 htmx 路由回傳 page.html,會把一整份文件塞進 div 裡。回 partial{% endset %}
{% set trap_wrapper %}outerHTML 需要回應帶著那層外框,否則目標直接消失{% endset %}
{% set trap_inherit %}hx-target 與 hx-swap 會從祖先繼承。卡片用 outerHTML 輪詢自己時,卡片裡的按鈕會繼承那個 swap,把本來只要填內容的元素整個換掉。巢在另一個 htmx 元素裡的觸發器,這兩個屬性都要明寫{% endset %}
{{ defs("值得先知道的陷阱", [
("整頁跑回來", trap_page),
("4xx 不會換", "htmx 預設忽略錯誤回應。shell 把 422(而且只有 422)加了回去,被退回的表單因此能以表單本身回來"),
("外框弄丟", trap_wrapper),
("篩選條件掉了", "列上的動作必須帶著目前的篩選,否則換進去的內容會默默把畫面重設"),
("swap 被繼承", trap_inherit),
]) }}
{{ defs("伺服器端的幫手", [
("HX-Request", "路由靠這個標頭知道自己正在回應一次局部更新"),
("HX-Trigger", "回應標頭,在前端觸發一個事件。fragment 裡沒有 toaster,fjkit 就是這樣從一次 swap 裡叫出 toast"),
("HX-Redirect", "從局部更新的回應裡做整頁轉址"),
("hx-swap-oob", "一次回應換掉好幾塊,下一課"),
]) }}
{% endcall %}
{% endcall %}
{# ============================================================== 06 #}
{% set t06 %}hx-target 只指得到一個元素。當一個答案要動六塊時,另外五塊自己帶著地址。{% endset %}
{% call lesson("oob", "06", "一次回應,換掉好幾塊:hx-swap-oob", t06, tag="htmx") %}
{% call prose() %}
到這裡為止每一次 swap 都只有一個 target。會變的東西都包在同一個元素底下時,一個 target 就夠:
板子是一個 <div id="board">,上面每一顆按鈕都指著它,每一次回應都是整塊板子。
搜尋結果不是單一區塊。一次查詢要動六塊:表格上方的計數、表格本身、側欄的進度卡、
facet 清單、同負責人面板,以及表格下方的明細面板。它們分散在三個地方,
唯一同時包住它們的元素是 <body>,而換掉 body 等於重新整理整頁。
所以回應把另外五塊走頻外(out of band)送回去,不走
hx-target 描述的那條唯一通道。每一塊靠自己身上的 id 指出去處。
看回應之前,先看觸發的那一端:它一點都不特別,而這正是重點。一個輸入框、一個
hx-get、一個 hx-target,形狀跟第 03 課一樣。
它指名 #search-matches,頁面上其他區塊它一個都不知道。
hx-swap-oob 的頂層元素抽出來{% endset %}
{% set step_place %}依 id 找到頁面上同名的元素,各自換掉{% endset %}
{% set step_rest %}剩下的才照常進 hx-target{% endset %}
{{ defs("htmx 的處理順序", [
("1 — 抽出", step_lift),
("2 — 就位", step_place),
("3 — 最後才是 target", step_rest),
]) }}
{% set oob_values %}true 是換掉同 id 的元素;innerHTML、beforeend 這些 swap 詞彙在這裡一樣能用{% endset %}
{% set oob_silent %}id 對不上時整塊被丟掉:不報錯,console 也沒有訊息,那一區停在上一次查詢的數字上{% endset %}
{% set oob_toplevel %}包在 wrapper 裡的 oob 元素不算 oob,所以回應那支模板是一排平行 include,沒有外框{% endset %}
{% set oob_target %}絕對不要標記 hx-target 指的那一塊:它會一起被抽走,target 換到的是空字串{% endset %}
{{ defs("四件要知道的事", [
("值可以怎麼寫", oob_values),
("它是靜靜失敗的", oob_silent),
("只認頂層", oob_toplevel),
("不要標記 target", oob_target),
]) }}
{% endcall %}
{% call prose() %}
fjkit 這一側沒有新機制。頁面與回應 {% raw %}{% include %}{% endraw %} 同樣六支
partial,差別只有一個變數:頁面直接渲染它們,回應讓它們帶上 hx-swap-oob。
每支 partial 自己給那個旗標一個預設值,所以單獨渲染也成立。
好處是版面位置由頁面決定,而且只決定一次。把 facet 卡從側欄搬到主欄,
router、response model 與 swap 都不用改。檔案在 repo 的
examples/fjkit-demo/app/templates/search/。下一課講同一頁的另一半,
因為實際畫面兩種都需要。
上一課的前提是一支 handler 能一次回答所有區域:一次查詢、六份 fragment、
一個裝得下全部的 response model。在同一頁上點開一列不是這個形狀。
點擊產生的是一個 id,在意它的每一塊面板各自需要不同的查詢:
那個任務的細節、那個負責人手上的其他任務。要一支 handler 回答全部,
它必須 import 這一頁上的每一個 service,而且多加第三塊面板就得回頭改它。
所以這次的選取只回應它自己那一塊:結果表,其中那一列被標記起來,
並把 id 放進 HX-Trigger 送出。htmx 把這個標頭轉成一個 DOM 事件,
在意它的 fragment 各自聽到之後,自己去要自己的資料。這條路由不知道訂閱者的數量,
也不知道它們存在。多一塊面板就是多一個訂閱者,沒有路由要改。
事件宣告在 hx_trigger,它是 @render 的參數而不是 handler 自己寫的標頭,
這樣會渲染的路由用的是同一套寫法。callable 每次請求解析一次,
依名字從 handler 自己的參數取值——這裡是 task_id——外加 result,
也就是 handler 的回傳值。callable 回傳 None 就不發事件,所以一條路由可以只在某些時候廣播。
其餘部分是一條普通的 partial 路由:一份樣板、一個 response model,外加一個事件。 這個模式便宜的原因就在這裡——swap 與廣播是同一趟來回,寫在同一個地方。
同一頁的第二個動作是另一半。推進會改變狀態,所以它發出不同的事件, 在意的是另一組 fragment。
{% endcall %} {% set bcast_shared = "@router.post(\"/search/advance/{task_id}\", name=\"search_advance\") @render(\"search/_detail.html\", hx_trigger=lambda task_id: {\"task-changed\": {\"task_id\": task_id}}) def advance_task(service: ServiceDep, task_id: int) -> DetailResponse: \"\"\"Advance the task, answer with the detail panel, and say what changed.\"\"\" if service.advance(task_id) is None: raise HTTPException(status_code=404, detail=\"task not found\") return DetailResponse(selected=service.get(task_id))" %} {% call stage("第二個事件,來自一條會改變東西的路由", "回應本身就是那次 swap——detail 面板正是這顆按鈕的 hx-target——而標頭是另外四塊聽到的東西。", padded=false) %} {{ code(bcast_shared, label="router.py") }} {% endcall %} {% call prose() %}
兩個事件,每一份 fragment 各自挑它有理由聽的那個。計數面板聽 task-changed、
不聽 task-selected,因為選取不會改變計數。facet 面板兩個都不聽,
因為推進一個任務不會換負責人,也不會換優先級。聽哪些廣播由 fragment 自己決定,
沒有路由知道它存在。
只有一條規則撐住這整套:一條路由回應的那份 fragment,不可以訂閱這條路由發出的事件。
它已經被就地換掉,再聽到那個事件就是同一次點擊把它抓第二次;如果那份 fragment 就是自己的
target,它不會停下來。htmx 不會擋,因為「訂閱者去要自己的 URL」正是那些屬性的意思。
所以結果表只聽 task-changed,detail 面板只聽 task-selected:
每一份對其中一個事件是 target、對另一個是訂閱者,不對同一個事件兩者兼具。
from:body——htmx 在發出請求的那個元素上觸發事件,事件再往上冒泡。面板離那個元素很遠,所以在事件必定抵達的地方聽{% endset %}
{% set sub_vals %}hx-vals="js:…" 是 id 變回查詢參數的唯一地方。從事件讀取它、而不是把選取狀態記在伺服器上,端點才能維持無狀態,兩個分頁才互不干擾{% endset %}
{% set sub_target %}hx-target="this"——每個訂閱者替換自己,所以一次回應只有一塊區域,發送端不需要知道這一頁有幾塊區域{% endset %}
{% set sub_include %}hx-include——補上事件沒帶的值。計數算的是符合的結果,所以除了 id 還需要搜尋框的值。用一個 selector 從頁面上取,不必讓每個事件都帶著它{% endset %}
{{ defs("要緊的幾個屬性", [
("誰在聽", sub_trigger),
("id 怎麼進來", sub_vals),
("還缺什麼", sub_include),
("換掉的是誰", sub_target),
]) }}
{% set bc_object %}trigger 的值必須是一個 JSON 物件。htmx 把物件原樣交給 event.detail;其他型別(含陣列)會被包成 {value: …},讀 event.detail.task_id 的監聽者拿不到值{% endset %}
{% set bc_order %}HX-Trigger 在 swap 之前觸發。id 走事件這條路的原因就在這裡:存放選取狀態的 hidden input,在訂閱者讀它的當下還是舊值。需要相反的順序時,標頭是 HX-Trigger-After-Swap{% endset %}
{% set bc_inherit %}hx-vals 會被繼承,htmx 沿著 DOM 往上合併它,而且這條路徑不看 hx-disinherit。訂閱者內部的 htmx 元素——那一列的標題,它本身就是開啟這一列的按鈕——必須寫 hx-vals="unset",那是唯一能中斷這趟走訪的值{% endset %}
{% set bc_cost %}N 個訂閱者就是 N 次請求。扇出發生在頁面上而不是收斂在 handler 裡,這是這個做法的代價。查詢成本高時,用 hx-sync 或一層快取換回來{% endset %}
{{ defs("四件要知道的事", [
("送物件", bc_object),
("它在 swap 之前觸發", bc_order),
("繼承會滲進來", bc_inherit),
("N 塊面板,N 次請求", bc_cost),
]) }}
{% endcall %}
{% call prose() %}
這兩課回答不同的問題,demo 也沒有把它們拆成兩頁。/search 兩個都問:
一次查詢是伺服器可以完整回答的問題;一次選取是一個 id,三塊區域各自拿它問不同的事。
可以動手改的檔案在 repo 的 examples/fjkit-demo/app/features/search/ 與
app/templates/search/,跟上一課同一頁。那裡每一塊區域都帶著渲染時間戳,
所以哪一次動作動到哪幾塊在頁面上就讀得出來,不必打開 network 分頁。
一次查詢是一個請求、六個新的時間戳。一次選取是三個請求、三個時間戳——結果表、detail 面板、
同負責人面板——計數、進度卡與 facet 卡不動。一次推進是五個,只有 facet 卡不動。
htmx 在請求期間把 htmx-request 這個 class 掛到某個元素上,結束後移除。
沒有寫 hx-indicator 時,那個元素是觸發請求的元素本身;寫了就是選擇器指到的元素。
其餘交給 htmx 自己在啟動時注入的這段樣式:
第一個選擇器對應 indicator 在被標記元素內部的情況,例如被點的那顆按鈕裡的 spinner。
第二個對應 indicator 就是被標記元素本身,也就是 fjkit 的做法:
spinner(indicator=true) 只加上 htmx-indicator。
所以一個 loading 狀態不需要 CSS、不需要 JavaScript,也不需要額外一趟來回去問伺服器狀態。
hx-disabled-elt 用同一套機制處理另一個問題:請求期間把選擇器指到的元素設成
disabled,結束後清掉。擋住雙擊把一筆任務推進兩次、或送出兩份相同的表單,
只需要這一個屬性。選擇器接受 this、find …/closest …
這類相對寫法,或一般的 CSS 選擇器。
visibility 與 opacity 隱藏,不用 display。它一直佔著位置,所以出現時畫面不會跳動{% endset %}
{% set p_inherit %}兩者都會繼承:hx-indicator 寫在容器上,裡面所有觸發器共用同一個 spinner。this 與 inherit 都是合法的值{% endset %}
{% set p_warn %}選擇器沒中時 htmx 會發出警告——The selector "…" returned no matches!——與對不上的 oob id 不同,後者是靜靜失敗{% endset %}
{{ defs("它們的行為", [
("計數的", p_count),
("不會讓畫面跳動", p_space),
("會繼承", p_inherit),
("它會出聲", p_warn),
]) }}
{% set p_inside %}把 indicator 放在 target 外面:放在會被換掉的區塊裡,它會在請求進行中連同 id 一起被換走{% endset %}
{% set p_default %}完全不寫 hx-indicator 時,class 直接掛在觸發元素上,所以對按鈕寫 .htmx-request 的樣式是最省事的 loading 狀態{% endset %}
{% set p_marker %}htmx 用 data-disabled-by-htmx 標記自己 disable 掉的元素,所以本來就是 disabled 的控制項,結束後仍然是 disabled{% endset %}
{% set p_flash %}有些 indicator 只閃一下,那是請求快,不是 indicator 壞掉。jobs 頁的工作在回應之後才跑,所以那張卡片需要輪詢{% endset %}
{{ defs("值得先知道的陷阱", [
("不要放在 target 裡面", p_inside),
("免費的那個版本", p_default),
("它記得", p_marker),
("請求太快", p_flash),
]) }}
{% endcall %}
{% endcall %}
{# ============================================================== 09 #}
{% set t09 %}畫面不同,形狀相同:每一種都是一個 trigger、一個 target 與一份 partial。{% endset %}
{% call lesson("patterns", "09", "九種 htmx 模式,以及對應的 handler", t09, tag="htmx") %}
{% call prose() %}
整套屬性詞彙一個螢幕放得下, 「速查表」那一頁印出了全部。 要花時間的是它反覆組成的那幾種排列。這裡把它們連同 fjkit 的寫法列出來: 一個分頁是 macro 呼叫,另一個是回應它的 handler。
底下每個 handler 都回一份 partial,而且都是 def,
所以 Starlette 會把它送進 threadpool。每種模式都連到對應的 htmx 頁面。上游的
{{ link("文件", "https://htmx.org/docs/") }} 與
{{ link("屬性參考", "https://htmx.org/reference/") }} 短到可以整份讀完。
「給發出這次請求的人看一行字」由三層組成:畫出它的元件、讓它搭上這個回應的方法, 以及讓它活過一次轉址的方法。只有最後一層需要簽章用的 secret, 所以三層裡只有它是 plugin,另外兩層在核心。
{% endcall %} {% call stage("三層", "其中兩層在核心,因為它們不需要任何設定。") %} {% call table([{"label": "層"}, {"label": "住在哪裡"}, {"label": "核心還是 plugin"}]) %}
不需要 plugin、secret 或任何設定,這條線就是為此劃的。簽一顆 cookie 需要 secret;
要求每一支 app 都準備一個(包括沒有表單、也沒有訊息要顯示的 app),等於讓
mount_fjkit(app) 不再零設定可跑。不需要 secret 的都留在核心。
第三層也不是第二層的超集合。轉址會換掉整頁,本來要顯示訊息的那份文件在回應抵達前
就不存在了,唯一跨得過去的只有 cookie。但對不是轉址的回應來說,同一顆 cookie 是
錯的工具:一則馬上就要渲染出來的訊息不必多跑一趟。所以兩者是疊起來而不是合而為一:
FlashPlugin 把 cookie 排進 fjkit.messages,shell 裡因此只有一個
迴圈,而不是每種送達方式各一個。
走標頭而不是 markup,因為 fragment 裡沒有 toaster:一次 htmx swap 換掉的是一個區域,
而 toast 該待的區域在頁面上的別處。它也能用在 htmx 不會 swap 的回應上:htmx 在
handleAjaxResponse 裡讀 HX-Trigger 的時機,早於它查
responseHandling 決定這個狀態碼要不要換內容,所以一個 422 或 500 照樣能在
使用者正在看的那一頁上跳出 toast。第 11 節整套送達機制就是這個。
info、success、warning、error 是意義,
不是顏色。toast() 把每一種對到一個圖示與一段停留時間,fjkit.css
把每一種對到一個角色 token,所以「錯誤是紅的」只決定一次,而且撐得過換品牌
{% endset %}
{{ defs("值得知道的幾件事", [
("fjkit_messages() 是 global,不是 context key", "路由不用傳任何東西,一份從沒聽過 messages 的模板照樣顯示得出來"),
("靠被迭代來消耗", consumed),
("HX-Trigger 是合併,不是覆蓋", "handler 自己設的事件會留著。只因為排了一個 toast 就把它丟掉,是那種兩邊同時出現才會現形的 bug"),
("category", category_note),
]) }}
{% endcall %}
{# ============================================================== 11 #}
{% set t11 %}表單被退回時,錯誤標在欄位下面,不再是一整頁 JSON,而且不需要任何一條路由要求。{% endset %}
{% call lesson("errors", "11", "表單被退回的時候", t11, tag="fjkit.errors") %}
{% call prose() %}
FastAPI 對一份被退回的表單回的是 {"detail":[{"type":"missing",…}]} 加一個 422。
對 API 來說這正確,對一頁畫面來說等於什麼都沒有:htmx 預設不會 swap 4xx,
所以正在填表單的人看到的是按了沒反應,而一般的 POST 會把他丟到一整頁 JSON 上。
解法是利用那個回應原本就有的內容。它點名每個失敗的欄位,用的正是表單送出時的名字, 訊息也是給人讀的;而表單還在頁面上,使用者打過的每一個字都還在控制項裡。 沒有東西需要重新渲染、記住或重建。
所以 mount_fjkit() 把 422 原封不動送回去,由 shell 載入的一支 script
js/errors.js 把每則訊息寫到它的欄位下面。每個 handler 的簽名還是 FastAPI 寫的那個,
也沒有任何一條路由要替「被退回」這個狀態指名模板,因為沒有東西要渲染。
最後那一列值得講明白。沒有要 markup 的呼叫端拿到的還是 {"detail": …},
因為把一個 API 的錯誤變成一頁 HTML,會是比這裡要修的問題更糟的 bug。
第一列是同樣的位元組,這正是整個設計:一個同時也是別人 API 的端點只有一份錯誤契約,
頁面讀它的方式跟其他客戶端一樣。
這對一條路由只有一項要求,而且 FastAPI 本來就在要求:
{% endcall %} {{ files([ {"id": "invalid-route", "label": "router.py", "lang": "python", "source": "@router.post(\"/tasks\", name=\"tasks_create\") @render(\"tasks/_board.html\") def create_task(service: ServiceDep, payload: TaskCreate) -> BoardResponse: service.create(payload) return _board(service)", "note": "模型就是簽名。它的欄位、限制與 OpenAPI schema 是同一份宣告,被退回時由 FastAPI 回報,放在 body 底下,帶著欄位的名字。"}, {"id": "invalid-form", "label": "_new_task.html", "lang": "jinja", "source": "{% call form(action=url_for(request, \"tasks_create\"), target=\"#board\", reset_on_success=true, encoding=\"json\", id=\"new-task\") %} {{ text_field(\"title\", label=\"New task\", required=true) }} {{ select_field(\"priority\", label=\"Priority\", options=priority_options, selected=\"normal\") }} {% endcall %}", "note": "一份表單,寫一次,裡面沒有任何關於錯誤的東西。script 用 name 找到控制項,用 aria-describedby 找到它下面那個段落,兩者都是每個欄位 macro 本來就接好的線。"}, ]) }} {% call prose() %}保留使用者填過的內容是零成本的,因為沒有東西碰到它。以前對 422 的做法是重畫表單、 把值回填進去,這行得通,直到某個欄位有多個值,或表單需要某樣只有那個沒跑過的 handler 才建得出來的東西。表單不再重畫,這兩個問題一起消失。
沒有欄位可以落腳的訊息——模型層級的規則、解析不了的 body、壞掉的路徑片段——會透過
fjkit.messages 用的同一個事件升成一個 toast。同一份表單的下一次送出會清掉上一次的訊息,
所以一次被接受的重送不會把過期的退回留在畫面上。
Task(**row) 失敗,而把一個 bug 畫成一句親切的訊息,等於用「某個沒人填過的欄位」
的道歉把它藏起來。把模型宣告在簽名上,FastAPI 就會丟出會被接住的那種 request 層級錯誤
{% endset %}
{% set unexpected %}
預設是關的,而這個預設有用:開發時終端機裡的一份 traceback,勝過瀏覽器裡一句得體的
道歉。FjkitConfig.for_production() 會把它打開,連同另外兩個只在開發時有意義的
開關。422 那個 handler 一律安裝,因為被退回的表單不是 bug
{% endset %}
{{ defs("其餘的介面", [
("你自己的程式碼丟出的 ValidationError", model_error),
("catch_unexpected_errors", unexpected),
("unexpected_error", "那則 toast 的內容。不設就是 kit 自己的一句 Something went wrong;給一個 Message 就整批換掉,給一個 (request, exc) -> Message 的函式就能依例外種類挑句子。用例外挑句子,不要把 str(exc) 放進去,那是你 app 的內部"),
("errors/page.html", "導覽失敗時落到的那一頁。在自己的模板目錄放一個同路徑的檔案就能換掉,用的是與 fjkit eject 相同的遮蔽機制,所以沒有另一套「自訂錯誤頁」要學"),
("js/errors.js", "每一頁載入的第三個、也是最後一個檔案,排在 htmx 與 Basecoat 之後。它聽 htmx:beforeSwap,所以不是 FastAPI 清單的 422——某條路由自己回了 markup——會被放過"),
]) }}
{% endcall %}
{# ============================================================== 12 #}
{% set t12 %}換品牌是一個 CSS 變數。狀態顏色不跟著變。{% endset %}
{% call lesson("knob", "12", "換品牌:品牌 token 與狀態 token", t12, tag="theming") %}
{% call prose() %}
拖動色相滑桿,編輯的是 :root 上的 --primary,
也就是一支 app 寫在自己 stylesheet 裡的那三行,中間沒有建置步驟。
整頁都會跟著變,包括側欄與這些 card,因為這一頁蓋在同一組 token 上。
狀態標籤不動。狀態顏色刻意不接到品牌 token 上, 因為「綠色代表完成」必須撐過一次改版。
{% endcall %} {# `.field > input[type="range"]` is Basecoat's — the slider needs no class of its own, and its JS binds the fill track. The same is true of the checkbox in the playground on the Components page. #} {% call card("你會寫的那三行") %} {# field_row has no "four" template — two columns, twice. #} {% call field_row("two") %}ring{% endset %}
{# `fjkit check` scans every line for colour literals, not just class attributes,
so prose cannot name the utility it is warning you off. Recorded in
docs/BACKLOG.md: the colour rules should skip comments and text nodes. #}
{% set d_pfg %}品牌填色上的文字,不是固定不變的色值{% endset %}
{{ defs("會跟著品牌變的", [
("primary", d_primary),
("primary-foreground", d_pfg),
("background · card", "頁底與各種表面,往品牌色帶一點"),
("muted · border", "低調的表面、細線、次要文字"),
]) }}
{{ defs("刻意不跟著品牌變的", [
("success", "完成、通過、健康"),
("warning", "需要注意,但還沒壞"),
("info", "進行中、中性的提示"),
("destructive", "刪除、失敗、無法回復"),
]) }}
{% endcall %}
{% endcall %}
{# ============================================================== 13 #}
{% set t13 %}被強制執行的慣例,勝過只寫在文件裡的慣例。{% endset %}
{% call lesson("gate", "13", "fjkit check:封閉詞彙表", t13) %}
{% call prose() %}
fjkit check 讀模板,拒絕兩種東西:顏色字面值,以及元件詞彙表以外的 class。
utility class 即使目前能用也一樣被拒。app 若依賴某個只是碰巧被 fjkit
外殼輸出的間距 utility,外殼哪天不再輸出它,app 就會靜靜地壞掉。
編輯底下的標記。這支檢查器跑 fjkit 自己的規則,用的是實際的 class 清單 ——…——與 CI 裡跑的、掃這個站模板的是同一份程式碼。
{% endcall %} {% set samples %} {% call button_group() %} {{ button("utility class 版本", variant="ghost", size="xs", data_sample="bad") }} {{ button("fjkit 的版本", variant="ghost", size="xs", data_sample="good") }} {% endcall %} {% endset %} {% call stage_with("app/templates/tasks/_board.html", samples) %} {% call grid(cols=2, gap=4) %} {# The checker appends one `alert` per violation. Without a gap their borders butt together and every boundary renders as a doubled line with the two rounded corners colliding, so the container is a `stack` rather than a bare div. #} {% call stack(gap=2, id="checker-output", aria_live="polite") %}{% endcall %} {% endcall %} {% endcall %} {% call prose() %}被擋下來的 class 指名了還不存在的元件或參數。 0.1 落地時,改寫前的模板有 258 條違規,改寫後是 0。
{% endcall %} {% call grid(cols=2, gap=4) %} {{ defs("指令", [ ("fastapi dev", "啟動 app;沒有其他東西需要一起重建"), ("fjkit check", "檢查每個 class 都在詞彙表裡"), ("fjkit eject", "逃生口:把一支 macro 拿進來改;同檔其餘的仍是 kit 的"), ("fjkit build-css", "fjkit 自己的發佈步驟,不是你的"), ]) }} {% set d_components %}每個簽名與列舉的實際渲染{% endset %} {{ defs("接著讀", [ ("元件", d_components), ("examples/fjkit-demo/", "同樣這些模式放在一支能跑的 app 裡,在 repo 中"), ("src/fjkit/static/src/fjkit.css", "每個 token 只定義一次;換品牌覆蓋的就是這支檔案"), ("docs/jinja-performance.md", "page() 對 stream(),以及那些建議背後的數字"), ]) }} {% endcall %} {% endcall %} {% endcall %} {% endblock %} {% block page_scripts %} {% endblock %}