跳到主要内容

表单处理

表单是 Web 应用与用户交互的核心机制。无论是用户注册、内容发布还是数据搜索,几乎所有需要用户输入的功能都依赖表单。然而,处理表单并非简单地接收数据那么简单,我们需要考虑数据验证、安全防护、用户体验等诸多方面。

Flask-WTF 是 Flask 生态系统中最流行的表单处理扩展,它在 WTForms 库的基础上提供了与 Flask 的紧密集成,包括 CSRF 保护、文件上传、表单渲染等功能。本章将深入探讨如何使用 Flask-WTF 构建安全、高效的表单系统。

理解表单处理的核心问题

在深入技术细节之前,让我们先理解 Web 表单处理面临的核心挑战。

数据验证的多层性

表单验证不仅是检查"字段是否为空"那么简单,它涉及多个层面:

  1. 类型验证:用户输入的年龄是否为有效数字?日期格式是否正确?
  2. 业务规则验证:用户名是否已存在?邮箱是否已注册?
  3. 安全验证:输入是否包含恶意脚本?文件类型是否合法?
  4. 一致性验证:两次输入的密码是否一致?

每一层验证都有其特定的时间和方式,良好的表单系统应该清晰地区分这些层面。

CSRF 攻击防护

CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种常见的 Web 安全漏洞。攻击者诱导用户在已登录的状态下访问恶意网站,该网站自动向目标网站发送请求(如转账、修改密码),由于用户已登录,请求会被成功执行。

防护 CSRF 的核心是在表单中嵌入一个攻击者无法获取的令牌(Token),服务器验证令牌的有效性后才处理请求。Flask-WTF 自动实现了这一机制。

用户友好的错误提示

当验证失败时,如何向用户清晰地展示错误信息是一门艺术。好的错误提示应该:

  • 准确描述问题所在
  • 提供解决问题的建议
  • 支持国际化
  • 与表单字段关联显示

安装与配置

安装依赖

pip install flask-wtf email-validator

email-validator 包提供了严格的邮箱格式验证,比简单的正则表达式更加准确。

基本配置

from flask import Flask
from flask_wtf import FlaskForm

app = Flask(__name__)

# CSRF 保护需要密钥
app.config['SECRET_KEY'] = 'your-secret-key-here'

# 可选配置
app.config['WTF_CSRF_ENABLED'] = True # 启用 CSRF(默认 True)
app.config['WTF_CSRF_SECRET_KEY'] = 'another-key' # CSRF 令牌密钥
app.config['WTF_CSRF_TIME_LIMIT'] = 3600 # 令牌有效期(秒)

为什么需要 SECRET_KEY?

CSRF 令牌使用密钥签名,防止攻击者伪造有效令牌。密钥应该足够复杂且保密:

import secrets

# 生成安全的密钥
app.config['SECRET_KEY'] = secrets.token_hex(32)

表单类基础

创建表单类

WTForms 采用声明式风格定义表单,每个字段作为类属性声明:

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length

class LoginForm(FlaskForm):
"""用户登录表单"""

username = StringField(
'用户名',
validators=[
DataRequired(message='请输入用户名'),
Length(min=3, max=20, message='用户名长度必须在 3-20 个字符之间')
],
description='请输入您的用户名'
)

password = PasswordField(
'密码',
validators=[
DataRequired(message='请输入密码'),
Length(min=6, message='密码长度不能少于 6 个字符')
]
)

remember = BooleanField('记住我')

submit = SubmitField('登录')

让我们详细分析这个表单类的各个组成部分。

字段构造参数

字段构造函数接受以下常用参数:

参数类型说明
labelstr字段标签,显示在表单中
validatorslist验证器列表
filterstuple数据过滤器,在验证前处理数据
descriptionstr字段描述,用于帮助文本
defaultany默认值
widgetWidget自定义渲染控件
render_kwdict渲染时的 HTML 属性
class ProfileForm(FlaskForm):
# 带完整参数的示例
nickname = StringField(
label='昵称',
validators=[Length(max=50)],
filters=[lambda x: x.strip() if x else x], # 去除首尾空白
description='显示在您的个人主页',
default='匿名用户',
render_kw={
'placeholder': '请输入昵称',
'class': 'form-control',
'maxlength': 50
}
)

在视图中使用表单

from flask import render_template, redirect, url_for, flash

@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()

if form.validate_on_submit():
# 验证通过,处理登录逻辑
username = form.username.data
password = form.password.data
remember = form.remember.data

user = User.query.filter_by(username=username).first()

if user and user.check_password(password):
login_user(user, remember=remember)
flash('登录成功!', 'success')

# 安全重定向
next_page = request.args.get('next')
if next_page and urlparse(next_page).netloc == '':
return redirect(next_page)
return redirect(url_for('main.index'))

flash('用户名或密码错误', 'danger')

# GET 请求或验证失败
return render_template('auth/login.html', form=form)

validate_on_submit() 方法做了两件事:

  1. 检查请求方法是否为 POST、PUT、PATCH 或 DELETE
  2. 调用 form.validate() 执行所有验证器

理解表单数据流

表单数据从提交到处理经历以下流程:

用户输入 → 表单提交 → process() → filters → validators → form.data
  1. process():将表单数据转换为 Python 对象
  2. filters:对数据进行预处理(如去除空白)
  3. validators:验证数据有效性
  4. form.data:存储最终处理后的数据
# 手动处理表单(不推荐,了解原理)
form = LoginForm(request.form)
if form.validate():
# 验证通过
pass

# 手动处理 JSON 数据
if request.is_json:
form = LoginForm(data=request.get_json())
if form.validate():
pass

字段类型详解

WTForms 提供了丰富的字段类型,涵盖几乎所有常见的表单输入需求。

文本字段

from wtforms import StringField, PasswordField, TextAreaField, HiddenField

class TextForm(FlaskForm):
# 普通文本输入
title = StringField(
'标题',
validators=[DataRequired(), Length(max=100)],
render_kw={'placeholder': '请输入标题'}
)

# 密码输入(显示为星号)
password = PasswordField(
'密码',
validators=[DataRequired(), Length(min=6)]
)

# 多行文本
content = TextAreaField(
'内容',
validators=[DataRequired(), Length(max=10000)],
render_kw={'rows': 10, 'cols': 50}
)

# 隐藏字段(用于传递数据,不显示给用户)
user_id = HiddenField()

数值字段

from wtforms import IntegerField, FloatField, DecimalField

class NumberForm(FlaskForm):
# 整数输入
age = IntegerField(
'年龄',
validators=[NumberRange(min=0, max=150, message='请输入有效年龄')]
)

# 浮点数
price = FloatField(
'价格',
validators=[NumberRange(min=0)]
)

# 高精度十进制(适合货币)
amount = DecimalField(
'金额',
places=2, # 小数位数
rounding='ROUND_HALF_UP',
validators=[NumberRange(min=0)]
)

IntegerField vs FloatField vs DecimalField

  • IntegerField:整数,适合计数、年龄等
  • FloatField:浮点数,适合科学计算
  • DecimalField:高精度十进制,适合货币金额,避免浮点误差

选择字段

from wtforms import SelectField, SelectMultipleField, RadioField

class SelectionForm(FlaskForm):
# 下拉选择
country = SelectField(
'国家',
choices=[
('', '-- 请选择 --'), # 空值选项
('cn', '中国'),
('us', '美国'),
('jp', '日本'),
('uk', '英国')
],
default='',
coerce=str # 将选择值转换为指定类型
)

# 动态选项(在视图中设置)
category = SelectField('分类', coerce=int)

# 多选下拉
tags = SelectMultipleField(
'标签',
choices=[
('python', 'Python'),
('javascript', 'JavaScript'),
('go', 'Go'),
('rust', 'Rust')
],
coerce=str
)

# 单选按钮组
gender = RadioField(
'性别',
choices=[
('male', '男'),
('female', '女'),
('other', '其他')
],
default='male'
)

动态选择选项

当选项来自数据库时,需要在视图中动态设置:

@app.route('/post/create', methods=['GET', 'POST'])
def create_post():
form = PostForm()

# 动态设置分类选项
form.category.choices = [
(c.id, c.name) for c in Category.query.order_by(Category.name)
]

if form.validate_on_submit():
post = Post(
title=form.title.data,
category_id=form.category.data
)
db.session.add(post)
db.session.commit()
return redirect(url_for('post.detail', id=post.id))

return render_template('post/create.html', form=form)

日期时间字段

from wtforms import DateField, DateTimeField, TimeField

class ScheduleForm(FlaskForm):
# 日期选择
birth_date = DateField(
'出生日期',
format='%Y-%m-%d',
validators=[DataRequired()]
)

# 日期时间
event_time = DateTimeField(
'活动时间',
format='%Y-%m-%d %H:%M',
validators=[DataRequired()]
)

# 时间
start_time = TimeField(
'开始时间',
format='%H:%M',
validators=[DataRequired()]
)

日期格式字符串使用 Python 的 strftime 格式:

格式说明示例
%Y四位年份2024
%m两位月份01-12
%d两位日期01-31
%H24小时制小时00-23
%M分钟00-59
%S00-59

文件上传字段

from flask_wtf.file import FileField, FileRequired, FileAllowed

class UploadForm(FlaskForm):
# 单文件上传
avatar = FileField(
'头像',
validators=[
FileRequired(message='请选择文件'),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'], message='只允许图片文件')
]
)

# 多文件上传
photos = FileField(
'相片',
validators=[
FileAllowed(['jpg', 'png'], '只允许 JPG 和 PNG 格式')
],
render_kw={'multiple': True}
)

处理文件上传:

from werkzeug.utils import secure_filename
import os

@app.route('/upload', methods=['GET', 'POST'])
def upload():
form = UploadForm()

if form.validate_on_submit():
file = form.avatar.data

# 安全处理文件名
filename = secure_filename(file.filename)

# 生成唯一文件名
unique_name = f"{uuid.uuid4().hex}_{filename}"

# 保存文件
upload_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_name)
file.save(upload_path)

flash('上传成功!', 'success')
return redirect(url_for('upload'))

return render_template('upload.html', form=form)

布尔字段

from wtforms import BooleanField

class SettingsForm(FlaskForm):
# 复选框
subscribe = BooleanField(
'订阅邮件通知',
default=True
)

agree = BooleanField(
'我已阅读并同意用户协议',
validators=[DataRequired(message='必须同意协议才能继续')]
)

注意:未勾选的复选框不会出现在表单数据中,WTForms 会将其视为 False

HTML5 字段

WTForms 提供了对 HTML5 新输入类型的支持:

from wtforms.fields.html5 import (
SearchField, TelField, URLField, EmailField,
DateField as HTML5DateField, DateTimeLocalField,
IntegerField as HTML5IntegerField, DecimalField as HTML5DecimalField
)

class HTML5Form(FlaskForm):
# 搜索框(浏览器可能显示清除按钮)
search = SearchField('搜索')

# 电话号码(移动端显示数字键盘)
phone = TelField('电话')

# URL(自动验证 URL 格式)
website = URLField('网站', validators=[DataRequired()])

# 邮箱(自动验证邮箱格式)
email = EmailField('邮箱', validators=[DataRequired(), Email()])

# 本地日期时间
event_time = DateTimeLocalField(
'活动时间',
format='%Y-%m-%dT%H:%M'
)

# 数字输入(显示上下箭头)
quantity = HTML5IntegerField('数量', validators=[NumberRange(min=1)])

HTML5 字段在浏览器端提供更好的用户体验,但仍需服务端验证。

验证器详解

验证器是表单系统的核心,负责确保数据的完整性和有效性。

内置验证器一览

验证器用途示例
DataRequired确保字段有值(非空且非纯空白)DataRequired()
InputRequired确保输入数据存在InputRequired()
Optional允许字段为空Optional()
Length验证字符串长度Length(min=3, max=20)
NumberRange验证数值范围NumberRange(min=0, max=100)
Email验证邮箱格式Email()
URL验证 URL 格式URL()
EqualTo验证两个字段的值相等EqualTo('password')
Regexp正则表达式验证Regexp(r'^[a-z]+$')
IPAddress验证 IP 地址IPAddress()
UUID验证 UUID 格式UUID()
MacAddress验证 MAC 地址MacAddress()
AnyOf验证值在允许列表中AnyOf(['a', 'b'])
NoneOf验证值不在禁止列表中NoneOf(['admin', 'root'])

DataRequired vs InputRequired

这两个验证器经常混淆,它们有重要区别:

from wtforms.validators import DataRequired, InputRequired

class ExampleForm(FlaskForm):
# DataRequired:验证处理后的数据
# 对于字符串,空白字符串会被视为"空"
# 对于数字,0 会被视为"有值"
field1 = IntegerField('Field 1', validators=[DataRequired()])

# InputRequired:验证原始输入是否存在
# 即使输入的是 "0" 或 "",只要有输入就算通过
field2 = IntegerField('Field 2', validators=[InputRequired()])

使用场景

  • DataRequired:大多数情况,确保字段有意义的数据
  • InputRequired:当 0 或空字符串是有效值时
class ScoreForm(FlaskForm):
# 评分 0 也是有效分数
score = IntegerField('评分', validators=[InputRequired()])

# 年龄 0 没有意义
age = IntegerField('年龄', validators=[DataRequired()])

长度验证

from wtforms.validators import Length

class ContentForm(FlaskForm):
# 只验证最大长度
title = StringField('标题', validators=[Length(max=100)])

# 只验证最小长度
password = PasswordField('密码', validators=[Length(min=8)])

# 验证范围
username = StringField(
'用户名',
validators=[
Length(
min=3,
max=20,
message='用户名长度必须在 %(min)d 到 %(max)d 个字符之间'
)
]
)

消息字符串支持占位符:

  • %(min)d:最小长度
  • %(max)d:最大长度

数值范围验证

from wtforms.validators import NumberRange

class NumberForm(FlaskForm):
# 整数范围
age = IntegerField(
'年龄',
validators=[
NumberRange(
min=0,
max=150,
message='年龄必须在 %(min)d 到 %(max)d 之间'
)
]
)

# 只验证最小值
price = FloatField(
'价格',
validators=[
NumberRange(min=0, message='价格不能为负数')
]
)

# 支持浮点数
discount = FloatField(
'折扣',
validators=[
NumberRange(min=0, max=1, message='折扣必须在 0 到 1 之间')
]
)

邮箱验证

from wtforms.validators import Email

class UserForm(FlaskForm):
email = StringField(
'邮箱',
validators=[
DataRequired(),
Email(message='请输入有效的邮箱地址')
]
)

# 高级选项
advanced_email = StringField(
'邮箱',
validators=[
Email(
message='请输入有效的邮箱地址',
check_deliverability=True, # 检查域名是否存在
granular_message=True # 显示详细错误信息
)
]
)

注意Email 验证器需要安装 email-validator 包。

相等性验证

from wtforms.validators import EqualTo

class PasswordForm(FlaskForm):
password = PasswordField(
'新密码',
validators=[
DataRequired(),
Length(min=8, message='密码至少 8 个字符')
]
)

confirm = PasswordField(
'确认密码',
validators=[
DataRequired(),
EqualTo('password', message='两次输入的密码不一致')
]
)

消息占位符

  • %(other_label)s:另一个字段的标签
  • %(other_name)s:另一个字段的名称

正则表达式验证

from wtforms.validators import Regexp

class CustomForm(FlaskForm):
# 只允许字母、数字和下划线
username = StringField(
'用户名',
validators=[
DataRequired(),
Regexp(
r'^[a-zA-Z][a-zA-Z0-9_]*$',
message='用户名必须以字母开头,只能包含字母、数字和下划线'
)
]
)

# 手机号验证(中国大陆)
phone = StringField(
'手机号',
validators=[
Regexp(
r'^1[3-9]\d{9}$',
message='请输入有效的手机号'
)
]
)

# 使用正则标志
case_insensitive = StringField(
'值',
validators=[
Regexp(r'^test', flags=re.IGNORECASE, message='必须以 test 开头(不区分大小写)')
]
)

枚举值验证

from wtforms.validators import AnyOf, NoneOf

class EnumForm(FlaskForm):
# 值必须在允许列表中
status = StringField(
'状态',
validators=[
AnyOf(
['active', 'inactive', 'pending'],
message='状态必须是 active、inactive 或 pending'
)
]
)

# 值不能在禁止列表中
username = StringField(
'用户名',
validators=[
NoneOf(
['admin', 'root', 'system', 'administrator'],
message='此用户名不可使用'
)
]
)

可选字段验证

from wtforms.validators import Optional

class OptionalForm(FlaskForm):
# 可选字段,如果为空则跳过后续验证
nickname = StringField(
'昵称',
validators=[
Optional(), # 允许为空
Length(max=50) # 如果有值,则验证长度
]
)

# 去除空白后判断
bio = StringField(
'简介',
validators=[
Optional(strip_whitespace=True), # 去除首尾空白后判断
Length(max=200)
]
)

组合验证器

多个验证器按顺序执行,任一失败则停止:

class ComplexForm(FlaskForm):
password = PasswordField(
'密码',
validators=[
DataRequired(message='请输入密码'),
Length(min=8, message='密码至少 8 个字符'),
Regexp(r'.*[A-Z].*', message='密码必须包含大写字母'),
Regexp(r'.*[a-z].*', message='密码必须包含小写字母'),
Regexp(r'.*\d.*', message='密码必须包含数字'),
Regexp(r'.*[!@#$%^&*].*', message='密码必须包含特殊字符')
]
)

自定义验证

当内置验证器无法满足需求时,可以创建自定义验证器。

内联验证

最简单的验证方式,直接在表单类中定义验证方法:

class RegistrationForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(3, 20)])
email = StringField('邮箱', validators=[DataRequired(), Email()])

def validate_username(self, field):
"""验证用户名是否已存在"""
if User.query.filter_by(username=field.data).first():
raise ValidationError('该用户名已被注册')

def validate_email(self, field):
"""验证邮箱是否已存在"""
if User.query.filter_by(email=field.data).first():
raise ValidationError('该邮箱已被注册')

验证方法命名规则:validate_<field_name>,接受 field 参数,验证失败时抛出 ValidationError

函数验证器

可复用的验证器函数:

from wtforms.validators import ValidationError

def unique_username(form, field):
"""验证用户名唯一性"""
if User.query.filter_by(username=field.data).first():
raise ValidationError('该用户名已被注册')

def unique_email(form, field):
"""验证邮箱唯一性"""
if User.query.filter_by(email=field.data).first():
raise ValidationError('该邮箱已被注册')

class RegistrationForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(),
Length(3, 20),
unique_username # 使用自定义验证器
])
email = StringField('邮箱', validators=[
DataRequired(),
Email(),
unique_email
])

类验证器

更强大的验证器,支持参数配置:

from wtforms.validators import ValidationError

class Unique(object):
"""验证字段值在数据库中唯一"""

def __init__(self, model, field, message=None):
self.model = model
self.field = field
self.message = message or f'该{field}已被使用'

def __call__(self, form, field):
# 排除当前记录(用于更新场景)
exclude_id = getattr(form, 'obj_id', None)

query = self.model.query.filter(
getattr(self.model, self.field) == field.data
)

if exclude_id:
query = query.filter(self.model.id != exclude_id)

if query.first():
raise ValidationError(self.message)

# 使用
class UserForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(),
Unique(User, 'username', message='用户名已存在')
])
email = StringField('邮箱', validators=[
DataRequired(),
Email(),
Unique(User, 'email', message='邮箱已注册')
])

验证器工厂

返回验证器函数的工厂函数:

def forbidden_password(message=None):
"""禁止使用常见密码"""
forbidden = ['password', '123456', 'qwerty', 'admin']

def _forbidden_password(form, field):
if field.data.lower() in forbidden:
raise ValidationError(message or '密码过于简单,请使用更复杂的密码')

return _forbidden_password

class SecureForm(FlaskForm):
password = PasswordField('密码', validators=[
DataRequired(),
Length(min=8),
forbidden_password()
])

带标志的验证器

验证器可以设置标志,用于模板中判断:

class StrongPassword(object):
"""强密码验证器"""
field_flags = ('strong_password',) # 设置标志

def __init__(self, message=None):
self.message = message or '密码强度不足'

def __call__(self, form, field):
# 密码强度检查逻辑
password = field.data
has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
has_digit = any(c.isdigit() for c in password)
has_special = any(c in '!@#$%^&*' for c in password)

if not all([has_upper, has_lower, has_digit, has_special]):
raise ValidationError(self.message)

# 在模板中使用标志
{% if field.flags.strong_password %}
<small class="help-text">密码需包含大小写字母、数字和特殊字符</small>
{% endif %}

模板渲染

基本渲染

<form method="POST" action="{{ url_for('register') }}">
{{ form.hidden_tag() }}

<!-- 渲染标签 -->
<div class="form-group">
{{ form.username.label }}
{{ form.username(class="form-control", placeholder="请输入用户名") }}
{% if form.username.errors %}
<div class="invalid-feedback">
{% for error in form.username.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>

<div class="form-group">
{{ form.email.label }}
{{ form.email(class="form-control", placeholder="请输入邮箱") }}
</div>

{{ form.submit(class="btn btn-primary") }}
</form>

使用宏简化渲染

<!-- templates/macros/forms.html -->
{% macro render_field(field, label=true, help_text='') %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{% if label %}
{{ field.label(class="form-label") }}
{% endif %}

{{ field(
class="form-control" + (" is-invalid" if field.errors else ""),
**kwargs
) }}

{% if help_text %}
<small class="form-text text-muted">{{ help_text }}</small>
{% endif %}

{% if field.errors %}
<div class="invalid-feedback">
{% for error in field.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}

{% macro render_form(form, action='', method='POST') %}
<form method="{{ method }}" action="{{ action }}" novalidate>
{{ form.hidden_tag() }}

{% for field in form %}
{% if field.type not in ['CSRFTokenField', 'SubmitField'] %}
{{ render_field(field) }}
{% endif %}
{% endfor %}

<div class="form-group">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
{% endmacro %}

使用宏:

{% from "macros/forms.html" import render_field, render_form %}

<!-- 使用 render_field -->
{{ render_field(form.username, placeholder="请输入用户名") }}
{{ render_field(form.email, help_text="我们不会泄露您的邮箱") }}

<!-- 使用 render_form -->
{{ render_form(form, action=url_for('register')) }}

自定义字段样式

<!-- 添加 CSS 类 -->
{{ form.username(class="form-control is-invalid", rows=5) }}

<!-- 添加数据属性 -->
{{ form.email(
class="form-control",
**{"data-toggle": "tooltip", "data-placement": "top"}
) }}

<!-- 复选框和单选按钮 -->
<div class="form-check">
{{ form.remember(class="form-check-input") }}
{{ form.remember.label(class="form-check-label") }}
</div>

<!-- 下拉选择 -->
{{ form.category(class="form-select") }}

<!-- 遍历单选按钮 -->
{% for subfield in form.gender %}
<div class="form-check form-check-inline">
{{ subfield(class="form-check-input") }}
{{ subfield.label(class="form-check-label") }}
</div>
{% endfor %}

Bootstrap 5 风格表单

<!-- templates/macros/bootstrap_form.html -->
{% macro render_bootstrap_field(field) %}
<div class="mb-3 {% if field.errors %}has-validation{% endif %}">
{{ field.label(class="form-label") }}

{% if field.type == 'BooleanField' %}
<div class="form-check">
{{ field(class="form-check-input") }}
{{ field.label(class="form-check-label") }}
</div>
{% elif field.type == 'SelectField' %}
{{ field(class="form-select" + (" is-invalid" if field.errors else "")) }}
{% elif field.type == 'TextAreaField' %}
{{ field(
class="form-control" + (" is-invalid" if field.errors else ""),
rows=kwargs.get('rows', 4)
) }}
{% else %}
{{ field(
class="form-control" + (" is-invalid" if field.errors else ""),
**kwargs
) }}
{% endif %}

{% if field.description %}
<div class="form-text">{{ field.description }}</div>
{% endif %}

{% if field.errors %}
<div class="invalid-feedback">
{% for error in field.errors %}
{{ error }}
{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}

表单安全

CSRF 保护

CSRF(跨站请求伪造)保护是 Web 安全的基础:

# 配置 CSRF
app.config['WTF_CSRF_ENABLED'] = True
app.config['WTF_CSRF_SECRET_KEY'] = 'your-csrf-secret'
app.config['WTF_CSRF_TIME_LIMIT'] = 3600 # 令牌有效期(秒)
<!-- 表单中包含 CSRF 令牌 -->
<form method="POST">
{{ form.hidden_tag() }}
<!-- 或手动添加 -->
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>

AJAX 请求中的 CSRF

// 从 meta 标签获取 CSRF 令牌
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

// 发送请求时包含令牌
fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(data)
});
<!-- 在基础模板中添加 meta 标签 -->
<head>
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>

AJAX 表单验证

from flask import jsonify

@app.route('/api/validate', methods=['POST'])
def validate_form():
form = MyForm(data=request.get_json())

if form.validate():
return jsonify({'valid': True})
else:
return jsonify({
'valid': False,
'errors': form.errors
})

XSS 防护

永远不要信任用户输入:

from markupsafe import escape

class CommentForm(FlaskForm):
content = TextAreaField('评论', validators=[DataRequired()])

@app.route('/comment', methods=['POST'])
def add_comment():
form = CommentForm()
if form.validate_on_submit():
# WTForms 会自动转义输出,但存储时应保留原始内容
comment = Comment(content=form.content.data)
db.session.add(comment)
db.session.commit()
return redirect(url_for('index'))
<!-- 模板中自动转义 -->
<p>{{ comment.content }}</p> <!-- 自动转义 -->
<p>{{ comment.content|safe }}</p> <!-- 不转义,危险!仅用于可信内容 -->

高级主题

表单继承

通过继承复用表单定义:

class BaseUserForm(FlaskForm):
"""用户表单基类"""
username = StringField('用户名', validators=[DataRequired(), Length(3, 20)])
email = StringField('邮箱', validators=[DataRequired(), Email()])

class RegistrationForm(BaseUserForm):
"""注册表单,继承基类并添加字段"""
password = PasswordField('密码', validators=[DataRequired(), Length(min=8)])
confirm = PasswordField('确认密码', validators=[
DataRequired(),
EqualTo('password', message='密码不一致')
])
submit = SubmitField('注册')

class ProfileForm(BaseUserForm):
"""个人资料表单,继承基类并修改验证"""
# 重写字段以添加/修改验证器
username = StringField('用户名', validators=[
DataRequired(),
Length(3, 20),
Unique(User, 'username')
])

bio = TextAreaField('个人简介', validators=[Length(max=500)])
submit = SubmitField('保存')

表单组合

使用 FormField 组合多个表单:

class AddressForm(FlaskForm):
"""地址表单"""
province = StringField('省份', validators=[DataRequired()])
city = StringField('城市', validators=[DataRequired()])
address = StringField('详细地址', validators=[DataRequired()])
postal_code = StringField('邮编')

class OrderForm(FlaskForm):
"""订单表单,包含多个地址"""
recipient = StringField('收件人', validators=[DataRequired()])
phone = StringField('电话', validators=[DataRequired()])

# 嵌入地址表单
shipping_address = FormField(AddressForm, label='收货地址')

submit = SubmitField('提交订单')

模板中访问嵌套表单:

<!-- 访问嵌套字段 -->
{{ form.shipping_address.province }}
{{ form.shipping_address.city }}

动态表单字段

运行时添加字段:

def create_dynamic_form(fields_config):
"""动态创建表单类"""
class DynamicForm(FlaskForm):
pass

for field_config in fields_config:
field_name = field_config['name']
field_type = field_config['type']
field_label = field_config.get('label', field_name)
field_validators = field_config.get('validators', [])

# 创建字段
field = field_type(field_label, validators=field_validators)
setattr(DynamicForm, field_name, field)

return DynamicForm

# 使用
config = [
{'name': 'name', 'type': StringField, 'label': '姓名', 'validators': [DataRequired()]},
{'name': 'age', 'type': IntegerField, 'label': '年龄', 'validators': [NumberRange(min=0)]},
]

DynamicForm = create_dynamic_form(config)
form = DynamicForm()

表单与模型

使用表单填充模型:

# 方式一:手动赋值
@app.route('/user/edit/<int:id>', methods=['GET', 'POST'])
def edit_user(id):
user = User.query.get_or_404(id)
form = UserForm(obj=user) # 使用 obj 参数填充表单

if form.validate_on_submit():
# 手动赋值
user.username = form.username.data
user.email = form.email.data
user.bio = form.bio.data

db.session.commit()
flash('保存成功', 'success')
return redirect(url_for('user.profile', id=user.id))

return render_template('user/edit.html', form=form)

# 方式二:自动填充(推荐)
@app.route('/user/edit/<int:id>', methods=['GET', 'POST'])
def edit_user(id):
user = User.query.get_or_404(id)
form = UserForm(obj=user)

if form.validate_on_submit():
# 自动将表单数据填充到模型
form.populate_obj(user)
db.session.commit()
flash('保存成功', 'success')
return redirect(url_for('user.profile', id=user.id))

return render_template('user/edit.html', form=form)

最佳实践

1. 表单与业务逻辑分离

# forms/auth.py - 表单定义
class LoginForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired()])
password = PasswordField('密码', validators=[DataRequired()])

# services/auth.py - 业务逻辑
class AuthService:
@staticmethod
def authenticate(username, password):
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
return user
return None

# views/auth.py - 视图
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = AuthService.authenticate(
form.username.data,
form.password.data
)
if user:
login_user(user)
return redirect(url_for('index'))
flash('用户名或密码错误', 'danger')
return render_template('auth/login.html', form=form)

2. 统一的错误响应格式

def form_errors_to_dict(form):
"""将表单错误转换为字典"""
errors = {}
for field_name, field_errors in form.errors.items():
errors[field_name] = field_errors
return errors

@app.route('/api/users', methods=['POST'])
def create_user():
form = UserForm(data=request.get_json())

if form.validate():
user = User(**form.data)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201

return jsonify({
'success': False,
'errors': form_errors_to_dict(form)
}), 400

3. 表单数据清洗

class CleanForm(FlaskForm):
# 使用 filters 清洗数据
username = StringField(
'用户名',
validators=[DataRequired(), Length(3, 20)],
filters=[lambda x: x.strip().lower() if x else x] # 去除空白并转小写
)

email = StringField(
'邮箱',
validators=[DataRequired(), Email()],
filters=[lambda x: x.strip().lower() if x else x]
)

4. 适当的默认值

class SearchForm(FlaskForm):
query = StringField('搜索')
category = SelectField('分类', choices=[...], default='all')
sort = SelectField('排序', choices=[
('relevance', '相关度'),
('date', '日期'),
('price', '价格')
], default='relevance')
page = IntegerField('页码', default=1)
per_page = IntegerField('每页数量', default=20)

5. 密码处理最佳实践

from werkzeug.security import generate_password_hash, check_password_hash

class PasswordMixin:
"""密码处理混入类"""
password = PasswordField('密码', validators=[
DataRequired(),
Length(min=8, message='密码至少 8 个字符')
])

def get_password_hash(self):
"""获取密码哈希"""
return generate_password_hash(self.password.data)

class UserForm(FlaskForm, PasswordMixin):
username = StringField('用户名', validators=[DataRequired()])
email = StringField('邮箱', validators=[DataRequired(), Email()])
submit = SubmitField('注册')

小结

本章深入探讨了 Flask 表单处理的各个方面:

  1. 表单基础:理解表单处理的核心问题,掌握 Flask-WTF 的基本用法
  2. 字段类型:了解各种字段类型的特点和适用场景
  3. 验证器:掌握内置验证器的用法,学会创建自定义验证器
  4. 模板渲染:学习表单渲染技巧,使用宏简化代码
  5. 安全实践:理解 CSRF、XSS 等 Web 安全威胁及防护方法
  6. 高级主题:表单继承、组合、动态字段等高级技巧

表单是 Web 应用与用户交互的核心,掌握表单处理技术对于构建安全、用户友好的应用至关重要。

参考资料