6.1 Form 与 ModelForm 本节摘要:表单类把"字段长什么样、数据怎么洗"从视图与模板里抽出来声明式描述。本节走完一个表单的完整生命周期(声明、渲染、绑定、校验、取净数据),再讲 ModelForm 如何让模型与表单的定义只写一遍,以及 CreateView 与 UpdateView 如何把整个循环压到几行。 一个评论表单的完整生命周期 声明: 视图里的完整循环——这是本章最重要的代码,值得逐行读懂: 模板里三行搞定渲染: 注意表单的三种状态:空表单(未绑定)、绑定数据待校验、校验失败带着错误重渲染。失败分支没有显式写——渲染语句在循环外,失败的 form 自带用户刚才填的数据与错误信息,体验顺滑。这正是第 4 章预留的分支模式,现在被表单体系完整实现。
本节摘要:表单类把"字段长什么样、数据怎么洗"从视图与模板里抽出来声明式描述。本节走完一个表单的完整生命周期(声明、渲染、绑定、校验、取净数据),再讲 ModelForm 如何让模型与表单的定义只写一遍,以及 CreateView 与 UpdateView 如何把整个循环压到几行。
声明:
from django import forms from .models import Comment class CommentForm(forms.ModelForm): class Meta: model = Comment fields = ["reviewer", "content"] widgets = { "content": forms.Textarea(attrs={"rows": 4, "placeholder": "说点什么……"}), }
视图里的完整循环——这是本章最重要的代码,值得逐行读懂:
from django.shortcuts import render, redirect, get_object_or_404 def article_detail(request, pk): article = get_object_or_404(Article.published, pk=pk) if request.method == "POST": form = CommentForm(request.POST) # 绑定数据的表单 if form.is_valid(): # 触发整条校验链 comment = form.save(commit=False) # 先建对象不落库 comment.article = article # 补上路由上下文 comment.save() return redirect("blog:article-detail", pk=pk) else: form = CommentForm() # 空表单,渲染用 return render(request, "blog/detail.html", {"article": article, "form": form})
模板里三行搞定渲染:
<form method="post"> {% csrf_token %} {{ form.as_p }} <button type="submit">发表评论</button> </form>
注意表单的三种状态:空表单(未绑定)、绑定数据待校验、校验失败带着错误重渲染。失败分支没有显式写——渲染语句在循环外,失败的 form 自带用户刚才填的数据与错误信息,体验顺滑。这正是第 4 章预留的分支模式,现在被表单体系完整实现。
普通 Form 要把字段重写一遍,模型改了表单忘改就出鬼。ModelForm 直接读模型定义生成字段,Meta 里裁剪:
class ArticleForm(forms.ModelForm): class Meta: model = Article fields = ["title", "body", "category", "tags"] help_texts = {"title": "建议 30 字以内,利于搜索"} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["title"].widget.attrs["placeholder"] = "文章标题"
省下的不只是定义。save 方法默认直接落库,save(commit=False) 返回未保存实例——上面的评论表单正是用它把"表单字段里没有的article 外键"补进去。发布流程也能插一步:先补作者再保存:
article = form.save(commit=False) article.author = request.user article.save() form.save_m2m() # 多对多关系单独保存
⚠️ save(commit=False) 之后多对多关系不会自动保存,必须补一句 save_m2m。忘写是高频 bug:单表字段都在,标签悄悄丢了,测试不覆盖多对多时很难发现。
as_p、as_table、as_ul 是"一键渲染",快速原型好用;正式页面要控制结构时有三级粒度:
{% for field in form %} <div class="field"> {{ field.label_tag }} {{ field }} {% if field.errors %}<p class="error">{{ field.errors.0 }}</p>{% endif %} </div> {% endfor %}
最细一级是逐属性拼装(field.id_for_label、field.errors、field.help_text)。错误信息会自动本地化,label 来自模型与表单的 verbose_name——第 2 章"给字段写人类可读名"的投资,在这里开始分红。
第 4 章留的 ArticleCreateView,现在能写完整了:
from django.views.generic.edit import CreateView class ArticleCreateView(CreateView): form_class = ArticleForm template_name = "blog/article_form.html" def form_valid(self, form): form.instance.author = self.request.user # 保存前插逻辑 return super().form_valid(form)
CreateView 内部执行的正是本节手写的那个循环:GET 渲染空表、POST 校验、失败重渲染、成功跳转。form_valid 是官方留好的插入点,补完作者再放行。UpdateView 同构,DeleteView 只需确认页。到这里,第 4 章的"三行一页"承诺全部兑现——通用视图加 ModelForm,是 Django 全家桶里配合最默契的一对。

表单体系用熟后,一个更深的问题会浮现:同一个业务概念(比如一篇文章),在模型层和表单层各有一份字段定义,两份之间该保持什么关系?ModelForm 的答案是从模型派生,但派生不等于照单全收。实践中真正的设计判断在于哪些字段暴露给用户:文章的标题正文分类暴露在表单里,而作者、浏览量、状态机标记这些字段属于系统内部管理,绝不进表单——哪怕它们也是模型字段。反过来的坑同样存在:表单需要确认密码、同意条款这类纯交互字段,它们不属于任何模型,此时就该用普通 Form 或在表单类里加额外字段并在保存前剔除。判断的唯一标尺是数据归属:用户能填的进表单,系统管的留在视图逻辑里赋值。把这条边界想清楚,表单类就不会膨胀成第二个模型层。
表单的自动渲染能把整个表单一行模板标签画出来,但真实项目里几乎没人用到底——页面设计稿的排版千姿百态,自动生成的结构对不上。惯常做法是逐字段渲染:每个字段单独引用,标签、控件、错误信息各自就位,排版自由。这里有个值得想清的取舍:逐字段渲染牺牲了简洁,换来的是模板对呈现的完全控制权;而为了少写几行模板去深度定制表单模板引擎,常常得不偿失。墨迹博客的定稿方案很朴素:三张表单模板(展示型、编辑型、简易型)覆盖全站,新表单挑一张最接近的继承微调。表单体系的设计哲学与模板层一脉相承——生成能力是兜底,呈现主权归模板。
校验链内部还有什么可挖?下一节把校验的四个挂载点与 CSRF 闭环拆开看。