FastAPI + HTMX:无需构建的全栈方案
# 无需React或webpack,即可使用FastAPI、HTMX、Alpine.js、Jinja2、原生CSS、Bootstrap模式、i18n、部署、SEO和性能优化技术构建生产级Web应用。
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)
这里有3项重要的设计决策。首先,docs_url=None和openapi_url=None会禁用自动生成的API文档端点。面向公众的内容网站无需将/docs或/openapi.json暴露在互联网上。8其次,中间件顺序至关重要——安全日志中间件最先执行(最后添加),因此能够捕获每个请求,包括被速率限制拒绝的请求。最后,GZipMiddleware会压缩所有超过500字节的响应,通常可将HTML传输大小减少70%至80%。
路由
路由分为两类:页面路由返回完整的HTML文档,API路由则返回JSON或HTML片段。
# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/about")
async def about(request: Request):
templates = request.app.state.templates
return templates.TemplateResponse("pages/about.html", {
"request": request,
"page_title": "About — Blake Crosley",
"page_description": "Designer, developer, dad.",
})
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
# Parse answers, compute next question or result
question = get_next_question(quiz_id, answers)
templates = request.app.state.templates
return templates.TemplateResponse("components/_quiz_step.html", {
"request": request,
"question": question,
"answers": answers,
"step": len(answers.split(",")) if answers else 0,
})
这一区别对HTMX至关重要。完整页面路由返回继承base.html的文档。API路由返回HTML片段,由HTMX将其替换到现有DOM元素中。两者都由同一个Jinja2模板引擎渲染,无需单独的API层。
依赖注入
FastAPI的Depends()系统可将路由处理程序与共享逻辑清晰分离:
from fastapi import Depends, Request
def get_templates(request: Request):
"""Get templates from app state."""
return request.app.state.templates
def get_current_locale(request: Request) -> str:
"""Get locale from middleware-set request state."""
return getattr(request.state, "locale", "en")
@router.get("/blog/{slug}")
async def blog_post(
request: Request,
slug: str,
templates=Depends(get_templates),
locale: str = Depends(get_current_locale),
):
post = load_post_by_slug(slug)
if not post:
raise HTTPException(404, "Post not found")
return templates.TemplateResponse("pages/blog/post.html", {
"request": request,
"post": post,
"locale": locale,
})
依赖项可以组合。get_db依赖项可以依赖get_current_locale,而后者又依赖请求。FastAPI会自动解析整条依赖链。
Pydantic 设置
配置使用Pydantic的BaseSettings,并以环境变量为优先:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
D1_WORKER_URL: str = ""
D1_AUTH_SECRET: str = ""
CLOUDFLARE_ACCOUNT_ID: str = ""
CLOUDFLARE_API_TOKEN: str = ""
ANALYTICS_PASSKEY: str = ""
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
环境变量会覆盖.env文件中的值。在生产环境(Railway)中,密钥通过环境变量设置;在本地,则由.env文件提供默认值。Settings类会在启动时验证类型——缺少必填字段时立即失败,而不是等到运行时才暴露问题。
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日)重构了自身的路由器内部结构: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后,其行为才会与应用的其余部分保持一致。28
FastAPI 0.140.0终结了自2025年11月以来每个版本都存在的内存回归问题——请升级。 2026年7月24日发布的这个版本仅包含一次重构,效果却举足轻重。Dependant是FastAPI为每条路由依赖图中的每个节点构建的内部对象。自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,减少至原来的1/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个版本的完整演进过程。29
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 解析器
每个 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-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所新增依赖项支持中的实际缺口。由于本指南采用服务器端渲染方式,并不会挂载 dist/ 目录,因此两项变更均纳入现有的 app.frontend() 说明,而非另设新章节。0.141.1还在 FastAPI CLI 指南中记录了 FASTAPI_ENV(仅文档变更,正文无需修改)。 |
28 |
| 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)直到该系列最后一个版本才合入。 |
29 |
| 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版本线”——事实并非如此。在0.136.3、0.137.0、0.138.0、0.139.2和0.140.0中,FastAPI 的运行时要求始终是 starlette>=0.46.0(只有下限,没有上限,Starlette 0.4x仍满足要求);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 会阻止实例级猴子补丁)——这是本指南从未引用的未公开内部 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 响应标头事件改为在交换后触发;支持自定义请求方法;以及重写 hx-ws 并转发 protocols。建议不变——在4.0正式发布前,生产环境继续使用 HTMX 2.x(latest = 2.0.10);此次重命名仅在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] 已同步更新。建议不变——在4.0正式发布前,生产环境继续使用 HTMX 2.x(latest = 2.0.10)。该信息已根据 htmx.org 的 npm dist-tags核实。 |
|
| 2026-07-02 | FastAPI 0.139.0(7月1日)。app.frontend() 现在支持依赖项——例如,为所提供的前端自动应用 cookie 身份验证(PR #15908)——这项变更在0.138.0静态前端挂载功能的基础上加入了标准 Depends() 机制;它仍与本指南服务器端渲染的主旨相互独立,因此在同一对比段落中予以说明。技术栈其他部分没有变化: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(),在 router.routes 自0.137.0起成为内部实现后,将其作为枚举路由的受支持方式。两者均为功能新增,没有破坏性变更;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,没有上限。)已在“异步模式”一节中添加 lifespan/路由器说明。SQLAlchemy 2.0.51(6月15日)仅包含错误修复。 |
24 |
| 2026-06-08 | SQLAlchemy 2.0.50异步安装变更。从 SQLAlchemy 2.0.50开始,异步技术栈所需的 greenlet 依赖项不再默认安装——请安装 sqlalchemy[asyncio] 额外依赖(否则首次针对引擎执行 await 时会因缺少greenlet而失败)。2.0.50还要求 Python 3.10+(不再支持3.7–3.9),并新增自由线程3.13t wheel。已在“SQLAlchemy 2.0异步”一节添加安装说明。技术栈其余部分的正文无需变更: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会验证服务器发送事件字段,以避免事件数据损坏。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。正式发布前值得跟踪的亮点包括:新的 hx-live 扩展(DOM响应式表达式)、新的 hx-nonce 扩展(通过 CSP nonce保护 htmx属性),以及迁移指南中针对配置、历史记录、事件和核心 JavaScript 辅助函数的变更。生产建议不变:在4.0正式发布前,htmx 2.x仍是 npm 的 latest 标签,也是推荐版本。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日)均已发布。迁移指导不变——在4.0稳定版发布前,htmx 2.x继续占用 npm latest 标签;安全修复仍在继续,目前没有升级压力。现在设计时值得考虑的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 命名空间更新至1.10.26并支持3.14。Alpine.js 3.15.11修复包括:x-anchor.noflip 修饰符、x-for 多根元素警告,以及 $refs 变形回归修复。 |
|
| 2026-03-24 | 首次发布 |
参考资料
本指南介绍了构建blakecrosley.com所采用的完整系统。无构建宣言阐述了其背后的理念。Lighthouse满分一文记录了性能优化历程。氛围编程与工程实践一文探讨了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的得分为100/100/100/100(性能、无障碍、最佳实践、SEO)。结果可公开验证。完整优化历程请参阅从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依赖项条目,不包含任何构建工具、编译器或打包器。 ↩ -
根据作者在2021至2024年维护Next.js项目的经验,对于活跃项目,JavaScript生态系统每月会生成15至25个Dependabot PR,其中大多数用于更新开发者从未直接导入的传递依赖项。 ↩
-
Tim Berners-Lee曾将向后兼容阐述为一项Web设计原则:“浏览器应当向后兼容。”1996年的页面至今仍可在Chrome 2026中渲染。请参阅w3.org/DesignIssues/Principles。 ↩
-
OWASP建议在生产环境中禁用API文档端点,以缩小攻击面。
/openapi.json端点会暴露所有路由定义、参数和响应模型。 ↩ -
FastAPI关于async与同步处理程序的文档: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。 ↩↩ -
全球超过97%的浏览器支持CSS自定义属性(CSS变量)。它们能够层叠和继承,并可在运行时响应媒体查询,而预处理器变量不具备这些能力。来源:caniuse.com/css-variables。 ↩
-
Google的hreflang文档:developers.google.com/search/docs/specialty/international/localized-versions。
x-default值用于指定回退页面,供语言不在hreflang列表中的用户访问。 ↩ -
Alpine.js的表达式求值引擎要求在内容安全策略中启用
'unsafe-eval'。兼容CSP的构建版本(@alpinejs/csp)不需要此设置,但存在一定限制。请参阅alpinejs.dev/advanced/csp。 ↩ -
基于HMAC的CSRF令牌遵循OWASP CSRF防护速查表中所述的“签名双重提交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日维护检查。FastAPIPyPI和发行说明列出的版本为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日;该测试版分支从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.org的npm dist-tags核实。 ↩↩↩↩↩↩ -
2026年5月24日维护检查。本地清单命令返回210篇Markdown博客文章、11个顶层指南文件和48个设计研究文件。FastAPI发行说明列出2026年5月23日发布的0.136.3;当
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年5月24日。asyncio的
greenlet依赖项不再默认安装;现在必须使用sqlalchemy[asyncio]安装目标才能将其一并安装。2.0.50还放弃了对Python 3.7、3.8和3.9的支持(现要求3.10及以上版本),增加了自由线程Python wheel,并为over(..., exclude=...)增加了窗口框架参数。截至2026年6月8日,已在PyPI核实为最新版本。htmx 4.0.0-beta4(“The Fetchening”,2026年5月22日)仍处于测试阶段,稳定版计划于2027年初发布;在此期间,FastAPI 0.136.3(2026年5月23日)、Alpine.js 3.15.12和Bootstrap 5.3.x均无变化。 ↩↩↩ -
FastAPI发行说明:0.137.0(2026年6月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年7月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年7月25日;本脚注先前的措辞及正文声明均有误。0.137.1(2026年6月15日)修复了APIRoute类型标注以及无前缀路由器中的空路径问题。Starlette发行说明:1.0.0(2026年3月22日)是其约8年来首个稳定版本,移除了on_startup、on_shutdown、on_event()以及@app.route()、@app.websocket_route()装饰器(请改用lifespan和Route/WebSocketRoute);最新版本为1.3.1(2026年6月12日)。SQLAlchemy 2.0.51(变更日志,2026年6月15日)仅包含错误修复,对async或安装方式没有影响。已于2026年6月16日通过PyPI和官方发行说明核实。 ↩↩↩ -
FastAPI发行说明:0.138.0(2026年6月20日)新增
app.frontend("/", directory="dist")和router.frontend("/", directory="dist"),用于提供已构建的静态前端(PR #15800;前端文档)——这是用于提供静态dist/SPA的功能,并非服务器渲染模式;没有破坏性变更。0.137.2(2026年6月18日)为此前需要遍历router.routes的高级用法新增iter_route_contexts()(自0.137.0起,后者属于内部实现);没有破坏性变更。截至2026年6月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年6月22日通过PyPI和官方发行说明核实。 ↩↩ -
FastAPI0.139.0发行说明,2026年7月1日:“支持在
app.frontend()中使用依赖项,例如为前端提供自动Cookie身份验证”(PR #15908)。该版本其余内容为翻译、文档和依赖项升级;没有破坏性变更。当前会话于2026年7月2日(PST)验证:0.139.0是GitHub发行页面上的最新版本。 ↩↩ -
FastAPI0.140.0发行说明,发布于2026年7月24日21:16 UTC(PyPI
upload_time_iso_8601为2026-07-24T21:16:42Z)。唯一的重构条目是“⚡️ 减少依赖项的内存使用。PR #16049”(合并于2026年7月24日21:07:52 UTC)。该性能回退由PR #14262引入(合并于2025年11月3日),并于同日发布的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()——源代码已通过0.140.0标签核实。已合并PR上的CodSpeed机器人报告显示,test_dependency_graph内存基准从17.5 MB(基础版本)降至1.1 MB(当前版本),“性能提升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会阻止对实例进行猴子补丁——这是本指南未使用的未记录内部API,与0.137.0中的router.routes变更属于同一类别。所有事实均已于2026年7月25日根据PyPI、GitHub API和对应标签的源代码重新核实。 ↩↩↩ -
FastAPI 0.141.0(2026年7月29日14:47 UTC)为使用
fastapi dev的本地开发新增了app.frontend(check_dir="auto")(PR #16102)。FastAPI 0.141.1(2026年7月29日17:17 UTC)修复了app.frontend()对后台任务及依赖项所返回标头的支持(PR #16105),并在FastAPI CLI指南中记录了FASTAPI_ENV(PR #16104)。两者均由@tiangolo发布。已于2026年7月29日确认PyPI最新版本为0.141.1。 ↩↩ -
FastAPI从0.140.1到0.140.7的版本均发布于2026年7月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的差异内容:该PR将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年7月27日根据GitHub发行版API、PR差异内容和PyPI核实;撰写本文时,0.140.7是最新版本。 ↩↩