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

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

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

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

摘要: FastAPI + HTMX + Alpine.js + Bootstrap 5 + Jinja2 + 原生 CSS 即可构建生产级 Web 应用——无需构建工具、无需 node_modules/,且能获得满分 Lighthouse 评分。本指南从架构到部署全面覆盖整个技术体系,以 blakecrosley.com 作为生产环境参考案例。该站点承载 37 篇博客文章、20 个交互式 JavaScript 组件、20 篇技术指南以及十种语言的翻译版本,全程未使用任何打包器、编译器或转译器。1

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

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


核心要点

  • 服务器渲染的HTML消除了三大类问题:客户端状态管理、JSON序列化边界以及hydration不匹配。HTMX将服务器响应作为最终输出——无需客户端渲染步骤。
  • 零构建工具意味着零构建失败。不会出现npm install对等依赖冲突,不会在未修改的文件中遭遇TypeScript编译错误,不会收到从未引入的传递依赖的Dependabot PR。部署流程就是git push
  • Alpine.js处理HTMX无法管理的纯客户端状态。下拉菜单、模态框、移动端导航切换,以及所有仅存在于浏览器中的UI状态都归Alpine.js管理。边界清晰明了:需要服务器的状态用HTMX,不需要的用Alpine.js。
  • 原生CSS配合自定义属性可替代Sass和Tailwind。CSS自定义属性支持级联、继承,并在运行时响应媒体查询。预处理器变量编译后变为静态值随即消失。浏览器直接读取自定义属性——无需编译步骤。
  • 这种方案有明确的适用边界。它不适合需要共享组件接口的大型团队、具有复杂客户端状态的SaaS产品,以及依赖npm生态系统库的应用。第15节的决策框架精确界定了这一边界。
  • blakecrosley.com就是最好的证明。本指南中的每个模式都在生产环境中运行。每个论述都有对应的文件路径、配置代码块或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迁移。


架构概览

请求流程

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

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,而后者又依赖于请求对象。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/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 前置元数据中声明依赖项(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、不依赖会话、不依赖客户端 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 实现防抖——在最后一次按键释放后等待 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> 内容并更新 URL——无需整页刷新。浏览器历史记录正常工作(前进/后退按钮可用)。如果 JavaScript 加载失败,链接将作为标准导航链接正常运作。

其优势在于感知性能的提升:增强导航让用户感觉页面切换瞬间完成,因为浏览器无需重新解析 CSS、重新执行脚本或重新渲染布局,仅 <body> 内容发生变化。blakecrosley.com 在主导航中使用了增强链接,使页面切换体验如同单页应用,却无需 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 在可用时提升用户体验。

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


无需 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;
}

原则是:布局机制(外边距、内边距、flexbox)使用 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)中,并在应用启动时加载到内存缓存:

@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 替代链接的站点地图

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

@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 链接。站点地图动态生成,缓存时长为1小时。


数据库模式

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

配合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

关键(首屏)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 总体积:

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

典型的 React 应用在业务代码之外还需加载 100-300 KB 的框架 JavaScript。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 项目,自动安装依赖并执行启动命令。无需 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 运行四个工作进程(经验法则: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 产品、复杂单页应用、设计系统消费者
团队规模 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 是否可以用于生产环境的正式Web应用?

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

可以在没有构建步骤的情况下使用TypeScript吗?

部分可以。TypeScript文件可通过tsc --noEmit进行类型检查而不生成输出文件,相当于将编译时检查用作代码检查工具。但浏览器无法直接执行.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对页面进行水合(hydrate),将框架运行时和组件代码发送到客户端。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属性提供基本的浏览器级别验证,作为第一道防线。

可以添加实时功能(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端点:

<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等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年3月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审计。截至2026年3月,blakecrosley.com 的评分为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的requirements.txt验证,截至2026年3月。零个包属于构建工具、编译器或打包器。 

  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的表达式求值引擎要求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支持带rel=preloadLink头部Early Hints。详见developer.chrome.com/blog/early-hints。 

  19. React 18 + ReactDOM压缩后约42 KB(minified + gzipped)。加上路由器、状态管理库和构建框架运行时,典型的React应用会交付100-300 KB的框架JavaScript。来源:bundlephobia.com/package/[email protected]。 

  20. HTMX的版本策略和向后兼容性承诺记录于htmx.org/migration-guide-htmx-1/。Carson Gross在Hypermedia Systems(2023)一书中阐述了向后兼容性原则,该书由Gross、Stepinski和Cotter合著:hypermedia.systems。 

NORMAL fastapi-htmx.md EOF