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

FastAPI + HTMX:无需构建的全栈方案

# FastAPI + HTMX:无需构建的全栈方案

words: 2094 read_time: 29m updated: 2026-04-11 18:29
$ less fastapi-htmx.md

摘要: FastAPI + HTMX + Alpine.js + Jinja2 + 纯 CSS 即可构建零构建工具、零 node_modules/、Lighthouse 满分的生产级 Web 应用。本指南涵盖从架构到部署的完整体系,以 blakecrosley.com 作为生产环境参考——该站点承载 100 余篇博客文章、交互式 JavaScript 组件、多部综合指南以及九种语言的翻译版本,全程未使用任何打包器、编译器或转译器。1

当下主流的 Web 开发技术栈默认你需要 React、webpack、TypeScript 以及一整套构建流水线。然而对于一大类应用——内容驱动型网站、内部工具、CRUD 应用、作品集站点、文档平台——这一假设并不成立。本指南所述的技术栈完全移除了前端构建工具链,同时产出的站点在 Lighthouse 上达到 100/100/100/100 的满分评分。2

这不是布道,而是实测结论。此处描述的架构已在生产环境运行,服务十种语言的真实用户,所有数据均可验证。


核心要点

  • 服务器端渲染的 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 章节涵盖该技术栈的通用模式,但并非本站所用。每一项论断都附有可验证的文件路径、配置代码块或 Lighthouse 审计结果,您可自行前往 PageSpeed Insights 查证。2

如何使用本指南

本指南是一份全面的参考资料。请根据自身经验水平选择起点:

经验水平 从这里开始 进阶探索
Python 开发者,初识 HTMX 零构建理念架构概览HTMX 深度解析 Alpine.js 模式安全性
React/Vue 开发者,评估替代方案 零构建理念决策框架 架构概览性能优化
FastAPI 开发者,添加交互能力 HTMX 深度解析Alpine.js 模式 国际化与本地化部署
全栈开发者,从零开始构建 架构概览开始顺序阅读 快速参考卡可作为日常速查手册

使用 Ctrl+F / Cmd+F 搜索特定模式或属性。末尾的快速参考卡提供了可快速浏览的总结。


零构建理念

这一理念的适用范围明确而具体:对于由个人开发者或小团队维护的内容驱动型网站,构建工具所解决的问题你并不存在,反而会制造新的问题。

以下是 blakecrosley.com 的真实数据:

指标 blakecrosley.com(零构建) 典型 Next.js 项目3
依赖数量 15 个 Python 包 311+ 个 npm 包
构建配置文件 0 5-8 个(next.config、tsconfig、postcss、tailwind 等)
node_modules/ 大小 不存在 基础 187 MB,加上扩展后 250-400 MB
安装时间 pip install:8 秒 npm install:30-90 秒
构建步骤 next build:15-60 秒
部署流程 git push → 约 40 秒上线 安装 → 构建 → 部署:2-5 分钟
Lighthouse 性能评分 100 未经专门优化时为 70-904

这 15 个 Python 包包括 FastAPI、Jinja2、Pydantic、uvicorn、nh3 及另外 10 个。没有一个是构建工具,没有一个是编译器,没有一个是打包器。5

你需要放弃什么

诚实地列出真实的代价:

没有 TypeScript。 所有 .js 文件都是原生 JavaScript。类型错误通过测试和代码分析捕获,而非编译器。这对个人开发者行之有效,但对 10 人团队共享组件接口的场景则不适用。

没有热模块替换。 CSS 的修改需要手动刷新浏览器。HTMX 的 hx-boost 使页面导航足够快,全量刷新尚可接受,但在需要频繁视觉微调的场景下,HMR 确实能节省时间。

没有 Tree Shaking。 你编写的每一字节 JavaScript 都会发送到浏览器。这一约束迫使你保持纪律:编写小而专注的文件,而非臃肿的工具模块。

没有 npm 组件库。 没有 Radix,没有 shadcn/ui,没有 Headless UI。每个交互元素都是手工构建或使用 Bootstrap 5 的内置组件。

没有来自 npm 的设计系统令牌。 设计系统存在于 CSS 自定义属性中,无法作为包导入到其他项目。

对于一到三人维护的内容驱动型网站,这些取舍完全可以接受。但对于拥有 15 人工程团队的 SaaS 产品,则不可接受。第 15 节提供了详细的决策框架。

你能获得什么

零构建失败。 npm install 不会因对等依赖冲突而失败。next build 不会因你未修改的文件中的 TypeScript 错误而失败。6

直接查看源码调试。 浏览器中运行的 JavaScript 就是你编写的 JavaScript,无需 source map。

即时本地启动。 uvicorn app.main:app --reload 在 2 秒内完成启动。

清晰的请求瀑布流。 首次访问加载:一个 HTML 文档(gzip 后约 15KB)、一个 CSS 文件(约 8KB)、HTMX(约 14KB,已缓存)、Alpine.js(约 14KB,已缓存),以及页面交互 JS(约 4-8KB)。首次访问总计:45-60KB。1

面向未来的前端。 客户端代码使用 HTML、CSS 和 JavaScript——这些标准在过去 30 年间始终保持向后兼容。7 无需经历 Webpack 4 → 5 迁移、Create React App 弃用或 Next.js App Router 迁移。

技术栈对比

零构建技术栈与常见替代方案在可量化维度上的对比:

维度 FastAPI+HTMX(本指南) Next.js (React) Astro 11ty
发送到浏览器的 JS 32-46KB(HTMX+Alpine) 85-250KB+(React 运行时) 默认 0KB,按需引入岛屿 默认 0KB
构建步骤 必需(webpack/turbopack) 必需(Vite) 必需(自定义)
配置文件 0 5-8 个(next.config、tsconfig 等) 1-3 个(astro.config、tsconfig) 1-2 个(.eleventy.js)
部署流程 git push(40 秒) 安装+构建+部署(2-5 分钟) 安装+构建+部署(1-3 分钟) 安装+构建+部署(1-2 分钟)
服务器端交互 原生支持(HTMX) API 路由 + 客户端 fetch 有限(表单操作) 无(静态输出)
客户端状态管理 Alpine.js(15KB) React state/context/Redux 框架岛屿 手写 JS
后端语言 Python JavaScript/TypeScript JavaScript/TypeScript JavaScript
国际化方案 服务器端(中间件) next-intl 或类似包 @astrojs/i18n 手动实现
Lighthouse 性能评分 100(实测) 典型值 70-904 典型值 95-100 典型值 95-100
最适用场景 内容站点、CRUD 应用、仪表板 复杂 SPA、大型团队 内容站点、营销页面 静态博客、文档站

Astro 和 11ty 是内容站点领域最接近的竞争对手。两者都能生成优秀的静态输出,但都需要构建步骤和 JavaScript 工具链。FastAPI+HTMX 技术栈以静态站点性能为代价,换取服务器端交互能力(分类筛选、表单处理、实时搜索),且无需引入构建步骤。如果你的站点是纯静态且不涉及服务器交互,Astro 或 11ty 可能是更优选择。


架构概览

请求流程

每个请求都沿着同一条路径,依次穿过四个层级:

Browser                FastAPI                Jinja2              HTMX/Alpine
  |                      |                     |                     |
  |--- GET /about ------>|                     |                     |
  |                      |-- render template ->|                     |
  |                      |                     |-- base.html ------->|
  |                      |                     |   + about.html      |
  |                      |<-- full HTML -------|                     |
  |<--- HTML response ---|                     |                     |
  |                                                                  |
  |--- hx-get /search ------------------------------------------------>|
  |                      |<-- HTMX request ----|                     |
  |                      |-- render partial -->|                     |
  |                      |                     |-- _results.html     |
  |                      |<-- HTML fragment ---|                     |
  |<--- HTML fragment ---|                     |                     |
  |--- DOM swap -------------------------------------------------------->|

完整页面加载返回完整的 HTML 文档(基础模板 + 页面模板)。HTMX 请求返回 HTML 片段(局部模板)。服务器根据请求类型决定渲染内容。Alpine.js 负责管理无需与服务器交互的纯客户端状态。

组件职责

组件 职责 作用范围
FastAPI 路由、业务逻辑、数据访问、验证 服务器
Jinja2 模板渲染、继承、宏 服务器
HTMX 服务器驱动的交互(表单、分页、搜索) 客户端 ↔ 服务器
Alpine.js 纯客户端状态(下拉菜单、模态框、开关切换) 仅客户端
Bootstrap 5 网格系统、工具类、响应式布局 客户端(CSS)
原生 CSS 自定义属性、组件样式、设计令牌 客户端(CSS)
Pydantic 请求/响应验证、配置管理 服务器

项目结构

app/
├── main.py              # FastAPI app, middleware, templates
├── config.py            # Pydantic settings management
├── routes/
│   ├── pages.py         # Page routes (HTML responses)
│   └── api.py           # API routes (JSON/HTML fragment responses)
├── content.py           # Markdown loading, blog post parsing
├── security/
│   ├── headers.py       # CSP, HSTS, security headers middleware
│   ├── csrf.py          # HMAC-signed CSRF tokens
│   ├── rate_limit.py    # 3-tier rate limiting
│   └── logging.py       # Security event logging
├── i18n/
│   ├── config.py        # Supported locales, mappings
│   ├── middleware.py     # URL-based locale detection
│   ├── jinja.py         # Translation functions for templates
│   └── d1_client.py     # Cloudflare D1 translation storage
├── cache_assets.py      # Content-hash asset versioning
└── templates/
    ├── base.html         # Base layout with Alpine.js state
    ├── components/       # Reusable partials (_language_switcher.html, etc.)
    └── pages/            # Page templates (home.html, about.html, etc.)

content/
├── blog/                # Markdown blog posts with YAML frontmatter
└── guides/              # Multi-section guide markdown

static/
├── css/                 # Plain CSS (no preprocessors)
├── js/                  # Vanilla JavaScript (no bundlers)
│   └── vendor/          # Self-hosted HTMX, Alpine.js
└── images/              # Optimized images with WebP srcset

整个结构遵循一条原则:每个目录只存放一类内容。路由放在 routes/,模板放在 templates/,静态资源放在 static/。没有任何构建步骤将一种内容转换为另一种。

与 SPA 架构的对比

在 React + Next.js 项目中,等效的结构大致如下:

src/
├── components/       # React components (JSX)
├── pages/            # Route handlers (also JSX)
├── api/              # API routes (also in pages/)
├── hooks/            # Custom React hooks
├── context/          # React context providers
├── lib/              # Utility functions
├── styles/           # CSS modules or Tailwind config
└── types/            # TypeScript type definitions

# Plus build configuration
next.config.js
tsconfig.json
postcss.config.js
tailwind.config.js
eslint.config.js
package.json
package-lock.json
node_modules/         # 187+ MB of dependencies

SPA 架构需要在这些目录之间进行构建时协调。TypeScript 将 .tsx 编译为 JavaScript。PostCSS 将 Tailwind 指令处理为 CSS。Webpack(或 Turbopack)将输出打包为代码块。每个步骤都可能独立出错。

无构建架构则不需要任何协调。模板引用一个 CSS 文件,该文件存放在 static/css/ 中,浏览器直接加载即可。如果重命名了文件,模板引用会在请求时报错——而非构建时。这将错误从编译期推迟到了运行期,是一个真实存在的权衡取舍。对于独立开发者在开发过程中运行 uvicorn --reload,运行时错误会立即在浏览器中显现。而对于大型团队,TypeScript 在编译期捕获的错误能够防范一类运行时错误无法覆盖的缺陷。


FastAPI 模式

应用初始化

应用在 main.py 中初始化,中间件的添加顺序需要明确指定:

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware

app = FastAPI(
    title="Blake Crosley",
    docs_url=None,     # Disable docs in production
    redoc_url=None,
    openapi_url=None,  # Prevent /openapi.json exposure
)

# Middleware order matters: last added = first executed
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(LocaleMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityLogMiddleware, site_name="blakecrosley.com")

# Static files
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

# Templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)

这里有三个值得关注的设计决策。首先,docs_url=Noneopenapi_url=None 禁用了自动生成的 API 文档端点。面向公众的内容站点无需将 /docs/openapi.json 暴露在互联网上。8 其次,中间件顺序至关重要——安全日志中间件最后添加、最先执行,因此能捕获每一个请求,包括被速率限制拦截的请求。第三,GZipMiddleware 会压缩所有超过 500 字节的响应,通常可将 HTML 传输体积缩减 70-80%。

路由

路由分为两类:页面路由返回完整的 HTML 文档,API 路由返回 JSON 或 HTML 片段。

# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request

router = APIRouter()

@router.get("/about")
async def about(request: Request):
    templates = request.app.state.templates
    return templates.TemplateResponse("pages/about.html", {
        "request": request,
        "page_title": "About — Blake Crosley",
        "page_description": "Designer, developer, dad.",
    })
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
    # Parse answers, compute next question or result
    question = get_next_question(quiz_id, answers)
    templates = request.app.state.templates
    return templates.TemplateResponse("components/_quiz_step.html", {
        "request": request,
        "question": question,
        "answers": answers,
        "step": len(answers.split(",")) if answers else 0,
    })

这一区分对 HTMX 而言意义重大。页面路由返回继承自 base.html 的完整文档,API 路由则返回 HTML 片段,由 HTMX 将其替换到现有 DOM 元素中。两者均由同一个 Jinja2 模板引擎渲染——无需额外的 API 层。

依赖注入

FastAPI 的 Depends() 系统在路由处理函数与共享逻辑之间实现了清晰的关注点分离:

from fastapi import Depends, Request

def get_templates(request: Request):
    """Get templates from app state."""
    return request.app.state.templates

def get_current_locale(request: Request) -> str:
    """Get locale from middleware-set request state."""
    return getattr(request.state, "locale", "en")

@router.get("/blog/{slug}")
async def blog_post(
    request: Request,
    slug: str,
    templates=Depends(get_templates),
    locale: str = Depends(get_current_locale),
):
    post = load_post_by_slug(slug)
    if not post:
        raise HTTPException(404, "Post not found")
    return templates.TemplateResponse("pages/blog/post.html", {
        "request": request,
        "post": post,
        "locale": locale,
    })

依赖可以组合。get_db 依赖可以依赖 get_current_locale,而后者又依赖 request 对象。FastAPI 会自动解析整个依赖链。

Pydantic 配置管理

配置使用 Pydantic 的 BaseSettings,环境变量具有更高的优先级:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    D1_WORKER_URL: str = ""
    D1_AUTH_SECRET: str = ""
    CLOUDFLARE_ACCOUNT_ID: str = ""
    CLOUDFLARE_API_TOKEN: str = ""
    ANALYTICS_PASSKEY: str = ""

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

settings = Settings()

环境变量会覆盖 .env 文件中的值。在生产环境(Railway)中,密钥通过环境变量设置;本地开发时,.env 文件提供默认值。Settings 类在启动时即进行类型校验——缺少必填字段会立即报错,而非等到运行时才暴露问题。

异步模式

FastAPI 路由默认支持异步。对于 I/O 密集型操作(数据库查询、HTTP 请求、文件读取),异步可以避免阻塞事件循环:

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load translations into memory cache at startup
    async with httpx.AsyncClient() as client:
        for locale in SUPPORTED_LOCALES:
            resp = await client.post(f"{D1_URL}/query", ...)
            TRANSLATIONS[locale] = resp.json()["results"]
    yield
    # Cleanup on shutdown (if needed)

app = FastAPI(lifespan=lifespan)

CPU 密集型操作(Markdown 渲染、CSS 提取)可以使用同步函数。当路由处理函数未声明为 async 时,FastAPI 会自动将其放入线程池执行:

# Sync function — FastAPI runs it in a thread pool
@router.get("/blog/{slug}")
def blog_post(slug: str):
    post = load_post_by_slug(slug)  # CPU-bound Markdown parsing
    return templates.TemplateResponse(...)

原则很简单:如果函数需要 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 更新浏览器 URL hx-push-url="true"
hx-replace-url 替换 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 实现无缝分页,同时更新 URL:

<!-- 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" 更新浏览器 URL 但不添加历史记录
  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 防抖——在最后一次按键释放后等待 300ms 再发送请求

服务器返回渲染好的 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> 并更新 URL——无需完整页面重载。浏览器历史记录正常运作(前进/后退按钮可用)。如果 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 类。hx-indicator 属性指向一个在请求期间变为可见的元素。通过 CSS 控制其样式:

.htmx-indicator {
  display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: inline;
}

无需加载状态管理,无需 useState(false),无需 setLoading(true)。CSS 控制可见性,HTMX 负责类名切换。


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 渲染和复杂的状态机。


端到端示例:/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 会更新浏览器 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 未参与(无需客户端状态)。URL 同步更新以支持分享。渐进增强:标签作为普通链接在无 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 实用工具类负责布局机制(外边距、内边距、弹性布局),自定义 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 替代链接的站点地图

动态站点地图包含所有页面在所有语言下的条目及交叉引用:

@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 个页面的站点,站点地图将包含 500 个 URL 条目和 5,500 个 hreflang 链接。站点地图动态生成,缓存时间为一小时。


数据库模式

注意: blakecrosley.com 使用 Cloudflare D1(无服务器 SQLite)通过 HTTP 处理所有持久化数据,而非 SQLAlchemy。本节介绍标准的 SQLAlchemy 异步模式,适用于需要关系型数据库的 FastAPI 项目——这也是该技术栈最常见的生产环境配置。

SQLAlchemy 2.0 异步支持

对于需要关系型数据库的应用,SQLAlchemy 2.0 的异步支持可与 FastAPI 无缝集成:

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase

engine = create_async_engine("sqlite+aiosqlite:///./data.db")
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

class Base(DeclarativeBase):
    pass

数据库会话的依赖注入

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with async_session() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

@router.get("/users/{user_id}")
async def get_user(request: Request, user_id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(404, "User not found")
    return templates.TemplateResponse("pages/user.html", {
        "request": request, "user": user
    })

get_db 依赖负责管理会话生命周期:打开会话,将其传递给路由处理函数,成功时提交事务,异常时回滚。所有数据库操作均使用参数化查询,绝不进行字符串拼接。

Pydantic 集成

Pydantic 模型在 API 边界验证输入,并为模板序列化输出:

from pydantic import BaseModel, EmailStr

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

@router.post("/contact")
async def submit_contact(request: Request, form: ContactForm):
    # form.name, form.email, form.message are validated
    await send_email(form)
    return templates.TemplateResponse("components/_contact_success.html", {
        "request": request
    })

Pydantic 在路由处理函数执行前验证类型、格式(邮箱、URL)及约束条件(最小/最大长度)。无效输入会自动返回 422 响应。这取代了客户端表单验证库——由服务器端完成验证,HTMX 负责将成功消息或错误反馈替换到页面中。

使用 Alembic 管理迁移

Alembic 管理数据库架构变更:

# Generate a migration from model changes
alembic revision --autogenerate -m "add user preferences table"

# Apply migrations
alembic upgrade head

# Roll back one migration
alembic downgrade -1

自动生成功能会将 SQLAlchemy 模型与当前数据库架构进行比对,生成迁移脚本。这些脚本是存放在代码仓库中的版本化 Python 文件:

# alembic/versions/001_add_user_preferences.py
def upgrade():
    op.create_table(
        "user_preferences",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id")),
        sa.Column("locale", sa.String(10), default="en"),
        sa.Column("theme", sa.String(20), default="dark"),
    )

def downgrade():
    op.drop_table("user_preferences")

迁移在部署阶段执行(应用启动之前),确保数据库架构与应用代码保持一致。对于 blakecrosley.com,大部分数据存储在 Cloudflare D1 中(通过 HTTP 访问),因此 Alembic 迁移仅应用于用于会话数据和分析的本地 SQLite 或 PostgreSQL 数据库。

Cloudflare D1 模式

blakecrosley.com 使用 Cloudflare D1 作为远程数据库,通过 Cloudflare Worker 代理进行访问:

class D1Client:
    """HTTP client for Cloudflare D1 via Worker proxy."""

    def __init__(self, worker_url: str, auth_secret: str):
        self.worker_url = worker_url
        self.auth_secret = auth_secret

    async def fetch_all(self, sql: str, params: list = None) -> list[dict]:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.worker_url}/query",
                json={"sql": sql, "params": params or []},
                headers={"Authorization": f"Bearer {self.auth_secret}"},
            )
            return response.json()["results"]

这种模式适用于需要数据库但不想自行管理数据库服务器的应用。D1 本质上是运行在 Cloudflare 边缘网络上的 SQLite,通过 HTTP 访问。Worker 代理负责身份验证和速率限制。其代价是延迟:每次查询都是一次 HTTP 请求(约 50-100 毫秒),而本地数据库连接仅需约 1-5 毫秒。启动时的内存缓存可以缓解读密集型工作负载(如翻译)的延迟问题。


安全

安全头中间件

blakecrosley.com 通过自定义中间件实现了强化的安全头:

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    CSP_DIRECTIVES = {
        "default-src": "'self'",
        "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
        "style-src": "'self' 'unsafe-inline'",
        "img-src": "'self' data: https:",
        "connect-src": "'self'",
        "frame-ancestors": "'self'",
        "base-uri": "'self'",
        "form-action": "'self'",
        "upgrade-insecure-requests": "",
    }

    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "SAMEORIGIN"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        response.headers["Strict-Transport-Security"] = (
            "max-age=31536000; includeSubDomains"
        )
        response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
        response.headers["Content-Security-Policy"] = self.csp
        response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
        return response

CSP 中包含 'unsafe-inline''unsafe-eval',因为 Alpine.js 的表达式求值需要这些指令。替代方案是 Alpine.js 的 CSP 兼容构建版本,但存在一定限制。14 其他安全特性均已严格锁定:frame-ancestors 防止点击劫持,form-action 限制表单仅能提交到同源地址,upgrade-insecure-requests 强制使用 HTTPS。

HTMX 的 CDN 缓存安全

安全头中间件为 HTMX 响应添加 Vary: HX-Request 头:

if request.headers.get("HX-Request"):
    existing_vary = response.headers.get("Vary", "")
    if "HX-Request" not in existing_vary:
        parts = [v.strip() for v in existing_vary.split(",") if v.strip()]
        parts.append("HX-Request")
        response.headers["Vary"] = ", ".join(parts)

如果缺少此头信息,CDN 可能会缓存 HTMX 的片段响应,并将其作为完整页面返回给非 HTMX 请求(反之亦然)。Vary 头指示 CDN 根据 HX-Request 头的值分别存储不同的缓存条目。11

CSRF 防护

HTMX 表单使用无状态的 HMAC 签名 CSRF 令牌:

# csrf.py
def generate_csrf_token() -> str:
    """Token format: timestamp:random:HMAC-SHA256-signature"""
    timestamp = str(int(time.time()))
    random_value = secrets.token_hex(16)
    payload = f"{timestamp}:{random_value}"
    signature = hmac.new(
        CSRF_SECRET.encode(), payload.encode(), hashlib.sha256
    ).hexdigest()
    return f"{payload}:{signature}"

def validate_csrf_token(token: str) -> bool:
    """Verify signature and check expiration (1 hour)."""
    timestamp, random_value, signature = token.split(":")
    if int(time.time()) - int(timestamp) > 3600:
        return False
    expected = hmac.new(
        CSRF_SECRET.encode(),
        f"{timestamp}:{random_value}".encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

令牌通过 Jinja2 全局变量在模板中生成,并包含在 HTMX 表单请求中:

<form hx-post="/contact" hx-target="#form-result">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <!-- form fields -->
</form>

无状态令牌免去了服务器端的会话存储。HMAC 签名确保令牌由服务器生成。时间戳防止重放攻击。hmac.compare_digest 防止计时攻击。15

HTML 清理

用户生成的内容在渲染前通过 nh3 进行清理:

templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}

nh3 库会剥离不在白名单中的标签和属性。链接自动添加 rel="noopener noreferrer"。该防御机制独立于 CSP——它在渲染层阻止存储型 XSS,而 CSP 在浏览器层阻止注入的脚本。纵深防御,层层把关。

输入验证

Pydantic 模型在 API 边界验证所有输入:

from pydantic import BaseModel, Field, EmailStr

class ContactRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: EmailStr
    message: str = Field(..., min_length=10, max_length=5000)

FastAPI 会自动对无效输入返回 422 Unprocessable Entity 响应。结合参数化数据库查询(SQLAlchemy 从不进行字符串拼接),有效防止 SQL 注入并确保边界处的类型安全。


性能优化

Lighthouse 100/100/100/100

blakecrosley.com 在 Lighthouse 四项评分中均获得满分:性能、无障碍、最佳实践和 SEO。可在 PageSpeed Insights 验证。2

核心优化策略如下:

CSS 加载策略

blakecrosley.com 通过单个 <link> 标签加载 CSS,并使用内容哈希 URL 实现不可变缓存:

<link rel="stylesheet" href="{{ asset('css/styles.css') }}">

asset() 辅助函数会附加一个内容哈希值(?v=a3b2c1d4),使浏览器在文件内容变更前可无限期缓存该文件。无需提取关键 CSS,无需 print-media 技巧,无需基于 JavaScript 的加载方式。CSS 文件经 gzip 压缩后仅约 8KB——小到无需任何优化技巧即可在 Lighthouse 性能评分中获得满分。

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%。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 总体积:

大小(压缩后)
HTMX ~14 KB
Alpine.js ~14 KB
页面专属 JS 4-8 KB
合计 32-36 KB

典型的 React 应用在加载业务代码之前,仅框架 JavaScript 就需要 100-300 KB。18 无构建方案之所以 JavaScript 体积更小,正是因为需要传输的 JavaScript 本身就更少。


部署

Railway

blakecrosley.com 通过 git push 部署到 Railway

# railway.toml
[build]
builder = "nixpacks"

[deploy]
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10

Railway 的 Nixpacks 构建器通过 requirements.txt 自动识别 Python 项目,安装依赖并运行启动命令。无需 Docker 文件。健康检查端点确保应用在接收流量前处于正常响应状态:

@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"]

精简版基础镜像保持容器体积小巧。--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 + 宏 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 是否已可用于生产环境的实际 Web 应用?

是的。HTMX 自2020年以来一直保持稳定,已在多个行业的生产环境中广泛使用。其创建者 Carson Gross 将向后兼容性作为核心设计原则——HTMX 文档明确指出,在同一主版本内不会破坏现有应用的兼容性。19 该库压缩后仅14KB,零依赖,遵循语义化版本规范。blakecrosley.com 已在生产环境中运行 HTMX 三年,未出现任何与 HTMX 相关的 bug。

能否在不使用构建步骤的情况下使用 TypeScript?

部分可以。TypeScript 文件可以通过 tsc --noEmit 进行类型检查而不生成输出文件,相当于在编译阶段充当代码检查工具。但浏览器无法直接执行 .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 不执行水合——HTML 即为最终输出。后续交互由 HTMX 向服务器请求新的 HTML 片段来完成。结果是:FastAPI + HTMX 总共只需传输30-40KB的 JavaScript,而 Next.js 应用通常需要100-300KB。18

在此技术栈中如何处理表单验证?

在服务端完成。表单提交时由 Pydantic 验证输入。若验证失败,服务器返回带有错误信息的表单,HTMX 将响应替换到 DOM 中:

<form hx-post="/contact" hx-target="#form-container" hx-swap="outerHTML">
  <input type="email" name="email" required>
  <button type="submit">Send</button>
</form>
@router.post("/contact")
async def contact(request: Request, email: str = Form(...)):
    if not validate_email(email):
        return templates.TemplateResponse("components/_contact_form.html", {
            "request": request,
            "error": "Please enter a valid email address",
            "email": email,  # Preserve input
        })
    await send_email(email)
    return templates.TemplateResponse("components/_contact_success.html", {
        "request": request
    })

服务器负责验证,服务器渲染错误状态,HTMX 替换结果。无需客户端验证库。HTML 的 required 属性可作为第一道防线提供基本的浏览器级验证。

能否添加实时功能(WebSocket)?

可以。FastAPI 内置了 WebSocket 支持:

from fastapi import WebSocket

@app.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await get_notification()
        await websocket.send_text(render_notification_html(data))

HTMX 提供了 WebSocket 扩展(hx-ws),可将元素连接到 WebSocket 端点:

<!-- HTMX 2.x WebSocket extension syntax -->
<div hx-ext="ws" ws-connect="/ws/notifications">
  <div id="notifications" ws-send></div>
</div>

注意: HTMX 1.x 使用 hx-ws="connect:..." 语法。HTMX 2.x 将 WebSocket 支持移至独立扩展(htmx-ext-ws),采用上述 ws-connectws-send 属性。如果使用 HTMX 1.x,旧的 hx-ws 语法仍然有效。

服务器发送的消息通过与 HTTP 响应相同的目标定位和替换机制插入 DOM。服务器通过 WebSocket 发送 HTML 片段,HTMX 负责将其插入页面。

此技术栈如何处理 SEO?

服务端渲染的 HTML 天然对 SEO 友好,因为爬虫无需执行 JavaScript 即可获取完整的页面内容。blakecrosley.com 还在此基础上增加了多层 SEO 优化:

  • JSON-LD 结构化数据——在每个页面的 <head> 中嵌入(Person、Article、WebSite、FAQPage 等 schema)
  • 动态站点地图——包含所有10个语言版本的 hreflang 替代链接
  • RSS 订阅源——位于 /blog/feed.xml
  • llms.txt——位于站点根目录,便于 AI 爬虫发现
  • 规范化 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年4月的生产环境指标。该站点提供100余篇博客文章、交互式 JavaScript 组件、9份综合指南以及9种语言翻译,仅依赖极少量 Python 且完全无需构建工具。数据来源于线上站点及 requirements.txt 验证。 

  2. Google PageSpeed Insights(pagespeed.web.dev)可对任意公开 URL 执行 Lighthouse 审计。截至2026年3月,blakecrosley.com 在性能、无障碍、最佳实践和 SEO 四项均获得 100/100/100/100 满分。结果可公开验证。详见 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 将向后兼容性阐述为 Web 设计原则:”浏览器应当向后兼容。”1996年的页面在2026年的 Chrome 中依然能够正常渲染。详见 w3.org/DesignIssues/Principles。 

  8. OWASP 建议在生产环境中禁用 API 文档端点以减少攻击面。/openapi.json 端点会暴露所有路由定义、参数和响应模型。 

  9. FastAPI 关于异步与同步处理器的文档: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 自定义属性(CSS 变量)在全球97%以上的浏览器中获得支持。它们具备级联、继承以及在运行时响应媒体查询的能力——这些是预处理器变量所不具备的。来源:caniuse.com/css-variables。 

  13. Google 的 hreflang 文档:developers.google.com/search/docs/specialty/international/localized-versionsx-default 值用于指定当用户语言不在 hreflang 列表中时的备选页面。 

  14. Alpine.js 的表达式求值引擎需要在内容安全策略中添加 '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. WebP 在同等视觉质量下比 JPEG 文件体积缩小25-35%。Google 的 WebP 研究报告:developers.google.com/speed/webp/docs/webp_study。 

  17. 103 Early Hints 允许服务器(或 CDN)在最终响应就绪前发送包含预加载提示的初步响应。Cloudflare 支持通过 Link 头部配合 rel=preload 使用 Early Hints。详见 developer.chrome.com/blog/early-hints。 

  18. React 18 + ReactDOM 压缩后(minified + gzipped)约为42 KB。加上路由、状态管理库和构建框架运行时,典型的 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。 

NORMAL fastapi-htmx.md EOF