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 直接失败;本机的 conda flask 环境里连这个包都没有装。

  • Flask 从 1.0 起自带命令行(底层是 click),flask --help 实测的命令清单就三个:

    1
    2
    3
    4
    5
    6
    Usage: 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
    9
    import click
    from flask import Flask

    app = Flask(__name__)

    @app.cli.command('stat')
    @click.argument('name', default='demo')
    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
    7
    from flask import Blueprint

    user = Blueprint('user', __name__, url_prefix='/user')

    @user.route('/login/')
    def login():
    return '欢迎登录'
  • 主工程注册:

    app.py
    1
    2
    3
    4
    5
    6
    from 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
2
3
4
project/
app.py # 启动文件(老笔记叫 manage.py,Flask 里没这个约定)
templates/ # 模板文件目录,名字是固定的默认值
static/ # 静态文件目录
  • 默认目录名实测是 templates 和 static,静态访问前缀 /static;想改就在构造时传参:Flask(__name__, template_folder='templates2', static_url_path='/s')。

准备模板文件

templates/hello.html
1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>模板引擎测试</title>
</head>
<body>
<h1>Hello Flask</h1>
</body>
</html>

渲染

  • 两个渲染函数:render_template('文件名') 渲染模板文件,render_template_string('字符串') 渲染模板字符串。

  • 实测:

    1
    2
    GET / -> 200 | '<h1>Hello Flask</h1>'
    render_template_string('<h1>{{ x }}</h1>', x=7) -> '<h1>7</h1>'
  • 出错时的异常名值得记住,排查全靠它:

    1
    2
    render_template('不存在.html')      -> TemplateNotFound: 不存在.html
    render_template_string('{{ 1 +') -> TemplateSyntaxError: unexpected 'end of template'

模板自动重载

  • 老笔记只写了一行 app.config['TEMPLATES_AUTO_RELOAD'] = True,实测这四条关系是:

    配置 jinja_env.auto_reload
    什么都不设 False
    TEMPLATES_AUTO_RELOAD = True True
    DEBUG = True(或 app.debug = True) True
    TEMPLATES_AUTO_RELOAD = False 且 DEBUG = True False
  • 也就是说开了 debug 就自动重载;显式写 False 时它优先。改完模板不用重启服务,这条就是给开发期用的。

使用变量

  • 模板里取变量写 {{ 变量名 }},渲染时以关键字参数传进来:

    templates/var.html
    1
    2
    <h1>Hello {{ name }}!</h1>
    <h1>Hello {{ g.name }}!</h1>
    app.py
    1
    2
    3
    4
    5
    @app.route('/var/')
    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
    2
    config, cycler, dict, g, get_flashed_messages, joiner, lipsum,
    namespace, range, request, session, url_for
  • 想再往里塞公共变量,用 @app.context_processor,返回的字典会并进上下文:

    app.py
    1
    2
    3
    @app.context_processor
    def inject_extra():
    return {'extra': '来自 context_processor'}

    实测模板里 {{ extra }} 直接得到 来自 context_processor。

  • 取容器里的值,实测下标和属性两种写法都行,缺失的键渲染成空:

    1
    2
    3
    4
    list={{ 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 WORLD
    lower 全部小写 hello world
    title 每个单词首字母大写 Hello World
    capitalize 整个串首字母大写 Hello world
    trim 去掉两边空白 hi
    striptags 去掉 HTML 标签 粗体
    safe 声明这段是可信 HTML,不转义 <b>粗</b>体
    escape 强制转义 &lt;b&gt;粗&lt;/b&gt;体
    default('替补') 变量未定义时给默认值 替补
    join('-') 列表拼串 x-y-z
    length 长度 3
    replace('o','0') 替换 hell0 w0rld
    tojson 转 JSON,可安全嵌进脚本 ["x", "y", "z"]

自动转义

  • 变量默认会被转义,这是 Jinja 防 XSS 的默认姿势;|safe 是显式关掉转义,只用在完全可信的内容上。

  • 实测转义与否取决于模板文件名,Flask 传给 Jinja 的 autoescape 是一个回调而不是布尔值:

    1
    2
    3
    render_template_string('{{ html }}')  -> &lt;b&gt;x&lt;/b&gt;   (转义)
    扩展名 .html 的模板 -> &lt;b&gt;x&lt;/b&gt; (转义)
    扩展名 .txt 的模板 -> <b>x</b> (不转义!)
  • 也就是说把模板存成 .txt、.md 之类后缀,转义就悄悄失效了。模板一律用 .html 结尾。

  • 反过来,{{ html }} 直接输出用户输入是安全的,{{ html|safe }} 才危险——老笔记「safe 不要乱用」这句要保留。

流程控制

  • 控制语句写在 {% %} 里,注释写在 {# #} 里。

分支结构

1
2
3
4
5
{% if name %}
<h1>Hello {{ name }}!</h1>
{% else %}
<h1>Hello World!</h1>
{% endif %}
  • 实测 name='ergou' 走第一个分支,name='' 走 else——空字符串在 Jinja 里是假值,和 Python 一致。

循环结构

1
2
3
4
5
<ol>
{% for i in range(1, 11) %}
<li>{{ i }}</li>
{% endfor %}
</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
    <!DOCTYPE html>
    <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-inverse navbar-dark bg-dark
    data-toggle="collapse" data-bs-toggle="collapse"
    data-target="#x" data-bs-target="#x"
    panel panel-default card
    jQuery + 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。

参考