FastAPI + HTMX:无需构建的全栈方案
# 无需React或webpack即可构建生产级Web应用:FastAPI、HTMX、Alpine.js、Jinja2、原生CSS、Bootstrap模式、i18n、部署、SEO与性能。
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=None和openapi_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_event、on_startup和on_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"(默认)、outerHTML、beforeend |
hx-trigger |
指定触发请求的事件 | hx-trigger="click"、keyup changed delay:300ms、load |
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>
四个属性协同工作:
hx-get发送请求到与href相同的 URL(渐进增强——无 JavaScript 时同样可用)hx-target将响应放入#writing-content容器hx-replace-url="true"更新浏览器 URL 但不添加历史记录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根据状态切换可见性(底层使用 CSSdisplay: none)x-cloak在 Alpine.js 初始化前隐藏元素(防止未样式化内容闪烁)@click通过表达式绑定点击事件处理器:aria-expanded(x-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>
显式的width和height属性可以防止 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=preload的Link 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.toml 和 Procfile。${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-connect和ws-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 之后的下一步。21224.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.xml的 RSS feed - 根目录下用于 AI 爬虫可发现性的
llms.txt - 基础模板中的规范 URL和 Open Graph 标签
- 语义化HTML:
<article>、<section>、<main>以及正确的标题层级
不需要 SSR 配置。不需要getStaticProps。不需要 ISR。HTML会在每次请求时渲染,这是默认行为,而不是优化手段。
与 React 相比,学习曲线如何?
对于Python开发者,学习曲线明显更低。您已经熟悉这门语言。FastAPI的路由处理器返回模板响应,心智模型与 Flask 或 Django 视图相同。HTMX只增加了少量HTML属性(hx-get、hx-target、hx-swap)。Alpine.js再增加一些(x-data、x-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() 装饰器——lifespan 和 Route/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辅助开发在此工作流中的位置。
-
blakecrosley.com截至2026年5月18日的生产指标。该站点包含210篇博客文章、交互式JavaScript组件、11篇核心指南、48项设计研究、英语加9种翻译语言区域、极少量Python依赖,并且没有任何构建工具。已通过本地内容清单、
app/i18n/config.py和requirements.txt验证。 ↩↩↩↩↩ -
Google PageSpeed Insights(pagespeed.web.dev)会对任意公开URL运行Lighthouse审计。截至2026年3月,blakecrosley.com得分为100/100/100/100(性能、无障碍、最佳实践、SEO)。结果可公开验证。完整优化过程请参阅从76到100:实现完美Lighthouse得分。 ↩↩↩
-
全新的
npx create-next-app@latest(Next.js 15,2026年2月测试)会在node_modules/中安装311个包,总计187 MB。带有额外依赖的生产项目通常更高。具体项目各不相同。来源:作者测试,记录于The No-Build Manifesto。 ↩ -
Vercel的Next.js性能文档建议通过特定优化(图像优化、字体加载、代码拆分)来获得90分以上的成绩。请参阅nextjs.org/docs/app/building-your-application/optimizing。70-90分范围反映的是应用这些优化前的默认设置。 ↩↩
-
完整依赖列表已根据blakecrosley.com截至2026年5月的
requirements.txt验证。该文件目前包含17条Python需求条目,且没有构建工具、编译器或打包器。 ↩ -
根据作者维护Next.js项目的经验(2021-2024),对于活跃项目,JavaScript生态系统每月会产生15-25个Dependabot PR,其中大多数是在更新开发者从未直接导入过的传递依赖。 ↩
-
Tim Berners-Lee曾将向后兼容阐述为一项Web设计原则:“浏览器应当向后兼容。”1996年的页面仍可在Chrome 2026中渲染。请参阅w3.org/DesignIssues/Principles。 ↩
-
OWASP建议在生产环境中禁用API文档端点,以减少攻击面。
/openapi.json端点会暴露所有路由定义、参数和响应模型。 ↩ -
FastAPI关于async与sync处理程序的文档:fastapi.tiangolo.com/async/。在
async函数中将await与阻塞调用混用,会使事件循环饥饿。 ↩ -
nh3是一个基于Rust的HTML清理器,是Bleach库的后继者。它由PyO3项目维护,并提供基于允许列表的HTML清理。请参阅github.com/messense/nh3。 ↩
-
Vary标头定义于RFC 9110第12.5.5节。它指示缓存根据指定的请求标头值存储不同响应。如果没有Vary: HX-Request,CDN可能会把HTMX片段作为完整页面响应提供。请参阅httpwg.org/specs/rfc9110.html#field.vary。 ↩↩ -
CSS Custom Properties(CSS Variables)已获得全球97%以上浏览器支持。它们会级联、继承,并可在运行时响应媒体查询,这些能力是预处理器变量所不具备的。来源:caniuse.com/css-variables。 ↩
-
Google的hreflang文档:developers.google.com/search/docs/specialty/international/localized-versions。
x-default值用于指定当用户语言不在hreflang列表中时使用的回退页面。 ↩ -
Alpine.js的表达式求值引擎要求在Content Security Policy中使用
'unsafe-eval'。兼容CSP的构建(@alpinejs/csp)可避免这一要求,但存在限制。请参阅alpinejs.dev/advanced/csp。 ↩ -
基于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。 ↩ -
在等同视觉质量下,WebP文件比JPEG小25-35%。Google的WebP研究:developers.google.com/speed/webp/docs/webp_study。 ↩
-
103 Early Hints允许服务器(或CDN)在最终响应就绪前,先发送带有preload提示的初步响应。Cloudflare支持对带有
rel=preload的Link标头使用Early Hints。请参阅developer.chrome.com/blog/early-hints。 ↩ -
React 18 + ReactDOM压缩并gzip后约为42 KB。加上路由器、状态管理库和构建框架运行时后,典型React应用会交付100-300 KB的框架JavaScript。来源:bundlephobia.com/package/[email protected]。 ↩↩
-
HTMX版本策略和向后兼容承诺记录于htmx.org/migration-guide-htmx-1/。Carson Gross曾在Gross、Stepinski和Cotter合著的Hypermedia Systems(2023)中阐述向后兼容原则:hypermedia.systems。 ↩
-
2026年5月15日维护检查。FastAPI PyPI和release 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.10和next=4.0.0-beta3;npm view alpinejs version和npm view @alpinejs/csp version返回3.15.12;Bootstrap official blog和npm包元数据列出5.3.8;SQLAlchemy PyPI和文档列出2.0.49;Pydantic PyPI列出2.13.4。 ↩↩↩↩ -
htmx 4.0.0-beta3包元数据列出发布时间为2026年5月8日,npm
next指向4.0.0-beta3;npmlatest仍为2.0.10。four.htmx.org上的4.0文档显示[email protected],4.0 extensions index列出hx-live和hx-nonce,4.0 migration guide记录了迁移变更,在将生产应用从2.x迁移前应先审阅。最新版本线跟踪已由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.3;python3 -m pip index versions sqlalchemy返回最新版本2.0.50;python3 -m pip index versions pydantic返回最新版本2.13.4。npm view htmx.org dist-tags version time.modified --json返回latest=2.0.10、next=4.0.0-beta4和time.modified=2026-05-22T15:56:21.948Z;four.htmx.org installation docs显示[email protected]。 ↩↩↩ -
SQLAlchemy 2.0.50 changelog和release 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在此窗口期内未变化。 ↩↩↩ -
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()装饰器(请使用lifespan和Route/WebSocketRoute);最新版本为1.3.1(2026-06-12)。SQLAlchemy 2.0.51(changelog,2026-06-15)仅包含错误修复,对async或安装没有影响。已于2026-06-16通过PyPI和官方release notes验证。 ↩ -
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验证。 ↩