Chapter 10

全栈实战:构建 Todo 应用

用 FastAPI + htmx 从零构建一个功能完整的 Todo 应用,综合运用本课程的所有知识

项目结构

todo-htmx/ ├── app/ │ ├── main.py # FastAPI 应用入口 │ ├── models.py # 数据模型(SQLite + SQLAlchemy) │ └── templates/ │ ├── base.html # 基础模板 │ ├── index.html # 主页(完整页面) │ └── partials/ │ ├── todo_list.html # Todo 列表片段 │ ├── todo_item.html # 单个 Todo 片段 │ └── todo_count.html # 计数器片段(OOB) ├── static/ │ ├── htmx.min.js │ └── app.css ├── requirements.txt └── fly.toml # Fly.io 部署配置

数据模型

# app/models.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime

Base = declarative_base()

class Todo(Base):
    __tablename__ = "todos"

    id        = Column(Integer, primary_key=True, autoincrement=True)
    title     = Column(String(200), nullable=False)
    completed = Column(Boolean, default=False)
    created   = Column(DateTime, default=datetime.utcnow)

FastAPI 路由

# app/main.py
from fastapi import FastAPI, Request, Form, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from . import models, database

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="app/templates")

def get_db():
    db = database.SessionLocal()
    try: yield db
    finally: db.close()

def is_htmx(request: Request): return request.headers.get("HX-Request") == "true"

# ─── 主页 ───────────────────────────────────────────
@app.get("/")
async def index(request: Request, filter: str = "all", db: Session = Depends(get_db)):
    todos = get_filtered_todos(db, filter)
    remaining = db.query(models.Todo).filter_by(completed=False).count()
    ctx = {"request": request, "todos": todos, "filter": filter, "remaining": remaining}
    if is_htmx(request):
        return templates.TemplateResponse("partials/todo_list.html", ctx)
    return templates.TemplateResponse("index.html", ctx)

# ─── 新增 Todo ──────────────────────────────────────
@app.post("/todos")
async def create_todo(request: Request, title: str = Form(...), db: Session = Depends(get_db)):
    if not title.strip():
        return HTMLResponse('<p class="error">内容不能为空</p>', status_code=422)
    todo = models.Todo(title=title.strip())
    db.add(todo); db.commit(); db.refresh(todo)
    remaining = db.query(models.Todo).filter_by(completed=False).count()
    return templates.TemplateResponse(
        "partials/todo_item.html",
        {"request": request, "todo": todo, "remaining": remaining}
    )

# ─── 切换完成状态 ────────────────────────────────────
@app.patch("/todos/{todo_id}/toggle")
async def toggle_todo(request: Request, todo_id: int, db: Session = Depends(get_db)):
    todo = db.query(models.Todo).filter_by(id=todo_id).first()
    todo.completed = not todo.completed
    db.commit()
    remaining = db.query(models.Todo).filter_by(completed=False).count()
    return templates.TemplateResponse(
        "partials/todo_item.html",
        {"request": request, "todo": todo, "remaining": remaining}
    )

# ─── 删除 Todo ──────────────────────────────────────
@app.delete("/todos/{todo_id}")
async def delete_todo(request: Request, todo_id: int, db: Session = Depends(get_db)):
    db.query(models.Todo).filter_by(id=todo_id).delete()
    db.commit()
    remaining = db.query(models.Todo).filter_by(completed=False).count()
    # 返回空字符串(移除 DOM 元素)+ OOB 更新计数器
    count_html = f'<span id="remaining-count" hx-swap-oob="true">{remaining}</span>'
    return HTMLResponse(count_html)

HTML 模板

<!-- app/templates/index.html -->
{% extends "base.html" %}
{% block content %}
<div class="todo-app">
  <h1>待办事项</h1>

  <!-- 新增表单(提交后追加到列表,清空输入框) -->
  <form
    hx-post="/todos"
    hx-target="#todo-list"
    hx-swap="afterbegin"
    hx-on::after-request="this.reset()"
  >
    <input name="title" placeholder="添加待办事项..." required/>
    <button type="submit">添加</button>
  </form>

  <!-- 过滤器 -->
  <nav class="filters">
    <button hx-get="/?filter=all"    hx-target="#todo-list" {% if filter=='all'    %}class="active"{% endif %}>全部</button>
    <button hx-get="/?filter=active" hx-target="#todo-list" {% if filter=='active' %}class="active"{% endif %}>未完成</button>
    <button hx-get="/?filter=done"   hx-target="#todo-list" {% if filter=='done'   %}class="active"{% endif %}>已完成</button>
  </nav>

  <!-- 实时搜索 -->
  <input
    type="search"
    name="q"
    placeholder="搜索..."
    hx-get="/search"
    hx-trigger="keyup changed delay:300ms"
    hx-target="#todo-list"
  />

  <!-- 剩余计数 -->
  <p>剩余 <span id="remaining-count">{{ remaining }}</span> 项未完成</p>

  <!-- Todo 列表 -->
  <ul id="todo-list">
    {% include "partials/todo_list.html" %}
  </ul>
</div>
{% endblock %}
<!-- app/templates/partials/todo_item.html -->

<!-- OOB 更新计数器 -->
<span id="remaining-count" hx-swap-oob="true">{{ remaining }}</span>

<!-- 主要内容:单个 Todo 项 -->
<li
  id="todo-{{ todo.id }}"
  class="todo-item {% if todo.completed %}done{% endif %} htmx-added"
>
  <!-- 切换完成状态 -->
  <input
    type="checkbox"
    {% if todo.completed %}checked{% endif %}
    hx-patch="/todos/{{ todo.id }}/toggle"
    hx-target="#todo-{{ todo.id }}"
    hx-swap="outerHTML"
  />

  <span class="title">{{ todo.title }}</span>

  <!-- 删除按钮 -->
  <button
    class="del-btn"
    hx-delete="/todos/{{ todo.id }}"
    hx-target="#todo-{{ todo.id }}"
    hx-swap="outerHTML"
    hx-confirm="确认删除?"
  >×</button>
</li>

部署到 Fly.io

# 安装 fly CLI
brew install flyctl

# 登录并创建应用
fly auth login
fly launch --name my-todo-htmx

# 应用已有 fly.toml 后直接部署
fly deploy
# fly.toml
app = "my-todo-htmx"
primary_region = "hkg"  # 香港节点

[build]
  dockerfile = "Dockerfile"

[http_service]
  internal_port = 8000
  force_https   = true
  auto_stop_machines  = true
  auto_start_machines = true
  min_machines_running = 0

[[vm]]
  memory = "256mb"
  cpus   = 1
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
完整的 htmx 应用特点总结

这个 Todo 应用展示了 htmx 的核心优势:零 JavaScript 状态管理——所有状态在服务器(SQLite 数据库),前端只是 HTML 的呈现;渐进增强——即使禁用 JS,表单提交和页面导航仍然工作;代码极少——不需要 Redux/Zustand,不需要 axios,不需要复杂的组件生命周期。整个前端交互逻辑用 HTML 属性表达。

本章小结与课程总结

通过这个 Todo 应用,htmx 教程的完整知识点体现:① hx-post/hx-delete/hx-patch 覆盖完整的 CRUD 操作,服务器返回 HTML 片段而非 JSON;② hx-swap="outerHTML" 替换元素自身(用于删除、切换),afterbegin 插入到列表头部(新增);③ OOB swaphx-swap-oob="true")在同一响应中同步更新多个独立位置(如删除一个 Todo 同时更新计数器);④ hx-trigger="keyup changed delay:300ms" 实现防抖搜索——只在停止输入 300ms 后触发请求;⑤ hx-on::after-request="this.reset()" 在内联 JavaScript 中操作元素(提交后清空表单);⑥ 完整页面和局部请求共用路由,通过 HX-Request 头判断返回内容;⑦ FastAPI + Jinja2 + htmx 的部署与普通 Web 应用无异,没有前后端分离部署的复杂性。