fastapi:~/app$ cat fastapi-htmx.md

FastAPI + HTMX:免建置全端架構

# 無需 React 或 webpack,即可建置正式環境網頁應用程式:FastAPI、HTMX、Alpine.js、Jinja2、純 CSS、Bootstrap 模式、i18n、部署、SEO 與效能。

words: 2271 read_time: 33m updated: 2026-06-22 16:56
$ less fastapi-htmx.md

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 ThesisArchitecture OverviewHTMX Deep Dive Alpine.js PatternsSecurity
正在評估替代方案的React/Vue開發者 The No-Build ThesisDecision Framework Architecture OverviewPerformance
FastAPI開發者想新增互動功能 HTMX Deep DiveAlpine.js Patterns i18n and LocalizationDeployment
從零開始建置的全端開發者 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中初始化,並明確指定 middleware 順序:

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=Noneopenapi_url=None會停用自動產生的API文件端點。面向大眾的內容網站不需要將/docs/openapi.json暴露在網際網路上。8其次,middleware 順序很重要——安全性記錄會先執行(最後加入),因此能擷取每個請求,包括被速率限制拒絕的請求。第三,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,而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檔案中的值。在 production(Railway)中,secrets 會設定為環境變數。在本機,.env檔案提供預設值。Settings類別會在啟動時驗證型別——缺少必要欄位會立即失敗,而不是等到執行階段才出錯。

Async 模式

FastAPI路由預設為 async。對於 I/O-bound 操作(資料庫查詢、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_eventon_startupon_shutdownhook——lifespan(如上)是唯一機制,而@app.route()@app.websocket_route()則改由routes清單中的RouteWebSocketRoute取代。FastAPI 0.137.0(2026年6月14日)將 Starlette 固定在 1.x 系列,並重構自身的 router 內部結構:router.routes不再是扁平的APIRoute物件清單,而是一棵由中介節點組成的樹,因此應將它視為內部細節,而不是拿來逐一巡覽的介面。好處是,在include_router()之後新增到 router 的路由現在會即時反映,而且 sub-router 可以在其路由定義之前就先被 include。24這些變更不會影響本指南中的模式——本指南全程使用lifespan與標準路由宣告——但如果您維護會巡覽router.routes的工具,或仍在執行舊版@app.on_event處理常式,0.137.0/Starlette 1.0 會造成 breaking change。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(...),用於提供已建置的靜態 frontend——若您發布獨立的 SPA build,這會很有用,但它與本指南不需 build、由伺服器渲染的做法互不相關(它掛載的是dist/目錄,而不是在伺服器上渲染HTML)。25

CPU-bound 操作(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"(預設)、outerHTMLbeforeend
hx-trigger 觸發請求的事件 hx-trigger="click"keyup changed delay:300msload
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>

四個屬性協同運作:

  1. hx-get 向與 href 相同的 URL 發出請求(漸進增強——無 JavaScript 時仍可正常運作)
  2. hx-target 將回應放入 #writing-content 容器中
  3. hx-replace-url="true" 更新瀏覽器網址但不新增歷史記錄
  4. 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-expandedx-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>

明確的widthheight屬性可防止 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=preloadLink標頭會告訴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 builder 會從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.tomlProcfile${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 parser

開發時,--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 packages、共用函式庫
SEO 預設由伺服器渲染 需要 SSR/SSG 設定
效能下限 高(最少 JS、伺服器渲染) 視情況而定(框架額外負擔)
複雜度上限 較低(無離線、無豐富客戶端狀態) 較高(可支援任何客戶端互動)
相依套件 17 個Python套件 300+ 個 npm 套件
建置時間 0 秒 15-60 秒

何時不該使用HTMX

HTMX會以伺服器來回請求取代客戶端狀態。這在延遲不是問題時可行:

  • 拖放介面—每次拖曳事件都要 200ms 伺服器來回請求,無法接受
  • 即時協作—由WebSocket驅動的狀態需要客戶端衝突解決
  • 離線優先應用程式—沒有伺服器就沒有HTMX
  • 與狀態緊密連動的複雜動畫—Framer Motion 和 React Spring 都假設使用 React reconciliation model
  • 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 此函式庫經 minify 與 gzip 後約 16KB,沒有任何依賴,並遵循語意化版本。blakecrosley.com 已在正式環境中使用 HTMX 3 年,期間沒有發生任何與 HTMX 相關的錯誤。20

我可以在沒有建置步驟的情況下使用 TypeScript 嗎?

部分可以。TypeScript 檔案可以透過 tsc --noEmit 進行型別檢查,而不產生輸出檔案,等同於以 linter 形式提供編譯時檢查。不過,瀏覽器無法直接執行 .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 hydration 頁面,將框架執行階段與元件程式碼傳送到用戶端。FastAPI + HTMX 不會 hydration;HTML 就是最終輸出。HTMX 會透過向伺服器請求新的 HTML 片段來處理後續互動。結果是:FastAPI + HTMX 總共傳送約 35-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-connectws-send 屬性。若使用 HTMX 1.x,舊的 hx-ws 語法仍然可用。

HTMX 4.0 beta 軌道:htmx 4.0.0-beta4 目前已在 npm next 標籤與 4.0 文件上發布,而 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 之後的下一步。2122

值得從 4.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:

  • JSON-LD 結構化資料位於每個頁面的 <head>(Person、Article、WebSite、FAQPage schema)
  • 動態 sitemap,包含全部 10 種語系的 hreflang 替代頁
  • RSS feed 位於 /blog/feed.xml
  • 根目錄的 llms.txt,用於 AI 爬蟲可探索性
  • 基礎範本中的 Canonical URLsOpen Graph tags
  • 語意化 HTML<article><section><main>、正確的標題階層

不需要 SSR 設定。不需要 getStaticProps。不需要 ISR。HTML 會在每次請求時轉譯;這是預設行為,不是最佳化手段。

與 React 相比,學習曲線如何?

對 Python 開發者而言,學習曲線明顯較低。您已經熟悉這個語言。FastAPI 的路由處理器會回傳範本回應;這與 Flask 或 Django view 是相同的思維模型。HTMX 只新增少量 HTML 屬性(hx-gethx-targethx-swap)。Alpine.js 則再新增幾個(x-datax-show@click)。不需要學 JSX、virtual DOM、hooks 系統、狀態管理函式庫,也不需要學建置工具設定。

HTMX 文件可容納在一個長頁面中。Alpine.js 文件只需要幾頁。React 文件則橫跨數百頁,涵蓋 hooks、context、refs、effects、suspense、server components 與 streaming SSR。

對 JavaScript/React 開發者而言,轉換重點在概念而非語法。核心洞察是:狀態由伺服器擁有,HTML 也由伺服器轉譯。用戶端狀態管理會變成伺服器端路由處理。用戶端資料擷取會變成 HTML 元素上的 HTMX 屬性。語法更簡單;真正需要調整的,是放下「用戶端負責轉譯」這個 SPA 假設。


變更記錄

日期 變更
2026-06-22 FastAPI 0.138.0 + 0.137.2。 0.138.0(6月20日)新增 app.frontend("/", directory="dist") / router.frontend(...),用於提供已建置的靜態 frontend(SPA dist/ 輸出),這與本指南主張的免建置、伺服器轉譯方向相互獨立,已在 Async Patterns 章節中作為對照補充。0.137.2(6月18日)新增 iter_route_contexts(),作為列舉路由的支援方式,因為 router.routes 自 0.137.0 起已改為內部實作。兩者都是功能新增,沒有破壞性變更;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)皆未變更。
2026-06-16 FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1。 FastAPI 0.137.0(6月14日)重構 router 內部實作:router.routes 現在是內部樹狀結構,不再是扁平的 APIRoute 清單(凡是迭代它的程式都會受破壞性影響),同時支援在 include_router() 之後新增路由,並加入新的 APIRouter.matches()/.handle() hooks;0.137.1(6月15日)修正 APIRoute 型別,以及空路徑、無 prefix 的 routers。Starlette 發布第一個穩定版 1.0(3月22日),目前為 1.3.1(6月12日),移除了已棄用的 on_event/on_startup/on_shutdown hooks 與 @app.route()/@app.websocket_route() decorators,現在只保留 lifespanRoute/WebSocketRoute 兩條路徑;FastAPI 0.137.0 釘選 Starlette 1.3.1。已在 Async Patterns 章節加入 lifespan/router 備註。SQLAlchemy 2.0.51(6月15日)僅包含錯誤修正。
2026-06-08 SQLAlchemy 2.0.50 async 安裝變更。 自 SQLAlchemy 2.0.50 起,async stack 的 greenlet 相依套件預設不再安裝,請安裝 sqlalchemy[asyncio] extra(否則第一次對 engine 執行 await 時,會因缺少 greenlet 而失敗)。2.0.50 也要求 Python 3.10+(停止支援 3.7–3.9),並新增 free-threaded 3.13t wheels。已在 SQLAlchemy 2.0 Async 章節加入安裝備註。其餘 stack 內容未變更:FastAPI 最新版仍為 0.136.3(2026-05-23,6月沒有釋出版本),htmx 穩定版維持 2.0.10(4.0.0-beta4「The Fetchening」仍為 beta,穩定版目標約為 2027 年初,尚不建議用於 production),Alpine.js 3.15.12、Bootstrap 5.3.x 皆未變更。Production 建議不變:在 4.0 穩定前使用 HTMX 2.x。23
2026-05-24 維護檢查:本機內容盤點仍顯示 210 篇部落格文章、11 篇核心指南、48 個設計研究,以及包含英文在內的 10 個支援 locale。FastAPI 最新版為 0.136.3(2026-05-23);release notes 中唯一點名與應用程式相關的重構,是在 convert_underscores=True 時採用更嚴格的底線 header 處理方式,且 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。Production 建議維持不變:在 4.0 穩定前使用 HTMX 2.x。122
2026-05-18 網站盤點更新:本機內容盤點目前顯示 210 篇部落格文章、11 篇核心指南、48 個設計研究,以及包含英文在內的 10 個支援 locale。FastAPI 最新版仍為 0.136.1;htmx 穩定版維持 2.0.10,npm next 為 4.0.0-beta3;Alpine.js npm 最新版仍為 3.15.12。Production 建議維持不變:在 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。Production 建議不變:在 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 extension(DOM-reactive expressions)、新的 hx-nonce extension(針對 htmx attributes 的 CSP nonce 保護),以及 migration guide 對設定、history、events 與核心 JavaScript helpers 的變更。Production 建議不變:htmx 2.x 仍是最新 npm 標籤,也是 4.0 GA 前的建議版本。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 個支援 locale,以及 17 個 Python requirements。Migration 指引不變:production 請使用 HTMX 2.x,直到 4.0 穩定為止。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日)已發布。Migration 指引不變:htmx 2.x 會維持在最新 npm 標籤,直到 4.0 穩定;安全性修正仍會持續,沒有升級壓力。現在值得納入設計考量的 4.0 主要變更包括:(1)fetch() 取代 XMLHttpRequest 成為核心 ajax 基礎設施,(2)attribute inheritance 預設改為明確宣告,(3)history 支援會針對還原的內容發出網路請求(不使用本機 DOM snapshot)。FastAPI 0.135.4(4月16日)移除了 0.135.3 加入的 April Fool’s @app.vibe() decorator。
2026-04-16 新增 HTMX 4.0-beta 認知(前向參照)。註記 FastAPI 0.136.0 支援 Python 3.14t free-threaded builds。Pydantic 2.13.x 功能(private-attribute default factories 可存取已驗證的 model data,pydantic.v1 namespace 更新至 1.10.26 並支援 3.14)。Alpine.js 3.15.11 修正:x-anchor.noflip modifier、x-for multiple-root-element 警告、$refs morph regression 修正。
2026-03-24 初次發布

參考資料


本指南涵蓋用來建置blakecrosley.com的完整系統。No-Build Manifesto提供其理念論證。Lighthouse Perfect Score文章記錄了效能最佳化歷程。Vibe Coding vs. Engineering文章探討AI輔助開發在此工作流程中的定位。


  1. blakecrosley.com截至2026年5月18日的正式環境指標。該網站有210篇部落格文章、互動式JavaScript元件、11篇核心指南、48個設計研究、英文加上9個翻譯語系、極少的Python相依項,並且沒有任何建置工具。已從本機內容清冊、app/i18n/config.pyrequirements.txt驗證。 

  2. Google PageSpeed Insights(pagespeed.web.dev)會針對任何公開URL執行Lighthouse稽核。截至2026年3月,blakecrosley.com取得100/100/100/100(效能、無障礙、最佳做法、SEO)。結果可公開驗證。完整最佳化歷程請參閱從76到100:達成完美Lighthouse分數。 

  3. 全新的npx create-next-app@latest(Next.js 15,2026年2月測試)會在node_modules/中安裝311個套件,總計187 MB。加入其他相依項的正式專案通常更高。各專案情況不一。來源:作者測試,記錄於The No-Build Manifesto。 

  4. Vercel的Next.js效能文件建議特定最佳化(圖片最佳化、字型載入、程式碼分割)以達到90分以上。請參閱nextjs.org/docs/app/building-your-application/optimizing。70到90的區間反映套用這些最佳化前的預設設定。 

  5. 完整相依項清單已從blakecrosley.com截至2026年5月的requirements.txt驗證。該檔案目前有17筆Python需求項目,且沒有任何建置工具、編譯器或 bundler。 

  6. 根據作者維護Next.js專案(2021到2024年)的經驗,對於活躍專案,JavaScript生態系每月會產生15到25個Dependabot PR,其中多數是在更新開發者從未直接匯入的傳遞相依項。 

  7. Tim Berners-Lee曾將向後相容闡述為網頁設計原則:「瀏覽器應該向後相容」。1996年的頁面可以在Chrome 2026中呈現。請參閱w3.org/DesignIssues/Principles。 

  8. OWASP建議在正式環境停用API文件端點,以降低攻擊面。/openapi.json端點會暴露所有路由定義、參數與回應模型。 

  9. FastAPI關於async與sync處理器的文件:fastapi.tiangolo.com/async/。在async函式中混用await與阻塞呼叫,會使事件迴圈資源耗盡。 

  10. nh3是以Rust為基礎的HTML清理器,也是Bleach函式庫的後繼者。它由PyO3專案維護,並提供以允許清單為基礎的HTML清理。請參閱github.com/messense/nh3。 

  11. Vary標頭定義於RFC 9110第12.5.5節。它指示快取依據指定的請求標頭值儲存不同回應。若沒有Vary: HX-Request,CDN可能會將HTMX片段作為完整頁面回應提供。請參閱httpwg.org/specs/rfc9110.html#field.vary。 

  12. CSS Custom Properties(CSS Variables)受到全球97%+瀏覽器支援。它們會階層串接、繼承,並在執行階段回應媒體查詢;這些能力是前處理器變數所缺乏的。來源:caniuse.com/css-variables。 

  13. Google的hreflang文件:developers.google.com/search/docs/specialty/international/localized-versionsx-default值指定當使用者語言不在hreflang清單中時使用的後備頁面。 

  14. Alpine.js的運算式求值引擎需要在Content Security Policy中加入'unsafe-eval'。CSP相容建置(@alpinejs/csp)可避免此需求,但有其限制。請參閱alpinejs.dev/advanced/csp。 

  15. 以HMAC為基礎的CSRF token遵循OWASP CSRF Prevention Cheat Sheet中描述的「Signed Double-Submit Cookie」模式。hmac.compare_digest使用固定時間比較,以防止計時旁通道攻擊。請參閱cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html。 

  16. 在相同視覺品質下,WebP檔案比JPEG小25到35%。Google的WebP研究:developers.google.com/speed/webp/docs/webp_study。 

  17. 103 Early Hints讓伺服器(或CDN)可在最終回應準備完成前,先傳送帶有preload提示的初步回應。Cloudflare支援針對含有rel=preloadLink標頭使用Early Hints。請參閱developer.chrome.com/blog/early-hints。 

  18. React 18 + ReactDOM約為42 KB(minified + gzipped)。若加上router、狀態管理函式庫與建置框架執行階段,典型React應用程式會傳送100到300 KB的框架JavaScript。來源:bundlephobia.com/package/[email protected]。 

  19. HTMX版本政策與向後相容承諾記錄於htmx.org/migration-guide-htmx-1/。Carson Gross也在Gross、Stepinski與Cotter合著的Hypermedia Systems(2023)中說明了向後相容原則:hypermedia.systems。 

  20. 2026年5月15日維護檢查。FastAPI PyPIrelease notes列出0.136.1;本網站環境的本機import驗證回傳FastAPI 0.128.0與Starlette 0.50.0;htmx.org在quick start中列出2.0.10;npm view htmx.org version dist-tags回傳latest=2.0.10next=4.0.0-beta3npm view alpinejs versionnpm view @alpinejs/csp version回傳3.15.12;Bootstrap official blog與npm套件中繼資料列出5.3.8;SQLAlchemy PyPI與文件列出2.0.49;Pydantic PyPI列出2.13.4。 

  21. htmx 4.0.0-beta3套件中繼資料列出2026年5月8日發布,且npm next指向4.0.0-beta3;npm latest仍為2.0.10。four.htmx.org上的4.0文件顯示[email protected]4.0 extensions index列出hx-livehx-nonce4.0 migration guide記錄了在將正式應用程式從2.x遷移前應檢視的遷移變更。最新版本線追蹤已由22取代。 

  22. 2026年5月24日維護檢查。本機清冊命令回傳210篇Markdown部落格文章、11個頂層指南檔案與48個設計研究檔案。FastAPI release notes列出0.136.3於2026-05-23發布,當convert_underscores=True時採用更嚴格的底線標頭處理;0.136.2會驗證Server-Sent Event欄位。python3 -m pip index versions fastapi回傳最新版本0.136.3python3 -m pip index versions sqlalchemy回傳最新版本2.0.50python3 -m pip index versions pydantic回傳最新版本2.13.4npm view htmx.org dist-tags version time.modified --json回傳latest=2.0.10next=4.0.0-beta4time.modified=2026-05-22T15:56:21.948Zfour.htmx.org installation docs顯示[email protected]。 

  23. SQLAlchemy 2.0.50 changelogrelease blog,發布於2026-05-24。asyncio的greenlet相依項不再預設安裝;現在需要使用sqlalchemy[asyncio]安裝目標才會將其納入。2.0.50也移除Python 3.7/3.8/3.9(現在為3.10+),加入free-threaded Python wheels,並新增over(..., exclude=...)視窗框架參數。截至2026-06-08已在PyPI驗證為最新。htmx 4.0.0-beta4(「The Fetchening」,2026-05-22)仍為beta,穩定目標在2027年初;FastAPI 0.136.3(2026-05-23)、Alpine.js 3.15.12與Bootstrap 5.3.x在此期間未變更。 

  24. FastAPI release notes:0.137.0(2026-06-14)重構router內部,使router.routes不再是APIRoute物件的扁平清單,而是中介物件樹(應視為內部實作);它也支援在include_router()之後新增路由、在子router的路由定義前先納入子router、避免複製路由,並新增APIRouter.matches()/.handle();固定Starlette 1.3.1。0.137.1(2026-06-15)修正APIRoute型別與無prefix router中的空路徑。Starlette release notes:1.0.0(2026-03-22)是約8年來第一個穩定版本,移除on_startup/on_shutdown/on_event()以及@app.route()/@app.websocket_route()裝飾器(請使用lifespanRoute/WebSocketRoute);最新版本為1.3.1(2026-06-12)。SQLAlchemy 2.0.51(changelog,2026-06-15)僅修正bug,沒有async或安裝影響。已於2026-06-16透過PyPI與官方release notes驗證。 

  25. FastAPI release notes:0.138.0(2026-06-20)新增app.frontend("/", directory="dist")router.frontend("/", directory="dist"),用於提供已建置的靜態frontend(PR #15800;Frontend docs)——這是靜態dist/ SPA提供功能,不是伺服器轉譯模式;沒有破壞性變更。0.137.2(2026-06-18)新增iter_route_contexts(),供先前遍歷router.routes的進階用法使用(自0.137.0起為內部實作);沒有破壞性變更。截至2026-06-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-06-22透過PyPI與官方release notes驗證。 

NORMAL fastapi-htmx.md EOF