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

FastAPI + HTMX:無需建置的全端開發

# FastAPI + HTMX:無需建置的全端開發

words: 1718 read_time: 26m updated: 2026-03-25 08:22
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Bootstrap 5 + Jinja2 + 純 CSS 即可打造正式環境的網頁應用程式——不需要建置工具、不需要 node_modules/,還能拿到滿分的 Lighthouse 成績。本指南涵蓋從架構到部署的完整系統,以 blakecrosley.com 作為正式環境的實際範例。該站點提供 37 篇部落格文章、20 個互動式 JavaScript 元件、20 份指南,以及十種語言的翻譯——全程未使用任何打包工具、編譯器或轉譯器。1

現代網頁開發生態預設您需要 React、webpack、TypeScript 以及一整套建置流程。然而對於一大類應用——內容導向網站、內部工具、CRUD 應用、作品集網站、文件平台——這個假設根本站不住腳。本指南所描述的技術棧徹底移除了前端建置工具鏈,同時產出 Lighthouse 四項指標皆達 100/100/100/100 的網站。2

這不是倡議,而是實測結果。此架構已在正式環境運行、服務十種語言的真實使用者,所有數據皆可驗證。


關鍵重點

  • 伺服器端渲染的HTML消除了三大類問題:客戶端狀態管理、JSON序列化邊界,以及 hydration 不一致。HTMX讓伺服器回應成為最終輸出——無需客戶端渲染步驟。
  • 零建置工具意味著零建置失敗。不會遇到npm install的 peer dependency 衝突,不會碰到TypeScript在您未修改的檔案中報錯,也不會收到 Dependabot 針對從未引入的間接依賴發出的 PR。部署流程就是git push
  • Alpine.js處理HTMX無法處理的純客戶端狀態。下拉選單、互動視窗、行動裝置導覽列切換,以及任何純粹存在於瀏覽器中的 UI 狀態,都屬於Alpine.js的範疇。分界線很明確:若狀態需要伺服器,用HTMX;若不需要,用Alpine.js。
  • 搭配自訂屬性的純CSS取代了 Sass 和 Tailwind。CSS自訂屬性能級聯、繼承,並在執行時回應媒體查詢。預處理器變數則編譯為靜態值後便消失。瀏覽器直接讀取自訂屬性——無需編譯步驟。
  • 此方法有明確的適用範圍。它不適合需要共用元件介面的大型團隊、具有複雜客戶端狀態的 SaaS 產品,以及依賴 npm 生態系函式庫的應用程式。第15節的決策框架精確指出了適用邊界。
  • blakecrosley.com就是實證。本指南中的每個模式都在正式環境中運行。每個論點都附有檔案路徑、設定區塊,或您可自行在PageSpeed Insights驗證的 Lighthouse 稽核結果。2

如何使用本指南

這是一份完整的參考資料。請依據您的經驗程度選擇起點:

經驗程度 從這裡開始 延伸閱讀
Python開發者,初次接觸HTMX No-Build 論點架構概覽HTMX深入探討 Alpine.js模式安全性
React/Vue開發者,評估替代方案 No-Build 論點決策框架 架構概覽效能
FastAPI開發者,需要新增互動性 HTMX深入探討Alpine.js模式 i18n與在地化部署
全端開發者,從零開始建置 架構概覽依序閱讀 快速參考卡供日常查閱

使用 Ctrl+F / Cmd+F 搜尋特定模式或屬性。結尾的快速參考卡提供可快速瀏覽的摘要。


No-Build 論點

此論點的範圍精確而明確:對於由單一開發者或小團隊維護的內容導向網站而言,建置工具解決的是您根本不存在的問題,同時製造了新的問題。

以下是 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不會因 peer dependency 衝突而失敗。next build不會因為您未修改的檔案中的TypeScript錯誤而失敗。6

用「檢視原始碼」即可除錯。瀏覽器中執行的JavaScript就是您撰寫的JavaScript。不需要 source map。

本機即時啟動。uvicorn app.main:app --reload在2秒內啟動完成。

清晰的請求瀑布圖。首次造訪載入:一份HTML文件(約15KB gzip壓縮後)、一個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 遷移。


架構概覽

請求流程

每個請求都經過四層架構的單一路徑:

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)

這裡有三項設計決策值得留意。首先,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,而後者又依賴 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 請求、檔案讀取),非同步處理可避免阻塞事件迴圈:

@app.on_event("startup")
async def startup_load_translations():
    """Load translations from D1 into memory at startup."""
    client = init_d1_client(
        worker_url=settings.D1_WORKER_URL,
        auth_secret=settings.D1_AUTH_SECRET,
    )
    if not client.is_configured:
        logger.warning("i18n: D1 not configured, translations use defaults")
        return
    cache = await load_translations(client)
    logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")

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"(預設)、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>

每次按鈕點擊都會將累積的答案作為查詢參數傳送。伺服器根據答案歷程計算下一道題目或最終結果。狀態累積在網址中——不需要 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 相同的網址發送請求(漸進增強——即使沒有 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 進行防抖——在最後一次按鍵後等待 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。

在 blakecrosley.com 的聯絡表單中就運用了此模式:提交表單後,表單主體被替換為成功訊息,同時透過 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> 內容發生變化。blakecrosley.com 在主導覽列中採用增強式連結,使頁面切換如同單頁應用程式般流暢,卻不需要 SPA 架構。

模式 6:HTMX 請求標頭

HTMX 會在每次請求中傳送自訂標頭:

標頭 用途
HX-Request true 在伺服器端偵測 HTMX 請求
HX-Target 元素 ID 得知哪個元素將接收回應
HX-Trigger 元素 ID 得知哪個元素觸發了請求
HX-Current-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 則在可用時提升使用體驗。

模式 5:載入狀態

<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 個互動式部落格元件(群體模擬、漢明碼視覺化工具等)則使用原生 JavaScript,因為它們需要 canvas 渲染與複雜的狀態機。


不使用 Sass 的 Bootstrap 5

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),並在啟動時載入至記憶體快取:

@app.on_event("startup")
async def startup_load_translations():
    client = init_d1_client(worker_url=settings.D1_WORKER_URL,
                            auth_secret=settings.D1_AUTH_SECRET)
    cache = await load_translations(client)
    logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")

記憶體快取避免了每次頁面渲染時的資料庫查詢。翻譯更新需要重新整理快取(透過管理端點或部署觸發)。此架構以即時性換取效能——翻譯內容變動不頻繁,但頁面渲染發生在每次請求中。

健康監控

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 動態產生,並快取一小時。


資料庫模式

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

資料庫 Session 的依賴注入

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(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 依賴負責管理 session 的生命週期:開啟一個 session,將其 yield 給路由處理函式,成功時提交,發生例外時回滾。所有資料庫操作一律使用參數化查詢,絕不使用字串內插。

Pydantic 整合

Pydantic 模型在 API 邊界驗證輸入,並將輸出序列化供模板使用:

from pydantic import BaseModel, EmailStr

class ContactForm(BaseModel):
    name: str
    email: EmailStr
    message: str

@router.post("/contact")
async def submit_contact(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 遷移僅適用於儲存 session 資料和分析數據的本機 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-100ms),相較於本機資料庫連線(約 1-5ms)慢了不少。啟動時的記憶體快取能有效緩解翻譯等讀取密集工作負載的延遲問題。


安全性

安全標頭中介軟體

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>

無狀態權杖免除了伺服器端 session 儲存的需求。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 injection 並確保邊界處的型別安全。


效能

Lighthouse 100/100/100/100

blakecrosley.com 在 Lighthouse 四大類別——效能、無障礙、最佳實踐與 SEO——均獲得滿分 100。可至 PageSpeed Insights 驗證。2

關鍵優化如下:

關鍵 CSS

關鍵(首屏)CSS 被提取並內嵌於 <head> 中,完整樣式表則以非同步方式載入:

<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>

<!-- Full CSS loads async — doesn't block render -->
<link rel="stylesheet" href="/static/css/styles.css"
      media="print" onload="this.media='all'">
<noscript>
  <link rel="stylesheet" href="/static/css/styles.css">
</noscript>

media="print" 技巧告知瀏覽器該樣式表並非螢幕渲染所需,因此不會阻擋首次繪製。onload 處理器在載入完成後將其切換為 media="all"<noscript> 備援機制則確保即使停用 JavaScript,樣式表仍能正常載入。16

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>

明確的 widthheight 屬性可防止累計版面位移(CLS)。loading="lazy" 屬性則延遲載入畫面外的圖片。WebP 在同等品質下比 JPEG 小 25-35%。17

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。18

精簡的 JavaScript

JavaScript 總體積:

函式庫 大小(壓縮 + gzip)
HTMX ~14 KB
Alpine.js ~14 KB
頁面專用 JS 4-8 KB
合計 32-36 KB

典型的 React 應用程式在應用程式碼之前,光框架 JavaScript 就需載入 100-300 KB。19 免建置方案之所以傳輸更少 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.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 執行四個 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 年以來一直穩定運作,已廣泛應用於多個產業的正式環境中。HTMX 的創建者 Carson Gross 將向後相容性視為核心設計原則——HTMX 文件明確指出,該程式庫在主要版本內不會破壞現有應用程式。20 此程式庫壓縮後僅 14KB,零依賴,並遵循語意化版本控制。blakecrosley.com 已在正式環境中運行 HTMX 三年,期間未出現任何與 HTMX 相關的錯誤。

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

部分可以。TypeScript 檔案可透過 tsc --noEmit 進行型別檢查而不產生輸出檔案,在編譯階段提供類似 linter 的檢查功能。然而,瀏覽器無法直接執行 .ts 檔案,因此要部署 TypeScript 仍需建置步驟。替代方案是在純 .js 檔案中使用 JSDoc 型別註解,TypeScript 可在不需編譯的情況下進行檢查。如此一來,開發階段享有型別安全,部署時則是標準的 JavaScript。

這種方式與 Astro 或 11ty 相比如何?

Astro 和 11ty 是靜態網站產生器,產出的是極少客戶端 JavaScript 的純 HTML,但它們需要建置步驟(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。19

這套技術堆疊如何處理表單驗證?

在伺服器端處理。表單提交時由 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 屬性則作為第一道防線,提供基本的瀏覽器層級驗證。

可以加入即時功能(WebSockets)嗎?

可以。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 端點:

<div hx-ws="connect:/ws/notifications">
  <div id="notifications" hx-ws="send"></div>
</div>

來自伺服器的訊息會透過與 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 URLOpen Graph 標籤——內建於基礎範本中
  • 語意化 HTML<article><section><main>,搭配正確的標題層級

無需 SSR 設定、無需 getStaticProps、無需 ISR。每次請求時直接渲染 HTML——這是預設行為,而非額外的最佳化。

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

對 Python 開發者而言,學習曲線大幅降低。語言本身已是熟悉的工具。FastAPI 的路由處理器回傳範本回應——與 Flask 或 Django 的視圖是同樣的思維模式。HTMX 僅增加了少數 HTML 屬性(hx-gethx-targethx-swap)。Alpine.js 再加上幾個(x-datax-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輔助開發在此工作流程中的定位。


  1. blakecrosley.com 截至2026年3月的生產環境指標。該網站提供37篇部落格文章、20個互動式JavaScript元件、20個指南章節,以及10種語言翻譯,僅使用15個Python套件,完全不需要建置工具。完整相依套件清單:fastapi、uvicorn、starlette、pydantic、pydantic-settings、jinja2、markdown、pygments、beautifulsoup4、lxml、nh3、resend、python-multipart、httpx、analytics-941。已從requirements.txt驗證。 

  2. Google PageSpeed Insights(pagespeed.web.dev)可對任何公開URL執行Lighthouse審計。blakecrosley.com截至2026年3月的評分為100/100/100/100(效能、無障礙、最佳實踐、SEO)。結果可公開驗證。完整優化歷程請參閱From 76 to 100: Achieving a Perfect Lighthouse Score。 

  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年3月的requirements.txt驗證。沒有任何套件屬於建置工具、編譯器或打包工具。 

  6. 根據作者維護Next.js專案的經驗(2021-2024),JavaScript生態系統每月會為活躍專案產生15-25個Dependabot PR,其中大多數是更新開發者從未直接引入的傳遞性相依套件。 

  7. Tim Berners-Lee將向後相容性闡述為網頁設計原則:「瀏覽器應具備向後相容性。」1996年的頁面在2026年的Chrome中依然能正常呈現。請參閱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變數)在全球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權杖遵循OWASP CSRF防護速查表中描述的「簽署式雙重提交Cookie」模式。hmac.compare_digest使用常數時間比較,以防止時序側通道攻擊。請參閱cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html。 

  16. media="print"非同步CSS載入技巧由web.dev團隊記錄。瀏覽器將該樣式表視為非阻塞渲染資源,因為它被宣告為列印媒體。onload處理器會在下載完成後將其升級為all媒體。請參閱web.dev/articles/defer-non-critical-css。 

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

  18. 103 Early Hints允許伺服器(或CDN)在最終回應準備好之前,先發送包含預載提示的初步回應。Cloudflare支援透過Link標頭搭配rel=preload使用Early Hints。請參閱developer.chrome.com/blog/early-hints。 

  19. React 18 + ReactDOM壓縮並gzip後約為42 KB。加上路由器、狀態管理函式庫及建置框架的執行時期程式碼,典型的React應用程式會輸出100-300 KB的框架JavaScript。來源:bundlephobia.com/package/[email protected]。 

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

NORMAL fastapi-htmx.md EOF