Architecture 2026-05-16·Ievgenii Svyryd

干掉你的硬编码表单:JSON 驱动的表单生成如何改变了我构建 Django 应用的方式

如果你 Django 应用中的每一个表单——创建、编辑、批量更新、模态框、内联——都被定义为一个 Python 字典而非一个类,会怎样?两个生产系统教会我:表单是数据,不是代码,而这一区别是变革性的。

干掉你的硬编码表单:JSON 驱动的表单生成如何改变了我构建 Django 应用的方式

硬编码表单之痛

每个 Django 开发者都熟悉这套仪式。你定义一个模型。然后写一个 ModelForm。然后调整控件。然后加上一份 crispy 布局。然后客户说“加个字段”,你就得动四个文件。然后他们说“把那个字段做成条件显示”,你就得加 JavaScript。然后他们说“同一个表单,但要放进模态框里”,你就得复制一份模板。

把这一切乘以 20 个模型,你就在维护一支小小的表单类大军,每一个都略有不同,每一个都写死了,每一个都在悄悄地与它所代表的模型脱节。

我在两个截然不同的项目上撞上了这堵墙——一个是工业制造质量控制系统,一个是金融交易后台——两次都得出了相同的解决方案:别再写表单类,改为写表单配置

核心思想:表单即数据

一个 Django 表单字段其实只是一组属性的集合:一个名称、一个类型、一个 widget、一个标签、它是否必填、它有哪些选项。这个描述中没有任何东西要求必须有一个 Python 类定义。它是数据,而数据应该存放在数据结构里。

这个洞见很简单:把字段定义为字典,将它们存储在一个 schema 中,并让单一的通用表单类在运行时根据该 schema 自行构建。

# 与其这样写:
class InspectionForm(forms.Form):
    diameter = forms.FloatField(
        label="Inner diameter",
        widget=forms.NumberInput(attrs={"class": "form-control"}),
    )
    status = forms.ChoiceField(
        choices=[("pass", "Pass"), ("fail", "Fail")],
        widget=forms.Select(attrs={"class": "form-select"}),
    )

# 不如这样写:
INSPECTION_SCHEMA = {
    "fields": [
        {
            "name": "diameter",
            "type": "FloatField",
            "widget": "NumberInput",
            "label": "Inner diameter",
            "widget_attrs": {"class": "form-control"},
        },
        {
            "name": "status",
            "type": "ChoiceField",
            "widget": "Select",
            "label": "Status",
            "choices": [("pass", "Pass"), ("fail", "Fail")],
            "widget_attrs": {"class": "form-select"},
        },
    ]
}

DynamicForm 引擎

整个系统建立在单个类之上。它将字符串标识符映射到 Django 的字段类和 widget 类,然后遍历 schema 来构建自身:

class DynamicForm(forms.Form):
    FIELD_MAP = {
        "CharField": forms.CharField,
        "ChoiceField": forms.ChoiceField,
        "FloatField": forms.FloatField,
        "IntegerField": forms.IntegerField,
        "BooleanField": forms.BooleanField,
        "DateField": forms.DateField,
        "DecimalField": forms.DecimalField,
        "EmailField": forms.EmailField,
        # ... every Django field type
    }

    WIDGET_MAP = {
        "TextInput": forms.TextInput,
        "Select": forms.Select,
        "NumberInput": forms.NumberInput,
        "Textarea": forms.Textarea,
        "CheckboxInput": forms.CheckboxInput,
        "DateInput": forms.DateInput,
        # ... every Django widget type
    }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for field_cfg in self.scheme["fields"]:
            field_class = self.FIELD_MAP[field_cfg["type"]]
            widget_class = self.WIDGET_MAP[field_cfg["widget"]]
            self.fields[field_cfg["name"]] = field_class(
                label=field_cfg["label"],
                widget=widget_class(
                    attrs=field_cfg.get("widget_attrs", {})
                ),
                required=field_cfg.get("required", True),
                choices=field_cfg.get("choices", []),
            )

那就是整个引擎。每一种 Django 字段类型、每一种 widget 类型,都通过字符串查找来映射。一个新表单只是一个新字典,而不是一个新类。

案例研究一:制造业质量控制

第一个项目是为一家工业制造商打造的生产数据系统。质检员遵循标准作业程序(SOP),需要测量特定尺寸、检查表面处理,并为每个生产阶段记录合格/不合格的结果。

挑战在于:20 多种不同的检验表单,每一种都有针对工件类型和 SOP 步骤的独特字段。端板立车检验测量的东西与双极板铣削检验完全不同。

如果采用硬编码表单,那就是 20 多个表单类,每个都有自己的字段定义、自己的校验逻辑、自己的模板怪癖。而采用 blueprint 方式,则是 20 多个字典加一个表单类

# 每一项 QC 检测都只是一个 schema 定义
QC_ENDPLATE_LATHE_SCHEMA = {
    "fields": [
        {
            "name": "inner_diameter",
            "widget": "TextInput",
            "label": _("Inner diameter: 1846mm"),
        },
        {
            "name": "outer_diameter",
            "widget": "TextInput",
            "label": _("Outer diameter: 1876-1876.1mm"),
        },
        {
            "name": "surface_qualified",
            "type": "ChoiceField",
            "widget": "Select",
            "label": _("Surface qualification"),
            "choices": [
                ("qualified", _("Qualified")),
                ("unqualified", _("Unqualified")),
            ],
        },
    ]
}

# 表单类只需一行
class EndPlateLathQCForm(DynamicForm, QCValidationMixin):
    scheme = QC_ENDPLATE_LATHE_SCHEMA

采集到的数据以 JSON 形式存储在 JSONField 中,同时保留字段标签和字段值。这意味着检验记录是自描述的——你无需知道它由哪个 schema 生成,就能直接读懂它。

# 数据以自描述的 JSON 形式存储
{
    "inner_diameter": {
        "label": "Inner diameter: 1846mm",
        "value": "1846.02"
    },
    "surface_qualified": {
        "label": "Surface qualification",
        "value": "qualified"
    }
}

案例研究二:金融交易后台

第二个项目把这个模式推进得更远。一个加密货币交易后台需要为账户、佣金、风险画像、合规状态、资费方案等提供表单——每一种都有不同的字段、不同的权限、不同的提交方式(创建用 POST,批量更新用 PATCH),以及不同的渲染上下文(整页、模态框、行内)。

在这里,蓝图从字段定义成长为了一份完整的表单规格说明

ACCOUNT_CREATE_BLUEPRINT: FormBlueprint = {
    "name": "account_create",
    "title": "Create Account",
    "column_count": 2,
    "modal_size": "xl",
    "method": "POST",
    "api_config": "account:accounts_v1",
    "success_message": "Account created successfully",
    "fields": [
        {
            "name": "currency",
            "type": "ChoiceField",
            "label": "Currency",
            "permission": "ACCOUNT_CURRENCY_UPDATE",
            "api_choices": "common:unified_search",
            "api_choices_args": ["assets-v1"],
            "api_choices_autocomplete": True,
            "row_class": "col-md-6",
        },
        {
            "name": "commission_rate",
            "type": "FloatField",
            "label": "Commission Rate (%)",
            "permission": "ACCOUNT_COMMISSION_UPDATE",
            "widget_attrs": {"step": "0.01", "min": "0"},
            "row_class": "col-md-6",
        },
    ],
}

注意这一个字典里编码了哪些东西:HTTP 方法、API 端点、模态框尺寸、哪些字段需要哪些权限、从何处加载下拉选项、Bootstrap 网格布局,以及成功提示消息。这是一整套表单体验,纯粹以数据的形式呈现。

基于权限的字段可见性

这个金融平台增加了一项强大的功能:逐字段的权限控制。blueprint 中的每个字段都可以声明一个权限字符串。表单渲染器会检查当前用户的权限,并优雅降级:

class FormRenderer:
    def _apply_field_permissions(self, fields, user):
        user_perms = get_user_section_permissions(user)
        for field in fields:
            required_perm = field.get("permission")
            if required_perm and required_perm not in user_perms:
                field.setdefault("widget_attrs", {})
                field["widget_attrs"]["disabled"] = True
                field["widget_attrs"]["readonly"] = True

没有 ACCOUNT_COMMISSION_UPDATE 权限的用户仍能看到佣金费率字段——但它是只读的,且以灰色显示。不需要单独的模板,不需要 {% if perms.x %} 代码块,也不需要条件渲染逻辑。蓝图声明规则,渲染器负责执行。

通过 API 加载选项与自动补全

对于「Qualified/Unqualified」这类下拉框,静态选项列表足够用,但货币选择器需要从一个实时 API 拉取数据。蓝图通过两个属性来处理这一点:

  • api_choices - 一个返回选项的 Django URL name
  • api_choices_autocomplete - 启用带服务端搜索的 Select2

表单引擎会解析这个 URL name,在渲染时获取选项,并在配置了的情况下接入 Select2 自动补全。蓝图的编写者从不写 JavaScript——他们只是声明数据存放在哪里。

为什么这比硬编码表单更好

在用这个模式构建了两个大型应用之后,我可以准确地表述它给你带来了什么:

  1. 添加一个表单是在添加数据,而不是代码。一个新的 QC 检查或一种新的账户类型就是一个新字典。没有新类、没有新模板、没有新 URL。系统会发现并渲染它。
  2. 更改一个表单是在更改一个地方。字段标签错了?改字典。需要一个新的下拉选项?更新 choices 列表。需要让某个字段变为可选?设置 "required": False。一个文件,一处更改,搞定。
  3. 表单变得可移植。一个蓝图只是一个字典。你可以将它序列化为 JSON、存储在数据库中、从配置文件加载,或通过 API 发送。表单引擎不在乎这个 schema 来自哪里。
  4. 一致性是自动的。每个表单都经过同一个渲染管线。Widget 属性、CSS 类、布局行为——它们全都由引擎统一标准化,而不是在各个表单类之间复制粘贴。
  5. 自描述的数据存储。当表单数据以带有标签和值的 JSON 形式保存时,存储的记录无需参考 schema 即可为人类所读。报告和导出变得轻而易举。
  6. 验证 mixin 干净地组合。动态表单仍然是一个 Django 表单。你可以为特定领域的验证(工件存在性检查、权限验证)添加 mixin,而无需触碰 schema。

何时该动用这个模式

动态表单并非总是正确的选择。对于一个只有两个字段的登录表单,ModelForm 更简单也更清晰。但在以下情况下,这一模式会大放异彩:

  • 你有许多相似的表单,但字段集各不相同(质检检查、入职步骤、调查问卷页)
  • 表单需要无需改动代码即可配置——由管理员、客户或外部系统来配置
  • 你需要在众多表单上实现由权限驱动的字段控制,而又不必重复编写模板逻辑
  • 表单数据需要以自描述的 JSON 形式存储,而不是存进固定的数据库列
  • 你正在构建一个多租户平台,每个租户都需要略有不同的表单

前期成本是一个 DynamicForm 类(约 50 行)以及定义 schema 而非表单类的自律。回报则是一个这样的系统:添加第 21 个表单和添加第 2 个表单所花的力气一样多。

一个硬编码的表单是当下需求的一张快照。一份表单 schema 则是一份会随之演进的契约。写下这份契约吧。

联系我 →