FastAPI + HTMX:無需建置的全端開發
# FastAPI + HTMX:無需建置的全端開發
重點摘要:FastAPI + HTMX + Alpine.js + Jinja2 + 純 CSS 能打造出無需任何建置工具、零
node_modules/、且 Lighthouse 滿分的正式環境網頁應用程式。本指南涵蓋從架構到部署的完整系統,以 blakecrosley.com 作為實際運行中的範例——該網站提供超過 100 篇部落格文章、互動式 JavaScript 元件、多份完整指南,以及九種語言的翻譯,全程未使用任何打包工具、編譯器或轉譯器。1
現代網頁開發生態預設您需要 React、webpack、TypeScript 和一整套建置流程。然而對於一大類應用——內容導向網站、內部工具、CRUD 應用、作品集網站、文件平台——這個假設並不成立。本指南所介紹的技術架構完全移除了前端建置工具鏈,同時打造出 Lighthouse 100/100/100/100 滿分的網站。2
這不是倡導,而是實測結果。此架構已在正式環境中運行,服務橫跨十種語言的真實使用者,所有數據皆可驗證。
重點摘要
- 伺服器端渲染的HTML消除了三大類問題:客戶端狀態管理、JSON序列化邊界,以及hydration不匹配。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章節涵蓋了該技術棧的標準模式,但並未用於此特定網站。每項主張都附有檔案路徑、設定區塊或Lighthouse稽核報告,您可在PageSpeed Insights自行驗證。2
如何使用本指南
這是一份全面的參考文件。請依您的經驗程度選擇起點:
| 經驗程度 | 從這裡開始 | 接著探索 |
|---|---|---|
| Python開發者,初次接觸HTMX | 無建置論點 → 架構概覽 → HTMX深入解析 | Alpine.js模式、安全性 |
| React/Vue開發者,評估替代方案 | 無建置論點 → 決策框架 | 架構概覽、效能 |
| FastAPI開發者,需要加入互動功能 | HTMX深入解析 → Alpine.js模式 | i18n與在地化、部署 |
| 全端開發者,從零開始建構 | 從架構概覽依序閱讀 | 快速參考卡供日常查閱 |
可使用Ctrl+F / Cmd+F搜尋特定模式或屬性。末尾的快速參考卡提供了一覽式摘要。
無建置論點
此論點的範圍既窄且明確:對於內容導向的網站,由個人開發者或小型團隊維護時,建置工具解決的是您根本不存在的問題,同時製造了原本沒有的問題。
以下是blakecrosley.com的實際指標:
| 指標 | blakecrosley.com(無建置) | 典型Next.js專案3 |
|---|---|---|
| 依賴套件數 | 15個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 |
這15個Python套件包括FastAPI、Jinja2、Pydantic、uvicorn、nh3及其他10個。沒有任何一個是建置工具、編譯器或打包工具。5
您放棄了什麼
誠實面對,以下是實際的代價:
沒有TypeScript。每個.js檔案都是原生JavaScript。型別錯誤透過測試和程式碼分析來捕捉,而非仰賴編譯器。這對個人開發者而言可行,但對10人團隊共享元件介面的情境則不適用。
沒有模組熱替換(HMR)。CSS的修改需要手動重新整理瀏覽器。HTMX的hx-boost讓導覽速度夠快,使完整重新整理尚可接受,但在密集的視覺微調循環中,HMR確實能節省時間。
沒有Tree Shaking。您撰寫的每一個位元組的JavaScript都會傳送到瀏覽器。這個限制迫使您保持紀律:使用小而專注的檔案,而非龐大的工具模組。
沒有npm元件庫。沒有Radix、沒有shadcn/ui、沒有Headless UI。每個互動元素都是手工打造或使用Bootstrap 5的內建元件。
沒有來自npm的設計系統Token。設計系統存在於CSS自訂屬性中,無法作為套件匯入至其他專案。
這些取捨對於一到三位開發者維護的內容導向網站是可接受的,但對於擁有15人工程團隊的SaaS產品則不適用。第15節提供了決策框架。
您獲得了什麼
零建置失敗。不會因為對等依賴衝突導致npm install失敗,也不會因為您未修改的檔案中的TypeScript錯誤導致next build失敗。6
可直接檢視原始碼除錯。瀏覽器中執行的JavaScript就是您撰寫的JavaScript,無需source map。
即時本地啟動。uvicorn app.main:app --reload在2秒內即可啟動。
清晰的請求瀑布流。首次造訪載入:一份HTML文件(gzip壓縮後約15KB)、一份CSS檔案(約8KB)、HTMX(約14KB,已快取)、Alpine.js(約14KB,已快取),以及頁面的互動JavaScript(約4-8KB)。首次造訪總計:45-60KB。1
面向未來的前端。客戶端程式碼使用HTML、CSS和JavaScript——這些標準已維持了30年的向下相容性。7不必經歷Webpack 4 → 5遷移、Create React App棄用,或Next.js App Router遷移。
技術棧比較
無建置技術棧與常見替代方案在可量測維度上的比較:
| 面向 | FastAPI+HTMX(本指南) | Next.js(React) | Astro | 11ty |
|---|---|---|---|---|
| 傳送至瀏覽器的JS | 32-46KB(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 | 有限(表單動作) | 無(靜態輸出) |
| 客戶端狀態管理 | Alpine.js(15KB) | React state/context/Redux | 框架islands | 手動撰寫JS |
| 後端語言 | Python | JavaScript/TypeScript | JavaScript/TypeScript | JavaScript |
| i18n方式 | 伺服器端(中介軟體) | 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)
此處有三項關鍵的設計決策。首先,docs_url=None 和 openapi_url=None 停用了自動產生的 API 文件端點。一個面向公眾的內容網站不需要將 /docs 或 /openapi.json 暴露在網際網路上。8 其次,中介軟體的順序很重要——安全日誌記錄最先執行(最後加入),因此能捕捉每一筆請求,包括被速率限制拒絕的請求。第三,GZipMiddleware 壓縮所有超過 500 位元組的回應,通常可減少 70-80% 的 HTML 傳輸量。
路由
路由分為兩類:頁面路由回傳完整的 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,而後者又依賴 request。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 類別在啟動時即驗證型別——缺少必填欄位會立即報錯,而非等到執行階段才失敗。
非同步模式
FastAPI 路由預設為非同步。針對 I/O 密集型操作(資料庫查詢、HTTP 請求、檔案讀取),非同步處理可避免阻塞事件迴圈:
@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)
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(...)
原則很簡單:若函式需要等待 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 動態產生並快取一小時。
資料庫模式
注意: blakecrosley.com 使用 Cloudflare D1(無伺服器 SQLite)透過 HTTP 處理所有持久化資料,而非 SQLAlchemy。本節介紹標準的 SQLAlchemy 非同步模式,適用於需要關聯式資料庫的 FastAPI 專案——這是此技術堆疊中最常見的正式環境配置。
SQLAlchemy 2.0 非同步
若應用程式需要關聯式資料庫,SQLAlchemy 2.0 的非同步支援能與 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
資料庫工作階段的依賴注入
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 依賴負責管理工作階段的生命週期:開啟工作階段、將其交給路由處理函式、成功時提交、發生例外時回滾。所有資料庫操作均使用參數化查詢,絕不使用字串內插。
Pydantic 整合
Pydantic 模型在 API 邊界驗證輸入,並為模板序列化輸出:
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 會在路由處理函式執行前驗證型別、格式(電子郵件、URL)及限制條件(最小/最大長度)。無效輸入會自動回傳 422 回應。這取代了用戶端表單驗證函式庫——由伺服器進行驗證,HTMX 則負責置換成功訊息或錯誤回饋。
使用 Alembic 管理遷移
Alembic 負責管理資料庫結構變更:
# 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
自動產生功能會比對 SQLAlchemy 模型與當前資料庫結構,產生遷移腳本。這些腳本是版本化的 Python 檔案,存放於版本庫中:
# 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")
遷移在部署時執行(應用程式啟動前)。這確保資料庫結構與應用程式程式碼一致。對於 blakecrosley.com,大部分資料存放在 Cloudflare D1(透過 HTTP 存取),因此 Alembic 遷移適用於用來儲存工作階段資料與分析資料的本機 SQLite 或 PostgreSQL 資料庫。
Cloudflare D1 模式
blakecrosley.com 使用 Cloudflare D1 作為遠端資料庫,透過 Cloudflare Worker 代理存取:
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"]
此模式適用於需要資料庫但不想管理資料庫伺服器的應用程式。D1 是位於 Cloudflare 邊緣的 SQLite,透過 HTTP 存取。Worker 代理負責驗證與速率限制。代價是延遲:每次查詢都是 HTTP 請求(約 50-100 毫秒),而本機資料庫連線僅需約 1-5 毫秒。啟動時的記憶體快取能為翻譯等讀取密集的工作負載緩解此問題。
安全性
安全標頭中介軟體
blakecrosley.com 透過自訂中介軟體實作強化的安全標頭:
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。
CDN 快取安全與 HTMX
安全標頭中介軟體為 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 防護
HTMX 表單使用無狀態 HMAC 簽章的 CSRF 權杖:
# 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)
權杖透過 Jinja2 全域函式在模板中產生,並包含在 HTMX 表單請求中:
<form hx-post="/contact" hx-target="#form-result">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- form fields -->
</form>
無狀態權杖免除了伺服器端工作階段儲存的需求。HMAC 簽章確保權杖由伺服器產生。時間戳記防止重放攻擊。hmac.compare_digest 防止計時攻擊。15
HTML 清理
使用者產生的內容在渲染前會經過 nh3 清理:
templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}
nh3 函式庫會移除不在允許清單中的標籤與屬性。連結自動加上 rel="noopener noreferrer"。此防禦機制獨立於 CSP——在渲染層防止儲存型 XSS,而 CSP 在瀏覽器層防止注入的腳本。縱深防禦,層層把關。
輸入驗證
Pydantic 模型在 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 注入並確保邊界處的型別安全。
效能
Lighthouse 100/100/100/100
blakecrosley.com 在 Lighthouse 四大類別——效能、無障礙、最佳實踐與 SEO——皆取得滿分 100。可至 PageSpeed Insights 驗證。2
關鍵的最佳化策略如下:
CSS 載入策略
blakecrosley.com 透過單一 <link> 標籤載入 CSS,搭配內容雜湊 URL 實現不可變快取:
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
asset() 輔助函式會附加內容雜湊(?v=a3b2c1d4),讓瀏覽器無限期快取該檔案,直到內容變更為止。不需要關鍵 CSS 擷取、不需要 print-media 技巧、也不需要基於 JavaScript 的載入方式。CSS 檔案經 gzip 壓縮後僅約 8KB——小到單一請求的方式就能在 Lighthouse 效能項目拿下 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 快取一年。當檔案內容變更時雜湊值隨之改變,強制瀏覽器與 CDN 擷取新版本。
延遲腳本載入
<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 屬性會在 HTML 解析的同時平行下載腳本,但等到文件解析完畢後才執行。這避免了阻塞渲染,同時免去非同步載入與執行順序管理的複雜性。
圖片最佳化
圖片使用 WebP 格式,搭配響應式 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 屬性可防止累積版面位移(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 的總體積:
| 程式庫 | 大小(壓縮後) |
|---|---|
| HTMX | ~14 KB |
| Alpine.js | ~14 KB |
| 頁面專屬 JS | 4-8 KB |
| 總計 | 32-36 KB |
典型的 React 應用在應用程式碼之前,光框架 JavaScript 就要載入 100-300 KB。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執行四個 worker 程序(一般準則:2 × CPU 核心數 + 1)--loop uvloop使用更快的 uvloop 事件迴圈(asyncio 的直接替代品)--http httptools使用更快的 httptools HTTP 解析器
開發環境中,--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 已保證資源的時效性。
決策框架
是否需要建置工具?
回答四個問題:
1. 是否有超過五位開發者共用 JavaScript 介面? 若是,TypeScript 的編譯期型別檢查能防止整合錯誤——這類錯誤若靠執行期測試才發現,往往為時已晚。加入建置步驟。
2. 應用程式是否需要管理複雜的用戶端狀態? 若拖放操作、即時協作或離線優先資料是核心功能(非錦上添花),React 或 Svelte 等框架所帶來的複雜度才值得引入。加入建置步驟。
3. 是否有多個產品共用同一套元件庫? 若是,該元件庫需要 npm 封裝、語意化版本控制與 tree shaking。加入建置步驟。
4. 是否依賴預設需要打包器的 npm 生態系程式庫? 若 Radix、Framer Motion、TanStack Query 或類似程式庫是產品的核心,建置管線便不可或缺。
若四個問題的答案皆為「否」,無建置方案即可行。若任何一個答案為「是」,建置工具確實在解決實際問題。真正的錯誤在於——四個答案都是「否」時仍加入建置工具,解決根本不存在的問題,同時徒增相依性管理的負擔。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、伺服器端渲染) | 不定(框架本身有額外負擔) |
| 複雜度上限 | 較低(無離線、無豐富用戶端狀態) | 較高(任何用戶端互動皆可實現) |
| 相依套件數 | 15 個 Python 套件 | 300+ 個 npm 套件 |
| 建置時間 | 0 秒 | 15-60 秒 |
HTMX 不適用的場景
HTMX 以伺服器往返取代用戶端狀態。在延遲成為瓶頸之前,這套模式運作良好:
- 拖放介面——每次拖曳事件 200ms 的伺服器往返延遲令人無法接受
- 即時協作——WebSocket 驅動的狀態需要用戶端衝突解決機制
- 離線優先應用——沒有伺服器就沒有 HTMX
- 與狀態緊密耦合的複雜動畫——Framer Motion 和 React Spring 預設基於 React 的協調模型
- 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 能否用於正式環境的網頁應用程式?
可以。HTMX 自2020年起便保持穩定,目前已廣泛應用於多種產業的正式環境中。創建者Carson Gross將向後相容性視為核心設計原則——HTMX 官方文件明確指出,在同一主要版本內不會破壞現有應用程式。19 該程式庫壓縮後僅14KB,零依賴,並遵循語意化版本控制。blakecrosley.com 已在正式環境中運行 HTMX 三年,期間未出現任何與 HTMX 相關的錯誤。
不經過建置步驟就能使用 TypeScript 嗎?
部分可以。TypeScript 檔案可透過 tsc --noEmit 進行型別檢查而不產生輸出檔案,等同於在編譯階段提供靜態檢查功能。然而瀏覽器無法直接執行 .ts 檔案,因此要在瀏覽器中運行仍需建置步驟。替代方案是在純 .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對頁面進行水合(hydration),將框架執行環境與元件程式碼一併傳送至客戶端。FastAPI + HTMX 不進行水合——HTML 即為最終產出。後續互動由 HTMX 負責,向伺服器請求新的 HTML 片段。結果是:FastAPI + HTMX 總共僅傳送30-40KB的 JavaScript,而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語法依然有效。
伺服器傳送的訊息透過與HTTP回應相同的目標定位與置換機制插入DOM。伺服器經由 WebSocket 傳送 HTML 片段,HTMX 負責將其插入頁面。
此技術棧如何處理SEO?
伺服器渲染的 HTML 天生對SEO友善,因為爬蟲收到的是完整頁面內容,無需執行 JavaScript。blakecrosley.com 額外採用了多層SEO強化措施:
- JSON-LD 結構化資料嵌入每個頁面的
<head>中(Person、Article、WebSite、FAQPage等結構描述) - 動態Sitemap包含所有10種語系的hreflang替代連結
- RSS feed 位於
/blog/feed.xml - 根目錄下的
llms.txt提升AI爬蟲可發現性 - 基礎模板中設定標準網址(Canonical URLs)與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、沒有hooks系統、沒有狀態管理程式庫,也沒有建置工具設定需要學習。
HTMX 的文件僅需一個長頁面即可涵蓋。Alpine.js 的文件也只有寥寥數頁。React的文件則橫跨數百頁,涵蓋hooks、context、refs、effects、suspense、server components以及streaming SSR。
對 JavaScript/React開發者來說,轉換的重點在於觀念而非語法。核心洞見是:狀態由伺服器掌控,HTML 由伺服器渲染。客戶端狀態管理變成伺服器端路由處理;客戶端資料擷取變成 HTML 元素上的 HTMX 屬性。語法更為簡潔——真正需要調整的是放下SPA「客戶端主導渲染」的既有假設。
變更紀錄
| 日期 | 變更內容 |
|---|---|
| 2026-03-24 | 首次發布 |
參考資料
本指南涵蓋建構 blakecrosley.com 所使用的完整系統。No-Build Manifesto 闡述其理念主張。Lighthouse Perfect Score 記錄了效能最佳化的歷程。Vibe Coding vs. Engineering 則探討 AI 輔助開發在此工作流程中的定位。
-
blakecrosley.com 截至2026年4月的正式環境指標。該網站提供100篇以上部落格文章、互動式JavaScript元件、9份完整指南及9種語言翻譯,僅使用極少的Python依賴且完全不需建置工具。資料來自線上網站及
requirements.txt驗證。 ↩↩↩ -
Google PageSpeed Insights(pagespeed.web.dev)可對任何公開網址執行 Lighthouse 稽核。blakecrosley.com 截至2026年3月的分數為 100/100/100/100(效能、無障礙、最佳做法、SEO),結果可公開驗證。完整最佳化歷程請參閱 From 76 to 100: Achieving a Perfect Lighthouse Score。 ↩↩↩
-
全新執行
npx create-next-app@latest(Next.js 15,2026年2月測試)會在node_modules/中安裝311個套件,總計187 MB。加入額外依賴的正式專案通常更大,實際數值因專案而異。資料來源:作者實測,詳見 The No-Build Manifesto。 ↩ -
Vercel 的 Next.js 效能文件建議採用特定最佳化措施(圖片最佳化、字型載入、程式碼分割)以達到90分以上。請參閱 nextjs.org/docs/app/building-your-application/optimizing。70-90分的範圍反映的是套用這些最佳化之前的預設狀態。 ↩↩
-
完整依賴清單來自 blakecrosley.com 截至2026年3月的
requirements.txt驗證。沒有任何套件屬於建置工具、編譯器或打包工具。 ↩ -
根據作者維護 Next.js 專案的經驗(2021-2024),JavaScript生態系每月為活躍專案產生15至25個 Dependabot PR,其中大多數更新的是開發者從未直接引入的傳遞性依賴。 ↩
-
Tim Berners-Lee 將向下相容性闡述為網頁設計原則:「瀏覽器應當向下相容。」1996年的網頁在2026年的 Chrome 中依然能正常呈現。請參閱 w3.org/DesignIssues/Principles。 ↩
-
OWASP 建議在正式環境中停用API文件端點,以縮小攻擊面。
/openapi.json端點會暴露所有路由定義、參數及回應模型。 ↩ -
FastAPI關於非同步與同步處理器的文件: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。 ↩↩ -
CSS Custom Properties(CSS變數)在全球97%以上的瀏覽器中獲得支援。它們具備層疊、繼承並在執行階段回應媒體查詢的能力——這些是預處理器變數所欠缺的功能。資料來源: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 預防速查表中描述的「簽署式雙重提交 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/[email protected]。 ↩↩
-
HTMX的版本政策與向下相容性承諾記載於 htmx.org/migration-guide-htmx-1/。Carson Gross 在 Gross、Stepinski 與 Cotter 合著的《Hypermedia Systems》(2023)中闡述了向下相容性原則:hypermedia.systems。 ↩