Flask模板引擎Jinja2详解
本文是 Flask 服务器专栏的第三期,系统讲解 Flask 默认模板引擎 Jinja2 的全部知识,涵盖模板语法、控制结构、过滤器、测试器、宏、模板继承、全局函数、上下文处理器、消息闪现以及完整实战案例。全文超过三万字,配有大量代码示例与渲染效果演示,面向有一定 Flask 基础的开发者,帮助你彻底掌握 Jinja2,从"会写模板"进阶到"精通模板工程化"。
引言
在 Web 开发的早期,人们习惯于把 HTML 代码直接写在 Python 字符串里,用字符串拼接或 str.format() 的方式往里塞数据。这种方式在项目规模较小时看似简单,但随着页面数量增加、交互逻辑变复杂,很快就会演变成一场灾难:HTML 与 Python 代码混杂在一起,难以维护,难以协作,前后端分工无从谈起。于是,模板引擎应运而生------它把"页面结构"和"业务逻辑"彻底分离,让设计师专注 HTML/CSS,让后端工程师专注数据处理,两者通过一套约定的模板语法协作。
Flask 默认使用 Jinja2 作为模板引擎。Jinja2 是由 Armin Ronacher(同时也是 Flask、Werkzeug 的作者)开发的现代化、高扩展性模板引擎,其语法受 Django 模板系统启发,但在设计上更加灵活和强大。Jinja2 的名字来源于日语"神社"(じんじゃ,Jinja),寓意其设计如同日本神社般优雅而富有秩序。
很多 Flask 开发者虽然每天都在写 .html 模板文件,但对 Jinja2 的理解往往停留在"{``{ }} 输出变量、{% for %} 循环"的层面,对于以下问题常常一知半解:
render_template函数底层做了什么?模板是如何被加载、编译、渲染的?- 变量的属性访问
.语法和[]语法到底有什么区别?Jinja2 是如何解析的? - 自动转义(autoescape)机制如何保护我们免受 XSS 攻击?
safe过滤器和Markup类何时使用? loop对象都有哪些属性?如何在循环中实现隔行换色、分组显示?- 过滤器可以链式调用吗?如何自定义过滤器、测试器、全局函数?
- 宏(macro)和 Python 函数有什么区别?
call块又是怎么回事? - 模板继承的
super()如何工作?多层继承的查找顺序是怎样的? - 上下文处理器(context processor)和全局函数有什么区别?各自适用什么场景?
- 消息闪现(flash)的底层原理是什么?如何按类别过滤消息?
- 如何用 Jinja2 构建一个完整、可复用、易维护的网站模板系统?
本文将从这些问题出发,系统而深入地讲解 Jinja2 的方方面面。每一章都配有完整的代码示例和渲染效果演示,最后通过一个完整的博客模板系统将所学知识融会贯通。让我们开始这段 Jinja2 的进阶之旅。
第一章 Jinja2模板引擎概述
1.1 什么是模板引擎及为什么需要它
在深入了解 Jinja2 之前,我们先搞清楚一个根本问题:什么是模板引擎?为什么 Web 开发需要它?
模板引擎(Template Engine) 是一种将"模板"和"数据"组合在一起,生成最终输出文档(通常是 HTML)的工具。模板是一个包含占位符和控制逻辑的文本文件,数据则由后端程序提供。模板引擎的工作就是:把数据"填入"模板的占位符中,并根据模板里的控制逻辑(条件判断、循环等)对输出进行调整,最终生成完整的文档。
为了理解它的价值,我们先看看"没有模板引擎"时的痛点。
反例:用字符串拼接生成 HTML
python
from flask import Flask
app = Flask(__name__)
@app.route('/user/<username>')
def user_profile(username):
# 不使用模板,直接用字符串拼接
html = '<html><head><title>用户主页</title></head><body>'
html += '<h1>欢迎,{}!</h1>'.format(username)
html += '<ul>'
for i in range(1, 4):
html += '<li>文章 {}</li>'.format(i)
html += '</ul>'
html += '</body></html>'
return html
这段代码能工作,但存在严重问题:
- 可读性差:HTML 结构被 Python 字符串操作拆得支离破碎,很难一眼看出页面长什么样。
- 维护困难:修改一个标签需要到 Python 代码里去找,前后端无法分工。
- 容易出错:引号转义、字符串闭合稍有疏忽就会导致 HTML 错乱。
- 无法复用:多个页面有相同的头部、尾部?只能复制粘贴。
- 安全风险:手动拼接 HTML 很容易忘记转义,导致 XSS 漏洞。
正例:使用 Jinja2 模板
把页面结构放进 templates/user.html:
html
<!DOCTYPE html>
<html>
<head><title>用户主页</title></head>
<body>
<h1>欢迎,{{ username }}!</h1>
<ul>
{% for i in range(1, 4) %}
<li>文章 {{ i }}</li>
{% endfor %}
</ul>
</body>
</html>
视图函数只需提供数据:
python
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/user/<username>')
def user_profile(username):
return render_template('user.html', username=username)
对比之下,优势一目了然:HTML 结构清晰完整,业务逻辑与表现分离,数据通过参数传入,前后端可以并行工作。这就是模板引擎的核心价值。
模板引擎的核心职责可以归纳为四点:
| 职责 | 说明 |
|---|---|
| 数据绑定 | 将后端数据填入模板占位符,生成动态内容 |
| 控制逻辑 | 提供条件判断、循环等控制结构,控制输出结构 |
| 内容复用 | 通过继承、包含、宏等机制复用模板片段 |
| 安全转义 | 自动对特殊字符进行转义,防止 XSS 等攻击 |
1.2 Jinja2的历史与设计理念
Jinja2 由 Armin Ronacher 开发,首个版本发布于 2008 年,目前由 Pallets 组织维护。它是 Flask 生态的核心组件之一,同时也被许多其他 Python 项目(如 Ansible、Pelican、SaltStack)广泛使用。
Jinja2 的命名灵感来自日语"神社"(じんじゃ,Jinja),加上"2"是因为它是 Jinja 的重写版本。其设计理念可以概括为以下几点:
1. 模板即文本,数据即对象
Jinja2 的模板本质上是纯文本(通常是 HTML),在其中嵌入特殊的"标签"。这些标签由定界符包裹,与文本内容区分开来。传入模板的数据则是真正的 Python 对象(字符串、数字、列表、字典、自定义对象等),Jinja2 会智能地访问它们的属性和方法。
2. 语法直观,贴近 Python
Jinja2 的语法深受 Python 和 Django 模板系统的影响,但又做了一些改进。例如:
{% if user.is_admin %}几乎和 Python 的if user.is_admin:一样直观。{% for item in items %}和 Python 的 for 循环高度一致。- 变量访问支持
.attr和['key']两种语法,兼容属性和方法。
不过,Jinja2 并不是 Python 语法的完整复制,它有意做了一些限制(比如不支持任意 Python 表达式、不支持 import 模块),这是为了在模板里强制"只做表现层逻辑",避免把业务逻辑塞进模板。
3. 沙箱安全
Jinja2 提供了一个 SandboxedEnvironment,可以限制模板能访问的属性和方法,防止不信任的模板代码执行危险操作。这在允许用户自定义模板的场景(如邮件模板、报表模板)中非常重要。
4. 可扩展性强
Jinja2 几乎每个核心组件都可以扩展或替换:过滤器、测试器、全局函数、加载器、运行时等。你可以轻松地添加自定义过滤器、自定义标签,甚至实现自己的扩展。
5. 高性能
Jinja2 会把模板编译成 Python 代码再执行,而不是每次都解析模板文本。这种"编译一次,多次执行"的策略让它的渲染速度非常快,接近手写 Python 代码生成 HTML 的性能。
6. 模板继承与宏系统
Jinja2 的模板继承机制借鉴了面向对象编程的思想,允许定义基础模板并在子模板中覆写特定的"块"。这种设计让大型网站的布局复用变得优雅而高效。同时,宏(Macro)系统提供了类似函数的可复用模板片段,进一步增强了模板的组件化能力。这两种机制的结合,使得 Jinja2 能够应对从简单页面到复杂 Web 应用的所有模板需求。
7. 环境隔离与上下文管理
Jinja2 的 Environment 对象是模板引擎的运行环境,它管理着所有的模板、过滤器、测试器和全局变量。每个 Flask 应用都有自己的 Environment 实例,彼此隔离。模板渲染时的上下文(Context)是一个临时的变量空间,包含了传入模板的数据以及全局变量。理解 Environment 和 Context 的关系,是掌握 Jinja2 高级用法的关键。
1.3 Jinja2与Flask的集成关系
Flask 从一开始就把 Jinja2 作为默认模板引擎,两者深度集成。当你在 Flask 中调用 render_template 时,背后发生的所有工作都由 Jinja2 完成。下面我们梳理一下两者的集成关系。
1. Flask 应用自动创建 Jinja2 Environment
当你实例化 Flask(__name__) 时,Flask 会在内部创建一个 jinja_env 属性,它是一个配置好的 jinja2.Environment 对象。这个 Environment 是 Jinja2 的核心,负责加载模板、管理过滤器/测试器/全局函数、配置自动转义等。
python
from flask import Flask
app = Flask(__name__)
# Flask 应用自带的 Jinja2 环境
env = app.jinja_env
print(type(env)) # <class 'jinja2.environment.Environment'>
# 查看已注册的过滤器
print(len(env.filters)) # 内置了几十个过滤器
# 查看已注册的全局函数
print('url_for' in env.globals) # True,Flask 自动注入了 url_for
print('get_flashed_messages' in env.globals) # True
2. Flask 自动配置的项
Flask 在创建 Jinja2 Environment 时,会根据应用配置自动设置以下内容:
- 加载器 :
FileSystemLoader,指向templates/目录(由app.template_folder决定)。 - 自动转义 :根据模板扩展名决定是否启用自动转义(默认对
.html、.htm、.xml、.xhtml启用)。 - 全局函数 :注入
url_for、get_flashed_messages、config、request、session、g等。 - 过滤器 :注入 Flask 特有的过滤器,如
tojson。 - 上下文处理器 :Flask 自带的上下文处理器,注入
request、session、g、config等。
3. 配置 Jinja2 的方式
你可以通过 app.jinja_env 来访问和修改 Jinja2 环境,也可以通过 app.jinja_options 在创建时传入选项:
python
app = Flask(__name__)
app.jinja_options = {
'trim_blocks': True, # 去除块标签后的第一个换行
'lstrip_blocks': True, # 去除块标签前的空白
'autoescape': True, # 自动转义
'extensions': ['jinja2.ext.do', 'jinja2.ext.loopcontrols']
}
或者在应用创建后动态修改:
python
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
app.jinja_env.add_extension('jinja2.ext.loopcontrols')
1.4 Jinja2 vs Django Template Language vs Mako对比
Python 生态中有三大主流模板引擎:Jinja2、Django Template Language(DTL)、Mako。理解它们的差异有助于我们在不同场景下做出正确选择。
| 特性 | Jinja2 | Django DTL | Mako |
|---|---|---|---|
| 设计哲学 | 灵活强大,接近Python | 简单受限,强制分离 | 极致性能,允许嵌入Python |
| 语法风格 | {``{ }} / {% %} |
{``{ }} / {% %} |
${ } / <% %> |
| 表达式能力 | 较强,支持复杂表达式 | 受限,不支持方法调用 | 极强,可写任意Python代码 |
| 自动转义 | 默认开启(按扩展名) | 默认开启 | 默认关闭,需手动 |
| 模板继承 | 支持,强大 | 支持 | 支持(通过继承机制) |
| 过滤器 | 丰富,可链式 | 丰富 | 较少,用函数代替 |
| 性能 | 高(编译执行) | 中等 | 极高(直接生成py) |
| 沙箱 | 支持沙箱模式 | 无 | 无 |
| 与Flask集成 | 原生默认 | 需额外配置 | 需插件 |
| 学习曲线 | 平缓 | 平缓 | 略陡 |
详细对比说明:
1. 语法风格对比
同样是输出用户名,Jinja2 和 DTL 几乎一样:
jinja2
{# Jinja2 / Django DTL #}
{{ user.name }}
而 Mako 的风格则完全不同:
mako
<%page args="user"/>
${user.name}
2. 表达式能力对比
Jinja2 允许在模板中调用对象的方法(只要没有参数,或者参数是常量):
jinja2
{{ user.full_name() }}
{{ items.append('new') }} {# 不推荐,但可以 #}
Django DTL 不允许调用带参数的方法,更不允许在模板里写复杂逻辑:
django
{# Django DTL #}
{{ user.full_name }} {# 调用无参方法,不能加括号 #}
Mako 则允许直接写 Python 代码:
mako
<%
import datetime
now = datetime.datetime.now()
%>
当前时间: ${now.strftime('%Y-%m-%d')}
3. 性能对比
Mako 因为直接把模板编译成 Python 模块文件,性能通常最高。Jinja2 也是编译执行,性能紧随其后。Django DTL 性能相对较低,但在大多数 Web 应用中,模板渲染并非瓶颈,这点性能差异可以忽略。
选择建议:
- 用 Flask?默认 Jinja2,几乎不需要换。
- 用 Django?默认 DTL,除非有特殊需求(如大量复杂逻辑),否则保持默认即可。
- 需要极致性能或大量嵌入 Python 逻辑?考虑 Mako。
- 需要处理用户自定义模板(不信任的模板代码)?Jinja2 的沙箱模式是最佳选择。
1.5 Jinja2的核心特性一览
Jinja2 功能丰富,下表列出了它的核心特性及其用途,后续章节会逐一深入讲解。
| 特性 | 语法示例 | 用途 |
|---|---|---|
| 变量输出 | {``{ name }} |
输出数据 |
| 属性访问 | {``{ user.name }} / {``{ user['name'] }} |
访问对象属性或字典键 |
| 注释 | {# 这是注释 #} |
模板内注释,不输出 |
| 条件判断 | {% if %}...{% elif %}...{% else %}...{% endif %} |
控制输出 |
| 循环 | {% for item in items %}...{% endfor %} |
遍历序列 |
| 循环变量 | {``{ loop.index }} |
获取循环信息 |
| 过滤器 | `{``{ name | upper }}` |
| 测试器 | {% if x is defined %} |
判断数据特征 |
| 宏 | {% macro %}...{% endmacro %} |
定义可复用片段 |
| 模板继承 | {% extends %} / {% block %} |
复用页面结构 |
| 包含 | {% include %} |
引入其他模板 |
| 导入 | {% import %} |
导入宏 |
| 变量赋值 | {% set x = 1 %} |
在模板内定义变量 |
| 作用域 | {% with %}...{% endwith %} |
限定变量作用域 |
| 全局函数 | {``{ url_for('index') }} |
调用预定义函数 |
| 自动转义 | {``{ html_string }} |
XSS 防护 |
| 安全输出 | `{``{ html | safe }}` |
| 消息闪现 | {``{ get_flashed_messages() }} |
显示一次性消息 |
1.6 模板渲染流程(render_template函数底层原理)
理解 render_template 的底层工作流程,有助于我们更深入地掌握 Jinja2。当你在视图函数中调用 render_template('index.html', name='Tom') 时,Flask 和 Jinja2 会协同完成以下步骤:
步骤1:加载模板文件
Flask 调用 app.jinja_env.get_template('index.html'),Jinja2 的加载器(FileSystemLoader)会在 templates/ 目录下查找 index.html 文件。如果找不到,抛出 TemplateNotFound 异常。
步骤2:编译模板
如果是第一次加载该模板(或模板文件已修改),Jinja2 会把模板文本解析成抽象语法树(AST),再编译成 Python 代码。编译后的代码会被缓存(默认在内存中,生产环境可配置文件系统缓存),下次渲染同一模板时直接复用。
步骤3:构建模板上下文
Flask 收集要传给模板的数据(你传入的关键字参数 name='Tom'),并合并以下来源:
- 应用上下文 :
g、config、session、request(通过代理对象)。 - 上下文处理器 :
@app.context_processor装饰的函数返回的字典。 - Jinja2 全局对象 :
url_for、get_flashed_messages等。 - flash 消息 :通过
get_flashed_messages按需获取。
步骤4:渲染模板
Jinja2 执行编译后的模板代码,用上下文中的数据替换占位符、执行控制逻辑,生成最终的字符串(通常是 HTML)。
步骤5:返回响应
Flask 把渲染好的字符串包装成 Response 对象,设置默认的 Content-Type: text/html; charset=utf-8,返回给客户端。
底层原理示意代码
为了更直观地理解,我们可以手动模拟这一过程(实际开发中不需要这样做,这里只为演示原理):
python
from flask import Flask
import jinja2
app = Flask(__name__)
@app.route('/')
def index():
# 手动获取 Jinja2 环境
env = app.jinja_env
# 步骤1:加载模板
template = env.get_template('index.html')
# 步骤2 & 3 & 4:编译并渲染(第一次会编译,之后用缓存)
# 传入上下文数据
context = {'name': 'Tom', 'items': ['a', 'b', 'c']}
html = template.render(**context)
# 步骤5:返回响应
return html
这个过程等价于 return render_template('index.html', name='Tom', items=['a','b','c']),但 render_template 封装得更优雅,还自动处理了上下文注入等细节。
查看编译后的模板代码
如果你想看看 Jinja2 把模板编译成了什么样的 Python 代码,可以这样:
python
from jinja2 import Environment
env = Environment()
source = "Hello, {{ name }}! {% if items %}{% for i in items %}{{ i }} {% endfor %}{% endif %}"
# 编译模板,生成 Python 代码
template = env.from_string(source)
compiled_code = template.environment.compile(source, raw=True)
print(compiled_code)
输出大致如下(简化版):
python
from __future__ import generator_stop
def root(context, missing=missing, environment=environment):
resolve = context.resolve_or_missing
undefined = environment.undefined
yield 'Hello, '
name = resolve('name')
yield str(name)
yield '! '
items = resolve('items')
if items:
for i in items:
yield str(i)
yield ' '
可以看到,Jinja2 把模板编译成了一个生成器函数 root,它通过 yield 逐段输出文本和数据。这种编译执行的方式正是 Jinja2 高性能的根源。
render_template 与 render_template_string 的区别
Flask 提供了两个渲染函数:render_template 和 render_template_string。理解它们的区别很重要:
render_template 从文件系统加载模板文件,这是最常用的方式。它支持模板继承、include、import 等特性,且模板文件可以被缓存,性能更好。模板文件存放在 templates/ 目录下,便于组织和维护。
render_template_string 从字符串渲染模板,不需要文件系统。它主要用于简单的模板片段或动态生成的模板。但由于它不支持文件缓存(每次都需要重新编译),性能较差,且容易引入安全风险(如果拼接了用户输入),因此应谨慎使用。
python
from flask import render_template, render_template_string
# 推荐方式:从文件加载模板
@app.route('/')
def index():
return render_template('index.html', name='Tom')
# 谨慎使用:从字符串渲染
@app.route('/greeting')
def greeting():
# 安全:模板字符串是固定的,用户输入通过参数传入
return render_template_string('Hello, {{ name }}!', name=request.args.get('name'))
# 危险!绝对不要这样做:
# return render_template_string('Hello, ' + request.args.get('name'))
Flask 中模板查找的优先级
当使用蓝图(Blueprint)时,Flask 的模板查找遵循特定的优先级规则。Flask 会先在主应用的 templates/ 目录中查找,如果在蓝图的 templates/ 目录中也存在同名模板,主应用的模板会优先。但如果蓝图的模板路径在主应用中不存在,则会使用蓝图的模板。
为了组织清晰,建议在蓝图模板目录下使用子目录:
myapp/
├── templates/ # 主应用模板
│ ├── base.html
│ ├── index.html
│ └── blog/
│ ├── list.html
│ └── detail.html
├── admin/
│ ├── __init__.py
│ ├── views.py
│ └── templates/ # 蓝图模板
│ └── admin/
│ ├── dashboard.html
│ └── users.html
蓝图注册时指定子目录:
python
from flask import Blueprint
admin_bp = Blueprint('admin', __name__, template_folder='templates/admin')
@admin_bp.route('/dashboard')
def dashboard():
return render_template('dashboard.html') # 从 admin/templates/admin/ 加载
这种组织方式既保持了模板的模块化,又避免了命名冲突。理解模板查找的优先级规则,有助于在大型项目中合理组织模板文件结构。
第二章 模板基础语法
2.1 模板文件组织(templates目录结构)
在 Flask 中,模板文件默认放在应用根目录下的 templates/ 文件夹中。这个位置由 Flask 类的 template_folder 参数决定,默认值就是 'templates'。
基础目录结构
myapp/
├── app.py
├── templates/
│ ├── base.html
│ ├── index.html
│ └── user/
│ └── profile.html
└── static/
├── css/
├── js/
└── images/
对应的视图函数:
python
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/user/profile')
def profile():
return render_template('user/profile.html')
自定义模板目录
如果你想改变模板目录的位置,可以在创建应用时指定:
python
app = Flask(__name__, template_folder='views')
这样模板就从 views/ 目录加载了。这个用法在大型项目里比较少见,但有时为了配合特定目录结构会用到。
多目录加载
Blueprint(蓝图)可以有自己的模板目录。Flask 会按"应用模板目录优先,蓝图模板目录其次"的顺序查找。这在多模块大型项目中很有用:
python
from flask import Blueprint
admin_bp = Blueprint('admin', __name__, template_folder='templates/admin')
@admin_bp.route('/dashboard')
def dashboard():
return render_template('dashboard.html') # 先在应用 templates/ 找,找不到再到 admin 蓝图的 templates/admin/ 找
最佳实践:目录组织建议
对于中小型项目,推荐的目录结构:
templates/
├── base.html # 基础布局
├── macros/ # 宏库
│ ├── forms.html # 表单宏
│ └── ui.html # UI组件宏
├── partials/ # 页面片段
│ ├── header.html
│ ├── footer.html
│ └── sidebar.html
├── errors/ # 错误页面
│ ├── 404.html
│ └── 500.html
├── index.html # 首页
├── user/ # 用户模块
│ ├── profile.html
│ └── settings.html
└── blog/ # 博客模块
├── list.html
└── detail.html
这种结构按职责划分目录,清晰易维护。
2.2 渲染模板(render_template函数详解)
render_template 是 Flask 提供的模板渲染函数,定义在 flask.helpers 中。它的签名如下:
python
def render_template(template_name_or_list, **context):
...
template_name_or_list:模板文件名(字符串),或模板名列表(选第一个存在的)。**context:传给模板的上下文变量,以关键字参数形式传递。
基本用法
python
from flask import render_template
@app.route('/')
def index():
return render_template('index.html', title='首页', user={'name': 'Tom'})
模板 index.html 中就可以使用 title 和 user:
jinja2
<h1>{{ title }}</h1>
<p>你好,{{ user.name }}</p>
传递多个变量
你可以传任意多个变量:
python
@app.route('/blog')
def blog():
return render_template(
'blog/list.html',
posts=[
{'id': 1, 'title': 'Flask入门'},
{'id': 2, 'title': 'Jinja2详解'},
],
page=1,
total_pages=10,
current_user={'name': 'Tom', 'is_admin': True}
)
传递字典作为上下文
如果变量很多,用一个字典组织更清晰:
python
@app.route('/profile')
def profile():
context = {
'user': {'name': 'Tom', 'age': 28},
'skills': ['Python', 'Flask', 'Jinja2'],
'settings': {'theme': 'dark', 'lang': 'zh'}
}
return render_template('profile.html', **context)
注意 **context 的展开语法,它把字典的键值对作为关键字参数传递。
render_template_string:渲染字符串模板
有时候我们只需要渲染一小段模板字符串,不必创建文件,这时用 render_template_string:
python
from flask import render_template_string
@app.route('/hello')
def hello():
return render_template_string('<h1>Hello, {{ name }}!</h1>', name='World')
这在动态生成短小内容(如邮件模板片段)时很方便。但要注意:永远不要用 render_template_string 渲染用户输入,否则会导致服务器端模板注入(SSTI)漏洞。
安全提醒:防范SSTI
下面这段代码是危险的:
python
# 危险!用户可以通过精心构造的 name 执行任意代码
@app.route('/unsafe/<name>')
def unsafe(name):
return render_template_string('Hello, ' + name)
攻击者只要访问 /unsafe/{``{ config }},就能看到你的应用配置(包括 SECRET_KEY)。正确的做法是把用户输入作为变量传递,而不是拼接到模板字符串中:
python
# 安全:用户输入作为变量传入
@app.route('/safe/<name>')
def safe(name):
return render_template_string('Hello, {{ name }}', name=name)
2.3 变量输出({{ variable }})
Jinja2 中输出变量使用双花括号语法 {``{ variable }}。这是模板里最常见的语法,用于把上下文中的数据"打印"到输出中。
基本输出
jinja2
{{ name }}
视图函数:
python
@app.route('/')
def index():
return render_template('index.html', name='Tom')
渲染结果:
Tom
输出各种类型
Jinja2 能智能地处理各种 Python 类型的输出:
python
return render_template(
'demo.html',
text='Hello', # 字符串
number=42, # 整数
pi=3.14, # 浮点数
flag=True, # 布尔
nothing=None, # None
items=[1, 2, 3], # 列表
user={'name': 'Tom'} # 字典
)
模板:
jinja2
字符串: {{ text }} {# Hello #}
数字: {{ number }} {# 42 #}
浮点: {{ pi }} {# 3.14 #}
布尔: {{ flag }} {# True #}
None: {{ nothing }} {# None #}
列表: {{ items }} {# [1, 2, 3] #}
字典: {{ user }} {# {'name': 'Tom'} #}
注意:布尔值 True/False 会原样输出为字符串 "True"/"False",None 会输出为 "None"。如果你不想要这种效果,可以用过滤器处理,例如 {``{ nothing or '' }} 让 None 输出为空字符串。
未定义变量
如果模板里用到了一个没有传入的变量,Jinja2 不会报错,而是输出空字符串(具体行为取决于 undefined 配置,默认是 Undefined):
jinja2
{{ undefined_var }} {# 输出空字符串 #}
这种设计避免了模板因缺少变量而崩溃,但也可能掩盖 bug。如果你希望未定义变量抛出异常,可以配置 StrictUndefined:
python
from jinja2 import StrictUndefined
app.jinja_env.undefined = StrictUndefined
这样访问未定义变量会抛出 UndefinedError,有助于在开发期发现问题。
2.4 变量的属性访问(.语法 vs \[\]语法)
Jinja2 提供两种语法访问变量的属性或字典的键:点号 . 和方括号 []。理解它们的解析逻辑很重要,能避免很多困惑。
点号语法 .
jinja2
{{ user.name }}
{{ user.age }}
方括号语法 []
jinja2
{{ user['name'] }}
{{ user['age'] }}
Jinja2的解析顺序
当 Jinja2 遇到 user.name 时,它会按以下顺序尝试解析:
- 字典的键 :如果
user是字典,先尝试user['name']。 - 属性 :尝试
getattr(user, 'name')。 - 列表索引 :如果
name是数字,尝试user[int(name)](对列表/元组)。 __getitem__:尝试user.__getitem__('name')。
如果都失败,返回 Undefined(默认输出空字符串)。
.语法 vs []语法 的选择
两种语法在大多数情况下等价,但有以下区别:
| 场景 | .语法 |
[]语法 |
|---|---|---|
| 字典访问 | user.name |
user['name'] |
| 对象属性 | user.name |
user['name'] |
| 列表索引 | 不支持 items.0 |
items[0] |
| 动态键名 | 不支持 | user[var] |
| 含特殊字符的键 | 不支持 | user['my-key'] |
| 数字键 | 不支持 | data[0] |
示例对比
python
class User:
def __init__(self, name, age):
self.name = name
self.age = age
user = User('Tom', 28)
data = {'name': 'Tom', 'age': 28, 'my-key': '特殊键'}
items = ['a', 'b', 'c']
key = 'name'
模板:
jinja2
{# 对象属性 #}
{{ user.name }} {# Tom #}
{{ user['name'] }} {# Tom #}
{# 字典访问 #}
{{ data.name }} {# Tom #}
{{ data['name'] }} {# Tom #}
{{ data['my-key'] }} {# 特殊键(只能用[]) #}
{# 列表索引(只能用[]) #}
{{ items[0] }} {# a #}
{{ items[1] }} {# b #}
{# 动态键名(只能用[]) #}
{{ data[key] }} {# Tom #}
建议
- 一般情况下用
.语法,更简洁。 - 当键名含特殊字符、是数字、或需要动态计算时,用
[]语法。
调用对象的方法
Jinja2 不仅可以访问对象的属性,还可以调用对象的方法。这在处理日期、字符串等对象时非常有用:
python
@app.route('/')
def index():
return render_template(
'index.html',
name=' Hello World ',
now=datetime.now(),
items=['apple', 'banana', 'cherry'],
)
jinja2
{# 字符串方法 #}
{{ name.strip() }} {# Hello World #}
{{ name.upper() }} {# HELLO WORLD #}
{{ name.lower() }} {# hello world #}
{{ name.replace('World', 'Jinja2') }} {# Hello Jinja2 #}
{{ name.split() }} {# ['Hello', 'World'] #}
{# 日期方法 #}
{{ now.strftime('%Y-%m-%d') }} {# 2024-01-15 #}
{{ now.year }} {# 2024 #}
{{ now.month }} {# 1 #}
{{ now.day }} {# 15 #}
{# 列表方法 #}
{{ items.count('apple') }} {# 1 #}
{{ items.index('banana') }} {# 1 #}
注意事项 :Jinja2 只允许调用"安全"的方法。默认情况下,以下划线开头的方法(如 __init__、__class__)不可调用,这是为了防止安全漏洞。如果需要调用受限方法,需要通过 SandboxedEnvironment 配置白名单。
变量表达式的运算
{``{ }} 中不仅可以输出变量,还可以进行简单的运算:
jinja2
{# 算术运算 #}
{{ 1 + 2 }} {# 3 #}
{{ 10 - 5 }} {# 5 #}
{{ 3 * 4 }} {# 12 #}
{{ 10 / 3 }} {# 3.333... #}
{{ 10 // 3 }} {# 3 #}
{{ 10 % 3 }} {# 1 #}
{{ 2 ** 10 }} {# 1024 #}
{# 比较运算 #}
{{ age >= 18 }} {# True #}
{{ name == 'Tom' }} {# True #}
{# 逻辑运算 #}
{{ is_admin and is_active }} {# True/False #}
{{ not is_banned }} {# True/False #}
{# 字符串拼接 #}
{{ 'Hello, ' ~ name ~ '!' }} {# Hello, Tom! #}
{# 三元运算 #}
{{ '成年' if age >= 18 else '未成年' }}
{# in 运算 #}
{{ 'admin' in roles }} {# True/False #}
{{ 'apple' in fruits }} {# True/False #}
Jinja2 的表达式语法与 Python 非常相似,但有一些区别:Jinja2 用 ~ 进行字符串拼接(自动转为字符串),而不是 Python 的 +;Jinja2 的 and/or/not 是关键字,不支持 &&/||/! 这种 C 风格的运算符。
2.5 注释({# comment #})
模板注释使用 {# #} 语法,注释内容不会出现在最终输出中:
jinja2
{# 这是一个注释,不会显示在页面上 #}
<p>这是正文。</p>
渲染结果:
<p>这是正文。</p>
多行注释
{# #} 可以跨多行:
jinja2
{#
这是多行注释
可以写很多内容
比如说明这段模板的用途
#}
<div>内容</div>
注释的作用
- 说明模板的复杂逻辑。
- 临时禁用某段代码(调试时常用)。
- 标注待办事项。
jinja2
{# TODO: 这里需要加上分页逻辑 #}
<div class="list">
{% for item in items %}
<p>{{ item }}</p>
{% endfor %}
</div>
注意:注释与HTML注释的区别
Jinja2 的 {# #} 注释在渲染时会被完全移除,客户端查看页面源码看不到。而 HTML 注释 <!-- --> 会保留在输出中:
jinja2
{# Jinja2注释:客户端看不到 #}
<!-- HTML注释:客户端能看到 -->
如果只是给自己/团队看的内部说明,用 {# #};如果是给前端开发者看的页面结构说明,用 <!-- -->。
2.6 控制结构标签({% %}语法)
控制结构使用 {% %} 语法包裹,用于实现条件判断、循环、宏定义、继承等逻辑。与 {``{ }}(输出变量)不同,{% %} 标签本身不产生输出,只控制模板的执行流程。
常用控制结构
jinja2
{# 条件判断 #}
{% if user.is_admin %}
<a href="/admin">管理后台</a>
{% endif %}
{# 循环 #}
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
{# 变量赋值 #}
{% set greeting = 'Hello' %}
{# 作用域 #}
{% with message = 'Hi' %}
<p>{{ message }}</p>
{% endwith %}
{# 宏定义 #}
{% macro greeting(name) %}
<p>Hello, {{ name }}!</p>
{% endmacro %}
{# 模板继承 #}
{% extends 'base.html' %}
{% block content %}...{% endblock %}
{# 包含 #}
{% include 'header.html' %}
{# 导入 #}
{% import 'macros.html' as macros %}
标签的闭合
大多数控制结构标签都需要显式闭合,如 if 对应 endif,for 对应 endfor,macro 对应 endmacro 等。忘记闭合是新手常犯的错误。
jinja2
{# 错误:忘记 endif #}
{% if user %}
<p>{{ user.name }}</p>
{# 正确 #}
{% if user %}
<p>{{ user.name }}</p>
{% endif %}
标签必须独占一行?
不一定。标签可以和其他内容混在一行:
jinja2
{% if user %}<p>欢迎,{{ user.name }}</p>{% endif %}
但为了可读性,建议复杂标签独占一行。
2.7 空白控制(trim_blocks, lstrip_blocks)
Jinja2 默认会保留模板中的所有空白字符(包括换行、缩进)。这在某些情况下会让生成的 HTML 出现多余的空行,影响美观(虽然不影响浏览器渲染)。Jinja2 提供了几个选项来控制空白。
问题演示
jinja2
<div>
{% for item in items %}
<p>{{ item }}</p>
{% endfor %}
</div>
默认渲染结果(假设 items = ['a', 'b']):
html
<div>
<p>a</p>
<p>b</p>
</div>
可以看到,{% for %} 和 {% endfor %} 所在行产生的换行被保留了,导致输出里有多余空行。
trim_blocks
启用 trim_blocks 后,Jinja2 会自动去除块标签({% %})后的第一个换行符:
python
app.jinja_env.trim_blocks = True
渲染结果:
html
<div>
<p>a</p>
<p>b</p>
</div>
lstrip_blocks
启用 lstrip_blocks 后,Jinja2 会自动去除块标签前行首的空白(从行开头到标签之间):
python
app.jinja_env.lstrip_blocks = True
渲染结果(两个都启用):
html
<div>
<p>a</p>
<p>b</p>
</div>
推荐配置
在 Flask 应用中,推荐同时启用这两个选项:
python
app = Flask(__name__)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
或者在创建应用时:
python
app = Flask(__name__)
app.jinja_options = {
'trim_blocks': True,
'lstrip_blocks': True,
}
手动空白控制:- 语法
除了全局配置,你还可以在标签级别手动控制空白。在 {% 或 %}、{``{ 或 }} 紧贴的位置加 -,可以去除一侧的所有空白:
{%-:去除标签前的所有空白(包括换行)。-%}:去除标签后的所有空白(包括换行)。{``{-和-}}:同理作用于变量输出。
jinja2
{# 去除前导空白 #}
<div>
{%- for item in items %}
<p>{{ item }}</p>
{%- endfor %}
</div>
渲染结果(假设 items = ['a', 'b']):
html
<div>
<p>a</p>
<p>b</p>
</div>
~ 运算符:字符串拼接
顺带提一下,Jinja2 用 ~ 运算符拼接字符串(会自动转为字符串):
jinja2
{{ 'Hello, ' ~ name ~ '!' }}
等价于 Python 的 'Hello, ' + str(name) + '!'。
2.8 转义与自动转义(autoescape)
Web 安全中,XSS(跨站脚本攻击)是最常见的漏洞之一。攻击者通过在输入中注入恶意 JavaScript,当这些输入被渲染到页面时,浏览器会执行恶意代码,盗取用户 Cookie 或执行其他操作。
Jinja2 通过**自动转义(autoescape)**机制来防范 XSS。当自动转义启用时,{``{ }} 输出的内容中,特殊字符会被替换为 HTML 实体:
| 原字符 | 转义后 |
|---|---|
< |
< |
> |
> |
& |
& |
" |
" |
' |
' |
示例
视图函数传入含 HTML 的字符串:
python
@app.route('/')
def index():
return render_template('index.html', content='<script>alert("XSS")</script>')
模板:
jinja2
<div>{{ content }}</div>
渲染结果:
html
<div><script>alert("XSS")</script></div>
浏览器会把它显示为纯文本 <script>alert("XSS")</script>,而不会执行脚本。这就是自动转义的保护作用。
自动转义的启用规则
在 Flask 中,自动转义默认根据模板文件的扩展名决定:
.html、.htm、.xml、.xhtml:启用自动转义。- 其他扩展名(如
.txt、.css、.js):不启用。
你也可以手动控制:
python
# 对所有模板启用自动转义
app.jinja_env.autoescape = True
# 或自定义规则
def select_autoescape(filename):
return filename.endswith(('.html', '.xml'))
app.jinja_env.autoescape = select_autoescape
手动转义:e 过滤器
即使自动转义关闭,你也可以用 e(或 escape)过滤器手动转义:
jinja2
<div>{{ content | e }}</div>
2.9 Markup类与safe过滤器
有时候,你确实需要输出原始 HTML(比如富文本编辑器的内容、自己生成的可信 HTML 片段)。这时需要"告诉"Jinja2 这段内容是安全的,不要转义。有两种方式:Markup 类和 safe 过滤器。
safe过滤器
jinja2
<div>{{ html_content | safe }}</div>
| safe 告诉 Jinja2:"这个内容是安全的,不要转义。"渲染结果会原样输出 HTML。
Markup类
在 Python 代码中,可以用 markupsafe.Markup(Flask 重新导出为 flask.Markup)把字符串标记为安全:
python
from flask import Markup
@app.route('/')
def index():
html = Markup('<strong>重要</strong>')
return render_template('index.html', content=html)
模板:
jinja2
<div>{{ content }}</div>
渲染结果:
html
<div><strong>重要</strong></div>
因为 content 是 Markup 对象,Jinja2 知道它已经安全,不再转义。
Markup的escape方法
Markup 还提供了 escape 方法,可以转义字符串后再标记为安全:
python
from flask import Markup
# 用户输入
user_input = '<script>alert("xss")</script>'
# 转义后包裹在 <p> 中
safe_html = Markup('<p>{}</p>').format(Markup.escape(user_input))
# 结果: <p><script>alert("xss")</script></p>
安全提醒
safe 和 Markup 是"双刃剑":用对了能输出富文本,用错了会引入 XSS。永远不要对用户输入直接用 safe:
jinja2
{# 危险!用户可能注入恶意脚本 #}
{{ user_input | safe }}
只有在以下情况才用 safe/Markup:
- 内容是程序自己生成的可信 HTML。
- 内容经过专门的富文本过滤器(如
bleach)清洗过。
自动转义与 safe 的交互机制
理解自动转义和 safe 过滤器的交互机制对于编写安全的模板至关重要。当 Jinja2 渲染 {``{ variable }} 时,它会检查 variable 的类型:
- 如果
variable是Markup对象(即已被标记为安全),直接输出,不转义。 - 如果
variable是普通字符串,且自动转义已启用,则转义后输出。 - 如果
variable是普通字符串,且自动转义已关闭,则直接输出。
safe 过滤器的作用就是将普通字符串转换为 Markup 对象,告诉 Jinja2 "这段内容是安全的"。但需要注意的是,safe 过滤器不会对内容做任何清洗或过滤,它只是标记。如果内容中包含恶意脚本,safe 过滤器会原样输出。
常见安全场景分析
场景一:用户评论中包含 HTML 标签。用户在评论中输入 <script>alert('hello')</script>,如果不做任何处理直接输出,由于自动转义的存在,这段脚本会被转义为纯文本,不会执行。这是正确的行为------用户输入应该被转义。
场景二:富文本编辑器的内容。如果使用了富文本编辑器(如 TinyMCE、CKEditor),用户输入的内容本身就是 HTML 格式的。直接输出会导致 HTML 标签被转义,显示为源代码而非渲染后的效果。此时需要先对内容进行清洗(使用 bleach 等库),然后再用 safe 输出:
python
import bleach
@app.template_filter('rich_text')
def rich_text(text):
"""富文本过滤器:清洗后标记为安全"""
allowed_tags = ['p', 'br', 'strong', 'em', 'a', 'img', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'blockquote', 'code', 'pre']
allowed_attrs = {
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'width', 'height'],
}
cleaned = bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
return Markup(cleaned)
场景三:程序生成的 HTML。当你在 Python 代码中拼接 HTML 时(如生成链接、构建表格),可以使用 Markup 标记为安全:
python
from flask import Markup
def generate_breadcrumb(items):
"""生成面包屑导航HTML"""
parts = []
for i, (name, url) in enumerate(items):
if i < len(items) - 1:
parts.append(f'<a href="{url}">{Markup.escape(name)}</a> / ')
else:
parts.append(f'<span>{Markup.escape(name)}</span>')
return Markup(''.join(parts))
注意在拼接时,用户提供的部分(如 name)仍然需要用 Markup.escape() 转义,只有程序自己生成的 HTML 标签不需要转义。
2.10 模板上下文(context)
模板上下文(context)是渲染模板时所有可用变量的集合。理解上下文的构成,有助于你明白模板里能访问哪些数据。
上下文的来源
Flask 渲染模板时,上下文由以下几部分组成:
| 来源 | 说明 | 示例 |
|---|---|---|
| 视图函数传入 | render_template 的关键字参数 |
render_template('x.html', name='Tom') |
| Flask 默认注入 | request、session、g、config |
{``{ request.args }} |
| 全局函数 | url_for、get_flashed_messages |
{``{ url_for('index') }} |
| 上下文处理器 | @app.context_processor 返回的字典 |
自定义注入的变量 |
Flask 默认注入的对象
即使你不传任何参数,模板里也能访问以下对象:
jinja2
{# 请求对象 #}
{{ request.method }} {# GET / POST #}
{{ request.args.get('q') }} {# 查询参数 #}
{{ request.path }} {# 当前路径 #}
{# 会话 #}
{{ session.get('user_id') }}
{# 全局对象 g #}
{{ g.user }}
{# 应用配置 #}
{{ config['SECRET_KEY'] }} {# 注意:不要在前端暴露敏感配置! #}
警告:虽然
config在模板中可用,但绝对不要在模板里输出敏感配置(如 SECRET_KEY、数据库密码),这会导致信息泄露。
自定义上下文:context_processor
如果你想在每个模板中都注入某些变量(如当前登录用户、网站名称),用上下文处理器最方便:
python
@app.context_processor
def inject_globals():
return dict(
site_name='我的博客',
current_year=2024,
user=get_current_user() # 假设这个函数返回当前登录用户
)
这样,所有模板都能直接用 {``{ site_name }}、{``{ current_year }}、{``{ user }},无需在每个视图函数里重复传递。
上下文处理器的详细用法会在第八章深入讲解。
第三章 控制结构
控制结构是模板引擎的核心能力之一,它让模板不仅能"填充数据",还能"做判断、做循环",从而生成结构化的动态内容。Jinja2 的控制结构使用 {% %} 语法,本章将系统讲解所有控制结构。
3.1 条件判断({% if %}/{% elif %}/{% else %})
{% if %} 是最常用的控制结构,用于根据条件决定是否输出某段内容。它的语法和 Python 的 if 语句非常相似。
基本if
jinja2
{% if user %}
<p>欢迎,{{ user.name }}!</p>
{% endif %}
视图函数:
python
@app.route('/')
def index():
return render_template('index.html', user={'name': 'Tom'})
渲染结果:
html
<p>欢迎,Tom!</p>
如果 user 为 None、False、空字符串、空列表等"假值",则不输出。
if-else
jinja2
{% if user.is_vip %}
<span class="badge">VIP会员</span>
{% else %}
<span class="badge">普通用户</span>
{% endif %}
if-elif-else
jinja2
{% if score >= 90 %}
<p>等级:优秀</p>
{% elif score >= 80 %}
<p>等级:良好</p>
{% elif score >= 60 %}
<p>等级:及格</p>
{% else %}
<p>等级:不及格</p>
{% endif %}
假设 score = 85,渲染结果:
html
<p>等级:良好</p>
条件表达式(三元运算符)
Jinja2 支持内联的条件表达式,类似 Python 的 a if condition else b:
jinja2
<p>状态:{{ '在线' if user.online else '离线' }}</p>
这等价于:
jinja2
{% if user.online %}
<p>状态:在线</p>
{% else %}
<p>状态:离线</p>
{% endif %}
条件表达式还支持省略 else 分支(此时为 undefined,输出空字符串):
jinja2
{{ '管理员' if user.is_admin }}
复合条件
Jinja2 支持 and、or、not 逻辑运算符:
jinja2
{% if user and user.is_admin and not user.is_banned %}
<a href="/admin">进入管理后台</a>
{% endif %}
也支持比较运算符 ==、!=、>、<、>=、<=:
jinja2
{% if items | length > 0 %}
<p>共有 {{ items | length }} 项</p>
{% else %}
<p>暂无数据</p>
{% endif %}
in 运算符
jinja2
{% if 'admin' in user.roles %}
<a href="/admin">管理后台</a>
{% endif %}
{% if user.username not in banned_users %}
<p>你的账号正常</p>
{% endif %}
3.2 for循环({% for item in items %})
{% for %} 用于遍历序列(列表、元组、字典、生成器等),是模板里最常用的控制结构之一。
遍历列表
jinja2
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
视图函数:
python
@app.route('/')
def index():
return render_template('index.html', items=['苹果', '香蕉', '橘子'])
渲染结果:
html
<ul>
<li>苹果</li>
<li>香蕉</li>
<li>橘子</li>
</ul>
遍历字典
jinja2
<dl>
{% for key, value in user.items() %}
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
视图函数:
python
@app.route('/')
def index():
return render_template('index.html', user={'name': 'Tom', 'age': 28, 'city': '北京'})
渲染结果:
html
<dl>
<dt>name</dt>
<dd>Tom</dd>
<dt>age</dt>
<dd>28</dd>
<dt>city</dt>
<dd>北京</dd>
</dl>
注意:Jinja2 的 for 循环不支持 dict.items() 的写法需要加括号调用。实际上,Jinja2 会自动处理字典遍历:
jinja2
{# 直接遍历字典的键值对 #}
{% for key, value in user.items() %}
{{ key }}: {{ value }}
{% endfor %}
遍历数字范围:range
jinja2
{% for i in range(1, 6) %}
<p>第 {{ i }} 行</p>
{% endfor %}
渲染结果:
html
<p>第 1 行</p>
<p>第 2 行</p>
<p>第 3 行</p>
<p>第 4 行</p>
<p>第 5 行</p>
带过滤的循环
Jinja2 的 for 循环支持 if 过滤器,直接在循环语句中筛选元素:
jinja2
{% for user in users if user.is_active %}
<li>{{ user.name }}</li>
{% endfor %}
这会只遍历 is_active 为真的用户。
解包遍历
如果列表元素是元组或列表,可以解包:
jinja2
{% for id, name, age in users %}
<tr>
<td>{{ id }}</td>
<td>{{ name }}</td>
<td>{{ age }}</td>
</tr>
{% endfor %}
视图函数:
python
users = [
(1, 'Tom', 28),
(2, 'Jerry', 25),
(3, 'Alice', 30),
]
递归循环:recursive
Jinja2 支持递归循环,用于遍历嵌套结构(如树形菜单):
jinja2
<ul>
{% for item in items recursive %}
<li>
{{ item.title }}
{% if item.children %}
<ul>{{ loop(item.children) }}</ul>
{% endif %}
</li>
{% endfor %}
</ul>
视图函数:
python
menu = [
{'title': '首页', 'children': []},
{'title': '产品', 'children': [
{'title': '产品A', 'children': []},
{'title': '产品B', 'children': [
{'title': '产品B-1', 'children': []},
]},
]},
{'title': '关于', 'children': []},
]
渲染结果:
html
<ul>
<li>首页</li>
<li>产品
<ul>
<li>产品A</li>
<li>产品B
<ul>
<li>产品B-1</li>
</ul>
</li>
</ul>
</li>
<li>关于</li>
</ul>
注意递归调用时用 loop(item.children),而不是 loop 的属性。
3.3 for循环特殊变量(loop对象)
Jinja2 的 for 循环提供了一个特殊的 loop 对象,包含当前循环的各种信息。这是 Jinja2 相比 Django DTL 的一大优势,让循环控制更加灵活。
loop对象的所有属性
| 属性 | 说明 | 示例值(第2次迭代,共5项) |
|---|---|---|
loop.index |
当前迭代序号(从1开始) | 2 |
loop.index0 |
当前迭代序号(从0开始) | 1 |
loop.revindex |
剩余次数(含当前,从1开始) | 4 |
loop.revindex0 |
剩余次数(不含当前,从0开始) | 3 |
loop.first |
是否第一次迭代 | False |
loop.last |
是否最后一次迭代 | False |
loop.length |
序列总长度 | 5 |
loop.previtem |
前一个元素 | - |
loop.nextitem |
后一个元素 | - |
loop.cycle |
循环取值的函数 | - |
loop.changed(...) |
参数是否变化 | True/False |
loop.cycle |
在多个值间循环 | - |
基本用法演示
jinja2
<ul>
{% for item in items %}
<li>
第 {{ loop.index }} 项(从0算是第 {{ loop.index0 }} 项),
剩余 {{ loop.revindex }} 项,
共 {{ loop.length }} 项。
{% if loop.first %}[第一项]{% endif %}
{% if loop.last %}[最后一项]{% endif %}
</li>
{% endfor %}
</ul>
视图函数:
python
items = ['苹果', '香蕉', '橘子']
渲染结果:
html
<ul>
<li>
第 1 项(从0算是第 0 项),
剩余 3 项,
共 3 项。
[第一项]
</li>
<li>
第 2 项(从0算是第 1 项),
剩余 2 项,
共 3 项。
</li>
<li>
第 3 项(从0算是第 2 项),
剩余 1 项,
共 3 项。
[最后一项]
</li>
</ul>
隔行换色:loop.index0
最常见的应用场景之一:
jinja2
<table>
{% for user in users %}
<tr class="{{ 'even' if loop.index0 % 2 == 0 else 'odd' }}">
<td>{{ user.name }}</td>
</tr>
{% endfor %}
</table>
或者用 loop.cycle 更简洁:
jinja2
{% for user in users %}
<tr class="{{ loop.cycle('even', 'odd') }}">
<td>{{ user.name }}</td>
</tr>
{% endfor %}
loop.cycle('even', 'odd') 会在 'even' 和 'odd' 之间循环取值:第一次 'even',第二次 'odd',第三次 'even'......
首尾特殊处理:loop.first / loop.last
jinja2
<nav class="breadcrumb">
{% for crumb in breadcrumbs %}
{% if not loop.first %} / {% endif %}
<span>{{ crumb }}</span>
{% endfor %}
</nav>
视图函数:
python
breadcrumbs = ['首页', '产品', '产品A', '详情']
渲染结果:
html
<nav class="breadcrumb">
<span>首页</span> / <span>产品</span> / <span>产品A</span> / <span>详情</span>
</nav>
前后元素访问:loop.previtem / loop.nextitem
jinja2
{% for item in items %}
<p>当前: {{ item }}
{% if loop.previtem %}前一个: {{ loop.previtem }}{% endif %}
{% if loop.nextitem %}后一个: {{ loop.nextitem }}{% endif %}
</p>
{% endfor %}
loop.changed:检测值变化
loop.changed(value) 返回当前迭代的 value 是否与上一次不同,常用于分组显示:
jinja2
{% for post in posts %}
{% if loop.changed(post.category) %}
<h2>{{ post.category }}</h2>
{% endif %}
<p>{{ post.title }}</p>
{% endfor %}
视图函数:
python
posts = [
{'title': '文章1', 'category': '技术'},
{'title': '文章2', 'category': '技术'},
{'title': '文章3', 'category': '生活'},
{'title': '文章4', 'category': '生活'},
{'title': '文章5', 'category': '技术'},
]
渲染结果:
html
<h2>技术</h2>
<p>文章1</p>
<p>文章2</p>
<h2>生活</h2>
<p>文章3</p>
<p>文章4</p>
<h2>技术</h2>
<p>文章5</p>
每当 post.category 变化时,就输出一个分类标题。
loop循环的实战技巧汇总
在实际项目开发中,loop 对象的各种属性经常组合使用,下面汇总一些常见的实战技巧。
技巧一:分列显示
当需要把一个列表分成多列显示时(如商品网格),可以利用 loop.index 和取模运算:
jinja2
<div class="row">
{% for product in products %}
<div class="col-md-4">
<div class="card">
<img src="{{ product.image }}" class="card-img-top">
<div class="card-body">
<h5>{{ product.name }}</h5>
<p class="price">{{ product.price | money }}</p>
</div>
</div>
</div>
{# 每3个元素后关闭当前行并开始新行 #}
{% if loop.index % 3 == 0 and not loop.last %}
</div>
<div class="row">
{% endif %}
{% endfor %}
</div>
技巧二:进度条显示
利用 loop.index 和 loop.length 可以计算进度百分比:
jinja2
{% for step in steps %}
<div class="progress-step {% if loop.first %}active{% endif %}"
style="width: {{ (100 / loop.length) | round }}%">
<span class="step-number">{{ loop.index }}</span>
<span class="step-label">{{ step.label }}</span>
</div>
{% if not loop.last %}
<div class="progress-connector"></div>
{% endif %}
{% endfor %}
技巧三:序号与分页结合
当列表有分页时,序号需要加上偏移量:
jinja2
{# page=2, per_page=10, 则第二页的第一条序号为11 #}
{% set offset = (page - 1) * per_page %}
<table>
{% for item in items %}
<tr>
<td>{{ loop.index + offset }}</td>
<td>{{ item.name }}</td>
</tr>
{% endfor %}
</table>
技巧四:利用 loop.changed 实现数据分组的高级用法
loop.changed 不仅可以用于简单的分类标题,还可以用于更复杂的分组场景:
jinja2
{% for order in orders %}
{# 按日期分组 #}
{% if loop.changed(order.created_at | format_date) %}
{% if not loop.first %}</tbody></table>{% endif %}
<h3>{{ order.created_at | format_date }}</h3>
<table class="order-table">
<thead>
<tr><th>订单号</th><th>金额</th><th>状态</th></tr>
</thead>
<tbody>
{% endif %}
<tr>
<td>{{ order.order_no }}</td>
<td>{{ order.amount | money }}</td>
<td>{{ order.status }}</td>
</tr>
{# 最后一组需要闭合表格 #}
{% if loop.last %}</tbody></table>{% endif %}
{% endfor %}
这个技巧在渲染按日期、分类等字段分组的列表时非常实用,避免了在视图函数中预先分组的麻烦。需要注意的是,数据必须已经按分组字段排序,否则 loop.changed 会在不合适的地方触发分组标题。
3.4 loop循环控制
默认情况下,Jinja2 的 for 循环不支持 break(提前退出)和 continue(跳过当前)。但在某些场景下,这些控制很有用。Jinja2 通过扩展 jinja2.ext.loopcontrols 提供了这两个功能。
启用扩展
python
app.jinja_env.add_extension('jinja2.ext.loopcontrols')
或者在创建应用时:
python
app = Flask(__name__)
app.jinja_options = {
'extensions': ['jinja2.ext.loopcontrols']
}
break:提前退出循环
jinja2
{% for user in users %}
{% if loop.index > 5 %}
{% break %} {# 只显示前5个 #}
{% endif %}
<li>{{ user.name }}</li>
{% endfor %}
continue:跳过当前迭代
jinja2
{% for user in users %}
{% if user.is_banned %}
{% continue %} {# 跳过被封禁的用户 #}
{% endif %}
<li>{{ user.name }}</li>
{% endfor %}
注意 :虽然 break 和 continue 很方便,但如果你发现自己频繁需要在模板里做这种复杂的循环控制,可能意味着这些逻辑应该放到视图函数里预处理数据,而不是塞进模板。模板应该尽量保持简单。
3.5 {% break %}和{% continue %}(扩展)
上一节已经介绍了 break 和 continue 的基本用法,这里补充一些更深入的细节和注意事项。
break 与 loop.last 的交互
break 不会改变 loop.last 的值。如果你 break 了,loop.last 仍然是序列真正最后一项才为 True,而不是 break 时的那一项:
jinja2
{% for item in items %}
{% if loop.index == 3 %}{% break %}{% endif %}
<p>{{ loop.last }}</p>
{% endfor %}
continue 与 loop 计数
continue 跳过当前迭代的剩余内容,但 loop.index 仍然正常递增:
jinja2
{% for item in items %}
{% if item | length > 10 %}
{% continue %}
{% endif %}
<p>第 {{ loop.index }} 项: {{ item }}</p>
{% endfor %}
为什么默认不启用?
Jinja2 默认不启用 break 和 continue,出于以下设计考虑:
- 鼓励数据预处理:把复杂逻辑放在视图函数,模板只负责展示。
- 保持模板可读性 :滥用
break/continue会让模板逻辑变复杂。 - 性能考量:简单的线性遍历更容易优化。
如果你确实需要,可以按上一节的方法启用扩展。
替代方案:用过滤器代替break
很多时候,break 的需求可以用过滤器的 select 或 reject 代替:
jinja2
{# 只取前5个 #}
{% for user in users[:5] %}
<li>{{ user.name }}</li>
{% endfor %}
{# 跳过被封禁的 #}
{% for user in users | rejectattr('is_banned') %}
<li>{{ user.name }}</li>
{% endfor %}
这种方式更"Jinja2 风格",不需要额外扩展。
3.6 循环与else({% for %}...{% else %})
Jinja2 的 for 循环支持一个特殊的 {% else %} 分支:当循环序列为空时,执行 else 块。这和 Python 的 for...else 语义一致(但注意 Python 的 for...else 还会在循环"正常结束"即没有 break 时执行 else,Jinja2 的 for...else 只在序列为空时执行)。
基本语法
jinja2
{% for item in items %}
<li>{{ item }}</li>
{% else %}
<li class="empty">暂无数据</li>
{% endfor %}
视图函数:
python
@app.route('/')
def index():
return render_template('index.html', items=[])
渲染结果:
html
<li class="empty">暂无数据</li>
如果 items = ['a', 'b']:
html
<li>a</li>
<li>b</li>
实用场景:空状态提示
在数据列表页,当没有数据时显示友好的空状态提示:
jinja2
<div class="post-list">
{% for post in posts %}
<article class="post">
<h3>{{ post.title }}</h3>
<p>{{ post.summary }}</p>
</article>
{% else %}
<div class="empty-state">
<img src="{{ url_for('static', filename='images/empty.svg') }}" alt="空">
<p>还没有文章,快去发布第一篇吧!</p>
<a href="{{ url_for('blog.create') }}" class="btn">写文章</a>
</div>
{% endfor %}
</div>
这种写法比在循环外用 {% if posts %} 判断更简洁。
注意:else 的触发条件
for...else 的 else 块只在序列为空时执行,不是"循环结束就执行"。即使序列有元素,else 也不会执行:
jinja2
{% for i in [1, 2, 3] %}
{{ i }}
{% else %}
这不会输出
{% endfor %}
渲染结果:
1 2 3
3.7 {% set %}变量赋值
{% set %} 用于在模板内定义变量或给变量赋值。这在需要临时计算、存储中间结果时很有用。
基本赋值
jinja2
{% set greeting = 'Hello, World!' %}
<p>{{ greeting }}</p>
赋值表达式
jinja2
{% set total = price * quantity %}
<p>总价: {{ total }} 元</p>
赋值为列表或字典
jinja2
{% set colors = ['red', 'green', 'blue'] %}
{% set user = {'name': 'Tom', 'age': 28} %}
<p>{{ colors[0] }}</p>
<p>{{ user.name }}</p>
set 的作用域问题
{% set %} 定义的变量在当前块(及其子块)内有效。但在 for 循环里 set 的变量,循环结束后就失效了:
jinja2
{% for item in items %}
{% set count = loop.index %}
{% endfor %}
<p>{{ count }}</p> {# 这里 count 是 undefined #}
如果你需要在循环外使用循环内计算的值,有几个变通方法:
方法1:用 namespace
Jinja2 2.10+ 提供了 namespace 对象,可以在循环内外共享状态:
jinja2
{% set ns = namespace(total=0) %}
{% for item in items %}
{% set ns.total = ns.total + item.price %}
{% endfor %}
<p>总价: {{ ns.total }}</p>
方法2:用 sum 过滤器(更简洁)
jinja2
<p>总价: {{ items | map(attribute='price') | sum }}</p>
方法3:在视图函数里计算
最推荐的方式还是把复杂计算放在视图函数:
python
@app.route('/')
def index():
items = [...]
total = sum(item['price'] for item in items)
return render_template('index.html', items=items, total=total)
3.8 {% with %}作用域
{% with %} 用于创建一个新的作用域,在其中定义的变量只在 with 块内有效。这和 {% set %} 类似,但 with 更明确地限定了作用域范围。
基本语法
jinja2
{% with greeting = 'Hello', name = 'Tom' %}
<p>{{ greeting }}, {{ name }}!</p>
{% endwith %}
{# 这里 greeting 和 name 都不可用 #}
为什么需要 with?
- 避免污染外部作用域:临时变量用完即弃,不影响模板其他部分。
- 提高可读性:明确标识"这段代码用到了这些临时变量"。
- 配合 set 使用:在 with 块内 set 的变量不会泄露到外部。
实用场景:简化长表达式
jinja2
{# 不用 with:重复写长表达式 #}
<h1>{{ user.profile.display_name or user.username }}</h1>
<p>邮箱: {{ user.profile.display_name or user.username }}的邮箱是 {{ user.email }}</p>
{# 用 with:提取到变量 #}
{% with display_name = user.profile.display_name or user.username %}
<h1>{{ display_name }}</h1>
<p>邮箱: {{ display_name }}的邮箱是 {{ user.email }}</p>
{% endwith %}
with 与 context 字典
{% with %} 还可以从一个字典批量设置变量:
jinja2
{% with %}
{% set vars = {'name': 'Tom', 'age': 28} %}
{# 这种写法不会自动展开字典... #}
{% endwith %}
不过更常见的是直接写多个赋值:
jinja2
{% with name = 'Tom', age = 28 %}
<p>{{ name }}, {{ age }} 岁</p>
{% endwith %}
with 的嵌套
with 可以嵌套,内层可以访问外层的变量:
jinja2
{% with a = 1 %}
{% with b = 2 %}
<p>{{ a }} + {{ b }} = {{ a + b }}</p> {# 1 + 2 = 3 #}
{% endwith %}
<p>{{ b }}</p> {# b 不可用 #}
{% endwith %}
3.9 {% block %}与{% extends %}继承
模板继承是 Jinja2 最强大的特性之一,它允许你定义一个基础模板(包含整体结构和占位符),然后子模板继承并填充占位符。这是第七章的主题,这里先做基本介绍。
基础模板 base.html
jinja2
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}默认标题{% endblock %}</title>
</head>
<body>
<header>网站头部</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>网站尾部</footer>
</body>
</html>
{% block name %} 定义了一个命名占位符,子模板可以覆写它。
子模板 index.html
jinja2
{% extends 'base.html' %}
{% block title %}首页{% endblock %}
{% block content %}
<h1>欢迎来到首页</h1>
<p>这是首页内容。</p>
{% endblock %}
{% extends 'base.html' %} 声明继承自 base.html,然后用同名 {% block %} 覆写占位符。
渲染结果:
html
<!DOCTYPE html>
<html>
<head>
<title>首页</title>
</head>
<body>
<header>网站头部</header>
<main>
<h1>欢迎来到首页</h1>
<p>这是首页内容。</p>
</main>
<footer>网站尾部</footer>
</body>
</html>
模板继承的详细讲解见第七章。
3.10 {% include %}包含其他模板
{% include %} 用于把另一个模板的内容"插入"到当前位置。这适合复用页面片段(如头部、尾部、侧边栏)。
基本用法
jinja2
<!DOCTYPE html>
<html>
<head><title>首页</title></head>
<body>
{% include 'partials/header.html' %}
<main>页面内容</main>
{% include 'partials/footer.html' %}
</body>
</html>
partials/header.html 的内容会被原样插入到 {% include %} 的位置。
传递变量
include 的模板可以访问当前上下文的所有变量:
jinja2
{# 主模板 #}
{% include 'user_card.html' %}
{# user_card.html 里可以直接用 user 变量 #}
你也可以用 with 传入额外变量:
jinja2
{% include 'user_card.html' with context %} {# 传递当前上下文(默认行为) #}
{% include 'user_card.html' without context %} {# 不传递当前上下文 #}
{% include 'user_card.html' with user=specific_user %} {# 传递指定变量 #}
忽略找不到的模板
默认情况下,如果 include 的模板不存在,会抛出异常。加 ignore missing 可以忽略:
jinja2
{% include 'optional_ads.html' ignore missing %}
这样即使 optional_ads.html 不存在,也不会报错,只是什么都不输出。
配合列表选择模板
include 的参数可以是变量,实现动态选择:
jinja2
{% include template_name %}
3.11 {% import %}与{% from import %}导入宏
宏(macro)是可复用的模板片段,类似 Python 的函数。当你定义了宏后,可以在其他模板中导入使用。{% import %} 和 {% from import %} 用于导入宏。
macros/forms.html:定义宏
jinja2
{% macro input(name, value='', type='text') %}
<input type="{{ type }}" name="{{ name }}" value="{{ value }}">
{% endmacro %}
{% macro label(text, for='') %}
<label for="{{ for }}">{{ text }}</label>
{% endmacro %}
在其他模板中导入
jinja2
{# 导入整个宏文件为一个命名空间 #}
{% import 'macros/forms.html' as forms %}
<form>
{{ forms.label('用户名', 'username') }}
{{ forms.input('username') }}
{{ forms.label('密码', 'password') }}
{{ forms.input('password', type='password') }}
</form>
from import:导入特定宏
jinja2
{% from 'macros/forms.html' import input, label %}
<form>
{{ label('用户名', 'username') }}
{{ input('username') }}
</form>
from import 可以直接使用宏名,不需要命名空间前缀。
import 的 context 问题
默认情况下,import 不会传递当前上下文变量给宏。如果宏需要访问当前上下文,加 with context:
jinja2
{% import 'macros/forms.html' as forms with context %}
宏的详细讲解见第六章。
3.12 控制结构综合实战
让我们用一个综合案例把本章学到的控制结构串联起来。场景:渲染一个用户列表表格,包含分页、状态筛选、隔行换色、空状态提示。
视图函数
python
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/users')
def user_list():
users = [
{'id': 1, 'name': 'Tom', 'age': 28, 'status': 'active', 'role': 'admin'},
{'id': 2, 'name': 'Jerry', 'age': 25, 'status': 'active', 'role': 'user'},
{'id': 3, 'name': 'Alice', 'age': 30, 'status': 'inactive', 'role': 'user'},
{'id': 4, 'name': 'Bob', 'age': 22, 'status': 'active', 'role': 'editor'},
{'id': 5, 'name': 'Carol', 'age': 35, 'status': 'banned', 'role': 'user'},
]
return render_template(
'users/list.html',
users=users,
page=1,
total_pages=3,
filter_status='all'
)
模板 users/list.html
jinja2
<!DOCTYPE html>
<html>
<head>
<title>用户列表</title>
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
tr.even { background: #f9f9f9; }
.status-active { color: green; }
.status-inactive { color: orange; }
.status-banned { color: red; }
.role-admin { font-weight: bold; }
.pagination { margin-top: 20px; }
.empty { text-align: center; padding: 40px; color: #999; }
</style>
</head>
<body>
<h1>用户列表</h1>
{# 使用 with 创建临时变量 #}
{% with active_count = users | selectattr('status', 'equalto', 'active') | list | length %}
<p>共 {{ users | length }} 位用户,其中 {{ active_count }} 位活跃。</p>
{% endwith %}
<table>
<thead>
<tr>
<th>#</th>
<th>姓名</th>
<th>年龄</th>
<th>状态</th>
<th>角色</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for user in users %}
{# 隔行换色 #}
<tr class="{{ loop.cycle('even', 'odd') }}">
<td>{{ loop.index }}</td>
<td>{{ user.name }}</td>
<td>{{ user.age }}</td>
<td>
{# 条件判断渲染状态 #}
<span class="status-{{ user.status }}">
{% if user.status == 'active' %}活跃
{% elif user.status == 'inactive' %}未激活
{% elif user.status == 'banned' %}已封禁
{% else %}未知
{% endif %}
</span>
</td>
<td>
<span class="{{ 'role-admin' if user.role == 'admin' else '' }}">
{{ user.role }}
</span>
</td>
<td>
{# 根据状态显示不同操作 #}
{% if user.status == 'banned' %}
<a href="/users/{{ user.id }}/unban">解封</a>
{% elif user.status == 'inactive' %}
<a href="/users/{{ user.id }}/activate">激活</a>
{% else %}
<a href="/users/{{ user.id }}/edit">编辑</a>
<a href="/users/{{ user.id }}/ban">封禁</a>
{% endif %}
</td>
</tr>
{% else %}
{# 空状态 #}
<tr>
<td colspan="6" class="empty">
暂无用户数据
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# 分页:使用 loop 和条件判断 #}
<div class="pagination">
{% if page > 1 %}
<a href="?page={{ page - 1 }}">上一页</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<strong>{{ p }}</strong>
{% else %}
<a href="?page={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?page={{ page + 1 }}">下一页</a>
{% endif %}
</div>
</body>
</html>
这个案例综合运用了:if/elif/else 条件判断、for 循环、for...else 空状态、loop.cycle 隔行换色、loop.index 序号、with 作用域、selectattr 过滤器等。通过这些控制结构的组合,我们用纯模板实现了一个功能完整的用户列表页。
控制结构的性能注意事项
虽然 Jinja2 的控制结构功能强大,但在模板中编写过于复杂的逻辑会影响渲染性能和可维护性。以下是几个需要注意的性能问题:
-
避免在模板中做大量数据计算 :模板的职责是"展示数据",而不是"处理数据"。如果需要在显示前对数据进行排序、分组、过滤等操作,应该尽量在视图函数中用 Python 完成,只把处理好的结果传给模板。模板中的
for循环和if判断虽然方便,但每次渲染都会执行,而视图函数中的处理只需要执行一次。 -
合理使用
loop变量 :loop对象在每次迭代时都会创建新的对象,对于超大列表(上万条数据),这会带来一定的内存开销。如果列表很大,考虑分页处理,每页只渲染几十条数据。 -
set和namespace的使用场景 :在循环中累加变量时,必须使用namespace而不是普通的set,因为 Jinja2 的set在循环体内有作用域限制。这个设计是为了避免循环变量"泄漏"到循环外部,但也是初学者经常遇到的陷阱。 -
条件判断的顺序 :在多个
elif分支中,将最可能匹配的条件放在前面,可以减少不必要的判断。这和 Python 中if-elif-else的优化原则一致。
控制结构与 Python 代码的边界
一个常见的问题是:模板中的逻辑应该写到什么程度?什么时候应该移到视图函数中?以下是一些指导原则:
| 场景 | 放在模板中 | 放在视图函数中 |
|---|---|---|
| 简单的条件显示(如根据状态显示不同文本) | 是 | 否 |
| 隔行换色、序号显示 | 是(用 loop 变量) | 否 |
| 数据排序、分组 | 否(用过滤器可以但性能差) | 是 |
| 复杂的数据聚合(如求和、平均值) | 否 | 是 |
| 跨多条数据的关联查询 | 否 | 是 |
| 日期格式化 | 是(用过滤器) | 也可以 |
| 权限判断(如是否显示编辑按钮) | 是(简单的) | 复杂的应该在视图函数处理 |
核心原则是:模板中只做与"展示"直接相关的简单逻辑,涉及数据处理、业务判断的复杂逻辑应该在视图函数中完成。这样既能保持模板的可读性,又能保证性能。
第四章 过滤器(Filters)
过滤器是 Jinja2 中用于转换数据的强大机制。通过管道符 |,你可以把一个值"传递"给过滤器,过滤器返回转换后的结果。过滤器可以链式调用,形成数据处理管道。本章将系统讲解所有内置过滤器和自定义方法。
4.1 过滤器概念与语法({{ variable | filter }})
基本语法
jinja2
{{ variable | filter_name }}
variable 的值会作为参数传给 filter_name 过滤器,过滤器返回处理后的结果。
示例
jinja2
{{ 'hello' | upper }} {# HELLO #}
{{ ' hello ' | trim }} {# hello #}
{{ 3.14159 | round(2) }} {# 3.14 #}
带参数的过滤器
有些过滤器接受额外参数,用括号传递:
jinja2
{{ 'hello world' | replace('world', 'flask') }} {# hello flask #}
{{ [1, 2, 3] | join('-') }} {# 1-2-3 #}
链式调用
过滤器可以串联,前一个的输出作为后一个的输入:
jinja2
{{ ' Hello World ' | trim | upper | replace('WORLD', 'FLASK') }}
{# HELLO FLASK #}
执行顺序从左到右:
' Hello World '-> trim ->'Hello World''Hello World'-> upper ->'HELLO WORLD''HELLO WORLD'-> replace ->'HELLO FLASK'
4.2 字符串过滤器
字符串过滤器用于处理文本,是最常用的一类。
大小写转换
| 过滤器 | 说明 | 示例 | 结果 |
|---|---|---|---|
upper |
转大写 | `{``{ 'hello' | upper }}` |
lower |
转小写 | `{``{ 'HELLO' | lower }}` |
title |
每个单词首字母大写 | `{``{ 'hello world' | title }}` |
capitalize |
首字母大写,其余小写 | `{``{ 'hello WORLD' | capitalize }}` |
空白处理
| 过滤器 | 说明 | 示例 | 结果 |
|---|---|---|---|
trim |
去除两端空白 | `{``{ ' hi ' | trim }}` |
striptags |
去除HTML标签 | `{``{ 'hi' | striptags }}` |
内容截断
jinja2
{# truncate: 截断字符串,默认255字符 #}
{{ '这是一段很长的文字内容' | truncate(5) }}
{# 这是一段... #}
{# truncate 的 killwords 参数(默认False,不截断单词) #}
{{ 'Hello World Foo Bar' | truncate(10, killwords=True) }}
{# Hello Worl... #}
{# truncate 的 leeway 参数(允许超出多少字符才截断) #}
{{ 'Hello World' | truncate(10, leeway=2) }}
{# Hello World (不截断,因为只超出0字符) #}
{{ 'Hello World!' | truncate(10, leeway=2) }}
{# Hello World! (不截断,只超出1字符,在leeway内) #}
{{ 'Hello World!!' | truncate(10, leeway=2) }}
{# Hello Worl... (超出2字符,等于leeway,还是截断) #}
wordcount:单词计数
jinja2
{{ 'hello world foo bar' | wordcount }} {# 4 #}
wordwrap:换行
jinja2
{{ 'Lorem ipsum dolor sit amet' | wordwrap(10) }}
{#
Lorem ipsum
dolor sit
amet
#}
replace:替换
jinja2
{{ 'hello world' | replace('world', 'flask') }} {# hello flask #}
{{ 'aaa' | replace('a', 'b', 2) }} {# bba,只替换前2个 #}
escape / e:HTML转义
jinja2
{{ '<script>alert("xss")</script>' | escape }}
{# <script>alert("xss")</script> #}
{# e 是 escape 的简写 #}
{{ '<b>bold</b>' | e }}
{# <b>bold</b> #}
safe:标记为安全(不转义)
jinja2
{{ '<b>bold</b>' | safe }}
{# <b>bold</b> #}
center / ljust / rjust:对齐
jinja2
{{ 'hi' | center(10) }} {# hi (居中,共10字符) #}
{{ 'hi' | ljust(10) }} {# hi (左对齐) #}
{{ 'hi' | rjust(10) }} {# hi (右对齐) #}
4.3 列表过滤器
列表过滤器用于处理序列类型(列表、元组、生成器等)。
基本操作
| 过滤器 | 说明 | 示例 | 结果 |
|---|---|---|---|
first |
取第一个元素 | `{``{ [1,2,3] | first }}` |
last |
取最后一个元素 | `{``{ [1,2,3] | last }}` |
length |
取长度 | `{``{ [1,2,3] | length }}` |
reverse |
反转 | `{``{ [1,2,3] | reverse |
注意:reverse 返回的是生成器,需要用 list 转成列表才能正确显示。
统计
jinja2
{{ [1, 2, 3, 4, 5] | sum }} {# 15 #}
{{ [3, 1, 4, 1, 5] | min }} {# 1 #}
{{ [3, 1, 4, 1, 5] | max }} {# 5 #}
排序
jinja2
{# sort:升序排序 #}
{{ [3, 1, 4, 1, 5] | sort }} {# [1, 1, 3, 4, 5] #}
{# sort 的 reverse 参数 #}
{{ [3, 1, 4, 1, 5] | sort(reverse=True) }} {# [5, 4, 3, 1, 1] #}
{# 对象列表按属性排序 #}
{{ users | sort(attribute='age') }}
{{ users | sort(attribute='age', reverse=True) }}
unique:去重
jinja2
{{ [1, 2, 2, 3, 3, 3] | unique | list }} {# [1, 2, 3] #}
join:拼接
jinja2
{{ ['a', 'b', 'c'] | join }} {# abc #}
{{ ['a', 'b', 'c'] | join('-') }} {# a-b-c #}
{{ ['a', 'b', 'c'] | join('、') }} {# a、b、c #}
map:提取属性或应用过滤器
jinja2
{# 提取对象列表的某个属性 #}
{{ users | map(attribute='name') | list }}
{# ['Tom', 'Jerry', 'Alice'] #}
{# 对每个元素应用过滤器 #}
{{ ['hello', 'world'] | map('upper') | list }}
{# ['HELLO', 'WORLD'] #}
select / reject:筛选
jinja2
{# select:保留真值 #}
{{ [1, 0, 2, None, 3, '', 4] | select | list }}
{# [1, 2, 3, 4] #}
{# reject:拒绝真值(保留假值) #}
{{ [1, 0, 2, None, 3] | reject | list }}
{# [0, None] #}
{# selectattr / rejectattr:按属性筛选 #}
{{ users | selectattr('is_active') | list }} {# 保留 is_active 为真的 #}
{{ users | rejectattr('is_banned') | list }} {# 拒绝 is_banned 为真的 #}
{# 带测试器的 selectattr #}
{{ users | selectattr('age', 'gt', 25) | list }} {# age > 25 的 #}
{{ users | selectattr('status', 'equalto', 'active') | list }}
groupby:分组
jinja2
{# 按属性分组 #}
{% for group in users | groupby('department') %}
<h3>{{ group.grouper }} ({{ group.list | length }}人)</h3>
<ul>
{% for user in group.list %}
<li>{{ user.name }}</li>
{% endfor %}
</ul>
{% endfor %}
视图函数:
python
users = [
{'name': 'Tom', 'department': '技术部'},
{'name': 'Jerry', 'department': '技术部'},
{'name': 'Alice', 'department': '市场部'},
{'name': 'Bob', 'department': '市场部'},
{'name': 'Carol', 'department': '技术部'},
]
渲染结果:
html
<h3>技术部 (3人)</h3>
<ul>
<li>Tom</li>
<li>Jerry</li>
<li>Carol</li>
</ul>
<h3>市场部 (2人)</h3>
<ul>
<li>Alice</li>
<li>Bob</li>
</ul>
groupby 返回的每个元素是一个 tuple(grouper, list),可以用解包语法:
jinja2
{% for department, members in users | groupby('department') %}
<h3>{{ department }}</h3>
...
{% endfor %}
4.4 数字过滤器
jinja2
{# round:四舍五入 #}
{{ 3.14159 | round }} {# 3.0 #}
{{ 3.14159 | round(2) }} {# 3.14 #}
{{ 3.5 | round(0, 'floor') }} {# 3.0 (向下取整) #}
{{ 3.5 | round(0, 'ceil') }} {# 4.0 (向上取整) #}
{# abs:绝对值 #}
{{ -5 | abs }} {# 5 #}
{{ 5 | abs }} {# 5 #}
{# 类型转换 #}
{{ '42' | int }} {# 42 #}
{{ '3.14' | float }} {# 3.14 #}
{{ 42 | string }} {# '42' #}
{{ 'hello' | list }} {# ['h', 'e', 'l', 'l', 'o'] #}
{# 带默认值的类型转换 #}
{{ 'abc' | int(0) }} {# 0 (转换失败返回默认值) #}
{{ 'abc' | int }} {# 0 (默认) #}
{{ '' | int(-1) }} {# -1 #}
4.5 默认值过滤器(default, d)
default(简写 d)过滤器用于在变量未定义或为假值时提供默认值。
基本用法
jinja2
{{ user.nickname | default('匿名用户') }}
{# 如果 user.nickname 未定义,输出"匿名用户" #}
default 与 boolean 参数
默认情况下,default 只在变量未定义 时生效。如果变量已定义但为假值(如空字符串、None、False),default 不会替换。加 true 参数可以让它对假值也生效:
jinja2
{# user.nickname = '' (空字符串) #}
{{ user.nickname | default('匿名') }} {# 输出空字符串(因为已定义) #}
{{ user.nickname | default('匿名', true) }} {# 输出"匿名"(因为空字符串是假值) #}
d 简写
jinja2
{{ user.age | d(18) }} {# 等价于 default(18) #}
{{ user.age | d(18, true) }} {# 对假值也生效 #}
实用场景
jinja2
{# 1. 用户昵称默认值 #}
<p>欢迎,{{ user.nickname | default(user.username) }}!</p>
{# 2. 文章摘要默认值 #}
<p>{{ post.summary | default(post.content[:100] ~ '...') }}</p>
{# 3. 分页参数默认值 #}
{{ request.args.get('page') | default(1) | int }}
4.6 日期时间过滤器
Jinja2 本身没有内置强大的日期过滤器,但 Flask 提供了基础支持,结合 moment 等库可以实现丰富功能。
基本用法
Flask 的 Jinja2 环境中,datetime 对象可以直接输出,但格式不友好。通常需要自定义过滤器:
python
from datetime import datetime
@app.template_filter('datetime')
def format_datetime(value, format='%Y-%m-%d %H:%M:%S'):
if isinstance(value, datetime):
return value.strftime(format)
return value
模板中使用:
jinja2
{{ post.created_at | datetime }} {# 2024-01-15 14:30:00 #}
{{ post.created_at | datetime('%Y年%m月%d日') }} {# 2024年01月15日 #}
{{ post.created_at | datetime('%H:%M') }} {# 14:30 #}
时间戳转换
python
@app.template_filter('timestamp')
def format_timestamp(value, format='%Y-%m-%d'):
return datetime.fromtimestamp(value).strftime(format)
jinja2
{{ 1705305600 | timestamp }} {# 2024-01-15 #}
相对时间(多久之前)
python
@app.template_filter('timeago')
def timeago(value):
if not isinstance(value, datetime):
return value
now = datetime.now()
diff = now - value
seconds = diff.total_seconds()
if seconds < 60:
return '刚刚'
elif seconds < 3600:
return f'{int(seconds / 60)}分钟前'
elif seconds < 86400:
return f'{int(seconds / 3600)}小时前'
elif seconds < 604800:
return f'{int(seconds / 86400)}天前'
else:
return value.strftime('%Y-%m-%d')
jinja2
{{ post.created_at | timeago }} {# 3小时前 #}
使用 Flask-Moment 处理日期
对于需要客户端动态更新的时间(如"3分钟前"会随时间变化),可以用 Flask-Moment:
python
from flask_moment import Moment
app = Flask(__name__)
Moment(app)
jinja2
{% extends 'base.html' %}
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{% endblock %}
{% block content %}
<p>发布时间: {{ moment(post.created_at).format('YYYY-MM-DD HH:mm:ss') }}</p>
<p>相对时间: {{ moment(post.created_at).fromNow() }}</p>
{% endblock %}
4.7 JSON过滤器(tojson)
tojson 是 Flask 特有的过滤器(标准 Jinja2 没有这个),用于把 Python 对象转为 JSON 字符串。这在向前端传递数据时非常有用。
基本用法
jinja2
<script>
var users = {{ users | tojson }};
console.log(users);
</script>
视图函数:
python
@app.route('/')
def index():
users = [
{'id': 1, 'name': 'Tom'},
{'id': 2, 'name': 'Jerry'},
]
return render_template('index.html', users=users)
渲染结果:
html
<script>
var users = [{"id": 1, "name": "Tom"}, {"id": 2, "name": "Jerry"}];
console.log(users);
</script>
安全说明
tojson 默认会处理 <、>、& 等字符,防止 XSS。它返回的是 Markup 对象,所以在 {``{ }} 中不会被再次转义。
缩进参数
jinja2
<pre>{{ data | tojson(indent=2) }}</pre>
渲染结果:
html
<pre>{
"name": "Tom",
"age": 28
}</pre>
传递配置给前端
jinja2
<script>
window.APP_CONFIG = {{ config | tojson }};
</script>
注意:不要传递敏感配置!
4.8 链式过滤器使用
过滤器的真正威力在于链式调用。通过组合多个过滤器,你可以在模板里完成复杂的数据转换。
示例1:用户名格式化
jinja2
{# 首字母大写 + 去除空白 + 默认值 #}
{{ user_input | default('guest') | trim | capitalize }}
执行步骤:
user_input-> default ->'guest'(如果未定义)'guest'-> trim ->'guest'(去空白)'guest'-> capitalize ->'Guest'
示例2:价格格式化
jinja2
{# 转浮点 + 保留2位小数 #}
{{ price | float | round(2) }}
示例3:文章摘要
jinja2
{# 去HTML标签 + 截断 + 默认值 #}
{{ post.content | striptags | truncate(100) | default('暂无内容') }}
示例4:标签云
jinja2
{# 提取所有标签 + 去重 + 排序 + 拼接 #}
{{ posts | map(attribute='tags') | flatten | unique | sort | join(', ') }}
示例5:统计活跃用户数
jinja2
{# 筛选活跃用户 + 计数 #}
{{ users | selectattr('is_active') | list | length }}
链式过滤器的执行顺序与原理
理解链式过滤器的执行顺序对于编写正确的模板代码非常重要。链式过滤器从左到右执行,前一个过滤器的输出作为下一个过滤器的输入。这类似于 Unix 管道(cat file | grep pattern | sort),数据从左向右"流过"每个过滤器。
jinja2
{# 执行顺序:value -> filter_a -> filter_b -> filter_c -> 输出 #}
{{ value | filter_a | filter_b | filter_c }}
链式过滤器的常见模式
模式一:数据清洗管道。先处理空值,再转换类型,最后格式化输出:
jinja2
{# 用户输入清洗:默认值 -> 去空白 -> 转小写 -> 截断 #}
{{ user_input | default('') | trim | lower | truncate(50, True) }}
{# 数字清洗:默认值 -> 转浮点 -> 四舍五入 #}
{{ price_str | default('0') | float | round(2) }}
{# 日期清洗:默认值 -> 转日期 -> 格式化 #}
{{ date_str | default('') | to_datetime | date_format('%Y年%m月%d日') }}
模式二:列表处理管道。先过滤,再排序,最后格式化:
jinja2
{# 产品列表:筛选有库存 -> 按价格排序 -> 取前10个 #}
{% for product in products | selectattr('in_stock') | sort(attribute='price') | list | first(10) %}
<li>{{ product.name }}: {{ product.price | money }}</li>
{% endfor %}
{# 用户列表:筛选活跃用户 -> 按注册时间排序 -> 提取用户名 #}
{% set active_usernames = users
| selectattr('is_active')
| sort(attribute='created_at', reverse=True)
| map(attribute='username')
| list %}
模式三:安全输出管道。先清洗,再转义,最后标记安全:
jinja2
{# 富文本:去除危险标签 -> 标记为安全 #}
{{ user_content | clean_html | safe }}
{# 用户名:转义 -> 包裹在标签中 -> 标记安全 #}
{{ ('<strong>' ~ (user.name | escape) ~ '</strong>') | safe }}
链式过滤器的注意事项
-
类型兼容性 :确保前一个过滤器的输出类型与下一个过滤器的输入类型兼容。例如,
selectattr返回的是生成器,如果后面要使用length,需要先转为列表(| list)。 -
性能考量:链式过滤器虽然方便,但每一步都会遍历一次数据。对于大型列表,考虑在视图函数中用 Python 处理,效率更高。
-
可读性 :过长的链式过滤器会降低可读性。如果链式超过 4-5 个过滤器,考虑拆分为多行或使用
set变量:
jinja2
{# 不推荐:一行太长,可读性差 #}
{{ posts | map(attribute='tags') | flatten | unique | sort | join(', ') | upper }}
{# 推荐:拆分为多行 #}
{% set tags = posts | map(attribute='tags') | flatten | unique | sort | join(', ') %}
{{ tags | upper }}
4.9 自定义过滤器(@app.template_filter)
Jinja2 的过滤器完全可以自定义。在 Flask 中,有几种方式注册自定义过滤器。
方式1:@app.template_filter 装饰器
python
@app.template_filter('reverse_str')
def reverse_filter(s):
return s[::-1]
jinja2
{{ 'hello' | reverse_str }} {# olleh #}
装饰器的参数是过滤器名(在模板中使用的名字),不传则用函数名。
方式2:app.jinja_env.filters 字典
python
def reverse_filter(s):
return s[::-1]
app.jinja_env.filters['reverse_str'] = reverse_filter
方式3:app.add_template_filter 方法
python
def reverse_filter(s):
return s[::-1]
app.add_template_filter(reverse_filter, 'reverse_str')
带参数的自定义过滤器
python
@app.template_filter('truncate_words')
def truncate_words(s, num=20, suffix='...'):
"""截断为指定单词数"""
words = s.split()
if len(words) <= num:
return s
return ' '.join(words[:num]) + suffix
jinja2
{{ article_content | truncate_words(30) }}
{{ article_content | truncate_words(30, '...[更多]') }}
实用的自定义过滤器集合
下面是一些常用的自定义过滤器,可以直接放到项目里:
python
from datetime import datetime
import re
@app.template_filter('format_date')
def format_date(value, format='%Y-%m-%d'):
"""格式化日期"""
if isinstance(value, datetime):
return value.strftime(format)
return value
@app.template_filter('money')
def money_filter(value, symbol='¥'):
"""金额格式化: 1234.5 -> ¥1,234.50"""
try:
return f'{symbol}{float(value):,.2f}'
except (ValueError, TypeError):
return value
@app.template_filter('nl2br')
def nl2br_filter(value):
"""换行符转<br>"""
if not value:
return ''
from markupsafe import Markup, escape
return Markup(escape(value).replace('\n', '<br>'))
@app.template_filter('truncate_chars')
def truncate_chars(s, length=100, suffix='...'):
"""按字符数截断(中文友好)"""
if len(s) <= length:
return s
return s[:length] + suffix
@app.template_filter('phone_mask')
def phone_mask(phone):
"""手机号脱敏: 13812345678 -> 138****5678"""
phone = str(phone)
if len(phone) == 11:
return phone[:3] + '****' + phone[-4:]
return phone
@app.template_filter('markdown')
def markdown_filter(text):
"""Markdown转HTML"""
import markdown
return markdown.markdown(text, extensions=['extra', 'codehilite'])
模板中使用:
jinja2
{{ product.price | money }} {# ¥1,234.50 #}
{{ user.phone | phone_mask }} {# 138****5678 #}
{{ post.content | markdown | safe }} {# Markdown渲染 #}
4.10 过滤器实战案例集
案例1:商品列表价格区间筛选与排序
python
@app.route('/products')
def products():
products = [
{'name': '商品A', 'price': 99.9, 'category': '电子', 'stock': 10},
{'name': '商品B', 'price': 199.0, 'category': '服装', 'stock': 0},
{'name': '商品C', 'price': 49.9, 'category': '电子', 'stock': 5},
{'name': '商品D', 'price': 299.0, 'category': '食品', 'stock': 20},
{'name': '商品E', 'price': 19.9, 'category': '电子', 'stock': 50},
]
return render_template('products.html', products=products)
jinja2
<div class="products">
{# 统计 #}
<p>
共 {{ products | length }} 件商品,
最低价 {{ products | map(attribute='price') | min | money }},
最高价 {{ products | map(attribute='price') | max | money }},
均价 {{ (products | map(attribute='price') | sum) / (products | length) | round(2) | money }}
</p>
{# 按价格排序 #}
<h3>按价格排序</h3>
{% for product in products | sort(attribute='price') %}
<p>{{ product.name }}: {{ product.price | money }}</p>
{% endfor %}
{# 按分类分组 #}
<h3>按分类分组</h3>
{% for category, items in products | groupby('category') %}
<h4>{{ category }} ({{ items | length }}件)</h4>
<ul>
{% for item in items %}
<li>{{ item.name }} - {{ item.price | money }}</li>
{% endfor %}
</ul>
{% endfor %}
{# 有库存的商品 #}
<h3>有库存的商品</h3>
{% for product in products | selectattr('stock') %}
<p>{{ product.name }} (库存:{{ product.stock }})</p>
{% else %}
<p>暂无有库存的商品</p>
{% endfor %}
</div>
案例2:文章列表标签处理
jinja2
{# 提取所有文章的标签并去重 #}
{% set all_tags = posts | map(attribute='tags') | flatten | unique | list | sort %}
<div class="tag-cloud">
{% for tag in all_tags %}
{% set count = posts | selectattr('tags', 'containing', tag) | list | length %}
<a href="/tags/{{ tag }}" class="tag tag-{{ 'large' if count > 5 else 'small' }}">
{{ tag }} ({{ count }})
</a>
{% endfor %}
</div>
案例3:数据表格的格式化
jinja2
<table class="data-table">
<tr>
<th>用户</th>
<th>手机</th>
<th>注册时间</th>
<th>余额</th>
<th>状态</th>
</tr>
{% for user in users %}
<tr>
<td>{{ user.name | default('未设置') | capitalize }}</td>
<td>{{ user.phone | phone_mask }}</td>
<td>{{ user.created_at | format_date('%Y-%m-%d') }}</td>
<td>{{ user.balance | money }}</td>
<td>
<span class="badge badge-{{ 'success' if user.is_active else 'danger' }}">
{{ '活跃' if user.is_active else '禁用' }}
</span>
</td>
</tr>
{% endfor %}
</table>
过滤器设计模式与最佳实践
在使用和设计过滤器时,遵循一些最佳实践可以让模板代码更加健壮和可维护。
1. 过滤器的职责分离
每个过滤器应该只做一件事,并且做好这件事。不要设计一个"万能过滤器"试图处理多种不同的转换。例如,不要写一个 format 过滤器既处理日期又处理数字,而是分别设计 format_date 和 format_number:
python
# 不好的设计:职责混乱
@app.template_filter('format')
def format_value(value, fmt):
if isinstance(value, datetime):
return value.strftime(fmt)
elif isinstance(value, (int, float)):
return format(value, fmt)
return str(value)
# 好的设计:职责分离
@app.template_filter('datetime_format')
def datetime_format(value, fmt='%Y-%m-%d %H:%M'):
if value is None:
return ''
if isinstance(value, str):
value = datetime.fromisoformat(value)
return value.strftime(fmt)
@app.template_filter('number_format')
def number_format(value, decimals=2):
if value is None:
return '0'
return f'{value:,.{decimals}f}'
2. 过滤器的容错处理
好的过滤器应该能够处理各种异常输入,而不是在遇到 None、空字符串或类型不符时直接报错:
python
@app.template_filter('truncate_words')
def truncate_words(text, length=50, suffix='...'):
"""截断文本到指定字数"""
if text is None:
return ''
if not isinstance(text, str):
text = str(text)
if len(text) <= length:
return text
return text[:length].rstrip() + suffix
这个过滤器处理了 None 输入(返回空字符串)、非字符串输入(自动转换)、短文本(不截断)等各种情况,确保在任何输入下都不会报错。
3. 过滤器的可测试性
过滤器本质上是 Python 函数,应该可以独立测试。在编写自定义过滤器时,确保它们不依赖 Flask 应用上下文(除非确实需要访问 request 或 session),这样可以在单元测试中直接调用:
python
# 可以独立测试的过滤器
def money_format(value, symbol='¥'):
if value is None:
return f'{symbol}0.00'
return f'{symbol}{value:,.2f}'
# 注册为模板过滤器
app.template_filter('money')(money_format)
# 单元测试
def test_money_format():
assert money_format(1234.5) == '¥1,234.50'
assert money_format(None) == '¥0.00'
assert money_format(0) == '¥0.00'
assert money_format(-100.5, symbol='$') == '$-100.50'
4. 过滤器组织与模块化
当项目积累了大量自定义过滤器时,应该将它们组织到独立的模块中,而不是全部堆在 app.py 里:
python
# utils/filters.py
from datetime import datetime
def register_filters(app):
"""注册所有自定义过滤器"""
@app.template_filter('datetime_format')
def datetime_format(value, fmt='%Y-%m-%d %H:%M'):
if value is None:
return ''
if isinstance(value, str):
value = datetime.fromisoformat(value)
return value.strftime(fmt)
@app.template_filter('money')
def money_format(value, symbol='¥'):
if value is None:
return f'{symbol}0.00'
return f'{symbol}{value:,.2f}'
@app.template_filter('phone_mask')
def phone_mask(phone):
if not phone or len(phone) < 7:
return phone or ''
return phone[:3] + '****' + phone[-4:]
# ... 更多过滤器
# app.py
from utils.filters import register_filters
app = Flask(__name__)
register_filters(app)
这种模块化组织方式让过滤器代码与业务逻辑代码分离,便于维护和复用。如果多个 Flask 项目共享相同的过滤器,还可以将其封装为独立的 Python 包。
第五章 测试器(Tests)
测试器(Tests)是 Jinja2 中用于判断数据特征的特殊函数。与过滤器(转换数据)不同,测试器返回布尔值,主要用于 {% if %} 条件判断中。本章将系统讲解内置测试器和自定义方法。
5.1 测试器概念与语法({% if variable is test %})
基本语法
jinja2
{% if variable is test_name %}
...
{% endif %}
is 关键字后面跟测试器名称。测试器对 variable 进行判断,返回 True 或 False。
示例
jinja2
{% if name is defined %}
<p>名字是:{{ name }}</p>
{% else %}
<p>名字未定义</p>
{% endif %}
带参数的测试器
jinja2
{% if age is equalto 18 %}
<p>刚成年</p>
{% endif %}
否定测试:not
jinja2
{% if name is not defined %}
<p>请输入名字</p>
{% endif %}
测试器 vs 过滤器
| 特性 | 测试器 | 过滤器 |
|---|---|---|
| 语法 | var is test |
`var |
| 返回值 | 布尔值 | 转换后的值 |
| 主要用途 | 条件判断 | 数据转换 |
| 可链式 | 否 | 是 |
5.2 内置测试器
Jinja2 内置了丰富的测试器,覆盖了类型判断、存在性判断等常见场景。
存在性测试器
| 测试器 | 说明 | 示例 |
|---|---|---|
defined |
变量是否已定义 | {% if x is defined %} |
undefined |
变量是否未定义 | {% if x is undefined %} |
none |
变量是否为 None | {% if x is none %} |
jinja2
{% if user is defined %}
<p>{{ user.name }}</p>
{% endif %}
{% if user is not none %}
<p>用户存在</p>
{% endif %}
类型测试器
| 测试器 | 说明 | 示例 |
|---|---|---|
string |
是否为字符串 | {% if x is string %} |
number |
是否为数字(int/float) | {% if x is number %} |
integer |
是否为整数 | {% if x is integer %} |
float |
是否为浮点数 | {% if x is float %} |
boolean |
是否为布尔值 | {% if x is boolean %} |
sequence |
是否为序列 | {% if x is sequence %} |
mapping |
是否为字典/映射 | {% if x is mapping %} |
iterable |
是否可迭代 | {% if x is iterable %} |
jinja2
{% if data is string %}
<p>{{ data }}</p>
{% elif data is sequence %}
<ul>
{% for item in data %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% elif data is mapping %}
<dl>
{% for key, value in data.items() %}
<dt>{{ key }}</dt><dd>{{ value }}</dd>
{% endfor %}
</dl>
{% endif %}
5.3 比较测试器
Jinja2 提供了比较测试器,虽然用运算符 ==、> 也能实现,但测试器在某些场景更灵活。
| 测试器 | 运算符等价 | 说明 |
|---|---|---|
eq / equalto |
== |
等于 |
ne |
!= |
不等于 |
lt / lessthan |
< |
小于 |
le |
<= |
小于等于 |
gt / greaterthan |
> |
大于 |
ge |
>= |
大于等于 |
jinja2
{% if age is equalto 18 %} {# age == 18 #}
{% if age is gt 18 %} {# age > 18 #}
{% if age is ge 18 %} {# age >= 18 #}
{% if age is lt 18 %} {# age < 18 #}
{% if age is le 18 %} {# age <= 18 #}
{% if age is ne 18 %} {# age != 18 #}
在 selectattr 中使用比较测试器
jinja2
{# 筛选年龄大于25的用户 #}
{{ users | selectattr('age', 'gt', 25) | list }}
{# 筛选状态为活跃的用户 #}
{{ users | selectattr('status', 'equalto', 'active') | list }}
这是比较测试器最常见的用途,比写 lambda 更简洁。
5.4 字符串测试器
| 测试器 | 说明 | 示例 |
|---|---|---|
lower |
是否全小写 | {% if s is lower %} |
upper |
是否全大写 | {% if s is upper %} |
startingwith |
是否以...开头 | {% if s is startingwith('http') %} |
endingwith |
是否以...结尾 | {% if s is endingwith('.com') %} |
jinja2
{% if url is startingwith('https') %}
<span class="secure">安全链接</span>
{% else %}
<span class="insecure">不安全链接</span>
{% endif %}
{% if filename is endingwith('.jpg') or filename is endingwith('.png') %}
<img src="{{ filename }}">
{% endif %}
5.5 容器测试器
| 测试器 | 说明 | 示例 |
|---|---|---|
in |
是否在容器中 | {% if item is in items %} |
empty |
是否为空 | {% if items is empty %} |
in 测试器
jinja2
{% if 'admin' is in user.roles %}
<a href="/admin">管理后台</a>
{% endif %}
{% if user.username is not in banned_list %}
<p>账号正常</p>
{% endif %}
注意:in 测试器的语法是 value is in container,和 Python 的 value in container 略有不同。
empty 测试器
jinja2
{% if cart is empty %}
<p>购物车是空的</p>
{% else %}
<p>购物车有 {{ cart | length }} 件商品</p>
{% endif %}
{% if user.bio is empty %}
<p class="text-muted">这个用户还没有填写简介</p>
{% endif %}
empty 对空字符串、空列表、空字典、None 都返回 True。
5.6 自定义测试器(@app.template_test)
和过滤器一样,测试器也可以自定义。在 Flask 中有三种注册方式。
方式1:@app.template_test 装饰器
python
@app.template_test('is_adult')
def is_adult(age):
return age >= 18
jinja2
{% if user.age is is_adult %}
<p>已成年</p>
{% else %}
<p>未成年</p>
{% endif %}
方式2:app.jinja_env.tests 字典
python
def is_adult(age):
return age >= 18
app.jinja_env.tests['is_adult'] = is_adult
方式3:app.add_template_test 方法
python
app.add_template_test(is_adult, 'is_adult')
带参数的自定义测试器
python
@app.template_test('of_type')
def of_type(value, type_name):
"""检查值的类型"""
type_map = {
'str': str,
'int': int,
'float': float,
'list': list,
'dict': dict,
'bool': bool,
}
return isinstance(value, type_map.get(type_name, object))
jinja2
{% if data is of_type('list') %}
<p>这是一个列表</p>
{% elif data is of_type('dict') %}
<p>这是一个字典</p>
{% endif %}
实用的自定义测试器集合
python
import re
@app.template_test('email')
def is_email(value):
"""是否为有效邮箱格式"""
if not isinstance(value, str):
return False
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, value))
@app.template_test('mobile')
def is_mobile(value):
"""是否为手机号"""
if not isinstance(value, str):
return False
return bool(re.match(r'^1[3-9]\d{9}$', value))
@app.template_test('url')
def is_url(value):
"""是否为URL"""
if not isinstance(value, str):
return False
return value.startswith(('http://', 'https://'))
@app.template_test('weekend')
def is_weekend(date):
"""是否为周末"""
if hasattr(date, 'weekday'):
return date.weekday() >= 5 # 5=周六, 6=周日
return False
jinja2
{% if user.email is email %}
<span class="valid">邮箱格式正确</span>
{% endif %}
{% if today is weekend %}
<p>今天是周末,好好休息!</p>
{% endif %}
5.7 测试器实战案例
案例:表单验证结果显示
jinja2
{% if form_data is defined %}
<div class="validation-results">
<h3>验证结果</h3>
{# 用户名验证 #}
<p>
用户名:
{% if form_data.username is defined and form_data.username is not empty %}
{% if form_data.username | length >= 3 %}
<span class="ok">有效</span>
{% else %}
<span class="error">至少3个字符</span>
{% endif %}
{% else %}
<span class="error">必填</span>
{% endif %}
</p>
{# 邮箱验证 #}
<p>
邮箱:
{% if form_data.email is email %}
<span class="ok">格式正确</span>
{% else %}
<span class="error">邮箱格式不正确</span>
{% endif %}
</p>
{# 手机号验证 #}
<p>
手机:
{% if form_data.phone is mobile %}
<span class="ok">格式正确</span>
{% else %}
<span class="error">手机号格式不正确</span>
{% endif %}
</p>
{# 年龄验证 #}
<p>
年龄:
{% if form_data.age is integer and form_data.age is gt 0 and form_data.age is lt 150 %}
<span class="ok">有效</span>
{% else %}
<span class="error">请输入1-149之间的整数</span>
{% endif %}
</p>
{# 标签验证(列表非空) #}
<p>
标签:
{% if form_data.tags is iterable and form_data.tags is not empty %}
<span class="ok">{{ form_data.tags | length }}个标签</span>
{% else %}
<span class="error">至少选择一个标签</span>
{% endif %}
</p>
</div>
{% endif %}
这个案例综合运用了多种测试器:defined、empty、email、mobile、integer、gt、lt、iterable 等,展示了测试器在表单验证场景下的实际应用。
案例:数据展示层条件渲染
在实际开发中,我们经常需要根据数据的类型和特征来决定如何渲染。测试器在这种场景下非常有用。下面是一个商品列表的渲染示例,它根据商品的不同属性来决定展示方式:
jinja2
{% for product in products %}
<div class="product-card">
{# 根据是否有图片决定布局 #}
{% if product.image is defined and product.image is not empty %}
<img src="{{ product.image }}" alt="{{ product.name }}">
{% else %}
<div class="no-image">暂无图片</div>
{% endif %}
<h3>{{ product.name }}</h3>
{# 价格显示:区分整数和小数 #}
<p class="price">
{% if product.price is number %}
{% if product.price is integer %}
¥{{ product.price }}.00
{% else %}
¥{{ "%.2f" | format(product.price) }}
{% endif %}
{% else %}
价格待定
{% endif %}
</p>
{# 标签渲染:确保是可迭代对象 #}
{% if product.tags is iterable and product.tags is not empty %}
<div class="tags">
{% for tag in product.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
{# 库存状态 #}
{% if product.stock is defined %}
{% if product.stock is gt 0 %}
<span class="in-stock">有货</span>
{% if product.stock is lt 10 %}
<span class="low-stock">仅剩{{ product.stock }}件</span>
{% endif %}
{% else %}
<span class="out-stock">缺货</span>
{% endif %}
{% endif %}
{# 评分显示 #}
{% if product.rating is number and product.rating is gt 0 %}
<div class="rating">
评分:{{ product.rating | round(1) }} / 5.0
</div>
{% endif %}
</div>
{% else %}
<p class="empty">暂无商品数据</p>
{% endfor %}
这个案例展示了测试器在真实业务场景中的综合运用:通过 defined 检查字段是否存在,通过 number/integer 区分数据类型,通过 iterable 确保可遍历,通过 gt/lt 做数值范围判断。这些测试器组合使用,能够让模板逻辑更加健壮,避免因数据缺失或类型不符导致的渲染错误。
测试器使用最佳实践
在实际项目中使用测试器时,有以下几个最佳实践值得遵循:
-
优先使用测试器而非过滤器做判断 :在
{% if %}条件中,使用is defined比使用| length > 0更语义化,代码可读性更强。测试器的设计初衷就是用于条件判断,而过滤器用于数据转换,各司其职才能让模板代码更加清晰。 -
注意测试器的短路特性 :Jinja2 的
and/or运算符具有短路特性。在多个测试器组合使用时,将最可能失败的条件放在前面,可以提前终止判断,提高模板渲染效率。例如{% if user is defined and user.email is email %}中,如果user未定义,后面的邮箱测试不会执行。 -
自定义测试器要纯函数:自定义测试器应该是纯函数,即不依赖外部状态、不产生副作用。测试器只负责判断,不应该修改数据。如果需要在模板中修改数据,应该使用过滤器或者在视图函数中处理。
-
善用否定形式 :Jinja2 支持
is not语法,善用否定形式可以让代码更易读。{% if x is not none %}比{% if not (x is none) %}更自然。 -
避免过度嵌套:测试器组合使用时,注意不要嵌套过深。如果条件逻辑过于复杂,应该考虑将其移到视图函数中预处理,在模板中只做简单的判断。
测试器与过滤器组合使用
测试器和过滤器可以配合使用,先通过过滤器转换数据,再用测试器判断:
jinja2
{# 先去除首尾空格,再判断是否为空 #}
{% if name | trim is not empty %}
<p>姓名:{{ name | trim }}</p>
{% endif %}
{# 先转为字符串,再判断长度 #}
{% if code | string | length is eq 6 %}
<p>验证码格式正确</p>
{% endif %}
{# 先取绝对值,再判断范围 #}
{% if value | abs is le 100 %}
<p>数值在合理范围内</p>
{% endif %}
这种组合方式在处理用户输入时特别有用:先清洗数据,再验证数据。需要注意的是,过滤器在测试器之前执行,理解这个执行顺序对于编写正确的模板逻辑至关重要。
第六章 宏(Macros)
宏(Macro)是 Jinja2 中用于定义可复用模板片段的机制,类似于 Python 中的函数。宏可以接受参数、有默认值、返回渲染后的内容,并能被导入到其他模板中使用。对于需要反复使用的 HTML 组件(如表单输入框、按钮、卡片等),宏是最佳的组织方式。
6.1 宏的概念与定义({% macro %})
基本定义
宏使用 {% macro %} 和 {% endmacro %} 定义:
jinja2
{% macro greeting(name) %}
<p>Hello, {{ name }}!</p>
{% endmacro %}
调用宏
定义后,可以直接在模板中调用:
jinja2
{{ greeting('Tom') }}
渲染结果:
html
<p>Hello, Tom!</p>
宏 vs Python 函数
宏和 Python 函数有很多相似之处,但有关键区别:
| 特性 | Jinja2 宏 | Python 函数 |
|---|---|---|
| 定义位置 | 模板文件中 | Python 代码中 |
| 参数类型 | 动态,无类型检查 | 可有类型注解 |
| 返回值 | 渲染后的字符串(Markup) | 任意类型 |
| 作用域 | 模板内或导入后 | 模块内或导入后 |
| 上下文 | 默认不访问模板上下文 | 访问闭包/全局 |
6.2 宏的参数与默认值
宏可以接受多个参数,并支持默认值。
带默认值的参数
jinja2
{% macro input(name, value='', type='text', placeholder='', required=False) %}
<input
type="{{ type }}"
name="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
{{ 'required' if required }}
>
{% endmacro %}
调用:
jinja2
{# 只传必须参数 #}
{{ input('username') }}
{# <input type="text" name="username" value="" placeholder="" > #}
{# 传部分参数 #}
{{ input('email', type='email', placeholder='请输入邮箱', required=True) }}
{# <input type="email" name="email" value="" placeholder="请输入邮箱" required> #}
{# 位置参数 #}
{{ input('password', '默认值', 'password') }}
可变参数:varargs 和 kwargs
宏支持接收额外的位置参数(varargs)和关键字参数(kwargs):
jinja2
{% macro button(text, **kwargs) %}
<button
{% for attr, value in kwargs.items() %}
{{ attr }}="{{ value }}"
{% endfor %}
>{{ text }}</button>
{% endmacro %}
调用:
jinja2
{{ button('点击', class='btn btn-primary', id='submit-btn', data_action='submit') }}
渲染结果:
html
<button
class="btn btn-primary"
id="submit-btn"
data_action="submit"
>点击</button>
这种方式可以灵活地给 HTML 元素添加任意属性。
6.3 宏的调用
宏可以在定义它的模板内直接调用,也可以被导入到其他模板使用。
在定义文件内调用
jinja2
{% macro card(title, content) %}
<div class="card">
<div class="card-header">{{ title }}</div>
<div class="card-body">{{ content }}</div>
</div>
{% endmacro %}
{# 在同一文件内调用 #}
{{ card('标题1', '内容1') }}
{{ card('标题2', '内容2') }}
导入后调用
宏的最大价值在于跨文件复用。详见 6.7 节。
嵌套调用
宏可以调用其他宏:
jinja2
{% macro icon(name) %}
<i class="icon icon-{{ name }}"></i>
{% endmacro %}
{% macro button_with_icon(text, icon_name) %}
<button class="btn">
{{ icon(icon_name) }}
<span>{{ text }}</span>
</button>
{% endmacro %}
{{ button_with_icon('保存', 'save') }}
渲染结果:
html
<button class="btn">
<i class="icon icon-save"></i>
<span>保存</span>
</button>
6.4 宏内部变量(var关键字)
宏内部可以定义局部变量,这些变量不会泄露到外部。
使用 set 定义局部变量
jinja2
{% macro user_card(user) %}
{% set display_name = user.nickname or user.username %}
{% set avatar = user.avatar or 'default.png' %}
<div class="user-card">
<img src="{{ avatar }}" alt="{{ display_name }}">
<span>{{ display_name }}</span>
</div>
{% endmacro %}
display_name 和 avatar 只在宏内部有效,不会影响外部模板的同名变量。
注意:set 在宏中的作用域
在宏内部用 set 定义的变量是局部的,这和模板顶层的 set 行为不同。宏内的 set 不会修改外部上下文。
6.5 宏的属性(name, arguments, caller)
每个宏对象都有一些内置属性,可以在调试或高级用法中访问。
name:宏的名称
jinja2
{% macro my_helper() %}...{% endmacro %}
{{ my_helper.name }} {# my_helper #}
arguments:参数列表
jinja2
{% macro input(name, value='', type='text') %}...{% endmacro %}
{{ input.arguments }} {# ('name', 'value', 'type') #}
caller:调用者(与 call 块配合)
当宏被 {% call %} 块调用时,caller 是一个可调用的对象,执行它会渲染 call 块的内容。详见 6.6 节。
jinja2
{% macro wrapper() %}
<div class="wrapper">
{{ caller() }}
</div>
{% endmacro %}
{% call wrapper() %}
<p>这段内容会出现在 wrapper 内部</p>
{% endcall %}
6.6 call块({% call %}调用带回调的宏)
{% call %} 块是 Jinja2 宏的一个高级特性,它允许你把一段模板内容作为"回调"传递给宏。这在需要把内容嵌入到固定结构中时非常有用。
基本用法
jinja2
{# 定义一个带 caller 的宏 #}
{% macro card(title) %}
<div class="card">
<div class="card-header">{{ title }}</div>
<div class="card-body">
{{ caller() }} {# 这里会渲染 call 块的内容 #}
</div>
</div>
{% endmacro %}
{# 用 call 调用 #}
{% call card('用户信息') %}
<p>姓名:Tom</p>
<p>年龄:28</p>
{% endcall %}
渲染结果:
html
<div class="card">
<div class="card-header">用户信息</div>
<div class="card-body">
<p>姓名:Tom</p>
<p>年龄:28</p>
</div>
</div>
{% call card('用户信息') %}...{% endcall %} 的意思是:调用 card 宏,{% call %} 和 {% endcall %} 之间的内容会作为 caller() 的返回值。
call 块传递参数
call 块还可以从宏接收参数,实现更灵活的内容生成:
jinja2
{% macro list_items(items) %}
<ul>
{% for item in items %}
<li>{{ caller(item, loop.index) }}</li>
{% endfor %}
</ul>
{% endmacro %}
{% call(item, index) list_items(users) %}
<strong>#{{ index }}</strong> {{ item.name }} ({{ item.age }}岁)
{% endcall %}
视图函数:
python
users = [
{'name': 'Tom', 'age': 28},
{'name': 'Jerry', 'age': 25},
]
渲染结果:
html
<ul>
<li><strong>#1</strong> Tom (28岁)</li>
<li><strong>#2</strong> Jerry (25岁)</li>
</ul>
这里 caller(item, loop.index) 把循环变量传递给 call 块,call 块通过 {% call(item, index) %} 接收。
实用场景:表格渲染
jinja2
{% macro data_table(headers, rows) %}
<table class="table">
<thead>
<tr>
{% for header in headers %}
<th>{{ header }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{{ caller(row, loop) }}
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% call(row, loop) data_table(['ID', '名称', '价格'], products) %}
<td>{{ loop.index }}</td>
<td>{{ row.name }}</td>
<td class="price">{{ row.price | money }}</td>
{% endcall %}
这种模式让表格的结构(thead/tbody/tr)由宏统一控制,而每行的具体内容由调用者决定,实现了结构复用与内容定制的分离。
call 块的更多实战场景
call 块的回调机制非常灵活,除了表格渲染,还可以用于许多其他场景:
场景一:卡片列表渲染
jinja2
{% macro card_list(items, columns=3) %}
<div class="card-grid" style="grid-template-columns: repeat({{ columns }}, 1fr);">
{% for item in items %}
<div class="card">
{{ caller(item, loop) }}
</div>
{% endfor %}
</div>
{% endmacro %}
{# 调用:每个卡片的内容由调用者决定 #}
{% call(product, loop) card_list(products, columns=4) %}
<div class="card-image">
<img src="{{ product.image }}" alt="{{ product.name }}">
</div>
<div class="card-body">
<h5 class="card-title">{{ product.name }}</h5>
<p class="card-price">{{ product.price | money }}</p>
{% if product.stock > 0 %}
<button class="btn btn-primary">加入购物车</button>
{% else %}
<span class="text-muted">暂时缺货</span>
{% endif %}
</div>
{% endcall %}
场景二:可配置的列表渲染
jinja2
{% macro render_list(items, ordered=False, item_class='') %}
{% set tag = 'ol' if ordered else 'ul' %}
<{{ tag }} class="{{ item_class }}">
{% for item in items %}
<li>{{ caller(item, loop) }}</li>
{% endfor %}
</{{ tag }}>
{% endmacro %}
{# 有序列表 #}
{% call(step, loop) render_list(tutorial_steps, ordered=True, item_class='tutorial-steps') %}
<strong>步骤 {{ loop.index }}:</strong> {{ step.title }}
<p>{{ step.description }}</p>
{% endcall %}
{# 无序列表 #}
{% call(feature, loop) render_list(features, item_class='feature-list') %}
<i class="icon-{{ feature.icon }}"></i>
<span>{{ feature.name }}</span>
{% endcall %}
call 块的核心价值在于"控制反转":宏负责整体结构和循环控制,调用者负责每一项的具体渲染内容。这种模式在前端组件化开发中非常常见,类似于 React 中的 render props 模式或 Vue 中的"作用域插槽"。
6.7 宏的导入与复用({% import %}, {% from import %})
宏的真正威力在于跨文件复用。Jinja2 提供了两种导入方式。
方式1:{% import %} 导入为命名空间
macros/forms.html:
jinja2
{% macro input(name, value='', type='text') %}
<input type="{{ type }}" name="{{ name }}" value="{{ value }}">
{% endmacro %}
{% macro label(text, for='') %}
<label for="{{ for }}">{{ text }}</label>
{% endmacro %}
{% macro textarea(name, value='', rows=4) %}
<textarea name="{{ name }}" rows="{{ rows }}">{{ value }}</textarea>
{% endmacro %}
在其他模板中导入:
jinja2
{% import 'macros/forms.html' as forms %}
<form>
{{ forms.label('用户名', 'username') }}
{{ forms.input('username') }}
{{ forms.label('密码', 'password') }}
{{ forms.input('password', type='password') }}
{{ forms.label('简介', 'bio') }}
{{ forms.textarea('bio', rows=5) }}
</form>
as forms 把整个宏文件导入为 forms 命名空间,通过 forms.宏名 调用。这种方式的好处是避免命名冲突。
方式2:{% from import %} 导入特定宏
jinja2
{% from 'macros/forms.html' import input, label, textarea %}
<form>
{{ label('用户名', 'username') }}
{{ input('username') }}
</form>
from import 直接把指定宏导入当前作用域,调用时不需要前缀。适合只用到少量宏的场景。
with context:传递上下文
默认情况下,导入的宏不能 访问当前模板的上下文变量。如果宏需要访问 request、session 等上下文,需要加 with context:
jinja2
{% import 'macros/forms.html' as forms with context %}
或:
jinja2
{% from 'macros/forms.html' import input with context %}
加了 with context 后,宏内部可以访问 request、config、session 等:
jinja2
{# macros/forms.html #}
{% macro csrf_field() %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% endmacro %}
jinja2
{# 需要加 with context 才能在宏内访问 session #}
{% from 'macros/forms.html' import csrf_field with context %}
<form>
{{ csrf_field() }}
...
</form>
import 的位置
{% import %} 通常放在模板顶部,紧跟 {% extends %} 之后:
jinja2
{% extends 'base.html' %}
{% import 'macros/forms.html' as forms %}
{% from 'macros/ui.html' import alert, badge %}
{% block content %}
...
{% endblock %}
6.8 宏文件组织
对于大型项目,把所有宏放在一个文件里会变得臃肿。推荐按功能分文件组织。
推荐目录结构
templates/
├── macros/
│ ├── forms.html # 表单相关宏(input, label, textarea, select等)
│ ├── ui.html # UI组件宏(alert, badge, card, modal等)
│ ├── pagination.html # 分页宏
│ ├── navigation.html # 导航相关宏
│ └── format.html # 格式化宏(日期、金额等)
macros/forms.html
jinja2
{# ==================== 表单宏 ==================== #}
{% macro input(name, value='', type='text', placeholder='', required=False, class='') %}
<input type="{{ type }}"
name="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
class="form-control {{ class }}"
{{ 'required' if required }}>
{% endmacro %}
{% macro textarea(name, value='', rows=4, placeholder='', required=False) %}
<textarea name="{{ name }}"
rows="{{ rows }}"
placeholder="{{ placeholder }}"
class="form-control"
{{ 'required' if required }}>{{ value }}</textarea>
{% endmacro %}
{% macro select(name, options, value='', class='') %}
<select name="{{ name }}" class="form-control {{ class }}">
{% for opt_value, opt_label in options.items() if options is mapping %}
<option value="{{ opt_value }}" {{ 'selected' if opt_value == value }}>{{ opt_label }}</option>
{% else %}
{% for option in options %}
<option value="{{ option.value if option.value is defined else option }}"
{{ 'selected' if (option.value if option.value is defined else option) == value }}>
{{ option.label if option.label is defined else option }}
</option>
{% endfor %}
{% endfor %}
</select>
{% endmacro %}
{% macro checkbox(name, label, checked=False, value='1') %}
<label class="checkbox">
<input type="checkbox" name="{{ name }}" value="{{ value }}" {{ 'checked' if checked }}>
{{ label }}
</label>
{% endmacro %}
{% macro radio(name, options, value='') %}
{% for opt_value, opt_label in options.items() if options is mapping %}
<label class="radio">
<input type="radio" name="{{ name }}" value="{{ opt_value }}" {{ 'checked' if opt_value == value }}>
{{ opt_label }}
</label>
{% else %}
{% for option in options %}
<label class="radio">
<input type="radio" name="{{ name }}" value="{{ option }}" {{ 'checked' if option == value }}>
{{ option }}
</label>
{% endfor %}
{% endfor %}
{% endmacro %}
{% macro submit(text='提交', class='btn-primary') %}
<button type="submit" class="btn {{ class }}">{{ text }}</button>
{% endmacro %}
{% macro form_field(label_text, field_html, error='') %}
<div class="form-group {{ 'has-error' if error }}">
<label>{{ label_text }}</label>
{{ field_html }}
{% if error %}<span class="help-block">{{ error }}</span>{% endif %}
</div>
{% endmacro %}
macros/ui.html
jinja2
{# ==================== UI组件宏 ==================== #}
{% macro alert(message, type='info', dismissible=False) %}
<div class="alert alert-{{ type }} {{ 'alert-dismissible' if dismissible }}">
{% if dismissible %}
<button type="button" class="close" data-dismiss="alert">×</button>
{% endif %}
{{ message }}
</div>
{% endmacro %}
{% macro badge(text, type='default') %}
<span class="badge badge-{{ type }}">{{ text }}</span>
{% endmacro %}
{% macro card(title='', class='') %}
<div class="card {{ class }}">
{% if title %}
<div class="card-header">{{ title }}</div>
{% endif %}
<div class="card-body">
{{ caller() }}
</div>
</div>
{% endmacro %}
{% macro modal(id, title) %}
<div class="modal fade" id="{{ id }}">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button type="button" class="close" data-dismiss="modal">×</button>
</div>
<div class="modal-body">
{{ caller() }}
</div>
</div>
</div>
</div>
{% endmacro %}
{% macro progress_bar(value, max=100, type='primary') %}
<div class="progress">
<div class="progress-bar progress-bar-{{ type }}"
style="width: {{ (value / max * 100) | round }}%">
{{ (value / max * 100) | round }}%
</div>
</div>
{% endmacro %}
macros/pagination.html
jinja2
{# ==================== 分页宏 ==================== #}
{% macro render_pagination(page, total_pages, endpoint, **kwargs) %}
{% if total_pages > 1 %}
<nav class="pagination">
{% if page > 1 %}
<a href="{{ url_for(endpoint, page=page-1, **kwargs) }}" class="prev"><< 上一页</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
<a href="{{ url_for(endpoint, page=p, **kwargs) }}">{{ p }}</a>
{% elif p == 2 or p == total_pages - 1 %}
<span class="ellipsis">...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for(endpoint, page=page+1, **kwargs) }}" class="next">下一页 >></a>
{% endif %}
</nav>
{% endif %}
{% endmacro %}
6.9 宏实战: 表单组件库(输入框/下拉框/复选框/按钮)
让我们把前面定义的宏整合起来,构建一个完整的表单页面。
视图函数
python
from flask import Flask, render_template
app = Flask(__name__)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
@app.route('/register')
def register():
return render_template(
'register.html',
form_data={
'username': '',
'email': '',
'gender': '',
'hobbies': [],
'bio': '',
'agree': False,
},
errors={},
gender_options={'male': '男', 'female': '女', 'other': '其他'},
hobby_options=['阅读', '编程', '旅行', '音乐', '运动'],
)
register.html
jinja2
{% extends 'base.html' %}
{% import 'macros/forms.html' as forms %}
{% block title %}用户注册{% endblock %}
{% block content %}
<h1>用户注册</h1>
<form method="POST" action="/register">
{# 用户名 #}
{{ forms.form_field(
'用户名',
forms.input('username', form_data.username, placeholder='3-20个字符', required=True),
errors.username
) }}
{# 邮箱 #}
{{ forms.form_field(
'邮箱',
forms.input('email', form_data.email, type='email', placeholder='example@mail.com', required=True),
errors.email
) }}
{# 性别(单选) #}
{{ forms.form_field(
'性别',
forms.radio('gender', gender_options, form_data.gender),
errors.gender
) }}
{# 兴趣爱好(多选) #}
{{ forms.form_field(
'兴趣爱好',
forms.checkbox_group('hobbies', hobby_options, form_data.hobbies),
errors.hobbies
) }}
{# 个人简介 #}
{{ forms.form_field(
'个人简介',
forms.textarea('bio', form_data.bio, rows=5, placeholder='简单介绍一下自己...'),
''
) }}
{# 同意条款 #}
{{ forms.checkbox('agree', '我已阅读并同意<a href="/terms">服务条款</a>', checked=form_data.agree) }}
{# 提交按钮 #}
<div class="form-actions">
{{ forms.submit('注册', 'btn-success btn-lg') }}
<a href="/" class="btn btn-default">取消</a>
</div>
</form>
{% endblock %}
通过宏的封装,表单模板变得非常简洁清晰。每个字段都用一行 forms.form_field(...) 描述,结构统一,易于维护。
6.10 宏的最佳实践
1. 宏应该职责单一
每个宏只做一件事。不要把整个表单塞进一个宏里,而是拆分成 input、label、select 等小宏,然后用组合的方式构建表单。
2. 合理使用默认值
给常用参数设置合理的默认值,减少调用时的参数数量:
jinja2
{% macro input(name, value='', type='text', class='form-control', required=False) %}
3. 支持额外的 HTML 属性
用 **kwargs 让宏可以接受任意 HTML 属性:
jinja2
{% macro input(name, **kwargs) %}
<input name="{{ name }}"
{% for attr, val in kwargs.items() %}
{{ attr }}="{{ val }}"
{% endfor %}
>
{% endmacro %}
4. 宏文件只定义宏,不输出内容
宏文件(macros/*.html)应该只包含 {% macro %} 定义,不包含任何会直接输出的 HTML。这样导入时不会产生意外输出。
5. 文档注释
给复杂的宏加上注释:
jinja2
{#
渲染分页导航
@param page: 当前页码
@param total_pages: 总页数
@param endpoint: URL 端点名
@param kwargs: 额外的 URL 参数
#}
{% macro render_pagination(page, total_pages, endpoint, **kwargs) %}
...
{% endmacro %}
6. 避免在宏里写业务逻辑
宏应该只负责"如何渲染",不负责"渲染什么数据"。数据的准备应该在视图函数中完成。
7. 宏的嵌套调用
宏可以调用其他宏,实现更复杂的组件组合。这种模式在构建大型 UI 组件库时非常有用。例如,一个"卡片"宏可以内部调用"头像"宏和"标签"宏:
jinja2
{# macros/components.html #}
{# 头像宏 #}
{% macro avatar(user, size=40) %}
<img src="{{ user.avatar_url or url_for('static', filename='images/default_avatar.png') }}"
alt="{{ user.username }}"
class="avatar avatar-{{ size }}"
width="{{ size }}"
height="{{ size }}">
{% endmacro %}
{# 标签宏 #}
{% macro tag(text, color='default') %}
<span class="tag tag-{{ color }}">{{ text }}</span>
{% endmacro %}
{# 用户信息卡片宏(内部调用头像和标签宏) #}
{% macro user_card(user, show_tags=True) %}
<div class="user-card">
<div class="user-card-header">
{{ avatar(user, size=48) }}
<div class="user-info">
<h4>{{ user.username }}</h4>
<p class="user-role">{{ user.role }}</p>
</div>
</div>
{% if show_tags and user.tags %}
<div class="user-card-tags">
{% for tag_text in user.tags %}
{{ tag(tag_text, color='primary') }}
{% endfor %}
</div>
{% endif %}
{% if user.bio %}
<p class="user-card-bio">{{ user.bio | truncate(100) }}</p>
{% endif %}
</div>
{% endmacro %}
在使用时,只需要导入并调用 user_card 即可,内部嵌套的 avatar 和 tag 宏会自动处理:
jinja2
{% from 'macros/components.html' import user_card %}
<div class="user-list">
{% for user in users %}
{{ user_card(user, show_tags=True) }}
{% endfor %}
</div>
8. 宏与上下文处理器配合
有时候宏需要访问全局上下文中的变量(如当前用户、配置等)。默认情况下,通过 {% import %} 导入的宏无法访问当前模板的上下文。如果需要,可以加上 with context:
jinja2
{# macros/auth.html #}
{% macro login_button() %}
{% if current_user.is_authenticated %}
<a href="{{ url_for('auth.logout') }}" class="btn btn-logout">
<i class="icon-logout"></i> 退出 ({{ current_user.username }})
</a>
{% else %}
<a href="{{ url_for('auth.login') }}" class="btn btn-login">
<i class="icon-login"></i> 登录
</a>
{% endif %}
{% endmacro %}
jinja2
{# 在模板中使用,必须加上 with context #}
{% from 'macros/auth.html' import login_button with context %}
<header>
{{ login_button() }}
</header>
需要注意的是,with context 会将整个模板上下文传递给宏,这会带来轻微的性能开销。因此,只在确实需要访问上下文变量时才使用,大多数情况下通过参数传递数据是更好的选择。
9. 宏的版本管理
当宏库变得庞大时,建议对宏文件进行合理的版本管理。可以为不同模块的宏创建独立的文件:
templates/
├── macros/
│ ├── forms.html # 表单相关宏
│ ├── components.html # UI 组件宏
│ ├── pagination.html # 分页宏
│ ├── navigation.html # 导航相关宏
│ ├── media.html # 媒体相关宏(图片、视频)
│ └── helpers.html # 辅助工具宏(日期格式化、文本处理)
这种按功能划分的文件组织方式,可以让团队成员各司其职,避免宏文件的冲突和膨胀。在模板中按需导入即可:
jinja2
{% from 'macros/forms.html' import input, textarea, select, submit %}
{% from 'macros/components.html' import card, alert, badge %}
{% from 'macros/pagination.html' import render_pagination %}
{% from 'macros/helpers.html' import format_date, truncate_text %}
第七章 模板继承
模板继承是 Jinja2 最强大、最常用的特性之一。它允许你定义一个基础模板(包含网站的整体结构和公共部分),然后子模板继承基础模板并覆写其中的"块"。这种机制让网站的布局复用变得极其优雅,是构建大型 Web 应用模板体系的基石。
7.1 模板继承的概念与价值
什么是模板继承?
模板继承的思想来自面向对象编程中的"继承":定义一个"父模板"(基类)包含整体框架和可覆写的"块"(方法),然后"子模板"(子类)继承父模板并覆写特定的块。
打个比方:一个网站的每个页面都有相同的头部导航、侧边栏、底部版权信息,只有中间的内容区域不同。如果不使用继承,每个页面都要复制粘贴这些公共部分,一旦导航需要修改,就要改所有页面。使用继承后,公共部分放在父模板里,子模板只写内容区域,修改公共部分只需改一处。
模板继承的价值
| 价值 | 说明 |
|---|---|
| 消除重复 | 公共部分只写一次,所有页面共享 |
| 统一风格 | 所有页面遵循相同的布局结构 |
| 易于维护 | 修改布局只需改父模板,自动生效到所有子页面 |
| 关注点分离 | 子模板只关注自己的内容,不关心整体布局 |
| 灵活扩展 | 支持多层继承,构建复杂的模板体系 |
7.2 基础模板编写({% block %}定义占位符)
基础模板(通常命名为 base.html)定义了页面的整体结构,并用 {% block %} 标记可被子模板覆写的区域。
基础模板 base.html
jinja2
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}我的网站{% endblock %}</title>
{# CSS #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block styles %}{% endblock %}
</head>
<body>
{# 头部导航 #}
<header class="site-header">
<nav class="navbar">
<a href="{{ url_for('index') }}" class="navbar-brand">我的网站</a>
<ul class="navbar-nav">
<li><a href="{{ url_for('index') }}">首页</a></li>
<li><a href="{{ url_for('blog.list') }}">博客</a></li>
<li><a href="{{ url_for('about') }}">关于</a></li>
</ul>
</nav>
</header>
{# 主内容区 #}
<main class="site-main">
{% block content %}{% endblock %}
</main>
{# 侧边栏 #}
<aside class="site-sidebar">
{% block sidebar %}
{# 默认侧边栏内容 #}
<h3>热门文章</h3>
<ul>
<li><a href="#">文章1</a></li>
<li><a href="#">文章2</a></li>
</ul>
{% endblock %}
</aside>
{# 底部 #}
<footer class="site-footer">
<p>© 2024 我的网站. All rights reserved.</p>
</footer>
{# JavaScript #}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
block 的语法
jinja2
{% block block_name %}
默认内容(子模板不覆写时显示)
{% endblock %}
block_name:块的名称,子模板用同名块覆写。- 块可以有默认内容,子模板不覆写时显示默认内容。
- 块也可以为空(
{% block x %}{% endblock %}),表示子模板必须填充。
block 的命名规范
推荐的块名称:
title:页面标题styles:额外CSScontent:主内容(几乎所有页面都要覆写)sidebar:侧边栏scripts:额外JavaScriptheader_extra:头部额外内容footer_extra:底部额外内容
7.3 子模板继承({% extends %}与{% block %}覆写)
子模板使用 {% extends %} 声明继承自哪个父模板,然后用 {% block %} 覆写需要自定义的部分。
子模板 index.html
jinja2
{% extends 'base.html' %}
{% block title %}首页 - 我的网站{% endblock %}
{% block content %}
<h1>欢迎来到我的网站</h1>
<p>这里是首页内容。</p>
<ul>
<li><a href="/blog">阅读博客</a></li>
<li><a href="/about">关于我们</a></li>
</ul>
{% endblock %}
{% block scripts %}
<script>
console.log('首页特有脚本');
</script>
{% endblock %}
渲染结果(简化):
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>首页 - 我的网站</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="site-header">
<nav class="navbar">...</nav>
</header>
<main class="site-main">
<h1>欢迎来到我的网站</h1>
<p>这里是首页内容。</p>
<ul>
<li><a href="/blog">阅读博客</a></li>
<li><a href="/about">关于我们</a></li>
</ul>
</main>
<aside class="site-sidebar">
<h3>热门文章</h3>
<ul>...</ul>
</aside>
<footer class="site-footer">
<p>© 2024 我的网站. All rights reserved.</p>
</footer>
<script src="/static/js/main.js"></script>
<script>
console.log('首页特有脚本');
</script>
</body>
</html>
关键点:
{% extends %}必须是子模板的第一个标签。- 子模板中
{% extends %}之外的内容(不在 block 内的)会被忽略。 - 子模板只覆写需要自定义的 block,其他 block 保持父模板的默认内容。
只覆写部分 block
jinja2
{# 只覆写 content,其他保持默认 #}
{% extends 'base.html' %}
{% block content %}
<h1>关于我们</h1>
<p>这是关于页面。</p>
{% endblock %}
title 保持默认"我的网站",sidebar 保持默认的热门文章列表,scripts 为空。
7.4 super()函数调用父模板内容
有时候,子模板不想完全替换父模板 block 的内容,而是想"在父模板内容的基础上追加"。这时用 super() 函数。
super() 的用法
jinja2
{# 父模板 base.html #}
{% block scripts %}
<script src="/static/js/main.js"></script>
{% endblock %}
{# 子模板 #}
{% block scripts %}
{{ super() }}
<script src="/static/js/page-specific.js"></script>
{% endblock %}
渲染结果:
html
<script src="/static/js/main.js"></script>
<script src="/static/js/page-specific.js"></script>
{``{ super() }} 会输出父模板中同名 block 的内容,然后你可以在其前后添加额外内容。
在 title 中使用 super()
jinja2
{# 父模板 #}
{% block title %}我的网站{% endblock %}
{# 子模板 #}
{% block title %}{{ super() }} - 博客{% endblock %}
渲染结果:
html
<title>我的网站 - 博客</title>
在 content 中使用 super()
jinja2
{# 父模板 #}
{% block content %}
<div class="container">
{# 默认内容为空 #}
</div>
{% endblock %}
{# 子模板 #}
{% block content %}
{{ super() }}
<div class="extra">
<p>这是子模板追加的内容</p>
</div>
{% endblock %}
7.5 多层继承
Jinja2 支持多层继承:A 继承 B,B 继承 C。这在构建复杂模板体系时很有用。
层次设计
base.html # 最基础:HTML骨架、头部、尾部
├── blog_base.html # 博客模块:博客特有的侧边栏、布局
│ ├── blog_list.html # 博客列表
│ └── blog_detail.html # 博客详情
├── admin_base.html # 管理后台:管理特有的导航
│ ├── admin_dashboard.html
│ └── admin_users.html
base.html
jinja2
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}我的网站{% endblock %}</title>
{% block styles %}{% endblock %}
</head>
<body>
{% block header %}
<header>通用头部</header>
{% endblock %}
{% block main %}
<main>{% block content %}{% endblock %}</main>
{% endblock %}
{% block footer %}
<footer>通用底部</footer>
{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>
blog_base.html(继承 base.html)
jinja2
{% extends 'base.html' %}
{% block title %}{{ super() }} - 博客{% endblock %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='css/blog.css') }}">
{% endblock %}
{% block main %}
<main class="blog-layout">
<div class="blog-content">
{% block blog_content %}{% endblock %}
</div>
<aside class="blog-sidebar">
{% block blog_sidebar %}
<h3>分类</h3>
<ul>
<li><a href="#">技术</a></li>
<li><a href="#">生活</a></li>
</ul>
{% endblock %}
</aside>
</main>
{% endblock %}
blog_list.html(继承 blog_base.html)
jinja2
{% extends 'blog_base.html' %}
{% block title %}{{ super() }} - 文章列表{% endblock %}
{% block blog_content %}
<h1>博客文章</h1>
{% for post in posts %}
<article>
<h2><a href="{{ url_for('blog.detail', id=post.id) }}">{{ post.title }}</a></h2>
<p>{{ post.summary }}</p>
</article>
{% else %}
<p>暂无文章</p>
{% endfor %}
{% endblock %}
继承链的查找
当 blog_list.html 调用 super() 时,Jinja2 会沿着继承链向上查找:
blog_list.html->blog_base.html->base.html
super() 总是引用"上一级"父模板的同名 block。如果 blog_base.html 的 title block 也调用了 super(),它会进一步引用 base.html 的 title block。
7.6 块的嵌套
block 可以嵌套,即一个 block 内部可以定义另一个 block:
jinja2
{% block main %}
<div class="container">
{% block content %}
{% block page_header %}
<div class="page-header">
<h1>{% block page_title %}{% endblock %}</h1>
</div>
{% endblock %}
{% block page_body %}{% endblock %}
{% endblock %}
</div>
{% endblock %}
子模板可以覆写任意层级的 block:
jinja2
{% extends 'base.html' %}
{% block page_title %}首页{% endblock %}
{% block page_body %}
<p>这是首页内容。</p>
{% endblock %}
嵌套 block 的注意事项
- 嵌套 block 的名称必须全局唯一(在同一模板树中)。
- 嵌套 block 允许更细粒度的覆写,但也增加了复杂度。
- 不要过度嵌套,保持结构清晰。
7.7 块的命名规范
好的命名规范能让模板更易维护。推荐以下命名约定:
| 块名 | 用途 | 是否有默认内容 |
|---|---|---|
title |
<title> 标签内容 |
有(网站名称) |
styles |
额外 CSS | 无 |
header |
页面头部 | 有 |
nav |
导航栏 | 有 |
content |
主内容区 | 无 |
sidebar |
侧边栏 | 有(可选) |
footer |
页面底部 | 有 |
scripts |
额外 JavaScript | 无 |
meta |
额外 meta 标签 | 无 |
命名建议:
- 使用小写字母和下划线。
- 名称应该描述内容,如
content、sidebar、scripts。 - 模块特有的 block 加前缀,如
blog_content、admin_nav。 - 避免太短或太通用的名称,如
x、block1。
7.8 {% block %}的required属性
Jinja2 2.10+ 为 {% block %} 引入了 required 属性,表示这个块必须被子模板覆写,否则渲染时报错。
用法
jinja2
{# 父模板 #}
{% block content required %}{% endblock %}
如果子模板没有覆写 content block,渲染时会抛出异常:
TemplateSyntaxError: 'content' block is required but not defined.
使用场景
required 适合那些"必须有内容才有意义"的块:
jinja2
<article class="post">
<h1>{% block post_title required %}{% endblock %}</h1>
<div class="post-content">
{% block post_content required %}{% endblock %}
</div>
</article>
这样,任何继承这个模板的子模板都必须提供 post_title 和 post_content,否则会报错。这是一种"契约",确保子模板不会遗漏关键内容。
注意 :required 是较新的特性,旧版 Jinja2 不支持。使用前确认你的 Jinja2 版本。
7.9 模板继承实战: 完整网站布局(base.html + 子页面)
让我们用一个完整的网站布局实战来综合演示模板继承。
base.html:网站基础布局
jinja2
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}我的个人网站{% endblock %}">
<title>{% block title %}我的网站{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- 自定义 CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% 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="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('index') }}">首页</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('blog.list') }}">博客</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('about') }}">关于</a>
</li>
</ul>
<ul class="navbar-nav">
{% if current_user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('user.profile') }}">{{ current_user.username }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">退出</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">登录</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">注册</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- 闪现消息 -->
<div class="container mt-3">
{% block messages %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endblock %}
</div>
<!-- 主内容区 -->
<main class="container mt-4">
{% block content %}{% endblock %}
</main>
<!-- 底部 -->
<footer class="bg-dark text-light mt-5 py-4">
<div class="container">
<div class="row">
<div class="col-md-6">
<h5>我的网站</h5>
<p>分享技术,记录生活。</p>
</div>
<div class="col-md-6 text-md-end">
<p>© 2024 我的网站. All rights reserved.</p>
{% block footer_extra %}{% endblock %}
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- 自定义 JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
index.html:首页
jinja2
{% extends 'base.html' %}
{% block title %}{{ super() }} - 首页{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8">
<h1>最新文章</h1>
{% for post in posts %}
<article class="blog-post">
<h2><a href="{{ url_for('blog.detail', post_id=post.id) }}">{{ post.title }}</a></h2>
<div class="post-meta">
<span class="date">{{ post.created_at | format_date }}</span>
<span class="author">by {{ post.author.name }}</span>
<span class="category">
<a href="{{ url_for('blog.category', cat=post.category) }}">{{ post.category }}</a>
</span>
</div>
<p>{{ post.summary }}</p>
<a href="{{ url_for('blog.detail', post_id=post.id) }}" class="btn btn-primary">阅读更多</a>
</article>
{% else %}
<div class="alert alert-info">还没有文章,敬请期待!</div>
{% endfor %}
</div>
<div class="col-md-4">
{% block sidebar %}
<div class="sidebar">
<h3>热门标签</h3>
<div class="tag-cloud">
{% for tag in popular_tags %}
<a href="{{ url_for('blog.tag', tag=tag.name) }}" class="badge bg-secondary">
{{ tag.name }} ({{ tag.count }})
</a>
{% endfor %}
</div>
<h3 class="mt-4">归档</h3>
<ul class="archive-list">
{% for item in archives %}
<li>
<a href="{{ url_for('blog.archive', year=item.year, month=item.month) }}">
{{ item.year }}年{{ item.month }}月 ({{ item.count }})
</a>
</li>
{% endfor %}
</ul>
</div>
{% endblock %}
</div>
</div>
{% endblock %}
blog/detail.html:博客详情页
jinja2
{% extends 'base.html' %}
{% block title %}{{ post.title }} - {{ super() }}{% endblock %}
{% block meta_description %}{{ post.summary }}{% endblock %}
{% block content %}
<article class="blog-detail">
<h1>{{ post.title }}</h1>
<div class="post-meta">
<span>作者:{{ post.author.name }}</span>
<span>发布:{{ post.created_at | format_date }}</span>
<span>阅读:{{ post.views }}</span>
</div>
<div class="post-content">
{{ post.content | markdown | safe }}
</div>
<div class="post-tags">
{% for tag in post.tags %}
<a href="{{ url_for('blog.tag', tag=tag) }}" class="badge bg-primary">{{ tag }}</a>
{% endfor %}
</div>
</article>
{% if post.prev or post.next %}
<nav class="post-nav">
{% if post.prev %}
<a href="{{ url_for('blog.detail', post_id=post.prev.id) }}" class="prev">
← {{ post.prev.title }}
</a>
{% endif %}
{% if post.next %}
<a href="{{ url_for('blog.detail', post_id=post.next.id) }}" class="next">
{{ post.next.title }} →
</a>
{% endif %}
</nav>
{% endif %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/prism.js') }}"></script>
{% endblock %}
errors/404.html:404页面
jinja2
{% extends 'base.html' %}
{% block title %}404 - 页面未找到{% endblock %}
{% block content %}
<div class="error-page text-center">
<h1 class="display-1">404</h1>
<p class="lead">抱歉,您访问的页面不存在。</p>
<a href="{{ url_for('index') }}" class="btn btn-primary">返回首页</a>
</div>
{% endblock %}
7.10 继承 vs Include vs Macro选择策略
Jinja2 提供了三种复用机制:继承(extends)、包含(include)、宏(macro)。初学者常困惑该用哪个,下表帮助决策。
| 机制 | 适用场景 | 特点 |
|---|---|---|
| 继承(extends) | 整体页面布局 | 定义骨架,子模板填充 |
| 包含(include) | 页面片段(头部、尾部、广告) | 把片段"插入"当前位置 |
| 宏(macro) | 可参数化的组件(表单元素、卡片) | 类似函数,接受参数 |
决策流程:
需要复用的是整个页面布局吗?
-> 是: 用 extends(继承)
需要复用的是一段固定HTML,不需要参数吗?
-> 是: 用 include(包含)
需要复用的是带参数的组件吗?
-> 是: 用 macro(宏)
示例对比
同一个"用户卡片"用三种方式实现:
方式1:Include(适合固定内容)
jinja2
{# partials/user_card.html #}
<div class="user-card">
<img src="{{ user.avatar }}" alt="{{ user.name }}">
<h3>{{ user.name }}</h3>
<p>{{ user.bio }}</p>
</div>
{# 使用 #}
{% include 'partials/user_card.html' %}
依赖上下文中的 user 变量,不能传参。
方式2:Macro(适合需要参数的组件)
jinja2
{# macros/ui.html #}
{% macro user_card(user, show_bio=True) %}
<div class="user-card">
<img src="{{ user.avatar }}" alt="{{ user.name }}">
<h3>{{ user.name }}</h3>
{% if show_bio %}<p>{{ user.bio }}</p>{% endif %}
</div>
{% endmacro %}
{# 使用 #}
{% from 'macros/ui.html' import user_card %}
{{ user_card(user, show_bio=False) }}
可以传参,更灵活。
方式3:继承(适合整个页面结构)
jinja2
{# base.html #}
<html>
<head>...</head>
<body>
<header>...</header>
<main>{% block content %}{% endblock %}</main>
<footer>...</footer>
</body>
</html>
{# 使用 #}
{% extends 'base.html' %}
{% block content %}...{% endblock %}
实际项目中的组合使用
在真实项目中,三种机制通常组合使用:
jinja2
{% extends 'base.html' %} {# 继承整体布局 #}
{% import 'macros/forms.html' as forms %} {# 导入宏 #}
{% block content %}
{% include 'partials/breadcrumb.html' %} {# 包含面包屑 #}
<form>
{{ forms.input('username') }} {# 调用宏 #}
{{ forms.submit('保存') }}
</form>
{% endblock %}
这种组合让每种机制各司其职,模板体系既清晰又灵活。
模板继承的常见陷阱与解决方案
在实际使用模板继承时,开发者经常会遇到一些陷阱。下面列举几个最常见的问题及其解决方案。
陷阱1:在 {% extends %} 之前写内容
Jinja2 要求 {% extends %} 必须是模板的第一个标签(注释除外)。如果在 extends 之前有任何 HTML 输出,Jinja2 会报错或产生意外的输出:
jinja2
{# 错误:extends 前有 HTML #}
<p>欢迎</p>
{% extends 'base.html' %}
{% block content %}...{% endblock %}
{# 正确:extends 在最前面 #}
{% extends 'base.html' %}
{% block content %}
<p>欢迎</p>
{% endblock %}
陷阱2:块名称冲突
如果不同的模板中使用了相同的块名称,可能会导致内容被意外覆写。特别是在大型项目中,块名称应该具有描述性和唯一性:
jinja2
{# 不好的命名:太通用 #}
{% block content %}{% endblock %}
{% block header %}{% endblock %}
{# 好的命名:有上下文 #}
{% block page_content %}{% endblock %}
{% block page_header %}{% endblock %}
{% block sidebar_widgets %}{% endblock %}
陷阱3:在块外写内容会被忽略
在子模板中,所有内容必须放在 {% block %} 标签内。块外的内容会被静默忽略,不会显示在页面上:
jinja2
{% extends 'base.html' %}
{# 这段文字不会显示! #}
<p>这段文字不会出现在页面上</p>
{% block content %}
{# 只有块内的内容才会显示 #}
<p>这段文字会显示</p>
{% endblock %}
这个行为是设计如此:子模板只能通过覆写块来填充父模板的占位符,块外的内容没有"位置"可以放置。如果需要在页面中添加全局内容,应该通过上下文处理器或在父模板中预留相应的块。
陷阱4:super() 调用导致无限循环
在多层继承中,如果不小心在每一层都调用了 super(),而父模板的块又调用了子模板的块,可能会导致意外的行为。super() 只会调用上一层的同名块,不会无限递归,但如果继承层次过深,理解起来会变得困难。建议继承层次不要超过三层。
陷阱5:动态继承的注意事项
{% extends %} 可以接受变量作为参数,实现动态继承:
jinja2
{% extends template_name %}
这在需要根据条件选择不同基础模板时很有用,但要注意:模板名称必须在视图函数中确定并传入,不能在模板中动态计算(因为 extends 在模板解析阶段就需要确定)。
python
@app.route('/page')
def page():
# 根据用户偏好选择基础模板
base_template = 'base_admin.html' if current_user.is_admin else 'base_user.html'
return render_template('page.html', template_name=base_template)
第八章 全局函数与上下文
在 Jinja2 模板中,除了通过 render_template 传入的变量外,还有一些"全局可用"的对象和函数。这些全局对象由 Flask 自动注入或由开发者自定义,它们在每个模板中都可以直接使用,无需额外传递。本章将系统讲解这些全局函数和上下文机制。
8.1 Flask内置模板全局函数(url_for, get_flashed_messages, config, request, session, g)
Flask 在创建 Jinja2 环境时,会自动注入以下全局对象,使它们在所有模板中可用:
| 全局对象 | 类型 | 用途 |
|---|---|---|
url_for |
函数 | 生成 URL |
get_flashed_messages |
函数 | 获取闪现消息 |
config |
字典 | 应用配置对象 |
request |
对象 | 当前请求对象 |
session |
字典 | 会话对象 |
g |
对象 | 请求级全局对象 |
config:应用配置
jinja2
{# 读取配置值 #}
<p>网站名称: {{ config['SITE_NAME'] }}</p>
<p>调试模式: {{ config['DEBUG'] }}</p>
{# 用 default 处理未定义的配置 #}
<p>每页显示: {{ config.get('POSTS_PER_PAGE', 10) }} 条</p>
request:当前请求
jinja2
{# 请求方法 #}
<p>请求方法: {{ request.method }}</p>
{# 查询参数 #}
<p>搜索关键词: {{ request.args.get('q', '') }}</p>
{# 当前路径 #}
<p>当前路径: {{ request.path }}</p>
{# 完整URL #}
<p>当前URL: {{ request.url }}</p>
{# 表单数据 #}
{% if request.method == 'POST' %}
<p>提交的用户名: {{ request.form.get('username') }}</p>
{% endif %}
{# User-Agent #}
<p>浏览器: {{ request.headers.get('User-Agent') }}</p>
session:会话数据
jinja2
{# 读取会话 #}
{% if session.get('user_id') %}
<p>已登录,用户ID: {{ session['user_id'] }}</p>
{% else %}
<p>未登录</p>
{% endif %}
{# 购物车数量 #}
<p>购物车: {{ session.get('cart_count', 0) }} 件</p>
g:请求级全局对象
g 对象在每次请求中重置,常用于存储请求范围内的数据(如当前用户):
jinja2
{# 显示当前用户(g.user 通常在 before_request 中设置) #}
{% if g.user %}
<p>欢迎,{{ g.user.username }}</p>
{% endif %}
8.2 url_for在模板中的使用(生成URL, 静态文件引用)
url_for 是 Flask 模板中最常用的全局函数,用于根据视图函数名生成 URL。相比硬编码 URL,使用 url_for 有很多优势。
为什么用 url_for 而不是硬编码?
jinja2
{# 不推荐:硬编码URL #}
<a href="/user/profile">个人中心</a>
<img src="/static/images/logo.png">
{# 推荐:用url_for #}
<a href="{{ url_for('user.profile') }}">个人中心</a>
<img src="{{ url_for('static', filename='images/logo.png') }}">
使用 url_for 的优势:
- 解耦:URL 路径改变时,不需要修改模板。
- 自动处理特殊字符:URL 中的特殊字符会被正确编码。
- 支持动态参数:自动处理路由参数。
- 支持静态文件:自动添加版本号(配置后)。
- 支持蓝图:自动处理蓝图的 URL 前缀。
生成视图URL
jinja2
{# 基本用法 #}
<a href="{{ url_for('index') }}">首页</a>
{# 生成: / #}
{# 带参数 #}
<a href="{{ url_for('user.profile', user_id=42) }}">用户资料</a>
{# 生成: /user/42 #}
{# 带查询参数 #}
<a href="{{ url_for('blog.list', page=2, category='tech') }}">下一页</a>
{# 生成: /blog?page=2&category=tech #}
{# 锚点 #}
<a href="{{ url_for('blog.detail', post_id=1, _anchor='comments') }}">评论</a>
{# 生成: /post/1#comments #}
{# 外部URL #}
<a href="{{ url_for('index', _external=True) }}">分享链接</a>
{# 生成: http://example.com/ #}
{# 蓝图中的端点 #}
<a href="{{ url_for('admin.dashboard') }}">管理后台</a>
{# 生成: /admin/dashboard #}
引用静态文件
jinja2
{# CSS #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{# 生成: /static/css/style.css #}
{# JavaScript #}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{# 图片 #}
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">
{# 带子目录的静态文件 #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}">
动态静态文件路径
jinja2
{# 根据条件选择不同主题的CSS #}
<link rel="stylesheet"
href="{{ url_for('static', filename='css/' ~ theme ~ '.css') }}">
{# 用户头像 #}
<img src="{{ url_for('static', filename='avatars/' ~ user.avatar) }}">
url_for 的 _external 参数
jinja2
{# 内部URL(相对) #}
{{ url_for('index') }} {# / #}
{# 外部URL(绝对,带域名) #}
{{ url_for('index', _external=True) }} {# http://localhost:5000/ #}
{# 指定scheme #}
{{ url_for('index', _external=True, _scheme='https') }}
{# https://localhost:5000/ #}
这在生成邮件中的链接、RSS feed 中的链接时很有用。
url_for 使用中的常见注意事项
在实际项目开发中使用 url_for 时,有几个常见问题需要注意。首先,url_for 的第一个参数是"端点名"(endpoint name),而不是 URL 路径。在 Flask 中,端点名默认是视图函数的名字,使用蓝图时则是"蓝图名.视图函数名"。其次,url_for 中传递的额外关键字参数会作为 URL 的查询参数或路径变量,具体取决于路由定义。最后,在模板中应始终使用 url_for 而非硬编码 URL,这样在修改路由规则时只需改一处,所有链接自动更新。
8.3 get_flashed_messages(消息闪现)
get_flashed_messages 用于获取通过 flash() 函数设置的一次性消息。这是 Flask 提供的用户反馈机制,第九章会详细讲解,这里先做基本介绍。
基本用法
视图函数中设置消息:
python
from flask import flash, redirect, url_for
@app.route('/login', methods=['POST'])
def login():
# ... 验证逻辑 ...
if success:
flash('登录成功!欢迎回来。')
return redirect(url_for('index'))
else:
flash('用户名或密码错误', 'error')
return redirect(url_for('login'))
模板中获取并显示消息:
jinja2
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash-messages">
{% for message in messages %}
<div class="flash-message">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
带类别的消息
jinja2
{% 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 %}
8.4 自定义全局函数(@app.template_global)
除了 Flask 内置的全局函数,你可以注册自定义全局函数,让它们在所有模板中可用。
方式1:@app.template_global 装饰器
python
@app.template_global()
def current_year():
"""返回当前年份"""
from datetime import datetime
return datetime.now().year
@app.template_global('truncate')
def truncate_text(text, length=100, suffix='...'):
"""截断文本"""
if len(text) <= length:
return text
return text[:length] + suffix
jinja2
{# 直接使用,无需导入 #}
<footer>© {{ current_year() }} 我的网站</footer>
<p>{{ article.content | truncate(200) }}</p>
方式2:app.jinja_env.globals 字典
python
def current_year():
from datetime import datetime
return datetime.now().year
app.jinja_env.globals['current_year'] = current_year
全局函数 vs 过滤器
全局函数和过滤器有时可以互换,选择哪个取决于使用场景:
jinja2
{# 作为全局函数 #}
{{ current_year() }}
{# 作为过滤器 #}
{{ '' | current_year }}
一般来说:
- 如果是"无输入,有输出"的函数,用全局函数更自然。
- 如果是"转换某个值"的操作,用过滤器更自然。
实用的全局函数集合
python
@app.template_global()
def now():
"""返回当前时间"""
from datetime import datetime
return datetime.now()
@app.template_global()
def active_nav(endpoint):
"""判断当前端点是否活跃(用于导航高亮)"""
from flask import request
return 'active' if request.endpoint == endpoint else ''
@app.template_global()
def active_nav_starts(prefix):
"""判断当前端点是否以某前缀开头"""
from flask import request
return 'active' if request.endpoint and request.endpoint.startswith(prefix) else ''
@app.template_global()
def csrf_token():
"""生成CSRF令牌"""
from flask import session
import uuid
if '_csrf_token' not in session:
session['_csrf_token'] = uuid.uuid4().hex
return session['_csrf_token']
jinja2
{# 导航高亮 #}
<nav>
<a href="{{ url_for('index') }}" class="{{ active_nav('index') }}">首页</a>
<a href="{{ url_for('blog.list') }}" class="{{ active_nav_starts('blog.') }}">博客</a>
</nav>
{# CSRF令牌 #}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
...
</form>
8.5 上下文处理器(@app.context_processor, 注入模板变量)
上下文处理器(Context Processor)是一种在每次模板渲染前自动注入变量的机制。它是一个返回字典的函数,字典中的键值对会被合并到模板上下文中。
基本用法
python
@app.context_processor
def inject_site_info():
return dict(
site_name='我的博客',
site_description='分享技术与生活',
current_year=2024,
)
这样,所有模板中都可以直接使用 {``{ site_name }}、{``{ site_description }}、{``{ current_year }},无需在每个视图函数中重复传递。
注入当前用户
python
from flask import g
@app.context_processor
def inject_user():
return dict(current_user=g.user)
jinja2
{# 所有模板都能用 current_user #}
{% if current_user.is_authenticated %}
<p>欢迎,{{ current_user.username }}</p>
{% endif %}
注入配置
python
@app.context_processor
def inject_config():
return dict(
POSTS_PER_PAGE=config.get('POSTS_PER_PAGE', 10),
SITE_NAME=config.get('SITE_NAME', 'My Site'),
ENABLE_REGISTRATION=config.get('ENABLE_REGISTRATION', True),
)
多个上下文处理器
可以注册多个上下文处理器,它们返回的字典会被合并:
python
@app.context_processor
def inject_site_info():
return dict(site_name='我的网站')
@app.context_processor
def inject_user():
return dict(current_user=g.user)
@app.context_processor
def inject_settings():
return dict(theme='dark', lang='zh-CN')
所有返回的变量都会注入到模板中。
上下文处理器的执行时机
上下文处理器在每次调用 render_template 时执行。这意味着:
- 它会影响所有模板的渲染。
- 如果处理器中有耗时操作(如数据库查询),会影响所有页面的性能。
- 处理器中不要做太重的操作。
蓝图级上下文处理器
蓝图也可以有自己的上下文处理器,只对该蓝图的模板生效:
python
admin_bp = Blueprint('admin', __name__)
@admin_bp.context_processor
def inject_admin_vars():
return dict(admin_section='dashboard', admin_version='2.0')
8.6 模板环境配置(Environment对象)
Jinja2 的 Environment 对象是模板引擎的核心,它管理着加载器、过滤器、测试器、全局函数等所有配置。Flask 在内部维护了一个 app.jinja_env,你可以通过它来配置模板环境。
常用配置项
python
app = Flask(__name__)
# 修改 Jinja2 配置
app.jinja_env.trim_blocks = True # 去除块标签后的换行
app.jinja_env.lstrip_blocks = True # 去除块标签前的空白
app.jinja_env.auto_reload = app.debug # 调试模式下自动重载模板
app.jinja_env.undefined = StrictUndefined # 未定义变量报错
通过 jinja_options 在创建时配置
python
app = Flask(__name__)
app.jinja_options.update({
'trim_blocks': True,
'lstrip_blocks': True,
'extensions': [
'jinja2.ext.do', # {% do %} 表达式
'jinja2.ext.loopcontrols', # {% break %} / {% continue %}
'jinja2.ext.with_', # {% with %} (旧版兼容)
],
})
常用 Environment 属性
| 属性 | 说明 | 示例 |
|---|---|---|
filters |
过滤器字典 | env.filters['my_filter'] = func |
tests |
测试器字典 | env.tests['my_test'] = func |
globals |
全局对象字典 | env.globals['my_func'] = func |
autoescape |
自动转义设置 | env.autoescape = True |
loader |
模板加载器 | env.loader |
extensions |
已加载的扩展 | env.extensions |
查看所有内置过滤器
python
# 打印所有内置过滤器
for name in sorted(app.jinja_env.filters.keys()):
print(name)
添加扩展
python
# 添加 do 扩展(允许在模板中执行表达式但不输出)
app.jinja_env.add_extension('jinja2.ext.do')
# 添加 loopcontrols 扩展(break/continue)
app.jinja_env.add_extension('jinja2.ext.loopcontrols')
do 扩展的用法:
jinja2
{# {% do %} 执行表达式但不输出结果 #}
{% do items.append('new_item') %}
8.7 自定义全局对象
除了函数,你也可以把任何 Python 对象注入为全局变量。
注入常量
python
app.jinja_env.globals['MAX_UPLOAD_SIZE'] = 10 * 1024 * 1024 # 10MB
app.jinja_env.globals['SUPPORTED_LANGUAGES'] = ['zh', 'en', 'ja']
jinja2
{# 模板中使用 #}
<p>最大上传: {{ MAX_UPLOAD_SIZE / 1024 / 1024 }}MB</p>
<p>支持语言: {{ SUPPORTED_LANGUAGES | join(', ') }}</p>
注入类
python
app.jinja_env.globals['Math'] = __import__('math')
jinja2
{{ Math.ceil(3.2) }} {# 4 #}
{{ Math.pi }} {# 3.141592653589793 #}
不过要小心:注入太多全局对象会让模板变得难以维护,且容易引入安全问题。
8.8 上下文处理器实战: 注入用户信息与配置
让我们用一个完整的实战案例来演示上下文处理器的应用。
应用配置
python
from flask import Flask, g, session, request
from datetime import datetime
app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev-secret'
app.config['SITE_NAME'] = '技术博客'
app.config['SITE_URL'] = 'https://blog.example.com'
app.config['POSTS_PER_PAGE'] = 10
app.config['ENABLE_COMMENTS'] = True
app.config['ENABLE_REGISTRATION'] = True
app.config['AVAILABLE_THEMES'] = ['light', 'dark', 'auto']
# 模拟用户数据库
USERS = {
1: {'id': 1, 'username': 'admin', 'name': '管理员', 'is_admin': True, 'avatar': 'admin.png'},
2: {'id': 2, 'username': 'tom', 'name': 'Tom', 'is_admin': False, 'avatar': 'tom.png'},
}
模拟登录逻辑
python
@app.before_request
def load_user():
"""每次请求前加载当前用户"""
user_id = session.get('user_id')
if user_id and user_id in USERS:
g.user = USERS[user_id]
else:
g.user = None
上下文处理器
python
@app.context_processor
def inject_globals():
"""注入全局变量"""
return dict(
# 网站信息
site_name=app.config['SITE_NAME'],
site_url=app.config['SITE_URL'],
current_year=datetime.now().year,
# 当前用户
current_user=g.user,
is_logged_in=g.user is not None,
# 配置项
posts_per_page=app.config['POSTS_PER_PAGE'],
enable_comments=app.config['ENABLE_COMMENTS'],
enable_registration=app.config['ENABLE_REGISTRATION'],
available_themes=app.config['AVAILABLE_THEMES'],
# 页面信息
current_path=request.path,
current_endpoint=request.endpoint,
)
@app.context_processor
def inject_utilities():
"""注入工具函数"""
def is_active(endpoint, exact=False):
"""判断导航项是否活跃"""
if exact:
return 'active' if request.endpoint == endpoint else ''
return 'active' if request.endpoint and request.endpoint.startswith(endpoint) else ''
def is_path(path):
"""判断当前路径"""
return 'active' if request.path == path else ''
return dict(
is_active=is_active,
is_path=is_path,
)
模板中的使用
jinja2
{# base.html #}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{% block title %}{{ site_name }}{% endblock %}</title>
<meta name="description" content="{{ site_name }}">
</head>
<body class="theme-{{ current_user.theme if current_user else 'auto' }}">
<nav class="navbar">
<a href="{{ url_for('index') }}" class="navbar-brand">{{ site_name }}</a>
<ul class="nav">
<li class="{{ is_active('index', exact=True) }}">
<a href="{{ url_for('index') }}">首页</a>
</li>
<li class="{{ is_active('blog.') }}">
<a href="{{ url_for('blog.list') }}">博客</a>
</li>
<li class="{{ is_active('about') }}">
<a href="{{ url_for('about') }}">关于</a>
</li>
</ul>
<ul class="nav nav-right">
{% if is_logged_in %}
<li><a href="{{ url_for('user.profile') }}">{{ current_user.name }}</a></li>
<li><a href="{{ url_for('auth.logout') }}">退出</a></li>
{% elif enable_registration %}
<li><a href="{{ url_for('auth.register') }}">注册</a></li>
<li><a href="{{ url_for('auth.login') }}">登录</a></li>
{% endif %}
</ul>
</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>© {{ current_year }} {{ site_name }}</p>
<p>
{% if enable_comments %}评论已开启 | {% endif %}
每页 {{ posts_per_page }} 篇
</p>
</footer>
</body>
</html>
通过上下文处理器,我们把网站信息、当前用户、配置项、工具函数都注入到了全局上下文,所有模板都能直接使用,大大减少了视图函数的重复代码。
上下文处理器的执行时机与性能考量
理解上下文处理器的执行时机对于性能优化非常重要。上下文处理器在每次调用 render_template 时都会执行,这意味着:
- 如果一个页面渲染了多个模板(主模板 + include 的子模板),上下文处理器只执行一次,其结果在整个渲染过程中共享。
- 上下文处理器中的代码应该尽量轻量,避免执行耗时的数据库查询或复杂的计算。如果确实需要注入耗时计算的结果,应该考虑使用缓存。
- 多个上下文处理器可以同时注册,Flask 会依次执行它们,并将所有返回的字典合并到模板上下文中。执行顺序与注册顺序一致。
python
# 多个上下文处理器示例
@app.context_processor
def inject_site_info():
"""注入网站基础信息"""
return dict(site_name='我的博客', site_url='https://example.com')
@app.context_processor
def inject_user_info():
"""注入当前用户信息"""
return dict(current_user=g.user, is_logged_in=g.user is not None)
@app.context_processor
def inject_utilities():
"""注入工具函数"""
return dict(
now=datetime.now(),
format_date=lambda dt: dt.strftime('%Y-%m-%d') if dt else '',
)
这三个上下文处理器会在每次模板渲染时依次执行,它们返回的字典会被合并到一起,最终形成一个完整的上下文。在模板中,你可以直接使用 site_name、current_user、now、format_date 等变量,无需关心它们来自哪个上下文处理器。
全局函数与上下文处理器的区别
这是一个常见疑问:既然上下文处理器可以注入函数,那它和 @app.template_global 注册的全局函数有什么区别?
| 特性 | 全局函数(@app.template_global) | 上下文处理器(@app.context_processor) |
|---|---|---|
| 注册方式 | 装饰器注册单个函数 | 装饰器注册返回字典的函数 |
| 注入内容 | 只能注入可调用的函数 | 可以注入任意类型的变量(函数、对象、值) |
| 执行时机 | 模板渲染时按需调用 | 每次渲染前自动执行 |
| 参数来源 | 从模板调用时传入 | 从 Flask 上下文(request/session/g)获取 |
| 性能开销 | 只有被调用时才执行 | 每次渲染都执行,即使模板不使用 |
| 适用场景 | 工具函数、格式化函数 | 全局变量、当前用户、配置信息 |
简而言之:如果你要注入的是"函数"(按需调用),用 @app.template_global;如果你要注入的是"值"(每次渲染都需要),用 @app.context_processor。
Environment 对象的深入配置
Flask 的 app.jinja_env 是一个 jinja2.Environment 对象,它是 Jinja2 模板引擎的核心。通过配置 Environment,可以定制模板引擎的各种行为:
python
# 在 Flask 应用中配置 Jinja2 Environment
class CustomFlask(Flask):
"""自定义 Flask 类,修改 Jinja2 默认配置"""
jinja_options = Flask.jinja_options.copy()
jinja_options.update({
# 块开始/结束标记
'block_start_string': '{%',
'block_end_string': '%}',
# 变量开始/结束标记
'variable_start_string': '{{',
'variable_end_string': '}}',
# 注释开始/结束标记
'comment_start_string': '{#',
'comment_end_string': '#}',
# 自动转义(默认 True,对 .html/.htm/.xml 启用)
'autoescape': True,
# 空白控制
'trim_blocks': True,
'lstrip_blocks': True,
# 模板加载器(默认从 templates 目录加载)
'loader': jinja2.FileSystemLoader('templates'),
# 字节码缓存(生产环境加速)
'cache_size': 400, # 缓存的模板数量,0 为不缓存,-1 为无限制
# 自动重载(开发环境开启,生产环境关闭)
'auto_reload': True,
# 未定义变量的行为
'undefined': jinja2.Undefined, # 默认,访问未定义变量报错
# 'undefined': jinja2.ChainableUndefined, # 支持链式访问
# 'undefined': jinja2.DebugUndefined, # 调试模式,输出调试信息
# 'undefined': jinja2.StrictUndefined, # 严格模式,任何访问都报错
# 扩展列表
'extensions': [
'jinja2.ext.do', # {% do %} 表达式
'jinja2.ext.loopcontrols', # {% break %} {% continue %}
'jinja2.ext.with_', # {% with %} 语法
'jinja2.ext.autoescape', # 自动转义
'jinja2.ext.i18n', # 国际化支持
],
})
app = CustomFlask(__name__)
自定义 Undefined 行为
Jinja2 默认的 Undefined 类在访问未定义变量的属性时会抛出异常。但在某些场景下,你可能希望更宽松的行为:
python
import jinja2
# 方式1:DebugUndefined - 输出调试信息而不是报错
app.jinja_env.undefined = jinja2.DebugUndefined
# 效果:{{ undefined_var }} 输出 "*** undefined_var ***"
# 方式2:ChainableUndefined - 允许链式访问未定义的属性
app.jinja_env.undefined = jinja2.ChainableUndefined
# 效果:{{ user.profile.avatar }} 即使 user 未定义也不报错,返回 Undefined
# 方式3:StrictUndefined - 严格模式,任何对未定义变量的访问都抛出异常
app.jinja_env.undefined = jinja2.StrictUndefined
# 效果:最适合开发环境,尽早发现未定义变量的问题
选择哪种 Undefined 行为取决于项目阶段和团队偏好。开发阶段推荐使用 StrictUndefined 以尽早发现问题;生产环境可以使用默认的 Undefined 或 ChainableUndefined 以提高容错性。
第九章 Flask消息闪现与模板
消息闪现(Flash)是 Flask 提供的一种轻量级用户反馈机制。它允许你在一次请求中存储消息,在下一次请求中显示,然后自动清除。这种"一次性消息"非常适合在重定向后向用户展示操作结果(如"保存成功"、"登录失败"等)。
9.1 flash消息机制概述
什么是消息闪现?
消息闪现的核心思想是:在一个请求(通常是 POST)中设置消息,重定向到另一个页面后,在那个页面显示消息,消息显示后自动删除。
典型场景:
- 用户提交表单 -> 后端处理 -> 重定向到结果页 -> 显示"操作成功"
- 用户登录失败 -> 重定向回登录页 -> 显示"用户名或密码错误"
- 用户发表评论 -> 重定向到文章页 -> 显示"评论发表成功"
为什么需要闪现?
在 POST-Redirect-GET 模式(PRG)中,用户提交表单后,后端处理完会重定向到另一个页面。如果直接在响应中显示消息,用户刷新页面时会重复提交表单。重定向解决了这个问题,但重定向后如何把"操作结果"告诉用户?这就是闪现消息的作用。
底层原理
Flask 的闪现消息基于 Session 实现:
flash(message)把消息存入 session。- 重定向后,
get_flashed_messages()从 session 读取消息并清除。 - 消息只在"下一次请求"中可用,之后自动消失。
9.2 flash函数使用(flash(message, category))
基本用法
python
from flask import flash, redirect, url_for, render_template
@app.route('/post/create', methods=['GET', 'POST'])
def create_post():
if request.method == 'POST':
title = request.form.get('title')
content = request.form.get('content')
if not title:
flash('标题不能为空!')
return redirect(url_for('create_post'))
# 保存文章...
flash('文章发布成功!')
return redirect(url_for('post_detail', post_id=42))
return render_template('create_post.html')
带类别的flash
flash 的第二个参数是消息类别(category),用于区分不同类型的消息:
python
flash('保存成功', 'success')
flash('请填写必填字段', 'warning')
flash('发生错误,请稍后重试', 'error')
flash('您有新的消息', 'info')
类别是自定义的字符串,常见的有:success、info、warning、error(或 danger)。
9.3 get_flashed_messages在模板中使用
基本用法
jinja2
{# 获取所有闪现消息 #}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash-messages">
{% for message in messages %}
<div class="flash-message">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
为什么用 with?
get_flashed_messages() 一旦调用,消息就会从 session 中清除。用 {% with %} 把结果存到变量中,避免多次调用导致消息丢失。
带类别获取
jinja2
{% 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 %}
9.4 消息分类(category过滤)
你可以按类别过滤闪现消息,只显示特定类别的消息。
按类别过滤
jinja2
{# 只显示 error 类别的消息 #}
{% with errors = get_flashed_messages(category_filter=['error']) %}
{% if errors %}
<div class="error-messages">
{% for error in errors %}
<div class="error">{{ error }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{# 只显示 success 类别的消息 #}
{% with successes = get_flashed_messages(category_filter=['success']) %}
{% if successes %}
<div class="success-messages">
{% for msg in successes %}
<div class="success">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
注意:过滤后的消息仍会被清除
即使你只获取了某个类别的消息,所有类别的消息都会被清除。也就是说,get_flashed_messages() 只能调用一次(每种过滤方式算一次调用,但消息池会被清空)。
如果你需要在不同位置显示不同类别的消息,应该一次性获取所有消息,然后在模板中分类处理:
jinja2
{# 一次性获取所有消息,按类别分组 #}
{% set messages = get_flashed_messages(with_categories=true) %}
{# 在表单区域显示 error #}
{% set form_errors = messages | selectattr(0, 'equalto', 'error') | list %}
{% if form_errors %}
<div class="form-errors">
{% for _, msg in form_errors %}
<p class="error">{{ msg }}</p>
{% endfor %}
</div>
{% endif %}
{# 在页面顶部显示 success/info/warning #}
{% set alerts = messages | rejectattr(0, 'equalto', 'error') | list %}
{% if alerts %}
<div class="alerts">
{% for category, msg in alerts %}
<div class="alert alert-{{ category }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
9.5 消息模板渲染(Alert组件)
通常,闪现消息会用 Bootstrap 的 Alert 组件或自定义样式来展示。
Bootstrap Alert 样式
jinja2
{# 放在 base.html 中,所有页面都能显示 #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container mt-3">
{% for category, message in messages %}
<div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
封装为宏
jinja2
{# macros/ui.html #}
{% macro flash_messages() %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, message in messages %}
{% set category = category if category != 'message' else 'info' %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{% if category == 'success' %}
<i class="icon-check"></i>
{% elif category == 'error' or category == 'danger' %}
<i class="icon-error"></i>
{% elif category == 'warning' %}
<i class="icon-warning"></i>
{% else %}
<i class="icon-info"></i>
{% endif %}
<span>{{ message }}</span>
<button type="button" class="close" data-dismiss="alert">
<span>×</span>
</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% endmacro %}
jinja2
{# base.html #}
{% from 'macros/ui.html' import flash_messages %}
<body>
{{ flash_messages() }}
{% block content %}{% endblock %}
</body>
9.6 消息闪现与模板继承结合
闪现消息通常放在基础模板中,这样所有继承它的子页面都能自动显示消息。
base.html
jinja2
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}我的网站{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav>导航栏</nav>
{# 闪现消息区域 #}
{% block flash %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">
{{ message }}
<button class="flash-close">×</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% endblock %}
<main>
{% block content %}{% endblock %}
</main>
<footer>底部</footer>
</body>
</html>
子模板正常继承即可,无需关心消息显示:
jinja2
{% extends 'base.html' %}
{% block content %}
<p>页面内容</p>
{% endblock %}
如果某个页面需要自定义消息显示位置,可以覆写 flash block:
jinja2
{% extends 'base.html' %}
{% block flash %}
{# 这个页面不显示闪现消息,或放在其他位置 #}
<div class="custom-flash-area">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="my-flash my-flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
</div>
{% endblock %}
9.7 一次性消息 vs 持久消息
Flask 的 flash() 默认是一次性消息:显示一次后自动清除。但有时候你需要消息在多次请求中保持(如未读通知数),这时应该用 session 而不是 flash。
一次性消息(flash)
python
# 设置:只在下一次请求中可用
flash('操作成功')
# 获取后自动清除
messages = get_flashed_messages()
持久消息(session)
python
# 设置:在 session 中保持,直到手动清除
session['notification'] = '您有3条未读消息'
# 获取:不会自动清除
msg = session.get('notification')
# 手动清除
session.pop('notification', None)
选择建议
| 场景 | 机制 |
|---|---|
| 操作反馈(保存成功、登录失败) | flash |
| 表单验证错误 | flash 或 WTForms |
| 未读通知数 | session |
| 用户偏好(主题、语言) | session 或数据库 |
| 全局提示(维护通知) | context processor + 数据库 |
9.8 消息闪现实战: 用户操作反馈系统
让我们构建一个完整的用户操作反馈系统,演示 flash 在实际项目中的使用。
视图函数
python
from flask import Flask, flash, redirect, url_for, render_template, request, session
app = Flask(__name__)
app.secret_key = 'dev-secret'
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# 模拟数据
posts = {}
next_id = 1
@app.route('/')
def index():
return render_template('index.html', posts=list(posts.values()))
@app.route('/post/create', methods=['GET', 'POST'])
def create_post():
global next_id
if request.method == 'POST':
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
# 验证
errors = []
if not title:
errors.append('标题不能为空')
if len(title) > 100:
errors.append('标题不能超过100字')
if not content:
errors.append('内容不能为空')
if errors:
for error in errors:
flash(error, 'error')
return render_template('create_post.html', title=title, content=content)
# 保存
post_id = next_id
next_id += 1
posts[post_id] = {'id': post_id, 'title': title, 'content': content}
flash(f'文章《{title}》发布成功!', 'success')
return redirect(url_for('post_detail', post_id=post_id))
return render_template('create_post.html')
@app.route('/post/<int:post_id>')
def post_detail(post_id):
post = posts.get(post_id)
if not post:
flash('文章不存在', 'error')
return redirect(url_for('index'))
return render_template('post_detail.html', post=post)
@app.route('/post/<int:post_id>/delete', methods=['POST'])
def delete_post(post_id):
post = posts.get(post_id)
if not post:
flash('文章不存在,无法删除', 'error')
return redirect(url_for('index'))
title = post['title']
del posts[post_id]
flash(f'文章《{title}》已删除', 'info')
return redirect(url_for('index'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username == 'admin' and password == '123456':
session['user'] = username
flash(f'欢迎回来,{username}!', 'success')
return redirect(url_for('index'))
else:
flash('用户名或密码错误', 'error')
return render_template('login.html', username=username)
return render_template('login.html')
@app.route('/logout')
def logout():
user = session.pop('user', None)
if user:
flash(f'再见,{user}!', 'info')
return redirect(url_for('index'))
base.html
jinja2
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{% block title %}博客系统{% endblock %}</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 0; }
nav { background: #333; color: #fff; padding: 10px 20px; }
nav a { color: #fff; margin-right: 15px; text-decoration: none; }
.container { max-width: 800px; margin: 20px auto; padding: 0 20px; }
/* Flash 消息样式 */
.flash-messages { margin: 20px 0; }
.flash {
padding: 12px 20px;
margin-bottom: 10px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.flash-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.flash-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.flash-warning { background: #fff3cd; color: #856404; border: 1px solid #ffeaa7; }
.flash-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
.flash-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
opacity: 0.5;
}
.flash-close:hover { opacity: 1; }
.post { border: 1px solid #ddd; padding: 15px; margin-bottom: 15px; border-radius: 4px; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; }
.form-group input, .form-group textarea {
width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
}
.btn { padding: 8px 20px; border: none; border-radius: 4px; cursor: pointer; }
.btn-primary { background: #007bff; color: #fff; }
.btn-danger { background: #dc3545; color: #fff; }
</style>
</head>
<body>
<nav>
<a href="{{ url_for('index') }}">首页</a>
<a href="{{ url_for('create_post') }}">写文章</a>
{% if session.get('user') %}
<span style="float:right">欢迎,{{ session['user'] }}</span>
<a href="{{ url_for('logout') }}" style="float:right">退出</a>
{% else %}
<a href="{{ url_for('login') }}" style="float:right">登录</a>
{% endif %}
</nav>
<div class="container">
{# 闪现消息 #}
{% block flash %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">
<span>{{ message }}</span>
<button class="flash-close" onclick="this.parentElement.remove()">×</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% endblock %}
{% block content %}{% endblock %}
</div>
<script>
// 3秒后自动关闭成功消息
setTimeout(function() {
document.querySelectorAll('.flash-success').forEach(function(el) {
el.style.opacity = '0';
el.style.transition = 'opacity 0.5s';
setTimeout(() => el.remove(), 500);
});
}, 3000);
</script>
</body>
</html>
index.html
jinja2
{% extends 'base.html' %}
{% block title %}首页 - 博客系统{% endblock %}
{% block content %}
<h1>文章列表</h1>
{% for post in posts %}
<div class="post">
<h2><a href="{{ url_for('post_detail', post_id=post.id) }}">{{ post.title }}</a></h2>
<p>{{ post.content[:50] }}...</p>
</div>
{% else %}
<p>还没有文章,<a href="{{ url_for('create_post') }}">写第一篇</a></p>
{% endfor %}
{% endblock %}
create_post.html
jinja2
{% extends 'base.html' %}
{% block title %}写文章 - 博客系统{% endblock %}
{% block content %}
<h1>写文章</h1>
<form method="POST">
<div class="form-group">
<label>标题</label>
<input type="text" name="title" value="{{ title or '' }}" maxlength="100">
</div>
<div class="form-group">
<label>内容</label>
<textarea name="content" rows="10">{{ content or '' }}</textarea>
</div>
<button type="submit" class="btn btn-primary">发布</button>
<a href="{{ url_for('index') }}" class="btn">取消</a>
</form>
{% endblock %}
login.html
jinja2
{% extends 'base.html' %}
{% block title %}登录 - 博客系统{% endblock %}
{% block content %}
<h1>登录</h1>
<form method="POST">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" value="{{ username or '' }}">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password">
</div>
<button type="submit" class="btn btn-primary">登录</button>
</form>
<p>提示: admin / 123456</p>
{% endblock %}
post_detail.html
jinja2
{% extends 'base.html' %}
{% block title %}{{ post.title }} - 博客系统{% endblock %}
{% block content %}
<article>
<h1>{{ post.title }}</h1>
<div>{{ post.content }}</div>
</article>
<hr>
<form method="POST" action="{{ url_for('delete_post', post_id=post.id) }}"
onsubmit="return confirm('确定删除这篇文章吗?')">
<a href="{{ url_for('index') }}" class="btn">返回列表</a>
<button type="submit" class="btn btn-danger">删除</button>
</form>
{% endblock %}
这个完整的实战案例展示了:
- 使用
flash()在 POST 请求后设置消息。 - 重定向后通过
get_flashed_messages()在模板中显示。 - 按类别(success/error/info)显示不同样式的消息。
- 消息显示后自动消失。
- JavaScript 增强用户体验(自动关闭成功消息)。
消息闪现的进阶用法与注意事项
在实际项目开发中,消息闪现还有一些进阶用法和需要注意的细节,下面逐一讲解。
1. 消息的追加与覆盖
flash() 函数是追加式的,多次调用会累积多条消息:
python
@app.route('/register', methods=['POST'])
def register():
username = request.form.get('username')
email = request.form.get('email')
# 可能产生多条消息
if len(username) < 3:
flash('用户名至少3个字符', 'error')
if '@' not in email:
flash('邮箱格式不正确', 'error')
# 如果没有错误,注册成功
if not get_flashed_messages(category_filter=['error']):
flash(f'欢迎加入,{username}!', 'success')
flash('一封验证邮件已发送到你的邮箱', 'info')
return redirect(url_for('index'))
return render_template('register.html', username=username, email=email)
在模板中,get_flashed_messages() 会返回所有累积的消息:
jinja2
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
<div class="alert-container">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
2. 跨请求消息的持久化
默认情况下,闪现消息在下一次请求后就会被清除。但有时候我们希望消息在多个请求中都可见,这可以通过 session 手动实现:
python
@app.route('/import-data', methods=['POST'])
def import_data():
"""长时间运行的数据导入任务"""
# 启动后台任务
task_id = start_background_task('import', request.files['file'])
# 使用 session 持久化消息,不使用 flash
session['task_message'] = f'数据导入任务已启动(任务ID: {task_id})'
session['task_id'] = task_id
return redirect(url_for('import_status', task_id=task_id))
@app.route('/import/status/<task_id>')
def import_status(task_id):
# 从 session 读取持久消息
task_message = session.pop('task_message', None)
if task_message:
flash(task_message, 'info') # 转为闪现消息
progress = get_task_progress(task_id)
return render_template('import_status.html', progress=progress, task_id=task_id)
3. 消息模板的组件化封装
对于大型项目,建议把消息显示逻辑封装成宏,便于统一管理和复用:
jinja2
{# macros/flash.html #}
{% macro render_flash(with_categories=True, dismissible=True) %}
{% set messages = get_flashed_messages(with_categories=with_categories) if with_categories
else get_flashed_messages() %}
{% if messages %}
<div class="flash-container" id="flashContainer">
{% if with_categories %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} {% if dismissible %}alert-dismissible{% endif %}"
role="alert">
<span class="alert-icon">
{% if category == 'success' %}✔
{% elif category == 'error' %}✖
{% elif category == 'warning' %}⚠
{% elif category == 'info' %}ℹ
{% endif %}
</span>
<span class="alert-text">{{ message }}</span>
{% if dismissible %}
<button type="button" class="alert-close"
onclick="this.parentElement.remove()">
×
</button>
{% endif %}
</div>
{% endfor %}
{% else %}
{% for message in messages %}
<div class="alert alert-info" role="alert">
<span class="alert-text">{{ message }}</span>
</div>
{% endfor %}
{% endif %}
</div>
{% if dismissible %}
<script>
// 5秒后自动关闭成功消息
setTimeout(function() {
document.querySelectorAll('.alert-success').forEach(function(el) {
el.style.transition = 'opacity 0.5s';
el.style.opacity = '0';
setTimeout(function() { el.remove(); }, 500);
});
}, 5000);
</script>
{% endif %}
{% endif %}
{% endmacro %}
在 base.html 中使用:
jinja2
{% from 'macros/flash.html' import render_flash %}
<body>
<nav>...</nav>
{{ render_flash(with_categories=True, dismissible=True) }}
<main>
{% block content %}{% endblock %}
</main>
</body>
这种封装方式的好处是:消息显示逻辑集中在一处,修改样式或行为只需改一个文件;所有页面自动继承,无需在每个模板中重复编写消息显示代码;支持参数配置,灵活性高。
第十章 模板实战与最佳实践
前面九章我们已经系统学习了 Jinja2 的所有核心知识。本章将这些知识融会贯通,通过完整的实战案例和最佳实践指南,帮助你构建真正可用于生产的模板系统。
10.1 实战: 完整博客模板系统(首页/列表/详情/编辑/错误页面)
让我们构建一个完整的博客模板系统,涵盖首页、文章列表、文章详情、文章编辑、错误页面等全部场景。这个案例综合运用了模板继承、宏、过滤器、上下文处理器、消息闪现等所有技术。
项目结构
blog/
├── app.py
├── templates/
│ ├── base.html # 基础布局
│ ├── macros/
│ │ ├── forms.html # 表单宏
│ │ ├── ui.html # UI组件宏
│ │ └── pagination.html # 分页宏
│ ├── partials/
│ │ ├── header.html # 页头
│ │ ├── footer.html # 页脚
│ │ ├── sidebar.html # 侧边栏
│ │ └── post_card.html # 文章卡片
│ ├── index.html # 首页
│ ├── blog/
│ │ ├── list.html # 文章列表
│ │ ├── detail.html # 文章详情
│ │ ├── edit.html # 文章编辑
│ │ └── archive.html # 归档
│ └── errors/
│ ├── 404.html # 404页面
│ ├── 500.html # 500页面
│ └── 403.html # 403页面
└── static/
├── css/
│ └── style.css
└── js/
└── main.js
app.py:视图函数与配置
python
from flask import Flask, render_template, request, redirect, url_for, flash, session, abort
from datetime import datetime
import re
app = Flask(__name__)
app.secret_key = 'your-secret-key-here'
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# ========== 模拟数据 ==========
POSTS = [
{
'id': 1,
'title': 'Flask入门指南',
'content': 'Flask是一个轻量级的Python Web框架...\n\n它简单易用,适合初学者。',
'summary': '本文介绍Flask框架的基本概念和入门方法。',
'category': '技术',
'tags': ['Flask', 'Python', 'Web'],
'author': {'name': '管理员', 'avatar': 'admin.png'},
'created_at': datetime(2024, 1, 15, 10, 30),
'updated_at': datetime(2024, 1, 16, 14, 0),
'views': 1234,
'status': 'published',
},
{
'id': 2,
'title': 'Jinja2模板引擎详解',
'content': 'Jinja2是Flask的默认模板引擎,功能强大...',
'summary': '深入讲解Jinja2模板引擎的各个方面。',
'category': '技术',
'tags': ['Jinja2', 'Flask', '模板'],
'author': {'name': '管理员', 'avatar': 'admin.png'},
'created_at': datetime(2024, 2, 20, 9, 0),
'updated_at': datetime(2024, 2, 20, 9, 0),
'views': 5678,
'status': 'published',
},
{
'id': 3,
'title': '我的旅行日记',
'content': '今年夏天去了美丽的云南...\n\n大理、丽江、香格里拉...',
'summary': '记录云南之行的美好时光。',
'category': '生活',
'tags': ['旅行', '云南'],
'author': {'name': '管理员', 'avatar': 'admin.png'},
'created_at': datetime(2024, 3, 1, 16, 45),
'updated_at': datetime(2024, 3, 1, 16, 45),
'views': 890,
'status': 'published',
},
]
# ========== 自定义过滤器 ==========
@app.template_filter('format_date')
def format_date(value, fmt='%Y-%m-%d'):
if isinstance(value, datetime):
return value.strftime(fmt)
return value
@app.template_filter('timeago')
def timeago(value):
if not isinstance(value, datetime):
return value
diff = datetime.now() - value
seconds = diff.total_seconds()
if seconds < 60:
return '刚刚'
elif seconds < 3600:
return f'{int(seconds / 60)}分钟前'
elif seconds < 86400:
return f'{int(seconds / 3600)}小时前'
elif seconds < 604800:
return f'{int(seconds / 86400)}天前'
else:
return value.strftime('%Y-%m-%d')
@app.template_filter('reading_time')
def reading_time(content):
"""估算阅读时间(按每分钟300字计算)"""
char_count = len(re.sub(r'\s', '', content))
minutes = max(1, char_count // 300)
return f'{minutes}分钟'
@app.template_filter('excerpt')
def excerpt(content, length=100):
"""生成摘要"""
text = re.sub(r'<[^>]+>', '', content) # 去HTML标签
text = re.sub(r'\s+', ' ', text).strip()
if len(text) <= length:
return text
return text[:length] + '...'
# ========== 自定义全局函数 ==========
@app.template_global()
def active_nav(endpoint, exact=False):
if exact:
return 'active' if request.endpoint == endpoint else ''
return 'active' if request.endpoint and request.endpoint.startswith(endpoint) else ''
# ========== 上下文处理器 ==========
@app.context_processor
def inject_globals():
return dict(
site_name='技术博客',
site_description='分享技术与生活的个人博客',
current_year=datetime.now().year,
categories=[
{'name': '技术', 'slug': 'tech', 'count': 2},
{'name': '生活', 'slug': 'life', 'count': 1},
],
popular_tags=['Flask', 'Python', 'Jinja2', '旅行', '云南'],
)
# ========== 路由 ==========
@app.route('/')
def index():
latest_posts = sorted(POSTS, key=lambda p: p['created_at'], reverse=True)[:5]
return render_template('index.html', posts=latest_posts, page_title='首页')
@app.route('/blog')
def blog_list():
page = request.args.get('page', 1, type=int)
category = request.args.get('category')
tag = request.args.get('tag')
filtered = POSTS
if category:
filtered = [p for p in filtered if p['category'] == category]
if tag:
filtered = [p for p in filtered if tag in p['tags']]
per_page = 5
total = len(filtered)
total_pages = max(1, (total + per_page - 1) // per_page)
start = (page - 1) * per_page
page_posts = filtered[start:start + per_page]
return render_template(
'blog/list.html',
posts=page_posts,
page=page,
total_pages=total_pages,
current_category=category,
current_tag=tag,
)
@app.route('/blog/<int:post_id>')
def blog_detail(post_id):
post = next((p for p in POSTS if p['id'] == post_id), None)
if not post:
abort(404)
# 上一篇/下一篇
published = [p for p in POSTS if p['status'] == 'published']
idx = published.index(post) if post in published else -1
prev_post = published[idx - 1] if idx > 0 else None
next_post = published[idx + 1] if 0 <= idx < len(published) - 1 else None
return render_template('blog/detail.html', post=post, prev_post=prev_post, next_post=next_post)
@app.route('/blog/<int:post_id>/edit', methods=['GET', 'POST'])
def blog_edit(post_id):
post = next((p for p in POSTS if p['id'] == post_id), None)
if not post:
abort(404)
if request.method == 'POST':
title = request.form.get('title', '').strip()
content = request.form.get('content', '').strip()
category = request.form.get('category', '').strip()
tags = request.form.get('tags', '').strip()
errors = []
if not title:
errors.append('标题不能为空')
if not content:
errors.append('内容不能为空')
if errors:
for err in errors:
flash(err, 'error')
return render_template('blog/edit.html', post=post, form_data=request.form)
post['title'] = title
post['content'] = content
post['category'] = category
post['tags'] = [t.strip() for t in tags.split(',') if t.strip()]
post['updated_at'] = datetime.now()
flash('文章更新成功!', 'success')
return redirect(url_for('blog_detail', post_id=post_id))
return render_template('blog/edit.html', post=post)
@app.route('/blog/archive')
def blog_archive():
archives = {}
for post in POSTS:
year = post['created_at'].year
month = post['created_at'].month
key = f'{year}年{month}月'
if key not in archives:
archives[key] = {'year': year, 'month': month, 'posts': []}
archives[key]['posts'].append(post)
return render_template('blog/archive.html', archives=list(archives.values()))
@app.route('/about')
def about():
return render_template('index.html', page_title='关于', about_page=True)
# ========== 错误处理 ==========
@app.errorhandler(404)
def not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def server_error(e):
return render_template('errors/500.html'), 500
@app.errorhandler(403)
def forbidden(e):
return render_template('errors/403.html'), 403
if __name__ == '__main__':
app.run(debug=True)
base.html:基础布局
jinja2
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}{{ site_description }}{% endblock %}">
<title>{% block title %}{{ site_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block styles %}{% endblock %}
</head>
<body>
{% include 'partials/header.html' %}
<div class="container">
{# 闪现消息 #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
<button class="alert-close" onclick="this.parentElement.remove()">×</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="layout">
<main class="main-content">
{% block content %}{% endblock %}
</main>
{% block sidebar %}
{% include 'partials/sidebar.html' %}
{% endblock %}
</div>
</div>
{% include 'partials/footer.html' %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
partials/header.html
jinja2
<header class="site-header">
<nav class="navbar">
<a href="{{ url_for('index') }}" class="navbar-brand">{{ site_name }}</a>
<ul class="nav-links">
<li class="{{ active_nav('index', exact=True) }}">
<a href="{{ url_for('index') }}">首页</a>
</li>
<li class="{{ active_nav('blog_list') }}">
<a href="{{ url_for('blog_list') }}">博客</a>
</li>
<li class="{{ active_nav('blog_archive') }}">
<a href="{{ url_for('blog_archive') }}">归档</a>
</li>
<li class="{{ active_nav('about') }}">
<a href="{{ url_for('about') }}">关于</a>
</li>
</ul>
</nav>
</header>
partials/sidebar.html
jinja2
<aside class="sidebar">
<div class="widget">
<h3>分类</h3>
<ul class="category-list">
{% for cat in categories %}
<li>
<a href="{{ url_for('blog_list', category=cat.name) }}">
{{ cat.name }} ({{ cat.count }})
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="widget">
<h3>热门标签</h3>
<div class="tag-cloud">
{% for tag in popular_tags %}
<a href="{{ url_for('blog_list', tag=tag) }}" class="tag">{{ tag }}</a>
{% endfor %}
</div>
</div>
<div class="widget">
<h3>搜索</h3>
<form action="{{ url_for('blog_list') }}" method="GET">
<input type="text" name="q" placeholder="搜索文章..." value="{{ request.args.get('q', '') }}">
<button type="submit">搜索</button>
</form>
</div>
</aside>
partials/footer.html
jinja2
<footer class="site-footer">
<div class="footer-content">
<p>© {{ current_year }} {{ site_name }}. All rights reserved.</p>
<p class="footer-links">
<a href="{{ url_for('index') }}">首页</a> |
<a href="{{ url_for('blog_list') }}">博客</a> |
<a href="{{ url_for('about') }}">关于</a>
</p>
</div>
</footer>
partials/post_card.html
jinja2
{# 文章卡片片段,可被 include #}
<article class="post-card">
<h2 class="post-title">
<a href="{{ url_for('blog_detail', post_id=post.id) }}">{{ post.title }}</a>
</h2>
<div class="post-meta">
<span class="post-date">{{ post.created_at | format_date }}</span>
<span class="post-author">by {{ post.author.name }}</span>
<span class="post-category">{{ post.category }}</span>
<span class="post-views">{{ post.views }} 次阅读</span>
</div>
<p class="post-summary">{{ post.summary or post.content | excerpt(120) }}</p>
<div class="post-tags">
{% for tag in post.tags %}
<a href="{{ url_for('blog_list', tag=tag) }}" class="tag">{{ tag }}</a>
{% endfor %}
</div>
<a href="{{ url_for('blog_detail', post_id=post.id) }}" class="read-more">阅读全文 →</a>
</article>
index.html:首页
jinja2
{% extends 'base.html' %}
{% from 'macros/pagination.html' import render_pagination %}
{% block title %}{{ page_title }} - {{ site_name }}{% endblock %}
{% block content %}
{% if about_page %}
{# 关于页面内容 #}
<div class="about-page">
<h1>关于我</h1>
<p>你好,我是{{ site_name }}的作者。</p>
<p>{{ site_description }}</p>
</div>
{% else %}
{# 首页:最新文章 #}
<section class="latest-posts">
<h1>最新文章</h1>
{% for post in posts %}
{% include 'partials/post_card.html' %}
{% else %}
<div class="empty-state">
<p>还没有文章,敬请期待!</p>
</div>
{% endfor %}
</section>
{% endif %}
{% endblock %}
blog/list.html:文章列表
jinja2
{% extends 'base.html' %}
{% from 'macros/pagination.html' import render_pagination %}
{% block title %}博客文章 - {{ site_name }}{% endblock %}
{% block content %}
<div class="blog-list-header">
<h1>
{% if current_category %}分类: {{ current_category }}
{% elif current_tag %}标签: {{ current_tag }}
{% else %}全部文章
{% endif %}
</h1>
<span class="post-count">共 {{ posts | length }} 篇</span>
</div>
{% for post in posts %}
{% include 'partials/post_card.html' %}
{% else %}
<div class="empty-state">
<p>没有找到相关文章。</p>
<a href="{{ url_for('blog_list') }}" class="btn">查看全部</a>
</div>
{% endfor %}
{{ render_pagination(page, total_pages, 'blog_list', category=current_category, tag=current_tag) }}
{% endblock %}
blog/detail.html:文章详情
jinja2
{% extends 'base.html' %}
{% block title %}{{ post.title }} - {{ site_name }}{% endblock %}
{% block meta_description %}{{ post.summary }}{% endblock %}
{% block content %}
<article class="blog-detail">
<h1 class="post-title">{{ post.title }}</h1>
<div class="post-meta">
<span class="post-author">
<img src="{{ url_for('static', filename='images/' ~ post.author.avatar) }}" alt="{{ post.author.name }}">
{{ post.author.name }}
</span>
<span class="post-date">{{ post.created_at | format_date('%Y年%m月%d日') }}</span>
<span class="post-views">{{ post.views }} 次阅读</span>
<span class="reading-time">预计阅读 {{ post.content | reading_time }}</span>
{% if post.updated_at > post.created_at %}
<span class="post-updated">最后更新: {{ post.updated_at | timeago }}</span>
{% endif %}
</div>
<div class="post-content">
{% for paragraph in post.content.split('\n\n') %}
<p>{{ paragraph | replace('\n', '<br>') | safe }}</p>
{% endfor %}
</div>
<div class="post-footer">
<div class="post-tags">
<strong>标签:</strong>
{% for tag in post.tags %}
<a href="{{ url_for('blog_list', tag=tag) }}" class="tag">{{ tag }}</a>
{% endfor %}
</div>
<div class="post-category">
<strong>分类:</strong>
<a href="{{ url_for('blog_list', category=post.category) }}">{{ post.category }}</a>
</div>
{% if session.get('user') %}
<a href="{{ url_for('blog_edit', post_id=post.id) }}" class="btn btn-edit">编辑</a>
{% endif %}
</div>
</article>
{# 上一篇/下一篇导航 #}
{% if prev_post or next_post %}
<nav class="post-nav">
{% if prev_post %}
<a href="{{ url_for('blog_detail', post_id=prev_post.id) }}" class="prev">
<span class="label">← 上一篇</span>
<span class="title">{{ prev_post.title }}</span>
</a>
{% endif %}
{% if next_post %}
<a href="{{ url_for('blog_detail', post_id=next_post.id) }}" class="next">
<span class="label">下一篇 →</span>
<span class="title">{{ next_post.title }}</span>
</a>
{% endif %}
</nav>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// 复制链接功能
document.querySelector('.copy-link')?.addEventListener('click', function() {
navigator.clipboard.writeText(window.location.href).then(() => {
alert('链接已复制!');
});
});
</script>
{% endblock %}
blog/edit.html:文章编辑
jinja2
{% extends 'base.html' %}
{% from 'macros/forms.html' import input, textarea, submit %}
{% block title %}编辑文章 - {{ site_name }}{% endblock %}
{% block content %}
<h1>编辑文章</h1>
<form method="POST" action="{{ url_for('blog_edit', post_id=post.id) }}">
<div class="form-group">
<label for="title">标题</label>
<input type="text" id="title" name="title"
value="{{ form_data.title if form_data else post.title }}"
required maxlength="100">
</div>
<div class="form-group">
<label for="category">分类</label>
<select id="category" name="category">
{% for cat in categories %}
<option value="{{ cat.name }}"
{{ 'selected' if (form_data.category if form_data else post.category) == cat.name }}>
{{ cat.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="tags">标签(逗号分隔)</label>
<input type="text" id="tags" name="tags"
value="{{ form_data.tags if form_data else post.tags | join(', ') }}">
</div>
<div class="form-group">
<label for="content">内容</label>
<textarea id="content" name="content" rows="15" required>{{ form_data.content if form_data else post.content }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
<a href="{{ url_for('blog_detail', post_id=post.id) }}" class="btn btn-cancel">取消</a>
</div>
</form>
<div class="post-info">
<p>创建时间: {{ post.created_at | format_date('%Y-%m-%d %H:%M') }}</p>
<p>更新时间: {{ post.updated_at | format_date('%Y-%m-%d %H:%M') }}</p>
</div>
{% endblock %}
blog/archive.html:归档
jinja2
{% extends 'base.html' %}
{% block title %}归档 - {{ site_name }}{% endblock %}
{% block content %}
<h1>文章归档</h1>
{% for archive in archives %}
<div class="archive-group">
<h2 class="archive-title">{{ archive.year }}年{{ archive.month }}月</h2>
<ul class="archive-posts">
{% for post in archive.posts %}
<li>
<span class="post-date">{{ post.created_at | format_date('%m-%d') }}</span>
<a href="{{ url_for('blog_detail', post_id=post.id) }}">{{ post.title }}</a>
<span class="post-category">[{{ post.category }}]</span>
</li>
{% endfor %}
</ul>
</div>
{% else %}
<p>暂无文章</p>
{% endfor %}
{% endblock %}
errors/404.html
jinja2
{% extends 'base.html' %}
{% block title %}404 - 页面未找到{% endblock %}
{% block content %}
<div class="error-page">
<h1 class="error-code">404</h1>
<p class="error-message">抱歉,您访问的页面不存在。</p>
<p class="error-hint">可能的原因:</p>
<ul>
<li>页面已被删除</li>
<li>URL 输入错误</li>
<li>链接已过期</li>
</ul>
<div class="error-actions">
<a href="{{ url_for('index') }}" class="btn btn-primary">返回首页</a>
<a href="{{ url_for('blog_list') }}" class="btn">浏览博客</a>
</div>
</div>
{% endblock %}
errors/500.html
jinja2
{% extends 'base.html' %}
{% block title %}500 - 服务器错误{% endblock %}
{% block content %}
<div class="error-page">
<h1 class="error-code">500</h1>
<p class="error-message">服务器内部错误,请稍后重试。</p>
<div class="error-actions">
<a href="{{ url_for('index') }}" class="btn btn-primary">返回首页</a>
</div>
</div>
{% endblock %}
这个完整的博客模板系统综合运用了:
- 模板继承 :所有页面继承
base.html。 - Include:头部、尾部、侧边栏、文章卡片使用 include 复用。
- 宏:分页、表单等使用宏封装。
- 过滤器:日期格式化、阅读时间、摘要提取等自定义过滤器。
- 全局函数:导航高亮等。
- 上下文处理器:注入网站信息、分类、标签。
- 消息闪现:编辑操作反馈。
- 错误处理:自定义 404/500 页面。
10.2 模板复用策略(组件化思维)
在大型项目中,模板的复用策略直接影响开发效率和可维护性。推荐采用"组件化"思维来组织模板。
组件层次模型
第一层:基础布局(base.html)
定义页面骨架,包含全局的头部、尾部、CSS/JS 引入。
第二层:模块布局(blog_base.html, admin_base.html)
在基础布局上定义模块特有的布局和侧边栏。
第三层:页面(index.html, blog_list.html)
具体的页面内容,继承相应的模块布局。
第四层:可复用组件(macros/*.html, partials/*.html)
独立的 UI 组件,可被任意层次的模板引用。
组件化原则
- 单一职责:每个组件只做一件事。一个表单宏只负责渲染表单,不处理业务逻辑。
- 可组合 :小组件组合成大组件。如
form_field由label+input+error组合。 - 可配置:通过参数让组件适应不同场景。
- 无副作用:组件不应该修改外部状态。
组件化示例
jinja2
{# macros/components.html:基础UI组件 #}
{% macro button(text, type='button', variant='primary', size='md', icon='') %}
<button type="{{ type }}" class="btn btn-{{ variant }} btn-{{ size }}">
{% if icon %}<i class="icon-{{ icon }}"></i>{% endif %}
{{ text }}
</button>
{% endmacro %}
{% macro badge(text, variant='default') %}
<span class="badge badge-{{ variant }}">{{ text }}</span>
{% endmacro %}
{% macro avatar(user, size=40) %}
<img src="{{ url_for('static', filename='avatars/' ~ (user.avatar or 'default.png')) }}"
alt="{{ user.name }}"
class="avatar avatar-{{ size }}"
width="{{ size }}" height="{{ size }}">
{% endmacro %}
{% macro card(title='', class='') %}
<div class="card {{ class }}">
{% if title %}<div class="card-header">{{ title }}</div>{% endif %}
<div class="card-body">{{ caller() }}</div>
</div>
{% endmacro %}
{# macros/blog_components.html:博客特有组件 #}
{% from 'macros/components.html' import button, badge, avatar %}
{% macro post_card(post) %}
<article class="post-card">
<h2 class="post-title">
<a href="{{ url_for('blog_detail', post_id=post.id) }}">{{ post.title }}</a>
</h2>
<div class="post-meta">
{{ avatar(post.author, 24) }}
<span>{{ post.author.name }}</span>
<span>{{ post.created_at | format_date }}</span>
{{ badge(post.category, 'primary') }}
</div>
<p class="post-summary">{{ post.summary }}</p>
<div class="post-tags">
{% for tag in post.tags %}
{{ badge(tag, 'secondary') }}
{% endfor %}
</div>
</article>
{% endmacro %}
{% macro comment_item(comment) %}
<div class="comment">
{{ avatar(comment.author, 32) }}
<div class="comment-body">
<div class="comment-meta">
<strong>{{ comment.author.name }}</strong>
<span>{{ comment.created_at | timeago }}</span>
</div>
<p>{{ comment.content }}</p>
</div>
</div>
{% endmacro %}
组件化模板的组织结构建议
对于中大型 Flask 项目,推荐采用以下模板目录结构来组织组件化的模板系统:
templates/
├── base.html # 最顶层基础模板
├── layouts/ # 布局模板(继承base.html)
│ ├── full_width.html # 全宽布局
│ ├── sidebar.html # 带侧边栏布局
│ └── admin.html # 管理后台布局
├── pages/ # 页面模板(继承layouts)
│ ├── home.html
│ ├── blog_list.html
│ ├── blog_detail.html
│ └── about.html
├── partials/ # 页面片段(include)
│ ├── header.html
│ ├── footer.html
│ ├── sidebar.html
│ ├── breadcrumb.html
│ └── pagination.html
├── macros/ # 宏库
│ ├── forms.html # 表单组件
│ ├── ui.html # UI组件(按钮/徽章/卡片)
│ ├── media.html # 媒体组件(图片/视频)
│ └── helpers.html # 辅助宏(日期/文本处理)
├── errors/ # 错误页面
│ ├── 404.html
│ ├── 500.html
│ └── 403.html
└── emails/ # 邮件模板
├── welcome.html
├── reset_password.html
└── notification.html
这种组织方式的核心思想是"分层复用":base.html 定义全局骨架,layouts/ 提供不同的页面布局,pages/ 是具体页面,partials/ 和 macros/ 是可复用的组件库。每一层都建立在下一层之上,通过继承和组合实现最大程度的复用。当项目规模增长时,这种结构能够保持模板的清晰和可维护性,避免模板文件变得混乱不堪。
10.3 模板性能优化(缓存、编译)
虽然 Jinja2 本身已经很快(编译执行),但在高并发场景下,仍然可以通过一些优化手段提升性能。
1. 模板缓存
Jinja2 默认会缓存编译后的模板。在生产环境中,确保 auto_reload 关闭:
python
app = Flask(__name__)
# 生产环境:关闭自动重载,使用缓存
app.config['TEMPLATES_AUTO_RELOAD'] = False
app.jinja_env.auto_reload = False
2. 字节码缓存
对于从字符串加载的模板或自定义环境,可以配置字节码缓存到文件系统:
python
from jinja2 import Environment, FileSystemLoader, BytecodeCache
import os
class FileBytecodeCache(BytecodeCache):
def __init__(self, directory):
self.directory = directory
def load(self, environment, filename, source):
cache_file = os.path.join(self.directory, filename + '.cache')
if os.path.exists(cache_file):
with open(cache_file, 'rb') as f:
return f.read()
return None
def dump(self, environment, filename, source):
os.makedirs(self.directory, exist_ok=True)
cache_file = os.path.join(self.directory, filename + '.cache')
with open(cache_file, 'wb') as f:
f.write(source)
# 使用(非Flask默认场景)
env = Environment(
loader=FileSystemLoader('templates'),
bytecode_cache=FileBytecodeCache('/tmp/jinja_cache'),
)
在 Flask 中,默认的 app.jinja_env 已经有内存缓存,通常不需要额外的字节码缓存。
3. 减少模板复杂度
jinja2
{# 不推荐:在模板中做复杂计算 #}
{% set total = 0 %}
{% for item in items %}
{% set total = total + item.price * item.quantity %}
{% endfor %}
<p>总价: {{ total }}</p>
{# 推荐:在视图函数中计算 #}
<p>总价: {{ total }}</p>
4. 避免在循环中做重复计算
jinja2
{# 不推荐:每次循环都调用 url_for #}
{% for post in posts %}
<a href="{{ url_for('blog_detail', post_id=post.id) }}">{{ post.title }}</a>
{% endfor %}
{# 这个其实还好,但如果循环量很大,可以考虑在视图函数中预生成URL #}
5. 使用 selectattr/rejectattr 代替循环内判断
jinja2
{# 不推荐 #}
{% set active_users = [] %}
{% for user in users %}
{% if user.is_active %}
{% if active_users.append(user) %}{% endif %}
{% endif %}
{% endfor %}
{# 推荐 #}
{% set active_users = users | selectattr('is_active') | list %}
6. 局部缓存(Flask-Caching)
对于不常变化的模板片段,可以使用 Flask-Caching 做局部缓存:
python
from flask_caching import Cache
cache = Cache(app, config={'CACHE_TYPE': 'SimpleCache'})
@app.context_processor
def inject_sidebar():
@cache.cached(timeout=300, key_prefix='sidebar_data') # 缓存5分钟
def get_sidebar_data():
return {
'popular_tags': Tag.query.order_by(Tag.count.desc()).limit(10).all(),
'recent_posts': Post.query.order_by(Post.created_at.desc()).limit(5).all(),
}
return dict(sidebar=get_sidebar_data())
10.4 模板安全(XSS防护, CSRF令牌)
Web 安全是模板开发中不可忽视的部分。Jinja2 和 Flask 提供了多层安全机制。
1. XSS 防护:自动转义
Jinja2 默认对 .html 模板启用自动转义,这是 XSS 防护的第一道防线:
jinja2
{# 安全:自动转义 #}
{{ user_input }} {# <script> 会被转义为 <script> #}
2. 谨慎使用 safe
只在内容确实可信时使用 safe:
jinja2
{# 安全:内容是程序生成的 #}
{{ '<strong>' ~ user.name ~ '</strong>' | safe }}
{# 危险:内容来自用户输入 #}
{{ user_bio | safe }} {# 如果 user_bio 含恶意脚本... #}
对于富文本内容,使用专门的清洗库:
python
import bleach
@app.template_filter('clean_html')
def clean_html(text):
"""清洗HTML,只允许安全标签"""
allowed_tags = ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre']
allowed_attrs = {'a': ['href', 'title']}
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
jinja2
{{ user_content | clean_html | safe }}
3. CSRF 防护
在表单中加入 CSRF 令牌:
python
# 生成 CSRF 令牌
@app.template_global()
def csrf_token():
import uuid
if '_csrf_token' not in session:
session['_csrf_token'] = uuid.uuid4().hex
return session['_csrf_token']
jinja2
{# 每个表单都加 CSRF 令牌 #}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- 其他字段 -->
</form>
或者使用 Flask-WTF 自动处理 CSRF:
python
from flask_wtf import FlaskForm, CSRFProtect
app.config['SECRET_KEY'] = 'your-secret-key'
csrf = CSRFProtect(app) # 全局启用 CSRF 保护
jinja2
{# Flask-WTF 的表单自动包含 CSRF 令牌 #}
<form method="POST">
{{ form.hidden_tag() }} {# 自动渲染 CSRF 令牌 #}
<!-- 其他字段 -->
</form>
4. 防范 SSTI(服务端模板注入)
永远不要用 render_template_string 拼接用户输入:
python
# 危险!
return render_template_string('Hello, ' + user_input)
# 安全
return render_template_string('Hello, {{ name }}', name=user_input)
5. 内容安全策略(CSP)头
除了模板层面的安全措施,还应该在 HTTP 响应头中设置内容安全策略(Content Security Policy),进一步增强安全性:
python
@app.after_request
def set_csp_header(response):
"""设置内容安全策略头"""
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"img-src 'self' data: https:; "
"font-src 'self' data:;"
)
return response
CSP 头可以限制浏览器只从可信来源加载资源,即使攻击者成功注入了恶意脚本,也无法从外部服务器加载资源,从而有效降低 XSS 攻击的危害。
6. 敏感数据处理
在模板中显示敏感数据时,应该进行脱敏处理:
python
@app.template_filter('mask_email')
def mask_email(email):
"""邮箱脱敏:tom@example.com -> t***@example.com"""
if not email or '@' not in email:
return email
name, domain = email.split('@', 1)
if len(name) <= 1:
return '*' + '@' + domain
return name[0] + '***@' + domain
@app.template_filter('mask_phone')
def mask_phone(phone):
"""手机号脱敏:13812345678 -> 138****5678"""
if not phone or len(phone) < 7:
return phone
return phone[:3] + '****' + phone[-4:]
@app.template_filter('mask_id_card')
def mask_id_card(id_number):
"""身份证号脱敏:110101199001011234 -> 110***********1234"""
if not id_number or len(id_number) < 8:
return id_number
return id_number[:3] + '*' * (len(id_number) - 7) + id_number[-4:]
jinja2
<p>邮箱:{{ user.email | mask_email }}</p>
<p>手机:{{ user.phone | mask_phone }}</p>
<p>身份证:{{ user.id_number | mask_id_card }}</p>
这些脱敏过滤器在用户信息展示页面、订单详情页等场景中非常实用,能够在不影响用户体验的前提下保护用户隐私。
7. 安全审计检查清单
在上线前,建议对模板进行安全审计。以下是一个实用的检查清单:
- 检查所有使用
| safe过滤器的地方,确认内容来源可信 - 检查
render_template_string的使用,确保没有拼接用户输入 - 检查所有表单是否包含 CSRF 令牌
- 检查
url_for生成的链接是否可能被篡改 - 检查用户上传的文件是否通过正确的 Content-Type 提供服务
- 检查
| attr或| getattr过滤器是否可能泄露敏感属性 - 检查模板中是否直接暴露了
config对象的敏感配置项 - 检查错误页面是否泄露了堆栈信息或文件路径
10.5 模板国际化(Flask-Babel集成)
对于多语言网站,可以使用 Flask-Babel 实现模板国际化。
安装与配置
python
from flask_babel import Babel, gettext as _
app = Flask(__name__)
app.config['BABEL_DEFAULT_LOCALE'] = 'zh_CN'
babel = Babel(app)
@babel.localeselector
def get_locale():
# 从 URL 参数、session 或 Accept-Language 头获取
lang = request.args.get('lang')
if lang:
return lang
return request.accept_languages.best_match(['zh_CN', 'en_US'])
在模板中使用
jinja2
{# 使用 _() 函数翻译文本 #}
<h1>{{ _('Welcome to my blog') }}</h1>
<p>{{ _('Hello, %(name)s!', name=user.name) }}</p>
{# 日期本地化 #}
<p>{{ post.created_at | format_datetime }}</p>
翻译消息提取
bash
# 提取需要翻译的文本
pybabel extract -F babel.cfg -o messages.pot .
# 初始化翻译(中文)
pybabel init -i messages.pot -d translations -l zh_CN
# 初始化翻译(英文)
pybabel init -i messages.pot -d translations -l en_US
# 编译翻译
pybabel compile -d translations
# 更新翻译
pybabel update -i messages.pot -d translations
babel.cfg 配置文件
babel.cfg 告诉 pybabel 如何从源码和模板中提取需要翻译的文本:
ini
# babel.cfg
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
这个配置文件指定了两个提取规则:从所有 .py 文件中提取 Python 代码中的翻译字符串,从 templates/ 目录下的所有 .html 文件中提取 Jinja2 模板中的翻译字符串。extensions 参数告诉 Babel 启用了哪些 Jinja2 扩展,以确保正确解析模板语法。
翻译文件结构
翻译文件使用 PO 格式(Portable Object),这是 GNU gettext 的标准格式:
po
# translations/zh_CN/LC_MESSAGES/messages.po
msgid "Welcome to my blog"
msgstr "欢迎来到我的博客"
msgid "Hello, %(name)s!"
msgstr "你好,%(name)s!"
msgid "Post count: %(count)d"
msgid_plural "Post count: %(count)d"
msgstr[0] "文章数:%(count)d"
msgstr[1] "文章数:%(count)d"
msgid "Home"
msgstr "首页"
msgid "About"
msgstr "关于"
msgid "Login"
msgstr "登录"
msgid "Logout"
msgstr "退出"
注意中文的复数形式:中文不像英文那样区分单复数(one/many),所以 msgstr[0] 和 msgstr[1] 的内容相同。但对于英文翻译,需要区分:
po
# translations/en_US/LC_MESSAGES/messages.po
msgid "Post count: %(count)d"
msgid_plural "Post count: %(count)d"
msgstr[0] "Post count: %(count)d"
msgstr[1] "Posts count: %(count)d"
模板中的高级国际化用法
jinja2
{# 普通翻译 #}
<h1>{{ _('Welcome to my blog') }}</h1>
{# 带参数的翻译 #}
<p>{{ _('Hello, %(name)s!', name=user.name) }}</p>
{# 带复数的翻译 #}
<p>{{ ngettext('You have %(count)d message', 'You have %(count)d messages', count) }}</p>
{# 带复数和参数的翻译 #}
<p>{{ ngettext('%(count)d new comment', '%(count)d new comments', comment_count) }}</p>
{# 日期时间本地化 #}
<p>{{ _('Published at %(date)s', date=post.created_at|format_datetime) }}</p>
{# 货币本地化 #}
<p>{{ _('Price: %(price)s', price=format_currency(product.price, 'CNY')) }}</p>
{# 在属性中使用翻译 #}
<input type="text" placeholder="{{ _('Enter your name') }}">
<button title="{{ _('Click to submit') }}">{{ _('Submit') }}</button>
国际化最佳实践
-
原文用英文 :在模板中,
_()的参数应该用英文原文,而不是中文。这样翻译文件中的msgid是英文,便于国际化团队理解和管理。中文作为zh_CN的翻译存在 PO 文件中。 -
不要拼接翻译字符串:不要把翻译字符串拼接在一起,因为不同语言的语序可能不同。应该使用带参数的翻译:
jinja2
{# 错误:拼接翻译 #}
{{ _('Hello,') }} {{ user.name }} {{ _('!') }}
{# 正确:带参数的翻译 #}
{{ _('Hello, %(name)s!', name=user.name) }}
- 给翻译者提供上下文 :对于有歧义的词汇,使用
pgettext提供上下文提示:
python
from flask_babel import pgettext
# 在 Python 代码中
label = pgettext('verb', 'Post') # 作为动词"发布"
label = pgettext('noun', 'Post') # 作为名词"文章"
10.6 模板与前端框架集成(Bootstrap/Tailwind/Vue)
与 Bootstrap 集成
jinja2
{# base.html #}
<!DOCTYPE html>
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">{{ site_name }}</a>
</div>
</nav>
<div class="container mt-4">
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
与 Tailwind CSS 集成
jinja2
{# base.html #}
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
<nav class="bg-gray-800 text-white p-4">
<div class="container mx-auto flex justify-between">
<a href="{{ url_for('index') }}" class="text-xl font-bold">{{ site_name }}</a>
</div>
</nav>
<main class="container mx-auto mt-8">
{% block content %}{% endblock %}
</main>
</body>
</html>
与 Vue.js 集成(渐进式)
在 Jinja2 模板中使用 Vue 时,需要处理定界符冲突。Jinja2 用 {``{ }},Vue 也用 {``{ }}。
方案1:修改 Vue 的定界符
jinja2
<div id="app" v-cloak>
<p>[[ message ]]</p> {# Vue 用 [[ ]] 代替 {{ }} #}
</div>
<script>
new Vue({
el: '#app',
delimiters: ['[[', ']]'], {# 修改Vue定界符 #}
data: {
message: 'Hello from Vue!'
}
});
</script>
方案2:用 tojson 传递数据
jinja2
<div id="app" v-cloak>
<p>{{ '{{' }} message {{ '}}' }}</p>
</div>
<script>
new Vue({
el: '#app',
data: {
message: {{ message | tojson }}
}
});
</script>
前端框架集成策略与最佳实践
在选择 Jinja2 模板与前端框架的集成方式时,有几种常见的策略,每种策略适用于不同的项目场景。
策略一:服务器端渲染为主(SSR)
这是最传统的 Flask + Jinja2 模式。服务器负责渲染完整的 HTML 页面,前端框架(如 Bootstrap)仅用于样式和交互增强。适用于内容型网站(博客、新闻、文档)、管理后台等场景。
优势:SEO 友好(搜索引擎可以直接抓取完整 HTML)、首屏加载快(无需等待 JavaScript 执行)、开发简单(前后端不需要分离)。劣势:交互体验不如单页应用(SPA)流畅、前后端耦合较紧。
python
# 典型的 SSR 模式
@app.route('/blog/<int:post_id>')
def blog_detail(post_id):
post = Post.query.get_or_404(post_id)
return render_template('blog/detail.html', post=post)
策略二:Jinja2 + Vue 混合模式
在这种模式下,Jinja2 负责渲染页面骨架和初始数据,Vue 负责页面中的交互区域。这种模式在管理后台中非常流行------列表页用 Jinja2 渲染,但表格的排序、筛选、弹窗等交互用 Vue 处理。
jinja2
{# list.html #}
{% extends 'base.html' %}
{% block content %}
<div class="page-header">
<h1>{{ page_title }}</h1>
</div>
{# Vue 接管的交互区域 #}
<div id="data-table-app" v-cloak>
<div class="toolbar">
<input v-model="search" placeholder="搜索..." class="form-control">
<button @click="exportData" class="btn btn-primary">导出</button>
</div>
<table class="table">
<thead>
<tr>
<th @click="sortBy('id')">ID [[ sortIcon('id') ]]</th>
<th @click="sortBy('name')">名称 [[ sortIcon('name') ]]</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item.id">
<td>[[ item.id ]]</td>
<td>[[ item.name ]]</td>
<td>
<a :href="'/edit/' + item.id" class="btn btn-sm btn-primary">编辑</a>
</td>
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#data-table-app',
delimiters: ['[[', ']]'],
data: {
items: {{ items | tojson }},
search: '',
sortKey: 'id',
sortOrder: 1,
},
computed: {
filteredItems() {
let items = this.items.filter(item =>
item.name.includes(this.search)
);
items.sort((a, b) => {
return (a[this.sortKey] - b[this.sortKey]) * this.sortOrder;
});
return items;
}
},
methods: {
sortBy(key) {
if (this.sortKey === key) {
this.sortOrder *= -1;
} else {
this.sortKey = key;
this.sortOrder = 1;
}
},
sortIcon(key) {
if (this.sortKey !== key) return '';
return this.sortOrder > 0 ? ' ↑' : ' ↓';
},
exportData() {
window.location.href = '{{ url_for("export_data") }}';
}
}
});
</script>
{% endblock %}
这种模式的关键是:Jinja2 在服务器端渲染页面结构和初始数据(通过 tojson 传递给 Vue),Vue 在客户端接管交互逻辑。URL 生成仍然由 url_for 在服务器端完成,确保了 URL 的一致性。
策略三:API + 前端 SPA(完全分离)
在这种模式下,Flask 只提供 JSON API,Jinja2 模板几乎不使用(可能只用于个别页面如登录页)。前端使用 Vue/React 等框架构建 SPA,通过 AJAX 调用 Flask API。
python
# Flask 只提供 API
@app.route('/api/posts')
def api_posts():
posts = Post.query.all()
return jsonify([post.to_dict() for post in posts])
@app.route('/api/posts/<int:post_id>')
def api_post_detail(post_id):
post = Post.query.get_or_404(post_id)
return jsonify(post.to_dict())
这种模式适用于交互复杂的 Web 应用(如在线编辑器、实时协作工具),但 SEO 不友好、首屏加载慢。如果需要 SEO,可以考虑使用 SSR 框架(如 Nuxt.js、Next.js)配合 Flask API。
选择建议
| 项目类型 | 推荐策略 | 理由 |
|---|---|---|
| 博客/新闻/文档网站 | 纯 SSR(Jinja2) | SEO 优先,内容为主 |
| 管理后台/CRM | Jinja2 + Vue 混合 | 需要交互,但不需要完全 SPA |
| 在线工具/编辑器 | API + SPA | 交互复杂,实时性强 |
| 电商网站 | SSR 为主 + 部分 SPA | 商品页需要 SEO,购物车用 SPA |
| 企业官网 | 纯 SSR(Jinja2) | 内容简单,SEO 优先 |
核心原则是:不要盲目追求"前后端分离",而应根据项目需求选择最合适的模式。对于大多数 Flask 项目来说,Jinja2 模板 + 适量的前端交互增强(如 Bootstrap + 少量 Vue/Alpine.js)是性价比最高的选择。
10.7 模板调试技巧
1. 开启调试模式
python
app.run(debug=True)
调试模式下,模板修改会自动重载,错误会显示详细的堆栈信息。
2. 使用 StrictUndefined 发现未定义变量
python
from jinja2 import StrictUndefined
app.jinja_env.undefined = StrictUndefined
这样访问未定义的变量会直接报错,而不是静默输出空字符串。
3. 打印变量内容
jinja2
{# 使用 pprint 过滤器(需自定义)查看变量结构 #}
{{ user | pprint }}
{# 使用 tojson 查看变量内容 #}
<pre>{{ user | tojson(indent=2) }}</pre>
4. 调试上下文变量
python
@app.context_processor
def debug_context():
if app.debug:
return dict(_debug=lambda: {
'request.endpoint': request.endpoint,
'request.args': dict(request.args),
'session': dict(session),
'g': {k: str(v) for k, v in vars(g).items()} if g else {},
})
return {}
jinja2
{% if config.DEBUG %}
<pre>{{ _debug() | tojson(indent=2) }}</pre>
{% endif %}
5. 常见错误排查
| 错误 | 原因 | 解决方案 |
|---|---|---|
TemplateNotFound |
模板文件不存在或路径错误 | 检查文件路径和 templates/ 目录 |
UndefinedError |
使用了未定义的变量 | 检查变量名,或用 default 过滤器 |
TemplateSyntaxError |
模板语法错误 | 检查标签是否闭合、语法是否正确 |
TypeError |
过滤器参数类型不匹配 | 检查传入过滤器的数据类型 |
10.8 模板编写规范与最佳实践
1. 命名规范
- 模板文件:小写字母,下划线分隔,如
blog_detail.html。 - block 名:小写字母,下划线,如
{% block page_title %}。 - 宏名:小写字母,下划线,如
{% macro render_pagination() %}。 - 变量名:有意义的名称,如
post、user_list,避免x、y。
2. 缩进规范
jinja2
{# 推荐:HTML 标签正常缩进,Jinja2 标签适当缩进 #}
<div class="container">
{% for post in posts %}
<article>
<h2>{{ post.title }}</h2>
</article>
{% endfor %}
</div>
3. 启用 trim_blocks 和 lstrip_blocks
python
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
避免生成的 HTML 出现多余空行。
4. 模板不宜过大
单个模板文件不建议超过 200 行。如果太大,考虑拆分:
- 提取公共部分为 include。
- 提取重复部分为宏。
- 拆分为多个子模板。
5. 业务逻辑放视图,展示逻辑放模板
python
# 视图函数:准备数据
@app.route('/blog')
def blog_list():
posts = Post.query.filter_by(status='published').order_by(Post.created_at.desc()).all()
total_pages = ...
return render_template('blog/list.html', posts=posts, total_pages=total_pages)
jinja2
{# 模板:只负责展示 #}
{% for post in posts %}
<article>{{ post.title }}</article>
{% endfor %}
6. 始终使用 url_for 生成 URL
jinja2
{# 不推荐 #}
<a href="/blog/{{ post.id }}">
{# 推荐 #}
<a href="{{ url_for('blog_detail', post_id=post.id) }}">
7. 静态文件引用用 url_for
jinja2
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
10.9 常见问题排查
Q1:模板修改后不生效?
A:检查是否开启了 auto_reload。开发模式下默认开启,生产模式下默认关闭:
python
app.config['TEMPLATES_AUTO_RELOAD'] = True # 开发环境
Q2:{``{ }} 输出了 HTML 实体?
A:这是自动转义在生效。如果内容是可信的,加 | safe:
jinja2
{{ html_content | safe }}
Q3:for 循环中 set 的变量在循环外不可用?
A:这是 Jinja2 的作用域特性。用 namespace 或在视图函数中计算:
jinja2
{% set ns = namespace(total=0) %}
{% for item in items %}
{% set ns.total = ns.total + item.price %}
{% endfor %}
{{ ns.total }}
Q4:include 的模板访问不到变量?
A:默认 include 会传递当前上下文。如果不传递,检查是否用了 without context:
jinja2
{% include 'partial.html' %} {# 默认传递上下文 #}
{% include 'partial.html' with context %} {# 显式传递 #}
{% include 'partial.html' without context %}{# 不传递 #}
Q5:导入的宏访问不到 request/session?
A:import 默认不传递上下文。加 with context:
jinja2
{% import 'macros.html' as macros with context %}
Q6:如何输出 {``{ }} 字面量?
A:用 {% raw %} 块或转义:
jinja2
{% raw %}{{ this will not be parsed }}{% endraw %}
{# 或者 #}
{{ '{{' }} variable {{ '}}' }}
Q7:如何在不同环境使用不同模板?
A:可以通过配置 TEMPLATES_AUTO_RELOAD 和自定义加载器实现。也可以在视图函数中根据条件选择模板:
python
@app.route('/page')
def page():
# 根据用户设备选择模板
if request.MOBILE:
template = 'page_mobile.html'
else:
template = 'page.html'
return render_template(template, **data)
# 或者使用蓝图隔离模板
bp = Blueprint('mobile', __name__, template_folder='templates/mobile')
Q8:模板中如何访问 Python 的内置函数?
A:Jinja2 默认不暴露 Python 内置函数。需要手动注册为全局函数:
python
# 注册 Python 内置函数到模板
app.jinja_env.globals['len'] = len
app.jinja_env.globals['max'] = max
app.jinja_env.globals['min'] = min
app.jinja_env.globals['abs'] = abs
app.jinja_env.globals['round'] = round
# 或者使用自定义全局函数
@app.template_global()
def length(obj):
return len(obj)
jinja2
{# 现在可以在模板中使用 #}
<p>共 {{ len(users) }} 个用户</p>
<p>最大值:{{ max(scores) }}</p>
不过,更好的做法是使用 Jinja2 的过滤器替代:{``{ users | length }} 比 {``{ len(users) }} 更符合 Jinja2 的风格。
Q9:{% set %} 在 include 中设置的变量为什么在外部不可见?
A:include 默认共享当前上下文,但 set 设置的变量在 include 结束后不会"泄漏"回父模板。如果需要在 include 中修改外部变量,可以使用 namespace:
jinja2
{% set ns = namespace(count=0) %}
{% include 'counter.html' with context %}
{# 在 counter.html 中: {% set ns.count = ns.count + 1 %} #}
<p>计数:{{ ns.count }}</p>
Q10:如何处理模板中的长文本(如邮件模板)?
A:对于长文本,可以使用 {% blocktrans %}(配合 Flask-Babel)或直接使用多行字符串:
jinja2
{# 邮件模板 email/welcome.html #}
{% block subject %}欢迎注册{{ site_name }}{% endblock %}
{% block body %}
亲爱的{{ user.name }}:
感谢您注册{{ site_name }}!
您的账号信息如下:
- 用户名:{{ user.username }}
- 注册邮箱:{{ user.email }}
请点击以下链接激活账号:
{{ activation_url }}
如果您有任何问题,请随时联系我们。
{{ site_name }}团队
{% endblock %}
在 Python 中渲染邮件:
python
from flask import render_template
def send_welcome_email(user):
subject = render_template('email/welcome.html', user=user, site_name=site_name)._blocks['subject'][0]()
body = render_template('email/welcome.html', user=user, site_name=site_name, activation_url=generate_activation_url(user))._blocks['body'][0]()
# 或者更优雅的方式:分别渲染
subject = render_template('email/welcome_subject.txt', user=user, site_name=site_name)
body = render_template('email/welcome_body.txt', user=user, site_name=site_name, activation_url=generate_activation_url(user))
send_mail(user.email, subject, body)
Q11:如何在模板中实现条件加载不同的静态资源?
A:可以在基础模板中使用条件判断:
jinja2
{# base.html #}
{% block styles %}
{% if config.get('USE_CDN') %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
{% else %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}">
{% endif %}
{% endblock %}
这样在开发环境可以使用本地静态文件,在生产环境切换到 CDN,只需修改配置即可。
10.10 本章总结与下一期预告
本章总结
本章我们通过一个完整的博客模板系统实战,把前九章学到的所有知识融会贯通。关键要点:
- 模板继承是骨架 :
base.html定义整体结构,子模板填充内容。 - 宏是组件:把可复用的 UI 片段封装为宏,提高复用率。
- Include 是片段:把页面片段提取出来,按需引入。
- 过滤器是工具:自定义过滤器处理日期、金额、文本等格式化。
- 上下文处理器是桥梁:把全局数据注入模板。
- 安全是底线:自动转义防 XSS,CSRF 令牌防跨站请求伪造。
- 性能是保障:合理使用缓存,避免模板中的复杂计算。
- 规范是维护性:好的命名、缩进、拆分让模板易于维护。
Flask模板引擎Jinja2完整知识图谱
Jinja2
├── 基础语法
│ ├── 变量输出 {{ }}
│ ├── 控制结构 {% %}
│ ├── 注释 {# #}
│ └── 空白控制 trim_blocks/lstrip_blocks
├── 控制结构
│ ├── 条件判断 if/elif/else
│ ├── 循环 for/for-else/loop变量
│ ├── 变量赋值 set
│ ├── 作用域 with
│ └── 继承 extends/block
├── 过滤器
│ ├── 字符串 upper/lower/trim/replace/truncate
│ ├── 列表 first/last/sort/groupby/map/select
│ ├── 数字 round/abs/int/float
│ ├── 默认值 default/d
│ └── 自定义 @app.template_filter
├── 测试器
│ ├── 类型 defined/none/string/number
│ ├── 比较 eq/gt/lt
│ └── 自定义 @app.template_test
├── 宏
│ ├── 定义 macro/参数/默认值
│ ├── call块 回调
│ └── 导入 import/from import
├── 模板继承
│ ├── 基础模板 block
│ ├── 子模板 extends
│ ├── super() 父模板内容
│ └── 多层继承
├── 全局函数
│ ├── url_for
│ ├── get_flashed_messages
│ ├── 自定义 @app.template_global
│ └── 上下文处理器 @app.context_processor
├── 安全
│ ├── 自动转义 autoescape
│ ├── safe/Markup
│ └── CSRF令牌
└── 性能
├── 模板缓存
├── 字节码缓存
└── 减少复杂度
下一期预告
本期我们深入讲解了 Jinja2 模板引擎的全部知识。下一期 Flask 服务器专栏将聚焦于 Flask 数据库集成与 SQLAlchemy:
- SQLAlchemy ORM 详解
- Flask-SQLAlchemy 的配置与使用
- 模型定义、关系映射(一对多/多对多)
- 数据库迁移(Flask-Migrate)
- 查询 API 高级用法
- 事务与会话管理
- 分页、排序、过滤
- 性能优化(N+1 问题、eager loading)
- 完整的 CRUD 实战案例
从"会写模板"到"精通模板",从"会用数据库"到"精通 ORM",让我们继续这段 Flask 进阶之旅。
总结
本文系统而深入地讲解了 Flask 默认模板引擎 Jinja2 的全部知识,全文超过三万字,涵盖以下核心内容:
基础层面 :我们了解了模板引擎的概念与价值、Jinja2 的设计理念、与 Flask 的集成关系,以及 render_template 底层的加载-编译-渲染流程。掌握了模板的基础语法:变量输出、属性访问、注释、控制结构标签、空白控制、自动转义和模板上下文。
核心能力 :深入学习了条件判断、for 循环(包括强大的 loop 对象)、set/with 变量与作用域、break/continue 扩展、for...else 空状态处理。系统梳理了所有内置过滤器(字符串、列表、数字、日期、JSON、默认值)及其链式调用,以及自定义过滤器的三种方式。掌握了测试器的概念、内置测试器和自定义方法。
高级特性 :深入讲解了宏的定义、参数、默认值、call 块回调、导入复用和最佳实践。系统学习了模板继承:基础模板编写、子模板覆写、super() 函数、多层继承、块嵌套、required 属性,以及继承/包含/宏的选择策略。
工程化:掌握了全局函数、上下文处理器、Environment 配置、消息闪现机制。通过完整的博客模板系统实战,把所有知识融会贯通。还涉及了性能优化、XSS/CSRF 安全防护、国际化、前端框架集成、调试技巧和最佳实践规范。
学习路径建议
对于想要进一步提升 Jinja2 技能的开发者,建议按照以下路径循序渐进:
第一阶段:夯实基础。确保熟练掌握 {``{ }} 变量输出、{% if %} 条件判断、{% for %} 循环(包括 loop 对象)、{% extends %} 模板继承和 {% block %} 块覆写。这些是日常开发中最常用的语法,占模板代码的百分之八十以上。
第二阶段:掌握过滤器与测试器。系统学习内置过滤器和测试器,理解它们的参数和返回值。学会链式过滤器的使用,掌握自定义过滤器的编写方法。这一阶段的目标是能够用过滤器优雅地处理数据格式化问题。
第三阶段:深入宏与组件化。学会用宏封装可复用的 UI 组件,理解 call 块的回调机制,掌握宏的导入和组织方式。这一阶段的目标是建立自己的宏库,提高模板的复用率和可维护性。
第四阶段:工程化与安全。学习上下文处理器、全局函数、Environment 配置,理解模板渲染的完整流程。掌握 XSS 防护、CSRF 令牌、SSTI 防范等安全知识,学会使用 StrictUndefined 和调试技巧排查问题。
第五阶段:性能优化与进阶。学习模板缓存、字节码缓存、局部缓存等性能优化手段。了解国际化(Flask-Babel)、前端框架集成(Bootstrap/Tailwind/Vue)等进阶主题。
Jinja2 的设计哲学启示
Jinja2 的设计给我们带来了很多启示。它的"模板即文本"理念让模板保持了可读性;"编译执行"策略保证了性能;"沙箱安全"机制提供了安全保障;"可扩展架构"赋予了无限可能。这些设计理念不仅适用于模板引擎,也适用于我们日常的软件设计:保持简单、追求性能、注重安全、预留扩展。
Jinja2 看似简单------{``{ }} 输出变量、{% %} 控制结构------但其设计之精巧、功能之丰富,足以应对从简单页面到复杂 Web 应用的所有模板需求。理解并掌握 Jinja2,是每一个 Flask 开发者从"能用"到"精通"的必经之路。