Flask表单处理与文件上传
本文是 Flask 服务器专栏的第五期,全面深入地讲解 Flask 中的表单处理与文件上传技术。内容涵盖原生表单处理、Flask-WTF 与 WTForms 表单库、字段类型、验证器、表单渲染、处理流程、文件上传、云存储集成,以及完整的实战案例。全文超过三万字,配有大量可运行的代码示例,面向有一定 Flask 基础的开发者,帮助你从"能写出表单"进阶到"精通表单处理与文件上传全流程"。
引言
在 Web 开发中,表单是用户与应用交互的最重要桥梁。无论是用户注册登录、文章发布、商品搜索,还是文件上传,几乎所有需要用户输入数据的场景都离不开表单。可以说,掌握了表单处理,就掌握了 Web 应用开发的半壁江山。
Flask 作为一个轻量级框架,本身并不强制要求你使用某个特定的表单库。你可以用最原始的方式,通过 request.form 手动获取数据、手动验证、手动返回错误信息。这种方式在简单场景下足够用,但随着表单数量增多、字段类型变复杂、验证规则变严格,手动处理的代码会迅速膨胀,变得难以维护,还容易留下安全漏洞。
这就是 Flask-WTF 和 WTForms 登场的舞台。WTForms 是一个独立的 Python 表单库,提供了字段类型、验证器、错误处理等完整能力;Flask-WTF 则是它在 Flask 中的集成层,负责 CSRF 保护、文件上传处理、与 Flask 请求上下文的对接。两者配合,让你可以用声明式的方式定义表单,用一行代码完成验证,用模板宏统一渲染风格,大幅提升开发效率和代码质量。
与此同时,文件上传是表单处理中最复杂、最容易出问题的部分。文件上传涉及 multipart/form-data 编码、文件名安全处理、文件类型与大小验证、存储路径管理、大文件分片上传等一系列问题。处理不当,轻则用户体验差,重则导致服务器被恶意文件攻击、磁盘被撑爆、敏感数据泄露。
很多 Flask 开发者在处理表单时,常常遇到以下困惑:
request.form和request.args到底什么时候用哪个?- CSRF 攻击是什么?为什么表单里一定要加那个
csrf_token? - WTForms 的字段类型这么多,分别什么时候用?
SelectField的choices怎么动态生成? - 验证器怎么组合使用?怎么写自定义验证器?
- 表单验证失败后,如何优雅地把用户已填的数据回显,并显示错误信息?
- 文件上传时,
secure_filename到底做了什么?为什么不能直接用用户传来的文件名? - 如何限制上传文件的大小和类型?如何防止上传可执行文件?
- 如何把文件上传到 AWS S3、阿里云 OSS 等云存储?
- 如何用 AJAX 异步提交表单,实现无刷新体验?
- 如何实现多步骤向导式表单?如何动态增减表单字段?
本文将从以上问题出发,系统而深入地讲解 Flask 表单处理与文件上传的方方面面。全文共十章,从表单基础概念讲起,逐步深入到 WTForms 字段与验证器、表单渲染与处理流程、文件上传与云存储,最后通过大量完整实战案例将所学知识融会贯通。每一章都配有完整的可运行代码示例,确保你看完就能上手。让我们开始这段进阶之旅。
在阅读本文之前,建议你已经具备以下基础:了解 Flask 的基本路由和视图函数写法、了解 Jinja2 模板的基本语法、了解 HTTP 协议中 GET 和 POST 请求的区别、掌握 Python 面向对象编程的基本概念。如果你对这些基础知识还不熟悉,建议先阅读本系列的前几期文章,打好基础后再来学习表单处理。
本文的代码示例基于 Flask 3.x、Flask-WTF 1.x、WTForms 3.x 版本,所有代码均在 Python 3.10+ 环境下测试通过。文中涉及的第三方库包括 Flask-WTF、WTForms、Flask-SQLAlchemy、Flask-Uploads、Flask-Reuploaded、Bootstrap-Flask、Flask-Babel、boto3、Pillow、bleach 等,建议在虚拟环境中安装使用。
此外,本文不仅关注功能的实现,更注重安全性、可维护性和用户体验。每个代码示例都遵循安全编码的最佳实践,避免常见的安全漏洞。每章末尾都有实践建议和注意事项,帮助你在实际项目中少走弯路。
第一章 表单处理基础
1.1 Web表单的概念与组成
Web 表单是 HTML 页面中用于收集用户输入数据的一组控件集合。当用户在表单中填写信息并点击提交按钮后,浏览器会将这些数据按照特定的编码格式打包,通过 HTTP 请求发送给服务器。服务器接收数据后进行验证、处理,并返回响应结果。
一个完整的 Web 表单由以下几个核心部分组成:
<form>标签 :表单的容器,定义数据提交的目标 URL(action)和提交方式(method)。- 输入控件:如文本框、密码框、下拉选择、单选/复选按钮、文本域、文件选择框等,用于接收用户输入。
<label>标签:为输入控件提供语义化的标签说明,提升可访问性。- 提交按钮:用户点击后触发表单提交。
- 隐藏字段:用于传递不需要用户感知的数据,如 CSRF 令牌、记录 ID 等。
从数据流的角度看,表单处理的完整链路如下:
用户填写表单 → 浏览器打包数据 → HTTP请求(POST/GET) → Flask接收(request) → 验证数据 → 处理业务 → 返回响应
下面是一个最简单的 HTML 表单示例:
html
<form action="/submit" method="post">
<label for="username">用户名:</label>
<input type="text" id="username" name="username" required>
<label for="email">邮箱:</label>
<input type="email" id="email" name="email" required>
<button type="submit">提交</button>
</form>
在这个表单中,action="/submit" 表示数据将提交到 /submit 这个 URL,method="post" 表示使用 POST 方式提交。两个 <input> 控件的 name 属性非常关键------它就是服务器端获取数据时的键名。
1.1.1 表单数据传输机制
理解表单数据的传输机制,有助于我们在后端正确地接收和处理数据。当用户点击提交按钮时,浏览器会执行以下步骤:
第一步,浏览器收集表单中所有带有 name 属性的控件值,构建一个键值对集合。没有 name 属性的控件值不会被提交。禁用的控件(disabled)也不会被提交,但只读控件(readonly)的值会被提交。
第二步,浏览器根据 enctype 属性对数据进行编码。对于默认的 application/x-www-form-urlencoded 编码,键值对会被拼接成 key1=value1&key2=value2 的格式,其中空格被替换为 +,特殊字符被 URL 编码。例如,用户名 张三 会被编码为 %E5%BC%A0%E4%B8%89。
第三步,浏览器将编码后的数据放入 HTTP 请求中。GET 请求将数据附加在 URL 的查询字符串中(如 /submit?username=张三&email=test@test.com),POST 请求将数据放在请求体中。
第四步,Flask 接收到请求后,根据请求方法和 Content-Type 自动解析数据。request.form 包含 POST 请求体中的表单数据,request.args 包含 URL 查询字符串中的参数,request.files 包含上传的文件,request.values 则是 form 和 args 的合并。
1.1.2 表单控件的 name 属性规则
name 属性是表单控件与服务端交互的关键标识,有以下重要规则需要注意:
对于同名的多个控件(如一组复选框),浏览器会将所有值都提交。在 Flask 中,request.form.getlist('hobbies') 可以获取所有值,而 request.form.get('hobbies') 只返回第一个值。
html
<!-- 同名复选框 -->
<input type="checkbox" name="hobbies" value="reading"> 阅读
<input type="checkbox" name="hobbies" value="music"> 音乐
<input type="checkbox" name="hobbies" value="sports"> 运动
<!-- 提交后:request.form.getlist('hobbies') => ['reading', 'music'] -->
对于没有选中任何选项的单选/复选框,浏览器不会提交任何值。这意味着服务端需要处理字段不存在的情况,而不是假设值为空字符串。
python
# 正确处理可能不存在的字段
hobbies = request.form.getlist('hobbies') # 返回 [],而不是 None
agree = request.form.get('agree') # 未勾选时返回 None,不是 'off'
1.1.3 表单设计中的常见概念
在深入代码之前,我们需要了解几个表单设计中的核心概念:
表单状态(Form State):表单在不同时刻有不同的状态------初始状态(空表单)、填写中状态(用户正在输入)、提交状态(已提交等待响应)、验证失败状态(显示错误并回显数据)、验证成功状态(处理完成并重定向)。理解这些状态的转换是设计良好表单处理流程的基础。
表单上下文(Form Context):表单总是在特定的业务上下文中使用。一个注册表单的上下文是"用户尚未登录,想要创建账号";一个编辑表单的上下文是"用户已登录,想要修改已有数据"。上下文决定了表单的字段构成、验证规则和处理逻辑。
表单生命周期(Form Lifecycle):从表单定义(编写 Form 类)到表单渲染(模板输出 HTML)到表单提交(用户填写并提交)到表单验证(服务端检查)到表单处理(业务逻辑)再到表单响应(成功重定向或失败回显),这就是表单的完整生命周期。后续章节将逐一讲解生命周期中的每个环节。
1.2 HTML表单元素回顾
在深入 Flask 表单处理之前,我们先系统回顾一下 HTML 中与表单相关的核心元素。虽然这些是前端知识,但理解它们对于后端正确处理表单数据至关重要。
1.2.1 <form> 元素
<form> 是表单的根容器,常用属性如下:
| 属性 | 说明 | 示例 |
|---|---|---|
action |
提交目标 URL | action="/login" |
method |
提交方式,GET 或 POST | method="post" |
enctype |
编码类型 | enctype="multipart/form-data" |
target |
响应展示窗口 | target="_blank" |
autocomplete |
自动填充 | autocomplete="off" |
novalidate |
禁用浏览器原生验证 | novalidate |
其中 enctype 属性尤其重要,它决定了表单数据的编码方式:
application/x-www-form-urlencoded(默认):所有字符都会被编码,空格变为+,特殊字符转为十六进制。适用于普通文本数据。multipart/form-data:不对字符编码,以二进制流传输。文件上传时必须使用此编码。text/plain:空格变为+,但不编码特殊字符。极少使用。
html
<!-- 普通文本表单 -->
<form action="/register" method="post">
...
</form>
<!-- 文件上传表单,必须设置 enctype -->
<form action="/upload" method="post" enctype="multipart/form-data">
...
</form>
1.2.2 <input> 元素
<input> 是最灵活的表单控件,通过 type 属性可以变身为各种输入框:
html
<!-- 文本框 -->
<input type="text" name="username">
<!-- 密码框,输入内容显示为圆点 -->
<input type="password" name="password">
<!-- 邮箱框,移动端会弹出邮箱键盘 -->
<input type="email" name="email">
<!-- 数字框 -->
<input type="number" name="age" min="0" max="150">
<!-- 日期选择器 -->
<input type="date" name="birthday">
<!-- 时间选择器 -->
<input type="time" name="appointment">
<!-- 日期时间选择器 -->
<input type="datetime-local" name="schedule">
<!-- 文件选择框 -->
<input type="file" name="avatar">
<!-- 多文件选择 -->
<input type="file" name="photos" multiple>
<!-- 隐藏字段 -->
<input type="hidden" name="csrf_token" value="abc123">
<!-- 复选框 -->
<input type="checkbox" name="agree" value="yes">
<!-- 单选按钮 -->
<input type="radio" name="gender" value="male"> 男
<input type="radio" name="gender" value="female"> 女
<!-- 范围滑块 -->
<input type="range" name="volume" min="0" max="100">
<!-- 颜色选择器 -->
<input type="color" name="theme_color">
<!-- 提交按钮 -->
<input type="submit" value="提交">
<!-- 重置按钮 -->
<input type="reset" value="重置">
<!-- 普通按钮 -->
<input type="button" value="点击" onclick="alert('hello')">
<!-- URL 输入框 -->
<input type="url" name="website">
<!-- 电话号码输入框 -->
<input type="tel" name="phone">
<!-- 搜索框 -->
<input type="search" name="keyword">
1.2.3 <select> 下拉选择
html
<select name="city">
<option value="">请选择城市</option>
<option value="beijing">北京</option>
<option value="shanghai" selected>上海</option>
<option value="guangzhou">广州</option>
</select>
<!-- 多选下拉,size指定可见行数,multiple允许多选 -->
<select name="hobbies" size="4" multiple>
<option value="reading">阅读</option>
<option value="music">音乐</option>
<option value="sports">运动</option>
<option value="travel">旅行</option>
</select>
1.2.4 <textarea> 文本域
html
<textarea name="content" rows="10" cols="50" placeholder="请输入内容..."></textarea>
1.2.5 <button> 按钮
html
<button type="submit">提交</button>
<button type="reset">重置</button>
<button type="button" onclick="doSomething()">点击</button>
1.2.6 <label> 标签
<label> 用于将文字与表单控件关联,点击文字也能聚焦控件:
html
<!-- 方式一:使用 for 属性指向控件的 id -->
<label for="username">用户名:</label>
<input type="text" id="username" name="username">
<!-- 方式二:将控件包裹在 label 内部 -->
<label>
用户名: <input type="text" name="username">
</label>
1.3 表单提交方式(GET vs POST)
表单的提交方式由 <form> 的 method 属性决定,主要有 GET 和 POST 两种。理解它们的区别对于正确设计表单至关重要。
1.3.1 GET 提交
GET 方式将表单数据以查询字符串的形式附加在 URL 后面:
/search?keyword=flask&page=1
特点:
- 数据出现在 URL 中,对用户可见
- 数据长度受 URL 长度限制(浏览器限制,通常 2KB~8KB)
- 不适合传输敏感信息(密码等)
- 适合幂等操作(查询、过滤、排序)
- 可以被收藏为书签
- 会被浏览器历史记录缓存
- 请求可以被重试而不会产生副作用
html
<form action="/search" method="get">
<input type="search" name="keyword">
<button type="submit">搜索</button>
</form>
1.3.2 POST 提交
POST 方式将表单数据放在 HTTP 请求体中:
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=admin&password=123456
特点:
- 数据在请求体中,不会出现在 URL
- 数据长度理论上无限制(受服务器配置约束)
- 适合传输敏感信息和大量数据
- 适合非幂等操作(创建、修改、删除)
- 不能被收藏为书签
- 浏览器重试提交时会弹出确认对话框(防止重复提交)
- 文件上传必须使用 POST
html
<form action="/login" method="post">
<input type="text" name="username">
<input type="password" name="password">
<button type="submit">登录</button>
</form>
1.3.3 GET 与 POST 对比
| 对比项 | GET | POST |
|---|---|---|
| 数据位置 | URL 查询字符串 | 请求体 |
| 数据可见性 | 可见 | 不可见 |
| 数据长度 | 有限制(2~8KB) | 理论上无限制 |
| 安全性 | 低(历史记录可见) | 较高 |
| 幂等性 | 幂等 | 非幂等 |
| 缓存 | 可被缓存 | 不缓存 |
| 书签 | 可收藏 | 不可收藏 |
| 编码类型 | application/x-www-form-urlencoded | 多种(multipart/form-data 等) |
| 适用场景 | 搜索、过滤、分页 | 登录、注册、提交数据、文件上传 |
| 后退按钮 | 无害 | 会提示重新提交 |
1.3.4 其他 HTTP 方法
虽然 HTML 表单原生只支持 GET 和 POST,但在 Flask 中可以通过隐藏字段模拟 PUT、DELETE 等方法:
html
<form action="/article/1" method="post">
<input type="hidden" name="_method" value="DELETE">
<button type="submit">删除</button>
</form>
在 Flask 中配合 MethodOverrideMiddleware 或 Flask-WTF 的一些扩展即可识别这些方法。
1.4 Flask中获取表单数据
Flask 提供了 request 对象来获取客户端提交的表单数据。根据提交方式的不同,数据存放的位置也不同。
1.4.1 request.form(POST 数据)
当表单以 POST 方式提交时,数据存放在 request.form 中,它是一个类似字典的 ImmutableMultiDict 对象:
python
from flask import Flask, request, render_template
app = Flask(__name__)
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
# 处理登录逻辑
return f'用户名: {username}, 密码: {password}'
return render_template('login.html')
request.form 的常用操作:
python
# 获取单个值
username = request.form.get('username')
username = request.form['username'] # 键不存在会抛出 KeyError
# 获取多个同名值(如多选框)
hobbies = request.form.getlist('hobbies')
# 遍历所有数据
for key, value in request.form.items():
print(f'{key}: {value}')
# 转为普通字典
data = request.form.to_dict()
# 转为多值字典(键对应值列表)
data_multi = request.form.to_dict(flat=False)
# 检查键是否存在
if 'username' in request.form:
pass
1.4.2 request.args(GET 数据)
当表单以 GET 方式提交时,数据以查询字符串附加在 URL 上,通过 request.args 获取:
python
@app.route('/search')
def search():
keyword = request.args.get('keyword', '')
page = request.args.get('page', 1, type=int)
return f'搜索关键词: {keyword}, 页码: {page}'
访问 /search?keyword=flask&page=2 时,request.args 的内容为 {'keyword': 'flask', 'page': '2'}。
注意 request.args.get('page', 1, type=int) 的第三个参数 type,它可以让 Flask 自动将字符串转为指定类型,转换失败时返回默认值。
1.4.3 request.values(合并数据)
request.values 合并了 request.form 和 request.args 的数据,优先级是 form > args:
python
@app.route('/submit', methods=['GET', 'POST'])
def submit():
# 无论 GET 还是 POST 都能获取到数据
data = request.values.get('data')
return f'数据: {data}'
1.4.4 request.files(文件数据)
文件上传的数据存放在 request.files 中:
python
@app.route('/upload', methods=['POST'])
def upload():
file = request.files.get('file')
if file:
filename = file.filename
file.save(f'uploads/{filename}')
return '上传成功'
return '未选择文件'
1.4.5 request.json(JSON 数据)
当前端以 AJAX 方式提交 JSON 数据时:
python
@app.route('/api/submit', methods=['POST'])
def api_submit():
data = request.get_json()
username = data.get('username')
return {'status': 'ok', 'username': username}
1.4.6 各数据源对比
| 属性 | 数据来源 | 提交方式 | 典型场景 |
|---|---|---|---|
request.form |
请求体 | POST | 表单提交 |
request.args |
URL 查询字符串 | GET | 搜索、分页 |
request.values |
form + args | 任意 | 通用获取 |
request.files |
请求体(multipart) | POST | 文件上传 |
request.json |
请求体(JSON) | POST | API 接口 |
request.data |
请求体(原始) | POST | 原始数据 |
1.5 手动处理表单(原生Flask方式)
在没有使用任何表单库的情况下,我们可以用纯 Flask 手动处理表单。下面通过一个完整的用户注册示例来演示原生表单处理的全流程。
1.5.1 项目结构
manual_form/
├── app.py
├── templates/
│ ├── register.html
│ └── success.html
1.5.2 后端代码
python
# app.py
import re
import os
from flask import Flask, render_template, request, redirect, url_for, flash
app = Flask(__name__)
app.secret_key = 'your-secret-key-here'
# 模拟数据库
users_db = {}
def validate_email(email):
"""简单的邮箱格式验证"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
@app.route('/register', methods=['GET', 'POST'])
def register():
errors = {}
form_data = {}
if request.method == 'POST':
# 获取表单数据
username = request.form.get('username', '').strip()
email = request.form.get('email', '').strip()
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
agree = request.form.get('agree')
# 保存用户已输入的数据,用于回显
form_data = {
'username': username,
'email': email,
}
# 验证用户名
if not username:
errors['username'] = '用户名不能为空'
elif len(username) < 3:
errors['username'] = '用户名至少3个字符'
elif len(username) > 20:
errors['username'] = '用户名最多20个字符'
elif username in users_db:
errors['username'] = '用户名已被注册'
# 验证邮箱
if not email:
errors['email'] = '邮箱不能为空'
elif not validate_email(email):
errors['email'] = '邮箱格式不正确'
elif any(u['email'] == email for u in users_db.values()):
errors['email'] = '邮箱已被注册'
# 验证密码
if not password:
errors['password'] = '密码不能为空'
elif len(password) < 6:
errors['password'] = '密码至少6个字符'
# 验证确认密码
if password != confirm_password:
errors['confirm_password'] = '两次密码不一致'
# 验证协议同意
if not agree:
errors['agree'] = '请同意用户协议'
# 验证通过,保存用户
if not errors:
users_db[username] = {
'email': email,
'password': password # 实际项目中应使用 hash
}
flash('注册成功!请登录。', 'success')
return redirect(url_for('success'))
return render_template('register.html', errors=errors, form_data=form_data)
@app.route('/success')
def success():
return render_template('success.html')
if __name__ == '__main__':
os.makedirs('templates', exist_ok=True)
app.run(debug=True)
1.5.3 前端模板
html
<!-- templates/register.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户注册</title>
<style>
body { font-family: Arial, sans-serif; max-width: 500px; margin: 50px auto; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input[type="text"], input[type="email"], input[type="password"] {
width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
box-sizing: border-box;
}
.error { color: red; font-size: 12px; margin-top: 3px; }
.checkbox-group { display: flex; align-items: center; gap: 8px; }
button { padding: 10px 20px; background: #007bff; color: white; border: none;
border-radius: 4px; cursor: pointer; }
button:hover { background: #0056b3; }
.flash { padding: 10px; margin-bottom: 15px; border-radius: 4px; }
.flash.success { background: #d4edda; color: #155724; }
</style>
</head>
<body>
<h1>用户注册</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form action="/register" method="post">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username"
value="{{ form_data.get('username', '') }}">
{% if errors.get('username') %}
<div class="error">{{ errors.username }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="email">邮箱</label>
<input type="email" id="email" name="email"
value="{{ form_data.get('email', '') }}">
{% if errors.get('email') %}
<div class="error">{{ errors.email }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password">
{% if errors.get('password') %}
<div class="error">{{ errors.password }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password">
{% if errors.get('confirm_password') %}
<div class="error">{{ errors.confirm_password }}</div>
{% endif %}
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="agree" name="agree" value="yes">
<label for="agree" style="font-weight: normal;">我已阅读并同意<a href="#">用户协议</a></label>
</div>
{% if errors.get('agree') %}
<div class="error">{{ errors.agree }}</div>
{% endif %}
</div>
<button type="submit">注册</button>
</form>
</body>
</html>
html
<!-- templates/success.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>注册成功</title>
</head>
<body>
<h1>注册成功!</h1>
<p>感谢您的注册。</p>
<a href="/register">返回注册页</a>
</body>
</html>
1.5.4 手动处理的痛点
上面的代码虽然能工作,但已经暴露出原生处理的诸多问题:
- 验证代码冗长:每个字段都需要手动写 if-else 判断,逻辑重复。
- 数据回显繁琐:需要手动保存用户已输入的数据,并在模板中逐个回填。
- 错误处理分散:错误信息的收集和展示分散在代码各处,容易遗漏。
- 没有 CSRF 保护:表单容易被跨站请求伪造攻击。
- 难以复用:如果另一个页面也需要注册表单,几乎要复制全部代码。
- 类型转换手动:数字、日期等需要手动转换,容易出错。
- 扩展性差:增加字段或修改验证规则需要改动大量代码。
这些问题正是表单库要解决的核心痛点。
1.6 表单CSRF防护原理
CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种常见的 Web 安全漏洞。理解它对于安全地处理表单至关重要。
1.6.1 什么是 CSRF 攻击
CSRF 攻击的原理是:攻击者诱导已登录用户在不知情的情况下,向目标网站发送恶意请求,利用用户的登录凭证(Cookie)完成操作。
攻击场景举例:
- 用户 A 登录了银行网站
bank.com,浏览器保存了登录 Cookie。 - 用户 A 在未退出银行网站的情况下,访问了攻击者的恶意网站
evil.com。 evil.com的页面中包含一个自动提交的表单,指向bank.com/transfer,收款人是攻击者。- 由于浏览器会自动携带
bank.com的 Cookie,银行网站误以为是用户 A 的合法操作,执行了转账。
html
<!-- evil.com 上的恶意代码 -->
<form action="http://bank.com/transfer" method="post" id="csrf-form">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById('csrf-form').submit();</script>
1.6.2 CSRF 防护原理
CSRF 防护的核心思路是:在表单中加入一个攻击者无法获取的随机令牌(Token),服务器在处理请求时验证令牌是否有效。
由于攻击者的恶意网站无法读取目标网站的 Cookie(同源策略限制),也就无法获取这个令牌,因此无法伪造合法请求。
1.6.3 Flask 中手动实现 CSRF
在不使用 Flask-WTF 的情况下,可以手动实现 CSRF 保护:
python
import secrets
from flask import Flask, render_template, request, session, abort
app = Flask(__name__)
app.secret_key = 'your-secret-key'
@app.before_request
def generate_csrf_token():
"""为每个请求生成 CSRF 令牌"""
if 'csrf_token' not in session:
session['csrf_token'] = secrets.token_hex(32)
@app.route('/form', methods=['GET', 'POST'])
def form():
if request.method == 'POST':
# 验证 CSRF 令牌
token_form = request.form.get('csrf_token')
token_session = session.get('csrf_token')
if not token_form or token_form != token_session:
abort(400, 'CSRF token validation failed')
# 处理表单...
return '提交成功'
return render_template('form.html', csrf_token=session['csrf_token'])
html
<!-- templates/form.html -->
<form action="/form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="data">
<button type="submit">提交</button>
</form>
1.6.4 CSRF 防护要点
- 令牌要足够随机 :使用
secrets模块而非random。 - 令牌要与用户会话绑定:存储在 session 中,每个用户不同。
- 令牌要有有效期:过期后需要重新生成。
- 所有 POST/PUT/DELETE 请求都要验证:GET 请求通常不需要(因为 GET 应该是幂等的)。
- 令牌不要出现在 URL 中:避免通过 Referer 泄露。
Flask-WTF 会自动处理所有这些细节,后面会详细讲解。
1.7 表单处理的挑战(验证, 错误处理, 重新填充)
表单处理看似简单------获取数据、验证、保存------但实际开发中会面临诸多挑战。
1.7.1 数据验证的复杂性
一个字段可能需要多重验证,且验证规则各不相同:
python
# 用户名:非空 + 长度限制 + 格式限制 + 唯一性检查
username = request.form.get('username', '').strip()
if not username:
errors['username'] = '用户名不能为空'
elif len(username) < 3 or len(username) > 20:
errors['username'] = '用户名长度必须为3-20个字符'
elif not re.match(r'^[a-zA-Z0-9_]+$', username):
errors['username'] = '用户名只能包含字母、数字和下划线'
elif username in users_db:
errors['username'] = '用户名已被注册'
对于日期、数字等类型,还需要手动做类型转换,转换失败时给出友好提示:
python
age_str = request.form.get('age', '')
try:
age = int(age_str)
if age < 0 or age > 150:
errors['age'] = '年龄必须在0-150之间'
except ValueError:
errors['age'] = '年龄必须是整数'
1.7.2 错误信息的收集与展示
错误信息需要在验证过程中收集,然后在模板中按字段展示。手动处理时,错误信息通常存在一个字典中:
python
errors = {}
# ... 各种验证 ...
if not errors:
# 全部通过,处理数据
pass
else:
# 有错误,重新显示表单
return render_template('form.html', errors=errors, ...)
问题在于,如果某个字段有多个验证规则,通常只显示第一个错误就停止,还是显示所有错误?手动处理很难统一这种策略。
1.7.3 表单数据回显
当验证失败时,用户体验最佳的做法是:保留用户已填写的数据,标记出错误字段,让用户只需修改错误部分即可重新提交。
手动实现回显需要在模板中对每个字段都做处理:
html
<input type="text" name="username" value="{{ form_data.username if form_data.username else '' }}">
对于复选框、下拉选择等控件,回显逻辑更加复杂:
html
<select name="city">
<option value="beijing" {% if form_data.city == 'beijing' %}selected{% endif %}>北京</option>
<option value="shanghai" {% if form_data.city == 'shanghai' %}selected{% endif %}>上海</option>
</select>
<input type="checkbox" name="hobbies" value="reading"
{% if 'reading' in form_data.hobbies %}checked{% endif %}>
1.7.4 手动处理表单的完整痛点演示
为了更直观地感受手动处理表单的痛苦,下面是一个"纯手工"的用户注册表单处理代码。请注意代码的冗长度和容易出错的地方:
python
@app.route('/register', methods=['GET', 'POST'])
def register_manual():
errors = {}
form_data = {}
if request.method == 'POST':
# 1. 逐个获取字段值
username = request.form.get('username', '').strip()
email = request.form.get('email', '').strip()
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
agree = request.form.get('agree')
# 保留用户已输入的数据(用于回显)
form_data = {
'username': username,
'email': email,
'agree': agree
}
# 2. 逐个验证字段(大量 if-else)
if not username:
errors['username'] = '用户名不能为空'
elif len(username) < 3:
errors['username'] = '用户名至少3个字符'
elif len(username) > 20:
errors['username'] = '用户名最多20个字符'
elif not re.match(r'^[a-zA-Z0-9_]+$', username):
errors['username'] = '用户名只能包含字母、数字和下划线'
else:
# 查数据库检查唯一性
existing = User.query.filter_by(username=username).first()
if existing:
errors['username'] = '该用户名已被注册'
if not email:
errors['email'] = '邮箱不能为空'
elif not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
errors['email'] = '邮箱格式不正确'
else:
existing = User.query.filter_by(email=email).first()
if existing:
errors['email'] = '该邮箱已被注册'
if not password:
errors['password'] = '密码不能为空'
elif len(password) < 8:
errors['password'] = '密码至少8位'
elif not (re.search(r'[a-z]', password) and
re.search(r'[A-Z]', password) and
re.search(r'\d', password)):
errors['password'] = '密码必须包含大小写字母和数字'
if password != confirm_password:
errors['confirm_password'] = '两次输入的密码不一致'
if not agree:
errors['agree'] = '请同意用户协议'
# 3. 验证通过后处理
if not errors:
user = User(username=username, email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('注册成功', 'success')
return redirect(url_for('login'))
# 4. 渲染表单(GET请求或验证失败)
return render_template('register_manual.html', errors=errors, form_data=form_data)
对应的 HTML 模板同样冗长,每个字段都需要手动处理值回显和错误显示:
html
<form method="post">
<!-- 用户名 -->
<div class="form-group">
<label>用户名</label>
<input type="text" name="username"
value="{{ form_data.username|default('') }}"
class="form-control {% if errors.username %}is-invalid{% endif %}">
{% if errors.username %}
<div class="invalid-feedback">{{ errors.username }}</div>
{% endif %}
</div>
<!-- 邮箱 -->
<div class="form-group">
<label>邮箱</label>
<input type="email" name="email"
value="{{ form_data.email|default('') }}"
class="form-control {% if errors.email %}is-invalid{% endif %}">
{% if errors.email %}
<div class="invalid-feedback">{{ errors.email }}</div>
{% endif %}
</div>
<!-- 密码 -->
<div class="form-group">
<label>密码</label>
<input type="password" name="password"
class="form-control {% if errors.password %}is-invalid{% endif %}">
{% if errors.password %}
<div class="invalid-feedback">{{ errors.password }}</div>
{% endif %}
</div>
<!-- 确认密码 -->
<div class="form-group">
<label>确认密码</label>
<input type="password" name="confirm_password"
class="form-control {% if errors.confirm_password %}is-invalid{% endif %}">
{% if errors.confirm_password %}
<div class="invalid-feedback">{{ errors.confirm_password }}</div>
{% endif %}
</div>
<!-- 协议复选框 -->
<div class="form-check">
<input type="checkbox" name="agree" value="yes" class="form-check-input"
{% if form_data.agree %}checked{% endif %}>
<label class="form-check-label">我已阅读并同意用户协议</label>
{% if errors.agree %}
<div class="invalid-feedback">{{ errors.agree }}</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">注册</button>
</form>
上面的代码有几个明显的问题:验证逻辑与视图函数耦合、错误收集完全手动、模板中每个字段都要重复写回显和错误显示逻辑、没有 CSRF 保护、类型转换需要手动处理(如数字字段需要 int() 转换并捕获异常)。这还只是一个注册表单,如果有几十个表单,维护成本将极其高昂。
1.7.5 代码重复与可维护性
当应用中有大量表单时,手动处理会导致大量重复代码。每个表单都需要:获取数据、验证、收集错误、回显数据------这套流程在每个视图函数中都要写一遍。更糟糕的是,如果验证规则需要修改(比如密码最小长度从 8 改为 10),你需要在多个地方查找和修改,极易遗漏。
1.8 为什么需要表单库
综合以上分析,手动处理表单存在以下痛点,而表单库正是为解决这些问题而生:
| 痛点 | 手动处理 | 表单库(WTForms) |
|---|---|---|
| 字段定义 | 在 HTML 和 Python 中各写一遍 | 一次定义,前后端共用 |
| 数据验证 | 大量 if-else | 声明式验证器 |
| 类型转换 | 手动 try-except | 自动转换 |
| 错误收集 | 手动维护字典 | 自动收集 |
| 数据回显 | 手动逐字段回填 | 自动回显 |
| CSRF 保护 | 手动实现 | 自动处理 |
| 代码复用 | 几乎无法复用 | 表单类可继承组合 |
| 模板渲染 | 手写 HTML | 可用宏统一渲染 |
WTForms 的设计理念是:将表单的结构、验证规则、渲染方式以声明式的方式集中定义在一个 Python 类中,实现"一次定义,处处使用"。
下面是同样的注册表单,用 WTForms 改写后的效果(预览,后面会详细讲解):
python
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email, EqualTo
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(message='用户名不能为空'),
Length(min=3, max=20, message='用户名长度必须为3-20个字符')
])
email = StringField('邮箱', validators=[
DataRequired(message='邮箱不能为空'),
Email(message='邮箱格式不正确')
])
password = PasswordField('密码', validators=[
DataRequired(message='密码不能为空'),
Length(min=6, message='密码至少6个字符')
])
confirm_password = PasswordField('确认密码', validators=[
DataRequired(message='请确认密码'),
EqualTo('password', message='两次密码不一致')
])
agree = BooleanField('我已阅读并同意用户协议', validators=[
DataRequired(message='请同意用户协议')
])
submit = SubmitField('注册')
对比之前几十行的手动验证代码,WTForms 的声明式写法简洁了数倍,而且验证规则与字段定义在一起,可读性和可维护性都大幅提升。从下一章开始,我们将深入学习 WTForms 和 Flask-WTF 的方方面面。
第二章 Flask-WTF与WTForms
2.1 WTForms概述与安装
WTForms 是一个灵活的 Python 表单验证和渲染库。它的设计目标是将表单的定义、验证逻辑和渲染方式解耦,让开发者可以用面向对象的方式管理表单。
WTForms 的核心特性包括:
- 声明式字段定义:通过类属性的方式定义表单字段,类型清晰。
- 内置验证器:提供常用验证规则,如必填、长度、邮箱、URL 等。
- 可扩展性:支持自定义字段类型和验证器。
- 框架无关:WTForms 本身不依赖任何 Web 框架,可以在 Flask、Django、Bottle 等框架中使用。
- HTML 渲染:内置字段渲染能力,可直接生成 HTML 控件。
安装 WTForms:
bash
pip install WTForms
安装后可以在 Python 中直接使用:
python
from wtforms import Form, StringField, validators
class ContactForm(Form):
name = StringField('姓名', [validators.DataRequired()])
email = StringField('邮箱', [validators.Email()])
上面是 WTForms 原生的用法,使用了 wtforms.Form 基类。在 Flask 中,我们通常使用 Flask-WTF 提供的 FlaskForm 基类,它在此基础上增加了 Flask 集成能力。
2.2 Flask-WTF概述与安装
Flask-WTF 是 WTForms 在 Flask 中的集成层。它的主要职责是:
- CSRF 保护:自动为所有 POST 表单生成和验证 CSRF 令牌。
- 文件上传支持 :提供
FileField和MultipleFileField,处理文件上传验证。 - Flask 集成 :自动从
request.form、request.files中读取数据填充表单。 - Recaptcha 支持:集成 Google reCAPTCHA 验证码。
- 本地化:支持 Flask-Babel 进行表单消息的国际化。
安装 Flask-WTF:
bash
pip install Flask-WTF
Flask-WTF 安装时会自动安装 WTForms 作为依赖。
一个最小化的 Flask-WTF 示例:
python
from flask import Flask, render_template, redirect, url_for, flash
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key' # Flask-WTF 需要
class NameForm(FlaskForm):
name = StringField('你的名字', validators=[DataRequired()])
submit = SubmitField('提交')
@app.route('/', methods=['GET', 'POST'])
def index():
form = NameForm()
if form.validate_on_submit():
flash(f'你好, {form.name.data}!')
return redirect(url_for('index'))
return render_template('index.html', form=form)
html
<!-- templates/index.html -->
<form method="post">
{{ form.hidden_tag() }}
{{ form.name.label }} {{ form.name() }}
{% for error in form.name.errors %}
<span style="color: red;">{{ error }}</span>
{% endfor %}
{{ form.submit() }}
</form>
2.3 Flask-WTF配置
Flask-WTF 通过 Flask 的配置项来控制其行为。以下是所有相关配置项:
2.3.1 核心配置项
python
app = Flask(__name__)
# === 基础配置 ===
app.config['SECRET_KEY'] = 'your-secret-key' # 必须设置,用于 session 和 CSRF
# === CSRF 配置 ===
app.config['WTF_CSRF_ENABLED'] = True # 是否启用 CSRF 保护,默认 True
app.config['WTF_CSRF_SECRET_KEY'] = 'csrf-secret' # CSRF 令牌的加密密钥,默认使用 SECRET_KEY
app.config['WTF_CSRF_TIME_LIMIT'] = 3600 # CSRF 令牌有效期(秒),默认 3600
app.config['WTF_CSRF_SSL_STRICT'] = True # 是否强制 HTTPS 校验 Referer,默认 True
app.config['WTF_CSRF_HEADERS'] = ['X-CSRFToken', 'X-CSRF-Token'] # 接受 CSRF 令牌的请求头
app.config['WTF_CSRF_FIELD_NAME'] = 'csrf_token' # CSRF 令牌的字段名,默认 'csrf_token'
app.config['WTF_CSRF_METHODS'] = ['POST', 'PUT', 'PATCH', 'DELETE'] # 需要 CSRF 保护的 HTTP 方法
# === 文件上传配置 ===
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 最大请求体 16MB
app.config['WTF_FIELD_SUFFIX'] = '' # 字段后缀,一般不修改
2.3.2 配置项详解
| 配置项 | 默认值 | 说明 |
|---|---|---|
SECRET_KEY |
无(必须设置) | Flask session 加密密钥 |
WTF_CSRF_ENABLED |
True |
是否启用 CSRF 保护 |
WTF_CSRF_SECRET_KEY |
同 SECRET_KEY |
CSRF 令牌签名密钥 |
WTF_CSRF_TIME_LIMIT |
3600 |
令牌有效期(秒),None 表示不过期 |
WTF_CSRF_SSL_STRICT |
True |
HTTPS 下是否严格校验 Referer |
WTF_CSRF_HEADERS |
['X-CSRFToken', 'X-CSRF-Token'] |
AJAX 请求传递令牌的请求头 |
WTF_CSRF_FIELD_NAME |
'csrf_token' |
表单中 CSRF 字段名 |
WTF_CSRF_METHODS |
['POST', 'PUT', 'PATCH', 'DELETE'] |
需要 CSRF 保护的 HTTP 方法 |
MAX_CONTENT_LENGTH |
None |
请求体最大字节数,超出返回 413 |
2.3.3 在不同环境下的配置
实际项目中,通常使用不同环境不同配置的策略:
python
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = 3600
class DevelopmentConfig(Config):
DEBUG = True
WTF_CSRF_ENABLED = False # 开发环境可关闭 CSRF 方便测试
SECRET_KEY = 'dev-secret-key'
class ProductionConfig(Config):
DEBUG = False
WTF_CSRF_ENABLED = True
WTF_CSRF_SSL_STRICT = True
SECRET_KEY = os.environ.get('SECRET_KEY') # 生产环境必须从环境变量读取
class TestingConfig(Config):
TESTING = True
WTF_CSRF_ENABLED = False # 测试环境关闭 CSRF
SECRET_KEY = 'test-secret-key'
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}
在测试中关闭 CSRF 是常见做法,可以避免在测试代码中手动注入 CSRF 令牌。
2.4 CSRF保护初始化
Flask-WTF 提供了两种方式来初始化 CSRF 保护。
2.4.1 方式一:FlaskForm 自动保护(推荐用于表单)
当你继承 FlaskForm 创建表单类时,CSRF 保护会自动启用。只需在模板中调用 form.hidden_tag() 即可渲染 CSRF 令牌:
python
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired
class MyForm(FlaskForm):
name = StringField('名称', validators=[DataRequired()])
submit = SubmitField('提交')
html
<form method="post">
{{ form.hidden_tag() }} <!-- 渲染 CSRF 令牌 -->
{{ form.name.label }} {{ form.name() }}
{{ form.submit() }}
</form>
form.hidden_tag() 会生成如下 HTML:
html
<input id="csrf_token" name="csrf_token" type="hidden" value="eyJ...">
2.4.2 方式二:CSRFProtect 全局保护(推荐用于 AJAX/API)
如果你需要保护所有 POST/PUT/DELETE 请求(包括不使用 FlaskForm 的 AJAX 请求),可以使用 CSRFProtect:
python
from flask import Flask
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
csrf = CSRFProtect(app) # 全局 CSRF 保护
# 现在所有 POST/PUT/PATCH/DELETE 请求都会自动验证 CSRF 令牌
在模板中,即使没有表单类,也可以生成 CSRF 令牌:
html
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// AJAX 请求时在请求头中携带 CSRF 令牌
fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ data: 'hello' })
});
</script>
2.4.3 两种方式结合使用
实际项目中,通常同时使用两种方式:
python
from flask import Flask
from flask_wtf import FlaskForm, CSRFProtect
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
csrf = CSRFProtect(app) # 全局保护
# 同时使用 FlaskForm 处理表单
class MyForm(FlaskForm):
pass
2.4.4 豁免 CSRF 保护
某些视图(如 Webhook 回调)不需要 CSRF 保护,可以使用 @csrf.exempt 装饰器豁免:
python
@app.route('/webhook', methods=['POST'])
@csrf.exempt
def webhook():
# 这个路由不需要 CSRF 保护
return {'status': 'ok'}
对于蓝图,可以豁免整个蓝图:
python
from flask import Blueprint
api_bp = Blueprint('api', __name__, url_prefix='/api')
csrf.exempt(api_bp) # 整个 API 蓝图豁免 CSRF
2.4.5 在测试中处理 CSRF
在测试中如果开启了 CSRF,需要在请求中携带令牌:
python
def test_submit(client):
# 获取 CSRF 令牌
response = client.get('/form')
csrf_token = extract_csrf_token(response.data) # 从 HTML 中提取令牌
# 提交表单时携带令牌
response = client.post('/form', data={
'name': 'test',
'csrf_token': csrf_token
})
assert response.status_code == 200
或者更简单的方式是直接在测试配置中关闭 CSRF:
python
class TestingConfig:
WTF_CSRF_ENABLED = False
2.5 WTForms架构设计
理解 WTForms 的架构设计,有助于你更灵活地使用它。WTForms 的核心由三个概念组成:Field(字段)、Validator(验证器)和 Form(表单)。
2.5.1 架构总览
┌─────────────────────────────────────────┐
│ Form(表单) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Field 1 │ │ Field 2 │ │ Field N │ │
│ │ │ │ │ │ │ │
│ │validators│ │validators│ │validators│ │
│ │ [V1, V2]│ │ [V3] │ │ [V4, V5]│ │
│ │ │ │ │ │ │ │
│ │ Widget │ │ Widget │ │ Widget │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ validate() → 遍历所有字段执行验证器 │
│ errors → 收集所有验证错误 │
└─────────────────────────────────────────┘
2.5.2 Field(字段)
Field 是表单的基本组成单元,每个字段对应一个用户输入。Field 的核心职责:
- 存储数据:接收用户输入的原始数据和经过处理的数据。
- 执行验证:调用绑定的验证器列表,收集错误信息。
- 渲染 HTML:通过 Widget 生成对应的 HTML 控件。
Field 的核心属性:
python
from wtforms import StringField
from wtforms.validators import DataRequired, Length
name = StringField(
label='用户名', # 字段标签
validators=[ # 验证器列表
DataRequired(),
Length(min=3, max=20)
],
description='3-20个字符', # 字段描述
default='', # 默认值
render_kw={ # 渲染时的额外 HTML 属性
'class': 'form-control',
'placeholder': '请输入用户名',
'data-toggle': 'tooltip'
},
widget=None, # 自定义 Widget(通常不直接指定)
filters=[] # 数据过滤器
)
Field 的核心方法:
python
# 数据相关
field.data # 处理后的数据(经过类型转换)
field.raw_data # 原始数据(字符串列表)
# 验证相关
field.validate(form, extra_validators) # 执行验证
field.errors # 该字段的错误列表
field.pre_validate(form) # 验证前的钩子
field.post_validate(form, validation_stopped) # 验证后的钩子
# 渲染相关
field() # 调用 widget 渲染 HTML
field.label # Label 对象,field.label() 渲染 <label>
field.__call__(**kwargs) # 带参数渲染
# 状态相关
field.flags # 验证标志(如 required, optional)
2.5.3 Validator(验证器)
Validator 是一个可调用对象,接收表单和字段作为参数,验证失败时抛出 ValidationError:
python
class Validator:
def __init__(self, *args, **kwargs):
# 初始化验证参数
pass
def __call__(self, form, field):
# 验证逻辑
if not valid:
raise ValidationError('错误信息')
验证器可以独立使用,也可以作为字段类的方法定义:
python
# 独立验证器(函数式)
def my_validator(form, field):
if field.data == 'forbidden':
raise ValidationError('禁止使用此值')
class MyForm(FlaskForm):
name = StringField('名称', validators=[my_validator])
# 内联验证器(方法式)
class MyForm(FlaskForm):
name = StringField('名称')
def validate_name(self, field):
if field.data == 'forbidden':
raise ValidationError('禁止使用此值')
2.5.4 Form(表单)
Form 是字段的容器,负责协调所有字段的验证和数据管理:
python
from wtforms import Form
class MyForm(Form):
name = StringField('名称')
email = StringField('邮箱')
form = MyForm(formdata=request.form) # 从请求数据创建表单
# 验证
if form.validate(): # 返回 True/False
# 验证通过
name = form.name.data
email = form.email.data
# 获取所有错误
for field_name, errors in form.errors.items():
for error in errors:
print(f'{field_name}: {error}')
Form 的核心方法:
| 方法 | 说明 |
|---|---|
form.validate() |
验证所有字段,返回 True/False |
form.errors |
所有字段的错误信息字典 |
form.data |
所有字段的数据字典 |
form.populate_obj(obj) |
将表单数据填充到对象属性 |
form.process(formdata, obj, data) |
处理输入数据填充字段 |
2.6 WTForms版本特性
WTForms 经历了多个版本的演进,不同版本之间有一些差异。
2.6.1 主要版本对比
| 版本 | 发布时间 | 主要特性 |
|---|---|---|
| WTForms 1.x | 2010-2013 | 初始版本,基本字段和验证器 |
| WTForms 2.x | 2013-2020 | 新增 FieldList、FormField、JSON 支持,Python 3 支持 |
| WTForms 3.x | 2021-至今 | 全面 Python 3,移除 Python 2 支持,类型注解,新验证器 |
2.6.2 WTForms 3.x 的重要变化
WTForms 3.0 是一个重大版本更新,主要变化包括:
-
移除 Python 2 支持:最低要求 Python 3.7。
-
验证器使用方式变化:
python# WTForms 2.x - 可以直接传列表 name = StringField('名称', [DataRequired(), Length(min=3)]) # WTForms 3.x - 推荐使用 validators 关键字 name = StringField('名称', validators=[DataRequired(), Length(min=3)])实际上两种方式在 3.x 中都可以使用,但
validators=是推荐写法。 -
email_validator独立包 :Email 验证器现在依赖email_validator包,需要单独安装:bashpip install email_validator -
改进的 JSON 支持:
pythonfrom wtforms import Form from wtforms.fields import StringField class MyForm(Form): name = StringField() # 从 JSON 数据创建表单 import json data = json.loads('{"name": "test"}') form = MyForm(data=data) -
更严格的类型检查:使用类型注解,IDE 支持更好。
2.6.3 Flask-WTF 版本对应
| Flask-WTF 版本 | WTForms 版本 | Python 版本 |
|---|---|---|
| 0.14.x | 2.x | Python 2.7 / 3.5+ |
| 1.0.x | 3.x | Python 3.7+ |
| 1.2.x | 3.x | Python 3.8+ |
建议使用最新稳定版本:
bash
pip install Flask-WTF WTForms email_validator
2.7 Flask-WTF与WTForms的关系
Flask-WTF 和 WTForms 的关系可以理解为"集成层"和"核心库"的关系。
2.7.1 职责划分
┌──────────────────────────────────────────┐
│ 你的应用代码 │
├──────────────────────────────────────────┤
│ Flask-WTF (集成层) │
│ - FlaskForm (继承 wtforms.Form) │
│ - CSRFProtect (CSRF 保护) │
│ - FileField (文件上传字段) │
│ - 从 request.form/files 自动填充数据 │
│ - reCAPTCHA 集成 │
├──────────────────────────────────────────┤
│ WTForms (核心库) │
│ - Field (字段类型) │
│ - Validator (验证器) │
│ - Widget (HTML 渲染) │
│ - Form (表单基类) │
│ - 错误处理 │
├──────────────────────────────────────────┤
│ Flask (Web 框架) │
│ - request / session / redirect │
│ - Jinja2 模板 │
└──────────────────────────────────────────┘
2.7.2 代码层面的关系
Flask-WTF 的 FlaskForm 继承自 WTForms 的 Form,并做了以下增强:
python
# WTForms 原生 Form
from wtforms import Form, StringField
class MyForm(Form):
name = StringField('名称')
# 使用时需要手动传入 formdata
from werkzeug.datastructures import MultiDict
form = MyForm(formdata=MultiDict([('name', 'test')]))
# Flask-WTF 的 FlaskForm
from flask_wtf import FlaskForm
from wtforms import StringField
class MyForm(FlaskForm):
name = StringField('名称')
# 使用时自动从 request 中读取数据,无需手动传入
form = MyForm() # 在视图函数中,自动从 request.form 填充
2.7.3 何时用 WTForms,何时用 Flask-WTF
| 场景 | 选择 |
|---|---|
| Flask Web 应用 | Flask-WTF(FlaskForm) |
| 纯 API 数据验证 | WTForms(不依赖 Flask 请求) |
| 非 Flask 框架 | WTForms |
| 需要 CSRF 保护 | Flask-WTF(CSRFProtect) |
| 需要文件上传验证 | Flask-WTF(FileField) |
| 批量数据导入验证 | WTForms |
一个在非 Flask 环境中使用 WTForms 的例子:
python
from wtforms import Form, StringField, IntegerField
from wtforms.validators import DataRequired, NumberRange
class ImportForm(Form):
name = StringField('名称', validators=[DataRequired()])
age = IntegerField('年龄', validators=[NumberRange(min=0, max=150)])
# 从字典创建
data = {'name': '张三', 'age': '25'}
form = ImportForm(data=data)
if form.validate():
print(f'验证通过: {form.name.data}, {form.age.data}')
else:
print(f'验证失败: {form.errors}')
2.7.4 FlaskForm 相比 Form 的增强
| 特性 | wtforms.Form | flask_wtf.FlaskForm |
|---|---|---|
| 数据来源 | 手动传入 formdata | 自动从 request 读取 |
| CSRF 保护 | 无 | 自动 |
| 文件上传 | 需手动处理 | FileField 自动处理 |
| 隐藏字段 | 无 | hidden_tag() 方法 |
| Recaptcha | 无 | 内置支持 |
| session 访问 | 无 | 可访问 Flask session |
理解了 Flask-WTF 和 WTForms 的关系后,我们就可以开始深入学习各种字段类型了。
2.7.5 Flask-WTF 配置实践
在实际项目中,Flask-WTF 的配置需要根据环境(开发/测试/生产)进行差异化设置。以下是一套推荐的生产级配置方案:
python
import os
from flask import Flask
app = Flask(__name__)
# 基础配置
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
app.config['WTF_CSRF_ENABLED'] = True
app.config['WTF_CSRF_SECRET_KEY'] = os.environ.get('WTF_CSRF_SECRET_KEY') or app.config['SECRET_KEY']
# CSRF 令牌有效期(秒),默认 3600(1小时)
# 根据表单复杂度调整:简单表单 1800,复杂表单 7200
app.config['WTF_CSRF_TIME_LIMIT'] = 3600
# 仅在 HTTPS 下接受 CSRF 令牌(生产环境必须开启)
app.config['WTF_CSRF_SSL_STRICT'] = os.environ.get('FLASK_ENV') == 'production'
# 需要 CSRF 保护的 HTTP 方法
app.config['WTF_CSRF_METHODS'] = ['POST', 'PUT', 'PATCH', 'DELETE']
# CSRF 令牌的字段名(默认为 csrf_token,一般不需要修改)
app.config['WTF_CSRF_FIELD_NAME'] = 'csrf_token'
# CSRF 令牌的 header 名称(AJAX 请求中使用)
app.config['WTF_CSRF_HEADERS'] = ['X-CSRFToken', 'X-CSRF-Token']
# 文件上传相关配置
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx'}
# 测试环境特殊配置
if app.config['TESTING']:
app.config['WTF_CSRF_ENABLED'] = False # 测试时禁用 CSRF
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
2.7.6 Flask-WTF 的 reCAPTCHA 集成
Flask-WTF 内置了 Google reCAPTCHA 支持,可以有效防止机器人注册和垃圾提交:
python
from flask_wtf import FlaskForm, RecaptchaField
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length
app.config['RECAPTCHA_PUBLIC_KEY'] = 'your-site-key'
app.config['RECAPTCHA_PRIVATE_KEY'] = 'your-secret-key'
# 可选:主题(dark/light)、语言
app.config['RECAPTCHA_THEME'] = 'light'
app.config['RECAPTCHA_TYPE'] = 'image'
app.config['RECAPTCHA_SIZE'] = 'normal'
# reCAPTCHA v3 (invisible) 的分数阈值
app.config['RECAPTCHA_THRESHOLD'] = 0.5
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(min=3, max=20)])
email = StringField('邮箱', validators=[DataRequired(), Email()])
password = PasswordField('密码', validators=[DataRequired(), Length(min=8)])
recaptcha = RecaptchaField()
submit = SubmitField('注册')
html
<!-- 模板中需要引入 reCAPTCHA JavaScript -->
<head>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<form method="post">
{{ form.hidden_tag() }}
{{ form.username.label }} {{ form.username() }}
{{ form.email.label }} {{ form.email() }}
{{ form.password.label }} {{ form.password() }}
{{ form.recaptcha() }}
{% for error in form.recaptcha.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
{{ form.submit() }}
</form>
注意:在中国大陆使用 reCAPTCHA 可能存在访问问题,可以考虑使用替代方案,如 hCaptcha、腾讯验证码或极验滑块验证。对于这些替代方案,可以通过自定义字段来实现集成。
第三章 表单字段类型
WTForms 提供了丰富的字段类型,几乎覆盖了所有常见的 HTML 表单控件。本章将逐一讲解每种字段类型的使用方法、属性和注意事项。
3.1 字段基类(Field)与属性
所有字段类型都继承自 wtforms.fields.Field 基类。理解基类的属性和参数,是使用所有字段的基础。
3.1.1 Field 构造参数
python
from wtforms import StringField
field = StringField(
label='用户名', # 1. 标签文本
validators=[], # 2. 验证器列表
filters=[], # 3. 数据过滤器
description='说明文字', # 4. 字段描述
id='username', # 5. HTML id 属性
default='默认值', # 6. 默认值
widget=None, # 7. 自定义 Widget
render_kw={}, # 8. 渲染时额外 HTML 属性
name=None, # 9. HTML name 属性(默认用字段名)
_form=None, # 内部使用
_name=None, # 内部使用
_translations=None, # 内部使用
)
各参数详解:
label(标签) :
字段的显示标签,用于渲染 <label> 标签。可以传字符串,也可以传 Label 对象。
python
# 字符串
name = StringField('用户名')
# Label 对象(更灵活)
from wtforms import Label
name = StringField(label=Label('username', '<span>用户名</span>', for_='username'))
validators(验证器) :
验证规则列表,按顺序执行。详见第四章。
python
from wtforms.validators import DataRequired, Length
name = StringField('用户名', validators=[
DataRequired(message='不能为空'),
Length(min=3, max=20)
])
filters(过滤器) :
在验证之前对数据进行预处理的函数列表。每个过滤器接收原始值,返回处理后的值。
python
# 去除首尾空格
def strip_filter(value):
if value is not None and hasattr(value, 'strip'):
return value.strip()
return value
# 转为小写
def lower_filter(value):
if value:
return value.lower()
return value
email = StringField('邮箱', filters=[strip_filter, lower_filter])
description(描述) :
字段的描述信息,常用于渲染帮助文本。
python
password = StringField('密码', description='至少8位,包含大小写字母和数字')
html
{{ form.password.label }}
{{ form.password() }}
<small class="form-text">{{ form.password.description }}</small>
id :
HTML id 属性。默认使用字段名。
python
name = StringField('用户名', id='user-name')
default(默认值) :
当表单没有数据时的默认值。可以是静态值,也可以是可调用对象。
python
# 静态默认值
status = SelectField('状态', choices=[('active', '有效')], default='active')
# 可调用默认值(每次创建表单时调用)
import datetime
created_at = DateTimeField('创建时间', default=datetime.datetime.utcnow)
# 使用函数
def generate_code():
import random
return random.randint(100000, 999999)
code = IntegerField('验证码', default=generate_code)
render_kw(渲染关键字) :
渲染时添加到 HTML 标签上的额外属性。这是最常用的定制渲染方式。
python
name = StringField('用户名', render_kw={
'class': 'form-control',
'placeholder': '请输入用户名',
'maxlength': '20',
'autocomplete': 'off',
'data-toggle': 'tooltip',
'title': '用户名用于登录'
})
渲染结果:
html
<input class="form-control" id="name" maxlength="20" name="name"
placeholder="请输入用户名" autocomplete="off"
data-toggle="tooltip" title="用户名用于登录" type="text" value="">
3.1.2 Field 实例属性
创建字段实例后,可以访问以下属性:
python
form = MyForm()
# 数据
form.name.data # 处理后的数据(类型转换后)
form.name.raw_data # 原始数据(字符串元组,如 ('张三',))
form.name.object_data # 对象数据(从 populate_obj 传入的)
# 标签
form.name.label # Label 对象
form.name.label.text # 标签文本
form.name.label() # 渲染 <label> 标签
# 描述
form.name.description # 描述文本
# 验证
form.name.errors # 错误列表,如 ['不能为空']
form.name.flags # Flags 对象,如 Flags(required=True)
form.name.flags.required # 是否必填
# 渲染
form.name() # 渲染 HTML 控件
form.name.type # 字段类型名称,如 'StringField'
form.name.short_name # 字段名(不含前缀),如 'name'
form.name.name # HTML name 属性
form.name.id # HTML id 属性
3.2 文本字段(StringField, TextAreaField)
3.2.1 StringField
StringField 是最常用的字段类型,渲染为 <input type="text">,数据类型为字符串。
python
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Length
class ProfileForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(message='用户名不能为空'),
Length(min=3, max=20, message='用户名长度3-20个字符')
], render_kw={'class': 'form-control', 'placeholder': '请输入用户名'})
nickname = StringField('昵称', validators=[
Length(max=50, message='昵称最多50个字符')
], render_kw={'class': 'form-control'})
submit = SubmitField('保存', render_kw={'class': 'btn btn-primary'})
渲染结果:
html
<input class="form-control" id="username" name="username"
placeholder="请输入用户名" type="text" value="">
3.2.2 TextAreaField
TextAreaField 渲染为 <textarea>,用于多行文本输入。数据类型同样是字符串,但支持换行。
python
from wtforms import TextAreaField
class ArticleForm(FlaskForm):
title = StringField('标题', validators=[DataRequired()])
content = TextAreaField('正文', validators=[
DataRequired(),
Length(min=10, max=10000, message='正文长度10-10000字')
], render_kw={
'class': 'form-control',
'rows': 10,
'placeholder': '请输入文章正文...'
})
渲染结果:
html
<textarea class="form-control" id="content" name="content"
rows="10" placeholder="请输入文章正文..."></textarea>
3.3 密码字段(PasswordField)
PasswordField 渲染为 <input type="password">,输入内容显示为圆点。数据类型为字符串。
python
from wtforms import PasswordField
from wtforms.validators import DataRequired, Length, EqualTo
class ChangePasswordForm(FlaskForm):
old_password = PasswordField('当前密码', validators=[
DataRequired(message='请输入当前密码')
], render_kw={'class': 'form-control'})
new_password = PasswordField('新密码', validators=[
DataRequired(message='请输入新密码'),
Length(min=8, max=32, message='密码长度8-32位'),
Regexp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$',
message='密码必须包含大小写字母和数字')
], render_kw={'class': 'form-control'})
confirm_password = PasswordField('确认新密码', validators=[
DataRequired(message='请确认新密码'),
EqualTo('new_password', message='两次密码不一致')
], render_kw={'class': 'form-control'})
注意:密码字段默认不会回显数据(出于安全考虑),即使用户验证失败后,密码框也会是空的。这是正确的行为。
3.4 数字字段(IntegerField, FloatField, DecimalField)
数字字段会自动将输入的字符串转换为对应的数字类型。
3.4.1 IntegerField
IntegerField 渲染为 <input type="number">,数据类型为 int。转换失败时会产生验证错误。
python
from wtforms import IntegerField
from wtforms.validators import DataRequired, NumberRange
class OrderForm(FlaskForm):
quantity = IntegerField('数量', validators=[
DataRequired(message='请输入数量'),
NumberRange(min=1, max=999, message='数量必须在1-999之间')
], render_kw={'class': 'form-control', 'min': '1', 'max': '999'})
# 带默认值
page = IntegerField('页码', default=1)
# 可选数字字段(允许为空)
discount = IntegerField('折扣(%)', validators=[Optional()])
如果用户输入 abc,IntegerField 会自动生成错误 "请输入有效数字"(可通过 render_kw 自定义)。
3.4.2 FloatField
FloatField 数据类型为 float,用于浮点数输入。
python
from wtforms import FloatField
class ProductForm(FlaskForm):
weight = FloatField('重量(kg)', validators=[
NumberRange(min=0.01, max=1000, message='重量范围0.01-1000kg')
], render_kw={'step': '0.01'})
price = FloatField('价格', validators=[
NumberRange(min=0, message='价格不能为负')
])
3.4.3 DecimalField
DecimalField 使用 Python 的 decimal.Decimal 类型,适合需要精确计算的场景(如金融金额)。
python
from wtforms import DecimalField
from decimal import Decimal
class PaymentForm(FlaskForm):
amount = DecimalField('金额', validators=[
NumberRange(min=Decimal('0.01'), message='金额至少0.01')
], places=2, rounding=None, render_kw={'step': '0.01'})
DecimalField 的特有参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
places |
小数位数 | None(不限制) |
rounding |
舍入方式 | ROUND_HALF_EVEN |
python
from decimal import ROUND_HALF_UP
# 保留2位小数,四舍五入
amount = DecimalField('金额', places=2, rounding=ROUND_HALF_UP)
3.5 布尔字段(BooleanField)
BooleanField 渲染为 <input type="checkbox">,数据类型为 bool。
python
from wtforms import BooleanField
class SettingsForm(FlaskForm):
remember_me = BooleanField('记住我')
newsletter = BooleanField('订阅邮件通知', default=True) # 默认勾选
agree_terms = BooleanField('同意服务条款', validators=[
DataRequired(message='必须同意服务条款才能注册')
])
注意 BooleanField 与 DataRequired 配合的特殊行为:复选框未勾选时,提交的值为 False。DataRequired 验证器对 BooleanField 的处理是"必须为 True",因此常用于强制用户勾选协议。
渲染结果:
html
<input id="remember_me" name="remember_me" type="checkbox" value="y">
获取数据:
python
if form.validate_on_submit():
if form.remember_me.data: # True 或 False
# 设置持久化 session
pass
3.6 日期时间字段(DateField, DateTimeField, TimeField)
日期时间字段提供了日期/时间的选择和数据类型转换。
3.6.1 DateField
DateField 渲染为 <input type="date">,数据类型为 datetime.date。
python
from wtforms import DateField
from wtforms.validators import DataRequired
import datetime
class EventForm(FlaskForm):
event_date = DateField('活动日期', validators=[
DataRequired(message='请选择日期')
], format='%Y-%m-%d', render_kw={'class': 'form-control'})
# 设置默认值为今天
start_date = DateField('开始日期', default=datetime.date.today)
# 设置默认值为特定日期
deadline = DateField('截止日期', default=datetime.date(2025, 12, 31))
format 参数指定日期格式,默认为 %Y-%m-%d。如果前端使用的日期格式不同,需要修改:
python
# 中文日期格式
date = DateField('日期', format='%Y年%m月%d日')
# 不同分隔符
date = DateField('日期', format='%d/%m/%Y') # 日/月/年
3.6.2 DateTimeField
DateTimeField 数据类型为 datetime.datetime。
python
from wtforms import DateTimeField
class ScheduleForm(FlaskForm):
start_time = DateTimeField('开始时间', format='%Y-%m-%d %H:%M',
default=datetime.datetime.utcnow)
end_time = DateTimeField('结束时间', format='%Y-%m-%d %H:%M')
3.6.3 TimeField
TimeField 数据类型为 datetime.time。
python
from wtforms import TimeField
class MeetingForm(FlaskForm):
meeting_time = TimeField('会议时间', format='%H:%M', default=datetime.time(9, 0))
3.6.4 日期验证示例
python
from wtforms.validators import ValidationError
import datetime
class BookingForm(FlaskForm):
check_in = DateField('入住日期', validators=[DataRequired()])
check_out = DateField('退房日期', validators=[DataRequired()])
def validate_check_out(self, field):
if field.data <= self.check_in.data:
raise ValidationError('退房日期必须晚于入住日期')
if (field.data - self.check_in.data).days > 30:
raise ValidationError('最多预订30天')
def validate_check_in(self, field):
if field.data < datetime.date.today():
raise ValidationError('入住日期不能早于今天')
3.7 选择字段(SelectField, SelectMultipleField, RadioField)
选择字段是表单中最常用的字段类型之一,用于从预定义选项中选择。
3.7.1 SelectField(单选下拉)
SelectField 渲染为 <select> 单选下拉框。
python
from wtforms import SelectField
class AddressForm(FlaskForm):
province = SelectField('省份', choices=[
('', '请选择省份'),
('beijing', '北京'),
('shanghai', '上海'),
('guangdong', '广东'),
('jiangsu', '江苏'),
], validators=[DataRequired(message='请选择省份')], render_kw={'class': 'form-control'})
choices 是一个元组列表,每个元组的第一个元素是提交给服务器的值(value),第二个元素是显示给用户的文本(label)。
渲染结果:
html
<select class="form-control" id="province" name="province">
<option value="">请选择省份</option>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
<option value="guangdong">广东</option>
<option value="jiangsu">江苏</option>
</select>
3.7.2 动态 choices
实际项目中,choices 往往需要从数据库动态生成。有两种方式:
方式一:在视图函数中动态赋值
python
class ArticleForm(FlaskForm):
category = SelectField('分类', choices=[], coerce=int)
submit = SubmitField('发布')
@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
form = ArticleForm()
# 从数据库获取分类
categories = Category.query.all()
form.category.choices = [(c.id, c.name) for c in categories]
form.category.choices.insert(0, (0, '请选择分类'))
if form.validate_on_submit():
category_id = form.category.data
# ...
return render_template('new_article.html', form=form)
方式二:使用可调用对象
python
def get_category_choices():
"""从数据库获取分类选项"""
categories = Category.query.all()
return [(c.id, c.name) for c in categories]
class ArticleForm(FlaskForm):
category = SelectField('分类', choices=get_category_choices, coerce=int)
注意:使用可调用对象时,choices 在表单实例化时求值。如果数据库内容在请求处理期间发生变化,需要重新实例化表单。
3.7.3 coerce 参数
coerce 参数指定将选择的值强制转换的类型,默认为 str。
python
# ID 是整数时
category = SelectField('分类', choices=[(1, '技术'), (2, '生活')], coerce=int)
# 转换失败会报错
# 用户篡改提交了 category=abc → 验证错误
3.7.4 SelectMultipleField(多选下拉)
SelectMultipleField 渲染为 <select multiple>,允许选择多个值,数据类型为列表。
python
from wtforms import SelectMultipleField
class TagForm(FlaskForm):
tags = SelectMultipleField('标签', choices=[
('python', 'Python'),
('flask', 'Flask'),
('django', 'Django'),
('fastapi', 'FastAPI'),
], coerce=str, render_kw={'class': 'form-control', 'size': '5'})
获取数据:
python
if form.validate_on_submit():
selected_tags = form.tags.data # ['python', 'flask']
渲染时,需要设置 size 属性才能看到多个选项,否则只会显示一行。
3.7.5 RadioField(单选按钮)
RadioField 渲染为一组 <input type="radio">,功能与 SelectField 相同,只是 UI 表现不同。
python
from wtforms import RadioField
class SurveyForm(FlaskForm):
gender = RadioField('性别', choices=[
('male', '男'),
('female', '女'),
('other', '其他'),
], validators=[DataRequired(message='请选择性别')])
rating = RadioField('评分', choices=[
('1', '1星'),
('2', '2星'),
('3', '3星'),
('4', '4星'),
('5', '5星'),
], default='5', coerce=int)
模板中渲染单选按钮:
html
{% for choice in form.gender %}
<div class="form-check">
{{ choice(class_='form-check-input') }}
{{ choice.label(class_='form-check-label') }}
</div>
{% endfor %}
渲染结果:
html
<div class="form-check">
<input class="form-check-input" id="gender-0" name="gender" type="radio" value="male">
<label class="form-check-label" for="gender-0">男</label>
</div>
<div class="form-check">
<input class="form-check-input" id="gender-1" name="gender" type="radio" value="female">
<label class="form-check-label" for="gender-1">女</label>
</div>
3.8 文件字段(FileField, MultipleFileField)
文件字段用于文件上传,在 Flask-WTF 中从 flask_wtf 导入(而非 wtforms)。
3.8.1 FileField(单文件上传)
python
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
class UploadForm(FlaskForm):
avatar = FileField('头像', validators=[
FileRequired(message='请选择文件'),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'], message='只允许图片文件')
])
submit = SubmitField('上传')
模板中渲染文件字段:
html
<form method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ form.avatar.label }}
{{ form.avatar() }}
{{ form.submit() }}
</form>
注意:文件上传表单必须设置 enctype="multipart/form-data"。
获取文件:
python
if form.validate_on_submit():
file = form.avatar.data # werkzeug FileStorage 对象
if file:
filename = secure_filename(file.filename)
file.save(os.path.join('uploads', filename))
3.8.2 MultipleFileField(多文件上传)
python
from flask_wtf.file import MultipleFileField
class GalleryForm(FlaskForm):
photos = MultipleFileField('照片', validators=[
FileAllowed(['jpg', 'jpeg', 'png'], message='只允许图片文件')
])
获取多个文件:
python
if form.validate_on_submit():
files = form.photos.data # FileStorage 对象列表
for file in files:
filename = secure_filename(file.filename)
file.save(os.path.join('uploads', filename))
文件上传的详细处理将在第七章专门讲解。
3.9 隐藏字段(HiddenField)
HiddenField 渲染为 <input type="hidden">,数据类型为字符串。用于传递不需要用户可见的数据。
python
from wtforms import HiddenField
class EditForm(FlaskForm):
id = HiddenField() # 记录ID
redirect_to = HiddenField() # 重定向目标
name = StringField('名称', validators=[DataRequired()])
隐藏字段常用于:
- 传递记录 ID(编辑操作)
- 传递来源 URL(提交后跳回)
- 存储状态信息
python
@app.route('/edit/<int:item_id>', methods=['GET', 'POST'])
def edit(item_id):
item = Item.query.get_or_404(item_id)
form = EditForm()
if request.method == 'GET':
form.id.data = item.id
form.name.data = item.name
if form.validate_on_submit():
item.name = form.name.data
db.session.commit()
return redirect(url_for('list'))
return render_template('edit.html', form=form)
3.10 提交字段(SubmitField)
SubmitField 渲染为 <input type="submit">,用于表单提交按钮。
python
from wtforms import SubmitField
class MyForm(FlaskForm):
name = StringField('名称')
submit = SubmitField('提交', render_kw={'class': 'btn btn-primary'})
# 一个表单中可以有多个提交按钮
save_draft = SubmitField('保存草稿', render_kw={'class': 'btn btn-secondary'})
publish = SubmitField('发布', render_kw={'class': 'btn btn-success'})
在视图函数中区分不同按钮:
python
if form.validate_on_submit():
if form.publish.data:
# 点击了"发布"
article.status = 'published'
elif form.save_draft.data:
# 点击了"保存草稿"
article.status = 'draft'
db.session.commit()
3.11 列表字段(FieldList)
FieldList 用于包含多个相同类型字段的列表,适合动态数量的输入。
3.11.1 基本使用
python
from wtforms import FieldList, StringField, FormField
class SurveyForm(FlaskForm):
# 多个文本输入
emails = FieldList(StringField('邮箱'), min_entries=1, max_entries=10)
# 多个整数输入
scores = FieldList(IntegerField('分数'), min_entries=3)
模板渲染:
html
{% for email_field in form.emails %}
{{ email_field.label }} {{ email_field(class_='form-control') }}
{% endfor %}
3.11.2 动态添加字段
前端通过 JavaScript 动态添加字段,FieldList 通过索引访问:
html
<div id="email-list">
{% for email_field in form.emails %}
<div class="email-item">
{{ email_field(class_='form-control', placeholder='邮箱地址') }}
</div>
{% endfor %}
</div>
<button type="button" onclick="addEmailField()">添加邮箱</button>
<script>
function addEmailField() {
const list = document.getElementById('email-list');
const count = list.children.length;
const html = `<div class="email-item">
<input class="form-control" id="emails-${count}"
name="emails-${count}" placeholder="邮箱地址" type="text" value="">
</div>`;
list.insertAdjacentHTML('beforeend', html);
}
</script>
3.11.3 min_entries 和 max_entries
python
# 至少3个,最多10个
phones = FieldList(StringField('电话'), min_entries=3, max_entries=10)
验证时会检查数量是否在范围内。
3.12 嵌套表单(FormField)
FormField 允许在一个表单中嵌套另一个表单,适合表单复用和复杂结构。
3.12.1 定义嵌套表单
python
from wtforms import FormField, Form
class AddressForm(Form):
"""地址子表单(不继承 FlaskForm,因为没有 CSRF)"""
province = StringField('省份', validators=[DataRequired()])
city = StringField('城市', validators=[DataRequired()])
detail = StringField('详细地址', validators=[DataRequired()])
zip_code = StringField('邮编', validators=[
Regexp(r'^\d{6}$', message='邮编必须是6位数字')
])
class UserForm(FlaskForm):
"""用户表单,嵌套地址表单"""
name = StringField('姓名', validators=[DataRequired()])
email = StringField('邮箱', validators=[DataRequired(), Email()])
address = FormField(AddressForm) # 嵌套表单
submit = SubmitField('提交')
3.12.2 模板渲染嵌套表单
html
<form method="post">
{{ form.hidden_tag() }}
{{ form.name.label }} {{ form.name() }}
{{ form.email.label }} {{ form.email() }}
<fieldset>
<legend>地址信息</legend>
{{ form.address.province.label }} {{ form.address.province() }}
{{ form.address.city.label }} {{ form.address.city() }}
{{ form.address.detail.label }} {{ form.address.detail() }}
{{ form.address.zip_code.label }} {{ form.address.zip_code() }}
</fieldset>
{{ form.submit() }}
</form>
3.12.3 获取嵌套表单数据
python
if form.validate_on_submit():
user = User(
name=form.name.data,
email=form.email.data,
province=form.address.province.data,
city=form.address.city.data,
detail=form.address.detail.data,
zip_code=form.address.zip_code.data,
)
3.12.4 FieldList + FormField 组合
FieldList 和 FormField 组合可以创建重复的嵌套结构:
python
class ContactForm(Form):
name = StringField('联系人姓名', validators=[DataRequired()])
phone = StringField('电话', validators=[DataRequired()])
class CompanyForm(FlaskForm):
company_name = StringField('公司名称', validators=[DataRequired()])
contacts = FieldList(FormField(ContactForm), min_entries=1, max_entries=5)
3.13 搜索字段(SearchField)
SearchField 渲染为 <input type="search">,功能与 StringField 相同,但语义上标识为搜索框,浏览器可能会提供不同的样式。
python
from wtforms import SearchField
class SearchForm(FlaskForm):
keyword = SearchField('关键词', validators=[
Length(min=1, max=100)
], render_kw={'class': 'form-control', 'placeholder': '搜索...'})
3.14 URL/Email/Tel字段
WTForms 提供了语义化的输入字段,它们在数据类型上都是字符串,但渲染为不同的 HTML5 input 类型。
python
from wtforms import URLField, EmailField, TelField
class ContactForm(FlaskForm):
website = URLField('个人网站', validators=[
URL(message='请输入有效的URL')
], render_kw={'placeholder': 'https://example.com'})
email = EmailField('邮箱', validators=[
DataRequired(),
Email(message='邮箱格式不正确')
])
phone = TelField('电话', validators=[
Regexp(r'^1[3-9]\d{9}$', message='请输入有效的手机号')
])
渲染结果:
html
<input type="url" name="website" placeholder="https://example.com">
<input type="email" name="email">
<input type="tel" name="phone">
使用 HTML5 类型的好处是移动端浏览器会弹出对应的键盘(如邮箱键盘、数字键盘),并可能有浏览器原生验证。
3.15 字段类型速查表
| 字段类型 | HTML 控件 | 数据类型 | 说明 |
|---|---|---|---|
StringField |
<input type="text"> |
str |
单行文本 |
TextAreaField |
<textarea> |
str |
多行文本 |
PasswordField |
<input type="password"> |
str |
密码 |
IntegerField |
<input type="number"> |
int |
整数 |
FloatField |
<input type="number"> |
float |
浮点数 |
DecimalField |
<input type="number"> |
Decimal |
高精度小数 |
BooleanField |
<input type="checkbox"> |
bool |
布尔值 |
DateField |
<input type="date"> |
date |
日期 |
DateTimeField |
<input type="datetime-local"> |
datetime |
日期时间 |
TimeField |
<input type="time"> |
time |
时间 |
SelectField |
<select> |
str/coerce |
单选下拉 |
SelectMultipleField |
<select multiple> |
list |
多选下拉 |
RadioField |
<input type="radio"> |
str/coerce |
单选按钮组 |
FileField |
<input type="file"> |
FileStorage |
文件上传 |
MultipleFileField |
<input type="file" multiple> |
list[FileStorage] |
多文件上传 |
HiddenField |
<input type="hidden"> |
str |
隐藏字段 |
SubmitField |
<input type="submit"> |
bool |
提交按钮 |
SearchField |
<input type="search"> |
str |
搜索框 |
URLField |
<input type="url"> |
str |
URL 输入 |
EmailField |
<input type="email"> |
str |
邮箱输入 |
TelField |
<input type="tel"> |
str |
电话输入 |
FieldList |
(重复字段) | list |
字段列表 |
FormField |
(嵌套表单) | Form |
嵌套表单 |
3.16 自定义字段类型
当内置字段无法满足需求时,可以自定义字段类型。
3.16.1 继承现有字段
最简单的自定义方式是继承现有字段并修改默认行为:
python
from wtforms import StringField
class LowercaseStringField(StringField):
"""自动转小写的文本字段"""
def process_formdata(self, valuelist):
super().process_formdata(valuelist)
if self.data:
self.data = self.data.lower()
class TrimmedStringField(StringField):
"""自动去除首尾空格的文本字段"""
def process_formdata(self, valuelist):
if valuelist:
self.data = valuelist[0].strip()
else:
self.data = ''
使用:
python
class MyForm(FlaskForm):
email = LowercaseStringField('邮箱', validators=[Email()])
name = TrimmedStringField('姓名')
3.16.2 自定义 Widget
更深入的自定义是创建新的 Widget,控制 HTML 渲染:
python
from wtforms.widgets import Widget, html_params
from markupsafe import Markup
class ToggleWidget(Widget):
"""渲染 Bootstrap 开关样式的复选框"""
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id)
kwargs.setdefault('type', 'checkbox')
kwargs['data-toggle'] = 'toggle'
if field.data:
kwargs['checked'] = 'checked'
return Markup(
f'<input {html_params(name=field.name, **kwargs)}>'
f'<label for="{field.id}">{field.label.text}</label>'
)
class ToggleField(BooleanField):
"""开关样式布尔字段"""
widget = ToggleWidget()
3.16.3 完全自定义字段
创建一个全新的字段类型,需要实现 process_formdata 方法:
python
from wtforms.fields import Field
from wtforms.widgets import TextInput
from wtforms.validators import ValidationError
class ColorField(Field):
"""颜色选择字段,存储为 (R, G, B) 元组"""
widget = TextInput()
def _value(self):
"""渲染时返回的值"""
if self.data:
r, g, b = self.data
return f'#{r:02x}{g:02x}{b:02x}'
return ''
def process_formdata(self, valuelist):
"""从表单数据中解析值"""
if valuelist and valuelist[0]:
value = valuelist[0].lstrip('#')
if len(value) == 6:
try:
self.data = (
int(value[0:2], 16),
int(value[2:4], 16),
int(value[4:6], 16),
)
except ValueError:
self.data = None
raise ValidationError('无效的颜色值')
else:
self.data = None
else:
self.data = None
使用:
python
class ThemeForm(FlaskForm):
primary_color = ColorField('主色调', default=(0, 123, 255))
模板:
html
{{ form.primary_color.label }}
{{ form.primary_color(type='color') }}
这个自定义字段在前端显示为颜色选择器,后端解析为 RGB 元组,实现了完整的自定义数据流。
3.16.4 标签输入字段
在实际项目中,文章标签、商品标签等场景需要用户输入多个标签。我们可以创建一个 TagListField,前端以逗号分隔输入,后端自动解析为列表:
python
from wtforms.fields import Field
from wtforms.widgets import TextInput
class TagListField(Field):
"""标签输入字段,前端逗号分隔,后端转为列表"""
widget = TextInput()
def _value(self):
"""渲染时将列表转为逗号分隔的字符串"""
if self.data:
return ', '.join(self.data)
return ''
def process_formdata(self, valuelist):
"""从表单数据解析为列表"""
if valuelist and valuelist[0]:
# 按逗号分割,去除空白,过滤空字符串,去重
tags = [tag.strip() for tag in valuelist[0].split(',')]
self.data = list(dict.fromkeys(tag for tag in tags if tag)) # 去重保序
else:
self.data = []
def pre_validate(self, form):
"""在验证器之前执行的自定义验证"""
if self.data:
for tag in self.data:
if len(tag) > 20:
raise ValidationError(f'标签"{tag}"超过20个字符')
使用示例:
python
class ArticleForm(FlaskForm):
title = StringField('标题', validators=[DataRequired(), Length(max=200)])
content = TextAreaField('正文', validators=[DataRequired()])
tags = TagListField('标签', description='多个标签用逗号分隔')
submit = SubmitField('发布')
# 在视图中使用
@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
form = ArticleForm()
if form.validate_on_submit():
article = Article(
title=form.title.data,
content=form.content.data
)
# tags.data 已经是列表
for tag_name in form.tags.data:
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name)
article.tags.append(tag)
db.session.add(article)
db.session.commit()
return redirect(url_for('article_detail', id=article.id))
return render_template('article_new.html', form=form)
3.16.5 JSON 数据字段
在需要存储结构化数据(如自定义表单配置、JSON 格式的用户设置)时,可以创建一个 JSONField:
python
import json
from wtforms.fields import Field
from wtforms.widgets import TextArea
from wtforms.validators import ValidationError
class JSONField(Field):
"""JSON 数据字段,前端输入 JSON 字符串,后端解析为 Python 对象"""
widget = TextArea()
def _value(self):
"""渲染时将 Python 对象转为 JSON 字符串"""
if self.data:
try:
return json.dumps(self.data, ensure_ascii=False, indent=2)
except (TypeError, ValueError):
return str(self.data)
return ''
def process_formdata(self, valuelist):
"""从表单数据解析 JSON"""
if valuelist and valuelist[0].strip():
try:
self.data = json.loads(valuelist[0])
except json.JSONDecodeError as e:
self.data = None
raise ValidationError(f'JSON 格式错误: {e}')
else:
self.data = None
使用示例:
python
class WidgetConfigForm(FlaskForm):
name = StringField('组件名称', validators=[DataRequired()])
config = JSONField('配置(JSON)', default={
"width": 300,
"height": 200,
"theme": "light"
})
submit = SubmitField('保存配置')
3.16.6 自定义字段设计原则
在创建自定义字段时,需要遵循以下设计原则:
- 单一职责:每个自定义字段只负责一种数据类型的处理,不要在一个字段中混合多种逻辑。
- 健壮性 :
process_formdata方法要处理各种边界情况------空值、格式错误、类型不匹配等,确保不会因为异常输入导致程序崩溃。 - 可测试性 :自定义字段应该易于单元测试,通过构造
valuelist参数模拟表单提交,验证解析结果。 - 与验证器配合:自定义字段负责数据解析(将原始字符串转为 Python 对象),验证器负责业务规则验证(如范围检查、唯一性检查),两者各司其职。
- 渲染友好 :
_value方法要确保返回的字符串能正确渲染到 HTML 中,避免 XSS 风险(依赖 Jinja2 的自动转义)。
第四章 验证器(Validators)
验证器是 WTForms 的核心功能之一,负责确保用户输入的数据符合业务规则。本章将系统讲解所有内置验证器的使用方法,以及如何编写自定义验证器。
4.1 验证器概念与使用方式
验证器(Validator)是一个可调用对象,接收两个参数------表单对象 form 和字段对象 field,在数据不符合要求时抛出 ValidationError 异常。
python
from wtforms.validators import ValidationError
def my_validator(form, field):
"""一个简单的验证器"""
if field.data != 'expected_value':
raise ValidationError('值不符合要求')
验证器的使用方式有三种:
4.1.1 列表方式(最常用)
python
name = StringField('用户名', validators=[
DataRequired(),
Length(min=3, max=20)
])
验证器按列表顺序执行,如果一个验证器失败,会继续执行后续验证器(除非使用 StopValidation)。
4.1.2 内联方法方式
在表单类中定义 validate_<字段名> 方法:
python
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired()])
def validate_username(self, field):
"""内联验证器,方法名必须是 validate_字段名"""
if User.query.filter_by(username=field.data).first():
raise ValidationError('该用户名已被注册')
内联验证器会在所有列表验证器之后执行。
4.1.3 临时额外验证器
在调用 validate() 时传入额外验证器:
python
def check_unique(form, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('用户名已被注册')
form = RegisterForm()
if form.validate(extra_validators={'username': [check_unique]}):
# ...
4.2 DataRequired/InputRequired(必填验证)
4.2.1 DataRequired
DataRequired 验证字段数据不为空。它会检查 field.data 是否为 None、空字符串、空列表等假值。
python
from wtforms.validators import DataRequired
name = StringField('用户名', validators=[
DataRequired(message='用户名不能为空')
])
DataRequired 的行为:
- 如果数据为空,会设置
field.errors并停止后续验证器的执行。 message参数自定义错误消息。- 它会设置
field.flags.required = True,模板中可以据此渲染必填标记。
4.2.2 InputRequired
InputRequired 与 DataRequired 类似,但验证的是原始输入(field.raw_data)而非处理后的数据(field.data)。
python
from wtforms.validators import InputRequired
name = StringField('用户名', validators=[
InputRequired(message='请输入用户名')
])
两者的区别:
| 对比项 | DataRequired | InputRequired |
|---|---|---|
| 验证对象 | field.data(处理后) |
field.raw_data(原始输入) |
| 空字符串 | 不通过 | 不通过 |
0(整数) |
通过(非假值) | 通过(有输入) |
False(布尔) |
不通过 | 通过(有输入) |
| 默认值填充 | 可能通过 | 不通过 |
实际使用中,DataRequired 更常用。InputRequired 适用于需要确保用户确实输入了数据(而非使用默认值)的场景。
4.3 Length(长度验证)
Length 验证字符串或列表的长度范围。
python
from wtforms.validators import Length
# 同时限制最小和最大长度
username = StringField('用户名', validators=[
Length(min=3, max=20, message='用户名长度必须为%(min)d-%(max)d个字符')
])
# 只限制最小长度
password = PasswordField('密码', validators=[
Length(min=8, message='密码至少%(min)d位')
])
# 只限制最大长度
bio = TextAreaField('个人简介', validators=[
Length(max=500, message='个人简介最多%(max)d字')
])
# 精确长度
code = StringField('验证码', validators=[
Length(min=6, max=6, message='验证码必须是6位')
])
message 中可以使用 %(min)d 和 %(max)d 占位符,WTForms 会自动替换为实际值。
Length 也可以验证列表类型(如 SelectMultipleField 的选择数量):
python
tags = SelectMultipleField('标签', choices=[...], validators=[
Length(min=1, max=5, message='请选择1-5个标签')
])
4.4 NumberRange(数字范围验证)
NumberRange 验证数字是否在指定范围内。
python
from wtforms.validators import NumberRange
from decimal import Decimal
age = IntegerField('年龄', validators=[
NumberRange(min=0, max=150, message='年龄必须在%(min)d-%(max)d之间')
])
price = DecimalField('价格', validators=[
NumberRange(min=Decimal('0.01'), message='价格必须大于0')
])
# 只限制最大值
score = IntegerField('分数', validators=[
NumberRange(max=100, message='分数不能超过%(max)d')
])
NumberRange 会自动处理类型转换,如果字段数据无法转为数字,验证器会跳过(类型转换错误由字段自身处理)。
4.5 Email(邮箱格式验证)
Email 验证输入是否为有效的邮箱地址。
python
from wtforms.validators import Email
email = StringField('邮箱', validators=[
DataRequired(message='邮箱不能为空'),
Email(message='请输入有效的邮箱地址')
])
Email 验证器依赖 email_validator 包,需要单独安装:
bash
pip install email_validator
email_validator 使用 RFC 5322 标准验证邮箱格式,比简单的正则更准确。
Email 验证器的参数:
python
email = StringField('邮箱', validators=[
Email(
message='邮箱格式不正确',
granular_message=False, # 是否返回详细的错误原因
check_deliverability=False, # 是否检查域名可投递性(需联网DNS查询)
allow_smtputf8=True, # 是否允许 UTF-8 字符
)
])
4.6 URL(URL格式验证)
URL 验证输入是否为有效的 URL。
python
from wtforms.validators import URL
website = StringField('个人网站', validators=[
URL(message='请输入有效的URL', require_tld=True)
])
参数:
python
website = StringField('网站', validators=[
URL(
require_tld=True, # 是否要求顶级域名
allow_ip=True, # 是否允许 IP 地址
allow_userpass=True, # 是否允许 user:pass@host 格式
message='请输入有效的URL'
)
])
4.7 EqualTo(相等验证)
EqualTo 验证当前字段的值是否与另一个字段相等,常用于密码确认。
python
from wtforms.validators import EqualTo
class RegisterForm(FlaskForm):
password = PasswordField('密码', validators=[
DataRequired(),
Length(min=8)
])
confirm_password = PasswordField('确认密码', validators=[
DataRequired(),
EqualTo('password', message='两次输入的密码不一致')
])
注意 EqualTo 的参数是字段名(字符串),不是字段对象。验证时通过 form.<字段名> 获取对应字段的数据。
4.8 Regexp(正则表达式验证)
Regexp 使用正则表达式验证输入格式。
python
from wtforms.validators import Regexp
# 验证用户名(字母、数字、下划线)
username = StringField('用户名', validators=[
Regexp(r'^[a-zA-Z0-9_]+$',
message='用户名只能包含字母、数字和下划线')
])
# 验证手机号
phone = StringField('手机号', validators=[
Regexp(r'^1[3-9]\d{9}$',
message='请输入有效的手机号')
])
# 验证邮政编码
zipcode = StringField('邮编', validators=[
Regexp(r'^\d{6}$', message='邮编必须是6位数字')
])
# 验证身份证号(简化版)
id_card = StringField('身份证号', validators=[
Regexp(r'^\d{17}[\dXx]$',
message='请输入有效的身份证号')
])
# 验证强密码
password = PasswordField('密码', validators=[
Regexp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$',
message='密码至少8位,必须包含大小写字母、数字和特殊字符')
])
Regexp 的参数:
python
Regexp(
regex, # 正则表达式(字符串或编译后的 Pattern)
flags=0, # 正则标志(如 re.IGNORECASE)
message='格式不正确'
)
4.9 AnyOf/NoneOf(枚举验证)
4.9.1 AnyOf
AnyOf 验证输入值是否在指定的值列表中。
python
from wtforms.validators import AnyOf
status = SelectField('状态', choices=[
('draft', '草稿'),
('published', '已发布'),
('archived', '已归档'),
], validators=[
AnyOf(['draft', 'published', 'archived'],
message='无效的状态值')
])
AnyOf 常用于验证 SelectField 的值,防止用户篡改提交非法值。不过 SelectField 本身已经会验证值是否在 choices 中,所以 AnyOf 更多用于自由输入字段:
python
color = StringField('颜色', validators=[
AnyOf(['red', 'green', 'blue', 'yellow'],
message='颜色必须是红、绿、蓝、黄之一')
])
values_formatter 参数自定义错误消息中值的显示格式:
python
AnyOf(['red', 'green', 'blue'],
values_formatter=lambda v: '、'.join(v),
message='必须是以下颜色之一: %(values)s')
4.9.2 NoneOf
NoneOf 验证输入值不在指定的值列表中(黑名单)。
python
from wtforms.validators import NoneOf
username = StringField('用户名', validators=[
NoneOf(['admin', 'root', 'system', 'administrator'],
message='该用户名为保留字,请更换')
])
4.10 Optional(可选验证)
Optional 标记字段为可选,当字段为空时跳过后续所有验证器。
python
from wtforms.validators import Optional
# 可选的手机号:不填则不验证,填了才验证格式
phone = StringField('手机号(选填)', validators=[
Optional(),
Regexp(r'^1[3-9]\d{9}$', message='手机号格式不正确')
])
# 可选的年龄范围
age = IntegerField('年龄(选填)', validators=[
Optional(),
NumberRange(min=0, max=150)
])
Optional 的行为:
- 如果字段为空(空字符串、None),设置
field.flags.optional = True,并停止后续验证器。 - 如果字段有值,继续执行后续验证器。
Optional 验证器的参数:
python
Optional(
stop_validation=True # 是否在空值时停止所有验证(默认 True)
)
4.11 StopValidation(停止验证)
StopValidation 是一种特殊的异常,抛出后会立即停止该字段的所有后续验证器。
python
from wtforms.validators import StopValidation, ValidationError
def validate_sensitive(form, field):
"""如果字段包含敏感词,停止所有验证"""
sensitive_words = ['spam', '广告', '违规']
for word in sensitive_words:
if word in field.data:
raise StopValidation(f'内容包含敏感词: {word}')
class CommentForm(FlaskForm):
content = TextAreaField('评论', validators=[
DataRequired(),
validate_sensitive, # 如果触发,下面的验证器不会执行
Length(max=500)
])
StopValidation 与 ValidationError 的区别:
| 异常类型 | 行为 |
|---|---|
ValidationError |
记录错误,继续执行后续验证器 |
StopValidation |
记录错误(如果有消息),立即停止所有后续验证器 |
4.12 ValidationError异常
ValidationError 是所有验证失败时抛出的异常。它的消息会被收集到 field.errors 列表中。
python
from wtforms.validators import ValidationError
raise ValidationError('这是一个错误消息')
# 支持格式化
raise ValidationError(f'值 {field.data} 无效')
当字段有多个验证器都失败时,field.errors 会包含所有错误消息:
python
field = StringField(validators=[
DataRequired(),
Length(min=3),
Regexp(r'^[a-z]+$')
])
# 如果输入空字符串:
# field.errors = ['该字段是必填项。']
# (DataRequired 失败后,后续验证器可能被跳过)
# 如果输入 "AB":
# field.errors = ['字段长度必须至少为 3。', '字段只能包含小写字母。']
# (两个验证器都失败,错误都被收集)
4.13 自定义验证器
当内置验证器无法满足需求时,可以编写自定义验证器。WTForms 支持两种风格的自定义验证器。
4.13.1 函数式验证器
最简单的自定义验证器是一个普通函数:
python
from wtforms.validators import ValidationError
import re
def validate_phone(form, field):
"""验证中国大陆手机号"""
if not re.match(r'^1[3-9]\d{9}$', field.data):
raise ValidationError('请输入有效的手机号')
def validate_username(form, field):
"""验证用户名格式"""
if not re.match(r'^[a-zA-Z0-9_]+$', field.data):
raise ValidationError('用户名只能包含字母、数字和下划线')
if len(field.data) < 3:
raise ValidationError('用户名至少3个字符')
class RegisterForm(FlaskForm):
phone = StringField('手机号', validators=[validate_phone])
username = StringField('用户名', validators=[validate_username])
4.13.2 类式验证器(带参数)
如果验证器需要参数,使用类来实现:
python
from wtforms.validators import ValidationError
from datetime import date
class DateRange:
"""验证日期在指定范围内"""
def __init__(self, min_date=None, max_date=None, message=None):
self.min_date = min_date
self.max_date = max_date
self.message = message or '日期不在有效范围内'
def __call__(self, form, field):
if field.data is None:
return
if self.min_date and field.data < self.min_date:
raise ValidationError(
f'{self.message}(最早: {self.min_date})'
)
if self.max_date and field.data > self.max_date:
raise ValidationError(
f'{self.message}(最晚: {self.max_date})'
)
class Unique:
"""验证数据库中值唯一"""
def __init__(self, model, field, message=None):
self.model = model
self.field = field
self.message = message or '该值已存在'
def __call__(self, form, field):
if self.model.query.filter_by(**{self.field: field.data}).first():
raise ValidationError(self.message)
使用:
python
class EventForm(FlaskForm):
event_date = DateField('活动日期', validators=[
DataRequired(),
DateRange(
min_date=date.today(),
max_date=date.today().replace(year=date.today().year + 1),
message='活动日期必须在今天到明年今天之间'
)
])
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(),
Unique(User, 'username', message='该用户名已被注册')
])
email = StringField('邮箱', validators=[
DataRequired(),
Email(),
Unique(User, 'email', message='该邮箱已被注册')
])
4.13.3 内联验证器
在表单类中定义 validate_<字段名> 方法,适合需要访问表单其他字段的验证:
python
class BookingForm(FlaskForm):
check_in = DateField('入住日期', validators=[DataRequired()])
check_out = DateField('退房日期', validators=[DataRequired()])
guests = IntegerField('人数', validators=[NumberRange(min=1, max=10)])
def validate_check_in(self, field):
"""入住日期不能早于今天"""
if field.data < date.today():
raise ValidationError('入住日期不能早于今天')
def validate_check_out(self, field):
"""退房日期必须晚于入住日期"""
if field.data <= self.check_in.data:
raise ValidationError('退房日期必须晚于入住日期')
def validate_guests(self, field):
"""人数不能超过日期范围内的可用房间数"""
days = (self.check_out.data - self.check_in.data).days
if days <= 0:
return # 日期错误由其他验证器处理
# 检查库存...
4.14 验证器组合使用
实际项目中,一个字段通常需要多个验证器组合使用。
4.14.1 常见组合模式
python
class RegisterForm(FlaskForm):
# 用户名:必填 + 长度 + 格式 + 唯一
username = StringField('用户名', validators=[
DataRequired(message='用户名不能为空'),
Length(min=3, max=20, message='用户名长度3-20个字符'),
Regexp(r'^[a-zA-Z0-9_]+$', message='用户名只能包含字母、数字和下划线'),
# Unique 验证器(类式,需要数据库访问)
])
# 邮箱:必填 + 格式 + 唯一
email = StringField('邮箱', validators=[
DataRequired(message='邮箱不能为空'),
Email(message='邮箱格式不正确'),
])
# 密码:必填 + 长度 + 强度
password = PasswordField('密码', validators=[
DataRequired(message='密码不能为空'),
Length(min=8, max=32, message='密码长度8-32位'),
Regexp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$',
message='密码必须包含大小写字母和数字')
])
# 确认密码:必填 + 相等
confirm_password = PasswordField('确认密码', validators=[
DataRequired(message='请确认密码'),
EqualTo('password', message='两次密码不一致')
])
# 手机号(选填):可选 + 格式
phone = StringField('手机号(选填)', validators=[
Optional(),
Regexp(r'^1[3-9]\d{9}$', message='手机号格式不正确')
])
# 年龄(选填):可选 + 范围
age = IntegerField('年龄(选填)', validators=[
Optional(),
NumberRange(min=0, max=150, message='年龄必须在0-150之间')
])
4.14.2 验证器执行顺序
验证器按以下顺序执行:
- 字段自身的类型转换(
process_formdata) validators列表中的验证器(按顺序)- 内联验证器(
validate_<字段名>) - 传入
validate()的额外验证器
python
# 执行顺序示例
class MyForm(FlaskForm):
name = StringField('名称', validators=[
DataRequired(), # 1. 先执行
Length(min=3), # 2. 再执行
])
def validate_name(self, field):
# 3. 最后执行内联验证器
pass
# 4. 额外验证器(如果传入了 extra_validators)
form.validate(extra_validators={'name': [custom_validator]})
4.15 验证器实战案例集
下面通过几个实际场景展示验证器的综合应用。
4.15.1 密码强度验证器
python
import re
from wtforms.validators import ValidationError
class PasswordStrength:
"""密码强度验证器"""
def __init__(self, min_length=8, require_upper=True, require_lower=True,
require_digit=True, require_special=False, message=None):
self.min_length = min_length
self.require_upper = require_upper
self.require_lower = require_lower
self.require_digit = require_digit
self.require_special = require_special
self.message = message
def __call__(self, form, field):
password = field.data
if not password:
return
errors = []
if len(password) < self.min_length:
errors.append(f'至少{self.min_length}位')
if self.require_upper and not re.search(r'[A-Z]', password):
errors.append('包含大写字母')
if self.require_lower and not re.search(r'[a-z]', password):
errors.append('包含小写字母')
if self.require_digit and not re.search(r'\d', password):
errors.append('包含数字')
if self.require_special and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
errors.append('包含特殊字符')
if errors:
msg = self.message or f'密码需要: {", ".join(errors)}'
raise ValidationError(msg)
class RegisterForm(FlaskForm):
password = PasswordField('密码', validators=[
DataRequired(),
PasswordStrength(min_length=8, require_special=True)
])
4.15.2 文件大小验证器
python
from wtforms.validators import ValidationError
class FileSize:
"""文件大小验证器"""
def __init__(self, max_size=None, min_size=None):
self.max_size = max_size # 字节
self.min_size = min_size
def __call__(self, form, field):
# 处理单个文件
file = field.data
if not file:
return
# 获取文件大小
file.seek(0, 2) # 移到文件末尾
size = file.tell()
file.seek(0) # 重置文件指针
if self.max_size and size > self.max_size:
max_mb = self.max_size / (1024 * 1024)
raise ValidationError(f'文件大小不能超过{max_mb:.1f}MB')
if self.min_size and size < self.min_size:
min_kb = self.min_size / 1024
raise ValidationError(f'文件大小不能小于{min_kb:.1f}KB')
class UploadForm(FlaskForm):
avatar = FileField('头像', validators=[
FileRequired(),
FileAllowed(['jpg', 'png', 'gif']),
FileSize(max_size=2 * 1024 * 1024) # 最大2MB
])
4.15.3 条件验证器
python
class ConditionalRequired:
"""条件必填:当另一个字段满足条件时,当前字段必填"""
def __init__(self, other_field, other_value, message=None):
self.other_field = other_field
self.other_value = other_value
self.message = message or '此字段为必填项'
def __call__(self, form, field):
other = getattr(form, self.other_field, None)
if other and other.data == self.other_value:
if not field.data:
raise ValidationError(self.message)
class OrderForm(FlaskForm):
delivery_method = SelectField('配送方式', choices=[
('pickup', '自提'),
('delivery', '快递'),
])
address = StringField('收货地址', validators=[
ConditionalRequired('delivery_method', 'delivery',
message='选择快递配送时必须填写地址')
])
pickup_time = StringField('自提时间', validators=[
ConditionalRequired('delivery_method', 'pickup',
message='选择自提时必须填写自提时间')
])
4.15.4 中国身份证验证器
python
import re
from datetime import datetime
def validate_id_card(form, field):
"""验证中国大陆身份证号(18位)"""
value = field.data
if not value:
return
# 基本格式
if not re.match(r'^\d{17}[\dXx]$', value):
raise ValidationError('身份证号格式不正确')
# 验证出生日期
try:
birth_date = datetime.strptime(value[6:14], '%Y%m%d')
if birth_date > datetime.now():
raise ValidationError('出生日期不能晚于今天')
except ValueError:
raise ValidationError('身份证号中的出生日期无效')
# 验证校验位
factors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
check_codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
total = sum(int(value[i]) * factors[i] for i in range(17))
check_code = check_codes[total % 11]
if value[-1].upper() != check_code:
raise ValidationError('身份证号校验位不正确')
class IDCardForm(FlaskForm):
id_card = StringField('身份证号', validators=[
DataRequired(message='请输入身份证号'),
validate_id_card
])
4.15.5 中国手机号验证器
python
import re
class ChinesePhone:
"""中国大陆手机号验证器"""
# 运营商号段(会随时间更新,需定期维护)
PHONE_PATTERN = re.compile(r'^1[3-9]\d{9}$')
def __init__(self, message=None):
self.message = message or '请输入有效的手机号'
def __call__(self, form, field):
if not field.data:
return
if not self.PHONE_PATTERN.match(field.data):
raise ValidationError(self.message)
class PhoneForm(FlaskForm):
phone = StringField('手机号', validators=[
DataRequired(message='手机号不能为空'),
ChinesePhone()
])
4.15.6 日期范围验证器
在预约、活动报名等场景中,需要验证用户选择的日期在允许的范围内:
python
from datetime import date, datetime
class DateRange:
"""日期范围验证器
Args:
min_date: 最小日期(date对象或返回date的callable)
max_date: 最大日期(date对象或返回date的callable)
"""
def __init__(self, min_date=None, max_date=None, message=None):
self.min_date = min_date
self.max_date = max_date
self.message = message
def __call__(self, form, field):
if not field.data:
return
value = field.data
if isinstance(value, str):
try:
value = datetime.strptime(value, '%Y-%m-%d').date()
except ValueError:
raise ValidationError('日期格式不正确')
min_d = self.min_date() if callable(self.min_date) else self.min_date
max_d = self.max_date() if callable(self.max_date) else self.max_date
if min_d and value < min_d:
msg = self.message or f'日期不能早于 {min_d.strftime("%Y年%m月%d日")}'
raise ValidationError(msg)
if max_d and value > max_d:
msg = self.message or f'日期不能晚于 {max_d.strftime("%Y年%m月%d日")}'
raise ValidationError(msg)
class AppointmentForm(FlaskForm):
# 预约日期不能是过去日期,不能超过30天后
appointment_date = DateField('预约日期', validators=[
DataRequired(message='请选择预约日期'),
DateRange(
min_date=date.today,
max_date=lambda: date.today() + timedelta(days=30),
message='请选择今天到30天内的日期'
)
])
4.15.7 唯一性验证器(数据库检查)
在注册、修改邮箱等场景中,需要检查某个值在数据库中是否唯一:
python
class Unique:
"""数据库唯一性验证器
Args:
model: SQLAlchemy 模型类
field: 模型中的字段名(字符串)
message: 错误消息
exclude: 排除的记录ID(用于编辑场景)
"""
def __init__(self, model, field, message=None, exclude=None):
self.model = model
self.field = field
self.message = message or '该值已存在'
self.exclude = exclude
def __call__(self, form, field):
if not field.data:
return
query = self.model.query.filter(
getattr(self.model, self.field) == field.data
)
# 编辑场景:排除当前记录
if self.exclude:
exclude_id = self.exclude() if callable(self.exclude) else self.exclude
if exclude_id:
query = query.filter(self.model.id != exclude_id)
if query.first():
raise ValidationError(self.message)
# 使用示例
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(),
Length(min=3, max=20),
Unique(User, 'username', message='该用户名已被注册')
])
email = StringField('邮箱', validators=[
DataRequired(),
Email(),
Unique(User, 'email', message='该邮箱已被注册')
])
# 编辑场景(排除当前用户)
class EditProfileForm(FlaskForm):
def __init__(self, current_user_id, *args, **kwargs):
super().__init__(*args, **kwargs)
# 动态设置exclude参数
self.email.validators.append(
Unique(User, 'email',
message='该邮箱已被其他用户使用',
exclude=current_user_id)
)
email = StringField('邮箱', validators=[DataRequired(), Email()])
4.15.8 敏感词过滤验证器
在用户生成内容(UGC)场景中,需要过滤敏感词汇:
python
class NoForbiddenWords:
"""敏感词过滤验证器"""
# 默认敏感词列表(实际项目中应从数据库或配置文件加载)
DEFAULT_FORBIDDEN = ['spam', '广告', '违法', '色情']
def __init__(self, forbidden_words=None, message=None, case_sensitive=False):
self.forbidden_words = forbidden_words or self.DEFAULT_FORBIDDEN
self.message = message or '内容包含不允许的词汇'
self.case_sensitive = case_sensitive
def __call__(self, form, field):
if not field.data:
return
text = field.data if self.case_sensitive else field.data.lower()
words = (self.forbidden_words if self.case_sensitive
else [w.lower() for w in self.forbidden_words])
found = [word for word in words if word in text]
if found:
raise ValidationError(f'{self.message}: {", ".join(found)}')
class CommentForm(FlaskForm):
content = TextAreaField('评论内容', validators=[
DataRequired(message='评论不能为空'),
Length(min=2, max=500, message='评论长度2-500字符'),
NoForbiddenWords(message='评论包含违规词汇')
])
4.15.9 验证器组合实战:完整的用户注册表单
将上述验证器组合使用,构建一个生产级别的注册表单:
python
class ProductionRegisterForm(FlaskForm):
"""生产级用户注册表单"""
username = StringField('用户名', validators=[
DataRequired(message='用户名不能为空'),
Length(min=3, max=20, message='用户名长度3-20个字符'),
Regexp(r'^[a-zA-Z0-9_]+$', message='用户名只能包含字母、数字和下划线'),
NoForbiddenWords(message='用户名包含违规词汇'),
Unique(User, 'username', message='该用户名已被注册')
], render_kw={'class': 'form-control', 'placeholder': '3-20个字符'})
email = StringField('邮箱', validators=[
DataRequired(message='邮箱不能为空'),
Email(message='邮箱格式不正确'),
Unique(User, 'email', message='该邮箱已被注册')
], render_kw={'class': 'form-control', 'placeholder': 'example@email.com'})
phone = StringField('手机号', validators=[
DataRequired(message='手机号不能为空'),
ChinesePhone()
], render_kw={'class': 'form-control', 'placeholder': '11位手机号'})
password = PasswordField('密码', validators=[
DataRequired(message='密码不能为空'),
Length(min=8, max=32, message='密码长度8-32位'),
PasswordStrength(min_length=8, require_upper=True,
require_lower=True, require_digit=True,
require_special=True)
], render_kw={'class': 'form-control', 'placeholder': '至少8位,含大小写字母、数字和特殊字符'})
confirm_password = PasswordField('确认密码', validators=[
DataRequired(message='请确认密码'),
EqualTo('password', message='两次输入的密码不一致')
], render_kw={'class': 'form-control'})
id_card = StringField('身份证号', validators=[
Optional(),
validate_id_card
], render_kw={'class': 'form-control', 'placeholder': '选填'})
agree = BooleanField('我已阅读并同意', validators=[
DataRequired(message='请同意用户协议')
])
submit = SubmitField('注册', render_kw={'class': 'btn btn-primary w-100'})
第五章 表单渲染与模板
表单定义好后,需要在模板中渲染成 HTML。本章讲解如何在 Jinja2 模板中渲染 WTForms 表单,包括字段渲染、错误显示、CSRF 令牌,以及使用 Bootstrap 美化表单。
5.1 表单类定义完整示例
我们先定义一个完整的表单类,作为本章渲染的示例:
python
# forms.py
from flask_wtf import FlaskForm
from wtforms import (StringField, TextAreaField, PasswordField, SelectField,
BooleanField, SubmitField, IntegerField, DateField)
from wtforms.validators import DataRequired, Email, Length, NumberRange, EqualTo
class ArticleForm(FlaskForm):
title = StringField('标题', validators=[
DataRequired(message='标题不能为空'),
Length(max=100, message='标题最多100字')
], description='文章标题将显示在列表页', render_kw={
'class': 'form-control',
'placeholder': '请输入文章标题'
})
category = SelectField('分类', choices=[
('', '请选择分类'),
('tech', '技术'),
('life', '生活'),
('travel', '旅行'),
], validators=[DataRequired(message='请选择分类')], coerce=str,
render_kw={'class': 'form-control'})
content = TextAreaField('正文', validators=[
DataRequired(message='正文不能为空'),
Length(min=10, message='正文至少10字')
], render_kw={
'class': 'form-control',
'rows': 10,
'placeholder': '请输入文章正文...'
})
tags = StringField('标签', description='多个标签用逗号分隔', render_kw={
'class': 'form-control',
'placeholder': 'Flask, Python, Web'
})
is_public = BooleanField('公开', default=True, render_kw={
'class': 'form-check-input'
})
submit = SubmitField('发布文章', render_kw={'class': 'btn btn-primary'})
5.2 在模板中渲染表单
5.2.1 基本渲染
在 Flask 视图函数中将表单传递给模板:
python
# app.py
from flask import render_template
from forms import ArticleForm
@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
form = ArticleForm()
if form.validate_on_submit():
# 处理表单...
return redirect(url_for('article_list'))
return render_template('new_article.html', form=form)
在模板中渲染:
html
<!-- templates/new_article.html -->
<form method="post">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.title.label(class_='form-label') }}
{{ form.title() }}
<small class="form-text text-muted">{{ form.title.description }}</small>
{% for error in form.title.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="form-group">
{{ form.category.label(class_='form-label') }}
{{ form.category() }}
{% for error in form.category.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="form-group">
{{ form.content.label(class_='form-label') }}
{{ form.content() }}
{% for error in form.content.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="form-group">
{{ form.tags.label(class_='form-label') }}
{{ form.tags() }}
<small class="form-text text-muted">{{ form.tags.description }}</small>
</div>
<div class="form-group form-check">
{{ form.is_public() }}
{{ form.is_public.label(class_='form-check-label') }}
</div>
{{ form.submit() }}
</form>
5.2.2 form.hidden_tag()
form.hidden_tag() 渲染所有隐藏字段,最重要的是 CSRF 令牌。它必须在 <form> 标签之后的第一行调用。
html
<form method="post">
{{ form.hidden_tag() }}
<!-- 其他字段... -->
</form>
生成的 HTML:
html
<input id="csrf_token" name="csrf_token" type="hidden" value="IjY5M...">
如果表单中有其他 HiddenField,它们也会被 hidden_tag() 一起渲染。
5.2.3 字段渲染方法
每个字段对象都可以像函数一样调用来渲染 HTML:
html
<!-- 渲染输入控件 -->
{{ form.title() }}
<!-- 渲染标签 -->
{{ form.title.label() }}
<!-- 带参数渲染 -->
{{ form.title(class_='form-control', placeholder='请输入标题') }}
{{ form.title.label(class_='form-label', for_='title') }}
5.3 字段渲染选项
在模板中调用字段时,可以传入参数覆盖 render_kw 中的设置。
5.3.1 常用渲染参数
html
<!-- class_ 设置 CSS 类 -->
{{ form.title(class_='form-control form-control-lg') }}
<!-- placeholder 设置占位文本 -->
{{ form.title(placeholder='请输入标题') }}
<!-- id 设置 HTML id -->
{{ form.title(id='article-title') }}
<!-- 任意 HTML 属性 -->
{{ form.title(data_toggle='tooltip', title='提示文字') }}
<!-- disabled 禁用 -->
{{ form.title(disabled='disabled') }}
<!-- readonly 只读 -->
{{ form.title(readonly='readonly') }}
<!-- required 必填(HTML5 原生验证) -->
{{ form.title(required=True) }}
<!-- maxlength 最大长度 -->
{{ form.title(maxlength=100) }}
<!-- 组合多个参数 -->
{{ form.title(class_='form-control', placeholder='标题',
id='title', required=True, maxlength=100) }}
5.3.2 render_kw 与模板参数的优先级
模板中传入的参数会与 render_kw 合并,模板参数优先:
python
# 表单定义
title = StringField('标题', render_kw={
'class': 'form-control',
'placeholder': '默认占位符'
})
html
<!-- 模板中覆盖 -->
{{ form.title(class_='form-control-lg', placeholder='自定义占位符') }}
渲染结果:
html
<input class="form-control-lg" placeholder="自定义占位符" ...>
5.3.3 SelectField 渲染
html
<!-- 基本渲染 -->
{{ form.category() }}
<!-- 带 class -->
{{ form.category(class_='form-select') }}
5.3.4 RadioField 渲染
RadioField 需要遍历每个选项:
html
{% for option in form.category %}
<div class="form-check">
{{ option(class_='form-check-input') }}
{{ option.label(class_='form-check-label') }}
</div>
{% endfor %}
5.3.5 BooleanField 渲染
html
<div class="form-check">
{{ form.is_public(class_='form-check-input') }}
{{ form.is_public.label(class_='form-check-label') }}
</div>
5.4 自定义字段Widget
Widget 控制字段的 HTML 渲染方式。WTForms 内置了多种 Widget,也支持自定义。
5.4.1 内置 Widget
python
from wtforms.widgets import (
Input, TextInput, PasswordInput, TextArea, Select,
CheckboxInput, RadioInput, SubmitInput, HiddenInput, FileInput
)
每个字段类型默认关联一个 Widget:
| 字段类型 | 默认 Widget |
|---|---|
| StringField | TextInput |
| PasswordField | PasswordInput |
| TextAreaField | TextArea |
| BooleanField | CheckboxInput |
| SelectField | Select |
| HiddenField | HiddenInput |
| FileField | FileInput |
| SubmitField | SubmitInput |
5.4.2 修改字段的 Widget
python
from wtforms import StringField, PasswordField
from wtforms.widgets import TextInput
# 让 StringField 渲染为 email 类型
email = StringField('邮箱', widget=TextInput(input_type='email'))
# 让 PasswordField 显示密码(明文)
password = StringField('密码', widget=TextInput(input_type='password'))
5.4.3 自定义 Widget
python
from wtforms.widgets import Widget, html_params
from markupsafe import Markup
class BootstrapTextInput(Widget):
"""自动添加 Bootstrap 样式的文本输入 Widget"""
def __init__(self, size=None, valid=None):
self.size = size # 'sm', 'lg', None
self.valid = valid # True, False, None
def __call__(self, field, **kwargs):
classes = ['form-control']
if self.size:
classes.append(f'form-control-{self.size}')
if self.valid is True:
classes.append('is-valid')
elif self.valid is False:
classes.append('is-invalid')
kwargs.setdefault('class', ' '.join(classes))
kwargs.setdefault('type', 'text')
kwargs.setdefault('id', field.id)
kwargs.setdefault('name', field.name)
if field.data:
kwargs.setdefault('value', field.data)
return Markup(f'<input {html_params(**kwargs)}>')
class StyledStringField(StringField):
widget = BootstrapTextInput()
使用:
python
class MyForm(FlaskForm):
name = StyledStringField('名称')
email = StyledStringField('邮箱', widget=BootstrapTextInput(input_type='email'))
5.5 使用Bootstrap渲染表单
手动给每个字段添加 Bootstrap class 很繁琐。可以使用 Flask-Bootstrap 或 Bootstrap-Flask 扩展来简化。
5.5.1 Bootstrap-Flask
bash
pip install Bootstrap-Flask
python
from flask import Flask
from flask_bootstrap import Bootstrap5
# 或 from bootstrap_flask import Bootstrap
app = Flask(__name__)
bootstrap = Bootstrap5(app)
Bootstrap-Flask 提供了 render_form、render_field 等宏:
html
<!-- templates/base.html -->
{% from 'bootstrap5/form.html' import render_form, render_field %}
<!-- 快速渲染整个表单 -->
{{ render_form(form) }}
<!-- 渲染单个字段 -->
{{ render_field(form.title) }}
{{ render_field(form.content) }}
{{ render_field(form.category) }}
{{ render_field(form.is_public) }}
{{ render_field(form.submit) }}
render_form 会自动生成完整的表单 HTML,包括 CSRF 令牌、错误显示、Bootstrap 样式等:
html
<!-- render_form(form) 生成的 HTML(简化) -->
<form method="post">
<input id="csrf_token" name="csrf_token" type="hidden" value="...">
<div class="mb-3">
<label class="form-label" for="title">标题</label>
<input class="form-control" id="title" name="title" type="text" value="">
</div>
<!-- ... 其他字段 ... -->
<button class="btn btn-primary" id="submit" name="submit" type="submit">发布文章</button>
</form>
render_form 的参数:
html
{{ render_form(
form,
action='/article/new', # 表单提交 URL
method='post', # 提交方式
enctype='multipart/form-data', # 编码类型(文件上传时需要)
button_text='保存', # 按钮文本
button_class='btn btn-success', # 按钮样式
id='article-form', # 表单 id
class_='my-form', # 表单 class
novalidate=True, # 禁用浏览器原生验证
) }}
5.5.2 render_field 参数
html
{{ render_field(
form.title,
label_class='form-label',
field_class='form-control form-control-lg',
placeholder='请输入标题',
description='文章标题',
show_errors=True,
error_class='text-danger'
) }}
5.6 表单宏封装
如果不想使用 Bootstrap-Flask,可以自己编写 Jinja2 宏来封装表单渲染逻辑。
5.6.1 创建表单宏文件
html
<!-- templates/macros/forms.html -->
{# 渲染单个字段(带标签、错误、描述) #}
{% macro render_field(field, label_class='', field_class='', show_label=True) %}
<div class="form-group mb-3">
{% if show_label %}
{{ field.label(class_=label_class or 'form-label') }}
{% endif %}
{{ field(class_=field_class or 'form-control', **kwargs) }}
{% if field.description %}
<small class="form-text text-muted">{{ field.description }}</small>
{% endif %}
{% if field.errors %}
{% for error in field.errors %}
<div class="invalid-feedback d-block text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
{% endmacro %}
{# 渲染复选框字段 #}
{% macro render_checkbox(field) %}
<div class="form-group mb-3 form-check">
{{ field(class_='form-check-input') }}
{{ field.label(class_='form-check-label') }}
{% if field.errors %}
{% for error in field.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
{% endmacro %}
{# 渲染单选按钮组 #}
{% macro render_radio(field) %}
<div class="form-group mb-3">
{{ field.label(class_='form-label') }}
{% for option in field %}
<div class="form-check">
{{ option(class_='form-check-input') }}
{{ option.label(class_='form-check-label') }}
</div>
{% endfor %}
{% if field.errors %}
{% for error in field.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
</div>
{% endmacro %}
{# 渲染整个表单 #}
{% macro render_form(form, action='', method='post', enctype='', submit_text='提交', submit_class='btn btn-primary') %}
<form action="{{ action }}" method="{{ method }}"
{% if enctype %}enctype="{{ enctype }}"{% endif %}>
{{ form.hidden_tag() }}
{{ caller() if caller }}
<button type="submit" class="{{ submit_class }}">{{ submit_text }}</button>
</form>
{% endmacro %}
5.6.2 使用宏
html
<!-- templates/new_article.html -->
{% from 'macros/forms.html' import render_field, render_checkbox %}
<form method="post">
{{ form.hidden_tag() }}
{{ render_field(form.title) }}
{{ render_field(form.category) }}
{{ render_field(form.content, field_class='form-control', rows='10') }}
{{ render_field(form.tags) }}
{{ render_checkbox(form.is_public) }}
{{ form.submit(class_='btn btn-primary') }}
</form>
5.7 表单错误显示(form.errors)
表单验证失败后,错误信息存储在 form.errors 字典中。
5.7.1 errors 结构
python
form.errors = {
'title': ['标题不能为空'],
'email': ['邮箱不能为空', '邮箱格式不正确'],
'password': ['密码至少8位'],
}
每个字段可以有多个错误,存储在列表中。
5.7.2 在模板中显示错误
html
<!-- 单个字段的错误 -->
{% if form.title.errors %}
{% for error in form.title.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
{% endif %}
<!-- 显示所有错误(汇总) -->
{% if form.errors %}
<div class="alert alert-danger">
<ul>
{% for field_name, errors in form.errors.items() %}
<li>{{ form[field_name].label.text }}:
{{ errors | join(', ') }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
5.7.3 Bootstrap 风格的错误显示
html
{% macro field_with_errors(field) %}
<div class="form-group mb-3">
{{ field.label(class_='form-label') }}
{{ field(class_='form-control' + (' is-invalid' if field.errors else '')) }}
{% if field.errors %}
{% for error in field.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% else %}
{% if field.description %}
<small class="form-text text-muted">{{ field.description }}</small>
{% endif %}
{% endif %}
</div>
{% endmacro %}
5.8 表单CSRF令牌渲染
5.8.1 FlaskForm 中的 CSRF
使用 FlaskForm 时,通过 form.hidden_tag() 渲染:
html
<form method="post">
{{ form.hidden_tag() }}
<!-- 字段... -->
</form>
5.8.2 非 FlaskForm 中的 CSRF
使用 CSRFProtect 全局保护时,在模板中使用 csrf_token() 函数:
html
<!-- 在普通表单中 -->
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- 字段... -->
</form>
<!-- 在 meta 标签中(AJAX 使用) -->
<meta name="csrf-token" content="{{ csrf_token() }}">
5.8.3 AJAX 请求携带 CSRF
html
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// 全局设置 AJAX 请求携带 CSRF 令牌
function getCookie(name) {
// Flask-WTF 默认也将 CSRF 令牌存储在 cookie 中
...
}
// 使用 fetch
async function submitForm(data) {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const response = await fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(data)
});
return response.json();
}
// 使用 jQuery
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (settings.type && settings.type.match(/POST|PUT|PATCH|DELETE/)) {
xhr.setRequestHeader('X-CSRFToken', csrfToken);
}
}
});
</script>
5.9 表单HTML完整示例
下面是一个完整的表单 HTML 示例,综合运用了本章所有知识:
html
<!-- templates/register.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户注册</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h4 class="mb-0">用户注册</h4>
</div>
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class_='form-label') }}
{{ form.username(class_='form-control' + (' is-invalid' if form.username.errors else ''),
placeholder='3-20个字符') }}
{% for error in form.username.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.email.label(class_='form-label') }}
{{ form.email(class_='form-control' + (' is-invalid' if form.email.errors else ''),
placeholder='example@email.com') }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class_='form-label') }}
{{ form.password(class_='form-control' + (' is-invalid' if form.password.errors else ''),
placeholder='至少8位') }}
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.confirm_password.label(class_='form-label') }}
{{ form.confirm_password(class_='form-control' + (' is-invalid' if form.confirm_password.errors else '')) }}
{% for error in form.confirm_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3 form-check">
{{ form.agree(class_='form-check-input' + (' is-invalid' if form.agree.errors else '')) }}
{{ form.agree.label(class_='form-check-label') }}
{% for error in form.agree.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="d-grid">
{{ form.submit(class_='btn btn-primary btn-lg') }}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
5.10 表单渲染最佳实践
- 使用宏封装:将重复的渲染逻辑封装成宏,保持模板简洁。
- 统一错误显示:所有字段使用统一的错误显示样式。
- 添加 CSRF 令牌:每个 POST 表单都必须包含 CSRF 令牌。
- 使用 novalidate:在开发阶段禁用浏览器原生验证,确保服务器端验证生效。
- 响应式设计:使用 Bootstrap 等框架确保表单在不同设备上良好显示。
- 可访问性 :使用
<label>关联字段,添加aria属性提升可访问性。 - 描述信息:为复杂字段添加描述帮助用户理解。
5.10.1 可访问性(Accessibility)详解
表单的可访问性不仅仅是"锦上添花",而是法律要求(在很多国家和地区)和道德义务。以下是提升表单可访问性的具体措施:
html
<!-- 1. 正确关联 label 和 input -->
<div class="mb-3">
<label for="email" class="form-label">邮箱地址</label>
<input type="email" id="email" name="email"
class="form-control"
aria-describedby="email-help email-error"
required>
<small id="email-help" class="form-text text-muted">
我们不会泄露您的邮箱地址
</small>
<div id="email-error" class="invalid-feedback" role="alert">
请输入有效的邮箱地址
</div>
</div>
<!-- 2. 为必填字段添加视觉和语义标记 -->
<div class="mb-3">
<label for="username" class="form-label">
用户名 <span class="text-danger" aria-label="必填">*</span>
</label>
<input type="text" id="username" name="username"
class="form-control" required
aria-required="true">
</div>
<!-- 3. 分组表单控件 -->
<fieldset class="mb-3">
<legend class="form-label">性别</legend>
<div class="form-check">
<input type="radio" id="gender-male" name="gender" value="male"
class="form-check-input">
<label for="gender-male" class="form-check-label">男</label>
</div>
<div class="form-check">
<input type="radio" id="gender-female" name="gender" value="female"
class="form-check-input">
<label for="gender-female" class="form-check-label">女</label>
</div>
</fieldset>
<!-- 4. 错误提示使用 role="alert" 确保屏幕阅读器即时播报 -->
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading">表单验证失败</h5>
<p>请修正以下错误后重新提交:</p>
<ul>
<li>用户名至少需要3个字符</li>
<li>邮箱格式不正确</li>
</ul>
</div>
5.10.2 表单布局最佳实践
不同类型的表单适合不同的布局。以下是三种常见布局的适用场景和实现:
html
<!-- 1. 垂直布局(最常用,适合大多数表单) -->
<form method="post">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class_='form-label') }}
{{ form.name(class_='form-control') }}
</div>
<div class="mb-3">
{{ form.email.label(class_='form-label') }}
{{ form.email(class_='form-control') }}
</div>
{{ form.submit(class_='btn btn-primary') }}
</form>
<!-- 2. 水平布局(标签和输入框在同一行,适合编辑表单) -->
<form method="post">
{{ form.hidden_tag() }}
<div class="row mb-3">
<label class="col-sm-2 col-form-label">{{ form.name.label.text }}</label>
<div class="col-sm-10">
{{ form.name(class_='form-control') }}
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 col-form-label">{{ form.email.label.text }}</label>
<div class="col-sm-10">
{{ form.email(class_='form-control') }}
</div>
</div>
<div class="row">
<div class="col-sm-10 offset-sm-2">
{{ form.submit(class_='btn btn-primary') }}
</div>
</div>
</form>
<!-- 3. 内联布局(适合搜索栏、登录条等紧凑场景) -->
<form class="d-flex gap-2" method="post">
{{ form.hidden_tag() }}
{{ form.keyword(class_='form-control', placeholder='搜索...') }}
{{ form.submit(class_='btn btn-outline-primary') }}
</form>
5.10.3 表单渲染性能考虑
在大型应用中,表单渲染的性能也需要关注。以下是一些优化建议:
-
缓存静态选项 :如果
SelectField的choices来自数据库且不常变化,应该缓存查询结果,避免每次渲染表单都查询数据库。可以使用functools.lru_cache或 Redis 缓存。 -
避免在模板中做复杂逻辑:表单的渲染逻辑应该尽量简单,复杂的业务逻辑(如根据用户角色显示不同字段)应该在视图函数中处理,通过传递不同的表单实例来实现。
-
使用宏减少模板体积:将重复的表单渲染代码封装成宏,不仅可以减少模板体积,还能确保所有表单的渲染风格一致。
-
延迟加载大表单:对于包含大量字段的表单(如复杂的设置页面),可以考虑使用分页或 Tab 标签页的方式拆分,避免一次性渲染过多 HTML。
第六章 表单处理流程
本章讲解表单从展示到提交到处理的完整流程,包括验证、数据获取、错误处理、PRG 模式等核心概念。
6.1 表单处理完整流程
表单处理的标准流程如下:
1. GET 请求 → 展示空表单
2. 用户填写 → POST 提交
3. 服务器接收 → 验证数据
4. 验证通过 → 处理业务 → 重定向(PRG 模式)
5. 验证失败 → 回显表单(保留数据,显示错误)
代码实现:
python
from flask import Flask, render_template, redirect, url_for, flash, request
from flask_wtf import FlaskForm
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret-key'
class ContactForm(FlaskForm):
name = StringField('姓名', validators=[DataRequired()])
email = StringField('邮箱', validators=[DataRequired(), Email()])
message = TextAreaField('留言', validators=[DataRequired()])
submit = SubmitField('发送')
@app.route('/contact', methods=['GET', 'POST'])
def contact():
form = ContactForm()
if form.validate_on_submit():
# 验证通过,处理业务
name = form.name.data
email = form.email.data
message = form.message.data
# 保存到数据库或发送邮件...
flash('留言发送成功!', 'success')
return redirect(url_for('contact')) # PRG 模式:重定向
# GET 请求或验证失败,都渲染表单
return render_template('contact.html', form=form)
这个流程的关键点:
- GET 和 POST 共用同一个视图函数 :通过
methods=['GET', 'POST']声明。 form.validate_on_submit():同时检查是否为 POST 请求和是否验证通过。- 验证失败自动回显:WTForms 会保留用户输入的数据,模板中渲染时自动回填。
- PRG 模式:验证通过后重定向,防止刷新导致重复提交。
6.2 form.validate_on_submit()详解
validate_on_submit() 是 FlaskForm 提供的便捷方法,等价于:
python
def validate_on_submit(self):
"""检查是否为 POST/PUT/PATCH/DELETE 请求 且 验证通过"""
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
return self.validate()
return False
它的两个条件:
- 请求方法为 POST(或 PUT/PATCH/DELETE)。
form.validate()返回True(所有字段验证通过)。
python
# 等价于
if request.method == 'POST' and form.validate():
# ...
# 使用 validate_on_submit
if form.validate_on_submit():
# ...
6.3 form.validate()手动验证
有时你需要在非标准场景下手动触发验证。
6.3.1 手动验证
python
form = ContactForm(request.form) # 手动传入数据
if form.validate(): # 手动验证
# 验证通过
pass
else:
# 验证失败
print(form.errors)
6.3.2 部分验证
只验证指定字段:
python
# 只验证 name 字段
if form.validate(extra_validators={}) :
pass
# 在 API 场景中,可能只验证部分字段
form = ContactForm(data=request.get_json(), meta={'csrf': False})
if form.name.validate(form):
# name 字段验证通过
pass
6.3.3 跳过 CSRF 验证
在某些场景下(如 API),需要跳过 CSRF:
python
class APIForm(FlaskForm):
class Meta:
csrf = False # 禁用此表单的 CSRF
name = StringField('名称', validators=[DataRequired()])
6.4 获取表单数据
验证通过后,通过 form.字段名.data 获取数据。
python
if form.validate_on_submit():
# 获取各字段数据
name = form.name.data # 字符串
age = form.age.data # 整数(自动转换)
birthday = form.birthday.data # date 对象(自动转换)
tags = form.tags.data # 列表(SelectMultipleField)
is_public = form.is_public.data # 布尔值
avatar = form.avatar.data # FileStorage 对象
# 获取所有字段数据
all_data = form.data # {'name': '张三', 'age': 25, ...}
6.4.1 populate_obj 批量赋值
populate_obj 方法可以将表单数据批量赋值到对象属性,非常适合与 ORM 模型配合使用:
python
class ArticleForm(FlaskForm):
title = StringField('标题', validators=[DataRequired()])
content = TextAreaField('正文', validators=[DataRequired()])
category_id = SelectField('分类', coerce=int)
is_public = BooleanField('公开')
@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
form = ArticleForm()
if form.validate_on_submit():
article = Article() # 创建模型实例
form.populate_obj(article) # 批量赋值
article.author_id = current_user.id
article.created_at = datetime.utcnow()
db.session.add(article)
db.session.commit()
flash('文章发布成功!')
return redirect(url_for('article_detail', id=article.id))
return render_template('new_article.html', form=form)
注意:populate_obj 会将表单中所有字段的数据赋值到对象的同名属性,如果表单中有 submit、csrf_token 等字段,需要确保对象有对应属性,或排除这些字段:
python
# 安全的 populate_obj:排除特殊字段
def safe_populate_obj(form, obj, exclude=None):
exclude = exclude or ['submit', 'csrf_token']
for name, field in form._fields.items():
if name in exclude:
continue
setattr(obj, name, field.data)
safe_populate_obj(form, article)
6.5 表单错误处理(form.errors字典)
验证失败时,所有错误信息存储在 form.errors 中。
python
form = ContactForm()
if not form.validate():
# form.errors 示例:
# {
# 'name': ['姓名不能为空'],
# 'email': ['邮箱不能为空', '邮箱格式不正确'],
# }
for field_name, errors in form.errors.items():
print(f'{field_name}: {errors}')
6.5.1 在 API 中返回错误
python
@app.route('/api/contact', methods=['POST'])
def api_contact():
form = ContactForm(data=request.get_json(), meta={'csrf': False})
if form.validate():
# 处理...
return {'status': 'success', 'message': '发送成功'}
return {'status': 'error', 'errors': form.errors}, 400
6.5.2 收集所有错误消息
python
# 获取所有错误消息的扁平列表
all_errors = []
for field_name, errors in form.errors.items():
for error in errors:
all_errors.append(f'{form[field_name].label.text}: {error}')
# 或使用列表推导
all_errors = [f'{form[f].label.text}: {e}'
for f, errs in form.errors.items()
for e in errs]
6.6 PRG模式(Post/Redirect/Get)
PRG(Post/Redirect/Get)是一种 Web 设计模式,用于防止表单重复提交。
6.6.1 问题:重复提交
如果 POST 请求直接返回 HTML 页面,用户刷新页面时浏览器会弹出"确认重新提交"对话框,如果用户确认,表单数据会被重复提交:
用户提交表单 → POST /register → 返回"注册成功"页面
用户刷新页面 → 浏览器提示"确认重新提交" → 确认 → 重复注册
6.6.2 解决方案:PRG 模式
用户提交表单 → POST /register → 重定向到 GET /success
用户刷新页面 → GET /success → 重新显示"成功"页面(无副作用)
python
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterForm()
if form.validate_on_submit():
# 保存用户
user = User(...)
db.session.add(user)
db.session.commit()
flash('注册成功!')
return redirect(url_for('register_success')) # 重定向!
return render_template('register.html', form=form)
@app.route('/register/success')
def register_success():
return render_template('register_success.html')
6.6.3 flash 消息在 PRG 中的作用
flash 消息存储在 session 中,在重定向后的 GET 请求中读取。这使得 PRG 模式下可以在目标页面显示操作结果:
python
# POST 处理
if form.validate_on_submit():
# ...
flash('操作成功!', 'success')
return redirect(url_for('list'))
# GET 页面
@app.route('/list')
def list():
return render_template('list.html')
html
<!-- templates/list.html -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
6.6.4 PRG 模式的变体与进阶
PRG 模式在实际应用中有多种变体,适用于不同的业务场景:
变体一:PRG 带参数重定向
有时需要在重定向后传递一些数据(如新创建记录的 ID)。由于 flash 消息只支持字符串,不适合传递结构化数据,可以使用查询参数或 session:
python
# 方式一:查询参数(适用于公开数据)
@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
form = ArticleForm()
if form.validate_on_submit():
article = Article(title=form.title.data, content=form.content.data)
db.session.add(article)
db.session.commit()
return redirect(url_for('article_detail', id=article.id))
return render_template('article_new.html', form=form)
# 方式二:session(适用于敏感数据或大量数据)
@app.route('/order/create', methods=['POST'])
def create_order():
form = OrderForm()
if form.validate_on_submit():
order = create_order_from_form(form)
session['last_order_id'] = order.id # 临时存储在session中
flash('订单创建成功', 'success')
return redirect(url_for('order_confirmation'))
return render_template('order_new.html', form=form)
@app.route('/order/confirmation')
def order_confirmation():
order_id = session.pop('last_order_id', None) # 读取后立即删除
if not order_id:
return redirect(url_for('order_list'))
order = Order.query.get_or_404(order_id)
return render_template('order_confirmation.html', order=order)
变体二:验证失败时不重定向
PRG 模式仅在验证成功时使用重定向。验证失败时必须直接渲染表单(不重定向),否则表单数据和错误信息会丢失:
python
# 正确:验证失败直接渲染
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterForm()
if form.validate_on_submit():
# 成功:重定向(PRG)
return redirect(url_for('register_success'))
# 失败:直接渲染(保留数据和错误)
return render_template('register.html', form=form)
# 错误:验证失败时也重定向
@app.route('/register', methods=['GET', 'POST'])
def register_bad():
form = RegisterForm()
if form.validate_on_submit():
return redirect(url_for('register_success'))
elif request.method == 'POST':
# 错误:重定向会导致表单数据和错误丢失
flash('验证失败', 'danger')
return redirect(url_for('register')) # 不要这样做!
return render_template('register.html', form=form)
变体三:AJAX 表单不需要 PRG
AJAX 表单提交不涉及页面刷新,因此不需要 PRG 模式。服务器直接返回 JSON 响应,前端根据响应结果更新页面:
python
@app.route('/api/contact', methods=['POST'])
def api_contact():
form = ContactForm()
if form.validate_on_submit():
# 处理业务逻辑
send_email(form.email.data, form.message.data)
return jsonify({'success': True, 'message': '消息已发送'})
# 验证失败:返回错误信息,不重定向
return jsonify({'success': False, 'errors': form.errors}), 422
6.6.5 防止重复提交的其他方法
除了 PRG 模式,还有其他防止重复提交的方法,可以与 PRG 配合使用:
python
import uuid
# 方法一:一次性令牌(适用于关键操作)
@app.route('/transfer', methods=['GET', 'POST'])
def transfer():
if request.method == 'GET':
# 生成一次性令牌
token = uuid.uuid4().hex
session['form_token'] = token
form = TransferForm()
return render_template('transfer.html', form=form, token=token)
form = TransferForm()
if form.validate_on_submit():
# 检查令牌
submitted_token = request.form.get('form_token')
stored_token = session.pop('form_token', None)
if not stored_token or submitted_token != stored_token:
flash('请勿重复提交', 'warning')
return redirect(url_for('transfer'))
# 执行转账
execute_transfer(form.amount.data, form.to_account.data)
flash('转账成功', 'success')
return redirect(url_for('transfer_success'))
# 方法二:数据库唯一约束(最终防线)
# 即使用户绕过了前端和令牌检查,数据库的唯一约束也能防止重复数据
class Order(db.Model):
id = db.Column(db.Integer, primary_key=True)
order_number = db.Column(db.String(32), unique=True, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
amount = db.Column(db.Numeric(10, 2))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@app.route('/order/submit', methods=['POST'])
def submit_order():
form = OrderForm()
if form.validate_on_submit():
# 使用用户ID+时间窗口生成唯一订单号
# 同一用户在同一秒内只能创建一个订单
order_number = f"{current_user.id}_{int(time.time())}"
order = Order(order_number=order_number, ...)
try:
db.session.add(order)
db.session.commit()
except IntegrityError:
db.session.rollback()
flash('订单已提交,请勿重复操作', 'info')
return redirect(url_for('order_list'))
6.7 flash消息与表单
flash 消息常用于表单操作的结果反馈。
6.7.1 基本使用
python
# 成功消息
flash('注册成功!请登录。', 'success')
# 错误消息
flash('注册失败,请重试。', 'danger')
# 警告消息
flash('您的账号即将到期。', 'warning')
# 信息消息
flash('系统将于今晚维护。', 'info')
6.7.2 模板中显示
html
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show"
role="alert">
{{ message }}
<button type="button" class="btn-close"
data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
6.7.3 表单验证失败时的 flash
python
if form.validate_on_submit():
# ...
flash('保存成功!', 'success')
return redirect(url_for('index'))
else:
if form.errors:
flash('表单验证失败,请检查输入。', 'danger')
6.7.4 自定义 flash 消息分类
除了内置的四种分类(success、danger、warning、info),你可以使用自定义分类来更精细地控制消息展示:
python
# 自定义分类
flash('您的账户需要邮箱验证才能继续操作。', 'verification')
flash('新功能已上线,点击查看详情。', 'feature')
# 模板中按分类过滤显示
{% with messages = get_flashed_messages(with_categories=true, category_filter=['verification']) %}
{% if messages %}
<div class="verification-banner">
{% for category, message in messages %}
<div class="alert alert-info verification-alert">
<i class="bi bi-envelope"></i> {{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{# 其他分类的消息 #}
{% with messages = get_flashed_messages(with_categories=true, category_filter=['success', 'danger', 'warning', 'info']) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
6.7.5 flash 消息与表单错误的最佳配合
在实际开发中,flash 消息和表单字段错误应该配合使用:flash 消息用于整体操作结果的提示,字段错误用于具体的输入纠正指引。最佳实践是:验证失败时不使用 flash 重定向(因为重定向会丢失表单数据),而是直接渲染表单页面并显示字段错误;验证成功时使用 flash 配合重定向(PRG 模式):
python
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id):
article = Article.query.get_or_404(id)
form = ArticleForm(obj=article) if request.method == 'GET' else ArticleForm()
if form.validate_on_submit():
form.populate_obj(article)
db.session.commit()
flash('文章更新成功!', 'success')
return redirect(url_for('article_detail', id=id))
# 验证失败时,不使用flash,直接渲染表单(字段错误会自动显示)
if form.errors:
# 可选:添加一个整体的提示消息
flash('请修正下方标记的错误后重新提交。', 'warning')
return render_template('article_edit.html', form=form, article=article)
这种设计确保了:成功操作有明确的成功提示(通过 flash + 重定向),失败操作保留了用户输入和字段级错误(通过直接渲染表单),两者各司其职,用户体验清晰。
6.8 表单数据持久化(保存到数据库)
表单验证通过后,通常需要将数据保存到数据库。
6.8.1 使用 Flask-SQLAlchemy
python
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash
db = SQLAlchemy(app)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(min=3, max=20)])
email = StringField('邮箱', validators=[DataRequired(), Email()])
password = PasswordField('密码', validators=[DataRequired(), Length(min=8)])
confirm_password = PasswordField('确认密码', validators=[EqualTo('password')])
submit = SubmitField('注册')
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterForm()
if form.validate_on_submit():
# 检查用户名是否已存在
if User.query.filter_by(username=form.username.data).first():
form.username.errors.append('用户名已被注册')
return render_template('register.html', form=form)
# 创建用户
user = User(
username=form.username.data,
email=form.email.data,
password_hash=generate_password_hash(form.password.data)
)
db.session.add(user)
db.session.commit()
flash('注册成功!请登录。', 'success')
return redirect(url_for('login'))
return render_template('register.html', form=form)
6.8.2 使用 populate_obj
python
@app.route('/profile/edit', methods=['GET', 'POST'])
@login_required
def edit_profile():
form = ProfileForm()
if form.validate_on_submit():
# 直接填充到 current_user 对象
form.populate_obj(current_user)
db.session.commit()
flash('资料更新成功!', 'success')
return redirect(url_for('profile'))
return render_template('edit_profile.html', form=form)
6.9 编辑表单(预填充数据)
编辑已有数据时,需要在展示表单时预填充数据。
6.9.1 GET 请求时预填充
python
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id):
article = Article.query.get_or_404(id)
form = ArticleForm()
if form.validate_on_submit():
# POST: 更新数据
form.populate_obj(article)
db.session.commit()
flash('文章更新成功!', 'success')
return redirect(url_for('article_detail', id=article.id))
if request.method == 'GET':
# GET: 预填充数据
form.title.data = article.title
form.content.data = article.content
form.category_id.data = article.category_id
form.is_public.data = article.is_public
return render_template('edit_article.html', form=form)
6.9.2 使用 obj 参数预填充
更简洁的方式是在创建表单时传入 obj:
python
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id):
article = Article.query.get_or_404(id)
form = ArticleForm(obj=article) # 从对象预填充
if form.validate_on_submit():
form.populate_obj(article) # 更新对象
db.session.commit()
flash('文章更新成功!', 'success')
return redirect(url_for('article_detail', id=article.id))
return render_template('edit_article.html', form=form)
obj=article 会从 article 对象的属性中读取初始值。当 POST 请求提交时,表单数据会覆盖对象数据。
6.9.3 动态 choices 的预填充
如果 SelectField 的 choices 是动态的,需要在预填充前设置 choices:
python
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id):
article = Article.query.get_or_404(id)
form = ArticleForm()
# 动态设置 choices
categories = Category.query.all()
form.category_id.choices = [(c.id, c.name) for c in categories]
if form.validate_on_submit():
form.populate_obj(article)
db.session.commit()
flash('更新成功!')
return redirect(url_for('article_detail', id=id))
if request.method == 'GET':
form.title.data = article.title
form.content.data = article.content
form.category_id.data = article.category_id
return render_template('edit_article.html', form=form)
6.10 表单处理完整实战案例
下面是一个完整的文章发布表单,综合运用了本章所有知识:
python
# app.py
import os
from datetime import datetime
from flask import Flask, render_template, redirect, url_for, flash, request
from flask_wtf import FlaskForm
from flask_sqlalchemy import SQLAlchemy
from wtforms import (StringField, TextAreaField, SelectField, BooleanField,
SubmitField, HiddenField)
from wtforms.validators import DataRequired, Length, Optional
app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev-secret-key'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
# === 模型 ===
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
articles = db.relationship('Article', backref='category', lazy='dynamic')
class Article(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
content = db.Column(db.Text, nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
tags = db.Column(db.String(200)) # 逗号分隔的标签
is_public = db.Column(db.Boolean, default=True)
status = db.Column(db.String(20), default='draft') # draft, published
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# === 表单 ===
class ArticleForm(FlaskForm):
id = HiddenField()
title = StringField('标题', validators=[
DataRequired(message='标题不能为空'),
Length(max=100, message='标题最多100字')
], render_kw={'class': 'form-control', 'placeholder': '请输入标题'})
category_id = SelectField('分类', coerce=int, validators=[
DataRequired(message='请选择分类')
], render_kw={'class': 'form-select'})
content = TextAreaField('正文', validators=[
DataRequired(message='正文不能为空'),
Length(min=10, message='正文至少10字')
], render_kw={'class': 'form-control', 'rows': 15, 'placeholder': '请输入正文'})
tags = StringField('标签', validators=[
Optional(), Length(max=200)
], description='多个标签用英文逗号分隔', render_kw={
'class': 'form-control', 'placeholder': 'Python, Flask, Web'
})
is_public = BooleanField('公开', default=True, render_kw={'class': 'form-check-input'})
save_draft = SubmitField('保存草稿', render_kw={'class': 'btn btn-secondary'})
publish = SubmitField('发布', render_kw={'class': 'btn btn-primary'})
# === 视图 ===
@app.route('/article/new', methods=['GET', 'POST'])
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id=None):
# 新建或编辑
if id:
article = Article.query.get_or_404(id)
else:
article = Article()
form = ArticleForm(obj=article)
# 动态设置分类选项
form.category_id.choices = [(c.id, c.name) for c in Category.query.order_by(Category.name)]
if form.validate_on_submit():
# 填充数据
article.title = form.title.data
article.content = form.content.data
article.category_id = form.category_id.data
article.tags = form.tags.data
article.is_public = form.is_public.data
# 根据按钮决定状态
if form.publish.data:
article.status = 'published'
message = '文章发布成功!'
else:
article.status = 'draft'
message = '草稿保存成功!'
# 保存到数据库
if not article.id:
db.session.add(article)
db.session.commit()
flash(message, 'success')
return redirect(url_for('view_article', id=article.id))
return render_template('edit_article.html', form=form, article=article)
@app.route('/article/<int:id>')
def view_article(id):
article = Article.query.get_or_404(id)
return render_template('view_article.html', article=article)
# 初始化数据库
with app.app_context():
db.create_all()
# 添加示例分类
if not Category.query.first():
for name in ['技术', '生活', '旅行', '读书']:
db.session.add(Category(name=name))
db.session.commit()
if __name__ == '__main__':
app.run(debug=True)
html
<!-- templates/edit_article.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{ '编辑文章' if article.id else '新建文章' }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<h2>{{ '编辑文章' if article.id else '新建文章' }}</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.title.label(class_='form-label') }}
{{ form.title() }}
{% for error in form.title.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.category_id.label(class_='form-label') }}
{{ form.category_id() }}
{% for error in form.category_id.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.content.label(class_='form-label') }}
{{ form.content() }}
{% for error in form.content.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.tags.label(class_='form-label') }}
{{ form.tags() }}
<small class="text-muted">{{ form.tags.description }}</small>
</div>
<div class="mb-3 form-check">
{{ form.is_public() }}
{{ form.is_public.label(class_='form-check-label') }}
</div>
<div class="d-flex gap-2">
{{ form.save_draft() }}
{{ form.publish() }}
<a href="{{ url_for('view_article', id=article.id) if article.id else url_for('edit_article') }}"
class="btn btn-outline-secondary">取消</a>
</div>
</form>
</div>
</body>
</html>