Flask-表单使用
这篇讲什么
- 表单有两条路:直接用
request.form接数据,或者用 Flask-WTF 的FlaskForm一次拿到字段定义、校验器和 CSRF 保护,这篇两条都写。 - 覆盖
url_for与静态文件、字段类型与验证器对照表、自定义校验、POST-Redirect-GET 模式,以及 cookie / session 两种会话保持。 - 实测环境 Flask-WTF 1.3.0 + WTForms 3.3.2;
Email验证器必须另装email_validator这条坑写在正文里。
url_for
- 根据视图函数名(准确说是 endpoint)反向构造路由地址,写模板时离不开它。
- 本机 Flask 3.1.3 实测的三条行为:
1
2
3url_for('login') -> '/login'
url_for('static', filename='css/common.css') -> '/static/css/common.css'
url_for('welcome', name='x', uid=250) -> '/welcome/x?uid=250' - 不在路由规则里的多余参数会自动变成查询串,这一条老笔记写对了。
- 要带协议和主机名的完整地址就加
_external=True(老笔记那句「制定参数」是错别字)。 - 完整的
url_for行为表(含BuildError、静态文件、外链)在 Flask-基本入门 的「重定向(redirect)」一节,这里不重复。
加载静态文件
目录结构
1 | project/ |
- 老笔记把启动文件写成
manage.py,那是 Django 的叫法;Flask 侧没有这个约定,通常就叫app.py。 - 目录名和访问前缀都由构造参数决定,实测
Flask(__name__, static_url_path='/s')之后地址前缀就变成/s。
引用方式
-
一律用
url_for('static', filename=...),别手写/static/,这样改前缀不会漏。 -
实测
url_for不检查文件存不存在:1
2
3url_for('static', filename='不存在.css') -> '/static/%E4%B8%8D%E5%AD%98%E5%9C%A8.css'
GET /static/css/common.css -> 200,6 字节
GET /static/nope.css -> 404也就是说地址生成永远成功,404 是浏览器请求时才暴露的,排查要去 Network 面板看。
-
收藏夹图标(不再需要
{% block head %},那是 flask-bootstrap 的写法):templates/base.html 1
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
-
图片、CSS、JS 三种引用:
1
2
3<img src="{{ url_for('static', filename='image/meinv.jpg') }}" alt="美女">
<link rel="stylesheet" href="{{ url_for('static', filename='css/common.css') }}">
<script src="{{ url_for('static', filename='js/common.js') }}"></script>Bootstrap 5 之后
<script>不再需要type="text/javascript"。
原生表单
-
不用任何扩展也能收表单,模板就是一个
method="post"的 form:templates/login.html 1
2
3
4
5<form method="post">
用户名:<input type="text" name="username"><br>
密码:<input type="password" name="password"><br>
<input type="submit" value="立即登录">
</form> -
视图函数要显式声明收 POST,否则 POST 打过来是 405:
app.py 1
2
3
4
5
def login():
if request.method == 'GET':
return render_template('login.html')
return request.form['username'] -
实测三种情况:
1
2
3GET /login -> 'GET 分支'
POST /login 带 username -> "POST 拿到 'tom'"
POST /login 不带 username -> request.form['username'] 抛 BadRequestKeyError(HTTP 400) -
form['字段']和form.get('字段')的区别就是 400 和 None,老笔记那种直接下标取值的写法,前端少传一个字段整条请求就挂。稳妥写法是request.form.get('username', '')。 -
原生表单没有 CSRF 保护,要么自己塞一个 token,要么用下一节的 Flask-WTF。
Flask-WTF
-
Flask-WTF 做三件事:把表单写成类、自带 CSRF 保护、把校验和渲染合并。本机版本 Flask-WTF 1.3.0 + WTForms 3.3.2。
-
安装:
1
pip install flask-wtf
-
用到
Email验证器必须再装一个包,实测没装时校验邮箱会抛Exception: Install 'email_validator' for email validation support.:1
pip install email_validator
-
最小可用的一套:
app.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length
app = Flask(__name__)
# CSRF 依赖签名密钥,没有它 session/表单 token 都写不进去
app.config['SECRET_KEY'] = '换成自己的密钥'
class NameForm(FlaskForm):
name = StringField('用户名:', validators=[Length(6, 20, '必须在 6-20 个字符之间')])
password = PasswordField('密码:', validators=[DataRequired('密码不能为空')])
submit = SubmitField('提交')
def index():
form = NameForm()
name = None
if form.validate_on_submit():
name = form.name.data
return render_template('form.html', form=form, name=name) -
模板里逐字段渲染(这套写法不依赖任何 CSS 框架):
templates/form.html 1
2
3
4
5
6
7
8
9
10
11<form method="post" action="">
{{ form.hidden_tag() }}
{{ form.name.label() }}{{ form.name(id='xxx', class='yyy') }}<br>
{% for e in form.name.errors %}<em>{{ e }}</em>{% endfor %}
{{ form.password.label() }}{{ form.password() }}<br>
{% for e in form.password.errors %}<em>{{ e }}</em>{% endfor %}
{{ form.submit() }}
</form>
validate_on_submit 的判定
-
它是「请求是 POST 且 校验全部通过」的合写,实测:
1
2
3GET 提交 -> ok=false, errors={}
POST 全空 -> ok=false, errors 里 name/pw/age/csrf_token 全中
POST 数据合法 -> ok=true -
校验失败时数据仍然读得到(
form.name.data是'abc'),错误信息在form.errors这个字典里,键是字段名、值是消息列表,实测:1
2
3
4{"name": ["必须在 6-20 个字符之间"],
"password": ["密码不能为空"],
"age": ["Not a valid integer value.", "Number must be between 1 and 120."],
"mail": ["Invalid email address."]}
CSRF
-
form.hidden_tag()会渲染出 csrf 隐藏域,实测长这样:1
<input id="csrf_token" name="csrf_token" type="hidden" value="IjJhMmIyNDA1OTAwMjBmNDA2MDc1MjZmODk1ZmZiNGIzN2Q2MmRkYWIi...">
-
少了这个域,
validate_on_submit()只会返回 False 并在errors['csrf_token']里给一句The CSRF token is missing.,不会报 400——很容易让人以为校验逻辑坏了。 -
想给整个应用加硬保护,用
CSRFProtect,实测差别很明显:app.py 1
2from flask_wtf import CSRFProtect
CSRFProtect(app)1
2POST 不带 token -> HTTP 400 Bad Request
GET -> 200,不受影响 -
需要在模板里单独取 token(比如 AJAX 请求头)时,用
generate_csrf(),它必须在请求上下文里调用,实测上下文外调用报RuntimeError: Working outside of application context.。
用 Bootstrap 5 渲染
-
老笔记这里用的是
{% import 'bootstrap/wtf.html' as wtf %}+wtf.quick_form(form),那是 flask-bootstrap 提供的,包已经停更,见 Flask-模板引擎。 -
现在直接在字段上传 Bootstrap 的 class 就够了,实测 kwargs 会原样落到标签上:
1
2{{ form.name(class='form-control', placeholder='请输入') }}
-> <input class="form-control" id="name" maxlength="20" minlength="6" name="name" type="text" value="">1
2{{ form.name.label(class='form-label') }}
-> <label class="form-label" for="name">用户名</label> -
套上 Bootstrap 5 的表单骨架:
templates/form_bs5.html 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<form method="post" action="">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class='form-label') }}
{{ form.name(class='form-control') }}
{% for e in form.name.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class='form-label') }}
{{ form.password(class='form-control') }}
</div>
<div class="mb-3 form-check">
{{ form.agree(class='form-check-input') }}
{{ form.agree.label(class='form-check-label') }}
</div>
{{ form.submit(class='btn btn-primary') }}
</form>
字段类型
-
老笔记的字段表基本对,只有一处要改:没有
FloatField这个常用叫法,浮点用DecimalField(FloatField在 WTForms 3.3.2 里确实还存在,hasattr为 True,但官方文档推荐DecimalField)。 -
本机
hasattr(wtforms, ...)实测存活情况:字段 存在 渲染出来的样子(实测) StringField是 <input type="text">PasswordField是 <input type="password" required>SubmitField是 <input type="submit">HiddenField是 <input type="hidden">TextAreaField是 <textarea>IntegerField是 <input type="number" min max>DecimalField是 <input type="number" step>FloatField是 同 DecimalField,官方不推荐BooleanField是 <input type="checkbox" value="y">RadioField是 <ul><li><input type="radio">…SelectField是 <select><option>…SelectMultipleField是 <select multiple>FileField是 <input type="file">DateField/DateTimeField是 <input type="date">/datetime-local -
一个容易忽略的点:
FileField要求表单是 multipart,实测普通 urlencoded 提交时request.files是空的,带文件的 multipart 提交才拿得到。所以有文件字段的 form 必须写enctype="multipart/form-data"。 -
校验器会自动往 HTML 上加属性,实测:
Length(6, 20)加minlength/maxlength、DataRequired加required、NumberRange(1, 120)给IntegerField加min/max并把 type 变成number。这些是给浏览器的即时提示,服务端仍然会再校验一遍。
验证器
-
老笔记的验证器表里
DateRequired是笔误,实测hasattr(wtforms.validators, 'DateRequired')为 False,正确名字是DataRequired。 -
本机 WTForms 3.3.2 的存活与签名实测:
验证器 存在 签名(实测) DataRequired是 (message=None)Optional是 (message=None)Length是 (min=-1, max=-1, message=None)Email是 (message=None, check_deliverability=False, ...),需装email_validatorURL是 (message=None, require_tld=True, ...)NumberRange是 (min=None, max=None, message=None)IntegerRange否 用 NumberRange代替Regexp是 (regex, flags=0, message=None, matcher=match)EqualTo是 (fieldname, message=None)IPAddress是 (ipv4=True, ipv6=False, message=None) -
用法就是把实例放进字段的
validators列表:app.py 1
2
3
4
5
6
7
8
9from wtforms.validators import DataRequired, Email, EqualTo, Length, NumberRange, Regexp
class F(FlaskForm):
name = StringField('用户名', validators=[Length(6, 20, '必须 6-20')])
pw = PasswordField('密码', validators=[DataRequired('必填'), EqualTo('pw2', '两次不一致')])
pw2 = PasswordField('确认密码')
age = IntegerField('年龄', validators=[NumberRange(1, 120)])
ip = StringField('IP', validators=[Regexp(r'^\d+\.\d+\.\d+\.\d+$', message='IP 格式不对')])
agree = BooleanField('同意', validators=[DataRequired('请勾选')]) -
实测各条失败时给出的消息,直接照抄就行:
1
2
3
4
5两次密码不一致 -> pw: ['两次不一致']
不勾同意 -> agree: ['请勾选']
IP 写成 999 -> ip: ['IP 格式不对']
年龄写成 x -> age: ['Number must be between 1 and 120.']
邮箱写成 bad -> mail: ['Invalid email address.'] -
DataRequired和Optional是一对:前者要求必须有值(复选框勾了才算有值),后者允许留空并跳过后续校验。
自定义验证
-
方法名固定是
validate_字段名,参数是self, field,失败时raise ValidationError(...)。 -
「验证通过要 return True」这句老笔记可以删掉——实测什么都不返回也算通过,只有抛
ValidationError才算失败。app.py 1
2
3
4
5
6
7
8
9from wtforms.validators import ValidationError
class NameForm(FlaskForm):
name = StringField('用户名:')
submit = SubmitField('提交')
def validate_name(self, field):
if 'admin' in field.data:
raise ValidationError('用户名不能含 admin') -
实测提交
admin1得到:1
{"ok": false, "errors": {"name": ["用户名不能含 admin"]}}
-
内置校验器和自定义方法会一起跑,错误消息合并在同一个字段的列表里。
-
需要查库的重名校验、以及「确认密码」这种跨字段比较,也写在同一个地方——
self.password.data就能取到别的字段:app.py 1
2
3
4
5
6
7
8
9
10
11
12class RegForm(FlaskForm):
password = PasswordField('密码', validators=[DataRequired()])
confirm = PasswordField('确认密码')
nickname = StringField('昵称')
def validate_confirm(self, field):
if field.data != self.password.data:
raise ValidationError('两次输入的密码不一致')
def validate_nickname(self, field):
if Nickname.query.filter_by(name=field.data).first():
raise ValidationError('昵称已被占用')1
2
3confirm 与 password 不一致 -> 400 {'confirm': ['两次输入的密码不一致']}
nickname 撞上库里已有的 -> 400 {'nickname': ['昵称已被占用']}
都通过 -> 200 {'ok': True}
POST重定向GET
-
浏览器会记住最后一次请求,POST 之后按刷新就会「重新提交表单」,多数时候没必要。解决办法是 POST 处理完立刻重定向回 GET 地址,这就是 PRG(Post/Redirect/Get)。
-
实测:
app.py 1
2
3
4
5
6
7
def prg():
if request.method == 'POST':
session['last'] = request.form.get('name', '?')
flash('提交成功')
return redirect(url_for('prg'))
return 'GET:%s;flash=%s' % (session.get('last'), get_flashed_messages())1
2
3POST /prg -> 302 Location='/prg'
随后 GET /prg -> "GET:tom;flash=['提交成功']"
再 GET 一次 -> "GET:tom;flash=[]" -
flash的消息只弹一次,取过就没了,这是get_flashed_messages()的行为。 -
和 Flask-WTF 配合时,PRG 只能在校验通过后走;校验失败要留在原页面重渲染表单,否则用户填的东西全丢:
app.py 1
2
3
4if form.validate_on_submit():
...保存...
return redirect(url_for('index'))
return render_template('form.html', form=form)
会话技术
cookie
-
设置、获取、删除三个动作都在 response 对象上:
app.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21import datetime
from flask import make_response, request
def set_cookie():
resp = make_response('cookie已设置')
resp.set_cookie('by_max_age', 'v1', max_age=10)
resp.set_cookie('by_expires', 'v2',
expires=datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=100))
resp.set_cookie('plain', 'v4')
return resp
def get_cookie():
return request.cookies.get('plain', '未设置')
def del_cookie():
resp = make_response('cookie已删除')
resp.delete_cookie('by_max_age')
return resp -
实测下发的
Set-Cookie:1
2
3
4by_max_age=v1; Expires=Fri, 06 Nov 2026 07:43:09 GMT; Max-Age=10; Path=/
by_expires=v2; Expires=Fri, 06 Nov 2026 07:44:39 GMT; Path=/
plain=v4; Path=/
delete_cookie 之后 -> by_max_age=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/ -
老笔记「
expires是给 IE8 用的、优先级比max_age低」这句已经没意义了:现在expires接受datetime或整数时间戳,两者都会翻成Expires;max_age额外带一个Max-Age,浏览器以Max-Age为准。 -
顺带一个 Python 侧的坑:老笔记用的
datetime.utcnow()在本机 Python 3.14 上已经报DeprecationWarning,改写datetime.now(datetime.UTC)。 -
删除 cookie 的本质就是下发一个过期时间为 1970 的同名空值。
session
-
用法就是字典:
app.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16from flask import session
def set_session():
session['name'] = 'ergouzi'
return 'session已设置'
def get_session():
return session.get('name', '未设置')
def del_session():
session.pop('name', None) # 删一个
session.clear() # 清空
return 'session已删除' -
实测读回与删除:
1
2设置后读回 -> session.get='ergouzi'
删除后读回 -> session.get='未设置' -
Flask 的 session 是签名 cookie,不存在服务端,实测
Set-Cookie里就是 base64 的 JSON 加一段签名:1
session=eyJfcGVybWFuZW50Ijp0cnVlLCJuYW1lIjoiZXJnb3V6aSJ9.au2FOw.pJLBicahrQIOsdg3J3TrUhKguAM
所以它只能放小东西,而且任何人都能解码看内容(只是改不了,改了签名对不上)。
-
没有
SECRET_KEY时写入会 500、只读能拿到空 session,这条在 Flask-基本入门 里也提过。 -
老笔记的
app.config['SESSION_PERMANENT']在 Flask 3.1 里已经彻底没人读了:全仓 grep 无此键,实测设成 True 之后session.permanent仍是False。要长期有效只能在代码里写:app.py 1
2session.permanent = True
session['name'] = 'ergouzi'实测这样才会带上过期时间:
1
Set-Cookie=session=...; Expires=Mon, 07 Dec 2026 ...(默认 31 天后)
-
PERMANENT_SESSION_LIFETIME仍然有效,默认值是datetime.timedelta(days=31),可以传整数秒或timedelta。 -
相关配置项的默认值,实测一台新 app 上是:
配置 默认值 说明 SESSION_COOKIE_NAME'session'cookie 名 SESSION_COOKIE_DOMAINNone跨子域时才设 SESSION_COOKIE_PATHNone默认全站 SESSION_COOKIE_HTTPONLYTrueJS 读不到 SESSION_COOKIE_SECUREFalse上 HTTPS 后改 True SESSION_COOKIE_SAMESITENone防 CSRF 建议 'Lax'SESSION_REFRESH_EACH_REQUESTTrue每次请求都重发 cookie,等于滑动过期 PERMANENT_SESSION_LIFETIMEtimedelta(days=31)只对 session.permanent=True生效 -
不设 permanent 时
Set-Cookie里没有Expires/Max-Age,就是浏览器关闭即失效的会话 cookie。