03-Flask模板引擎Jinja2详解

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

这段代码能工作,但存在严重问题:

  1. 可读性差:HTML 结构被 Python 字符串操作拆得支离破碎,很难一眼看出页面长什么样。
  2. 维护困难:修改一个标签需要到 Python 代码里去找,前后端无法分工。
  3. 容易出错:引号转义、字符串闭合稍有疏忽就会导致 HTML 错乱。
  4. 无法复用:多个页面有相同的头部、尾部?只能复制粘贴。
  5. 安全风险:手动拼接 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 时,它会按以下顺序尝试解析:

  1. 字典的键 :如果 user 是字典,先尝试 user['name']。
  2. 属性 :尝试 getattr(user, 'name')。
  3. 列表索引 :如果 name 是数字,尝试 user[int(name)](对列表/元组)。
  4. __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 实体:

原字符 转义后
< &lt;
> &gt;
& &amp;
" &#34;
' &#39;

示例

视图函数传入含 HTML 的字符串:

python 复制代码
@app.route('/')
def index():
    return render_template('index.html', content='<script>alert("XSS")</script>')

模板:

jinja2 复制代码
<div>{{ content }}</div>

渲染结果:

html 复制代码
<div>&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;</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>&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</p>

安全提醒

safe 和 Markup 是"双刃剑":用对了能输出富文本,用错了会引入 XSS。永远不要对用户输入直接用 safe:

jinja2 复制代码
{# 危险!用户可能注入恶意脚本 #}
{{ user_input | safe }}

只有在以下情况才用 safe/Markup:

  • 内容是程序自己生成的可信 HTML。
  • 内容经过专门的富文本过滤器(如 bleach)清洗过。

自动转义与 safe 的交互机制

理解自动转义和 safe 过滤器的交互机制对于编写安全的模板至关重要。当 Jinja2 渲染 {``{ variable }} 时,它会检查 variable 的类型:

  1. 如果 variable 是 Markup 对象(即已被标记为安全),直接输出,不转义。
  2. 如果 variable 是普通字符串,且自动转义已启用,则转义后输出。
  3. 如果 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,出于以下设计考虑:

  1. 鼓励数据预处理:把复杂逻辑放在视图函数,模板只负责展示。
  2. 保持模板可读性 :滥用 break/continue 会让模板逻辑变复杂。
  3. 性能考量:简单的线性遍历更容易优化。

如果你确实需要,可以按上一节的方法启用扩展。

替代方案:用过滤器代替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?

  1. 避免污染外部作用域:临时变量用完即弃,不影响模板其他部分。
  2. 提高可读性:明确标识"这段代码用到了这些临时变量"。
  3. 配合 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 的控制结构功能强大,但在模板中编写过于复杂的逻辑会影响渲染性能和可维护性。以下是几个需要注意的性能问题:

  1. 避免在模板中做大量数据计算 :模板的职责是"展示数据",而不是"处理数据"。如果需要在显示前对数据进行排序、分组、过滤等操作,应该尽量在视图函数中用 Python 完成,只把处理好的结果传给模板。模板中的 for 循环和 if 判断虽然方便,但每次渲染都会执行,而视图函数中的处理只需要执行一次。

  2. 合理使用 loop 变量 :loop 对象在每次迭代时都会创建新的对象,对于超大列表(上万条数据),这会带来一定的内存开销。如果列表很大,考虑分页处理,每页只渲染几十条数据。

  3. set 和 namespace 的使用场景 :在循环中累加变量时,必须使用 namespace 而不是普通的 set,因为 Jinja2 的 set 在循环体内有作用域限制。这个设计是为了避免循环变量"泄漏"到循环外部,但也是初学者经常遇到的陷阱。

  4. 条件判断的顺序 :在多个 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 #}

执行顺序从左到右:

  1. ' Hello World ' -> trim -> 'Hello World'
  2. 'Hello World' -> upper -> 'HELLO WORLD'
  3. '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 }}
{# &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; #}

{# e 是 escape 的简写 #}
{{ '<b>bold</b>' | e }}
{# &lt;b&gt;bold&lt;/b&gt; #}

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 }}

执行步骤:

  1. user_input -> default -> 'guest'(如果未定义)
  2. 'guest' -> trim -> 'guest'(去空白)
  3. '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 }}

链式过滤器的注意事项

  1. 类型兼容性 :确保前一个过滤器的输出类型与下一个过滤器的输入类型兼容。例如,selectattr 返回的是生成器,如果后面要使用 length,需要先转为列表(| list)。

  2. 性能考量:链式过滤器虽然方便,但每一步都会遍历一次数据。对于大型列表,考虑在视图函数中用 Python 处理,效率更高。

  3. 可读性 :过长的链式过滤器会降低可读性。如果链式超过 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 做数值范围判断。这些测试器组合使用,能够让模板逻辑更加健壮,避免因数据缺失或类型不符导致的渲染错误。

测试器使用最佳实践

在实际项目中使用测试器时,有以下几个最佳实践值得遵循:

  1. 优先使用测试器而非过滤器做判断 :在 {% if %} 条件中,使用 is defined 比使用 | length > 0 更语义化,代码可读性更强。测试器的设计初衷就是用于条件判断,而过滤器用于数据转换,各司其职才能让模板代码更加清晰。

  2. 注意测试器的短路特性 :Jinja2 的 and/or 运算符具有短路特性。在多个测试器组合使用时,将最可能失败的条件放在前面,可以提前终止判断,提高模板渲染效率。例如 {% if user is defined and user.email is email %} 中,如果 user 未定义,后面的邮箱测试不会执行。

  3. 自定义测试器要纯函数:自定义测试器应该是纯函数,即不依赖外部状态、不产生副作用。测试器只负责判断,不应该修改数据。如果需要在模板中修改数据,应该使用过滤器或者在视图函数中处理。

  4. 善用否定形式 :Jinja2 支持 is not 语法,善用否定形式可以让代码更易读。{% if x is not none %} 比 {% if not (x is none) %} 更自然。

  5. 避免过度嵌套:测试器组合使用时,注意不要嵌套过深。如果条件逻辑过于复杂,应该考虑将其移到视图函数中预处理,在模板中只做简单的判断。

测试器与过滤器组合使用

测试器和过滤器可以配合使用,先通过过滤器转换数据,再用测试器判断:

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">&times;</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">&times;</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>&copy; 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:额外CSS
  • content:主内容(几乎所有页面都要覆写)
  • sidebar:侧边栏
  • scripts:额外JavaScript
  • header_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>&copy; 2024 我的网站. All rights reserved.</p>
    </footer>
    
    <script src="/static/js/main.js"></script>
    <script>
        console.log('首页特有脚本');
    </script>
</body>
</html>

关键点:

  1. {% extends %} 必须是子模板的第一个标签。
  2. 子模板中 {% extends %} 之外的内容(不在 block 内的)会被忽略。
  3. 子模板只覆写需要自定义的 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 的注意事项

  1. 嵌套 block 的名称必须全局唯一(在同一模板树中)。
  2. 嵌套 block 允许更细粒度的覆写,但也增加了复杂度。
  3. 不要过度嵌套,保持结构清晰。

7.7 块的命名规范

好的命名规范能让模板更易维护。推荐以下命名约定:

块名 用途 是否有默认内容
title <title> 标签内容 有(网站名称)
styles 额外 CSS 无
header 页面头部 有
nav 导航栏 有
content 主内容区 无
sidebar 侧边栏 有(可选)
footer 页面底部 有
scripts 额外 JavaScript 无
meta 额外 meta 标签 无

命名建议:

  1. 使用小写字母和下划线。
  2. 名称应该描述内容,如 content、sidebar、scripts。
  3. 模块特有的 block 加前缀,如 blog_content、admin_nav。
  4. 避免太短或太通用的名称,如 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>&copy; 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">
        &larr; {{ post.prev.title }}
    </a>
    {% endif %}
    {% if post.next %}
    <a href="{{ url_for('blog.detail', post_id=post.next.id) }}" class="next">
        {{ post.next.title }} &rarr;
    </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 的优势:

  1. 解耦:URL 路径改变时,不需要修改模板。
  2. 自动处理特殊字符:URL 中的特殊字符会被正确编码。
  3. 支持动态参数:自动处理路由参数。
  4. 支持静态文件:自动添加版本号(配置后)。
  5. 支持蓝图:自动处理蓝图的 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>&copy; {{ 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 时执行。这意味着:

  1. 它会影响所有模板的渲染。
  2. 如果处理器中有耗时操作(如数据库查询),会影响所有页面的性能。
  3. 处理器中不要做太重的操作。

蓝图级上下文处理器

蓝图也可以有自己的上下文处理器,只对该蓝图的模板生效:

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>&copy; {{ current_year }} {{ site_name }}</p>
        <p>
            {% if enable_comments %}评论已开启 | {% endif %}
            每页 {{ posts_per_page }} 篇
        </p>
    </footer>
</body>
</html>

通过上下文处理器,我们把网站信息、当前用户、配置项、工具函数都注入到了全局上下文,所有模板都能直接使用,大大减少了视图函数的重复代码。

上下文处理器的执行时机与性能考量

理解上下文处理器的执行时机对于性能优化非常重要。上下文处理器在每次调用 render_template 时都会执行,这意味着:

  1. 如果一个页面渲染了多个模板(主模板 + include 的子模板),上下文处理器只执行一次,其结果在整个渲染过程中共享。
  2. 上下文处理器中的代码应该尽量轻量,避免执行耗时的数据库查询或复杂的计算。如果确实需要注入耗时计算的结果,应该考虑使用缓存。
  3. 多个上下文处理器可以同时注册,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)中设置消息,重定向到另一个页面后,在那个页面显示消息,消息显示后自动删除。

典型场景:

  1. 用户提交表单 -> 后端处理 -> 重定向到结果页 -> 显示"操作成功"
  2. 用户登录失败 -> 重定向回登录页 -> 显示"用户名或密码错误"
  3. 用户发表评论 -> 重定向到文章页 -> 显示"评论发表成功"

为什么需要闪现?

在 POST-Redirect-GET 模式(PRG)中,用户提交表单后,后端处理完会重定向到另一个页面。如果直接在响应中显示消息,用户刷新页面时会重复提交表单。重定向解决了这个问题,但重定向后如何把"操作结果"告诉用户?这就是闪现消息的作用。

底层原理

Flask 的闪现消息基于 Session 实现:

  1. flash(message) 把消息存入 session。
  2. 重定向后,get_flashed_messages() 从 session 读取消息并清除。
  3. 消息只在"下一次请求"中可用,之后自动消失。

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>&times;</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">&times;</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()">&times;</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' %}&#10004;
                {% elif category == 'error' %}&#10006;
                {% elif category == 'warning' %}&#9888;
                {% elif category == 'info' %}&#8505;
                {% endif %}
            </span>
            <span class="alert-text">{{ message }}</span>
            {% if dismissible %}
            <button type="button" class="alert-close" 
                    onclick="this.parentElement.remove()">
                &times;
            </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()">&times;</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>&copy; {{ 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">阅读全文 &rarr;</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">&larr; 上一篇</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">下一篇 &rarr;</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 组件,可被任意层次的模板引用。

组件化原则

  1. 单一职责:每个组件只做一件事。一个表单宏只负责渲染表单,不处理业务逻辑。
  2. 可组合 :小组件组合成大组件。如 form_field 由 label + input + error 组合。
  3. 可配置:通过参数让组件适应不同场景。
  4. 无副作用:组件不应该修改外部状态。

组件化示例

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> 会被转义为 &lt;script&gt; #}

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>

国际化最佳实践

  1. 原文用英文 :在模板中,_() 的参数应该用英文原文,而不是中文。这样翻译文件中的 msgid 是英文,便于国际化团队理解和管理。中文作为 zh_CN 的翻译存在 PO 文件中。

  2. 不要拼接翻译字符串:不要把翻译字符串拼接在一起,因为不同语言的语序可能不同。应该使用带参数的翻译:

jinja2 复制代码
{# 错误:拼接翻译 #}
{{ _('Hello,') }} {{ user.name }} {{ _('!') }}

{# 正确:带参数的翻译 #}
{{ _('Hello, %(name)s!', name=user.name) }}
  1. 给翻译者提供上下文 :对于有歧义的词汇,使用 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 本章总结与下一期预告

本章总结

本章我们通过一个完整的博客模板系统实战,把前九章学到的所有知识融会贯通。关键要点:

  1. 模板继承是骨架 :base.html 定义整体结构,子模板填充内容。
  2. 宏是组件:把可复用的 UI 片段封装为宏,提高复用率。
  3. Include 是片段:把页面片段提取出来,按需引入。
  4. 过滤器是工具:自定义过滤器处理日期、金额、文本等格式化。
  5. 上下文处理器是桥梁:把全局数据注入模板。
  6. 安全是底线:自动转义防 XSS,CSRF 令牌防跨站请求伪造。
  7. 性能是保障:合理使用缓存,避免模板中的复杂计算。
  8. 规范是维护性:好的命名、缩进、拆分让模板易于维护。

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 开发者从"能用"到"精通"的必经之路。

相关推荐
for_ever_love__13 分钟前
线性回归与梯度下降——从零手写一个模型
python·机器学习·线性回归·梯度下降
code_whiter36 分钟前
7.自动化测试常用函数
python·功能测试
小园子的小菜1 小时前
Python协程深度解析:从原理演进到实战避坑
后端·python
砚底藏山河1 小时前
量化实战:截面因子有效性检验(IC 分析与分层回测)
java·python·金融·maven
GitFun2 小时前
给策略加了条-8%止损线,11年只触发1次
python·股票·量化交易
马六六i2 小时前
市面上正规的IP驱动产业新场景新工具有哪些
大数据·网络·python·tcp/ip
AOI小白新手上路2 小时前
anomalib 缺陷检测复现笔记:从跑通库到 EfficientAD 落地
人工智能·笔记·机器学习
xxwl5853 小时前
RabbitMQ 学习笔记
笔记·学习·rabbitmq
I Am a robert girl3 小时前
稀有事件估计的迭代去对齐:从源码视角拆解重要性采样新范式
开发语言·python·机器学习·重要性采样·蒙特卡洛方法·稀有事件估计
天若有情6733 小时前
开源|Comfort Lang v1.0.1,一款主打清爽交互的新型命令式编程语言
python·开源·交互·编程语言·项目分享·repl·comfort lang