Architecture 2026-06-07·Ievgenii Svyryd

五种 URL 模式搞定一切:HTMX 与抽象视图如何消除了我的整个 API 层

当你把 Django 的基于类的视图推向逻辑上的极致时,会发生什么?你会得到五个 URL 模式,它们为应用中的每一个模型处理完整的 CRUD——没有 REST framework,没有序列化器,没有客户端路由器。只有 HTMX、约定优于配置,以及对偶发复杂度的哲学式拒斥。

五种 URL 模式搞定一切:HTMX 与抽象视图如何消除了我的整个 API 层

思想实验

我想回答一个一直困扰我的问题:在 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)

三条约定完成了全部工作:

  1. 模型发现——apps.get_model("nest", model_name) 把一个 URL 片段转换成一个 Django 模型类。没有注册表,没有配置文件,没有装饰器。只要该模型存在于应用中,它就是可用的。
  2. 表单发现——海象运算符会在 globals() 中查找名为 {ModelName}Form 的类。如果找到,就使用这个自定义表单;如果没找到,则回退到根据模型的 fields 属性自动生成表单。
  3. 用户隔离——每一个 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"]

这就是全部的约定。定义你的模型、列出字段,剩下的由通用系统处理。往应用中添加一个新实体——比如 PatentMilitaryService——是一个三步流程:

  1. 编写带 fields 属性的模型
  2. 创建一个详情模板(继承基础卡片)
  3. 把它加入仪表盘的分区循环中

没有新的 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)、交换策略(deleteinnerHTMLnone)以及带防抖的触发器,能以 SPA 十分之一的复杂度实现其 90% 的交互性。
  • Django 的通用视图被低估了——CreateViewUpdateViewListViewDeleteView 正是为这种组合方式而设计的。框架一直有这个能力;只是当 React 到来后,我们不再去用它了。
  • fields 属性这一模式出人意料地强大。它既是表单字段列表,又是关于哪些内容可编辑的文档契约。一个属性,两种用途。
  • Alpine.js + HTMX 是完美搭档——Alpine 处理纯客户端的事务(可见性、过渡),HTMX 处理与服务器的通信。两者都不去抢对方的活。

最好的代码是你不必写的代码。最好的端点是能处理所有模型的那一个。最好的框架是懂得让路、让约定去挑大梁的那一个。

联系我 →