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

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

# 无需React或webpack即可构建生产级Web应用:FastAPI、HTMX、Alpine.js、Jinja2、原生CSS、Bootstrap模式、i18n、部署、SEO与性能。

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

TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + 原生 CSS 可构建生产级 Web 应用:零构建工具、零 node_modules/,并获得满分 Lighthouse 得分。本指南涵盖从架构到部署的完整体系,并以 blakecrosley.com 作为生产参考。该站点承载 210 篇博客文章、交互式 JavaScript 组件、11 篇核心指南、48 项设计研究,以及英语加 9 个翻译语言区域,全程无需任何打包器、编译器或转译器。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相关章节涵盖该技术栈的标准模式,但未在本站使用。每个论断都有文件路径、配置块或您可在PageSpeed Insights上自行验证的Lighthouse审计。2

如何使用本指南

这是一份全面的参考资料。请根据您的经验水平选择起点:

经验 从这里开始 然后探索
Python开发者,HTMX新手 无构建论点架构概览HTMX深度解析 Alpine.js模式安全性
正在评估替代方案的React/Vue开发者 无构建论点决策框架 架构概览性能
FastAPI开发者添加交互功能 HTMX深度解析Alpine.js模式 i18n与本地化部署
从零开始构建的全栈开发者 架构概览开始顺序阅读 持续使用快速参考卡

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


无构建论点

该论点范围狭窄而具体:对于由独立开发者或小团队维护的内容驱动型站点,构建工具解决的是您并不存在的问题,同时却制造出您实际面临的问题。

以下是来自blakecrosley.com的真实指标:

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

这17个Python包包括FastAPI、Jinja2、Pydantic、uvicorn、nh3以及其他12个。其中没有一个是构建工具,没有一个是编译器,也没有一个是打包器。5

您所放弃的

诚实要求列出真实的代价:

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

没有热模块替换。 CSS更改需要手动刷新浏览器。HTMX的hx-boost使导航足够快,全量刷新可以接受,但在紧凑的视觉迭代周期中,HMR能节省时间。

没有Tree Shaking。 您编写的每一字节JavaScript都会被发送到浏览器。这一约束强制了纪律:小而专注的文件而非大型工具模块。

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

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

对于一到三名开发者的内容驱动型站点而言,这些权衡是可以接受的。但对于拥有15人工程团队的SaaS产品则无法接受。第15节提供了决策框架。

您所获得的

零构建失败。 没有npm install会因同等依赖冲突而失败。没有next build会因您从未触及的文件中的TypeScript错误而失败。6

通过查看源代码进行调试。 浏览器中运行的JavaScript就是您编写的JavaScript。无需源码映射。

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

具体的请求瀑布。 首次访问加载:一个HTML文档(gzip压缩后约15KB)、一个CSS文件(约8KB)、HTMX(约16KB,已缓存)、Alpine.js(约15KB,已缓存),以及该页面的交互式JS(约4-8KB)。总计:首次访问约55-65KB。1

面向未来的前端。 客户端代码使用HTML、CSS和JavaScript——这些标准30年来一直保持向后兼容。7 没有Webpack 4 → 5迁移、没有Create React App弃用、没有Next.js App Router迁移。

技术栈对比

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

维度 FastAPI+HTMX(本指南) Next.js(React) Astro 11ty
发送到浏览器的JS 35-40KB(HTMX+Alpine+小型页面脚本) 85-250KB以上(React运行时) 默认0KB,按需启用孤岛 默认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 有限(表单action) 无(静态输出)
客户端状态管理 Alpine.js(15KB) React state/context/Redux 框架孤岛 手动JS
后端语言 Python JavaScript/TypeScript JavaScript/TypeScript JavaScript
i18n方案 服务器端(中间件) next-intl或类似包 @astrojs/i18n 手动
Lighthouse性能 100(实测) 通常70-904 通常95-100 通常95-100
最适合 内容站点、CRUD、仪表板 复杂SPA、大型团队 内容站点、营销 静态博客、文档

Astro和11ty是内容站点最接近的竞争者。两者都能产出优秀的静态输出,但都需要构建步骤和JavaScript工具链。FastAPI+HTMX技术栈以静态站点性能换取了无需构建步骤的服务器端交互性(分类筛选、表单处理、实时搜索)。如果您的站点纯粹是静态的且没有服务器交互,Astro或11ty可能是更好的选择。


架构概览

请求流程

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

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

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

组件职责

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

项目结构

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

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

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

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

与 SPA 架构的对比

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

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

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

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

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


FastAPI 模式

应用程序设置

应用程序在main.py中初始化,并明确规定 middleware 顺序:

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

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

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

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

# Templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)

这里有3个重要的设计决策。首先,docs_url=Noneopenapi_url=None会禁用自动生成的API文档端点。面向公众的内容网站不需要将/docs/openapi.json暴露到互联网。8其次,middleware 顺序很重要——安全日志最先执行(最后添加),因此可以捕获每个请求,包括被速率限制拒绝的请求。第三,GZipMiddleware会压缩所有超过500字节的响应,通常可将HTML传输大小减少70-80%。

路由

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

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

router = APIRouter()

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

这一区别对HTMX很重要。完整页面路由返回扩展自base.html的文档。API路由返回HTML片段,由HTMX交换进现有 DOM 元素。同一个Jinja2模板引擎负责渲染两者——无需单独的API层。

依赖注入

FastAPI的Depends()系统在路由处理程序与共享逻辑之间提供清晰分离:

from fastapi import Depends, Request

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

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

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

依赖可以组合。get_db依赖可以依赖get_current_locale,而后者又依赖 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类会在启动时验证类型——缺少必填字段会快速失败,而不是等到运行时才出错。

Async 模式

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

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

app = FastAPI(lifespan=lifespan)

Lifespan 现在是唯一的启动/关闭路径。 Starlette 于2026年3月发布首个稳定版本1.0(截至6月12日为1.3.1),并移除了长期弃用的on_eventon_startupon_shutdown钩子——lifespan(见上文)是唯一机制,@app.route()/@app.websocket_route()也改为在routes列表中使用Route/WebSocketRoute。FastAPI 0.137.0(2026年6月14日)将 Starlette 固定到1.x系列,并重构了自身的 router 内部结构:router.routes不再是由APIRoute对象组成的扁平列表,而是一棵由中间节点组成的树,因此应将其视为内部细节,而不是可迭代处理的对象。好处是,在include_router()之后添加到 router 的路由现在会实时反映出来,而且可以先包含 sub-router,再定义它的路由。24这些变化不会改变本指南中的模式——本文始终使用lifespan和标准路由声明——但如果维护的工具会遍历router.routes,或者仍在运行旧式@app.on_event处理程序,那么0.137.0/Starlette 1.0会带来破坏性变更。FastAPI 0.137.2(2026年6月18日)随后加入了iter_route_contexts(),这是在router.routes变为内部结构后枚举路由的受支持方式。FastAPI 0.138.0(2026年6月20日)接着添加了app.frontend("/", directory="dist")/router.frontend(...),用于服务已构建的静态 frontend——如果发布单独的 SPA 构建会很有用,但它与本指南无构建、服务器端渲染的方法相互独立(它挂载的是dist/目录,而不是在服务器上渲染HTML)。25

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通过HTTP使用Cloudflare D1(serverless SQLite)处理所有持久化数据,而不是SQLAlchemy。本节介绍适用于需要关系型数据库的FastAPI项目的标准SQLAlchemy async模式,这是该技术栈最常见的生产环境配置。

SQLAlchemy 2.0 Async

对于需要关系型数据库的应用,SQLAlchemy 2.0的async支持可以与FastAPI顺畅集成:

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

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

class Base(DeclarativeBase):
    pass

安装说明(SQLAlchemy 2.0.50+): 从2.0.50开始,async技术栈的greenlet依赖不再默认安装。请使用asyncio额外依赖以便自动引入,否则第一次对engine执行await时会因缺少greenlet而失败:23

pip install "sqlalchemy[asyncio]" aiosqlite

SQLAlchemy 2.0.50还要求Python 3.10+(已放弃3.7–3.9),并新增了free-threaded(3.13t)wheels。23

数据库会话的依赖注入

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依赖负责管理session生命周期:打开session,将其yield给路由处理器,成功时提交,出现异常时回滚。每个数据库操作都使用参数化查询,绝不使用字符串插值。

Pydantic集成

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

from pydantic import BaseModel, EmailStr

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

@router.post("/contact")
async def submit_contact(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会在路由处理器执行前验证类型、格式(email、URL)和约束(最小/最大长度)。无效输入会自动返回422响应。这取代了客户端表单验证库:服务器负责验证,HTMX则替换为成功消息或错误反馈。

使用Alembic进行迁移

Alembic负责管理数据库schema变更:

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

# Apply migrations
alembic upgrade head

# Roll back one migration
alembic downgrade -1

autogenerate功能会将SQLAlchemy模型与当前数据库schema进行比较,并生成迁移脚本。这些脚本是带版本管理的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")

迁移会在部署期间运行(在应用启动前)。这样可以确保数据库schema与应用代码一致。对于blakecrosley.com,大多数数据存放在Cloudflare D1中(通过HTTP访问),因此Alembic迁移适用于用于session数据和分析的本地SQLite或PostgreSQL数据库。

Cloudflare D1模式

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

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

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

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

这种模式适用于需要数据库但不想管理数据库服务器的应用。D1是在Cloudflare边缘运行的SQLite,通过HTTP访问。Worker代理负责处理身份验证和速率限制。取舍在于延迟:每个查询都是一次HTTP请求(约50-100ms),而本地数据库连接约为1-5ms。启动时的内存缓存可缓解这一问题,尤其适合翻译这类读多写少的工作负载。


安全

安全 Headers Middleware

blakecrosley.com通过自定义 middleware 实现了强化的安全 headers:

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 缓存安全

安全 headers middleware 会为HTMX响应添加Vary: HX-Request

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

没有此 header 时,CDN 可能会缓存HTMX片段响应,并将其作为完整页面提供给非HTMX请求(反之亦然)。Vary header 会告知 CDN 根据HX-Request header 值分别存储缓存条目。11

CSRF 防护

HTMX表单使用无状态 HMAC 签名 CSRF token:

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

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

token 通过Jinja2全局对象在模板中生成,并包含在HTMX表单请求中:

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

无状态 token 消除了服务器端 session 存储需求。HMAC 签名确保 token 由服务器生成。时间戳可防止重放攻击。hmac.compare_digest可防止时序攻击。15

HTML清理

用户生成内容在渲染前会经过nh3处理:

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

nh3库会移除不在允许列表中的标签和属性。链接会自动获得rel="noopener noreferrer"。这项防护独立于 CSP,它在渲染层防止存储型 XSS,而 CSP 则在浏览器层防止注入脚本。二者形成纵深防御。

输入验证

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 的四个类别中均获得100分:Performance、Accessibility、Best Practices和 SEO。可在PageSpeed Insights中验证。2

关键优化如下:

CSS加载策略

blakecrosley.com使用单个<link>标签加载CSS,并采用带内容哈希的 URL 进行不可变缓存:

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

asset()辅助函数会追加内容哈希(?v=a3b2c1d4),因此浏览器会无限期缓存该文件,直到内容发生变化。无需提取关键CSS,无需 print-media 技巧,也无需基于JavaScript的加载。该CSS文件经 gzip 压缩后约为8KB,足够小,因此单请求方案无需复杂优化,也能在 Lighthouse Performance 中获得100分。

GZip 压缩

app.add_middleware(GZipMiddleware, minimum_size=500)

超过500字节的响应会被压缩。HTML通常可压缩70-80%,将15KB文档减少到3-4KB。

不可变静态资源缓存

# In security headers middleware
if request.url.path.startswith("/static/"):
    if os.environ.get("RAILWAY_ENVIRONMENT"):
        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"

带内容哈希 URL(?v=a3f8b2c1d0)的静态资源会使用immutable缓存一年。文件变化时哈希也会变化,从而强制浏览器和 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解析并行下载,但在文档解析完成后执行。这样可以避免阻塞渲染,同时不引入 async 加载和执行顺序管理的复杂性。

图片优化

图片使用 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属性可以防止 Cumulative Layout Shift(CLS)。loading="lazy"属性会延迟加载屏幕外图片。在同等质量下,WebP 文件比 JPEG 小25-35%。16

Early Hints

# In main.py
app.state.preload_links = [
    f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]

# In security headers middleware
if "text/html" in content_type:
    preload_links = getattr(request.app.state, "preload_links", [])
    if preload_links:
        response.headers["Link"] = ", ".join(preload_links)

带有rel=preloadLink header 会告知Cloudflare发送103 Early Hints响应,使浏览器可以在服务器完成生成HTML响应之前,就开始获取CSS。17

最小化JavaScript

总JavaScript占用如下:

大小(minified + gzipped)
HTMX ~16 KB
Alpine.js ~15 KB
页面特定 JS 4-8 KB
总计 35-39 KB

典型 React 应用在应用代码之前,就会先发送100-300 KB的框架JavaScript。18无构建方案发送的JavaScript更少,因为需要发送的JavaScript本来就更少。


部署

Railway

blakecrosley.com 通过 git push 部署到 Railway

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

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

Railway 的 Nixpacks 构建器会根据 requirements.txt 检测到 Python 项目,安装依赖,并运行启动命令。不需要 Dockerfile。健康检查端点会确保应用在接收流量前具备响应能力:

@app.get("/health")
async def health():
    return {"status": "healthy"}

部署流水线

git push origin main
  → Railway detects push
  → Nixpacks installs Python + requirements.txt (cached)
  → uvicorn starts
  → Health check passes
  → Traffic routes to new deployment
  → ~40 seconds total

不需要 npm install。不需要 npm run build。不需要 webpack 编译。不需要 TypeScript 编译。唯一的安装步骤是 pip install -r requirements.txt,并且会在部署之间缓存。

Procfile

web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}

Procfile 提供了与 Heroku 兼容的替代方案。Railway 同时支持 railway.tomlProcfile${PORT:-8000} 语法会使用平台提供的端口;如果没有提供,则在本地开发中默认使用 8000。

Uvicorn 生产配置

对于更高流量的部署,请使用多个 worker:

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port ${PORT:-8000} \
  --workers 4 \
  --loop uvloop \
  --http httptools
  • --workers 4 运行 4 个 worker 进程(通用规则:2 * CPU 核心数 + 1)
  • --loop uvloop 使用速度更快的 uvloop 事件循环(asyncio 的直接替代)
  • --http httptools 使用速度更快的 httptools HTTP 解析器

开发时,--reload 会监视文件变更:

uvicorn app.main:app --reload --port 8000

Docker 替代方案

对于要求使用 Docker 的平台:

FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

slim 基础镜像可以让容器保持较小体积。--no-cache-dir 会阻止 pip 将下载的软件包存储到镜像层中。

Cloudflare CDN

blakecrosley.com 使用 Cloudflare 进行 CDN 缓存、DNS 和 Workers:

# Cache headers for HTML pages (set in security middleware)
response.headers["Cache-Control"] = (
    "public, max-age=300, s-maxage=3600, "
    "stale-while-revalidate=86400"
)
  • max-age=300 — 浏览器缓存 5 分钟
  • s-maxage=3600 — CDN 缓存 1 小时
  • stale-while-revalidate=86400 — 在 24 小时内重新验证时提供过期内容

静态资源使用 max-age=31536000, immutable,因为带内容哈希的 URL 可以保证新鲜度。


决策框架

是否需要构建工具?

回答 4 个问题:

1. 是否有超过 5 名开发者共享 JavaScript 接口? 如果是,TypeScript 的编译期类型检查可以防止集成缺陷,避免等到运行时测试才发现问题。请添加构建步骤。

2. 您的应用是否管理复杂的客户端状态? 如果拖放、实时协作或离线优先数据是核心功能(而不是锦上添花),React 或 Svelte 这类框架的复杂度就是值得的。请添加构建步骤。

3. 是否有多个产品消费共享组件库? 如果是,该库需要 npm 打包、语义化版本控制和 tree shaking。请添加构建步骤。

4. 是否依赖假定存在 bundler 的 npm 生态库? 如果 Radix、Framer Motion、TanStack Query 或类似库是产品的核心组成部分,构建流水线就是必需的。

如果 4 个答案都是“否”,无构建方案就是可行的。如果任一答案是“是”,构建工具就是在解决真实问题。错误在于 4 个答案都是“否”时仍然引入构建工具——一边解决并不存在的问题,一边制造依赖管理开销。1

技术栈对比

类别 无构建(本指南) React + 构建工具
最适合 内容站点、作品集、内部工具、CRUD 应用 SaaS 产品、复杂 SPA、设计系统消费者
团队规模 1-5 名开发者 5-50+ 名开发者
状态管理 服务器(HTMX)+ 客户端(Alpine.js) 客户端(React state、Redux、Zustand)
类型安全 运行时(服务器端 Pydantic) 编译期(TypeScript)
组件复用 Jinja2 includes + macros npm packages、共享库
SEO 默认服务器端渲染 需要 SSR/SSG 配置
性能下限 高(极少 JS、服务器端渲染) 不一(框架开销)
复杂度上限 较低(无离线、无丰富客户端状态) 较高(可实现任何客户端交互)
依赖 17 个 Python 包 300+ 个 npm 包
构建时间 0 秒 15-60 秒

何时不该使用 HTMX

HTMX 用服务器往返替代客户端状态。这在延迟变得重要之前都有效:

  • 拖放界面 — 每个拖动事件都产生 200ms 服务器往返是不可接受的
  • 实时协作 — 由 WebSocket 驱动的状态需要客户端冲突解决
  • 离线优先应用 — 没有服务器就没有 HTMX
  • 与状态绑定的复杂动画 — Framer Motion 和 React Spring 假定使用 React 协调模型
  • 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

FAQ

HTMX是否已具备真实 Web 应用的生产就绪能力?

是的。HTMX自2020年以来一直保持稳定,并已在多个行业的生产环境中使用。其创建者 Carson Gross 将向后兼容性作为核心设计原则维护,HTMX文档也说明,该库不会在同一个主版本内破坏现有应用。19该库压缩并 gzip 后约16KB,零依赖,并遵循语义化版本控制。blakecrosley.com 已在生产环境中运行HTMX三年,期间没有出现任何与HTMX相关的 bug。20

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

部分可以。TypeScript文件可以通过tsc --noEmit进行类型检查,而不生成输出文件,从而像 linter 一样提供编译期检查。不过,浏览器不能直接执行.ts文件,因此要提供TypeScript服务仍然需要构建步骤。另一种做法是在普通.js文件中使用 JSDoc 类型注解,TypeScript可以在不编译的情况下检查这些文件。这样既能在开发期间获得类型安全,又能发布标准JavaScript。

这种方法与 Astro 或 11ty 相比如何?

Astro 和 11ty 是静态站点生成器,会生成普通HTML并搭配极少量客户端JavaScript,但它们需要构建步骤(Node.js、npm install、构建命令)。无构建方法则消除了这一步:服务器会在每次请求时渲染HTML。取舍在于:Astro/11ty 生成的静态页面更快(没有服务器计算),而FastAPI + HTMX可以原生处理动态内容(用户专属数据、表单提交、实时更新),无需单独的API层。

React 的服务器端渲染(SSR)怎么办?

Next.js SSR 与FastAPI + HTMX方法有一个共同目标:向浏览器发送服务器渲染的HTML。区别在于初始渲染之后会发生什么。Next.js 会用 React 对页面进行 hydration,将框架运行时和组件代码发送到客户端。FastAPI + HTMX不会进行 hydration,HTML就是最终输出。后续交互由HTMX通过向服务器请求新的HTML片段来处理。结果是:FastAPI + HTMX总共发送约35-40KB 的JavaScript,而 Next.js 应用通常为100-300KB。18

使用这套技术栈如何处理表单验证?

在服务器端处理。提交表单时,Pydantic 会验证输入。如果验证失败,服务器会返回带有错误消息的表单。HTMX会将响应替换到 DOM 中:

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

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

我可以添加实时功能(WebSocket)吗?

可以。FastAPI内置支持WebSocket:

from fastapi import WebSocket

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

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

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

注意:HTMX 1.x 使用hx-ws="connect:..."语法。HTMX 2.x 将WebSocket支持移到了单独扩展(htmx-ext-ws)中,并使用上面展示的ws-connectws-send属性。如果使用HTMX 1.x,旧的hx-ws语法仍然可用。

HTMX 4.0 beta 轨道:htmx 4.0.0-beta4 现在位于 npm 的next标签和4.0文档中,而 htmx.org 的快速开始以及 npm 的latest标签仍停留在2.0.10。本指南仍然面向HTMX 2.x,在4.0稳定之前,它仍是生产工作的推荐版本;2.x -> 4.x 迁移是一次代际跃迁,而不是2.x 的小版本更新。big-skies-software 的版本模式会跳过奇数主版本,因此4.0是2.x 之后的下一步。2122

4.0文档中值得跟踪的内容。在4.0 GA 之前,有两项新增内容值得进行安全和架构审查:新的hx-live扩展引入了 DOM 响应式表达式,会在引用的状态变化时重新求值;新的hx-nonce扩展则通过 CSP nonce 限制 htmx 属性处理。4.0迁移指南还移动了若干配置概念,恢复或改变了一些事件/历史行为,并从核心中移除了部分JavaScript辅助函数。应将4.0视为一个迁移项目,而不是可直接替换的2.x 补丁。21

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

这套技术栈如何处理 SEO?

服务器渲染的HTML天然有利于 SEO,因为爬虫无需执行JavaScript就能接收到完整页面内容。blakecrosley.com 添加了多层 SEO:

  • 每个页面的<head>中都有JSON-LD 结构化数据(Person、Article、WebSite、FAQPage schema)
  • 动态 sitemap,为全部10种语言区域提供 hreflang alternates
  • 位于/blog/feed.xmlRSS feed
  • 根目录下用于 AI 爬虫可发现性的 llms.txt
  • 基础模板中的规范 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-06-22 FastAPI 0.138.0 + 0.137.2。 0.138.0(6月20日)新增 app.frontend("/", directory="dist") / router.frontend(...),用于提供已构建的静态 frontend(SPA dist/ 输出)——这与本指南“不构建、服务器渲染”的主旨相互独立,已在 Async Patterns 部分作为对照说明。0.137.2(6月18日)新增 iter_route_contexts(),由于 router.routes 自 0.137.0 起已成为内部 API,现在这是枚举路由的受支持方式。两者都是功能新增,没有破坏性变更;Starlette(1.3.1)、Pydantic(2.13.4)、HTMX(2.0.10)、Alpine.js(3.15.12)、Bootstrap(5.3.8)、SQLAlchemy(2.0.51)均未变化。
2026-06-16 FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1。 FastAPI 0.137.0(6月14日)重构了 router 内部实现:router.routes 现在是内部树结构,不再是扁平的 APIRoute 列表(对任何遍历它的代码都是破坏性变更),同时支持在 include_router() 之后添加路由,并新增 APIRouter.matches()/.handle() 钩子;0.137.1(6月15日)修复了 APIRoute 类型标注和空路径无前缀 router。Starlette 发布了首个稳定版 1.0(3月22日),现已到 1.3.1(6月12日),移除了已弃用的 on_event/on_startup/on_shutdown 钩子以及 @app.route()/@app.websocket_route() 装饰器——lifespanRoute/WebSocketRoute 是仅有路径;FastAPI 0.137.0 固定使用 Starlette 1.3.1。已在 Async Patterns 部分添加 lifespan/router 说明。SQLAlchemy 2.0.51(6月15日)仅包含 bug 修复。
2026-06-08 SQLAlchemy 2.0.50 async 安装变更。 从 SQLAlchemy 2.0.50 起,async 栈的 greenlet 依赖不再默认安装——请安装 sqlalchemy[asyncio] extra(否则首次对 engine 执行 await 时会因缺少 greenlet 而失败)。2.0.50 还要求 Python 3.10+(不再支持 3.7–3.9),并新增 free-threaded 3.13t wheels。已在 SQLAlchemy 2.0 Async 部分添加安装说明。其余栈正文无变化:FastAPI 最新版仍为 0.136.3(2026年5月23日,6月没有发布),htmx 稳定版仍为 2.0.10(4.0.0-beta4“The Fetchening”处于 beta,稳定版目标约为 2027 年初,尚不建议用于生产),Alpine.js 3.15.12、Bootstrap 5.3.x 未变化。生产建议不变:在 4.0 稳定前使用 HTMX 2.x。23
2026-05-24 维护检查:本地内容清单仍显示 210 篇博客文章、11 篇核心指南、48 个设计研究,以及包括英语在内的 10 个受支持 locale。FastAPI 最新版为 0.136.3(2026年5月23日);发布说明中唯一提到的应用层重构,是在 convert_underscores=True 时更严格地处理下划线 header,0.136.2 还会校验 Server-Sent Event 字段以避免事件数据损坏。htmx 稳定版仍为 2.0.10,而 npm next 和 4.0 文档现在指向 4.0.0-beta4;SQLAlchemy 2.0 最新版为 2.0.50;Pydantic 最新版仍为 2.13.4。生产建议保持不变:在 4.0 稳定前使用 HTMX 2.x。122
2026-05-18 站点清单刷新:本地内容清单现在显示 210 篇博客文章、11 篇核心指南、48 个设计研究,以及包括英语在内的 10 个受支持 locale。FastAPI 最新版仍为 0.136.1;htmx 稳定版仍为 2.0.10,npm next 为 4.0.0-beta3;Alpine.js npm 最新版仍为 3.15.12。生产建议保持不变:在 4.0 稳定前使用 HTMX 2.x。12021
2026-05-15 维护检查:FastAPI 最新版仍为 0.136.1;此本地站点环境导入的是 FastAPI 0.128.0 和 Starlette 0.50.0;htmx 稳定版仍为 2.0.10,npm next 现在是 4.0.0-beta3;Alpine.js npm 最新版为 3.15.12;Bootstrap 最新版为 5.3.8;SQLAlchemy 2.0 最新版为 2.0.49;Pydantic 最新版为 2.13.4。生产建议不变:在 4.0 稳定前使用 HTMX 2.x。2021
2026-05-09 htmx 4.0.0-beta3 跟踪(2026年5月8日):htmx 4.0.0-beta3 已可通过 npm next 标签和 4.0 文档获取,而 npm latest 仍为 2.0.10。GA 前值得跟踪的重点包括:新的 hx-live 扩展(DOM 响应式表达式)、新的 hx-nonce 扩展(为 htmx 属性提供 CSP nonce 保护),以及迁移指南中对配置、history、事件和核心 JavaScript helpers 的变更。生产建议不变:htmx 2.x 仍是最新 npm 标签对应版本,也是 4.0 GA 前的推荐版本。21
2026-05-07 维护检查:FastAPI 最新版仍为 0.136.1;htmx 稳定版为 2.0.10,v4 仍处于 beta,目标是 2026 年夏季;Alpine.js npm 最新版为 3.15.12;Bootstrap 最新版为 5.3.8;SQLAlchemy 2.0 最新版为 2.0.49;Pydantic 最新版为 2.13.4。站点本地指标已刷新为 182 篇博客文章、11 篇指南、10 个受支持 locale,以及 17 项 Python requirements。迁移建议不变:在 4.0 稳定前,生产环境使用 HTMX 2.x。20
2026-04-25 FastAPI 0.136.1(2026年4月23日):Pydantic v2 弃用项清理(对应用代码没有行为变化)。HTMX 4.0 时间线跟踪:htmx 4.0.0-beta1(4月6日)和 4.0.0-beta2(4月14日)已发布。迁移建议不变——在 4.0 稳定前,htmx 2.x 继续保留在最新 npm 标签上;安全修复仍会持续,没有升级压力。现在值得纳入设计考量的 4.0 主要变化:(1)fetch() 取代 XMLHttpRequest 成为核心 ajax 基础设施,(2)属性继承默认变为显式,(3)history 支持会为恢复内容发起网络请求(不使用本地 DOM 快照)。FastAPI 0.135.4(4月16日)移除了 0.135.3 中加入的愚人节 @app.vibe() 装饰器。
2026-04-16 添加 HTMX 4.0-beta 认知说明(前瞻引用)。注明 FastAPI 0.136.0 支持 Python 3.14t free-threaded 构建。Pydantic 2.13.x 功能(private-attribute default factories 可访问已验证的 model data,pydantic.v1 namespace 升至 1.10.26 并支持 3.14)。Alpine.js 3.15.11 修复:x-anchor.noflip modifier、x-for 多根元素警告、$refs morph 回归修复。
2026-03-24 首次发布

参考文献


本指南涵盖用于构建blakecrosley.com的完整系统。No-Build Manifesto提供了理念层面的论证。Lighthouse Perfect Score一文记录了性能优化历程。Vibe Coding vs. Engineering一文探讨了AI辅助开发在此工作流中的位置。


  1. blakecrosley.com截至2026年5月18日的生产指标。该站点包含210篇博客文章、交互式JavaScript组件、11篇核心指南、48项设计研究、英语加9种翻译语言区域、极少量Python依赖,并且没有任何构建工具。已通过本地内容清单、app/i18n/config.pyrequirements.txt验证。 

  2. Google PageSpeed Insights(pagespeed.web.dev)会对任意公开URL运行Lighthouse审计。截至2026年3月,blakecrosley.com得分为100/100/100/100(性能、无障碍、最佳实践、SEO)。结果可公开验证。完整优化过程请参阅从76到100:实现完美Lighthouse得分。 

  3. 全新的npx create-next-app@latest(Next.js 15,2026年2月测试)会在node_modules/中安装311个包,总计187 MB。带有额外依赖的生产项目通常更高。具体项目各不相同。来源:作者测试,记录于The No-Build Manifesto。 

  4. Vercel的Next.js性能文档建议通过特定优化(图像优化、字体加载、代码拆分)来获得90分以上的成绩。请参阅nextjs.org/docs/app/building-your-application/optimizing。70-90分范围反映的是应用这些优化前的默认设置。 

  5. 完整依赖列表已根据blakecrosley.com截至2026年5月的requirements.txt验证。该文件目前包含17条Python需求条目,且没有构建工具、编译器或打包器。 

  6. 根据作者维护Next.js项目的经验(2021-2024),对于活跃项目,JavaScript生态系统每月会产生15-25个Dependabot PR,其中大多数是在更新开发者从未直接导入过的传递依赖。 

  7. Tim Berners-Lee曾将向后兼容阐述为一项Web设计原则:“浏览器应当向后兼容。”1996年的页面仍可在Chrome 2026中渲染。请参阅w3.org/DesignIssues/Principles。 

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

  9. FastAPI关于async与sync处理程序的文档:fastapi.tiangolo.com/async/。在async函数中将await与阻塞调用混用,会使事件循环饥饿。 

  10. nh3是一个基于Rust的HTML清理器,是Bleach库的后继者。它由PyO3项目维护,并提供基于允许列表的HTML清理。请参阅github.com/messense/nh3。 

  11. Vary标头定义于RFC 9110第12.5.5节。它指示缓存根据指定的请求标头值存储不同响应。如果没有Vary: HX-Request,CDN可能会把HTMX片段作为完整页面响应提供。请参阅httpwg.org/specs/rfc9110.html#field.vary。 

  12. CSS Custom Properties(CSS Variables)已获得全球97%以上浏览器支持。它们会级联、继承,并可在运行时响应媒体查询,这些能力是预处理器变量所不具备的。来源:caniuse.com/css-variables。 

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

  14. Alpine.js的表达式求值引擎要求在Content Security Policy中使用'unsafe-eval'。兼容CSP的构建(@alpinejs/csp)可避免这一要求,但存在限制。请参阅alpinejs.dev/advanced/csp。 

  15. 基于HMAC的CSRF令牌遵循OWASP CSRF Prevention Cheat Sheet中描述的“Signed Double-Submit Cookie”模式。hmac.compare_digest使用常量时间比较,以防止计时侧信道攻击。请参阅cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html。 

  16. 在等同视觉质量下,WebP文件比JPEG小25-35%。Google的WebP研究:developers.google.com/speed/webp/docs/webp_study。 

  17. 103 Early Hints允许服务器(或CDN)在最终响应就绪前,先发送带有preload提示的初步响应。Cloudflare支持对带有rel=preloadLink标头使用Early Hints。请参阅developer.chrome.com/blog/early-hints。 

  18. React 18 + ReactDOM压缩并gzip后约为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。 

  20. 2026年5月15日维护检查。FastAPI PyPIrelease notes列出0.136.1;本网站环境的本地导入验证返回FastAPI 0.128.0和Starlette 0.50.0;htmx.org在快速开始中列出2.0.10;npm view htmx.org version dist-tags返回latest=2.0.10next=4.0.0-beta3npm view alpinejs versionnpm view @alpinejs/csp version返回3.15.12;Bootstrap official blog和npm包元数据列出5.3.8;SQLAlchemy PyPI和文档列出2.0.49;Pydantic PyPI列出2.13.4。 

  21. htmx 4.0.0-beta3包元数据列出发布时间为2026年5月8日,npm next指向4.0.0-beta3;npm latest仍为2.0.10。four.htmx.org上的4.0文档显示[email protected]4.0 extensions index列出hx-livehx-nonce4.0 migration guide记录了迁移变更,在将生产应用从2.x迁移前应先审阅。最新版本线跟踪已由22取代。 

  22. 2026年5月24日维护检查。本地清单命令返回210篇Markdown博客文章、11个顶层指南文件和48个设计研究文件。FastAPI release notes列出0.136.3于2026-05-23发布,当convert_underscores=True时采用更严格的下划线标头处理;0.136.2会验证Server-Sent Event字段。python3 -m pip index versions fastapi返回最新版本0.136.3python3 -m pip index versions sqlalchemy返回最新版本2.0.50python3 -m pip index versions pydantic返回最新版本2.13.4npm view htmx.org dist-tags version time.modified --json返回latest=2.0.10next=4.0.0-beta4time.modified=2026-05-22T15:56:21.948Zfour.htmx.org installation docs显示[email protected]。 

  23. SQLAlchemy 2.0.50 changelogrelease blog,发布于2026-05-24。asyncio的greenlet依赖不再默认安装;现在需要使用sqlalchemy[asyncio]安装目标才能引入它。2.0.50还取消了对Python 3.7/3.8/3.9的支持(现在为3.10+),新增free-threaded Python wheels,并添加over(..., exclude=...)窗口框架参数。截至2026-06-08,已在PyPI验证为最新版本。htmx 4.0.0-beta4(“The Fetchening”,2026-05-22)仍为beta版,稳定版目标为2027年初;FastAPI 0.136.3(2026-05-23)、Alpine.js 3.15.12和Bootstrap 5.3.x在此窗口期内未变化。 

  24. FastAPI release notes:0.137.0(2026-06-14)重构了路由器内部结构,因此router.routes不再是APIRoute对象的扁平列表,而是由中间对象组成的树(应视为内部实现);它还支持在include_router()之后添加路由,包括在子路由定义其路由之前将其加入,避免复制路由,并新增APIRouter.matches()/.handle();固定Starlette 1.3.1。0.137.1(2026-06-15)修复APIRoute类型标注以及无前缀路由器中的空路径。Starlette release notes:1.0.0(2026-03-22)是约8年来的首个稳定版本,移除了on_startup/on_shutdown/on_event()以及@app.route()/@app.websocket_route()装饰器(请使用lifespanRoute/WebSocketRoute);最新版本为1.3.1(2026-06-12)。SQLAlchemy 2.0.51(changelog,2026-06-15)仅包含错误修复,对async或安装没有影响。已于2026-06-16通过PyPI和官方release notes验证。 

  25. FastAPI release notes:0.138.0(2026-06-20)新增app.frontend("/", directory="dist")router.frontend("/", directory="dist"),用于提供已构建的静态前端(PR #15800;Frontend docs)——这是静态dist/ SPA服务功能,而不是服务器端渲染模式;无破坏性变更。0.137.2(2026-06-18)新增iter_route_contexts(),用于此前需要遍历router.routes的高级用法(自0.137.0起属内部实现);无破坏性变更。截至2026-06-22,没有比0.138.0更新的版本。Starlette 1.3.1、Pydantic 2.13.4、Uvicorn 0.49.0、SQLAlchemy 2.0.51、HTMX 2.0.10、Alpine.js 3.15.12、Bootstrap 5.3.8均未变化。已于2026-06-22通过PyPI和官方release notes验证。 

NORMAL fastapi-htmx.md EOF