Jinja2 学习指南

Jinja2 学习指南

Jinja2 是 Python 的模板引擎:将数据传入模板,生成文本。常见用途包括生成 HTML、配置文件、提示词和 JSON。

本文基于 Jinja2==3.1.6,先介绍语法与模板复用,再通过完整示例演示如何动态生成 JSON,以及如何添加自定义 filter。

模板、Context 和渲染结果

三个概念:

安装:

python -m pip install Jinja2==3.1.6

最小示例:

from jinja2 import Environment, StrictUndefined

# autoescape 控制是否对输出值进行 HTML 转义,例如将 < 转为 &lt;。
# 这里生成纯文本,因此关闭;生成 HTML 时应启用。
env = Environment(undefined=StrictUndefined, autoescape=False)
template = "你好,{{ name }}!"
context = {"name": "张三"}

result = env.from_string(template).render(context)
assert result == "你好,张三!"
print(result)

from_string() 将字符串编译成模板,render() 接收上下文并执行渲染。上下文既可以通过 render(context) 直接传入字典,也可以通过 render(**context) 展开为关键字参数;在本例中,两种写法的效果相同。

同一个模板需要反复使用时,可以先编译一次,再用不同 context 调用 render()。 通常也可以复用配置好的 Environment。

三种基本标记

{# 这是一条注释 #}
名称:{{ user.name }}
{% if user.enabled %}
状态:启用
{% else %}
状态:停用
{% endif %}

字典可以通过 user.name 或 user["name"] 访问。

点号访问优先查找对象属性,方括号访问优先查找字典键。例如,字典 user = {"items": ["书", "笔"]} 中,user.items 会取到字典自带的 items 方法,而 user["items"] 才会取到列表 ["书", "笔"]。因此,读取与字典方法同名的键时,应使用方括号。

表达式与字符串拼接

Jinja 支持常见的算术、比较和逻辑表达式:

{{ price * quantity }}
{% if count > 10 and enabled %}满足条件{% endif %}
{{ "启用" if enabled else "停用" }}

~ 是字符串拼接运算符,会先把操作数转为字符串。

{{ source ~ ":" ~ document_id }}

当 source="video"、document_id=42 时,结果为 video:42。类似于 Python:

source = "video"
document_id = 42
result = str(source) + ":" + str(document_id)
assert result == "video:42"

不要将 Jinja 表达式当作任意 Python 代码:模板支持自己的语法,并不是 Python 解释器。


循环与条件

{% for item in items %}
{{ loop.index }}. {{ item.name }}
{% else %}
暂无数据
{% endfor %}

for 的 else 在没有元素被迭代时执行。loop 是 Jinja 在 for 循环内部自动提供的特殊变量,无需在 context 中定义;它指向当前循环的上下文对象,可通过以下属性获取循环信息:

可以直接筛选循环元素:

{% for item in items if item.enabled %}
{{ item.name }}
{% endfor %}

loop.changed(value) 用于判断传入的值是否发生变化:第一次调用时返回 true;之后与上一次调用的值比较,不同则返回 true,相同则返回 false。

例如,依次遍历分类为“图书、图书、文具”的三条记录,希望每个连续分类只输出一次标题:

{% for item in items %}
  {% if loop.changed(item.category) %}
分类:{{ item.category }}
  {% endif %}
- {{ item.name }}
{% endfor %}

第一条记录输出“分类:图书”,第二条分类未变,不重复输出标题;第三条分类变为“文具”,输出新标题。

loop.changed() 只比较调用时传入的值,不负责排序或分组。如果分类顺序是“图书、文具、图书”,就会输出三次标题;需要同类记录集中展示时,应先按分类排序。

Filter 与 Test

Filter:处理值

Filter 使用 | 连接,可以传参数、连续调用:

{{ name | upper }}
{{ items | length }}
{{ names | join(", ") }}
{{ users | selectattr("enabled") | map(attribute="name") | join(", ") }}

最后一个例子依次筛选启用的用户、提取姓名、拼接为字符串。

from jinja2 import Environment, StrictUndefined

env = Environment(undefined=StrictUndefined, autoescape=False)
template = '{{ users | selectattr("enabled") | map(attribute="name") | join(", ") }}'
context = {
    "users": [
        {"name": "张三", "enabled": True},
        {"name": "李四", "enabled": False},
        {"name": "王五", "enabled": True},
    ]
}

result = env.from_string(template).render(context)
assert result == "张三, 王五"
print(result)

处理过程:

  1. selectattr("enabled") 保留 enabled 为真值的用户,即张三和王五。
  1. map(attribute="name") 提取这两个用户的 name 字段。
  1. join(", ") 用逗号和空格连接姓名,输出 张三, 王五。

常用的集合过滤器还包括 sort、groupby、rejectattr、unique。部分过滤器返回迭代器,需要列表时使用 |list。

Test:判断值

Test 使用 is:

{% if name is defined %}变量已定义{% endif %}
{% if value is none %}值为 None{% endif %}
{% if value is string %}值是字符串{% endif %}
{% if value is mapping %}值是映射{% endif %}

未定义变量与默认值

建议使用 StrictUndefined,让缺少变量的问题在渲染时暴露,而不是静默输出空字符串。

可选变量可以显式提供默认值:

{{ description | default("暂无描述") }}

默认只替换未定义的变量;要同时替换 None、空字符串、False、0 等假值,使用:

{{ description | default("暂无描述", true) }}
default(..., true) 也会替换合法的 0 或 False,使用前应确认这符合预期。

自定义 Filter

自定义 filter 是一个 Python 函数。通过 env.filters 注册后,就能在模板中使用。

from jinja2 import Environment, StrictUndefined


def zfill(value: int | str, width: int = 6) -> str:
    """将值转为字符串,不足指定宽度时在左侧补零。"""
    return str(value).zfill(width)


env = Environment(undefined=StrictUndefined, autoescape=False)
env.filters["zfill"] = zfill

template = '{{ source ~ ":" ~ (document_id | zfill(6)) }}'
context = {"source": "video", "document_id": 42}
result = env.from_string(template).render(context)
assert result == "video:000042"
print(result)

表达式 document_id | zfill(6) 对应函数调用 zfill(document_id, 6):管道左侧的值作为第一个参数,括号中的 6 是后续参数。

超过指定宽度时,这个过滤器不会截断原值。

过滤器应在编译或加载模板前注册。自定义 test 注册到 env.tests,全局函数或常量注册到 env.globals。

根据 Context 生成 JSON

推荐在模板中构造字典或列表,再用 tojson 序列化整个对象。这样不需要手动处理 JSON 的引号、逗号和转义。

下面是完整示例:模板是字符串,context 是字典,自定义 zfill 用于格式化 ID。

"""根据 context 渲染主键 JSON,并验证它可以被解析。"""

import json
from typing import Any

from jinja2 import Environment, StrictUndefined


def zfill(value: int | str, width: int = 6) -> str:
    """将 ID 转成字符串并在左侧补零,例如 42 转为 000042。"""
    return str(value).zfill(width)


def render_json(template: str, context: dict[str, Any]) -> str:
    """渲染模板,确保结果是合法 JSON 后返回文本。"""
    env = Environment(
        undefined=StrictUndefined,
        autoescape=False,
        trim_blocks=True,  # 移除 {% ... %} 语句块后紧接的第一个换行。
        lstrip_blocks=True,  # 去掉 {% if %}、{% for %} 等控制语句前的行首缩进。
    )
    # 注册后,模板可通过 document_id | zfill(6) 调用。
    env.filters["zfill"] = zfill
    rendered = env.from_string(template).render(context).strip()
    json.loads(rendered)
    return rendered


def main() -> None:
    template = """
{% if source == "video" %}
{{ {
    "tenant_id": tenant_id,
    "video_id": source ~ ":" ~ (document_id | zfill(6))
} | tojson }}
{% else %}
{{ {
    "tenant_id": tenant_id,
    "document_id": document_id
} | tojson }}
{% endif %}
"""
    context: dict[str, Any] = {
        "tenant_id": 100,
        "source": "video",
        "document_id": 42,
    }
    rendered = render_json(template, context)
    print("生成的 JSON:")
    print(rendered)

    # 模拟接收方解析 JSON。
    primary_key = json.loads(rendered)
    print("\n解析后的 Python 对象:")
    print(primary_key)
    print("\n字段类型:")
    for name, value in primary_key.items():
        print(f"{name}: {type(value).__name__}")


if __name__ == "__main__":
    main()

source="video" 时输出:

{"tenant_id": 100, "video_id": "video:000042"}

将 source 改为 "document" 后输出:

{"document_id": 42, "tenant_id": 100}

为什么不要直接拼接 JSON 字符串

下面这种模板容易因数据中出现引号或换行而生成错误 JSON:

{"name": "{{ name }}"}

推荐写为:

{{ {"name": name} | tojson }}

tojson 会将可序列化的 Python 值转换为 JSON,包括将 None 转为 null,将布尔值转为 true、false。它不会自动将任意 Python 对象转换为 JSON;日期等类型需要先转换。


macro 与 import:复用带参数的模板逻辑

macro 类似模板中的函数,支持参数和默认值:

{% macro label(name, suffix="") -%}
{{ name }}{{ suffix }}
{%- endmacro %}

{{ label("任务", "已完成") }}

在常规字符串渲染环境中,宏返回渲染后的文本,不用于返回任意 Python 对象。

宏可以保存在另一个模板中,用 import 导入。下面通过 DictLoader 将所有模板都保留为 Python 字符串:

from jinja2 import DictLoader, Environment, StrictUndefined

templates = {
    "macros.j2": '{% macro label(name) %}[{{ name }}]{% endmacro %}',
    "main.j2": '{% import "macros.j2" as ui %}{{ ui.label(name) }}',
}
env = Environment(
    loader=DictLoader(templates), undefined=StrictUndefined, autoescape=False
)
result = env.get_template("main.j2").render({"name": "完成"})
assert result == "[完成]"
print(result)

也可以只导入某个宏:

{% from "macros.j2" import label %}
{{ label("完成") }}

include 与 import 的区别

include 渲染另一个模板并插入结果;import 导入宏等定义,供后续调用。

from jinja2 import DictLoader, Environment, StrictUndefined

env = Environment(
    loader=DictLoader({
        "header.j2": "标题:{{ title }}",
        "main.j2": '{% include "header.j2" %}\n正文',
    }),
    undefined=StrictUndefined,
    autoescape=False,
)
result = env.get_template("main.j2").render({"title": "执行报告"})
assert result == "标题:执行报告\n正文"
print(result)

上下文的默认行为不同:

需要改变默认行为时:

{% include "header.j2" without context %}
{% import "macros.j2" as ui with context %}

include 还支持忽略不存在的模板:

{% include "optional.j2" ignore missing %}

仅使用 from_string() 并没有为环境配置加载器;如果模板内部需要 include、import 或 extends,仍需配置 DictLoader、FileSystemLoader 等加载器。


extends、block 与 super():模板继承

模板继承可以理解为:父模板规定整体结构,并用 block 留出可以替换的区域;子模板用同名 block 提供这些区域的内容。

第一步:父模板定义结构和可替换区域

父模板 base.j2:

标题:{% block title %}默认标题{% endblock %}
内容:{% block content %}{% endblock %}

这里定义了两个区域:

标题:、内容: 以及它们之间的换行位于 block 外,属于父模板的固定结构。子模板覆盖 block 时,这些固定文字仍然保留。

第二步:子模板覆盖同名区域

子模板 report.j2:

{% extends "base.j2" %}
{% block title %}执行报告 · {{ super() }}{% endblock %}
{% block content %}共处理 {{ count }} 条记录。{% endblock %}

{% extends "base.j2" %} 表示使用 base.j2 的整体结构。子模板中的 title 和 content 分别替换父模板中同名区域的内容。

super() 表示“取父模板中这个 block 的内容”。这里位于 title 内,所以得到“默认标题”,最终标题区域是“执行报告 · 默认标题”。如果不调用 super(),父模板的默认标题就不会保留。

子模板没有覆盖的 block,继续使用父模板的默认内容。block 是模板中的命名区域,不是 context 中的变量。

第三步:渲染子模板,得到完整结果

传入 count=3,渲染 report.j2,结果是:

标题:执行报告 · 默认标题
内容:共处理 3 条记录。

可以按下面的顺序理解:

  1. 使用父模板的 标题:。
  1. 在 title 区域填入子模板的内容;其中 super() 补入父模板的“默认标题”。
  1. 保留父模板的换行和 内容:。
  1. 在 content 区域填入子模板的内容,并将 count 渲染为 3。

渲染的是子模板,但输出采用父模板的整体结构。 不需要先手动渲染父模板,再拼接子模板。

完整 Python 示例

下面用 DictLoader 为两个字符串模板指定名称:

from jinja2 import DictLoader, Environment, StrictUndefined

templates = {
    "base.j2": (
        "标题:{% block title %}默认标题{% endblock %}\n"
        "内容:{% block content %}{% endblock %}"
    ),
    "report.j2": """{% extends "base.j2" %}
{% block title %}执行报告 · {{ super() }}{% endblock %}
{% block content %}共处理 {{ count }} 条记录。{% endblock %}
""",
}
env = Environment(
    loader=DictLoader(templates),
    undefined=StrictUndefined,
    autoescape=False,
)

template = env.get_template("report.j2")
result = template.render({"count": 3})
assert result == "标题:执行报告 · 默认标题\n内容:共处理 3 条记录。"
print(result)

可以尝试两处修改:


call 与 caller():向宏传递模板内容

call 用于调用宏,并向宏传入一段模板内容。宏负责外层结构,调用方决定内部内容;宏通过 caller() 将这段内容渲染到指定位置。

先定义宏:

{% macro section(title) %}
标题:{{ title }}
{{ caller() }}
{% endmacro %}

这个宏输出标题,然后在 caller() 所在的位置输出调用方传入的内容。

调用宏:

{% call section("运行结果") %}
共处理 {{ count }} 条记录。
{% endcall %}

这次调用传入了两样东西:

假设 context 中的 count=3,渲染后得到以下内容(省略额外空行):

标题:运行结果
共处理 3 条记录。

其中,{{ title }} 输出“运行结果”,{{ caller() }} 渲染调用方传入的模板内容,并将 count 替换为 3。

caller 是 Jinja 为这次 call 调用自动提供的特殊变量,无需在 context 中定义。它指向调用方传入的那段模板内容,可通过 caller() 调用。

set、作用域与 namespace

set 可以定义变量,也可以捕获一段渲染结果:

{% set title = "报告" %}
{% set summary %}共 {{ items | length }} 条记录{% endset %}

循环中的普通赋值不会更新循环外的同名变量。 需要跨循环保存状态时使用 namespace:

{% set ns = namespace(total=0) %}
{% for item in items %}
  {% set ns.total = ns.total + item.size %}
{% endfor %}
总大小:{{ ns.total }}

如果只是求和,可以直接写 {{ items | sum(attribute="size") }};复杂计算通常放在 Python 中完成。


空白控制

环境级配置:

局部控制:

{{- value -}}
{%- if enabled -%}
内容
{%- endif -%}

左侧的 - 删除标记前的空白,右侧的 - 删除标记后的空白,可能包括换行。应按需使用,避免把本来需要分开的文字连接起来。


按输出类型配置环境

JSON 与纯文本

本指南的 JSON 示例使用 autoescape=False,由 tojson 负责 JSON 序列化。

HTML

生成 HTML 时应启用自动转义,避免把输入数据直接解释成 HTML:

from jinja2 import Environment, StrictUndefined

env = Environment(autoescape=True, undefined=StrictUndefined)
result = env.from_string("<p>{{ text }}</p>").render({"text": "<script>"})
assert result == "<p>&lt;script&gt;</p>"

同时使用多种类型的模板文件时,可以通过 select_autoescape() 按文件扩展名决定是否启用自动转义。例如,下面的配置为 .html、.xml 模板启用转义,其他扩展名默认不启用:

from jinja2 import Environment, FileSystemLoader, select_autoescape

env = Environment(
    loader=FileSystemLoader("templates"),
    autoescape=select_autoescape(["html", "xml"]),
)

|safe 则用于标记一个值,让它跳过自动转义。例如,text 的值为 "<b>你好</b>",启用自动转义后:

safe 只跳过转义,不会检查或清理 HTML。 仅在内容已确认可安全作为 HTML 输出时使用,不要直接用于未经处理的用户输入。