五种 URL 模式搞定一切:HTMX 与抽象视图如何消除了我的整个 API 层
当你把 Django 的基于类的视图推向逻辑上的极致时,会发生什么?你会得到五个 URL 模式,它们为应用中的每一个模型处理完整的 CRUD——没有 REST framework,没有序列化器,没有客户端路由器。只有 HTMX、约定优于配置,以及对偶发复杂度的哲学式拒斥。
思想实验
我想回答一个一直困扰我的问题:在 Django 的抽象崩溃之前,你能把它推到多远?
不是学术上的探讨,而是实践中的验证。我构建了一个完整的简历管理应用,包含 20 多个不同的模型(工作经历、教育背景、技能、认证、出版物、奖项、志愿工作、语言等等),并向自己发起挑战:用仅仅五条 URL 模式来支撑整个应用。
没有 REST framework。没有 JSON API。没有 JavaScript 构建步骤。没有 React。没有客户端状态管理。只有 Django、HTMX、Alpine.js,以及一个信念 —— 大多数 Web 应用都被严重过度设计了。
五种模式
整个应用都通过这五个 URL 条目进行路由:
# 每一个模型。每一项操作。五行代码。
urlpatterns = [
path("<str:model_name>/list",
ModelListView.as_view(), name="model_list"),
path("<str:model_name>/create",
ModelCreateView.as_view(), name="model_create"),
path("<str:model_name>/<int:pk>",
ModelDetailView.as_view(), name="model_detail"),
path("<str:model_name>/<int:pk>/update",
ModelUpdateView.as_view(), name="model_update"),
path("<str:model_name>/<int:pk>/delete",
ModelDeleteView.as_view(), name="model_delete"),
]
/experience/list、/education/create、/award/42/update、/hobby/7/delete——它们全都通过同样的五个视图来解析。model_name 参数是唯一变化的东西。
这不是玩具。二十个模型,具备完整的创建、读取、更新、删除功能——用户隔离、经过认证,在需要时使用自定义表单,不需要时则使用自动生成的表单。
工作原理:以约定作为配置
其中的诀窍简单到令人难堪。每个视图都在派发时使用 Django 内置的应用注册表来解析模型:
class ModelCreateView(CreateView, LoginRequiredMixin):
template_name = "create.html"
@staticmethod
def get_model(model_name):
try:
return apps.get_model("nest", model_name)
except LookupError:
return None
def dispatch(self, request, *args, **kwargs):
self.model_name = self.kwargs.get("model_name")
self.model = self.get_model(self.model_name)
# Convention: look for ModelNameForm in globals
if form_class := globals().get(
f"{self.model.__name__}Form"
):
self.form_class = form_class
else:
self.fields = self.model.fields
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
三条约定完成了全部工作:
- 模型发现——
apps.get_model("nest", model_name)把一个 URL 片段转换成一个 Django 模型类。没有注册表,没有配置文件,没有装饰器。只要该模型存在于应用中,它就是可用的。 - 表单发现——海象运算符会在
globals()中查找名为{ModelName}Form的类。如果找到,就使用这个自定义表单;如果没找到,则回退到根据模型的fields属性自动生成表单。 - 用户隔离——每一个
form_valid都会把request.user打到实例上,每一个get_queryset都会据此过滤。不需要权限框架——用户根本无法看到彼此的数据。
模型契约:一个属性统领全局
要让一个模型参与到通用的 CRUD 系统中,它只需要一样东西:一个列出可编辑字段的 fields 类属性。
class Award(BaseTimeStamp):
user = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
title = models.CharField(max_length=255)
awarded_by = models.CharField(max_length=255)
date_awarded = models.DateField()
fields = ["title", "awarded_by", "date_awarded"]
class VolunteerExperience(BaseTimeStamp):
user = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
organization = models.CharField(max_length=255)
role = models.CharField(max_length=255)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
description = models.TextField()
fields = ["organization", "role", "start_date",
"end_date", "description"]
这就是全部的约定。定义你的模型、列出字段,剩下的由通用系统处理。往应用中添加一个新实体——比如 Patent 或 MilitaryService——是一个三步流程:
- 编写带
fields属性的模型 - 创建一个详情模板(继承基础卡片)
- 把它加入仪表盘的分区循环中
没有新的 URL。没有新的视图。没有新的序列化器。没有新的 API 端点。没有对路由层的迁移。系统会发现这个模型并提供服务。
HTMX:魔法所在之处
它之所以能做到这一点却又不像 2005 年那种服务端渲染的应用,靠的是 HTMX。每一个 CRUD 操作都是异步的,每一次交互都很流畅,页面从不会完全重新加载。
关键技巧是带外交换(hx-swap-oob)。当你点击「添加经历」时,HTMX 会获取创建表单并将其交换进一个指定的容器——不是通过替换按钮,而是通过 ID 定位页面上任意位置的一个 div。
<!-- The create button -->
<button hx-get="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML">
Add Experience
</button>
<!-- The form submits via HTMX, not a page reload -->
<form method="post">
{{ form.as_p }}
<button hx-post="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "..."}'>Create</button>
<button hx-get="/clean-div/experience_section"
hx-target="#experience_section"
hx-swap="innerHTML">Cancel</button>
</form>
<!-- Delete removes the card from DOM entirely -->
<button hx-delete="/experience/42/delete"
hx-target="#experience_42"
hx-swap="delete">Delete</button>
取消按钮尤为优雅——它请求一个 /clean-div/{id} 端点,该端点返回一个空的 <div>,通过把模态框的内容替换为空白,实际上就关闭了它。没有 JavaScript 的显示/隐藏逻辑。没有 CSS 类的切换。服务器返回空白,而 HTMX 照办。
Alpine.js:轻量的编排层
Alpine.js 只负责一件事:控制仪表盘的哪个区块可见。整个状态管理就是一个变量:
<body x-data="{ openDiv: null }">
<!-- Sidebar toggles -->
<a @click="openDiv = openDiv === 'experience'
? null : 'experience'">Work</a>
<a @click="openDiv = openDiv === 'education'
? null : 'education'">Education</a>
<!-- Sections appear/disappear with transitions -->
<div x-show="openDiv === 'experience'"
x-transition:enter="transition ease-out"
x-transition:leave="transition ease-in">
{% include "list.html" %}
</div>
</body>
就是这样。一个变量、几个 @click 处理函数,以及带过渡效果的 x-show。Alpine.js 不获取数据、不管理表单,也不跟踪状态。它只决定哪个面板可见。其余的一切都由 HTMX 处理。
正是这种分工让架构保持整洁。Alpine 掌管可见性。HTMX 掌管数据。Django 掌管真相。谁也不踩谁的地盘。
两层式表单系统
并非每个模型都能用一个扁平的自动生成表单来支撑。教育背景需要一个带国家筛选的学校自动补全。工作经历需要一个技能多选框。系统通过一个优雅的两级回退机制来处理这一点:
- 第一级:自定义表单 —— 如果模块命名空间中存在
EducationForm,视图就会使用它。这些表单可以拥有自定义控件、自定义校验、自动补全字段 —— 模型需要什么都行。 - 第二级:自动生成表单 —— 如果不存在自定义表单,Django 就会根据模型的
fields列表构建一个。像Hobby(只有一个标题字段)这样简单的模型永远不需要自定义表单。
这套发现机制就是使用海象运算符的一行代码:
if form_class := globals().get(f"{self.model.__name__}Form"):
self.form_class = form_class # custom form
else:
self.fields = self.model.fields # auto-generate
这是约定优于配置最纯粹的体现。没有表单注册表,没有装饰器,没有设置字典。把你的表单命名为 {Model}Form,放在视图能看到的地方,系统就会自动识别它。什么都不做,它照样能工作。
哲学层面的争论:偶然复杂度与本质复杂度
Fred Brooks 区分了 本质复杂性(问题本身固有的)和 偶然复杂性(由我们的工具和选择引入的)。大多数 Web 应用都被偶然复杂性淹没了。
想想一个典型的现代技术栈完成一次 CRUD 操作需要什么:
- 一个 Django 模型
- 一个序列化器(DRF)
- 一个 viewset 或 APIView
- 一条在 router 中注册的 URL 路由
- 一个镜像该序列化器的 TypeScript 接口
- 一个带有 useState、useEffect、fetch 调用的 React 组件
- 一条 React Router 中的客户端路由
- 两端各自的错误处理
- 加载状态、乐观更新、缓存失效
这就是为一个实体准备的九件产物。乘以二十个模型,你就要维护 180 个文件,它们必须跨两种语言、两个运行时、两条部署流水线保持同步。
在这个项目中,添加一个模型只需要:
- 模型本身(带一个
fields属性) - 一个详情模板(10 行,继承自一个基类)
- 仪表盘循环中的一行代码
三件产物。一种语言。一个运行时。一次部署。其余一切皆由约定处理。
它在哪里会失效(以及在哪里不会)
我并不是说这种模式能取代所有 SPA。它在以下情况下会失效:
- 需要离线优先——基于 HTMX 的 SSR 需要一个服务器。使用 IndexedDB 的 PWA 则不需要。
- 复杂的客户端交互——一个拖拽式看板、一个协作文档编辑器,或一个实时游戏,都需要丰富的客户端状态。
- API 本身就是产品——如果你构建的平台要被移动应用和第三方消费,那么无论如何你都需要一个正经的 API 层。
但关键在于:大多数 CRUD 应用都不属于上述任何一种。大多数业务应用都是带认证的表单、列表和详情视图。它们不需要一个 50KB 的 JavaScript 运行时,去渲染一张服务器早已知道如何渲染的记录表格。
这次实验向我证明了:Django 的基于类的视图,结合 HTMX 让服务端渲染 HTML 感觉像 SPA 的能力,可以用大约 1,500 行代码 处理二十个模型的完整 CRUD。等价的 React + DRF 技术栈将是这个量的五到十倍。
我学到了什么
构建这个项目改变了我对 Web 架构的思考方式:
- 约定胜于配置——基于
globals()的表单查找并不常规,但它消除了整整一层注册层。有时最简单的方案就是最好的方案。 - HTMX 不是妥协之选——带外交换(out-of-band swaps)、交换策略(
delete、innerHTML、none)以及带防抖的触发器,能以 SPA 十分之一的复杂度实现其 90% 的交互性。 - Django 的通用视图被低估了——
CreateView、UpdateView、ListView、DeleteView正是为这种组合方式而设计的。框架一直有这个能力;只是当 React 到来后,我们不再去用它了。 fields属性这一模式出人意料地强大。它既是表单字段列表,又是关于哪些内容可编辑的文档契约。一个属性,两种用途。- Alpine.js + HTMX 是完美搭档——Alpine 处理纯客户端的事务(可见性、过渡),HTMX 处理与服务器的通信。两者都不去抢对方的活。
最好的代码是你不必写的代码。最好的端点是能处理所有模型的那一个。最好的框架是懂得让路、让约定去挑大梁的那一个。