Chapter 06

表单增强与验证

掌握 htmx 的完整表单处理能力,从自动序列化到文件上传,从客户端到服务端验证的最佳实践

表单自动序列化

htmx 自动将表单字段序列化为请求数据,规则与原生 HTML 表单一致:

<!-- 完整的注册表单 -->
<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 原生验证(requiredtype="email"minlength)做第一层拦截;服务端验证做权威校验;422 状态码 + OOB 更新的方式展示字段级错误。这样既保证了安全性,又无需在前端维护独立的验证逻辑。