表单自动序列化
htmx 自动将表单字段序列化为请求数据,规则与原生 HTML 表单一致:
- 所有有
name属性的表单控件都会被包含 - 未选中的 checkbox 不发送(选中的发送
name=on) - 未选中的 radio 不发送
disabled的元素不发送- GET 请求:序列化为 URL 查询字符串
- POST/PUT/PATCH:序列化为
application/x-www-form-urlencoded请求体
<!-- 完整的注册表单 -->
<form
hx-post="/api/register"
hx-target="#form-result"
hx-swap="outerHTML"
>
<input type="text" name="username" required/>
<input type="email" name="email" required/>
<input type="password" name="password" required/>
<select name="role">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
<input type="checkbox" name="agree" value="1"/> 同意条款
<button type="submit">注册</button>
</form>
<div id="form-result"></div>
<!-- 非表单元素触发时,也能携带表单数据 -->
<div id="editor">
<input name="title" value="文章标题"/>
<textarea name="content">正文内容</textarea>
<!-- 这个按钮不在 form 内,但通过 hx-include 包含上面的字段 -->
<button
hx-post="/api/save-draft"
hx-include="#editor input, #editor textarea"
>保存草稿</button>
</div>
hx-include:包含额外字段
当触发请求的元素不在表单内,或需要包含来自其他表单/页面的字段时,hx-include 通过 CSS 选择器指定要包含的表单元素:
<!-- 包含特定 id 的整个表单 -->
<button
hx-post="/api/action"
hx-include="#my-form"
>提交</button>
<!-- 包含 closest(向上查找)的表单 -->
<div class="form-container">
<input name="filter" value="active"/>
<button
hx-get="/api/items"
hx-include="closest .form-container"
hx-target="#item-list"
>筛选</button>
</div>
<!-- 常见:搜索时包含分页和排序参数 -->
<input
type="search"
name="q"
hx-get="/api/search"
hx-include="#sort-select, #per-page-select"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
/>
<select id="sort-select" name="sort">
<option value="date">按日期</option>
<option value="name">按名称</option>
</select>
<select id="per-page-select" name="per_page">
<option value="10">10条/页</option>
<option value="50">50条/页</option>
</select>
hx-vals:注入静态参数
hx-vals 允许在 HTML 中直接注入额外的参数,值为 JSON 格式。这些参数会与表单数据合并后发送:
<!-- 注入静态参数 -->
<button
hx-post="/api/like"
hx-vals='{"post_id": 42, "action": "like"}'
>点赞</button>
<!-- 配合列表项,每个项注入不同的 id -->
<ul>
<li>
<span>文章一</span>
<button
hx-delete="/api/posts"
hx-vals='{"id": 1}'
hx-target="closest li"
hx-swap="outerHTML"
>删除</button>
</li>
</ul>
<!-- 动态值(使用 js: 前缀执行 JavaScript)-->
<button
hx-post="/api/track"
hx-vals='js:{"ts": Date.now(), "page": location.pathname}'
>记录行为</button>
文件上传
文件上传需要将编码类型设为 multipart/form-data,使用 hx-encoding 属性:
<!-- 文件上传表单 -->
<form
hx-post="/api/upload"
hx-target="#upload-result"
hx-encoding="multipart/form-data"
hx-indicator="#upload-progress"
>
<input type="file" name="file" accept="image/*"/>
<input type="text" name="description" placeholder="图片描述"/>
<button type="submit">上传</button>
</form>
<div id="upload-progress" class="htmx-indicator">
上传中,请稍候...
</div>
<div id="upload-result"></div>
<!-- 拖拽上传(需要少量 JS) -->
<div
id="drop-zone"
class="drop-zone"
hx-post="/api/upload"
hx-encoding="multipart/form-data"
hx-target="#upload-result"
>
拖拽文件到此处上传
</div>
<script>
const zone = document.getElementById('drop-zone');
zone.addEventListener('drop', function(e) {
e.preventDefault();
const formData = new FormData();
formData.append('file', e.dataTransfer.files[0]);
htmx.ajax('POST', '/api/upload', {
source: zone,
values: formData,
target: '#upload-result'
});
});
</script>
# FastAPI 文件上传处理
from fastapi import UploadFile, File, Form
@app.post("/api/upload")
async def upload(
file: UploadFile = File(...),
description: str = Form("")
):
# 验证文件类型
if file.content_type not in ["image/jpeg", "image/png", "image/webp"]:
return HTMLResponse(
'<p class="error">只支持 JPEG/PNG/WebP 格式</p>',
status_code=422
)
# 验证文件大小(5MB)
content = await file.read()
if len(content) > 5 * 1024 * 1024:
return HTMLResponse(
'<p class="error">文件大小不能超过 5MB</p>',
status_code=422
)
url = await save_file(content, file.filename)
return HTMLResponse(f"""
<div class="upload-success">
<img src="{url}" alt="{description}">
<p>上传成功:{file.filename}</p>
</div>
""")
服务端验证与错误展示
htmx 推荐的验证方式是服务端验证 + HTML 错误片段,无需前端 JavaScript 验证逻辑:
<!-- 带有错误展示区的表单 -->
<form
id="signup-form"
hx-post="/api/signup"
hx-target="#signup-form"
hx-swap="outerHTML"
>
<div class="field">
<label>邮箱</label>
<input type="email" name="email" value="" required/>
<!-- 服务器返回错误时,这里显示错误提示 -->
</div>
<button type="submit">注册</button>
</form>
@app.post("/api/signup")
async def signup(request: Request, email: str = Form(...)):
errors = {}
# 验证邮箱格式
if "@" not in email:
errors["email"] = "请输入有效的邮箱地址"
# 验证邮箱是否已注册
elif await db.email_exists(email):
errors["email"] = "该邮箱已被注册"
if errors:
# 返回带错误的表单 HTML(替换整个表单,保留用户输入)
return templates.TemplateResponse(
"signup_form.html",
{"request": request, "email": email, "errors": errors},
status_code=422
)
# 注册成功:重定向
return HTMLResponse("", headers={"HX-Redirect": "/dashboard"})
<!-- signup_form.html (Jinja2 模板) -->
<form
id="signup-form"
hx-post="/api/signup"
hx-target="#signup-form"
hx-swap="outerHTML"
>
<div class="field {% if errors.email %}has-error{% endif %}">
<label>邮箱</label>
<input
type="email"
name="email"
value="{{ email }}"
required
/>
{% if errors.email %}
<span class="error-msg">{{ errors.email }}</span>
{% endif %}
</div>
<button type="submit">注册</button>
</form>
表单验证的最佳实践
htmx 推荐的模式是:HTML5 原生验证(required、type="email"、minlength)做第一层拦截;服务端验证做权威校验;422 状态码 + OOB 更新的方式展示字段级错误。这样既保证了安全性,又无需在前端维护独立的验证逻辑。