Flask-模板引擎
这篇讲什么
- Jinja2 模板在 Flask 里怎么用:变量与过滤器、
if/for流程控制、include、宏、模板继承,以及「自动转义按模板文件后缀判定」这条容易踩的规则。 - 还包括用蓝本(Blueprint)拆分工程、用 Flask 自带 CLI 写自定义命令(替掉已经装不上的
flask_script),以及一套 Bootstrap 5 基础模板。 - 实测环境 Flask 3.1.3 + Jinja2 3.1.6,末尾留了练习。
命令行启动
-
老笔记这一节讲的是
flask_script这个第三方启动器(Manager(app)+python manager.py runserver -d -r)。它已经死了:Flask 2.x 删掉了flask._compat,实测import flask_script直接失败;本机的 condaflask环境里连这个包都没有装。 -
Flask 从 1.0 起自带命令行(底层是 click),
flask --help实测的命令清单就三个:1
2
3
4
5
6Usage: python -m flask [OPTIONS] COMMAND [ARGS]...
Commands:
routes Show the routes for the app.
run Run a development server.
shell Run a shell in the app context. -
flask run的端口、主机、debug 开关,以及 Windows 上 5000 端口被系统预留的坑,都写在 Flask-基本入门 的「使用测试」里,这里不重复。 -
想要自己的启动参数,就用
@app.cli.command注册子命令,这是flask_script的官方替代:app.py 1
2
3
4
5
6
7
8
9import click
from flask import Flask
app = Flask(__name__)
def stat_cmd(name):
click.echo('自定义命令输出:' + name)1
2$ flask stat hello
自定义命令输出:hello -
用
flask命令前要让框架找到应用:目录里有app.py或wsgi.py时自动识别,否则设FLASK_APP。 -
自定义命令是注册在 app 上的,所以
FLASK_APP必须指向定义了它的那个文件,不然flask stat会报No such command 'stat'。
蓝本(blueprint)
-
项目变复杂以后,所有视图函数堆在一个文件里不好维护。蓝本(Blueprint)就是按功能把路由拆到不同文件的机制,拆出去的那份还能有自己的模板目录和静态目录。
-
蓝本定义:
user.py 1
2
3
4
5
6
7from flask import Blueprint
user = Blueprint('user', __name__, url_prefix='/user')
def login():
return '欢迎登录' -
主工程注册:
app.py 1
2
3
4
5
6from flask import Flask, url_for
from user import user
app = Flask(__name__)
# 注册时的 url_prefix 会覆盖蓝本创建时写的那个
app.register_blueprint(user, url_prefix='/u') -
实测前缀覆盖:蓝本里写
/user、注册时写/u,结果GET /u/login/是 200,GET /user/login/是 404——老笔记「注册时优先级高」这句是对的。 -
蓝本里的 endpoint 会自动加上蓝本名做前缀,实测规则全名是
user.login,所以url_for要这么写:1
url_for('user.login') -> '/u/login/'
-
蓝本也可以带自己的目录,实测
Blueprint(..., template_folder='bp_templates', static_folder='bp_static')注册后,主 app 的render_template('bp.html')能找到蓝本里的模板,蓝本静态前缀是/bp_static。
模板引擎
- 模板文件是按特定语法规则写的、专门负责显示的文件;模板引擎就是负责解析和替换这套语法的工具。
- Flask 用的是 Jinja(习惯上仍写作 Jinja2),本机实测版本 3.1.6,和 Flask 同属 Pallets 团队维护,官方首页把它列为三大依赖之一。
- Jinja 的模板语法与 Python 无关,它有自己的变量、过滤器和控制标签三套写法,分别放在
{{ }}、|、{% %}里。
Jinja2使用流程
目录结构
1 | project/ |
- 默认目录名实测是
templates和static,静态访问前缀/static;想改就在构造时传参:Flask(__name__, template_folder='templates2', static_url_path='/s')。
准备模板文件
1 |
|
渲染
-
两个渲染函数:
render_template('文件名')渲染模板文件,render_template_string('字符串')渲染模板字符串。 -
实测:
1
2GET / -> 200 | '<h1>Hello Flask</h1>'
render_template_string('<h1>{{ x }}</h1>', x=7) -> '<h1>7</h1>' -
出错时的异常名值得记住,排查全靠它:
1
2render_template('不存在.html') -> TemplateNotFound: 不存在.html
render_template_string('{{ 1 +') -> TemplateSyntaxError: unexpected 'end of template'
模板自动重载
-
老笔记只写了一行
app.config['TEMPLATES_AUTO_RELOAD'] = True,实测这四条关系是:配置 jinja_env.auto_reload什么都不设 FalseTEMPLATES_AUTO_RELOAD = TrueTrueDEBUG = True(或app.debug = True)TrueTEMPLATES_AUTO_RELOAD = False且DEBUG = TrueFalse -
也就是说开了 debug 就自动重载;显式写
False时它优先。改完模板不用重启服务,这条就是给开发期用的。
使用变量
-
模板里取变量写
{{ 变量名 }},渲染时以关键字参数传进来:templates/var.html 1
2<h1>Hello {{ name }}!</h1>
<h1>Hello {{ g.name }}!</h1>app.py 1
2
3
4
5
def var():
# g 上的东西不用传参,模板里直接可用,但必须写 g.xxx
g.name = 'curry'
return render_template('var.html', name='ergou')实测输出:
1
2<h1>Hello ergou!</h1>
<h1>Hello curry!</h1> -
g要带前缀:实测模板里只写{{ zz }}取不到g.zz,输出是空串。想省前缀就用context_processor。 -
模板上下文里默认就有的名字,实测一共 12 个:
1
2config, cycler, dict, g, get_flashed_messages, joiner, lipsum,
namespace, range, request, session, url_for -
想再往里塞公共变量,用
@app.context_processor,返回的字典会并进上下文:app.py 1
2
3
def inject_extra():
return {'extra': '来自 context_processor'}实测模板里
{{ extra }}直接得到来自 context_processor。 -
取容器里的值,实测下标和属性两种写法都行,缺失的键渲染成空:
1
2
3
4list={{ items[1] }} -> b
dict={{ d['k'] }} -> v
attr={{ d.k }} -> v
missing={{ d.nope }} -> 空
过滤器
-
过滤器写在变量后面,用竖线分隔:
{{ 变量名 | 过滤器名 }},还能带参数、能串联。 -
老笔记的「常用函数」表在本机 Jinja 3.1.6 上逐个实测过(输入
name='hello world'、html='<b>粗</b>体'、items=['x','y','z']):过滤器 说明 实测结果 upper全部大写 HELLO WORLDlower全部小写 hello worldtitle每个单词首字母大写 Hello Worldcapitalize整个串首字母大写 Hello worldtrim去掉两边空白 histriptags去掉 HTML 标签 粗体safe声明这段是可信 HTML,不转义 <b>粗</b>体escape强制转义 <b>粗</b>体default('替补')变量未定义时给默认值 替补join('-')列表拼串 x-y-zlength长度 3replace('o','0')替换 hell0 w0rldtojson转 JSON,可安全嵌进脚本 ["x", "y", "z"]
自动转义
-
变量默认会被转义,这是 Jinja 防 XSS 的默认姿势;
|safe是显式关掉转义,只用在完全可信的内容上。 -
实测转义与否取决于模板文件名,Flask 传给 Jinja 的
autoescape是一个回调而不是布尔值:1
2
3render_template_string('{{ html }}') -> <b>x</b> (转义)
扩展名 .html 的模板 -> <b>x</b> (转义)
扩展名 .txt 的模板 -> <b>x</b> (不转义!) -
也就是说把模板存成
.txt、.md之类后缀,转义就悄悄失效了。模板一律用.html结尾。 -
反过来,
{{ html }}直接输出用户输入是安全的,{{ html|safe }}才危险——老笔记「safe 不要乱用」这句要保留。
流程控制
- 控制语句写在
{% %}里,注释写在{# #}里。
分支结构
1 | {% if name %} |
- 实测
name='ergou'走第一个分支,name=''走 else——空字符串在 Jinja 里是假值,和 Python 一致。
循环结构
1 | <ol> |
-
range()在模板里直接可用(它就在默认上下文那 12 个名字里)。 -
循环里有个
loop对象,实测:1
2{% for it in items %}<li>{{ loop.index }}:{{ it }} first={{ loop.first }} last={{ loop.last }}</li>{% endfor %}
-> <li>1:a first=True last=False</li><li>2:b first=False last=True</li>loop.cycle('odd','even')也实测可用,输出odd:x even:y odd:z,适合做斑马纹样式。 -
空列表时
{% for %}一次都不执行,实测<ul></ul>;要兜底就写{% else %}。
注释与空白控制
-
{# 这是注释 #}实测不会出现在输出里,输出直接是<p>注释后面</p>。 -
标签自己占一行时,行尾换行会被吃掉,实测:
1
2保留写法 -> A\n * x\n * y\nZ
{%- ... -%} -> A* x* yZ减号
-贴在{%或%}边上就表示把那一侧的空白删掉,HTML 里少一堆空行。
文件包含
-
包含就是把另一个文件的内容原样搬过来,省得重复写。
1
{% include 'base.html' %}
-
实测三种写法:
1
2
3{% include 'base.html' %} -> [tom] 带当前上下文
{% include 'base.html' without context %} -> [] 不带,变量取不到
{% include '没有这个.html' ignore missing %} -> 空,不报错 -
默认
include会把当前模板的变量传进去;写without context就是彻底隔离,被包含的文件里读不到任何变量。 -
老笔记示例里那行
# {% include ... %}是错的:#在 HTML 里是普通文本,会被原样输出。Jinja 的注释只有{# #}。
宏的使用
-
宏相当于模板里的函数,先定义再使用。
1
2
3
4
5
6
7{# 定义宏 #}
{% macro show_name(name) %}
<h1>Hello {{ name }}!</h1>
{% endmacro %}
{# 调用宏 #}
{{ show_name(name) }} -
跨文件用宏有两种方式,实测都可用:
1
2
3
4
5
6
7{% from 'macros.html' import show_name %}
{{ show_name('tom') }}
-> <h1>Hello tom!</h1>
{% import 'macros.html' as forms %}
{{ forms.field('用户名', 'tom') }}
-> <label>用户名:tom</label> -
区别记一句话:
from ... import按名字逐个导入,import ... as整个模块挂在一个命名空间下,宏多了不会污染当前模板的变量名。 -
宏能不能看到外面的变量,实测有个容易搞错的细节:宏能读到同一份模板顶层
{% set %}的变量,但用{% from ... import %}跨文件导入时,读到的是定义它那份模板的顶层变量,不是调用方的。1
2本模板 set + 本模板宏 -> [外面的值]
从别的文件 import 的宏 -> [定义处的值] (用的是定义处那份模板的变量) -
所以宏要的可信数据一律走参数:
{{ show(outer) }},别指望它自动看见调用现场。
模板继承
-
基础模板负责整体骨架,子模板只写差异部分。
-
三个标签:
{% extends %}声明继承、{% block 名字 %}圈出可替换的块、{{ super() }}保留父块内容。templates/parent.html 1
2<title>{% block title %}父级模板标题{% endblock %}</title>
<body>{% block body %}默认内容{% endblock %}</body>templates/child.html 1
2
3
4
5{% extends 'parent.html' %}
{% block title %}欢迎登录{% endblock %}
{% block body %}{{ super() }} 新增内容{% endblock %} -
实测渲染结果:
1
2<title>欢迎登录</title>
<body>默认内容 新增内容</body> -
重写 block 后原内容消失,八成是忘了
super():实测子模板写{% block body %}{% endblock %}得到的是<body></body>,父内容被整块删掉——这既是坑,也是「故意删掉父内容」的正规写法。 -
{% extends %}要写在模板最前面,这不是洁癖而是实测出来的:- 前面只有普通文本时不报错,但那行文本会留在页面最上方,父模板内容照旧渲染(实测
<p>先写点东西</p>+父内容)。 - 前面已经出现过
{% block %}时,那个块的内容会被输出两遍(实测顶层块先出现\n顶层块先出现),排查起来很费劲。
- 前面只有普通文本时不报错,但那行文本会留在页面最上方,父模板内容照旧渲染(实测
-
子模板里写在 block 之外的内容会被直接丢弃,实测只剩
子内容,那行<p>block 之外的段落</p>不见了。
Bootstrap 5 基础模板
-
老笔记这两节(flask-bootstrap、定制项目基础模板)用的是
flask_bootstrap包。它同样已经停更(最后一次发布停在 2017 年),实测它注入的是 Bootstrap 3.3.7 的 cdnjs 链接,类名体系(navbar-inverse、panel)在 Bootstrap 5 里早就不存在了。现在直接引 CDN 就行,不需要任何 Python 包。 -
一份能用的基础模板,block 名字自己定,实测能被 Jinja 正常渲染:
templates/base.html 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}默认标题{% endblock %} - 我的站点</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
{% block styles %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">首页</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navmenu">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navmenu">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="#">版块一</a></li>
<li class="nav-item"><a class="nav-link" href="#">版块二</a></li>
</ul>
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="{{ url_for('login') }}">登录</a></li>
<li class="nav-item"><a class="nav-link" href="#">注册</a></li>
</ul>
</div>
</div>
</nav>
<main class="container py-4">
{% block page_content %}默认内容{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html> -
子模板只重写
page_content,导航条和骨架全部继承:templates/login.html 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22{% extends 'base.html' %}
{% block title %}登录{% endblock %}
{% block page_content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">登录</h5>
<form method="post">
<div class="mb-3">
<label class="form-label">用户名</label>
<input class="form-control" name="name" value="{{ name }}">
</div>
<button class="btn btn-primary" type="submit">提交</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %} -
实测
GET /login返回 200、1898 字节,CSS 与 JS 两条 CDN、card-title、name="name" value="tom"都在,默认内容已被替换掉。 -
从 Bootstrap 3 迁到 5,这几处名字变了,照旧笔记抄会失效:
旧笔记(Bootstrap 3) 现在(Bootstrap 5.3) navbar-inversenavbar-dark bg-darkdata-toggle="collapse"data-bs-toggle="collapse"data-target="#x"data-bs-target="#x"panel panel-defaultcardjQuery + bootstrap.js 只要 bootstrap.bundle.min.js,不再依赖 jQuery -
折叠菜单能不能点开,取决于
data-bs-target的值和被点的那个id对得上,实测两者都是#navmenu/navmenu;老笔记把id改成class的那一步在 Bootstrap 5 里是错的,会点不开。 -
404 页面同样继承基础模板,实测 1370 字节、导航条还在:
templates/404.html 1
2
3
4
5
6
7{% extends 'base.html' %}
{% block title %}页面不存在{% endblock %}
{% block page_content %}
<div class="alert alert-danger">大哥,是不是搞错了?</div>
{% endblock %}
练习
- 用上面的基础模板派生出登录页、注册页和 404 页,404 页配合
@app.errorhandler(404)返回。 - 把一份 CSS 放进
static/css/,用{{ url_for('static', filename='css/a.css') }}引入,实测得到/static/css/a.css。