03-Jinja2模板引擎
Python 3.11+
本章讲解 Jinja2 模板引擎的基本语法、模板继承、过滤器和宏的使用。
第一部分:Jinja2 简介
1.1 实际场景
你需要在 Web 应用中展示动态内容,比如显示用户姓名、文章列表、当前日期等。纯字符串拼接太繁琐。
问题:如何优雅地将数据渲染到 HTML 页面中?
1.2 概念说明
Jinja2 是 Flask 默认的模板引擎,是一个现代且设计师友好的 Python 模板语言。
1.3 配置 Jinja2
from flask import Flask
app: Flask = Flask(__name__)
# Jinja2 配置
app.jinja_env.auto_reload = True # 自动重载模板
app.jinja_env.cache = None # 模板缓存
# 自定义配置
app.jinja_env.globals.update({
"site_name": "My Site",
"current_year": 2024
})
# 添加自定义过滤器
def datetime_format(value: str, format: str = "%Y-%m-%d") -> str:
if value:
return value.strftime(format)
return ""
app.jinja_env.filters["datetime"] = datetime_format第二部分:模板基础语法
2.1 实际场景
你有一个用户对象,需要在 HTML 页面中显示用户的姓名、邮箱、头像等信息。
问题:如何在模板中输出变量?
2.2 变量输出
<!-- 基本变量 -->
<p>用户名: {{ user.name }}</p>
<p>邮箱: {{ user.email }}</p>
<!-- 对象属性 -->
<p>{{ user.profile.bio }}</p>
<!-- 字典 -->
<p>{{ user_dict['name'] }}</p>
<!-- 列表 -->
<p>{{ items[0] }}</p>2.3 注释
{# 这是注释,不会被输出 #}
{#
多行注释
可以包含多行内容
#}第三部分:条件判断
3.1 实际场景
用户登录后显示"欢迎",未登录显示"请登录"。文章已发布显示内容,未发布显示"草稿"。
问题:如何在模板中进行条件判断?
3.2 if 语句
{% if user.is_active %}
<p>用户已激活</p>
{% elif user.is_pending %}
<p>等待审核</p>
{% else %}
<p>用户未激活</p>
{% endif %}
<!-- 简化的条件 -->
<p>{{ "欢迎" if user.is_logged_in else "请登录" }}</p>3.3 比较运算符
{% if age >= 18 %}
<p>成年人</p>
{% else %}
<p>未成年人</p>
{% endif %}
{% if name == 'admin' %}
<p>管理员</p>
{% endif %}3.4 逻辑运算符
{% if user.is_active and user.has_permission %}
<p>可以访问</p>
{% endif %}
{% if not user.is_banned %}
<p>未被封禁</p>
{% endif %}
{% if role == 'admin' or role == 'moderator' %}
<p>有管理权限</p>
{% endif %}第四部分:循环
4.1 实际场景
你有一组文章需要显示为列表,每篇文章显示标题、摘要和发布日期。
问题:如何在模板中遍历数据并渲染?
4.2 for 循环
<!-- 遍历列表 -->
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
<!-- 遍历字典 -->
<ul>
{% for key, value in user_dict.items() %}
<li>{{ key }}: {{ value }}</li>
{% endfor %}
</ul>
<!-- 遍历对象属性 -->
<ul>
{% for post in posts %}
<article>
<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>
</article>
{% endfor %}
</ul>4.3 循环变量
<!-- loop 变量提供循环信息 -->
<ul>
{% for item in items %}
<li>
索引: {{ loop.index }} <!-- 1-based -->
索引: {{ loop.index0 }} <!-- 0-based -->
首个: {{ loop.first }}
末位: {{ loop.last }}
总数: {{ loop.length }}
</li>
{% endfor %}
</ul>4.4 循环过滤
<!-- 反向遍历 -->
{% for item in items|reverse %}
<li>{{ item }}</li>
{% endfor %}
<!-- 带条件遍历 -->
{% for item in items if item.is_active %}
<li>{{ item.name }}</li>
{% endfor %}
<!-- 限制数量 -->
{% for item in items[:10] %}
<li>{{ item }}</li>
{% endfor %}第五部分:过滤器
5.1 实际场景
用户名需要大写显示,文章摘要需要截断为 200 字符,日期需要格式化显示。
问题:如何在模板中对数据进行处理?
5.2 内置过滤器
<!-- 字符串过滤器 -->
<p>{{ name|upper }}</p> <!-- 大写 -->
<p>{{ name|lower }}</p> <!-- 小写 -->
<p>{{ name|capitalize }}</p> <!-- 首字母大写 -->
<p>{{ title|trim }}</p> <!-- 去除空格 -->
<p>{{ text|truncate(50) }}</p> <!-- 截断 -->
<!-- 数字过滤器 -->
<p>{{ price|round(2) }}</p> <!-- 四舍五入 -->
<!-- 列表过滤器 -->
<p>{{ items|length }}</p> <!-- 长度 -->
<p>{{ items|first }}</p> <!-- 第一个 -->
<p>{{ items|last }}</p> <!-- 最后一个 -->
<p>{{ items|sort }}</p> <!-- 排序 -->
<p>{{ items|unique }}</p> <!-- 去重 -->
<!-- 布尔过滤器 -->
<p>{{ value|default('N/A') }}</p> <!-- 默认值 -->5.3 自定义过滤器
from flask import Flask
app: Flask = Flask(__name__)
# 方法一:注册过滤器
@app.template_filter("reverse")
def reverse_filter(s: str) -> str:
return s[::-1]
# 方法二:添加到全局
def markdown_to_html(text: str) -> str:
import markdown
return markdown.markdown(text)
app.jinja_env.globals["markdown"] = markdown_to_html<!-- 使用自定义过滤器 -->
<p>{{ name|reverse }}</p>
<p>{{ content|markdown }}</p>5.4 测试器
<!-- 测试类型 -->
{% if value is defined %}
<p>已定义</p>
{% endif %}
{% if value is none %}
<p>是 None</p>
{% endif %}
{% if name is string %}
<p>是字符串</p>
{% endif %}
{% if number is even %}
<p>是偶数</p>
{% endif %}
{% if user is mapping %}
<p>是字典</p>
{% endif %}第六部分:宏
6.1 实际场景
多个页面都有相似的表单输入框,如登录页、注册页、修改密码页,每次重复写相同的 HTML 很繁琐。
问题:如何复用模板中的重复代码片段?
6.2 定义宏
<!-- 定义输入宏 -->
{% macro input_field(name, label, type='text', value='') %}
<div class="form-group">
<label for="{{ name }}">{{ label }}</label>
<input type="{{ type }}"
id="{{ name }}"
name="{{ name }}"
value="{{ value }}">
</div>
{% endmacro %}
<!-- 定义按钮宏 -->
{% macro button(text, type='button', onclick='') %}
<button type="{{ type }}" onclick="{{ onclick }}">{{ text }}</button>
{% endmacro %}6.3 使用宏
<!-- 使用宏 -->
{{ input_field('username', '用户名') }}
{{ input_field('email', '邮箱', 'email') }}
{{ input_field('password', '密码', 'password') }}
{{ button('提交', 'submit', 'submitForm()') }}6.4 宏的导入
<!-- 导入宏文件 -->
{% from 'forms.html' import input_field, button %}
<!-- 导入并重命名 -->
{% from 'forms.html' import input_field as input %}
<!-- 导入所有 -->
{% from 'forms.html' import * %}第七部分:模板继承
7.1 实际场景
网站有统一的页面布局(头部导航、底部版权),但每个页面的内容区域不同。你不想在每个页面重复写导航和版权信息。
问题:如何定义基础模板让其他页面继承?
7.2 基础模板
<!-- templates/base.html -->
<!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>
<style>
{% block styles %}{% endblock %}
</style>
</head>
<body>
<header>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
{% block nav %}{% endblock %}
</nav>
</header>
<main>
{% block content %}
<p>默认内容</p>
{% endblock %}
</main>
<footer>
{% block footer %}
<p>© 2024 我的网站</p>
{% endblock %}
</footer>
<script>
{% block scripts %}{% endblock %}
</script>
</body>
</html>7.3 子模板
<!-- templates/home.html -->
{% extends "base.html" %}
{% block title %}首页{% endblock %}
{% block styles %}
{{ super() }}
<style>
.hero { background: #f0f0f0; }
</style>
{% endblock %}
{% block nav %}
<a href="/home">首页</a>
{% endblock %}
{% block content %}
<div class="hero">
<h1>欢迎来到首页</h1>
</div>
{% endblock %}
{% block footer %}
{{ super() }}
<p>首页专属 footer</p>
{% endblock %}第八部分:包含
8.1 实际场景
导航栏在多个页面都要显示,但不同页面的导航栏可能有些细微差异。
问题:如何在一个模板中嵌入另一个模板?
8.2 include 语句
<!-- 包含导航 -->
{% include 'navigation.html' %}
<!-- 条件包含 -->
{% include 'admin_sidebar.html' if current_user.is_admin %}
<!-- 包含并传递变量 -->
{% include 'post_item.html' with context %}8.3 动态包含
<!-- 根据变量选择模板 -->
{% include template_name %}第九部分:实战示例
9.1 博客布局
<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}博客{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="site-header">
<h1><a href="/">{{ site_name }}</a></h1>
<nav>
{% for item in nav_items %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% endfor %}
</nav>
</header>
<main class="content">
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<p>© {{ current_year }} {{ site_name }}</p>
</footer>
</body>
</html><!-- templates/index.html -->
{% extends "base.html" %}
{% block title %}首页 - {{ site_name }}{% endblock %}
{% block content %}
<section class="posts">
<h2>最新文章</h2>
{% for post in posts %}
<article class="post-card">
<h3>
<a href="{{ url_for('post', slug=post.slug) }}">
{{ post.title }}
</a>
</h3>
<div class="meta">
<span>作者: {{ post.author.username }}</span>
<span>日期: {{ post.created_at|date }}</span>
<span>浏览: {{ post.views }}</span>
</div>
<p class="excerpt">{{ post.summary|truncate(200) }}</p>
<a href="{{ url_for('post', slug=post.slug) }}" class="read-more">
阅读全文 →
</a>
</article>
{% else %}
<p>暂无文章</p>
{% endfor %}
</section>
<aside class="sidebar">
{% block sidebar %}
<div class="widget">
<h3>分类</h3>
<ul>
{% for category in categories %}
<li>
<a href="{{ url_for('category', slug=category.slug) }}">
{{ category.name }}
</a>
<span class="count">({{ category.post_count }})</span>
</li>
{% endfor %}
</ul>
</div>
{% endblock %}
</aside>
{% endblock %}第九部分:L3 专家层 — 底层原理
9.2 模板编译过程(AST → Python 代码)
Jinja2 模板在首次使用时被编译为 Python 字节码,后续请求直接执行编译后的代码。
┌─────────────────────────────────────────────────────────────────┐
│ 模板编译管线 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 原始模板 (template.html) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. Lexer (词法分析器) │ │
│ │ 输入: 模板源代码字符串 │ │
│ │ 输出: Token 流 [Text, VariableStart, Name, ...] │ │
│ │ │ │
│ │ <h1>{{ user.name }}</h1> │ │
│ │ → TEXT('<h1>') VAR_START NAME('user') DOT NAME('name') │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 2. Parser (语法分析器) │ │
│ │ 输入: Token 流 │ │
│ │ 输出: AST (抽象语法树) │ │
│ │ │ │
│ │ Template │ │
│ │ ├── nodes.Output │ │
│ │ │ └── nodes.Getattr │ │
│ │ │ ├── nodes.Name('user') │ │
│ │ │ └── 'name' │ │
│ │ └── ... │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 3. Code Generator (代码生成器) │ │
│ │ 输入: AST │ │
│ │ 输出: Python 源代码字符串 │ │
│ │ │ │
│ │ def root(context): │ │
│ │ l_user = resolve(context, 'user') │ │
│ │ yield '<h1>' │ │
│ │ yield escape(environment.getattr(l_user, 'name')) │ │
│ │ yield '</h1>' │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 4. Bytecode Compiler (字节码编译) │ │
│ │ 输入: Python 源代码 │ │
│ │ 输出: code object → 缓存到 TemplateModule │ │
│ │ │ │
│ │ compile(source, filename='<template>', 'exec') │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘# 查看模板编译后的 Python 代码
from jinja2 import Environment, FileSystemLoader
env: Environment = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("hello.html")
# 获取编译后的源码
source: str = env.compile_source(env.get_source("hello.html")[0])
print(source)
# 输出类似:
# from jinja2.runtime import LoopContext, Macro, Markup, TemplateRuntimeError
# def root(context, environment=environment):
# yield '<h1>Hello, '
# yield str(environment.getattr(resolve(context, 'user'), 'name'))
# yield '</h1>'9.3 沙盒机制
Jinja2 提供 SandboxedEnvironment 用于安全渲染不可信模板。
┌─────────────────────────────────────────────────────────────────┐
│ 沙盒环境安全控制 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 普通 Environment vs SandboxedEnvironment: │
│ │
│ ┌──────────────────────┬──────────────────────────────┐ │
│ │ 普通 Environment │ SandboxedEnvironment │ │
│ ├──────────────────────┼──────────────────────────────┤ │
│ │ 可调用任意 Python 函数│ 仅允许标记为 @unsafe 的属性 │ │
│ │ 可访问 __class__ │ 拦截 __class__/__mro__ 访问 │ │
│ │ 可执行 os.system() │ 拦截危险方法调用 │ │
│ │ 无属性访问限制 │ is_safe_attribute() 检查 │ │
│ └──────────────────────┴──────────────────────────────┘ │
│ │
│ 沙盒拦截点: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • getattr: 拦截访问以下划线开头的属性 │ │
│ │ • call: 拦截调用未标记为 safe 的可调用对象 │ │
│ │ • getitem: 拦截对非映射/序列类型的索引访问 │ │
│ │ • iter: 拦截对不可迭代对象的遍历 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 典型攻击向量拦截: │
│ {{ ''.__class__.__mro__[1].__subclasses__() }} │
│ → SecurityError: access to attribute '__class__' is unsafe │
│ │
└─────────────────────────────────────────────────────────────────┘from jinja2 import SandboxedEnvironment
env = SandboxedEnvironment()
# 安全:普通属性访问
tmpl = env.from_string("Hello {{ user.name }}")
print(tmpl.render(user={"name": "Alice"})) # Hello Alice
# 拦截:危险属性访问
tmpl = env.from_string("{{ data.__class__ }}")
try:
tmpl.render(data="test")
except Exception as e:
print(f"拦截: {e}") # SecurityError
# 自定义安全检查
class MySandboxedEnv(SandboxedEnvironment):
def is_safe_attribute(self, obj: object, attr: str, value: object) -> bool:
# 禁止访问所有以 'secret' 开头的属性
if attr.startswith("secret"):
return False
return super().is_safe_attribute(obj, attr, value)9.4 模板继承的 MRO 查找规则
Jinja2 的模板继承机制类似 Python 类的 MRO(Method Resolution Order),使用 super() 调用父模板的 block。
┌─────────────────────────────────────────────────────────────────┐
│ 模板继承链与 block 解析 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 继承层次: │
│ │
│ base.html │
│ ├── block title: "默认标题" │
│ ├── block styles: "" │
│ ├── block content: "<p>默认内容</p>" │
│ └── block footer: "© 2024" │
│ │ │
│ ▲ extends │
│ │ │
│ page.html │
│ ├── block title: "页面标题" │
│ ├── block styles: {{ super() }} + 额外 CSS │
│ ├── block content: "<h1>页面内容</h1>" │
│ └── block footer: {{ super() }} + "额外信息" │
│ │ │
│ ▲ extends │
│ │ │
│ home.html │
│ ├── block content: {{ super() }} + "<p>首页特有</p>" │
│ └── block title: "首页" │
│ │
│ 渲染 home.html 时的 block 解析: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ block title: home → page → base │ │
│ │ "首页" (home 覆盖) │ │
│ │ │ │
│ │ block styles: home → page → base │ │
│ │ page 有 {{ super() }} + 额外 CSS │ │
│ │ → base 的 styles + page 的额外 CSS │ │
│ │ │ │
│ │ block content: home → page → base │ │
│ │ home 有 {{ super() }} │ │
│ │ → page 的 content + home 的首页特有 │ │
│ │ │ │
│ │ block footer: home → page → base │ │
│ │ page 有 {{ super() }} + "额外信息" │ │
│ │ → base 的 footer + page 的额外信息 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 内部实现:blocks 字典存储继承链上所有同名 block, │
│ render 时从最派生类开始执行,遇到 super() 时调用父级版本。 │
│ │
└─────────────────────────────────────────────────────────────────┘9.5 性能考量
| 操作 | 耗时 | 内存 | 说明 |
|---|---|---|---|
| 模板首次编译 | ~5ms | ~50KB | Lexer → Parser → CodeGen → compile |
| 缓存命中渲染 | ~0.3ms | ~10KB | 直接执行编译后的 bytecode |
| 缓存未命中渲染 | ~5.3ms | ~60KB | 编译 + 渲染 |
| 模板继承(3 层) | ~0.5ms | ~15KB | block 链式调用开销 |
| 宏调用 | ~0.02ms | ~1KB | 函数调用级别开销 |
| 过滤器链(5 个) | ~0.1ms | — | 每个过滤器额外函数调用 |
扩展性: Jinja2 模板缓存默认启用(cache_size=400)。生产环境应设置 cache=SimpleCache(400) 或 cache=FileSystemCache()。模板数量超过 400 时,LRU 淘汰最久未使用的模板。
9.6 设计动机
| 设计决策 | 动机 | 权衡 |
|---|---|---|
| 编译为 Python 代码后执行 | 利用 Python 虚拟机优化,性能接近原生代码 | 编译开销,首次渲染较慢 |
| 自动 HTML 转义(autoescape) | 防止 XSS 攻击,安全默认值 | 对不需要转义的内容需用 ` |
| 沙盒环境独立于主环境 | 安全渲染不可信模板(用户自定义模板) | 功能受限,部分过滤器不可用 |
| block 而非占位符的继承机制 | 支持多层继承和 super() 组合 | 继承链过深时调试困难 |
| 宏作为模板函数 | 代码复用,参数化组件 | 宏内无法访问调用者上下文(需 with context) |
9.7 知识关联
模板文件 (.html)
│
▼
┌─────────────────┐
│ Lexer │ ← 词法分析
│ (Token 流) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Parser │ ← 语法分析
│ (AST 树) │
└────────┬────────┘
│
┌──────┴──────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Code Gen │ │ Inheritance │
│ (Python 码) │ │ (MRO 链) │
└──────┬───────┘ └──────┬───────┘
│ │
▼ │
┌──────────────┐ │
│ Bytecode │ │
│ (缓存) │◄───────┘
└──────┬───────┘
│
▼
┌──────────────┐
│ Renderer │ ← 上下文数据 + 编译模板 → HTML
└──────────────┘总结
| 知识点 | 说明 |
|---|---|
| 变量 | |
| 条件 | {% if %} |
| 循环 | {% for %} |
| 过滤器 | ` |
| 宏 | {% macro %} |
| 继承 | {% extends %} |
| 包含 | {% include %} |
| 块 | {% block %} |