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_forget_flashed_messagesconfigrequestsessiong 等。
  • 过滤器 :注入 Flask 特有的过滤器,如 tojson
  • 上下文处理器 :Flask 自带的上下文处理器,注入 requestsessiongconfig 等。

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'),并合并以下来源:

  • 应用上下文 :gconfigsessionrequest(通过代理对象)。
  • 上下文处理器 :@app.context_processor 装饰的函数返回的字典。
  • Jinja2 全局对象 :url_forget_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_templaterender_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 中就可以使用 titleuser:

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>

因为 contentMarkup 对象,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>

安全提醒

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

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

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

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

自动转义与 safe 的交互机制

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

  1. 如果 variableMarkup 对象(即已被标记为安全),直接输出,不转义。
  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 默认注入 requestsessiongconfig {``{ request.args }}
全局函数 url_forget_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>

如果 userNoneFalse、空字符串、空列表等"假值",则不输出。

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 支持 andornot 逻辑运算符:

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.indexloop.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 %}

注意 :虽然 breakcontinue 很方便,但如果你发现自己频繁需要在模板里做这种复杂的循环控制,可能意味着这些逻辑应该放到视图函数里预处理数据,而不是塞进模板。模板应该尽量保持简单。

3.5 {% break %}和{% continue %}(扩展)

上一节已经介绍了 breakcontinue 的基本用法,这里补充一些更深入的细节和注意事项。

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 默认不启用 breakcontinue,出于以下设计考虑:

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

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

替代方案:用过滤器代替break

很多时候,break 的需求可以用过滤器的 selectreject 代替:

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. setnamespace 的使用场景 :在循环中累加变量时,必须使用 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_dateformat_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 应用上下文(除非确实需要访问 requestsession),这样可以在单元测试中直接调用:

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

这个案例综合运用了多种测试器:definedemptyemailmobileintegergtltiterable 等,展示了测试器在表单验证场景下的实际应用。

案例:数据展示层条件渲染

在实际开发中,我们经常需要根据数据的类型和特征来决定如何渲染。测试器在这种场景下非常有用。下面是一个商品列表的渲染示例,它根据商品的不同属性来决定展示方式:

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_nameavatar 只在宏内部有效,不会影响外部模板的同名变量。

注意: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:传递上下文

默认情况下,导入的宏不能 访问当前模板的上下文变量。如果宏需要访问 requestsession 等上下文,需要加 with context:

jinja2 复制代码
{% import 'macros/forms.html' as forms with context %}

或:

jinja2 复制代码
{% from 'macros/forms.html' import input with context %}

加了 with context 后,宏内部可以访问 requestconfigsession 等:

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. 宏应该职责单一

每个宏只做一件事。不要把整个表单塞进一个宏里,而是拆分成 inputlabelselect 等小宏,然后用组合的方式构建表单。

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 即可,内部嵌套的 avatartag 宏会自动处理:

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.htmltitle block 也调用了 super(),它会进一步引用 base.htmltitle 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. 名称应该描述内容,如 contentsidebarscripts
  3. 模块特有的 block 加前缀,如 blog_contentadmin_nav
  4. 避免太短或太通用的名称,如 xblock1

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_titlepost_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_namecurrent_usernowformat_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 以尽早发现问题;生产环境可以使用默认的 UndefinedChainableUndefined 以提高容错性。


第九章 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')

类别是自定义的字符串,常见的有:successinfowarningerror(或 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_fieldlabel + 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() %}
  • 变量名:有意义的名称,如 postuser_list,避免 xy

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

相关推荐
Capricorn19883 小时前
解决全网抄 Karpathy 导致的 LLM Wiki 污染?基于知芽 Notebook Skill 的 raw/ 笔记法排障指南
大数据·论文阅读·人工智能·笔记·论文笔记
壹玖玖肆3 小时前
openGauss使用笔记
笔记
宵时待雨3 小时前
linux笔记归纳17:传输层协议UDP
linux·笔记·udp
临沂GEO3 小时前
GEO搜索优化科普|正规地理位置流量运营入门指南
大数据·人工智能·python·流量运营
大模型码小白3 小时前
Spring AI Tool 实现自然语言操作 MySQL 数据库详解
服务器·开发语言·数据库·人工智能·python·mysql·spring
噜~噜~噜~5 小时前
操作系统笔记-2.3.2.1 进程互斥的软件实现方法
笔记·操作系统
荷蒲5 小时前
【小白量化Qbuddy】利用AI学习中文Python
人工智能·python·学习
云贝教育-郑老师5 小时前
【麒麟服务器系统交付:一个DBA的六年信创实战笔记】
服务器·笔记·dba
玖玥拾6 小时前
LeetCode 392 判断子序列
笔记·算法·leetcode