上一篇讲 Signal.send() 遇到一个 receiver 抛异常就整个中断广播——Form 的校验流水线走的是完全相反的设计:哪怕好几个字段同时不合法,也要把每一个都跑一遍、把所有错误一次性收集给用户看。本机真实实测了这条"绝不提前中断"的流水线,以及它带来的一个很容易踩、真实会抛 KeyError 的副作用。
form.is_valid() 背后真正做事的是 full_clean(),而它第一次真正跑起来,是在你访问 form.errors 的那一刻。
django/forms/forms.py · django @ 6.0.7, L197 @property def errors(self): """Return an ErrorDict for the data provided for the form.""" if self._errors is None: self.full_clean() # 只有第一次访问 errors 才会真的跑校验 return self._errors def is_valid(self): return self.is_bound and not self.errors # is_valid() 本质就是"errors 是不是空的"
django/forms/forms.py · django @ 6.0.7, L324 def full_clean(self): self._errors = ErrorDict(renderer=self.renderer) if not self.is_bound: return self.cleaned_data = {} ... self._clean_fields() # 第一步:逐个字段清洗 self._clean_form() # 第二步:整表级别的 clean() self._post_clean() # 第三步:留给 ModelForm 做模型级校验的钩子,基类里什么都不做
三步是固定顺序、无条件全部执行的——不会因为第一步里某个字段失败就跳过第二步。这是理解后面几个反直觉行为的关键。
跟上一篇 Signal.send() 一个 receiver 抛异常就整个中断完全相反,_clean_fields() 的 try/except 是放在 for 循环内部的。
django/forms/forms.py · django @ 6.0.7, L341 def _clean_fields(self): for name, bf in self._bound_items(): field = bf.field try: self.cleaned_data[name] = field._clean_bound_field(bf) if hasattr(self, "clean_%s" % name): value = getattr(self, "clean_%s" % name)() self.cleaned_data[name] = value except ValidationError as e: self.add_error(name, e) # 记下这个字段的错误,循环继续,不会 break
class SignupForm(forms.Form):
username = forms.CharField(min_length=3, validators=[not_admin])
age = forms.IntegerField(min_value=18)
f = SignupForm(data={'username': 'ad', 'age': '10'}) # 两个字段都不合法
f.is_valid()
print(dict(f.errors))
{'username': ['Ensure this value has at least 3 characters (it has 2).'],
'age': ['Ensure this value is greater than or equal to 18.']}
username 先失败,但 age 照样被真实清洗了一遍,两个错误同时出现在 errors 里——这正是表单校验应该有的体验:一次提交,把所有问题都告诉用户,而不是改一个报一个、来回提交好几次才能把所有错误都暴露出来。
不只是字段之间不会互相打断,同一个字段身上挂的多个 validators,失败了也不会在第一个就停。
django/forms/fields.py · django @ 6.0.7, L187 def run_validators(self, value): if value in self.empty_values: return errors = [] for v in self.validators: try: v(value) except ValidationError as e: errors.extend(e.error_list) # 收集,不 return、不 break if errors: raise ValidationError(errors) # 循环跑完了,才把攒的一堆错误一次性抛出去
# username 同时违反三条规则:太短 + 有空格 + 不是全小写
class F(forms.Form):
username = forms.CharField(min_length=6, validators=[no_space, must_be_lower])
f = F(data={'username': 'AB C'})
f.is_valid()
print(f.errors['username'])
不能有空格
必须全小写
Ensure this value has at least 6 characters (it has 4).
三条规则全违反,三条错误全部出现,顺序也如实反映了 self.validators 列表本身的顺序——自定义的 validators=[...] 排在前面,CharField.__init__ 里自动追加的 MinLengthValidator 排在最后,因为它是在 super().__init__(**kwargs)(处理用户传入的 validators)之后才 append 进去的。
_clean_form() 不管前面 _clean_fields() 有没有报错,都会去调用 self.clean()——但失败的字段,压根不会出现在 cleaned_data 里。
django/forms/forms.py · django @ 6.0.7, L298 def add_error(self, field, error): ... for field, error_list in error.items(): ... self._errors[field].extend(error_list) if field in self.cleaned_data: del self.cleaned_data[field] # 这个字段哪怕之前侥幸写进去过,报错了也会被删掉
常见的"两次密码必须一致"写法,如果不用 .get() 而是直接用下标取值,一旦 password2 自己先因为太短校验失败,clean() 里的下标访问会真实炸掉:
class SignupForm(forms.Form):
password = forms.CharField(min_length=6)
password2 = forms.CharField(min_length=6)
def clean(self):
cleaned = super().clean()
if cleaned['password'] != cleaned['password2']: # 没用 .get(),假设两个字段都在
raise ValidationError('两次密码不一致')
return cleaned
f = SignupForm(data={'password': '123456', 'password2': '123'}) # password2 太短
f.is_valid()
KeyError: 'password2'
password2 自己的"至少 6 个字符"报错,已经通过 add_error() 把它从 cleaned_data 里删掉了;_clean_form() 完全不知道也不关心这件事,照样调用 self.clean(),于是 cleaned['password2'] 这行代码直接命中一个不存在的 key。这也是为什么 Django 官方文档反复强调 Form.clean() 里取值要用 cleaned_data.get('password2'),不是编码洁癖,是真的会在生产环境炸出一个用户提交了几个字符就能触发的 500。
回到最开始那个惰性属性——self._errors is None 这个判断顺带做了缓存。
call_count = {'n': 0}
def counting_validator(value):
call_count['n'] += 1
class F(forms.Form):
name = forms.CharField(validators=[counting_validator])
f = F(data={'name': 'alice'})
f.is_valid(); f.is_valid()
dict(f.errors)
print('校验函数被调用次数:', call_count['n'])
校验函数被调用次数: 1
调用了两次 is_valid() 加一次 errors,校验函数真实只跑了一次——full_clean() 只会在 self._errors 还是 None 的时候执行一次,之后不管你怎么反复检查这个表单,拿到的都是第一次跑出来的那份结果。如果你在校验通过之后又手动改了 form.data 里的内容,errors/cleaned_data 也不会跟着刷新——这不是 bug,只是 Form 从设计上就假定一次绑定的数据只会被清洗一次。
跟上面 username/age 的真实实验完全同构:两个字段都不合法,看流水线怎么把两份错误都收集出来。
| 字段 | 状态 | 结果 |
|---|
数据直接取自上面真实实测的 SignupForm(username 太短、age 小于 18,外加一个合法的 email 字段做对照),演示动画只是把"逐个清洗、互不打断、最后一起报错"这个规则可视化,最终状态和真实的 f.errors 逐条核对过一致。