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 中初始化,并明确指定中间件顺序:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware
app = FastAPI(
title="Blake Crosley",
docs_url=None, # Disable docs in production
redoc_url=None,
openapi_url=None, # Prevent /openapi.json exposure
)
# Middleware order matters: last added = first executed
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(LocaleMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityLogMiddleware, site_name="blakecrosley.com")
# Static files
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)
这里有三项重要的设计决策。首先,docs_url=None 和 openapi_url=None 会禁用自动生成的 API 文档端点。面向公众的内容网站无需将 /docs 或 /openapi.json 暴露在互联网上。8 其次,中间件顺序至关重要——安全日志最先执行(最后添加),因此能捕获每一个请求,包括被速率限制拒绝的请求。第三,GZipMiddleware 会压缩超过500字节的响应,通常可将 HTML 传输大小减少70%-80%。自 Starlette 1.5.0 起,它不再压缩所有内容:默认排除列表现在会跳过已压缩的二进制负载(gzip 和 zip 归档文件、PNG、JPEG、WebP、GIF、AVIF、audio/*、video/*、WOFF 和 WOFF2 字体,以及 text/event-stream),这正是理想行为——重新压缩 PNG 只会消耗 CPU,却让文件略微变大。请注意,该列表有意不排除 image/*,因此 image/svg+xml 仍会被压缩。可通过仅限关键字参数 exclude_content_types 覆盖此设置。28
路由
路由分为两类:页面路由返回完整的 HTML 文档,而 API 路由返回 JSON 或 HTML 片段。
# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/about")
async def about(request: Request):
templates = request.app.state.templates
return templates.TemplateResponse("pages/about.html", {
"request": request,
"page_title": "About — Blake Crosley",
"page_description": "Designer, developer, dad.",
})
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
# Parse answers, compute next question or result
question = get_next_question(quiz_id, answers)
templates = request.app.state.templates
return templates.TemplateResponse("components/_quiz_step.html", {
"request": request,
"question": question,
"answers": answers,
"step": len(answers.split(",")) if answers else 0,
})
这种区别对 HTMX 很重要。完整页面路由返回继承 base.html 的文档。API 路由返回 HTML 片段,由 HTMX 将其替换到现有 DOM 元素中。两者均由同一个 Jinja2 模板引擎渲染——无需单独的 API 层。
依赖注入
FastAPI 的 Depends() 系统可清晰地分离路由处理函数与共享逻辑:
from fastapi import Depends, Request
def get_templates(request: Request):
"""Get templates from app state."""
return request.app.state.templates
def get_current_locale(request: Request) -> str:
"""Get locale from middleware-set request state."""
return getattr(request.state, "locale", "en")
@router.get("/blog/{slug}")
async def blog_post(
request: Request,
slug: str,
templates=Depends(get_templates),
locale: str = Depends(get_current_locale),
):
post = load_post_by_slug(slug)
if not post:
raise HTTPException(404, "Post not found")
return templates.TemplateResponse("pages/blog/post.html", {
"request": request,
"post": post,
"locale": locale,
})
依赖项可以组合。get_db 依赖项可以依赖于 get_current_locale,后者又依赖请求。FastAPI 会自动解析这条依赖链。
Pydantic 设置
配置使用 Pydantic 的 BaseSettings,并遵循环境变量优先级:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
D1_WORKER_URL: str = ""
D1_AUTH_SECRET: str = ""
CLOUDFLARE_ACCOUNT_ID: str = ""
CLOUDFLARE_API_TOKEN: str = ""
ANALYTICS_PASSKEY: str = ""
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
环境变量会覆盖 .env 文件中的值。在生产环境(Railway)中,应将密钥设置为环境变量。本地开发时,.env 文件可提供默认值。Settings 类会在启动时验证类型——缺少必填字段会立即失败,而不是等到运行时才出错。
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(截至8月8日为1.6.0),并移除了长期弃用的 on_event、on_startup 和 on_shutdown 钩子——lifespan(如上所示)是唯一机制,@app.route() / @app.websocket_route() 也被 routes 列表中的 Route / WebSocketRoute 取代。FastAPI 0.137.0(2026年6月14日)重构了自身的路由器内部实现:router.routes 不再是由 APIRoute 对象组成的扁平列表,而是一棵包含中间节点的树,因此应将其视为内部实现细节,而非可迭代对象。好处是,在 include_router() 之后 添加到路由器的路由现在会实时生效,子路由器也可在其路由定义前被包含。FastAPI 本身并未将 Starlette 限制在1.x版本线:自0.136.3以来,其运行时要求始终只是最低版本 starlette>=0.46.0,直到0.140.7仍保持不变——没有上限,Starlette 0.4x 依然满足要求。0.137.0 发布说明中的1.x版本号,是 dependabot 对仓库自身测试锁定文件的更新,并非对您应用程序的运行时约束。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(...),用于提供已构建的静态前端——若您发布独立的 SPA 构建会很有用,但与本指南无构建、服务器端渲染的方案并不相干(它挂载的是 dist/ 目录,而不是在服务器端渲染 HTML)。25 FastAPI 0.139.0(2026年7月1日)通过 app.frontend() 中的依赖支持 对其进行了扩展——例如为所提供的前端自动进行 Cookie 身份验证——将您在 API 路由上使用的同一套 Depends() 机制引入静态前端挂载。26 FastAPI 0.141.0(2026年7月29日)新增 app.frontend(check_dir="auto"),当构建目录尚不存在时,可避免 fastapi dev 启动失败——这是在运行前端构建前先启动服务器的常见情形。同日发布的 FastAPI 0.141.1 修复了 app.frontend() 中依赖项会悄然丢弃后台任务和响应头的问题:设置 Cookie 或安排 BackgroundTask 的依赖项,在前端挂载时会丢失这些操作,而在 API 路由上则可正常运行。如果您已采用0.139.0的依赖支持,0.141.1 才是让它与应用其余部分行为一致的版本。29
FastAPI 0.140.0 终结了自2025年11月以来每个版本都存在的内存回归问题——请升级。 2026年7月24日的发布仅包含一次重构,却产生了显著影响。FastAPI 为每个路由依赖图中每个节点构建的内部对象 Dependant,自0.121.0(2025年11月3日)起逐步累积了 functools.cached_property 属性——到0.139.2时已有10个。缓存属性需要每个实例都有一个 __dict__ 来写入结果,因此成本会在应用中每个图的每个节点上不断放大。PR #16049 将该逻辑从类中移至模块级辅助函数(_get_cache_key()、_get_oauth_scopes()、_uses_scopes()),并将 Dependant 声明为 @dataclass(slots=True),使其仅保留为纯数据容器。FastAPI 在合并后 PR 上运行的 CodSpeed 表明,test_dependency_graph 内存基准测试从 17.5 MB → 1.1 MB,降低 ×16;促成此项工作的报告描述了一个生产服务在0.120.4下占用不足约400 MB,却在0.121.3上发生 OOM。此后本指南推荐的每个版本——0.137.x、0.138.0、0.139.2——都包含该问题。如果您的应用有深或宽的依赖树(嵌套 Depends()、安全方案、多个包含的路由器),0.140.0 无需修改任何应用程序代码即可带来内存收益。27
0.140.0 只是开端,并非完整修复——请固定到0.140.7或更高版本。 该版本发布3天后,即2026年7月27日,FastAPI 在5个半小时内连续发布了另外7个版本:0.140.1至0.140.7,全部都是对同一套依赖机制的重构。这项工作分为两部分。第一部分是扁平化依赖树:FastAPI 过去会构建并保留每个路由依赖图的扁平副本,0.140.2 停止保留它;0.140.3、0.140.5、0.140.6 和0.140.7 则移除了其余会重建该副本的位置——OpenAPI 生成、请求体字段、请求参数,以及再次处理 OpenAPI。0.140.4 移除了跟踪重复依赖项但从未被读取的簿记信息。第二部分,也是唯一存在可见阈值的更改:0.140.1 将 fastapi/dependencies/models.py 中可调用对象分类辅助函数的 lru_cache 从1,024提升至4,096个条目,并使用命名常量 _CALLABLE_CLASSIFICATION_CACHE_SIZE,因为用户反馈拥有超过1,024个不同依赖项的应用会导致缓存频繁失效。这里没有任何改动会影响您调用的 API,因此升级只需更新版本。两点注意事项值得直言不讳:该版本线的发布节奏意味着它仍在演进,因此应阅读发布说明,而不要假定0.140.7已是终点;并且 FastAPI 在同一时期加入了衡量这些工作的 OpenAPI 依赖基准测试(PR #16075),因此公开数据涵盖的是最后几个版本,而非完整的7个版本历程。30
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。启动时的内存缓存可缓解这一问题,尤其适合翻译这类读多写少的工作负载。
安全性
限制请求体大小
Starlette 1.6.0新增了max_body_size,补齐了此技术栈此前缺失的控制机制:如果没有它,客户端可以向应用持续流式传输无限大的请求体,最终使内存成为故障点。可在Starlette、Router、Mount或单个Route上设置该值,也可以使用RequestBodyLimitMiddleware包装任意ASGI应用。嵌套路由能够提高或降低全局限制,因此上传端点可以放宽限制,而其他端点仍保持严格。
app = FastAPI(lifespan=lifespan)
app.router.max_body_size = 2 * 1024 * 1024 # 2 MB default for the whole app
该限制按ASGI服务器实际接收的字节数计算,其中包括multipart文件数据;Content-Length仅作为快速失败检查——缺失或虚报较小的请求头都无法绕过限制。默认值为None,即不限制大小,因此需要主动启用。28
安全响应头中间件
blakecrosley.com通过自定义中间件实施强化的安全响应头:
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
CSP_DIRECTIVES = {
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data: https:",
"connect-src": "'self'",
"frame-ancestors": "'self'",
"base-uri": "'self'",
"form-action": "'self'",
"upgrade-insecure-requests": "",
}
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = self.csp
response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
return response
由于Alpine.js需要'unsafe-inline'和'unsafe-eval'来进行表达式求值,因此CSP包含这两项。另一种选择是Alpine.js的CSP兼容构建版本,但它存在一些限制。14其他功能均受到严格限制:frame-ancestors可防止点击劫持,form-action将表单提交限制为同源,upgrade-insecure-requests会强制使用HTTPS。
使用HTMX时的CDN缓存安全性
安全响应头中间件会向HTMX响应添加Vary: HX-Request:
if request.headers.get("HX-Request"):
existing_vary = response.headers.get("Vary", "")
if "HX-Request" not in existing_vary:
parts = [v.strip() for v in existing_vary.split(",") if v.strip()]
parts.append("HX-Request")
response.headers["Vary"] = ", ".join(parts)
如果没有此响应头,CDN可能会缓存HTMX片段响应,并将其作为完整页面提供给非HTMX请求(反之亦然)。Vary响应头会告知CDN根据HX-Request响应头的值分别存储缓存条目。11
CSRF防护
HTMX表单使用无状态、经HMAC签名的CSRF令牌:
# csrf.py
def generate_csrf_token() -> str:
"""Token format: timestamp:random:HMAC-SHA256-signature"""
timestamp = str(int(time.time()))
random_value = secrets.token_hex(16)
payload = f"{timestamp}:{random_value}"
signature = hmac.new(
CSRF_SECRET.encode(), payload.encode(), hashlib.sha256
).hexdigest()
return f"{payload}:{signature}"
def validate_csrf_token(token: str) -> bool:
"""Verify signature and check expiration (1 hour)."""
timestamp, random_value, signature = token.split(":")
if int(time.time()) - int(timestamp) > 3600:
return False
expected = hmac.new(
CSRF_SECRET.encode(),
f"{timestamp}:{random_value}".encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
令牌通过Jinja2全局变量在模板中生成,并包含在HTMX表单请求中:
<form hx-post="/contact" hx-target="#form-result">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- form fields -->
</form>
无状态令牌无需服务器端会话存储。HMAC签名可确保令牌由服务器生成。时间戳可防止重放攻击。hmac.compare_digest可防止时序攻击。15
HTML净化
用户生成的内容会在渲染前经过nh3处理:
templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}
nh3库会移除不在允许列表中的标签和属性。链接会自动获得rel="noopener noreferrer"。这一防御措施独立于CSP:它在渲染层防止存储型XSS,而CSP在浏览器层防止注入脚本。纵深防御。
输入验证
Pydantic模型会在API边界验证所有输入:
from pydantic import BaseModel, Field, EmailStr
class ContactRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr
message: str = Field(..., min_length=10, max_length=5000)
FastAPI会自动为无效输入返回422 Unprocessable Entity。结合参数化数据库查询(SQLAlchemy从不插入字符串),这既可防止SQL注入,也能确保边界处的类型安全。
性能
Lighthouse 100/100/100/100
blakecrosley.com在Lighthouse的4个类别中均获得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字节的响应会被压缩,但不包括Starlette 1.5.0引入的默认内容类型排除项(归档文件、图像、音频、视频、字体、SSE)。HTML可压缩70-80%,将15KB文档缩减至3-4KB。28
不可变静态资源缓存
# 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缓存1年。文件变更时,哈希也会随之变化,从而强制浏览器和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响应头会告知Cloudflare发送103 Early Hints响应,使浏览器能够在服务器完成生成HTML响应之前开始获取CSS。17
最小化JavaScript
JavaScript总体积如下:
| 库 | 大小(压缩并经gzip处理后) |
|---|---|
| HTMX | ~16 KB |
| Alpine.js | ~15 KB |
| 页面专用JS | 4-8 KB |
| 总计 | 35-39 KB |
典型React应用在应用代码之前就会交付100-300KB的框架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 解析器
每个 worker 都是独立进程,分别持有应用程序的一份副本,因此每个进程的内存用量会随 worker 数量成倍增加——这正是 FastAPI 0.140.0 依赖关系图修复发挥作用之处:对于依赖项繁多的应用程序,0.139.2 版本中的 4 个 worker 会将旧版 Dependant 开销承担 4 次。27
开发时,--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. 是否依赖那些以打包器为前提的 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 include + 宏 | npm 软件包、共享库 |
| 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
常见问题
HTMX 是否已达到可用于实际 Web 应用的生产就绪状态?
是。HTMX 自 2020 年以来一直保持稳定,并已在多个行业的生产环境中投入使用。其创建者 Carson Gross 将向后兼容性作为核心设计原则——HTMX 文档明确指出,在同一主版本内,该库不会破坏现有应用程序。19 该库压缩并经 gzip 处理后约为 16KB,零依赖,并遵循语义化版本控制。blakecrosley.com 已在生产环境中运行 HTMX 三年,期间未出现任何与 HTMX 相关的错误。20
可以在没有构建步骤的情况下使用 TypeScript 吗?
部分可以。可使用 tsc --noEmit 对 TypeScript 文件进行类型检查,而不生成输出文件,从而像使用代码检查工具一样获得编译期检查。不过,浏览器无法直接执行 .ts 文件,因此提供 TypeScript 时仍然需要构建步骤。另一种方案是在普通 .js 文件中使用 JSDoc 类型注解,TypeScript 无需编译即可检查这些注解。这样既能在开发过程中获得类型安全,又能交付标准 JavaScript。
这种方法与 Astro 或 11ty 相比如何?
Astro 和 11ty 都是静态网站生成器,可生成仅含少量客户端 JavaScript 的普通 HTML,但它们需要构建步骤(Node.js、npm install 和构建命令)。无构建方法则彻底省去这一步——服务器会在每次请求时渲染 HTML。其取舍在于:Astro/11ty 生成的静态页面速度更快(无需服务器计算),而 FastAPI + HTMX 无需单独的 API 层,即可原生处理动态内容(用户专属数据、表单提交和实时更新)。
使用 React 进行服务器端渲染(SSR)又如何?
Next.js SSR 与 FastAPI + HTMX 方法目标一致:向浏览器发送服务器渲染的 HTML。两者的区别在于首次渲染后的处理方式。Next.js 使用 React 对页面进行水合,并将框架运行时和组件代码发送到客户端。FastAPI + HTMX 不进行水合——HTML 就是最终输出。后续交互由 HTMX 处理,它会向服务器请求新的 HTML 片段。最终,FastAPI + HTMX 交付的 JavaScript 总量约为 35-40KB,而 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-beta6 现已发布至 npm 的
next标签,并提供 4.0 文档(beta6 发布于 2026 年 7 月 23 日);与此同时,htmx.org 的快速入门指南和 npm 的latest标签仍停留在 2.0.10。本指南仍以 HTMX 2.x 为目标版本。在 4.0 稳定之前,2.x 仍是生产工作的推荐版本;从 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) - 动态站点地图,为全部 10 个区域设置提供 hreflang 替代版本
- 位于
/blog/feed.xml的 RSS 订阅源 - 根目录下的
llms.txt,用于提升 AI 爬虫的可发现性 - 基础模板中的规范 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、服务器组件和流式 SSR。
对于 JavaScript/React 开发者而言,这种转变更多是概念上的,而非语法上的。核心理念是由服务器持有状态,并由服务器渲染 HTML。客户端状态管理转变为服务器端路由处理;客户端数据获取转变为 HTML 元素上的 HTMX 属性。语法更加简单——但思维模式的转变需要摒弃“由客户端负责渲染”这一 SPA 固有假设。
更新日志
| 日期 | 变更 | 来源 |
|---|---|---|
| 2026-08-16 | Starlette 1.3.1 → 1.6.0,其中一项变更悄然改变了本指南自身的 GZip 代码片段。 行为变更:Starlette 1.5.0(8月8日)将 DEFAULT_EXCLUDED_CONTENT_TYPES 的范围从 text/event-stream 大幅扩展到 gzip/zip 压缩包、PNG、JPEG、WebP、GIF、AVIF、audio/*、video/* 以及 WOFF/WOFF2 字体,因此默认不再压缩这些内容;image/* 则被有意保留在排除范围之外,因此 image/svg+xml 仍可压缩。新的仅关键字参数 exclude_content_types 可覆盖该列表,匹配不区分大小写,并且在运行时重新赋值模块常量不再生效。FastAPI 的运行时版本约束为 starlette>=0.46.0,没有上限,因此全新安装会拉取 1.6.0,这项变更无需读者采取任何操作便会影响本指南中的代码片段——两处 GZip 段落均已修正。1.5.0 还会跳过状态码为 206 的部分响应,并针对每个流式分块执行刷新;1.4.0(8月5日)则会将达到或超过 128 KiB thread_minimum_size 的 gzip 分块转交工作线程处理,使大规模压缩不再阻塞事件循环。新增能力:1.6.0(8月8日)在 Starlette/Router/Mount/Route 上新增 max_body_size,并提供 RequestBodyLimitMiddleware——新增的“安全”小节对此进行了说明,因为此前本指南完全未涵盖请求体限制。另有说明:encode/starlette 现会重定向至 Kludex/starlette,与 Uvicorn 的迁移保持一致。仅更新日志:Uvicorn 0.52.0–0.52.3(基于 Zig 的实验性 zttp HTTP/1.1 实现,发布说明明确表示不应将其置于生产流量前;本指南关于 --http httptools 的建议依然有效)、Alpine.js 3.16.0/3.16.1、SQLAlchemy 2.0.52(Python 3.15 支持;ORM UPDATE 的 synchronize_session="fetch" 结果列错位修复)。已验证未变:FastAPI 0.141.1、HTMX 2.0.10 最新版/4.0.0-beta6 后续版、Bootstrap 5.3.8、Jinja2 3.1.6、Pydantic 2.13.4 稳定版。窗口期内,这九项依赖均无新增安全公告。 |
28 |
| 2026-07-29 | FastAPI 0.141.0 + 0.141.1(均为7月29日)。0.141.0 新增 app.frontend(check_dir="auto"),因此在构建目录不存在时,fastapi dev 不再失败——这正是前端构建尚未运行便启动服务器的常见情况。数小时后的 0.141.1 修复了 app.frontend() 中依赖项丢弃后台任务和响应头的问题;某个依赖项设置 Cookie 或调度 BackgroundTask 时,该工作会在前端挂载中被丢弃,而在 API 路由上表现正确,因此此修复填补了 0.139.0 所引入依赖支持中的实际缺口。两项变更均纳入现有的 app.frontend() 叙述,而非另设小节,因为本指南的服务端渲染方案不会挂载 dist/ 目录。0.141.1 还在 FastAPI CLI 指南中记录了 FASTAPI_ENV(仅文档变更,正文不变)。 |
29 |
| 2026-07-27 | FastAPI 在7月27日的5个半小时内发布了 0.140.1 至 0.140.7——共 7 个版本,均为对 0.140.0 所启动依赖机制重构的延续。主要有两条线:FastAPI 过去构建并保留的扁平依赖图副本现已移除(0.140.2),以及所有仍会重建该副本的位置——OpenAPI 生成(0.140.3、0.140.7)、请求体字段(0.140.5)、请求参数(0.140.6)——0.140.4 则删除了不再读取的重复跟踪簿记。唯一带有可见阈值的变更是 0.140.1:fastapi/dependencies/models.py 中可调用对象分类辅助函数的 lru_cache 从 1,024 个条目提升至 4,096 个(名为 _CALLABLE_CLASSIFICATION_CACHE_SIZE),此前有报告称,应用超过 1,024 个不同依赖时会导致该缓存频繁抖动。没有 API 变更;依赖内存段落中的建议从 0.140.0 调整为 0.140.7 或更高版本,并说明该版本线仍在变动,且 OpenAPI 依赖基准测试(PR #16075)直到该系列最后一个版本才合入。 |
30 |
| 2026-07-25 | FastAPI 0.140.0(7月24日,21:16 UTC)修复了一项自 0.121.0(2025年11月3日)起便在线上存在的依赖系统内存回归。PR #16049 从 Dependant 中移除 10 个 functools.cached_property 属性,将其迁移至模块级辅助函数,并将该类改为 @dataclass(slots=True);已合并 PR 的官方 CodSpeed 运行结果显示,test_dependency_graph 内存基准从 17.5 MB 降至 1.1 MB(×16),最初的报告则描述了 0.121.3 在生产中 OOM,而 0.120.4 可维持在约 400 MB 以下。已在“异步模式”中新增 0.140.0 段落,并在“Uvicorn 生产配置”中补充了工作进程内存说明。同时修正了一处既有错误:本指南曾声称 0.137.0“将 Starlette 固定在 1.x 版本线”——事实并非如此。FastAPI 的运行时要求为 starlette>=0.46.0(仅有下限、无上限,Starlette 0.4x 仍满足要求),并且在 0.136.3、0.137.0、0.138.0、0.139.2 和 0.140.0 中保持一致;0.137.0 说明中的 1.x 版本号是对仓库测试锁文件的 dependabot 升级(PR #15722 仅涉及 uv.lock)。正文断言和 24 均已修复。另标注了两项未变更:Dependant 内部实现如今会对工具造成破坏性影响(oauth_scopes、cache_key、_uses_scopes、_is_security_scheme 不再作为属性存在,改由 _get_oauth_scopes() / _get_cache_key() / _uses_scopes() 模块函数替代,且 slots=True 会阻止实例 monkey-patching)——这是本指南从未引用的未文档化内部 API,与 0.137.0 的 router.routes 变更属于同一类别;以及 FastAPI 的官方文档现已在包括 README、index.md、virtual-environments.md 和 Docker/部署页面在内的 30 个文件中,默认使用 uv 项目而非 pip/venv(PR #16032,7月21日合并)。这项文档变更不会影响您的代码,但本指南始终讲授 pip install -r requirements.txt,如今已与上游入口不同——这是未来的编辑决策,本次有意不作处理。 |
27 |
| 2026-07-24 | htmx 4.0.0-beta6 取代 beta5 成为 npm 的 next 标签(发布于2026年7月23日;GitHub 于同日发布)。该 beta 的重点项目包括:新的 hx-multipart 扩展(流式 multipart/mixed/multipart/parallel 响应,并为每个部分提供 HX-* 操作头)、通过 Navigation API 实现历史滚动位置恢复及 Firefox 回退、beta 内部事件重命名 htmx:swap:finally → htmx:finally:swap、HX-Trigger 响应头事件现会在交换后触发、自定义请求方法,以及携带 protocols 转发的 hx-ws 重写。建议不变——生产环境仍使用 HTMX 2.x(latest = 2.0.10),直至 4.0 GA;该重命名仅在 4.0 beta 版本线内具有破坏性。beta 跟踪说明和 21 已更新。FastAPI 0.139.2、Uvicorn 0.51.0、Alpine.js 3.15.12、Starlette 1.3.1、Jinja2 3.1.6 均已验证未变;窗口期内零安全公告。 |
|
| 2026-07-17 | FastAPI 0.139.1 + 0.139.2(7月16日):修复 app.frontend() 回退路径中的点号问题(/users/john.doe,PR #16011),以及为并行线程测试提供线程安全的路由构建(PR #16013)——没有面向应用的 API 变更。Uvicorn 0.49.0 → 0.51.0:旧版 websockets 实现已弃用,auto 现默认使用 websockets-sansio(0.50.0);默认实现要求 websockets>=13.0(0.50.2);0.51.0(7月8日)新增带重叠期的 SIGHUP 工作进程重启,以实现接近零停机的重载;仓库现位于 Kludex/uvicorn。HTMX(2.0.10 / 4.0.0-beta5 next)、Alpine.js 3.15.12、Starlette 1.3.1、Pydantic 2.13.4、SQLAlchemy 2.0.51、Bootstrap 5.3.8 均已验证未变;窗口期内零安全公告。 |
|
| 2026-07-07 | htmx 4.0.0-beta5 现为 npm next 标签(发布于2026年6月26日),取代 beta4;HTMX 4.0 beta 跟踪说明和 [^22] 已相应更新。建议不变——生产工作仍应使用 HTMX 2.x(latest = 2.0.10),直至 4.0 GA。已根据 htmx.org npm dist-tags 验证。 |
|
| 2026-07-02 | FastAPI 0.139.0(7月1日)。app.frontend() 现支持 dependencies——例如为所提供的前端自动进行 Cookie 身份验证(PR #15908)——通过标准 Depends() 机制扩展了 0.138.0 的静态前端挂载;这仍与本指南服务端渲染的主旨相互独立,并在同一对比段落中说明。技术栈没有其他变动:HTMX 2.0.10、Alpine.js 3.15.12、Bootstrap 5.3.8、SQLAlchemy 2.0.51 均未变。 |
26 |
| 2026-06-22 | FastAPI 0.138.0 + 0.137.2。0.138.0(6月20日)新增 app.frontend("/", directory="dist") / router.frontend(...),用于提供已构建的静态前端(SPA dist/ 输出)——这与本指南无构建的服务端渲染主旨相互独立,并在“异步模式”小节中作为对比说明。0.137.2(6月18日)新增 iter_route_contexts(),作为枚举路由的受支持方式,因为自 0.137.0 起 router.routes 已成为内部实现。两者均为功能新增,没有破坏性变更;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)均未变。 |
25 |
| 2026-06-16 | FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1。FastAPI 0.137.0(6月14日)重构路由器内部实现:router.routes 现为内部树结构,而非扁平的 APIRoute 列表(对任何迭代它的代码均属破坏性变更),同时支持在 include_router() 后添加路由,以及新增 APIRouter.matches()/.handle() 钩子;0.137.1(6月15日)修复了 APIRoute 类型标注和空路径无前缀路由器的问题。Starlette 于3月22日发布首个稳定版 1.0,目前为 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——已于2026-07-25 修正:并非如此;运行时要求为 starlette>=0.46.0,没有上限。)已在“异步模式”小节中补充生命周期/路由器说明。SQLAlchemy 2.0.51(6月15日)仅包含错误修复。 |
24 |
| 2026-06-08 | SQLAlchemy 2.0.50 async 安装变更。自 SQLAlchemy 2.0.50 起,async 技术栈的 greenlet 依赖不再默认安装——请安装 sqlalchemy[asyncio] extra(否则首次对引擎执行 await 会因缺少 greenlet 而失败)。2.0.50 还要求 Python 3.10+(不再支持 3.7–3.9),并新增自由线程 3.13t wheels。已在 SQLAlchemy 2.0 Async 小节中添加安装说明。技术栈其余部分正文没有变更:FastAPI 最新版仍为 0.136.3(2026-05-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 个受支持语言区域。FastAPI 最新版为 0.136.3(2026-05-23);发行说明中唯一指出的面向应用重构,是当 convert_underscores=True 时更严格的下划线请求头处理;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 个受支持语言区域。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 保护),以及迁移指南中对配置、历史记录、事件和核心 JavaScript 辅助函数的变更。生产建议不变: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 个受支持语言区域以及 17 项 Python 要求。迁移指引不变:生产环境在 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日)均已发布。迁移指引不变——htmx 2.x 在 4.0 稳定前仍处于最新 npm 标签;安全修复持续提供,无需急于升级。现在值得围绕其进行设计的主要 4.0 变更包括:(1)fetch() 取代 XMLHttpRequest 成为核心 ajax 基础设施,(2)属性继承默认改为显式,(3)历史记录支持会为恢复的内容发出网络请求(不再使用本地 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 自由线程构建。Pydantic 2.13.x 功能(使用已验证模型数据访问私有属性默认工厂、pydantic.v1 命名空间升级至支持 3.14 的 1.10.26)。Alpine.js 3.15.11 修复:x-anchor.noflip 修饰符、x-for 多根元素警告、$refs morph 回归修复。 |
|
| 2026-03-24 | 首次发布 |
参考资料
本指南涵盖了用于构建blakecrosley.com的完整系统。No-Build Manifesto阐述了其哲学依据。Lighthouse Perfect Score文章记录了性能优化历程。Vibe Coding vs. Engineering文章探讨了AI辅助开发在此工作流中的定位。
-
截至2026年5月18日的blakecrosley.com生产指标。该网站拥有210篇博客文章、交互式JavaScript组件、11份核心指南、48项设计研究,提供英语及另外9种已翻译的语言区域版本,Python依赖极少,且不使用任何构建工具。已根据本地内容清单、
app/i18n/config.py和requirements.txt完成验证。 ↩↩↩↩↩ -
Google PageSpeed Insights(pagespeed.web.dev)可针对任何公开URL运行Lighthouse审计。截至2026年3月,blakecrosley.com在性能、无障碍功能、最佳实践和SEO四项中均获得100/100的评分。结果可公开验证。完整优化历程请参阅从76到100:实现满分Lighthouse评分。 ↩↩↩
-
全新的
npx create-next-app@latest(Next.js 15,测试于2026年2月)会在node_modules/中安装311个包,总计187 MB。包含额外依赖的生产项目通常会更高。各个项目会有所不同。来源:作者测试,记录于无构建宣言。 ↩ -
Vercel的Next.js性能文档建议采用特定优化措施(图像优化、字体加载、代码拆分)以实现超过90分的评分。请参阅nextjs.org/docs/app/building-your-application/optimizing。70-90分的范围反映了尚未应用这些优化前的默认设置。 ↩↩
-
截至2026年5月,已从blakecrosley.com的
requirements.txt验证完整依赖列表。该文件目前有17项Python依赖条目,且没有构建工具、编译器或打包器。 ↩ -
根据作者维护Next.js项目(2021-2024)的经验,活跃项目中的JavaScript生态系统每月会产生15-25个Dependabot PR,其中大多数更新的是开发者从未直接导入的传递依赖。 ↩
-
Tim Berners-Lee将向后兼容性阐述为一项Web设计原则:“浏览器应当向后兼容。”1996年的页面可在2026年的Chrome中渲染。请参阅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自定义属性(CSS变量)受到全球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)在最终响应准备就绪前,先发送包含预加载提示的初步响应。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/react-dom@18.2.0。 ↩↩
-
HTMX的版本控制策略和向后兼容性承诺记录于htmx.org/migration-guide-htmx-1/。Carson Gross在Gross、Stepinski和Cotter合著的Hypermedia Systems(2023)中阐述了向后兼容性原则:hypermedia.systems。 ↩
-
2026年5月15日维护检查。FastAPI PyPI和发行说明列出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官方博客和npm包元数据列出5.3.8;SQLAlchemy PyPI和文档列出2.0.49;Pydantic PyPI列出2.13.4。 ↩↩↩↩ -
htmx 4.0.0-beta6是当前npm的
next标签(发布于2026年7月23日;beta系列于2026年5月8日为beta3,随后依次为beta4、beta5、beta6),而npm的latest仍为2.0.10。four.htmx.org上的4.0文档跟踪next构建,4.0扩展索引列出hx-live和hx-nonce,而4.0迁移指南记录了在将生产应用从2.x迁移前应审查的变更。已于2026年7月24日根据htmx.orgnpm dist-tags完成验证。 ↩↩↩↩↩↩ -
2026年5月24日维护检查。本地清单命令返回210篇Markdown博客文章、11个顶层指南文件和48个设计研究文件。FastAPI 发行说明列出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安装文档显示htmx.org@4.0.0-beta4。 ↩↩ -
SQLAlchemy 2.0.50更新日志和发布博客,发布于2026-05-24。asyncio
greenlet依赖不再默认安装;现在必须使用sqlalchemy[asyncio]安装目标才能引入它。2.0.50还停止支持Python 3.7/3.8/3.9(现为3.10+),新增自由线程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 发行说明:0.137.0(2026-06-14)重构了路由器内部结构,因此
router.routes不再是由APIRoute对象组成的扁平列表,而是中间对象树(应视为内部实现);它还支持在include_router()之后添加路由,包括在定义子路由器的路由之前添加子路由器,避免复制路由,并新增APIRouter.matches()/.handle()。它并未将Starlette固定到1.x:FastAPI的运行时要求是starlette>=0.46.0——仅有下限,没有上限——0.136.3、0.137.0、0.138.0、0.139.2和0.140.0中均完全一致,已于2026-07-25根据PyPI JSON API中的requires_dist元数据完成验证。0.137.0说明中“将starlette从1.1.0升级到1.2.1”这一行(PR #15722)是Internal下的dependabot更新,仅涉及仓库的uv.lock测试锁定文件。(早期确实存在上限——0.120.4和0.121.0使用starlette<0.50.0,>=0.40.0——但到0.136.3时已移除。)修正于2026-07-25应用;此前本脚注和正文中的表述均有误。0.137.1(2026-06-15)修复了APIRoute类型标注以及无前缀路由器中的空路径。Starlette发行说明: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(更新日志,2026-06-15)仅包含错误修复,不影响async或安装。已于2026-06-16通过PyPI和官方发行说明验证。 ↩↩↩ -
FastAPI 发行说明:0.138.0(2026-06-20)新增
app.frontend("/", directory="dist")和router.frontend("/", directory="dist"),用于提供已构建的静态前端(PR #15800;Frontend文档)——这是用于提供静态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和官方发行说明验证。 ↩↩ -
FastAPI 0.139.0发行说明,2026年7月1日:“支持
app.frontend()中的依赖,例如用于前端自动Cookie认证”(PR #15908)。其余发行内容为翻译、文档和依赖升级;没有破坏性变更。当前会话于2026年7月2日(PST)验证:0.139.0是GitHub发行页面上的最新版本。 ↩↩ -
FastAPI 0.140.0发行说明,发布于2026年7月24日21:16 UTC(PyPI
upload_time_iso_8601为2026-07-24T21:16:42Z)。唯一的重构条目是“⚡️ 减少依赖中的内存使用。PR #16049”(合并于2026-07-24T21:07:52Z)。该回归由PR #14262引入(合并于2025-11-03),同日随0.121.0发布;该PR向Dependant.cache_key添加了functools.cached_property;到0.139.2时,该类已有10个@cached_property定义。在0.140.0中,fastapi/dependencies/models.py声明@dataclass(slots=True) class Dependant,并将逻辑移至模块级的_get_cache_key()、_get_oauth_scopes()、_uses_scopes()和_is_security_scheme()——源代码已在tag 0.140.0验证。合并PR上的CodSpeed机器人报告,test_dependency_graph内存基准测试由17.5 MB(base)降至1.1 MB(head),“性能提升×16”;0.140.0还新增CI内存基准测试(PR #16046),以防止其再次回归。最初的报告见讨论#14742,其中0.120.4保持在约400 MB以下,而0.121.3在生产环境中发生OOM。工具作者请注意:Dependant.oauth_scopes、.cache_key、._uses_scopes和._is_security_scheme不再作为属性存在,且slots=True会阻止对实例进行monkey-patching——这是本指南未使用的、未记录的内部API,与0.137.0的router.routes变更属于同一类别。所有事实已于2026-07-25根据PyPI、GitHub API和带标签的源代码再次验证。 ↩↩↩ -
Starlette发布了1.4.0(2026-08-05)、1.5.0(2026-08-08)和1.6.0(2026-08-08)。1.5.0的标题是“此版本致力于让
GZipMiddleware获得更多关注”,并列出“向GZipMiddleware添加exclude_content_types参数”“为每个流式块刷新GZip输出”“在GZipMiddleware中跳过对部分响应的压缩”以及“扩展GZipMiddleware中默认排除的内容类型”。排除元组和签名直接读取自starlette/middleware/gzip.py:DEFAULT_EXCLUDED_CONTENT_TYPES= application/gzip、application/x-gzip、application/zip、audio/、font/woff、font/woff2、image/avif、image/gif、image/jpeg、image/png、image/webp、text/event-stream、video/——以及def __init__(self, app, minimum_size=500, compresslevel=9, thread_minimum_size=128*1024, *, exclude_content_types=DEFAULT_EXCLUDED_CONTENT_TYPES)。1.6.0在Starlette/Router/Mount/Route上新增max_body_size,并新增RequestBodyLimitMiddleware。全部内容均于2026-08-16获取并验证。 ↩↩↩↩ -
FastAPI 0.141.0(2026-07-29,14:47 UTC)新增
app.frontend(check_dir="auto"),用于通过fastapi dev进行本地开发(PR #16102)。FastAPI 0.141.1(2026-07-29,17:17 UTC)修复了app.frontend()中对后台任务以及来自依赖的标头的支持(PR #16105),并在FastAPI CLI指南中记录了FASTAPI_ENV(PR #16104)。两者均由@tiangolo完成。已于2026-07-29确认PyPI最新版本为0.141.1。 ↩↩ -
FastAPI发布了0.140.1至0.140.7,均于2026-07-27的12:07至17:34 UTC之间发布(PyPI
upload_time_iso_8601:0.140.1为12:07:51Z,0.140.2为14:15:38Z,0.140.3为15:30:52Z,0.140.4为15:46:49Z,0.140.5为16:02:53Z,0.140.6为16:31:48Z,0.140.7为17:34:47Z)。每个版本的正文均只有一条重构条目:0.140.1“更新依赖的lru_cache限制,以适应大型应用”(PR #16062);0.140.2“停止保留扁平依赖树”(PR #16065);0.140.3“避免在OpenAPI中重复扁平化依赖”(PR #16067);0.140.4“跳过未使用依赖的重复记录”(PR #16069);0.140.5“避免为请求体字段扁平化依赖”(PR #16071);0.140.6“避免为请求参数扁平化依赖,主要针对OpenAPI”(PR #16073);0.140.7“避免为OpenAPI扁平化依赖”(PR #16076)。缓存数据来自#16062差异,该差异将fastapi/dependencies/models.py中的3个@lru_cache(maxsize=1024)装饰器替换为@lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE),并更新tests/test_dependency_models.py以断言cache_info.maxsize == 4096;PR正文指出:“一些用户报告依赖数量超过1024,这应能适应更大型的应用。”0.140.2还新增内存基准测试(PR #16064),0.140.7新增OpenAPI依赖基准测试(PR #16075),因此基准测试覆盖范围晚于该系列的大多数版本。已于2026-07-27根据GitHub发行API、PR差异和PyPI验证;0.140.7在撰写时是最新版本。 ↩↩