FastAPI + HTMX:免建置的全端開發
# 無須React或webpack,也能打造正式環境等級的網頁應用程式:涵蓋FastAPI、HTMX、Alpine.js、Jinja2、純CSS、Bootstrap模式、i18n、部署、SEO與效能。
TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + 純CSS,能打造正式環境可用的 web 應用程式,且不需要任何建置工具、不需要
node_modules/,並取得滿分 Lighthouse 分數。本指南以blakecrosley.com作為正式環境參考,完整涵蓋從架構到部署的整套系統。該網站提供210篇 blog posts、互動式JavaScript元件、11份核心指南、48個設計研究,以及英文加上9個翻譯語系,全程不使用任何 bundler、compiler 或 transpiler。1
現代 web 開發堆疊通常假設您需要 React、webpack、TypeScript 和建置管線。對於很大一類應用程式而言,這個假設並不成立,包括以內容為主的網站、內部工具、CRUD 應用程式、作品集網站與文件平台。本指南描述的堆疊移除了整套前端建置工具鏈,同時仍能產出在 Lighthouse 取得100/100/100/100分的網站。2
這不是倡議,而是測量結果。本文所述架構已在正式環境執行,服務橫跨10種語言的真實使用者,而且數據可供驗證。
重點摘要
- 伺服器端渲染的HTML消除了三大類問題:用戶端狀態管理、JSON序列化邊界,以及水合不匹配。HTMX讓伺服器回應成為最終輸出——不再需要用戶端渲染步驟。
- 零建置工具意味著零建置失敗。 不會出現
npm install同層相依性衝突,不會在您未動過的檔案中出現TypeScript編譯器錯誤,也不會收到從未匯入的傳遞相依性的Dependabot PR。部署流程就是git push。 - Alpine.js負責處理HTMX無法處理的純用戶端狀態。 下拉選單、模態視窗、行動裝置導覽切換,以及任何純粹存在於瀏覽器中的UI狀態,都歸Alpine.js所有。界線清晰明確:若狀態需要伺服器,使用HTMX;若不需要,使用Alpine.js。
- 搭配自訂屬性的純CSS取代了Sass與Tailwind。 CSS自訂屬性會在執行階段串接、繼承,並回應媒體查詢。預處理器變數則會編譯成靜態值後消失。瀏覽器直接讀取自訂屬性——無需編譯步驟。
- 此方法有明確的適用界線。 對於共享元件介面的大型團隊、具有複雜用戶端狀態的SaaS產品,以及依賴npm生態系函式庫的應用程式而言,這個方法並不適合。第15節中的決策框架精確界定了這條界線。
- blakecrosley.com就是證明。 本指南中的核心模式(HTMX、Alpine.js、Jinja2、純CSS)在blakecrosley.com的正式環境中運作。Bootstrap與SQLAlchemy章節則涵蓋此技術堆疊的標準模式,但並未在本網站上使用。每一項主張都有對應的檔案路徑、設定區塊,或是您可以自行在PageSpeed Insights驗證的Lighthouse稽核結果。2
如何使用本指南
這是一份完整的參考資料。請從符合您經驗等級的位置開始閱讀:
| 經驗 | 從這裡開始 | 接著探索 |
|---|---|---|
| Python開發者,初次接觸HTMX | The No-Build Thesis → Architecture Overview → HTMX Deep Dive | Alpine.js Patterns、Security |
| 正在評估替代方案的React/Vue開發者 | The No-Build Thesis → Decision Framework | Architecture Overview、Performance |
| FastAPI開發者想新增互動功能 | HTMX Deep Dive → Alpine.js Patterns | i18n and Localization、Deployment |
| 從零開始建置的全端開發者 | 從Architecture Overview依序閱讀 | Quick Reference Card供日常使用 |
請使用Ctrl+F / Cmd+F搜尋特定模式或屬性。文末的Quick Reference Card提供可快速瀏覽的摘要。
不建置論點
這個論點範圍狹窄而具體:對於由獨立開發者或小型團隊經營的內容導向網站,建置工具解決的是您並不存在的問題,卻製造了您原本不會遇到的問題。
以下是來自blakecrosley.com的實際指標:
| 指標 | blakecrosley.com(無建置) | 典型Next.js專案3 |
|---|---|---|
| 相依套件 | 17個Python套件 | 311個以上的npm套件 |
| 建置設定檔 | 0 | 5至8個(next.config、tsconfig、postcss、tailwind等) |
node_modules/大小 |
不存在 | 基準值187 MB,加入額外項目後為250至400 MB |
| 安裝時間 | pip install:8秒 |
npm install:30至90秒 |
| 建置步驟 | 無 | next build:15至60秒 |
| 部署流程 | git push → 約40秒內上線 |
安裝→建置→部署:2至5分鐘 |
| Lighthouse效能 | 100 | 未經明確優化時為70至904 |
這17個Python套件包含FastAPI、Jinja2、Pydantic、uvicorn、nh3,以及其他12個套件。沒有一個是建置工具。沒有一個是編譯器。也沒有一個是打包工具。5
您所放棄的
誠實要求列出真正的代價:
沒有TypeScript。 每個.js檔案都是純JavaScript。型別錯誤透過測試與程式碼分析來發現,而非依靠編譯器。這對獨立開發者行得通,但對於10人共享元件介面的團隊則行不通。
沒有熱模組替換。 CSS變更需要手動重新整理瀏覽器。HTMX的hx-boost讓導覽夠快,完整重新整理是可以接受的,但在密集的視覺迭代週期中,HMR能節省時間。
沒有Tree Shaking。 您寫的每一個位元組的JavaScript都會傳送到瀏覽器。這項限制強迫紀律:使用小而聚焦的檔案,而非龐大的工具模組。
沒有npm元件函式庫。 沒有Radix、沒有shadcn/ui、也沒有Headless UI。每一個互動元素都是手工打造,或使用Bootstrap 5的內建元件。
沒有來自npm的設計系統tokens。 設計系統存在於CSS自訂屬性中。它無法作為套件匯入到另一個專案中。
這些取捨對於一到三位開發者經營的內容導向網站是可以接受的。但對於擁有15人工程團隊的SaaS產品則無法接受。第15節提供了決策框架。
您所獲得的
零建置失敗。 任何npm install都不可能因為同層相依性衝突而失敗。任何next build都不可能因為您未動過的檔案中的TypeScript錯誤而失敗。6
用檢視原始碼除錯。 在瀏覽器中執行的JavaScript,就是您所撰寫的JavaScript。不需要原始碼對應檔。
本機即時啟動。 uvicorn app.main:app --reload在2秒內啟動。
具體的請求瀑布圖。 首次造訪會載入:一份HTML文件(壓縮後約15KB)、一份CSS檔案(約8KB)、HTMX(約16KB,可快取)、Alpine.js(約15KB,可快取),以及該頁面的互動JS(約4至8KB)。總計:首次造訪大約55至65KB。1
面向未來的前端。 用戶端程式碼使用HTML、CSS與JavaScript——這些標準已維持30年的向後相容性。7沒有Webpack 4到5的遷移、沒有Create React App的棄用,也沒有Next.js App Router的遷移。
技術堆疊比較
無建置技術堆疊與常見替代方案在可衡量維度上的比較:
| 維度 | FastAPI+HTMX(本指南) | Next.js(React) | Astro | 11ty |
|---|---|---|---|---|
| 傳送至瀏覽器的JS | 35至40KB(HTMX+Alpine+小型頁面腳本) | 85至250KB以上(React執行階段) | 預設0KB,可選擇加入islands | 預設0KB |
| 建置步驟 | 無 | 必需(webpack/turbopack) | 必需(Vite) | 必需(自訂) |
| 設定檔 | 0 | 5至8個(next.config、tsconfig等) | 1至3個(astro.config、tsconfig) | 1至2個(.eleventy.js) |
| 部署流程 | git push(40秒) |
安裝+建置+部署(2至5分鐘) | 安裝+建置+部署(1至3分鐘) | 安裝+建置+部署(1至2分鐘) |
| 伺服器端互動性 | 原生支援(HTMX) | API路由+用戶端fetch | 有限(表單actions) | 無(靜態輸出) |
| 用戶端狀態管理 | Alpine.js(15KB) | React state/context/Redux | 框架islands | 手動JS |
| 後端語言 | Python | JavaScript/TypeScript | JavaScript/TypeScript | JavaScript |
| i18n方法 | 伺服器端(middleware) | next-intl或類似套件 | @astrojs/i18n | 手動 |
| Lighthouse效能 | 100(實測值) | 通常70至904 | 通常95至100 | 通常95至100 |
| 最適合 | 內容網站、CRUD、儀表板 | 複雜SPA、大型團隊 | 內容網站、行銷 | 靜態部落格、文件 |
Astro與11ty是內容網站最接近的競爭對手。兩者都能產生優秀的靜態輸出,但需要建置步驟與JavaScript工具鏈。FastAPI+HTMX技術堆疊以靜態網站效能換取伺服器端互動性(類別篩選、表單處理、即時搜尋),且無需新增建置步驟。如果您的網站完全是靜態的,沒有任何伺服器端互動,那麼Astro或11ty可能是更好的選擇。
架構概覽
請求流程
每個請求都循著同一條路徑,依序通過四個層級:
Browser FastAPI Jinja2 HTMX/Alpine
| | | |
|--- GET /about ------>| | |
| |-- render template ->| |
| | |-- base.html ------->|
| | | + about.html |
| |<-- full HTML -------| |
|<--- HTML response ---| | |
| |
|--- hx-get /search ------------------------------------------------>|
| |<-- HTMX request ----| |
| |-- render partial -->| |
| | |-- _results.html |
| |<-- HTML fragment ---| |
|<--- HTML fragment ---| | |
|--- DOM swap -------------------------------------------------------->|
完整頁面載入會回傳完整的 HTML 文件(基底模板加上頁面模板)。HTMX 請求則回傳 HTML 片段(局部模板)。伺服器根據請求類型決定要渲染的內容。Alpine.js 負責管理純客戶端狀態,完全不與伺服器互動。
元件角色
| 元件 | 角色 | 範疇 |
|---|---|---|
| FastAPI | 路由、商業邏輯、資料存取、驗證 | 伺服器端 |
| Jinja2 | 模板渲染、繼承、巨集 | 伺服器端 |
| HTMX | 伺服器驅動的互動性(表單、分頁、搜尋) | 客戶端 ↔ 伺服器端 |
| Alpine.js | 純客戶端狀態(下拉選單、對話框、切換開關) | 僅客戶端 |
| Bootstrap 5 | 格線系統、工具類別、響應式佈局 | 客戶端(CSS) |
| 純 CSS | 自訂屬性、元件樣式、設計語彙 | 客戶端(CSS) |
| Pydantic | 請求/回應驗證、設定管理 | 伺服器端 |
專案結構
app/
├── main.py # FastAPI app, middleware, templates
├── config.py # Pydantic settings management
├── routes/
│ ├── pages.py # Page routes (HTML responses)
│ └── api.py # API routes (JSON/HTML fragment responses)
├── content.py # Markdown loading, blog post parsing
├── security/
│ ├── headers.py # CSP, HSTS, security headers middleware
│ ├── csrf.py # HMAC-signed CSRF tokens
│ ├── rate_limit.py # 3-tier rate limiting
│ └── logging.py # Security event logging
├── i18n/
│ ├── config.py # Supported locales, mappings
│ ├── middleware.py # URL-based locale detection
│ ├── jinja.py # Translation functions for templates
│ └── d1_client.py # Cloudflare D1 translation storage
├── cache_assets.py # Content-hash asset versioning
└── templates/
├── base.html # Base layout with Alpine.js state
├── components/ # Reusable partials (_language_switcher.html, etc.)
└── pages/ # Page templates (home.html, about.html, etc.)
content/
├── blog/ # Markdown blog posts with YAML frontmatter
└── guides/ # Multi-section guide markdown
static/
├── css/ # Plain CSS (no preprocessors)
├── js/ # Vanilla JavaScript (no bundlers)
│ └── vendor/ # Self-hosted HTMX, Alpine.js
└── images/ # Optimized images with WebP srcset
此結構遵循一個核心原則:每個目錄只存放一種類型的內容。路由放在 routes/,模板放在 templates/,靜態資源放在 static/。沒有任何建置步驟會將其中一種轉換成另一種。
與 SPA 架構的對比
在 React + Next.js 專案中,對應的結構大致如下:
src/
├── components/ # React components (JSX)
├── pages/ # Route handlers (also JSX)
├── api/ # API routes (also in pages/)
├── hooks/ # Custom React hooks
├── context/ # React context providers
├── lib/ # Utility functions
├── styles/ # CSS modules or Tailwind config
└── types/ # TypeScript type definitions
# Plus build configuration
next.config.js
tsconfig.json
postcss.config.js
tailwind.config.js
eslint.config.js
package.json
package-lock.json
node_modules/ # 187+ MB of dependencies
SPA 架構需要在這些目錄之間進行建置時期的協調。TypeScript 將 .tsx 編譯為 JavaScript,PostCSS 將 Tailwind 指令處理為 CSS,Webpack(或 Turbopack)再將輸出打包成區塊。每個步驟都可能各自出錯。
無建置架構則不需要任何協調。模板引用一個 CSS 檔案,該檔案就存在於 static/css/ 中,瀏覽器直接載入即可。如果重新命名了某個檔案,模板的引用會在請求時中斷——而非建置時。這將錯誤從編譯時期轉移到了執行時期,是一項確實存在的取捨。對於開發期間執行 uvicorn --reload 的獨立開發者而言,執行時期錯誤會立即顯示在瀏覽器中。但對於大型團隊來說,TypeScript 在編譯時期攔截的錯誤,能防範一整類執行時期錯誤無法捕捉的問題。
FastAPI 模式
應用程式設定
應用程式會在 main.py 中初始化,並明確指定中介軟體的順序:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware
app = FastAPI(
title="Blake Crosley",
docs_url=None, # Disable docs in production
redoc_url=None,
openapi_url=None, # Prevent /openapi.json exposure
)
# Middleware order matters: last added = first executed
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(LocaleMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityLogMiddleware, site_name="blakecrosley.com")
# Static files
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)
此處有3項重要的設計決策。首先,docs_url=None 與 openapi_url=None 會停用自動產生的 API 文件端點。面向公眾的內容網站不需要將 /docs 或 /openapi.json 暴露於網際網路上。8其次,中介軟體的順序至關重要——安全性記錄最先執行(但最後加入),因此能擷取每個請求,包括遭速率限制拒絕的請求。第三,GZipMiddleware 會壓縮所有超過500位元組的回應,通常可將 HTML 傳輸大小減少70–80%。
路由
路由分為兩類:頁面路由會傳回完整的 HTML 文件,而 API 路由則會傳回 JSON 或 HTML 片段。
# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/about")
async def about(request: Request):
templates = request.app.state.templates
return templates.TemplateResponse("pages/about.html", {
"request": request,
"page_title": "About — Blake Crosley",
"page_description": "Designer, developer, dad.",
})
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
# Parse answers, compute next question or result
question = get_next_question(quiz_id, answers)
templates = request.app.state.templates
return templates.TemplateResponse("components/_quiz_step.html", {
"request": request,
"question": question,
"answers": answers,
"step": len(answers.split(",")) if answers else 0,
})
這項區別對 HTMX 至關重要。完整頁面路由會傳回擴充 base.html 的文件。API 路由則會傳回 HTML 片段,由 HTMX 將其置換至現有的DOM元素中。兩者皆由相同的 Jinja2 範本引擎呈現——無須另設 API 層。
相依性注入
FastAPI 的 Depends() 系統能清楚區隔路由處理常式與共用邏輯:
from fastapi import Depends, Request
def get_templates(request: Request):
"""Get templates from app state."""
return request.app.state.templates
def get_current_locale(request: Request) -> str:
"""Get locale from middleware-set request state."""
return getattr(request.state, "locale", "en")
@router.get("/blog/{slug}")
async def blog_post(
request: Request,
slug: str,
templates=Depends(get_templates),
locale: str = Depends(get_current_locale),
):
post = load_post_by_slug(slug)
if not post:
raise HTTPException(404, "Post not found")
return templates.TemplateResponse("pages/blog/post.html", {
"request": request,
"post": post,
"locale": locale,
})
相依性可彼此組合。get_db 相依性可以依賴 get_current_locale,而後者又依賴請求。FastAPI 會自動解析整條相依鏈。
Pydantic 設定
設定採用Pydantic的 BaseSettings,並以環境變數為優先:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
D1_WORKER_URL: str = ""
D1_AUTH_SECRET: str = ""
CLOUDFLARE_ACCOUNT_ID: str = ""
CLOUDFLARE_API_TOKEN: str = ""
ANALYTICS_PASSKEY: str = ""
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
環境變數會覆寫 .env 檔案中的值。在正式環境(Railway)中,機密資料會設為環境變數;在本機則由 .env 檔案提供預設值。Settings 類別會在啟動時驗證型別——若缺少必要欄位,系統會立即失敗,而非等到執行階段才出錯。
Async 模式
FastAPI 路由預設採用async。對於I/O密集型操作(資料庫查詢、HTTP請求、檔案讀取),async可避免阻塞事件迴圈:
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load translations into memory cache at startup
async with httpx.AsyncClient() as client:
for locale in SUPPORTED_LOCALES:
resp = await client.post(f"{D1_URL}/query", ...)
TRANSLATIONS[locale] = resp.json()["results"]
yield
# Cleanup on shutdown (if needed)
app = FastAPI(lifespan=lifespan)
Lifespan現已成為唯一的啟動/關閉途徑。 Starlette於2026年3月推出首個穩定版本1.0(截至6月12日為1.3.1),並移除早已棄用的 on_event、on_startup 與 on_shutdown 掛鉤——lifespan(如上)成為唯一機制,而 @app.route()/@app.websocket_route() 則由 routes 清單中的 Route/WebSocketRoute 取代。FastAPI 0.137.0(2026年6月14日)重構其路由器內部機制:router.routes 不再是由 APIRoute 物件組成的扁平清單,而是由中介節點構成的樹狀結構。因此,應將其視為內部實作細節,而非可供反覆走訪的結構。好處是,在 include_router() 之後新增至路由器的路由,如今會即時反映;子路由器也能在其路由定義完成前先行納入。FastAPI 本身並未將Starlette鎖定於1.x系列:自0.136.3起,其執行階段需求一直只有最低版本 starlette>=0.46.0,直至0.140.7仍未改變——沒有上限,Starlette 0.4x也依然符合需求。0.137.0版本說明中的1.x版本號,是dependabot對儲存庫自身測試鎖定檔所做的更新,並非您應用程式的執行階段限制。24這些變動不會影響本指南中的模式——全篇皆使用 lifespan 與標準路由宣告——但若您維護的工具會走訪 router.routes,或仍在執行舊版 @app.on_event 處理常式,0.137.0/Starlette 1.0便屬於破壞性變更。FastAPI 0.137.2(2026年6月18日)隨後加入 iter_route_contexts();在 router.routes 成為內部實作後,這是目前受支援的路由列舉方式。接著,FastAPI 0.138.0(2026年6月20日)新增 app.frontend("/", directory="dist")/router.frontend(...),用於提供建置完成的靜態前端——若您要交付獨立的SPA建置成品,這項功能相當實用;但它與本指南免建置、伺服器端呈現的做法無關(此功能會掛載 dist/ 目錄,而非在伺服器上呈現 HTML)。25 FastAPI 0.139.0(2026年7月1日)進一步為其加入 app.frontend() 的相依性支援——例如自動執行Cookie驗證——讓您在 API 路由上使用的相同 Depends() 機制也能套用至靜態前端掛載點。26 FastAPI 0.141.0(2026年7月29日)新增 app.frontend(check_dir="auto"),可避免建置目錄尚不存在時導致 fastapi dev 啟動失敗——這正是先啟動伺服器、後執行前端建置時的常見情況。同日發布的 FastAPI 0.141.1修正了 app.frontend() 中的相依性會無聲捨棄背景工作與回應標頭的問題:如果相依性設定了Cookie或排程 BackgroundTask,在前端掛載點上,這些工作會被捨棄,即使它們在 API 路由上能正常運作。若已採用0.139.0的相依性支援,升級至0.141.1後,其行為才會與應用程式其他部分一致。28
FastAPI 0.140.0終結了自2025年11月以來每個版本皆存在的記憶體退化問題——請升級。 2026年7月24日發布的版本僅包含一項重構,影響卻非同小可。Dependant 是 FastAPI 為每條路由相依性圖中的每個節點建立的內部物件;從0.121.0(2025年11月3日)起,它開始累積 functools.cached_property 屬性,到0.139.2時已有10個。快取屬性需要每個執行個體各自具備 __dict__,才能將結果寫入其中,因此成本會隨應用程式內每張相依性圖的每個節點成倍增加。PR #16049將這些邏輯移出類別,改為模組層級的輔助函式(_get_cache_key()、_get_oauth_scopes()、_uses_scopes()),並將 Dependant 宣告為 @dataclass(slots=True),使其單純作為資料容器。FastAPI 對合併後PR執行的CodSpeed結果顯示,test_dependency_graph 記憶體基準測試從 17.5 MB降至1.1 MB,減少16倍;促成這項工作的報告則描述,一項正式環境服務在0.120.4上維持不到約400 MB,升級至0.121.3後卻發生OOM。本指南此後推薦過的所有版本——0.137.x、0.138.0、0.139.2——都帶有此問題。若應用程式具有較深或較廣的相依性樹(巢狀 Depends()、安全性配置、納入大量路由器),升級至0.140.0即可免費節省記憶體,無須修改任何應用程式碼。27
0.140.0只是序幕,並非完整修正——請鎖定0.140.7或更新版本。 該版本發布3天後,也就是2026年7月27日,FastAPI 在5個半小時內發布了另外7個版本:從0.140.1一路到0.140.7,每個版本都在重構相同的相依性機制。相關工作分為兩部分。首先是扁平相依性樹:FastAPI 過去會為每條路由建立並保留其相依性圖的扁平副本;0.140.2不再保留該副本,而0.140.3、0.140.5、0.140.6及0.140.7則移除其餘仍會重建副本之處——OpenAPI 產生、本文欄位、請求參數,以及再次處理OpenAPI 的部分。0.140.4移除用來追蹤重複相依性、卻無任何程式讀取的簿記資料。第二部分也是唯一具有明顯門檻的變更:0.140.1將 fastapi/dependencies/models.py 中可呼叫物件分類輔助函式的 lru_cache 容量從 1,024提高至4,096個項目,並以具名的 _CALLABLE_CLASSIFICATION_CACHE_SIZE 常數控制。原因是使用者回報,擁有超過1,024個不同相依性的應用程式會讓快取反覆失效。這些變更皆不會影響您呼叫的任何 API,因此升級僅需變更版本號。仍有兩點必須開門見山地說明:密集的發布節奏代表此系列仍在快速演進,請閱讀版本說明,不要逕自認定0.140.7就是終點;此外,FastAPI 在同一期間才加入用於衡量這些工作的OpenAPI 相依性基準測試(PR #16075),因此已發布的數據只涵蓋最後幾個版本,而非完整的7版本演進過程。29
CPU密集型操作(Markdown呈現、CSS 擷取)可以使用同步函式。若路由處理常式未宣告為 async,FastAPI 會自動在線程集區中執行:
# Sync function — FastAPI runs it in a thread pool
@router.get("/blog/{slug}")
def blog_post(slug: str):
post = load_post_by_slug(slug) # CPU-bound Markdown parsing
return templates.TemplateResponse(...)
原則如下:若函式會await I/O,便將其設為 async;若執行CPU運算,則維持同步。請勿在同一函式中混用 await 與阻塞式呼叫。9
Jinja2 模板
模板繼承
Jinja2 的繼承系統以更簡潔的模型取代了 React 的元件組合方式。一個基底模板定義頁面骨架,子模板則填入具名區塊:
<!-- base.html — the skeleton -->
<!DOCTYPE html>
<html lang="{{ lang_attr() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title | default("Blake Crosley") }}</title>
<meta name="description" content="{{ page_description | default('...') }}">
<!-- CSS — single file, no preprocessor -->
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
<!-- JSON-LD structured data -->
<script type="application/ld+json">
{ "@context": "https://schema.org", "@graph": [...] }
</script>
{% block head %}{% endblock %}
</head>
<body>
<header class="header">...</header>
<main id="main" role="main">
{% block content %}{% endblock %}
</main>
<footer class="footer">...</footer>
<!-- Scripts deferred for performance -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
<!-- pages/about.html — fills the blocks -->
{% extends "base.html" %}
{% block head %}
<script type="application/ld+json">
{ "@type": "AboutPage", "name": "About Blake Crosley", ... }
</script>
{% endblock %}
{% block content %}
<section class="hero">
<h1>About</h1>
<p>Designer, developer, dad.</p>
</section>
{% endblock %}
{% extends %} 指令建立了父子關係。子模板只需定義要覆寫的區塊,其餘一切——<head>、頁首、頁尾、腳本標籤——皆來自基底模板。這是透過「減法」而非「建構」來實現組合。
asset() 全域函式
靜態資源採用內容雜湊版本控制來清除快取:
# cache_assets.py
def build_asset_map(static_dir: Path) -> dict[str, str]:
"""Compute MD5 hashes of all static files at startup."""
asset_map = {}
for filepath in static_dir.rglob("*"):
if filepath.is_file():
rel_path = str(filepath.relative_to(static_dir))
content_hash = hashlib.md5(filepath.read_bytes()).hexdigest()[:10]
asset_map[rel_path] = content_hash
return asset_map
def make_asset_url(asset_map: dict, path: str) -> str:
"""Generate versioned URL: /static/css/styles.css?v=a3f8b2c1d0"""
clean_path = path.lstrip("/")
version = asset_map.get(clean_path, "0")
return f"/static/{clean_path}?v={version}"
在模板中:{{ asset('css/styles.css') }} 會輸出 /static/css/styles.css?v=a3f8b2c1d0。當檔案內容變更時雜湊值隨之改變,進而清除 CDN 快取。僅需 30 行在啟動時計算的 Python,即可取代 webpack 的 [contenthash] 檔名策略。
以 Include 實現可重用局部模板
跨頁面重複使用的元件透過 {% include %} 引入:
<!-- base.html -->
{% include "components/_language_switcher.html" %}
<!-- components/_language_switcher.html -->
{%- set current = current_locale() -%}
{%- set locales = all_locales() -%}
<div class="language-switcher"
x-data="{ open: false }"
@click.away="open = false">
<button @click="open = !open" :aria-expanded="open">
{{ current_locale_native() }}
</button>
<ul class="language-switcher-menu"
:class="{ 'is-open': open }"
x-cloak>
{% for locale in locales %}
<li>
<a href="{{ locale_url(request.url.path, locale.code) }}"
hreflang="{{ locale.code }}">
{{ locale.native }}
</a>
</li>
{% endfor %}
</ul>
</div>
底線前綴(_language_switcher.html)是一種慣例,表示這是局部模板——不應單獨渲染的模板片段。此元件同時運用了 Alpine.js(控制下拉選單開關)和 Jinja2(產生語系列表)。兩者的邊界清晰分明:Alpine.js 負責開關狀態,Jinja2 負責資料。
以巨集建立可重用元件
巨集是 Jinja2 的函式——帶有參數的可重用模板區塊:
<!-- components/_macros.html -->
{% macro card(title, description, href, badge=None) %}
<article class="card">
<a href="{{ href }}" class="card__link">
{% if badge %}
<span class="card__badge">{{ badge }}</span>
{% endif %}
<h3 class="card__title">{{ title }}</h3>
{% if description %}
<p class="card__description">{{ description }}</p>
{% endif %}
</a>
</article>
{% endmacro %}
{% macro optimized_image(image_config, loading="lazy") %}
{% if image_config.get("svg") %}
<img src="{{ image_config.svg }}"
width="{{ image_config.width }}"
height="{{ image_config.height }}"
alt="{{ image_config.alt }}">
{% else %}
<picture>
<source type="image/webp"
srcset="{{ image_config.webp_srcset }}"
sizes="(max-width: 768px) 100vw, 50vw">
<img src="{{ image_config.fallback }}"
width="{{ image_config.width }}"
height="{{ image_config.height }}"
alt="{{ image_config.alt }}"
loading="{{ loading }}">
</picture>
{% endif %}
{% endmacro %}
在頁面模板中匯入並使用巨集:
{% from "components/_macros.html" import card, optimized_image %}
<section class="projects">
{% for project in projects %}
{{ card(
title=project.title,
description=project.description,
href=project.link,
badge="New" if project.is_new else None
) }}
{% endfor %}
</section>
巨集取代了 React 元件在呈現模式上的角色。它們接受參數、支援預設值,並可與其他巨集組合使用。關鍵差異在於:巨集在伺服器端渲染一次,產生靜態 HTML;React 元件則在客戶端渲染並維持狀態。對於內容展示而言,巨集才是正確的工具。
模板上下文與全域函式
Jinja2 全域函式可在所有模板中直接使用,無需顯式傳遞:
# In main.py — register globals
templates.env.globals["asset"] = lambda path: make_asset_url(_asset_map, path)
templates.env.globals["csrf_token"] = generate_csrf_token
templates.env.globals["analytics_script"] = analytics.tracking_script
asset() 全域函式產生版本化的 URL。csrf_token() 全域函式產生新的 CSRF 權杖。analytics_script() 全域函式注入追蹤程式碼片段。這些函式可在任何模板中直接呼叫,路由處理器無需顯式傳遞。
針對 i18n,設定則更為複雜——翻譯函式需要存取當前請求的語系:
# i18n/jinja.py
def setup_i18n_jinja(env):
"""Register translation functions as Jinja2 globals."""
env.globals["_"] = get_translation # _('ui.nav.about')
env.globals["locale_prefix"] = get_locale_prefix # '/ja' or ''
env.globals["current_locale"] = get_current_locale
env.globals["all_locales"] = get_all_locales
env.globals["alternate_urls"] = get_alternate_urls
env.globals["lang_attr"] = get_lang_attr # 'ja' for HTML lang
env.globals["og_locale"] = get_og_locale # 'ja_JP' for og:locale
env.globals["jsonld_lang"] = get_jsonld_lang # 'ja-JP' for JSON-LD
每個函式從語系中介軟體設定的請求上下文變數中讀取語系。模板呼叫 {{ _('ui.nav.about') }} 即可取得當前請求語系的翻譯字串,無需任何顯式的語系參數。
條件區塊
Jinja2 的區塊系統支援條件式覆寫:
<!-- base.html -->
{% block head %}{% endblock %}
<!-- pages/blog/post.html -->
{% block head %}
<script type="application/ld+json">
{
"@type": "Article",
"headline": "{{ post.meta.title }}",
"author": { "@id": "https://blakecrosley.com/#person" },
"datePublished": "{{ post.meta.date.isoformat() }}",
"dateModified": "{{ post.meta.updated.isoformat() if post.meta.updated else post.meta.date.isoformat() }}"
}
</script>
{% if post.meta.scripts %}
{% for script in post.meta.scripts %}
<script defer src="{{ asset(script.lstrip('/static/')) }}"></script>
{% endfor %}
{% endif %}
{% if post.meta.styles %}
{% for style in post.meta.styles %}
<link rel="stylesheet" href="{{ asset(style.lstrip('/static/')) }}">
{% endfor %}
{% endif %}
{% endblock %}
部落格文章在 YAML frontmatter 中宣告其相依項(scripts: ["/static/js/boids.js"]),模板據此條件式地引入。不需要額外腳本或樣式的頁面則不會載入任何多餘資源——沒有無用程式碼,沒有未使用的匯入。
自訂過濾器
Jinja2 過濾器在渲染時轉換資料。sanitize 過濾器可防止使用者產生內容中的 XSS 攻擊:
import nh3
ALLOWED_TAGS = {"a", "b", "blockquote", "br", "code", "em", "h1", "h2",
"h3", "h4", "h5", "h6", "hr", "i", "img", "li", "ol",
"p", "pre", "span", "strong", "table", "td", "th", "tr", "ul"}
def sanitize_html(value: str) -> str:
"""Sanitize HTML to prevent XSS attacks."""
if not value:
return ""
return nh3.clean(
value,
tags=ALLOWED_TAGS,
attributes={"a": {"href", "title"}, "img": {"src", "alt"}},
link_rel="noopener noreferrer",
)
templates.env.filters["sanitize"] = sanitize_html
在模板中使用:{{ user_content | sanitize }}。nh3 是一個以 Rust 編寫的 HTML 消毒函式庫——速度快且安全。它會移除不在允許清單中的所有標籤和屬性,即使內容來自不受信任的來源,也能有效防止儲存型 XSS。10
HTMX 深入探討
HTMX 讓任何 HTML 元素都能發出 HTTP 請求,並將回應內容替換至 DOM 中。其核心架構理念在於:伺服器端渲染的 HTML 就是 API。伺服器回傳的即是最終呈現結果,無需客戶端渲染、無需 JSON 序列化、無需 hydration。
核心屬性
| 屬性 | 用途 | 範例 |
|---|---|---|
hx-get |
發送 GET 請求 | hx-get="/search?q=term" |
hx-post |
發送 POST 請求 | hx-post="/contact" |
hx-target |
指定回應放置位置 | hx-target="#results" |
hx-swap |
指定回應插入方式 | hx-swap="innerHTML"(預設)、outerHTML、beforeend |
hx-trigger |
觸發請求的事件 | hx-trigger="click"、keyup changed delay:300ms、load |
hx-indicator |
請求期間顯示的元素 | hx-indicator="#spinner" |
hx-push-url |
更新瀏覽器網址 | hx-push-url="true" |
hx-replace-url |
替換網址但不新增歷史記錄 | hx-replace-url="true" |
模式 1:互動測驗(多步驟伺服器狀態)
blakecrosley.com 包含一個互動測驗,引導使用者完成工具選擇。整個測驗狀態完全保存在伺服器端,無需任何客戶端狀態管理:
<!-- _quiz_container.html — initial load -->
<div hx-get="/api/quiz/claude-vs-codex/step?answers="
hx-trigger="load"
hx-swap="innerHTML"
id="quiz-wrapper">
<p>Loading quiz...</p>
</div>
<!-- _quiz_step.html — each question -->
<div class="quiz-step" id="quiz-container">
<p>Question {{ step }} of {{ total }}</p>
<h3>{{ question.question }}</h3>
<div class="quiz-step__options">
{% for opt in question.options %}
<button class="quiz-step__btn"
hx-get="/api/quiz/claude-vs-codex/step?answers={{ answers }},{{ opt.value }}"
hx-target="#quiz-container"
hx-swap="outerHTML">
{{ opt.label }}
</button>
{% endfor %}
</div>
</div>
每次按鈕點擊都會將累積的答案作為查詢參數傳送。伺服器根據答案歷程計算下一題或最終結果。狀態透過 URL 逐步累積——不使用 cookie、不依賴 session、不需要客戶端 JavaScript。測驗透過 outerHTML 替換推進:每次回應都會替換整個測驗步驟元素。
模式 2:分頁部落格列表
文章頁面使用 HTMX 實現無縫分頁,同時更新網址:
<!-- Pagination link -->
<a href="/writing?page=2&category=Engineering"
hx-get="/writing?page=2&category=Engineering"
hx-target="#writing-content"
hx-swap="innerHTML"
hx-replace-url="true"
hx-indicator="#writing-loading"
aria-label="Go to page 2">
2
</a>
四個屬性協同運作:
hx-get向與href相同的 URL 發出請求(漸進增強——無 JavaScript 時仍可正常運作)hx-target將回應放入#writing-content容器中hx-replace-url="true"更新瀏覽器網址但不新增歷史記錄hx-indicator在請求期間顯示載入動畫
伺服器透過 HX-Request 標頭偵測 HTMX 請求,僅回傳文章列表片段而非完整頁面。這也是安全標頭中介層加入 Vary: HX-Request 的原因——讓 CDN 快取分別儲存完整頁面與片段版本。11
模式 3:帶防抖的搜尋
<input type="search" name="q"
hx-get="/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-indicator="#search-spinner" />
<div id="results"></div>
hx-trigger 屬性結合了三個修飾符:
keyup在按鍵釋放時觸發changed僅在值實際改變時觸發(避免修飾鍵造成的重複請求)delay:300ms防抖——在最後一次 keyup 後等待 300 毫秒才發送請求
伺服器回傳已渲染的 HTML 片段:
@router.get("/api/search")
async def search(request: Request, q: str = ""):
results = search_content(q)
return templates.TemplateResponse("components/_search_results.html", {
"request": request,
"results": results,
"query": q,
})
無需客戶端狀態、無需防抖函式庫、無需 useEffect。模板負責渲染結果,HTMX 將其替換至頁面中,伺服器始終是唯一的事實來源。
模式 4:帶外(OOB)替換
有時單一伺服器操作需要同時更新多個 DOM 元素。HTMX 的帶外替換機制無需客戶端協調即可處理此需求:
<!-- Server returns multiple elements in one response -->
<!-- Primary target: swapped normally via hx-target -->
<div id="cart-items">
<ul>
<li>Widget A — $29.99</li>
<li>Widget B — $14.99</li>
</ul>
</div>
<!-- OOB target: swapped independently via hx-swap-oob -->
<span id="cart-count" hx-swap-oob="true">2 items</span>
<span id="cart-total" hx-swap-oob="true">$44.98</span>
hx-swap-oob="true" 屬性會指示 HTMX 在整個 DOM 中依 id 尋找對應元素並替換,不受 hx-target 限制。這取代了 React 的「狀態提升」模式——由伺服器計算所有衍生狀態,並在單一回應中傳送每個元素的最終 HTML。
聯絡表單便是一個好例子:提交表單後可將表單本體替換為成功訊息,同時透過 OOB 替換更新通知徽章:
模式 5:增強連結
HTMX 能將標準導覽連結「增強」為使用 AJAX 的方式,取代整頁載入:
<nav hx-boost="true">
<a href="/about">About</a>
<a href="/writing">Writing</a>
<a href="/guides">Guides</a>
</nav>
啟用 hx-boost="true" 後,點擊連結會透過 AJAX 取得頁面內容、替換 <body> 並更新網址——無需整頁重新載入。瀏覽器歷史記錄正常運作(上一頁/下一頁按鈕皆可使用)。若 JavaScript 載入失敗,連結仍作為標準導覽正常運作。
其優勢在於感知效能:增強導覽讓頁面切換感覺近乎即時,因為瀏覽器無需重新解析 CSS、重新執行腳本或重新渲染版面。僅 <body> 內容發生變更。增強連結特別適用於主導覽元素,讓頁面轉場如同單頁應用程式般流暢,卻不需要 SPA 架構。
模式 6:HTMX 請求標頭
HTMX 會隨每次請求傳送自訂標頭:
| 標頭 | 值 | 用途 |
|---|---|---|
HX-Request |
true |
在伺服器端偵測 HTMX 請求 |
HX-Target |
元素 ID | 得知哪個元素將接收回應 |
HX-Trigger |
元素 ID | 得知哪個元素觸發了請求 |
HX-Current-URL |
完整 URL | 得知使用者當前所在頁面 |
伺服器可利用 HX-Request 回傳不同的回應:
@router.get("/writing")
async def writing(request: Request, page: int = 1, category: str = None):
posts = load_all_posts(page=page, category=category)
context = {"request": request, "posts": posts, "current_page": page}
# HTMX request: return only the post list fragment
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
"pages/writing/_post_list.html", context
)
# Normal request: return the full page
return templates.TemplateResponse("pages/writing/index.html", context)
這種雙重回應模式是整個架構的核心。完整頁面載入會回傳完整文件(基礎模板+頁面內容),而 HTMX 導覽則僅回傳變更的內容。決定權在伺服器,而非客戶端。
模式 7:漸進增強
blakecrosley.com 上每個 HTMX 連結都包含標準的 href 屬性:
<a href="/writing?page=2"
hx-get="/writing?page=2"
hx-target="#writing-content"
hx-swap="innerHTML">
Next Page
</a>
若 JavaScript 載入失敗,href 會作為普通連結正常運作。若 HTMX 成功載入,則會攔截點擊事件並執行 AJAX 替換。這就是漸進增強:網站無需 JavaScript 即可運作,而 HTMX 則在可用時提供更佳的使用體驗。
模式 8:載入狀態
<button hx-post="/api/contact"
hx-target="#form-result"
hx-indicator="#submit-spinner">
<span id="submit-spinner" class="htmx-indicator">Sending...</span>
<span>Send Message</span>
</button>
HTMX 在請求期間會為觸發元素加上 htmx-request class。hx-indicator 屬性指向一個在請求期間變為可見的元素。透過 CSS 設定樣式即可:
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}
無需載入狀態管理、無需 useState(false)、無需 setLoading(true)。CSS 處理可見性,HTMX 處理 class 切換。
Alpine.js 模式
Alpine.js 填補了 HTMX 留下的空缺:純粹存在於客戶端、無需與伺服器溝通的狀態。當使用者點擊下拉選單並展開時,該狀態僅存在於瀏覽器中。Alpine.js 透過 HTML 屬性來管理這類狀態。
界線原則
HTMX 與 Alpine.js 之間的分界十分明確:
| 狀態類型 | 工具 | 範例 |
|---|---|---|
| 需要伺服器資料 | HTMX | 搜尋結果、表單驗證、分頁 |
| 僅存在於瀏覽器 | Alpine.js | 下拉選單開關、行動版選單切換、對話框顯示 |
| 兩者兼具 | 兩者皆用 | 語言切換器(Alpine.js 切換、HTMX 式導覽) |
行動版導覽列
基礎範本將整個頁首包裹在一個 Alpine.js 元件中:
<div x-data="{ navOpen: false, langOpen: false }"
@keydown.escape.window="navOpen = false; langOpen = false">
<!-- Mobile hamburger button -->
<button @click="navOpen = !navOpen; langOpen = false"
:aria-expanded="navOpen"
:class="navOpen ? 'nav__toggle is-open' : 'nav__toggle'"
aria-label="Toggle navigation">
<span class="nav__toggle-icon">
<span class="nav__toggle-bar"></span>
<span class="nav__toggle-bar"></span>
<span class="nav__toggle-bar"></span>
</span>
</button>
<!-- Mobile menu panel -->
<div class="mobile-menu" x-show="navOpen" x-cloak>
<nav class="mobile-menu__nav">
<a href="/about" @click="navOpen = false">About</a>
<a href="/#work" @click="navOpen = false">Work</a>
<a href="/writing" @click="navOpen = false">Writing</a>
</nav>
</div>
</div>
關鍵的 Alpine.js 模式:
x-data宣告元件範圍與初始狀態x-show根據狀態切換可見性(使用 CSS 的display: none)x-cloak在 Alpine.js 初始化前隱藏元素(避免未樣式化內容閃爍)@click以表達式綁定點擊事件處理器:aria-expanded(x-bind:aria-expanded的簡寫)動態設定屬性@keydown.escape.window全域監聽 Escape 鍵以關閉面板
下拉選單元件
語言切換器使用 Alpine.js 管理切換狀態,並透過 @click.away 實現點擊外部自動關閉:
<div x-data="{ open: false }"
@click.away="open = false"
@keydown.escape.window="open = false">
<button @click="open = !open"
:aria-expanded="open"
aria-haspopup="listbox">
English
<svg :class="{ 'rotated': open }">...</svg>
</button>
<ul :class="{ 'is-open': open }"
:aria-hidden="!open"
role="listbox"
x-cloak>
<li role="option">
<a href="/ja/about">日本語</a>
</li>
<!-- more languages -->
</ul>
</div>
@click.away 修飾符在點擊外部時自動關閉下拉選單。Alpine.js 僅用一個屬性即可處理——無需手動註冊事件監聽器、無需清除操作、無需 ref 管理。
何時使用 Alpine.js,何時使用原生 JavaScript
適合使用 Alpine.js 的情境:
- 狀態範圍限於單一 DOM 元素(下拉選單、對話框、切換開關)
- 互動為二元或簡單操作(開啟/關閉、顯示/隱藏、切換)
- 多個元素需要響應同一狀態變化
- 無障礙屬性必須與可見性保持同步
適合使用原生 JavaScript 的情境:
- 互動涉及複雜運算(視覺化、模擬)
- 元件擁有自身的渲染迴圈(canvas、動畫)
- 效能至關重要(Alpine.js 為每個
x-data元件增加額外開銷) - 邏輯超過 20-30 行 Alpine.js 表達式
blakecrosley.com 將 Alpine.js 用於導覽列、語言切換及內容摺疊。而 20 個互動式部落格元件(boids 模擬、Hamming 碼視覺化工具等)則使用原生 JavaScript,因為它們需要 canvas 渲染與複雜的狀態機。
端對端範例:/writing 頁面的分類篩選
本節追蹤正式環境程式碼中的一個實際功能,涵蓋每一層:路由、範本、HTMX 互動、安全性、快取,以及最終的渲染結果。此功能為:寫作頁面上的分類標籤,可在不重新載入整頁的情況下篩選部落格文章。
路由(app/routes/pages.py:508)
async def writing_listing(request: Request, page: int = 1, category: str | None = None):
"""Writing page — blog posts and external publications."""
templates = get_templates(request)
markdown_posts = load_all_posts(published_only=True)
all_posts = CUSTOM_BLOG_POSTS + markdown_posts
# Filter by category if specified
if category and category in CATEGORY_MAP:
display_name = CATEGORY_MAP[category]
all_posts = [
p for p in all_posts
if _get_post_category(p).lower() == display_name.lower()
]
# Pagination
total_pages = max(1, (len(all_posts) + POSTS_PER_PAGE - 1) // POSTS_PER_PAGE)
page = max(1, min(page, total_pages))
paginated = all_posts[(page - 1) * POSTS_PER_PAGE : page * POSTS_PER_PAGE]
template_context = {
"request": request,
"posts": paginated,
"categories": categories,
"current_category": category,
"current_page": page,
"total_pages": total_pages,
# ... SEO: canonical, prev/next URLs
}
# HTMX partial: return just the post list fragment
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
"pages/writing/_post_list.html",
template_context,
)
# Full page for direct navigation
return templates.TemplateResponse(
"pages/writing/index.html",
template_context,
)
HX-Request 標頭檢查是核心模式:同一路由、同一資料、不同範本。HTMX 取得片段,瀏覽器取得完整頁面。
分類標籤(HTMX)
<!-- Category filter tabs -->
<nav class="writing-categories">
<a href="/writing"
hx-get="/writing"
hx-target="#post-list"
hx-push-url="true"
class="category-tab {% if not current_category %}active{% endif %}">
All ({{ total_posts }})
</a>
{% for cat in categories %}
<a href="/writing?category={{ cat.slug }}"
hx-get="/writing?category={{ cat.slug }}"
hx-target="#post-list"
hx-push-url="true"
class="category-tab {% if current_category == cat.slug %}active{% endif %}">
{{ cat.name }} ({{ cat.count }})
</a>
{% endfor %}
</nav>
<div id="post-list">
{% include "pages/writing/_post_list.html" %}
</div>
每個標籤同時具備 href(在無 JavaScript 環境下仍可正常運作)與 hx-get(僅置換文章列表)。hx-push-url 會更新瀏覽器網址,使篩選後的檢視可被分享與加入書籤。
片段範本(pages/writing/_post_list.html)
無論是在頁面初次載入時引入,或是由 HTMX 置換,片段範本的渲染結果完全一致:
{% for post in posts %}
<article class="post-card">
<a href="{{ locale_prefix() }}/blog/{{ post.meta.slug }}">
<h3>{{ post.meta.title }}</h3>
<p>{{ post.meta.description }}</p>
<time>{{ post.meta.date }}</time> · {{ post.reading_time }}m
</a>
</article>
{% endfor %}
片段範本中沒有特殊的 HTMX 標記,也沒有客戶端渲染邏輯。同一份 HTML 同時服務於初始頁面載入與後續的每次篩選操作。
安全性
分類值在篩選前會先與 CATEGORY_MAP(伺服器端字典)進行驗證。無效的分類會被忽略,而非回傳給客戶端。使用者輸入不會被插入 SQL 或 HTML 中。CSP 標頭則阻止行內腳本執行。
快取
分類回應為動態內容(不經 CDN 快取)。但靜態資源(CSS、HTMX、Alpine.js)經內容雜湊處理,首次載入後即可無限期快取。後續的分類切換僅傳輸 HTML 片段(約 3-5KB)——不會重新擷取 CSS、JS 或圖片。
此範例所展示的要點
一個功能、真實的正式環境程式碼、零建置工具。伺服器負責篩選並渲染 HTML。HTMX 置換文章列表。Alpine.js 未參與其中(無需客戶端狀態)。網址隨之更新以支援分享。漸進增強:標籤在無 JavaScript 的環境下仍可作為一般連結運作。此功能的自訂 JavaScript 程式碼:零行。
選用擴充
以下章節涵蓋可與核心技術堆疊互補的模式,但並未在 blakecrosley.com 上使用。收錄這些內容是因為它們代表了團隊採用此架構時最常見的擴充方式。
不使用 Sass 的 Bootstrap 5
注意: blakecrosley.com 使用純 CSS 搭配自訂屬性,並未採用 Bootstrap。本節介紹 Bootstrap 5 作為一個選項,適合希望在無需建置步驟的情況下使用工具類框架的團隊。Bootstrap 編譯後的 CSS 可從 CDN 載入或整合至您的樣式表中。以下模式適用於各種情境,可與前面章節所述的 HTMX + Alpine.js 方法搭配使用。
Bootstrap 5 移除了對 jQuery 的依賴,並支援獨立使用 CSS。不需要 Sass、PostCSS 或任何建置工具,即可使用 Bootstrap 的格線系統與工具類別。
無 CDN 的自行託管
blakecrosley.com 自行託管所有第三方函式庫:
<!-- base.html — no CDN, no external requests -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
自行託管可消除外部依賴、防止 CDN 故障導致網站異常,並允許使用內容雜湊 URL 進行不可變快取。請下載 Bootstrap 編譯後的 CSS(非 Sass 原始碼),放置於 static/css/vendor/ 目錄中。
格線系統
Bootstrap 的格線系統僅需純 HTML 類別即可運作:
<div class="container">
<div class="row">
<div class="col-12 col-md-8">
<article>Main content</article>
</div>
<div class="col-12 col-md-4">
<aside>Sidebar</aside>
</div>
</div>
</div>
無需 Sass mixin,無需 @include make-col()。編譯後的 CSS 已包含響應式格線類別。若需超出 Bootstrap 預設值的自訂斷點,直接撰寫純 CSS 媒體查詢即可。
純 CSS 覆寫
透過 CSS 自訂屬性與標準選擇器覆寫 Bootstrap 的預設值:
/* Custom design tokens — no Sass, no Tailwind */
:root {
--color-bg-dark: #000000;
--color-text-primary: #ffffff;
--color-text-secondary: rgba(255, 255, 255, 0.65);
--color-text-tertiary: rgba(255, 255, 255, 0.40);
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--spacing-lg: 2rem;
--gutter: 48px;
--font-size-lg: 1.25rem;
}
/* Responsive override — the browser reads this at runtime */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 48px → 24px on mobile */
}
}
/* Override Bootstrap's default body styles */
body {
background: var(--color-bg-dark);
color: var(--color-text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
CSS 自訂屬性會沿 DOM 層級串聯、從父元素繼承,並在執行時回應媒體查詢。Sass 變數則會編譯為靜態值後消失。這項區別對主題設計至關重要:只需修改單一自訂屬性,即可更新所有衍生值,無需重新編譯。12
工具類別與元件 CSS
針對一次性的間距與版面配置,使用 Bootstrap 工具類別;針對重複出現的模式,則使用元件 CSS:
<!-- Bootstrap utility for one-off spacing -->
<div class="mt-4 mb-3 px-2">One-off layout</div>
<!-- Component class for repeated patterns -->
<article class="writing__item">
<h3 class="writing__item-title">Post Title</h3>
<p class="writing__item-description">Description</p>
</article>
/* Component CSS — BEM naming, reusable */
.writing__item {
padding: var(--spacing-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.15s ease;
}
.writing__item:hover {
background: rgba(255, 255, 255, 0.03);
}
.writing__item-title {
font-size: var(--font-size-lg);
margin-bottom: 0.5rem;
}
原則如下:Bootstrap 工具類別用於版面機制(邊距、內距、flexbox),自訂 CSS 用於視覺識別(色彩、排版、動畫)。同一關注點切勿混用工具類別與元件樣式。
國際化與在地化
blakecrosley.com 以 10 種語言提供內容:英文、日文、韓文、簡體中文、繁體中文、德文、法文、西班牙文、波蘭文及巴西葡萄牙文。
基於 URL 的語系路由
語系嵌入在 URL 路徑中:/about(英文)、/ja/about(日文)、/zh-Hans/about(簡體中文)。英文為預設語系,不需要前綴。
# i18n/config.py
SUPPORTED_LOCALES = [
"en", "zh-Hans", "zh-Hant", "fr", "de", "ja", "ko", "pl", "pt-BR", "es"
]
DEFAULT_LOCALE = "en"
語系中介軟體從 URL 路徑中擷取語系:
# i18n/middleware.py
class LocaleMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
path = request.url.path
# Check if path starts with a supported locale
for locale in SUPPORTED_LOCALES:
if path.startswith(f"/{locale}/") or path == f"/{locale}":
request.state.locale = locale
# Strip locale prefix for route matching
request.scope["path"] = path[len(f"/{locale}"):]
break
else:
request.state.locale = DEFAULT_LOCALE
response = await call_next(request)
return response
中介軟體在路由比對前會移除語系前綴,因此路由處理器不需要設定語系專用路徑——/about 同時處理英文(/about)和日文(/ja/about)的請求,因為中介軟體已將路徑正規化。
範本中的翻譯函式
Jinja2 全域變數提供翻譯函式:
<!-- Template usage -->
<h3>{{ _('ui.footer.navigate') | default('Navigate') }}</h3>
<a href="{{ locale_prefix() }}/about">
{{ _('ui.nav.about') | default('About') }}
</a>
_() 函式從記憶體快取中查詢翻譯鍵值。| default() 過濾器在翻譯缺失時提供英文備援。locale_prefix() 函式回傳當前語系的 URL 前綴(英文為 "",日文為 "/ja")。
Hreflang 標籤
每個頁面都包含所有支援語系的 hreflang 標籤:
<!-- Generated in base.html -->
{% for alt in alternate_urls(request.url.path) %}
<link rel="alternate" hreflang="{{ alt.hreflang }}" href="{{ alt.url }}">
{% endfor %}
產生的結果如下:
<link rel="alternate" hreflang="en" href="https://blakecrosley.com/about">
<link rel="alternate" hreflang="ja" href="https://blakecrosley.com/ja/about">
<link rel="alternate" hreflang="zh-Hans" href="https://blakecrosley.com/zh-Hans/about">
<!-- ... all 10 locales -->
<link rel="alternate" hreflang="x-default" href="https://blakecrosley.com/about">
搜尋引擎利用 hreflang 在搜尋結果中提供正確的語言版本。x-default 項目指向英文版本作為備援。13
翻譯儲存與記憶體快取
翻譯資料儲存在 Cloudflare D1(邊緣端的 SQLite)中,並透過 lifespan 處理器載入記憶體快取:
@asynccontextmanager
async def lifespan(app: FastAPI):
# Load translations into memory at startup
for locale in SUPPORTED_LOCALES:
data = await fetch_translations(locale)
TRANSLATIONS[locale] = data
yield
app = FastAPI(lifespan=lifespan)
記憶體快取避免了每次頁面渲染時的資料庫查詢。翻譯更新需要重新整理快取(透過管理端點或部署觸發)。這種架構以即時性換取效能——翻譯內容變動頻率低,但頁面渲染則發生在每一次請求。
健康監控
blakecrosley.com 內建了 i18n 健康檢查端點,用於監控各語系的翻譯覆蓋率:
@app.get("/health/i18n")
async def health_i18n():
cache = get_translation_cache()
result = {
"status": "healthy",
"cache_loaded": cache.is_loaded,
"locales": {},
"alerts": [],
}
# Check coverage for each locale
for locale in SUPPORTED_LOCALES:
coverage = await calculate_coverage(locale, en_count)
result["locales"][locale] = {"coverage": round(coverage, 2)}
if coverage < 99.5:
result["alerts"].append(
f"{locale}: {coverage:.1f}% coverage (threshold: 99.5%)"
)
result["status"] = "warning"
return result
99.5% 的覆蓋率門檻能在使用者遇到未翻譯字串之前及時發現缺漏。此健康檢查端點整合了 Railway 的監控功能,在覆蓋率下降時發出警示——例如新增了尚未翻譯的 UI 字串。
語系感知的內容渲染
部落格文章與指南支援按語系翻譯中繼資料與內容:
# In route handler
translated = get_blog_translation(post.meta.slug, locale)
return templates.TemplateResponse("pages/blog/post.html", {
"request": request,
"post": post,
"translated_title": translated.title if translated else post.meta.title,
"translated_description": translated.description if translated else post.meta.description,
})
<!-- In template -->
<h1>{{ translated_title }}</h1>
<p class="post__description">{{ translated_description }}</p>
<!-- Body content falls back to English if translation unavailable -->
{{ post.html | sanitize | safe }}
模式始終如一:優先使用翻譯內容,找不到時退回英文。這允許部分翻譯——日文使用者即使文章本文仍為英文,也能看到翻譯後的標題和描述。Jinja2 的 | default() 過濾器將此模式封裝在單一管道中:
{{ translated.title if translated else post.meta.title }}
語系資料翻譯
專案描述和導覽標籤等靜態內容,透過輔助函式翻譯,在維持相同資料結構的同時替換為語系專用的字串:
# i18n/data.py
def translate_projects(projects: list, locale: str) -> list:
"""Return projects with translated titles and descriptions."""
if locale == "en":
return projects
translated = []
for project in projects:
t = get_translation(f"project.{project['slug']}.title", locale)
d = get_translation(f"project.{project['slug']}.description", locale)
translated.append({
**project,
"title": t or project["title"],
"description": d or project["description"],
})
return translated
這種做法將翻譯層與資料層分離。路由無論語系為何,都傳遞相同的 projects 清單,翻譯函式透明地包裝資料。
包含 Hreflang 替代連結的 Sitemap
動態 Sitemap 包含所有頁面在所有語系中的交叉引用:
@app.get("/sitemap.xml")
async def sitemap():
for page in static_pages:
for locale in SUPPORTED_LOCALES:
# Each URL entry includes alternates for all locales
locale_path = f"/{locale}{path}" if locale != "en" else path
xml_parts.append(f"<loc>{base_url}{locale_path}</loc>")
# Add xhtml:link alternates
for alt_locale in SUPPORTED_LOCALES:
alt_path = f"/{alt_locale}{path}" if alt_locale != "en" else path
hreflang = LOCALE_TO_HREFLANG[alt_locale]
xml_parts.append(
f'<xhtml:link rel="alternate" hreflang="{hreflang}" '
f'href="{base_url}{alt_path}"/>'
)
每個頁面會產生 10 個 URL 項目(每個語系一個),每個項目包含 11 個替代連結(10 個語系加上 x-default)。以一個擁有 50 個頁面的網站為例,Sitemap 將包含 500 個 URL 項目與 5,500 個 hreflang 連結。Sitemap 動態產生並快取一小時。
Database Patterns
注意: blakecrosley.com透過HTTP使用Cloudflare D1(serverless SQLite)儲存所有持久性資料,而不是SQLAlchemy。本節說明需要關聯式資料庫的FastAPI專案中,標準的SQLAlchemy async模式,也就是此技術堆疊最常見的生產環境設定。
SQLAlchemy 2.0 Async
對於需要關聯式資料庫的應用程式,SQLAlchemy 2.0的async支援能與FastAPI順暢整合:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_async_engine("sqlite+aiosqlite:///./data.db")
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
安裝注意事項(SQLAlchemy 2.0.50+): 自2.0.50起,async堆疊的greenlet相依套件不再預設安裝。請使用asyncio extra將其一併拉入,否則第一次對engine執行await時,會因缺少greenlet而失敗:23
pip install "sqlalchemy[asyncio]" aiosqlite
SQLAlchemy 2.0.50也要求Python 3.10+(已移除3.7–3.9支援),並新增free-threaded(3.13t)wheels。23
資料庫Session的Dependency Injection
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
@router.get("/users/{user_id}")
async def get_user(request: Request, user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
return templates.TemplateResponse("pages/user.html", {
"request": request, "user": user
})
get_db dependency負責管理session生命週期:開啟session、將其yield給route handler、成功時commit,發生exception時rollback。所有資料庫操作都使用參數化查詢,絕不使用字串插值。
Pydantic整合
Pydantic models會在API邊界驗證輸入,並將輸出序列化供templates使用:
from pydantic import BaseModel, EmailStr
class ContactForm(BaseModel):
name: str
email: EmailStr
message: str
@router.post("/contact")
async def submit_contact(request: Request, form: ContactForm):
# form.name, form.email, form.message are validated
await send_email(form)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
Pydantic會在route handler執行前驗證型別、格式(email、URL)與限制條件(最小/最大長度)。無效輸入會自動回傳422 response。這取代了client-side form validation libraries:由伺服器負責驗證,HTMX則換入成功訊息或錯誤回饋。
使用Alembic進行Migrations
Alembic負責管理資料庫schema變更:
# Generate a migration from model changes
alembic revision --autogenerate -m "add user preferences table"
# Apply migrations
alembic upgrade head
# Roll back one migration
alembic downgrade -1
autogenerate功能會比較SQLAlchemy models與目前的資料庫schema,並產生migration scripts。這些scripts是版本化的Python檔案,存放在repository中:
# alembic/versions/001_add_user_preferences.py
def upgrade():
op.create_table(
"user_preferences",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id")),
sa.Column("locale", sa.String(10), default="en"),
sa.Column("theme", sa.String(20), default="dark"),
)
def downgrade():
op.drop_table("user_preferences")
Migrations會在部署期間執行(於應用程式啟動前)。這可確保資料庫schema與應用程式程式碼一致。對blakecrosley.com而言,多數資料位於Cloudflare D1(透過HTTP存取),因此Alembic migrations適用於儲存session data與analytics所使用的本機SQLite或PostgreSQL資料庫。
Cloudflare D1模式
blakecrosley.com使用Cloudflare D1作為遠端資料庫,並透過Cloudflare Worker proxy存取:
class D1Client:
"""HTTP client for Cloudflare D1 via Worker proxy."""
def __init__(self, worker_url: str, auth_secret: str):
self.worker_url = worker_url
self.auth_secret = auth_secret
async def fetch_all(self, sql: str, params: list = None) -> list[dict]:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.worker_url}/query",
json={"sql": sql, "params": params or []},
headers={"Authorization": f"Bearer {self.auth_secret}"},
)
return response.json()["results"]
此模式適合需要資料庫、但不想管理database server的應用程式。D1是在Cloudflare edge上的SQLite,透過HTTP存取。Worker proxy負責處理authentication與rate limiting。取捨在於延遲:每個query都是一次HTTP request(約50-100ms),相較之下,本機database connection約為1-5ms。對translations這類read-heavy workloads而言,啟動時的in-memory cache可以緩解這項成本。
安全性
Security Headers Middleware
blakecrosley.com透過自訂 middleware 實作強化的安全性標頭:
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
CSP_DIRECTIVES = {
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data: https:",
"connect-src": "'self'",
"frame-ancestors": "'self'",
"base-uri": "'self'",
"form-action": "'self'",
"upgrade-insecure-requests": "",
}
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = self.csp
response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
return response
CSP包含'unsafe-inline'與'unsafe-eval',因為Alpine.js需要它們來進行運算式求值。替代方案是使用Alpine.js的 CSP 相容建置版本,但其功能有所限制。14其他所有功能都已鎖定:frame-ancestors可防止點擊劫持,form-action將表單提交限制在相同來源,而upgrade-insecure-requests會強制使用HTTPS。
使用HTMX確保 CDN 快取安全
安全性標頭 middleware 會在HTMX回應中加入Vary: HX-Request:
if request.headers.get("HX-Request"):
existing_vary = response.headers.get("Vary", "")
if "HX-Request" not in existing_vary:
parts = [v.strip() for v in existing_vary.split(",") if v.strip()]
parts.append("HX-Request")
response.headers["Vary"] = ", ".join(parts)
若沒有此標頭,CDN 可能會快取HTMX片段回應,並將其當作完整頁面提供給非HTMX請求(反之亦然)。Vary標頭會告訴 CDN,需根據HX-Request標頭值儲存不同的快取項目。11
CSRF Protection
HTMX表單使用無狀態、以 HMAC 簽章的 CSRF token:
# csrf.py
def generate_csrf_token() -> str:
"""Token format: timestamp:random:HMAC-SHA256-signature"""
timestamp = str(int(time.time()))
random_value = secrets.token_hex(16)
payload = f"{timestamp}:{random_value}"
signature = hmac.new(
CSRF_SECRET.encode(), payload.encode(), hashlib.sha256
).hexdigest()
return f"{payload}:{signature}"
def validate_csrf_token(token: str) -> bool:
"""Verify signature and check expiration (1 hour)."""
timestamp, random_value, signature = token.split(":")
if int(time.time()) - int(timestamp) > 3600:
return False
expected = hmac.new(
CSRF_SECRET.encode(),
f"{timestamp}:{random_value}".encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
token 會透過Jinja2全域物件在範本中產生,並包含在HTMX表單請求中:
<form hx-post="/contact" hx-target="#form-result">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- form fields -->
</form>
無狀態 token 可免除伺服器端 session 儲存。HMAC 簽章可確保 token 是由伺服器產生。時間戳記可防止重放攻擊。hmac.compare_digest可防止時序攻擊。15
HTML清理
使用者產生的內容在渲染前會先通過nh3處理:
templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}
nh3函式庫會移除不在允許清單中的標籤與屬性。連結會自動取得rel="noopener noreferrer"。這道防線獨立於 CSP:它在渲染層防止儲存型 XSS,而 CSP 則在瀏覽器層防止注入的 script。這就是縱深防禦。
輸入驗證
Pydantic models會在API邊界驗證所有輸入:
from pydantic import BaseModel, Field, EmailStr
class ContactRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr
message: str = Field(..., min_length=10, max_length=5000)
FastAPI會自動針對無效輸入回傳 422 Unprocessable Entity。再搭配參數化資料庫查詢(SQLAlchemy絕不插入字串),即可防止 SQL injection,並確保邊界上的型別安全。
效能
Lighthouse 100/100/100/100
blakecrosley.com在 Lighthouse 的四個類別中都獲得 100 分:Performance、Accessibility、Best Practices與 SEO。可在PageSpeed Insights驗證。2
關鍵最佳化如下:
CSS載入策略
blakecrosley.com使用單一<link>標籤載入CSS,並採用內容雜湊 URL 以便不可變快取:
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
asset()輔助函式會附加內容雜湊(?v=a3b2c1d4),讓瀏覽器無限期快取該檔案,直到內容變更為止。沒有 critical CSS抽取,沒有 print-media 技巧,也沒有以JavaScript為基礎的載入方式。CSS檔案經 gzip 後約為 8KB,足夠小,因此單一請求做法無需複雜的最佳化手法,也能在 Lighthouse Performance 得到 100 分。
GZip 壓縮
app.add_middleware(GZipMiddleware, minimum_size=500)
超過 500 位元組的回應會被壓縮。HTML可壓縮 70-80%,將 15KB 文件縮減至 3-4KB。
不可變靜態資產快取
# In security headers middleware
if request.url.path.startswith("/static/"):
if os.environ.get("RAILWAY_ENVIRONMENT"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
具有內容雜湊 URL(?v=a3f8b2c1d0)的靜態資產會以immutable快取 1 年。當檔案變更時,雜湊也會改變,迫使瀏覽器與 CDN 擷取新版本。
延後 Script 載入
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>
defer屬性會讓 script 與HTML解析並行下載,但在文件解析完成後才執行。這可避免阻塞渲染,同時不必處理 async 載入與執行順序管理的複雜性。
圖片最佳化
圖片使用 WebP,搭配 responsive srcset 與明確尺寸:
OPTIMIZED_IMAGES = {
"vision-sprint": {
"webp_srcset": (
"/static/images/optimized/vision-sprint-400w.webp 400w, "
"/static/images/optimized/vision-sprint-800w.webp 800w, "
"/static/images/optimized/vision-sprint-1200w.webp 1200w"
),
"fallback": "/static/images/optimized/vision-sprint-fallback.jpg",
"width": 1200,
"height": 1045,
},
}
<picture>
<source type="image/webp"
srcset="{{ image.webp_srcset }}"
sizes="(max-width: 768px) 100vw, 50vw">
<img src="{{ image.fallback }}"
width="{{ image.width }}"
height="{{ image.height }}"
alt="{{ image.alt }}"
loading="lazy">
</picture>
明確的width與height屬性可防止 Cumulative Layout Shift(CLS)。loading="lazy"屬性會延後載入畫面外圖片。在相同品質下,WebP 檔案比 JPEG 小 25-35%。16
Early Hints
# In main.py
app.state.preload_links = [
f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]
# In security headers middleware
if "text/html" in content_type:
preload_links = getattr(request.app.state, "preload_links", [])
if preload_links:
response.headers["Link"] = ", ".join(preload_links)
帶有rel=preload的Link標頭會告訴Cloudflare送出 103 Early Hints 回應,讓瀏覽器能在伺服器完成產生HTML回應前,先開始擷取CSS。17
最小化JavaScript
JavaScript總體大小如下:
| 函式庫 | 大小(minified + gzipped) |
|---|---|
| HTMX | ~16 KB |
| Alpine.js | ~15 KB |
| Page-specific JS | 4-8 KB |
| 總計 | 35-39 KB |
典型 React 應用程式在應用程式碼之前,會先傳送 100-300 KB 的 framework JavaScript。18無建置流程的做法會傳送較少JavaScript,原因很單純:需要傳送的JavaScript本來就更少。
部署
Railway
blakecrosley.com 透過 git push 部署至 Railway:
# railway.toml
[build]
builder = "nixpacks"
[deploy]
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10
Railway 的 Nixpacks 建置器會透過 requirements.txt 偵測 Python 專案、安裝相依套件,並執行啟動命令。無須 Dockerfile。健康狀態檢查端點會確保應用程式正常回應後,才開始接收流量:
@app.get("/health")
async def health():
return {"status": "healthy"}
部署管線
git push origin main
→ Railway detects push
→ Nixpacks installs Python + requirements.txt (cached)
→ uvicorn starts
→ Health check passes
→ Traffic routes to new deployment
→ ~40 seconds total
不需要 npm install。不需要 npm run build。不需要 webpack 編譯。也不需要 TypeScript 編譯。唯一的安裝步驟是 pip install -r requirements.txt,且會在不同部署之間快取。
Procfile
web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
Procfile 提供與 Heroku 相容的替代方案。Railway 同時支援 railway.toml 與 Procfile。${PORT:-8000} 語法會使用平台提供的連接埠;若未提供,則在本機開發時預設使用 8000。
Uvicorn 正式環境設定
對於流量較高的部署,請使用多個 worker:
uvicorn app.main:app \
--host 0.0.0.0 \
--port ${PORT:-8000} \
--workers 4 \
--loop uvloop \
--http httptools
--workers 4會執行 4 個 worker 行程(通則:2 * CPU 核心數 + 1)--loop uvloop會使用速度更快的 uvloop 事件迴圈(可直接取代 asyncio)--http httptools會使用速度更快的 httptools HTTP 解析器
每個 worker 都是獨立行程,並各自保有一份應用程式副本,因此每個行程的記憶體用量會乘上 worker 數量——這正是 FastAPI 0.140.0 相依關係圖修正能發揮效益之處:在相依套件繁多的應用程式中,0.139.2 的 4 個 worker 會將舊有的 Dependant 額外負擔承擔 4 次。27
在開發環境中,--reload 會監看檔案變更:
uvicorn app.main:app --reload --port 8000
Docker 替代方案
對於要求使用 Docker 的平台:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
slim 基礎映像檔可縮小容器體積。--no-cache-dir 可避免 pip 將下載的套件儲存在映像檔層中。
Cloudflare CDN
blakecrosley.com 使用 Cloudflare 提供 CDN 快取、DNS 與 Workers:
# Cache headers for HTML pages (set in security middleware)
response.headers["Cache-Control"] = (
"public, max-age=300, s-maxage=3600, "
"stale-while-revalidate=86400"
)
max-age=300——瀏覽器快取 5 分鐘s-maxage=3600——CDN 快取 1 小時stale-while-revalidate=86400——重新驗證期間可持續提供過期內容 24 小時
靜態資源使用 max-age=31536000, immutable,因為含有內容雜湊的 URL 可確保內容維持最新。
決策架構
您需要建置工具嗎?
請回答以下 4 個問題:
1. 是否有超過 5 位開發人員共用 JavaScript 介面? 若是,TypeScript 的編譯期型別檢查可防止整合錯誤,避免等到執行期測試時才為時已晚。請加入建置步驟。
2. 您的應用程式是否需要管理複雜的用戶端狀態? 若拖放、即時協作或離線優先資料是核心功能,而非錦上添花,React 或 Svelte 等框架帶來的複雜度便物有所值。請加入建置步驟。
3. 是否有多項產品使用共用元件庫? 若是,該程式庫便需要 npm 封裝、語意化版本控制及 tree shaking。請加入建置步驟。
4. 您是否依賴預設使用 bundler 的 npm 生態系程式庫? 若 Radix、Framer Motion、TanStack Query 或類似程式庫是產品核心,建置管線便不可或缺。
若 4 個問題的答案全都是「否」,免建置方案便切實可行。只要任一答案為「是」,建置工具就在解決實際問題。真正的錯誤,是在 4 個答案全為「否」時仍加入建置工具——為不存在的問題尋求解方,反而徒增相依套件管理負擔。1
技術堆疊比較
| 類別 | 免建置(本指南) | React + 建置工具 |
|---|---|---|
| 最適合 | 內容網站、作品集、內部工具、CRUD 應用程式 | SaaS 產品、複雜 SPA、設計系統使用者 |
| 團隊規模 | 1-5 位開發人員 | 5-50+ 位開發人員 |
| 狀態管理 | 伺服器(HTMX)+ 用戶端(Alpine.js) | 用戶端(React state、Redux、Zustand) |
| 型別安全 | 執行期(伺服器端 Pydantic) | 編譯期(TypeScript) |
| 元件重用 | Jinja2 includes + macros | npm 套件、共用程式庫 |
| SEO | 預設由伺服器端算繪 | 需要設定 SSR/SSG |
| 效能下限 | 高(少量 JS、伺服器端算繪) | 不一(受框架額外負擔影響) |
| 複雜度上限 | 較低(不支援離線模式與複雜用戶端狀態) | 較高(可實現任何用戶端互動) |
| 相依套件 | 17 個 Python 套件 | 300+ 個 npm 套件 |
| 建置時間 | 0 秒 | 15-60 秒 |
不適合使用 HTMX 的情況
HTMX 以伺服器往返取代用戶端狀態。此方式在延遲變得重要之前都行之有效:
- 拖放介面——每次拖曳事件都需 200ms 的伺服器往返,令人難以接受
- 即時協作——由 WebSocket 驅動的狀態需要在用戶端解決衝突
- 離線優先應用程式——沒有伺服器,就無法使用 HTMX
- 與狀態連動的複雜動畫——Framer Motion 與 React Spring 預設採用 React reconciliation 模型
- Canvas/WebGL 應用程式——算繪迴圈本質上是在用戶端執行
對這些使用情境而言,用戶端框架才是合適的工具。免建置方案無意取而代之。
快速參考卡
FastAPI
# Development
source venv/bin/activate
uvicorn app.main:app --reload --port 8000
# Production
uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
# Testing
python -m pytest -v --cov=app
# Database migrations
alembic upgrade head
alembic revision --autogenerate -m "description"
HTMX 屬性
hx-get="/url" <!-- GET request -->
hx-post="/url" <!-- POST request -->
hx-target="#element" <!-- Where to put response -->
hx-swap="innerHTML" <!-- How to insert (innerHTML, outerHTML, beforeend) -->
hx-trigger="click" <!-- What triggers request -->
hx-trigger="keyup changed delay:300ms" <!-- Debounced input -->
hx-trigger="load" <!-- Fire on element load -->
hx-indicator="#spinner" <!-- Show during request -->
hx-push-url="true" <!-- Update browser URL -->
hx-replace-url="true" <!-- Replace URL (no history) -->
Alpine.js 屬性
x-data="{ open: false }" <!-- Component scope + state -->
x-show="open" <!-- Toggle visibility -->
x-cloak <!-- Hide until Alpine inits -->
@click="open = !open" <!-- Event handler -->
@click.away="open = false" <!-- Outside click -->
@keydown.escape="open = false" <!-- Keyboard event -->
:class="{ 'active': open }" <!-- Dynamic class -->
:aria-expanded="open" <!-- Dynamic attribute -->
x-text="count" <!-- Dynamic text content -->
x-init="fetchData()" <!-- Run on init -->
CSS 自訂屬性
:root {
--color-bg: #000000;
--color-text: #ffffff;
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--font-size-lg: 1.25rem;
}
@media (max-width: 768px) {
:root { --gutter: 24px; }
}
安全性標頭
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
專案設定檢查清單
[ ] FastAPI app with Jinja2Templates
[ ] Security headers middleware (CSP, HSTS, X-Frame-Options)
[ ] CSRF token generation and validation
[ ] GZip middleware (minimum_size=500)
[ ] Content-hash asset versioning (cache busting)
[ ] HTMX self-hosted in /static/js/vendor/
[ ] Alpine.js self-hosted in /static/js/vendor/
[ ] CSS custom properties for design tokens
[ ] Health check endpoint (/health)
[ ] Error handlers (404, 500)
[ ] robots.txt, sitemap.xml, llms.txt
[ ] JSON-LD structured data in base template
[ ] Hreflang tags for i18n (if multi-language)
[ ] HTML sanitization filter (nh3)
[ ] Rate limiting middleware
[ ] Deferred script loading
常見問題
HTMX 是否已適合實際 Web 應用程式的正式環境?
是。HTMX 自 2020 年起便維持穩定,並已應用於多個產業的正式環境。其創作者 Carson Gross 將向下相容列為核心設計原則——HTMX 文件明確指出,同一主要版本內不會破壞現有應用程式。19此函式庫經過壓縮並以 gzip 處理後約為 16KB、沒有任何相依套件,且遵循語意化版本規範。blakecrosley.com 已在正式環境使用 HTMX 三年,期間未發生任何與 HTMX 相關的錯誤。20
不使用建置步驟也能採用 TypeScript 嗎?
只能部分採用。您可以使用 tsc --noEmit 對 TypeScript 檔案進行型別檢查,而不產生輸出檔案,藉此像使用程式碼檢查工具一樣,在編譯階段執行檢查。然而,瀏覽器無法直接執行 .ts 檔案,因此若要提供 TypeScript,仍然需要建置步驟。另一種做法是在一般 .js 檔案中使用 JSDoc 型別註解,讓 TypeScript 無須編譯即可進行檢查。如此既能在開發期間享有型別安全,又能交付標準 JavaScript。
此方法與 Astro 或 11ty 相比如何?
Astro 與 11ty 都是靜態網站產生器,可輸出一般 HTML,並將用戶端 JavaScript 降至最低;但兩者都需要建置步驟(Node.js、npm install 與建置指令)。免建置方法則完全省去這個步驟——伺服器會在每次收到請求時算繪 HTML。其中的取捨是:Astro/11ty 產生的靜態頁面速度較快(無須伺服器運算),而 FastAPI + HTMX 則能原生處理動態內容(使用者專屬資料、表單提交、即時更新),無須另設 API 層。
使用 React 進行伺服器端算繪(SSR)又如何?
Next.js SSR 與 FastAPI + HTMX 方法有著相同目標:將伺服器算繪的 HTML 傳送至瀏覽器。差異在於初次算繪後的處理方式。Next.js 會使用 React 對頁面進行水合,將框架執行環境與元件程式碼傳送至用戶端。FastAPI + HTMX 不會進行水合——HTML 就是最終輸出。後續互動則由 HTMX 向伺服器請求新的 HTML 片段來處理。結果是:FastAPI + HTMX 傳送的 JavaScript 總量約為 35–40KB,相較之下,Next.js 應用程式通常為 100–300KB。18
如何使用這套技術堆疊處理表單驗證?
在伺服器端處理。提交表單時,由 Pydantic 驗證輸入內容。若驗證失敗,伺服器會傳回附有錯誤訊息的表單,再由 HTMX 將回應替換至 DOM:
<form hx-post="/contact" hx-target="#form-container" hx-swap="outerHTML">
<input type="email" name="email" required>
<button type="submit">Send</button>
</form>
@router.post("/contact")
async def contact(request: Request, email: str = Form(...)):
if not validate_email(email):
return templates.TemplateResponse("components/_contact_form.html", {
"request": request,
"error": "Please enter a valid email address",
"email": email, # Preserve input
})
await send_email(email)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
伺服器負責驗證與算繪錯誤狀態,HTMX 則負責替換結果。無須使用用戶端驗證函式庫。HTML 的 required 屬性會提供基本的瀏覽器層級驗證,作為第一道防線。
可以加入即時功能(WebSocket)嗎?
可以。FastAPI 內建 WebSocket 支援:
from fastapi import WebSocket
@app.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
while True:
data = await get_notification()
await websocket.send_text(render_notification_html(data))
HTMX 提供 WebSocket 擴充功能(hx-ws),可將元素連線至 WebSocket 端點:
<!-- HTMX 2.x WebSocket extension syntax -->
<div hx-ext="ws" ws-connect="/ws/notifications">
<div id="notifications" ws-send></div>
</div>
注意:HTMX 1.x 使用
hx-ws="connect:..."語法。HTMX 2.x 已將 WebSocket 支援移至獨立擴充功能(htmx-ext-ws),並改用上述ws-connect與ws-send屬性。若使用 HTMX 1.x,舊版hx-ws語法仍然有效。HTMX 4.0 Beta 版進度:htmx 4.0.0-beta6 現已發布於 npm 的
next標籤,4.0 文件也已更新(beta6 發布於 2026年7月23日);與此同時,htmx.org 的快速入門指南與 npm 的latest標籤仍維持在 2.0.10。本指南依然以 HTMX 2.x 為準;在 4.0 穩定之前,2.x 仍是正式環境的建議版本。從 2.x 遷移至 4.x 是跨世代升級,並非 2.x 的小版本更新。big-skies-software 的版本編排慣例會略過奇數主要版本,因此 4.0 是 2.x 之後的下一個版本。21224.0 文件中值得持續關注的項目。在 4.0 GA 正式發布前,有兩項新增功能尤其值得進行安全性與架構審查:新的
hx-live擴充功能引進 DOM 回應式運算式,當參照的狀態變更時會重新求值;新的hx-nonce擴充功能則透過 CSP nonce 管制 htmx 屬性處理。4.0 遷移指南也調整了多項設定概念、恢復或變更部分事件與歷程記錄行為,並從核心移除若干 JavaScript 輔助工具。請將 4.0 視為一項遷移專案,而非可直接替換的 2.x 修補版本。21
伺服器傳來的訊息會沿用 HTTP 回應的目標選取與替換機制,置換至 DOM。伺服器透過 WebSocket 傳送 HTML 片段,再由 HTMX 插入這些片段。
這套技術堆疊如何處理 SEO?
伺服器算繪的 HTML 本質上對 SEO 友善,因為爬蟲無須執行 JavaScript,即可取得完整的頁面內容。blakecrosley.com 另加入多層 SEO 強化措施:
- 每個頁面的
<head>皆包含 JSON-LD 結構化資料(Person、Article、WebSite、FAQPage 結構描述) - 動態 Sitemap,包含全部 10 個語系的 hreflang 替代版本
- 位於
/blog/feed.xml的 RSS 摘要 - 根目錄下的
llms.txt,提升 AI 爬蟲的內容探索能力 - 基礎範本中的標準網址與 Open Graph 標籤
- 語意化 HTML:
<article>、<section>、<main>,以及正確的標題階層
無須設定 SSR。不需要 getStaticProps,也不需要 ISR。每次收到請求時都會算繪 HTML——這是預設行為,而非額外的最佳化措施。
與 React 相比,學習曲線如何?
對 Python 開發人員而言,學習曲線明顯平緩許多,因為原本就已熟悉這門語言。FastAPI 的路由處理常式會傳回範本回應——其思維模式與 Flask 或 Django 檢視相同。HTMX 僅增加少數 HTML 屬性(hx-get、hx-target、hx-swap),Alpine.js 則再加入幾項(x-data、x-show、@click)。無須學習 JSX、虛擬 DOM、Hook 系統、狀態管理函式庫或建置工具設定。
HTMX 的文件只需一個長頁面即可容納。Alpine.js 的文件也只有寥寥數頁。React 的文件則多達數百頁,涵蓋 Hook、Context、Ref、Effect、Suspense、伺服器元件與串流式 SSR。
對 JavaScript/React 開發人員而言,這項轉變主要在觀念,而非語法。核心概念是:狀態由伺服器掌控,HTML 也由伺服器算繪。用戶端狀態管理會轉化為伺服器端路由處理;用戶端資料擷取則轉化為 HTML 元素上的 HTMX 屬性。語法更為簡潔,但思維模式必須跳脫「用戶端負責算繪」的 SPA 既定假設。
變更記錄
| 日期 | 變更 | 來源 |
|---|---|---|
| 2026-07-29 | FastAPI 0.141.0 + 0.141.1(兩者皆於7月29日發布)。0.141.0 新增app.frontend(check_dir="auto"),因此當建置目錄不存在時,fastapi dev不再執行失敗——這正是前端建置尚未執行便先啟動伺服器的常見情況。數小時後發布的0.141.1,修正app.frontend()中的相依項目會遺失背景工作與回應標頭的問題;若相依項目設定Cookie或排定BackgroundTask,該工作先前會在前端掛載點遭到捨棄,儘管在API路由上能正確運作。此修正補上了0.139.0所新增相依項目支援的一項實質缺口。兩者均納入現有的app.frontend()說明,而非另闢新節,因為本指南採用伺服器端轉譯方式,不會掛載dist/目錄。0.141.1也在FastAPI CLI指南中記錄FASTAPI_ENV(僅更新文件,正文未變更)。 |
28 |
| 2026-07-27 | FastAPI於7月27日的5個半小時內連續發布0.140.1至0.140.7——共7個版本,全都重構0.140.0著手調整的相依項目機制。變更分為兩條主線:FastAPI過去建立並保留的扁平化相依關係圖副本現已移除(0.140.2),其他所有會重新建立該副本的地方也一併清除,包括OpenAPI產生(0.140.3、0.140.7)、主體欄位(0.140.5)與請求參數(0.140.6);0.140.4則刪除未曾讀取的重複追蹤簿記。唯一具有明顯門檻影響的變更是0.140.1:fastapi/dependencies/models.py中可呼叫物件分類輔助函式的lru_cache,從1,024個項目提高至4,096個項目(命名為_CALLABLE_CLASSIFICATION_CACHE_SIZE)。此前有回報指出,應用程式的不同相依項目超過1,024個時,快取會頻繁失效。API沒有變更;相依項目記憶體段落中的建議版本從0.140.0調整為0.140.7或更新版本,並註明此系列仍在持續變動,且OpenAPI相依項目效能基準(PR #16075)直到該系列最後一版才納入。 |
29 |
| 2026-07-25 | FastAPI 0.140.0(7月24日21:16 UTC)修正自0.121.0(2025年11月3日)起便存在的相依項目系統記憶體退化問題。PR #16049從Dependant移除10個functools.cached_property屬性,將其移至模組層級的輔助函式,並把該類別改為@dataclass(slots=True);合併後PR的官方CodSpeed執行結果顯示,test_dependency_graph記憶體效能基準從17.5 MB降至1.1 MB(×16)。最初的問題回報則指出,0.121.3在正式環境中發生OOM,而0.120.4可維持在約400 MB以下。已在非同步模式章節加入0.140.0相關說明,並於Uvicorn正式環境設定中新增一行工作程序記憶體說明。此外也修正一項既有錯誤:本指南原先聲稱0.137.0「將Starlette鎖定在1.x版本線」——事實並非如此。FastAPI的執行階段需求是starlette>=0.46.0(僅設下限,未設上限,而Starlette 0.4x仍符合需求),且0.136.3、0.137.0、0.138.0、0.139.2及0.140.0均完全一致;0.137.0說明中的1.x版號,是dependabot對儲存庫測試鎖定檔所做的升級(PR #15722僅變更uv.lock)。正文敘述與24均已修正。另有兩項特別標示、但未修改正文的變化:對工具而言,Dependant內部機制現在具有破壞性變更(oauth_scopes、cache_key、_uses_scopes、_is_security_scheme已不再是屬性,改由模組函式_get_oauth_scopes()/_get_cache_key()/_uses_scopes()取代,而slots=True會阻止在執行個體上進行猴子補丁)——這是本指南從未引用的未記錄內部API,與0.137.0的router.routes變更屬於同一類;此外,FastAPI官方文件目前在30個檔案中改以uv專案作為預設,包括README、index.md、virtual-environments.md以及Docker/部署頁面(PR #16032,於7月21日合併)。文件變更不會影響您的程式碼,但本指南全篇仍教授pip install -r requirements.txt,如今已與上游的入門方式分道揚鑣——這是日後需處理的編輯決策,本次特意不作變更。 |
27 |
| 2026-07-24 | htmx 4.0.0-beta6取代beta5成為npm的next標籤(發布於2026年7月23日;GitHub同日釋出)。值得關注的beta項目包括:新增hx-multipart擴充功能(串流傳送multipart/mixed/multipart/parallel回應,各部分可帶有HX-*動作標頭)、透過Navigation API還原歷程捲動位置並為Firefox提供備援、beta內部事件重新命名htmx:swap:finally → htmx:finally:swap、HX-Trigger回應標頭事件改於交換後觸發、自訂請求方法,以及重寫hx-ws並轉送protocols。建議不變——正式環境應維持使用HTMX 2.x(latest = 2.0.10),直到4.0 GA為止;重新命名只會在4.0 beta版本線內造成破壞性變更。已更新beta追蹤備註與21。經確認,FastAPI 0.139.2、Uvicorn 0.51.0、Alpine.js 3.15.12、Starlette 1.3.1、Jinja2 3.1.6均維持不變;此期間沒有任何安全性公告。 |
|
| 2026-07-17 | FastAPI 0.139.1 + 0.139.2(7月16日):修正app.frontend()備援處理中的含點路徑問題(/users/john.doe,PR #16011),並讓路由器的路由建置具備執行緒安全性,以支援平行執行緒測試(PR #16013)——沒有面向應用程式的API變更。Uvicorn 0.49.0 → 0.51.0:舊版websockets實作已棄用,auto目前預設使用websockets-sansio(0.50.0);預設實作需要websockets>=13.0(0.50.2);0.51.0(7月8日)則新增可重疊執行的SIGHUP工作程序重新啟動功能,實現近乎零停機的重新載入。儲存庫目前位於Kludex/uvicorn。經確認,HTMX(2.0.10/4.0.0-beta5 next)、Alpine.js 3.15.12、Starlette 1.3.1、Pydantic 2.13.4、SQLAlchemy 2.0.51、Bootstrap 5.3.8均維持不變;此期間沒有任何安全性公告。 |
|
| 2026-07-07 | htmx 4.0.0-beta5目前已成為npm的next標籤(發布於2026年6月26日),並取代beta4;HTMX 4.0 beta追蹤備註與[^22]已同步更新。建議不變——正式環境應維持使用HTMX 2.x(latest = 2.0.10),直到4.0 GA為止。已依據htmx.org的npm dist-tags完成驗證。 |
|
| 2026-07-02 | FastAPI 0.139.0(7月1日)。app.frontend()目前支援相依項目——例如,可針對所提供的前端自動進行Cookie驗證(PR #15908)——讓0.138.0新增的靜態前端掛載功能也能使用標準Depends()機制;這仍與本指南的伺服器端轉譯主張各行其道,因此記錄於同一段對照說明中。技術堆疊其餘部分沒有變動:HTMX 2.0.10、Alpine.js 3.15.12、Bootstrap 5.3.8、SQLAlchemy 2.0.51均維持不變。 |
26 |
| 2026-06-22 | FastAPI 0.138.0 + 0.137.2。0.138.0(6月20日)新增app.frontend("/", directory="dist")/router.frontend(...),用於提供建置完成的靜態前端(SPA的dist/輸出)——這與本指南不需建置的伺服器端轉譯主張無直接關聯,因此在非同步模式章節中作為對照說明。0.137.2(6月18日)新增iter_route_contexts();自0.137.0起,router.routes已成為內部機制,因此前者是目前列舉路由的受支援方式。兩者均為功能新增,沒有破壞性變更;Starlette(1.3.1)、Pydantic(2.13.4)、HTMX(2.0.10)、Alpine.js(3.15.12)、Bootstrap(5.3.8)、SQLAlchemy(2.0.51)全數維持不變。 |
25 |
| 2026-06-16 | FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1。FastAPI 0.137.0(6月14日)重構路由器內部機制:router.routes目前是內部樹狀結構,不再是扁平的APIRoute清單(任何逐一巡覽該清單的程式都會受到破壞性影響);同時,此變更支援在include_router()之後新增路由,並加入新的APIRouter.matches()/.handle()掛鉤。0.137.1(6月15日)則修正APIRoute型別與空路徑、無前置詞的路由器。Starlette於3月22日發布首個穩定版1.0,目前已更新至1.3.1(6月12日),並移除已棄用的on_event/on_startup/on_shutdown掛鉤,以及@app.route()/@app.websocket_route()裝飾器——目前僅能使用lifespan與Route/WebSocketRoute。(此項目原先聲稱FastAPI 0.137.0會鎖定Starlette 1.3.1——已於2026-07-25更正:實際上並不會;執行階段需求為starlette>=0.46.0,且沒有上限。)已在非同步模式章節新增lifespan/路由器備註。SQLAlchemy 2.0.51(6月15日)僅包含錯誤修正。 |
24 |
| 2026-06-08 | SQLAlchemy 2.0.50的async安裝變更。自SQLAlchemy 2.0.50起,async技術堆疊的greenlet相依套件不再預設安裝——請安裝sqlalchemy[asyncio]額外套件,否則第一次對引擎執行await時,將因缺少greenlet而失敗。2.0.50也開始要求Python 3.10以上版本(不再支援3.7–3.9),並新增自由執行緒3.13t wheel。已在SQLAlchemy 2.0非同步章節加入安裝備註。技術堆疊其餘部分的正文沒有變更:FastAPI最新版本仍為0.136.3(2026-05-23,6月沒有發布新版);htmx穩定版仍為2.0.10(4.0.0-beta4「The Fetchening」仍處於beta階段,穩定版目標約為2027年初,尚不建議用於正式環境);Alpine.js 3.15.12與Bootstrap 5.3.x均維持不變。正式環境建議不變:在4.0穩定版發布前使用HTMX 2.x。23 |
|
| 2026-05-24 | 維護檢查:本機內容清單仍顯示210篇部落格文章、11篇核心指南、48項設計研究,以及包括英文在內的10個支援語系。FastAPI最新版本為0.136.3(2026-05-23);版本說明中唯一特別提及、且會影響應用程式的重構,是當convert_underscores=True時採用更嚴格的底線標頭處理方式;0.136.2則會驗證Server-Sent Event欄位,避免事件資料損壞。htmx穩定版仍為2.0.10,而npm next與4.0文件目前皆指向4.0.0-beta4;SQLAlchemy 2.0最新版本為2.0.50;Pydantic最新版本仍為2.13.4。正式環境建議維持不變:在4.0成為穩定版前使用HTMX 2.x。122 |
|
| 2026-05-18 | 網站清單更新:本機內容清單目前顯示210篇部落格文章、11篇核心指南、48項設計研究,以及包括英文在內的10個支援語系。FastAPI最新版本仍為0.136.1;htmx穩定版仍為2.0.10,npm next則為4.0.0-beta3;Alpine.js的npm最新版本仍為3.15.12。正式環境建議維持不變:在4.0成為穩定版前使用HTMX 2.x。12021 |
|
| 2026-05-15 | 維護檢查:FastAPI最新版本仍為0.136.1;此網站的本機環境匯入FastAPI 0.128.0與Starlette 0.50.0;htmx穩定版仍為2.0.10,npm next目前為4.0.0-beta3;Alpine.js的npm最新版本為3.15.12;Bootstrap最新版本為5.3.8;SQLAlchemy 2.0最新版本為2.0.49;Pydantic最新版本為2.13.4。正式環境建議不變:在4.0成為穩定版前使用HTMX 2.x。2021 |
|
| 2026-05-09 | htmx 4.0.0-beta3追蹤(2026年5月8日):htmx 4.0.0-beta3已透過npm next標籤與4.0文件提供,而npm latest仍為2.0.10。GA前值得追蹤的重點包括:新增hx-live擴充功能(DOM回應式運算式)、新增hx-nonce擴充功能(透過CSP nonce保護htmx屬性),以及遷移指南針對設定、歷程、事件與核心JavaScript輔助函式所做的變更。正式環境建議不變:在4.0 GA前,htmx 2.x仍是npm的latest標籤版本,也是建議使用的版本。21 |
|
| 2026-05-07 | 維護檢查:FastAPI最新版本仍為0.136.1;htmx穩定版為2.0.10,v4仍處於beta階段,目標於2026年夏季發布;Alpine.js的npm最新版本為3.15.12;Bootstrap最新版本為5.3.8;SQLAlchemy 2.0最新版本為2.0.49;Pydantic最新版本為2.13.4。網站本機指標已更新為182篇部落格文章、11篇指南、10個支援語系,以及17項Python需求。遷移指引不變:在4.0成為穩定版前,正式環境請使用HTMX 2.x。20 | |
| 2026-04-25 | FastAPI 0.136.1(2026年4月23日):清理Pydantic v2棄用項目(應用程式程式碼的行為沒有變更)。追蹤HTMX 4.0時程:htmx 4.0.0-beta1(4月6日)與4.0.0-beta2(4月14日)均已發布。遷移指引不變——在4.0成為穩定版前,htmx 2.x仍維持於npm的latest標籤;安全性修正持續提供,無須急於升級。現在值得預先納入設計考量的4.0主要變更包括:(1)以fetch()取代XMLHttpRequest作為核心ajax基礎架構;(2)屬性繼承預設改為明確指定;(3)歷程支援會針對還原的內容發出網路請求,不再使用本機DOM快照。FastAPI 0.135.4(4月16日)移除了0.135.3所加入的愚人節@app.vibe()裝飾器。 |
|
| 2026-04-16 | 新增對HTMX 4.0-beta的前瞻性說明。註明FastAPI 0.136.0支援Python 3.14t自由執行緒建置。Pydantic 2.13.x功能(私有屬性的預設工廠可存取已驗證的模型資料、pydantic.v1命名空間更新至1.10.26並支援3.14)。Alpine.js 3.15.11修正:x-anchor.noflip修飾詞、x-for多根元素警告、$refsmorph退化問題修正。 |
|
| 2026-03-24 | 首次發布 |
參考資料
本指南涵蓋建置blakecrosley.com所使用的完整系統。無建置宣言闡述其背後理念。Lighthouse 滿分一文記錄效能最佳化歷程。氛圍式程式設計與工程實務一文則探討 AI 輔助開發在此工作流程中的定位。
-
blakecrosley.com 截至2026年5月18日的正式環境指標。網站包含210篇部落格文章、互動式JavaScript元件、11份核心指南、48項設計研究、英文與9種翻譯語系,僅有少量Python相依套件,且完全未使用建置工具。已透過本機內容清單、
app/i18n/config.py及requirements.txt驗證。 ↩↩↩↩↩ -
Google PageSpeed Insights(pagespeed.web.dev)可針對任何公開 URL 執行 Lighthouse 稽核。截至2026年3月,blakecrosley.com 的分數為100/100/100/100(效能、無障礙、最佳做法、SEO)。結果可公開驗證。完整的最佳化歷程請參閱從76到100:達成 Lighthouse 滿分。 ↩↩↩
-
全新執行
npx create-next-app@latest(Next.js 15,於2026年2月測試)會在node_modules/中安裝311個套件,總計187 MB。加入其他相依套件的正式專案通常會更大;各專案實際情況不盡相同。來源:作者測試,記錄於無建置宣言。 ↩ -
Vercel 的 Next.js 效能文件建議採用特定最佳化措施(圖片最佳化、字型載入、程式碼分割),以取得90分以上的成績。請參閱nextjs.org/docs/app/building-your-application/optimizing。70至90分的範圍反映套用這些最佳化措施前的預設設定。 ↩↩
-
完整相依套件清單已透過 blakecrosley.com 截至2026年5月的
requirements.txt驗證。該檔案目前包含17個Python需求項目,沒有任何建置工具、編譯器或打包工具。 ↩ -
根據作者於2021至2024年間維護 Next.js 專案的經驗,對於持續開發的專案,JavaScript生態系每月會產生15至25個 Dependabot PR,其中多數是更新開發者從未直接匯入的遞移相依套件。 ↩
-
Tim Berners-Lee 曾將向下相容闡述為一項網頁設計原則:「瀏覽器應該向下相容。」1996年的網頁至今仍可在 Chrome 2026 中呈現。請參閱w3.org/DesignIssues/Principles。 ↩
-
OWASP 建議在正式環境停用API文件端點,以縮小攻擊面。
/openapi.json端點會公開所有路由定義、參數及回應模型。 ↩ -
FastAPI關於 async 與 sync 處理常式的文件:fastapi.tiangolo.com/async/。在
async函式中混用await與阻塞呼叫,會使事件迴圈無法正常處理其他工作。 ↩ -
nh3 是以 Rust 為基礎的HTML清理工具,也是 Bleach 函式庫的後繼者。此工具由 PyO3 專案維護,能以允許清單為基礎清理HTML。請參閱github.com/messense/nh3。 ↩
-
Vary標頭定義於 RFC 9110 第12.5.5節。它會指示快取依指定的請求標頭值分別儲存回應。若沒有Vary: HX-Request,CDN 可能會將HTMX片段作為完整頁面回應提供。請參閱httpwg.org/specs/rfc9110.html#field.vary。 ↩↩ -
全球超過97%的瀏覽器支援CSS自訂屬性(CSS變數)。它們具備層疊與繼承能力,並能在執行階段回應媒體查詢;這些都是前置處理器變數所不具備的功能。來源:caniuse.com/css-variables。 ↩
-
Google 的 hreflang 文件:developers.google.com/search/docs/specialty/international/localized-versions。
x-default值會指定備用頁面,供語言不在 hreflang 清單中的使用者瀏覽。 ↩ -
Alpine.js的運算式評估引擎要求在 Content Security Policy 中加入
'unsafe-eval'。相容 CSP 的版本(@alpinejs/csp)不需要此設定,但有若干限制。請參閱alpinejs.dev/advanced/csp。 ↩ -
以 HMAC 為基礎的 CSRF 權杖遵循 OWASP CSRF Prevention Cheat Sheet 所述的「Signed Double-Submit Cookie」模式。
hmac.compare_digest採用固定時間比較,防止計時旁路攻擊。請參閱cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html。 ↩ -
在同等視覺品質下,WebP 檔案比 JPEG 小25%至35%。Google 的 WebP 研究:developers.google.com/speed/webp/docs/webp_study。 ↩
-
103 Early Hints 可讓伺服器(或 CDN)在最終回應準備完成前,先傳送包含預先載入提示的初步回應。Cloudflare支援在
Link標頭中使用rel=preload的 Early Hints。請參閱developer.chrome.com/blog/early-hints。 ↩ -
React 18 與 ReactDOM 經壓縮並以 gzip 壓縮後約為42 KB。若再加上路由器、狀態管理函式庫及建置框架執行階段,一般 React 應用程式會傳送100至300 KB的框架JavaScript。來源:bundlephobia.com/package/react-dom@18.2.0。 ↩↩
-
HTMX的版本政策與向下相容承諾記錄於htmx.org/migration-guide-htmx-1/。Carson Gross 亦在 Gross、Stepinski 與 Cotter 合著的Hypermedia Systems(2023)中闡述向下相容原則:hypermedia.systems。 ↩
-
2026年5月15日維護檢查。FastAPIPyPI與版本資訊列出的版本為0.136.1;本機匯入驗證顯示,此網站環境使用FastAPI 0.128.0及 Starlette 0.50.0;htmx.org的快速入門列出2.0.10;
npm view htmx.org version dist-tags回傳latest=2.0.10及next=4.0.0-beta3;npm view alpinejs version與npm view @alpinejs/csp version均回傳3.15.12;Bootstrap 的官方部落格與 npm 套件中繼資料列出5.3.8;SQLAlchemy PyPI及文件列出2.0.49;Pydantic PyPI列出2.13.4。 ↩↩↩↩ -
htmx 4.0.0-beta6是目前 npm 的
next標籤(發布於2026年7月23日;beta 系列從2026年5月8日的 beta3,依序發展至 beta4、beta5與 beta6),而 npm 的latest仍為2.0.10。four.htmx.org上的4.0文件會追蹤next版本;4.0擴充功能索引列出hx-live與hx-nonce;4.0遷移指南則記錄正式環境應用程式從2.x遷移前需要檢視的變更。已於2026年7月24日依據htmx.org的 npm dist-tags 驗證。 ↩↩↩↩↩↩ -
2026年5月24日維護檢查。本機清單命令回傳210篇 Markdown 部落格文章、11個頂層指南檔案及48個設計研究檔案。FastAPI版本資訊列出2026年5月23日發布的0.136.3;當
convert_underscores=True時,底線標頭的處理更為嚴格;0.136.2則會驗證 Server-Sent Event 欄位。python3 -m pip index versions fastapi回傳最新版本0.136.3;python3 -m pip index versions sqlalchemy回傳最新版本2.0.50;python3 -m pip index versions pydantic回傳最新版本2.13.4。npm view htmx.org dist-tags version time.modified --json回傳latest=2.0.10、next=4.0.0-beta4及time.modified=2026-05-22T15:56:21.948Z;four.htmx.org安裝文件顯示htmx.org@4.0.0-beta4。 ↩↩ -
SQLAlchemy 2.0.50變更記錄與發布部落格,發布於2026年5月24日。asyncio 的
greenlet相依套件不再預設安裝;現在必須使用sqlalchemy[asyncio]安裝目標才會一併安裝。2.0.50也不再支援Python 3.7、3.8及3.9(目前需3.10以上版本),新增自由執行緒Python wheel,並加入over(..., exclude=...)視窗框架參數。截至2026年6月8日,已在PyPI驗證其為最新版本。htmx 4.0.0-beta4(「The Fetchening」,2026年5月22日)仍處於 beta 階段,穩定版預計於2027年初推出;FastAPI 0.136.3(2026年5月23日)、Alpine.js 3.15.12及 Bootstrap 5.3.x在此期間均未變更。 ↩↩↩ -
FastAPI版本資訊:0.137.0(2026年6月14日)重構路由器內部結構,使
router.routes不再是由APIRoute物件組成的扁平清單,而是包含中介物件的樹狀結構(應視為內部實作);此外也支援在include_router()後新增路由、在子路由器的路由尚未定義前先納入該路由器、避免複製路由,並新增APIRouter.matches()與.handle()。它並未將 Starlette 鎖定於1.x:FastAPI的執行階段需求是starlette>=0.46.0,僅設下限而無上限;0.136.3、0.137.0、0.138.0、0.139.2及0.140.0皆完全相同。此資訊已於2026年7月25日透過PyPI JSON API中的requires_dist中繼資料驗證。0.137.0版本資訊中的「bump starlette from 1.1.0 to 1.2.1」(PR #15722)是Internal項目下的 dependabot 更新,僅影響儲存庫的uv.lock測試鎖定檔。(先前版本確實設有上限——0.120.4與0.121.0隨附starlette<0.50.0,>=0.40.0——但至0.136.3已移除。)已於2026年7月25日套用更正;本註腳先前的文字與內文主張有誤。0.137.1(2026年6月15日)修正APIRoute型別及無前綴路由器中的空白路徑。Starlette 版本資訊:1.0.0(2026年3月22日)是近8年來首個穩定版本,移除了on_startup、on_shutdown、on_event()及@app.route()、@app.websocket_route()裝飾器(請改用lifespan與Route/WebSocketRoute);最新版本為1.3.1(2026年6月12日)。SQLAlchemy 2.0.51(變更記錄,2026年6月15日)僅修正錯誤,對 async 或安裝方式均無影響。已於2026年6月16日透過 PyPI 與官方版本資訊驗證。 ↩↩↩ -
FastAPI版本資訊:0.138.0(2026年6月20日)新增
app.frontend("/", directory="dist")與router.frontend("/", directory="dist"),可提供已建置的靜態前端(PR #15800;前端文件)——這是用於提供靜態dist/SPA的功能,並非伺服器端呈現模式;無破壞性變更。0.137.2(2026年6月18日)新增iter_route_contexts(),供過去需要走訪router.routes的進階用途使用(自0.137.0起屬於內部實作);無破壞性變更。截至2026年6月22日,沒有比0.138.0更新的版本。Starlette 1.3.1、Pydantic 2.13.4、Uvicorn 0.49.0、SQLAlchemy 2.0.51、HTMX 2.0.10、Alpine.js 3.15.12及 Bootstrap 5.3.8均未變更。已於2026年6月22日透過 PyPI 與官方版本資訊驗證。 ↩↩ -
FastAPI0.139.0版本資訊,2026年7月1日:「支援在
app.frontend()中使用相依項目,例如讓前端自動進行 Cookie 驗證」(PR #15908)。此版本其餘內容為翻譯、文件及相依套件升級;無破壞性變更。2026年7月2日(PST)於目前工作階段驗證:0.139.0是GitHub版本頁面上的最新版本。 ↩↩ -
FastAPI0.140.0版本資訊,發布於2026年7月24日21:16 UTC(PyPI
upload_time_iso_8601為2026-07-24T21:16:42Z)。唯一的重構項目是「⚡️ 降低相依項目的記憶體用量。PR #16049」(合併於2026年7月24日21:07:52 UTC)。此效能退化由 PR #14262 引入(合併於2025年11月3日),並於同日發布的0.121.0中推出;該 PR 將functools.cached_property加入Dependant.cache_key。截至0.139.2,該類別已有10個@cached_property定義。在0.140.0中,fastapi/dependencies/models.py宣告了@dataclass(slots=True) class Dependant,並將邏輯移至模組層級的_get_cache_key()、_get_oauth_scopes()、_uses_scopes()及_is_security_scheme()——原始碼已透過0.140.0標籤驗證。已合併 PR 上的 CodSpeed bot 報告指出,test_dependency_graph記憶體基準測試從17.5 MB(基準版本)降至1.1 MB(變更版本),「效能提升16倍」;0.140.0也新增 CI 記憶體基準測試(PR #16046),以防問題再次發生。最初的問題報告為討論串 #14742,其中0.120.4維持在約400 MB以下,而0.121.3則在正式環境中因記憶體不足而終止。工具作者請注意:Dependant.oauth_scopes、.cache_key、._uses_scopes及._is_security_scheme已不再以屬性形式存在,且slots=True會阻止對執行個體進行 monkey patch——這是本指南未使用、未記錄於文件的內部API,與0.137.0的router.routes變更屬於同一類型。所有事實已於2026年7月25日依據 PyPI、GitHub API及標記版本的原始碼重新驗證。 ↩↩↩ -
FastAPI 0.141.0(2026年7月29日14:47 UTC)新增
app.frontend(check_dir="auto"),供使用fastapi dev進行本機開發(PR #16102)。FastAPI 0.141.1(2026年7月29日17:17 UTC)修正app.frontend()對背景工作及相依項目所回傳標頭的支援(PR #16105),並在FastAPI CLI指南中記錄FASTAPI_ENV(PR #16104)。兩者皆由@tiangolo完成。已於2026年7月29日確認 PyPI 最新版本為0.141.1。 ↩↩ -
FastAPI從0.140.1至0.140.7的版本,皆於2026年7月27日12:07至17:34 UTC之間發布(PyPI
upload_time_iso_8601:0.140.1為12:07:51Z、0.140.2為14:15:38Z、0.140.3為15:30:52Z、0.140.4為15:46:49Z、0.140.5為16:02:53Z、0.140.6為16:31:48Z、0.140.7為17:34:47Z)。每個版本的內容都只有一項重構:0.140.1「更新相依項目的 lru_cache 上限,以因應大型應用程式」(PR #16062);0.140.2「停止保留扁平化的相依性樹狀結構」(PR #16065);0.140.3「避免在 OpenAPI中重複展平相依項目」(PR #16067);0.140.4「略過未使用的相依項目重複記錄」(PR #16069);0.140.5「避免為本文欄位展平相依項目」(PR #16071);0.140.6「避免為請求參數展平相依項目,主要針對 OpenAPI」(PR #16073);0.140.7「避免為 OpenAPI展平相依項目」(PR #16076)。快取數值取自 #16062 的差異內容:該變更將fastapi/dependencies/models.py中的3個@lru_cache(maxsize=1024)裝飾器替換為@lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE),並更新tests/test_dependency_models.py,驗證cache_info.maxsize == 4096;PR 內容說明:「部分使用者回報相依項目數量超過1024,此調整應能因應更大型的應用程式。」0.140.2也新增記憶體基準測試(PR #16064),0.140.7則新增 OpenAPI相依項目基準測試(PR #16075),因此基準測試涵蓋範圍是在此系列多數版本發布後才補上。已於2026年7月27日依據GitHub版本API、PR差異內容及 PyPI 驗證;撰寫本文時,0.140.7是最新版本。 ↩↩