Flask 的 Jinja2 模板怎么用?自动转义和 XSS 要注意什么?
简化版
Jinja2 是 Flask 默认的模板引擎,核心语法只有三种:{{ 表达式 }} 输出值、{% 语句 %} 控制流(if/for/block/extends/include/macro)、{# 注释 #}。最重要的安全特性是自动转义——Flask 对 .html、.htm、.xml、.xhtml 后缀的模板默认开启 autoescape,{{ user_input }} 里的 <、>、&、"、' 会被转成 HTML 实体,从而挡住绝大多数 XSS。但有三个自动转义管不到的地方:① |safe 过滤器和 Markup() 会显式关闭转义(用在不可信数据上就是直接开洞);② <script> 标签内部——HTML 转义规则和 JavaScript 不同,把数据插进 JS 代码里必须用 |tojson;③ HTML 属性里的 URL——href="{{ url }}" 挡不住 javascript:alert(1)。模板复用靠三件套:extends + block(继承,最常用)、include(片段插入,共享上下文)、macro(可传参的可复用组件)。Flask 注入的模板全局变量有 request、session、g、config、url_for()、get_flashed_messages();要在所有模板里加自定义变量用 @app.context_processor,加自定义过滤器用 @app.template_filter。性能上要知道:模板会被编译成 Python 代码并缓存,所以渲染本身很快,真正的瓶颈通常是模板里触发的数据库查询(N+1)。核心记忆:{{ }} 输出、{% %} 控制、{# #} 注释;HTML 模板默认自动转义;JS 里用 |tojson、URL 要校验协议;|safe 只用于自己生成的内容。
详细版
模板语法速查:
| 语法 | 用途 | 例子 |
|---|---|---|
{{ }} | 输出(自动转义) | {{ user.name }} |
{% %} | 控制流 | {% for x in xs %} |
{# #} | 注释(不输出) | {# TODO #} |
|filter | 过滤器 | {{ s|upper|truncate(20) }} |
extends/block | 模板继承 | 布局复用 |
include | 插入片段 | 共享当前上下文 |
macro | 可传参组件 | 像函数一样调用 |
<!-- ① ★继承:base.html★ -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}默认标题{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body>
{% include "_nav.html" %} <!-- ★共享上下文★ -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>
<!-- ② ★子模板★ -->
{% extends "base.html" %} <!-- ★必须是第一行★ -->
{% block title %}文章列表 - {{ super() }}{% endblock %} <!-- ★super() 取父块内容★ -->
{% block content %}
{% for post in posts %}
<article>
<h2><a href="{{ url_for('blog.detail', pid=post.id) }}">{{ post.title }}</a></h2>
<p>{{ post.summary|truncate(100) }}</p>
<time>{{ post.created|datetimeformat }}</time> <!-- ★自定义过滤器★ -->
</article>
{% else %} <!-- ★★for-else:列表为空时★★ -->
<p>还没有文章</p>
{% endfor %}
{% endblock %}
<!-- ③ ★macro:可复用组件★ -->
{% macro field(name, label, type="text", value="", error=None) %}
<div class="form-group {% if error %}has-error{% endif %}">
<label for="{{ name }}">{{ label }}</label>
<input type="{{ type }}" name="{{ name }}" id="{{ name }}" value="{{ value }}">
{% if error %}<span class="err">{{ error }}</span>{% endif %}
</div>
{% endmacro %}
<!-- 使用(跨文件要 import) -->
{% from "_macros.html" import field %}
{{ field("email", "邮箱", type="email", error=errors.email) }}
<!-- ④ ★★安全:三个转义场景★★ -->
{{ user_input }} <!-- ✓ ★自动转义★ -->
{{ user_input|safe }} <!-- ✗ ★关闭转义 = XSS 风险★ -->
<script>
var data = {{ data|tojson }}; <!-- ✓ ★★JS 里必须用 tojson★★ -->
var bad = "{{ data }}"; <!-- ✗ ★HTML 转义挡不住 JS 注入★ -->
</script>
<a href="{{ url }}">链接</a> <!-- ✗ ★javascript: 协议能绕过★ -->
<a href="{{ url|safe_url }}">链接</a> <!-- ✓ ★自定义过滤器校验协议★ -->
<!-- ⑤ ★常用过滤器★ -->
{{ s|upper }} {{ s|lower }} {{ s|title }} {{ s|trim }}
{{ s|truncate(50, killwords=False, end="…") }}
{{ s|default("无", boolean=true) }} <!-- ★boolean=true 时空串也走默认★ -->
{{ n|round(2) }} {{ n|int }} {{ items|length }}
{{ items|join(", ") }} {{ items|first }} {{ items|last }}
{{ items|sort(attribute="name") }} {{ items|selectattr("active") }}
{{ d|tojson }} <!-- ★JSON 序列化(安全)★ -->
{{ text|striptags }} <!-- ★去掉 HTML 标签★ -->
{{ html|safe }} <!-- ★★只用于自己生成的内容★★ -->
# ⑥ ★Python 侧:渲染与扩展★
from flask import render_template, render_template_string
@app.route("/posts")
def posts():
return render_template("posts/list.html", posts=Post.query.all())
# ★上下文处理器:给所有模板注入变量★
@app.context_processor
def inject_globals():
return {"site_name": "我的站点", "now": datetime.now()}
# ★自定义过滤器★
@app.template_filter("datetimeformat")
def datetimeformat(value, fmt="%Y-%m-%d %H:%M"):
return value.strftime(fmt) if value else ""
# ★自定义测试(用在 is 后面)★
@app.template_test("recent")
def is_recent(dt): return (datetime.now() - dt).days < 7
# 模板:{% if post.created is recent %}新{% endif %}
# ★全局函数★
app.jinja_env.globals["current_year"] = lambda: datetime.now().year
# ★★危险:render_template_string 拼接用户输入 = SSTI★★
render_template_string("Hello " + user_input) # ✗ ★服务端模板注入★
render_template_string("Hello {{ name }}", name=user_input) # ✓
⚠️ 三个必须记住的点:① 自动转义只按文件扩展名开启——Flask 默认对
.html、.htm、.xml、.xhtml结尾的模板开启 autoescape,.txt、.md、.j2这些后缀是不转义的。所以邮件纯文本模板、CSV 模板不转义是设计如此,但如果你把一个 HTML 模板命名成mail.j2,自动转义就悄悄失效了——这是个非常隐蔽的漏洞来源。② HTML 自动转义挡不住 JavaScript 上下文的注入。<script>var x = "{{ data }}";</script>里,即使data中的<被转义成<,攻击者仍可以用";alert(1);//这样的载荷闭合字符串并注入代码(因为 JS 字符串里的"转义规则和 HTML 不同)。正确做法是{{ data|tojson }}——它输出的是合法的 JS 字面量,并且 Jinja2 的tojson会额外转义<、>、&、'以防止</script>提前闭合。③|safe是显式的信任声明,只能用在「你自己生成的 HTML」上。用在任何来自用户的内容上(评论、简介、富文本)就等于直接开了 XSS。如果确实需要允许用户提交富文本,必须先用bleach/nh3这类库做白名单清洗再|safe输出——注意「过滤黑名单」的做法基本都能被绕过,一定要用白名单。
完整版教学
一、Jinja2 的执行模型
★ 模板不是"每次解析文本",而是"编译成 Python 代码":
┌────────────────────────────────────────────────────┐
│ posts.html(文本) │
│ ↓ ★第一次渲染时编译★ │
│ 生成一个 Python 函数(generator) │
│ def root(context): │
│ yield "<h1>" │
│ yield escape(context["title"]) │
│ yield "</h1>" │
│ ↓ ★缓存在 jinja_env.cache★ │
│ 之后每次渲染 = ★直接调用这个函数★ │
└────────────────────────────────────────────────────┘
★ 所以:★模板渲染本身很快★,
★真正的瓶颈几乎总是模板里触发的数据库查询★
★ ★缓存与自动重载★:
app.config["TEMPLATES_AUTO_RELOAD"] = True # ★开发时★(debug 下默认开)
app.jinja_env.cache_size = 400 # ★默认 400 个模板★
★ 生产环境不要开 AUTO_RELOAD(每次渲染都 stat 文件)
★ ★渲染的完整流程★:
render_template("posts.html", posts=xs)
↓
① ★jinja_loader 找文件★(默认 templates/ 目录 + 各蓝图的 templates/)
↓
② 编译(或取缓存)
↓
③ ★合并上下文★:
- 你传的 kwargs
- ★context_processor 注入的★
- ★Flask 内置的:request/session/g/config/url_for/...★
↓
④ ★before_render_template 信号★
↓
⑤ 执行 → str
↓
⑥ ★template_rendered 信号(测试时用它断言)★
★ ★模板查找顺序(★蓝图相关★)★:
app = Flask(__name__) # ★templates/ 在应用包下★
bp = Blueprint("blog", __name__, template_folder="templates")
★ 查找顺序:★应用的 templates/ 优先★,然后才是蓝图的
→ ★所以应用可以覆盖蓝图提供的模板★(做主题定制很有用)
✓ 蓝图模板建议放子目录避免冲突:
blog/templates/blog/list.html
render_template("blog/list.html")
★ ★变量查找的规则★:
{{ user.name }}
→ ① 尝试 ★user.name(属性)★
→ ② 失败则尝试 ★user['name'](下标)★
{{ user['name'] }}
→ ① 先尝试下标,② 再尝试属性
★ 都失败 → 返回 ★Undefined 对象★(★输出为空字符串,不报错★)
★ ★Undefined 的三种模式(★调试相关★)★:
from jinja2 import StrictUndefined, ChainableUndefined
app.jinja_env.undefined = StrictUndefined
┌──────────────────┬──────────────────────────────────┐
│ Undefined(默认) │ ★输出空串,静默★ │
│ ★StrictUndefined★ │ ★访问就报错(★推荐开发时用★)★ │
│ ChainableUndefined│ 允许链式访问 a.b.c 不报错 │
└──────────────────┴──────────────────────────────────┘
★ ★默认的静默行为是"模板明明写了却没显示"的头号原因★
(变量名拼错、忘了传参 → 悄无声息地渲染成空)
理解 Jinja2 的关键是:模板会被编译成 Python 函数并缓存,之后每次渲染就是直接调用这个函数——所以模板渲染本身很快,真正的瓶颈几乎总是模板里触发的数据库查询。渲染流程里有个蓝图相关的细节:应用的 templates/ 优先于蓝图的,所以应用可以覆盖蓝图提供的模板(做主题定制很有用),但也意味着蓝图模板应该放在子目录里避免同名冲突。变量查找的规则是 {{ user.name }} 先试属性再试下标({{ user['name'] }} 反过来),都失败时返回 Undefined 对象——输出空字符串且不报错。这个静默行为是「模板明明写了却没显示」的头号原因(变量名拼错、忘了传参都会悄无声息),所以开发环境强烈建议设 app.jinja_env.undefined = StrictUndefined 让它直接报错。
二、自动转义与 XSS 防护
★ 自动转义做了什么:
{{ "<script>alert(1)</script>" }}
→ ★<script>alert(1)</script>★
转义的字符:★< > & " '★(五个)
★ ★开启规则:按扩展名(★关键★)★:
select_jinja_autoescape 的默认实现:
┌────────────────────────────┬──────────┐
│ .html / .htm / .xml / .xhtml│ ★✓ 开启★ │
│ .txt / .md / .j2 / 无扩展名 │ ★✗ 关闭★ │
└────────────────────────────┴──────────┘
★ ★把 HTML 模板命名成 .j2 = 静默关闭了 XSS 防护★
✓ 想全部开启:
app.jinja_env.autoescape = True
✓ 或自定义规则:
def select_autoescape(name): return True
app.jinja_options = {"autoescape": select_autoescape}
★ ★模板内局部控制★:
{% autoescape false %}
{{ trusted_html }}
{% endautoescape %}
★ 比到处写 |safe 更集中、更容易审计
★ ★★三个自动转义管不到的场景(必考)★★:
① ★JavaScript 上下文★
<script>var name = "{{ name }}";</script>
攻击载荷:name = '";alert(document.cookie);//'
→ HTML 转义★不会转义引号以外的 JS 语法★
→ ★即使转义了引号,还有 </script> 提前闭合的问题★
✓ ★正确:{{ name|tojson }}★
输出:var name = "";alert(1);//";
★tojson 会额外转义 < > & ' 防止闭合标签★
② ★URL 属性★
<a href="{{ url }}">点我</a>
攻击载荷:url = "javascript:alert(1)"
→ ★引号被转义了,但协议没被检查★
✓ 自定义过滤器:
@app.template_filter("safe_url")
def safe_url(u):
if not u: return "#"
p = urlparse(u)
return u if p.scheme in ("http", "https", "") else "#"
③ ★HTML 属性没加引号★
<div class={{ cls }}> ← ★没引号★
攻击载荷:cls = "x onmouseover=alert(1)"
→ ★转义不会处理空格★,属性被拆开了
✓ ★属性值一律加引号★
★ ★Markup 与 |safe 的本质★:
from markupsafe import Markup, escape
Markup("<b>粗体</b>") # ★标记为"已安全",不再转义★
escape("<b>") # ★→ Markup('<b>')★
Markup("<b>{}</b>").format(user) # ★★format 会自动转义参数★★
★ |safe 就是把值包成 Markup
★ ★用户富文本的正确处理★:
import nh3 # 或 bleach(★已停止维护,推荐 nh3★)
clean = nh3.clean(user_html,
tags={"p","br","strong","em","a","ul","ol","li"},
attributes={"a": {"href", "title"}})
# 模板:{{ clean|safe }}
★ ★必须白名单,黑名单一定能被绕过★
★ 更稳的方案:★让用户写 Markdown★,服务端渲染时禁用 raw HTML
★ ★SSTI:服务端模板注入(★比 XSS 更严重★)★:
✗ render_template_string("Hello " + request.args.get("name"))
攻击:?name={{ config }} → ★泄露 SECRET_KEY★
?name={{ ''.__class__.__mro__[1].__subclasses__() }} → ★可能 RCE★
✓ ★永远不要把用户输入拼进模板源码★
render_template_string("Hello {{ name }}", name=user_input)
★ 更好:★根本不用 render_template_string 处理用户数据★
自动转义的开启规则是按扩展名——.html/.htm/.xml/.xhtml 开启,.txt/.md/.j2 关闭,所以把 HTML 模板命名成 .j2 等于静默关掉了 XSS 防护,这是个很隐蔽的漏洞来源。三个自动转义管不到的场景是必考内容:① JavaScript 上下文——HTML 转义规则和 JS 不同,";alert(1);// 能闭合字符串,而且还有 </script> 提前闭合的问题,必须用 |tojson(它会额外转义 <、>、&、');② URL 属性——引号被转义了但协议没被检查,javascript:alert(1) 照样能执行,要自定义过滤器校验 scheme;③ 属性没加引号——转义不处理空格,x onmouseover=alert(1) 能拆开属性,所以属性值一律加引号。用户富文本必须用 nh3/bleach 白名单清洗(黑名单一定能被绕过),更稳的方案是让用户写 Markdown 并禁用 raw HTML。最后 SSTI 比 XSS 更严重——把用户输入拼进模板源码可能泄露 SECRET_KEY 甚至 RCE。
三、继承、include 与 macro
★ 三种复用方式的定位:
┌──────────┬────────────────────────────────────────┐
│ ★extends★ │ ★整页布局★:定义骨架,子模板填坑 │
│ ★include★ │ ★片段插入★:共享当前上下文,无参数 │
│ ★macro★ │ ★组件★:像函数一样传参,可跨文件 import │
└──────────┴────────────────────────────────────────┘
★ ★extends 的规则★:
① ★必须是模板的第一个标签★(前面不能有内容)
② ★只能继承一个父模板★(Jinja2 没有多继承)
③ ★子模板里 block 之外的内容会被忽略★
{% extends "base.html" %}
<p>这行不会显示</p> ← ★★被丢弃★★
{% block content %}...{% endblock %}
④ ★super() 取父块内容★
{% block title %}{{ super() }} - 子页{% endblock %}
⑤ ★block 可以嵌套,也可以在 for 里★(但要注意作用域)
★ ★block 的作用域坑★:
{% for item in items %}
{% block row %}{{ item }}{% endblock %} ← ★★item 在 block 里不可见!★★
{% endfor %}
✓ 加 scoped:
{% block row scoped %}{{ item }}{% endblock %}
★ 原因:block 会被编译成★独立的函数★,默认拿不到外层循环变量
★ ★include vs macro(★选择标准★)★:
{% include "_card.html" %} # ★共享当前上下文(post 变量直接可用)★
{{ card(post) }} # ★显式传参,依赖清晰★
★ include 的优点:写起来快
★ include 的缺点:★依赖隐式★(改了变量名,include 的片段悄悄坏掉)
★ macro 的优点:★参数明确、可复用、可测试★
✓ 判断:★片段依赖外层变量 > 2 个 → 用 macro★
{% include "x.html" ignore missing %} # ★文件不存在不报错★
{% include ["a.html", "b.html"] %} # ★用第一个存在的★
{% include "x.html" without context %} # ★不传上下文(更快)★
★ ★macro 的进阶★:
{% macro input(name, value="", type="text") %}
<input name="{{ name }}" value="{{ value|e }}" type="{{ type }}">
{% endmacro %}
★ 特殊变量:
{{ varargs }} # 多余的位置参数
{{ kwargs }} # 多余的关键字参数
{{ caller() }} # ★配合 {% call %} 使用★
★ call 块(★传"内容"给 macro★):
{% macro dialog(title) %}
<div class="dialog"><h3>{{ title }}</h3>{{ caller() }}</div>
{% endmacro %}
{% call dialog("提示") %}
<p>这里是内容</p> ← ★通过 caller() 插入★
{% endcall %}
★ ★跨文件使用必须 import★:
{% from "_macros.html" import input, dialog %}
{% import "_macros.html" as forms %} # 命名空间
{{ forms.input("email") }}
★ ★注意:import 的模板默认拿不到当前上下文★
{% from "_m.html" import x with context %} ← ★需要时加 with context★
★ ★set 与作用域(★经典坑★)★:
{% set total = 0 %}
{% for item in items %}
{% set total = total + item.price %} ← ★★循环外看不到!★★
{% endfor %}
{{ total }} ← ★还是 0★
✓ 方案一:★namespace(2.10+)★
{% set ns = namespace(total=0) %}
{% for item in items %}
{% set ns.total = ns.total + item.price %}
{% endfor %}
{{ ns.total }} ← ✓
✓ 方案二(★更好★):★在 Python 里算好再传进来★
★ 原则:★模板只负责展示,计算逻辑放视图★
三种复用方式定位不同:extends 管整页布局、include 插入片段并共享上下文、macro 是可传参的组件。extends 有几条硬规则:必须是第一个标签、只能继承一个父模板、block 之外的内容会被丢弃。block 的作用域是个经典坑——在 for 循环里定义的 block 默认拿不到循环变量(因为 block 被编译成独立函数),要加 scoped。include 和 macro 的选择标准是「片段依赖外层变量超过 2 个就用 macro」——include 的依赖是隐式的,改了变量名会让片段悄悄坏掉。set 在循环里的赋值不会传到循环外(同样是作用域问题),解法是用 namespace(),但更好的做法是在 Python 里算好再传进来——原则是「模板只负责展示,计算逻辑放视图」。
四、Flask 的模板集成
★ Flask 自动注入的模板变量:
┌──────────────────────┬──────────────────────────────┐
│ ★request★ │ 当前请求对象 │
│ ★session★ │ 会话 │
│ ★g★ │ 应用上下文全局 │
│ ★config★ │ ★app.config(★小心泄露密钥★)★│
│ ★url_for()★ │ 反向生成 URL │
│ ★get_flashed_messages()│ 闪现消息 │
└──────────────────────┴──────────────────────────────┘
★ ⚠️ ★{{ config }} 会输出整个配置包括 SECRET_KEY★
→ ★这就是 SSTI 攻击的第一步★
★ ★context_processor:注入全局变量★
@app.context_processor
def inject_common():
return {
"site_name": current_app.config["SITE_NAME"],
"current_year": datetime.now().year,
"unread_count": get_unread(), # ★★小心:每次渲染都执行!★★
}
★ ★性能陷阱:context_processor 在每次 render_template 时都执行★
✓ 重的计算改成★惰性★:
return {"unread_count": lambda: get_unread()} # 模板里 {{ unread_count() }}
✓ 或用 ★g 缓存★:
def get_unread():
if "unread" not in g: g.unread = query()
return g.unread
★ 蓝图级的 context_processor:
@bp.context_processor # ★只对该蓝图的模板生效★
★ ★自定义过滤器 / 测试 / 全局函数★:
@app.template_filter("money")
def money(v): return f"¥{v:,.2f}"
# {{ price|money }}
@app.template_test("admin")
def is_admin(u): return u.role == "admin"
# {% if user is admin %}
@app.template_global("csrf_token")
def csrf_token(): return generate_csrf()
# {{ csrf_token() }}
★ 蓝图版本:@bp.app_template_filter(★全局生效★)
@bp.app_template_global
★ ★flash 消息★:
from flask import flash
flash("保存成功", "success")
flash("邮箱格式不对", "error")
# 模板:
{% for category, message in get_flashed_messages(with_categories=true) %}
★ ★flash 依赖 session★(所以需要 SECRET_KEY)
★ ★消息读取后即被清除★(下次渲染就没了)
★ ★模板里的 N+1(★最常见的性能问题★)★:
<!-- 视图:posts = Post.query.all() -->
{% for post in posts %}
{{ post.author.name }} ← ★★每次触发一条 SQL!★★
{{ post.comments|length }} ← ★★又一条★★
{% endfor %}
→ 100 篇文章 = ★201 条 SQL★
✓ 视图里预加载:
posts = Post.query.options(joinedload(Post.author),
selectinload(Post.comments)).all()
★ ★排查:开 SQLALCHEMY_RECORD_QUERIES 或用 flask-debugtoolbar★
★ ★测试模板渲染★:
from flask import template_rendered
from contextlib import contextmanager
@contextmanager
def captured_templates(app):
recorded = []
def record(sender, template, context, **extra):
recorded.append((template, context))
template_rendered.connect(record, app)
try: yield recorded
finally: template_rendered.disconnect(record, app)
with captured_templates(app) as templates:
client.get("/posts")
assert templates[0][0].name == "posts/list.html"
assert len(templates[0][1]["posts"]) == 3
★ 比断言 HTML 字符串★健壮得多★
Flask 自动注入了 request/session/g/config/url_for()/get_flashed_messages() 六个模板变量——注意 {{ config }} 会输出包括 SECRET_KEY 在内的全部配置,这正是 SSTI 攻击的第一步。context_processor 有个性能陷阱:它在每次 render_template 时都执行,所以里面放重的查询(比如未读消息数)会让每个页面都多几条 SQL——解法是改成惰性调用(返回 lambda)或用 g 缓存。模板里的 N+1 是最常见的性能问题:循环里访问 post.author.name 每次都触发一条 SQL,100 篇文章就是 201 条——必须在视图里用 joinedload/selectinload 预加载。测试方面推荐用 template_rendered 信号捕获模板名和上下文,比断言 HTML 字符串健壮得多。
五、性能与工程实践
★ 模板性能的真相:
┌──────────────────────────────────────────────────┐
│ ★渲染本身★:编译后是纯 Python 函数,★很快★ │
│ ★真正的瓶颈★: │
│ ① ★模板里的数据库查询(N+1)★ ← ★90% 的问题★ │
│ ② context_processor 里的重计算 │
│ ③ ★循环里调用复杂过滤器★ │
│ ④ 超大列表一次性渲染 │
└──────────────────────────────────────────────────┘
★ ★优化手段(按收益排序)★:
① ★消灭 N+1★:视图里预加载
② ★分页★:别一次渲染 10000 行
③ ★片段缓存★:
from flask_caching import Cache
{% cache 300, "sidebar" %}...{% endcache %} # ★需要 jinja2 缓存扩展★
app.jinja_env.add_extension("flask_caching.jinja2ext.CacheExtension")
④ ★整页缓存★:@cache.cached(timeout=60, query_string=True)
⑤ 关闭 TEMPLATES_AUTO_RELOAD(生产)
⑥ ★去掉不必要的空白★:
app.jinja_env.trim_blocks = True # ★{% %} 后的换行去掉★
app.jinja_env.lstrip_blocks = True # ★{% %} 前的空白去掉★
★ ★trim_blocks / lstrip_blocks 的效果(算例)★:
模板:
{% for i in items %}
<li>{{ i }}</li>
{% endfor %}
★关闭★(默认):每项前后都有换行和缩进 → ★1000 项多出 ~20KB★
★开启★:紧凑输出
★ gzip 后差距会缩小,但 DOM 解析仍有微小收益
★ ★模板组织建议★:
templates/
├── base.html # ★根布局★
├── layouts/
│ ├── one-column.html # 继承 base
│ └── two-column.html
├── _macros/ # ★下划线前缀 = 不直接渲染★
│ ├── forms.html
│ └── cards.html
├── _partials/
│ ├── _nav.html
│ └── _footer.html
└── blog/
├── list.html
└── detail.html
★ 约定:★下划线开头 = 片段/宏,不作为独立页面★
★ ★该在模板里做什么,不该做什么★:
✓ 展示逻辑:if/for、格式化、条件样式
✗ ★数据库查询★(N+1 的根源)
✗ ★业务计算★(金额、权限判断——★难测试、难复用★)
✗ ★复杂条件嵌套 3 层以上★(说明视图该整理数据了)
★ 原则:★模板拿到的应该是"可以直接展示的数据"★
★ ★前后端分离时代模板还有用吗★:
✓ ★服务端渲染(SSR)对 SEO 友好★
✓ ★管理后台、邮件模板、报表★——不值得上前端框架
✓ ★HTMX / Turbo 等"回归服务端渲染"的方案正在流行★
✓ ★首屏性能★:不用等 JS 下载执行
★ 判断:★内容型页面用模板,交互密集型用前端框架★
★ ★邮件模板的特殊要求★:
- ★不转义的 .txt 版本 + 转义的 .html 版本★
- ★内联 CSS★(邮件客户端不支持 <style>)
- ★url_for(_external=True)★(★邮件里必须是绝对 URL★)
- ★注意 SERVER_NAME 的副作用★
模板性能的真相是:渲染本身很快,90% 的问题是模板里触发的数据库查询。优化手段按收益排序:消灭 N+1 > 分页 > 片段缓存 > 整页缓存。有两个低成本的开关值得开:trim_blocks 和 lstrip_blocks 能去掉 {% %} 标签产生的多余空白,1000 项的列表能省约 20KB。模板组织上的约定是下划线开头表示片段/宏,不作为独立页面。最重要的原则是「模板拿到的应该是可以直接展示的数据」——数据库查询、业务计算、三层以上的条件嵌套都不该在模板里。至于「前后端分离时代模板还有用吗」:内容型页面(SEO 敏感)、管理后台、邮件模板、报表仍然是模板的主场,而且 HTMX/Turbo 这类「回归服务端渲染」的方案正在流行。邮件模板要特别注意内联 CSS 和 url_for(_external=True) 生成绝对 URL。
六、实践清单
★ 安全检查清单:
□ ★模板扩展名是 .html(确保自动转义开启)★
□ ★<script> 里的数据用 |tojson★
□ ★href/src 的 URL 校验协议(挡 javascript:)★
□ ★HTML 属性值一律加引号★
□ ★|safe 只用于自己生成的 HTML★
□ ★用户富文本用 nh3/bleach 白名单清洗★
□ ★不用 render_template_string 处理用户输入(SSTI)★
□ ★不在模板里输出 {{ config }}★
□ ★CSP 响应头作为纵深防御★
★ 质量检查清单:
□ ★开发环境用 StrictUndefined★
□ ★视图里预加载关联数据(防 N+1)★
□ ★context_processor 里不放重计算★
□ ★循环里的 set 用 namespace 或在视图算好★
□ ★for 循环里的 block 加 scoped★
□ ★片段依赖 >2 个变量时用 macro 而不是 include★
□ ★生产关闭 TEMPLATES_AUTO_RELOAD★
□ ★用 template_rendered 信号做测试★
★ 报错/现象速查:
┌────────────────────────────────────┬──────────────────────┐
│ 变量显示为空,无报错 │ ★拼错名字/没传参★ │
│ │ → ★StrictUndefined★ │
│ TemplateNotFound │ 路径错/蓝图 folder │
│ block 里拿不到循环变量 │ ★缺 scoped★ │
│ 循环里 set 的值循环外是旧的 │ ★作用域,用 namespace★│
│ 页面显示出 HTML 源码 │ ★该 safe 的没 safe★ │
│ 用户输入的脚本执行了 │ ★不该 safe 的 safe 了★│
│ 列表页很慢 │ ★模板里 N+1★ │
│ macro 里拿不到全局变量 │ ★import 缺 with context★│
└────────────────────────────────────┴──────────────────────┘
★ 一句话总结:
★"Jinja2 把模板编译成 Python 函数并缓存,所以慢的从来不是渲染而是
模板里的 SQL;自动转义只对 .html 系扩展名生效,且管不了 JS 上下文
(用 |tojson)和 URL 协议(要校验 scheme);|safe 是信任声明,
只能给自己生成的 HTML;计算放视图,模板只管展示。"★
安全清单里最容易被忽略的三条:模板扩展名必须是 .html 系(否则自动转义静默关闭)、<script> 里的数据用 |tojson、URL 要校验协议。质量清单里则是 开发环境用 StrictUndefined(能立刻暴露拼错的变量名)和视图里预加载防 N+1。报错速查表里有一对很有意思的对照:「页面显示出 HTML 源码」是该 safe 的没 safe,而**「用户输入的脚本执行了」是不该 safe 的 safe 了**——两个方向的错误现象完全不同。
记忆钩子:「Jinja2 三种语法:★
{{ }}输出、{% %}控制流、{# #}注释★。★它把模板编译成 Python 函数并缓存★,所以★渲染本身很快,90% 的性能问题是模板里触发的数据库查询(N+1)★——循环里post.author.name每次一条 SQL,100 篇文章 201 条,★必须在视图里 joinedload/selectinload 预加载★。★自动转义按扩展名开启★:★.html/.htm/.xml/.xhtml 开、.txt/.md/.j2 关★——★把 HTML 模板命名成 .j2 等于静默关掉 XSS 防护★。★三个自动转义管不到的场景(必考)★:★① JavaScript 上下文★——HTML 转义规则和 JS 不同,\";alert(1);//能闭合字符串,还有</script>提前闭合问题,★必须用 |tojson(它会额外转义 < > & ’)★;★② URL 属性★——引号转义了但★协议没校验★,javascript:alert(1)照样执行,要自定义过滤器查 scheme;★③ 属性没加引号★——转义不处理空格,x onmouseover=alert(1)能拆开属性。★|safe 是显式信任声明,只能用于自己生成的 HTML★;用户富文本★必须用 nh3/bleach 白名单清洗★(★黑名单一定能被绕过★),更稳是让用户写 Markdown 并禁用 raw HTML。★SSTI 比 XSS 更严重★:★永远不要把用户输入拼进 render_template_string★({{ config }}能泄露 SECRET_KEY,再往下可能 RCE)——同理★模板里不要输出 {{ config }}★。三种复用:★extends 管布局(必须第一个标签、只能继承一个、block 外的内容被丢弃、super() 取父块)★、★include 共享上下文★、★macro 显式传参(跨文件 import 时默认拿不到上下文,需要 with context)★;★片段依赖 >2 个变量就该用 macro★。两个作用域坑:★for 循环里的 block 默认拿不到循环变量,要加 scoped★;★循环里 {% set %} 的值循环外看不到,要用 namespace()★——★但更好的做法是在视图里算好★。★变量找不到时返回 Undefined,输出空串且不报错★,这是『模板写了却没显示』的头号原因 → ★开发环境设 app.jinja_env.undefined = StrictUndefined★。★context_processor 每次 render 都执行★,重计算要改惰性或用 g 缓存。测试用 ★template_rendered 信号断言模板名和上下文★,比断言 HTML 健壮。」
七、常见误区与追问
- 误区:Jinja2 开了自动转义,就不会有 XSS 了。 自动转义只解决HTML 文本上下文的注入,有三个它管不到的地方。①
<script>标签内部:var name = "{{ name }}";中,HTML 转义会把<变成<,但在 JS 字符串里<就是普通字符,而攻击者用";alert(1);//这样的载荷可以直接闭合字符串注入代码;更麻烦的是即使转义了引号,字符串里出现</script>仍会被 HTML 解析器提前闭合脚本块。正确做法是{{ data|tojson }}——它输出合法的 JS 字面量,且额外转义<、>、&、'。② URL 属性:href="{{ url }}"里引号确实被转义了,但协议没人检查,javascript:alert(1)一样会执行——需要自定义过滤器校验urlparse(u).scheme in ("http", "https", "")。③ 没加引号的属性:<div class={{ cls }}>中转义不处理空格,x onmouseover=alert(1)就能拆出新属性。所以规矩是:属性一律加引号、JS 里用tojson、URL 校验协议,再配上 CSP 响应头做纵深防御。 - 误区:模板文件叫什么后缀无所谓,反正
render_template都能渲染。 后缀直接决定自动转义是否开启。Flask 的select_jinja_autoescape默认实现是「文件名以.html、.htm、.xml、.xhtml结尾就开启转义,否则关闭」——这个设计本意是让纯文本模板(邮件的 txt 版本、CSV、配置文件模板)不被转义。但它有个危险的副作用:如果你按某些项目的习惯把 HTML 模板命名成page.j2或page.tpl,自动转义就悄悄失效了,而页面看起来完全正常,只有在有人提交含<script>的内容时才会暴露。这类漏洞极难在 code review 中发现(因为模板内容本身没问题)。两个应对:① 统一用.html后缀;② 或者显式配置app.jinja_env.autoescape = True全部开启,纯文本模板改用{% autoescape false %}块局部关闭——显式关闭比隐式关闭安全得多。 - 误区:模板里变量写错了会报错,所以不会有问题。 默认情况下完全不报错。Jinja2 找不到变量时返回一个
Undefined对象,它在输出时渲染成空字符串、在布尔判断里是False、在for里当空序列——一切静默进行。所以「模板里明明写了{{ user.nickname }}却什么都不显示」的常见原因是:变量名拼错了(nicknamevsnick_name)、视图忘了传这个参数、或者对象上根本没这个属性。这类 bug 在开发时可能一直没人注意,上线后才发现某个字段一直是空的。解法是开发和测试环境设app.jinja_env.undefined = StrictUndefined——访问未定义变量时直接抛UndefinedError,问题立刻暴露。生产环境是否开启要权衡:开启后一个变量缺失会导致整页 500(对内容展示型站点可能过于严厉),折中做法是开发/CI 用 Strict、生产用默认,靠测试覆盖率来保证。 - 误区:在模板的
for循环里用{% set %}累加,循环结束后就能拿到总和。 拿不到,total还是初始值。原因是 Jinja2 的作用域规则:循环体是一个独立的作用域,里面对外层变量的赋值不会传播出去(这和 Python 的 for 循环不同,更接近函数作用域)。解决办法有两个:① 用namespace()(Jinja 2.10+)——{% set ns = namespace(total=0) %}然后在循环里{% set ns.total = ns.total + x %},因为修改的是对象属性而不是重新绑定变量名,所以能穿透作用域;② 更好的做法是在视图里算好再传进模板。第二种才是正解——模板里做累加、求和、条件统计这类计算,本质上是把业务逻辑写进了展示层:难以单元测试、无法复用、改动时容易漏。同样的作用域问题还出现在{% block %}上:在for循环里定义的 block 默认拿不到循环变量(block 会被编译成独立函数),必须写成{% block row scoped %}。 - 误区:
include和macro差不多,用哪个都行。 关键差别是依赖是隐式还是显式。{% include "_card.html" %}会把当前的整个上下文传给被包含的模板——写起来最快,但被包含的片段依赖哪些变量完全看不出来:你在list.html里 include 了_card.html(它用了post变量),某天你把循环变量从post改成item,片段会静默变成空白(因为post变成了 Undefined),而且报错信息不会指向真正的原因。macro则是显式传参:{{ card(item) }}一眼就能看出依赖,改名时会立刻发现,还能在不同上下文里复用、参数有默认值、甚至可以用{% call %}传入内容块。判断标准很实用:片段依赖的外层变量超过 2 个,或者这个片段会在多个不同的页面里使用,就该用 macro;纯粹的静态片段(导航栏、页脚)用include完全没问题。另外注意 macro 跨文件import时默认拿不到当前上下文(request、config都不可用),需要写{% from "_m.html" import x with context %}。 - 追问:为什么说模板里的 N+1 是最常见的性能问题?怎么排查? 因为模板的写法天然会诱导 N+1:视图里查了
posts = Post.query.all()(1 条 SQL),模板里写{% for post in posts %}{{ post.author.name }}{% endfor %}看起来完全无害——但 SQLAlchemy 的关系默认是懒加载,每次访问post.author都会单独发一条 SQL,100 篇文章就是 100 条额外查询。更隐蔽的是{{ post.comments|length }}(又 100 条)和{% if post.tags %}(再 100 条)。这类问题在开发环境(10 条测试数据)完全感觉不到,上线后列表页要几秒才出来。排查方法:① 开SQLALCHEMY_RECORD_QUERIES并在响应里打印查询数;② 用flask-debugtoolbar(浏览器里直接看每个页面执行了多少条 SQL 及各自耗时);③ 日志里开 SQL echo 观察是否有大量相似语句。修复方式是在视图里预加载:joinedload(一次 JOIN,适合一对一/多对一)、selectinload(多发一条IN查询,适合一对多,不会因 JOIN 产生笛卡尔积)。 - 追问:
context_processor有什么性能风险? 它注册的函数在每一次render_template调用时都会执行——不是每个请求一次,而是每次渲染一次(一个请求里渲染多个模板就执行多次)。所以如果你在里面写了{"unread_count": get_unread_count()}这种数据库查询,站点上每一个页面都会多出这条 SQL,包括那些根本不显示未读数的页面。三个改进方向:① 惰性化——返回可调用对象而不是值({"unread_count": lambda: get_unread()}),模板里写{{ unread_count() }},只有真正用到的页面才会执行;② 用g做请求级缓存——函数内部先查g,一次请求内只算一次;③ 用蓝图级的@bp.context_processor缩小生效范围,而不是全局注入。还有一个容易忽略的点:context_processor返回的字典会覆盖同名的模板变量吗? 不会——视图传给render_template的显式参数优先级更高,这一点在调试「为什么我传的值没生效」时很重要。 - 追问:前后端分离流行之后,服务端模板还有必要学吗? 有,而且在几个场景里服务端渲染仍是更优解。① SEO 敏感的内容型页面——博客、文档、商品详情页,虽然搜索引擎能执行 JS,但服务端渲染的首屏内容抓取更可靠、索引更快。② 管理后台和内部工具——上一整套前端工程化(构建、路由、状态管理)的成本远超收益,模板加一点 JS 就够了。③ 邮件模板——邮件客户端不执行 JS,只能服务端渲染 HTML(还得内联 CSS)。④ 报表和导出——PDF 生成、打印页面。⑤ 首屏性能——不用等 JS 下载、解析、执行,Time to Content 明显更短。而且行业里有一股「回归服务端渲染」的潮流:HTMX、Turbo/Hotwire 让你用服务端模板返回 HTML 片段就能做出接近 SPA 的交互体验,避免了维护两套状态和一整条前端构建链。判断标准是:内容型、SEO 重要、交互不复杂 → 服务端模板;交互密集、状态复杂、离线能力 → 前端框架;两者也完全可以在同一个项目里共存(页面壳用模板、局部交互用前端组件)。
八、加强记忆
Jinja2 的三种语法:{{ }} 输出、{% %} 控制流、{# #} 注释。它把模板编译成 Python 函数并缓存,所以渲染本身很快,90% 的性能问题是模板里触发的数据库查询(N+1)——循环里访问 post.author.name 每次一条 SQL,100 篇文章就是 201 条,必须在视图里用 joinedload/selectinload 预加载。自动转义按扩展名开启:.html/.htm/.xml/.xhtml 开启,.txt/.md/.j2 关闭——把 HTML 模板命名成 .j2 等于静默关掉了 XSS 防护。三个自动转义管不到的场景是必考内容:① JavaScript 上下文——HTML 转义规则和 JS 不同,";alert(1);// 能闭合字符串,还有 </script> 提前闭合的问题,必须用 |tojson(它会额外转义 <、>、&、');② URL 属性——引号转义了但协议没被校验,javascript:alert(1) 照样执行,要自定义过滤器检查 scheme;③ 属性没加引号——转义不处理空格,x onmouseover=alert(1) 能拆开属性。|safe 是显式的信任声明,只能用于自己生成的 HTML;用户富文本必须用 nh3/bleach 做白名单清洗(黑名单一定能被绕过),更稳妥的是让用户写 Markdown 并禁用 raw HTML。SSTI 比 XSS 更严重:永远不要把用户输入拼进 render_template_string({{ config }} 能泄露 SECRET_KEY,再往下可能 RCE),同理模板里不要输出 {{ config }}。三种复用方式:extends 管布局(必须是第一个标签、只能继承一个、block 之外的内容被丢弃、super() 取父块)、include 共享当前上下文、macro 显式传参(跨文件 import 时默认拿不到上下文,需要 with context);片段依赖超过 2 个变量就该用 macro。两个作用域坑:for 循环里的 block 默认拿不到循环变量,要加 scoped;循环里 {% set %} 的赋值循环外看不到,要用 namespace()——但更好的做法是在视图里算好再传进来。变量找不到时返回 Undefined,输出空串且不报错,这是「模板写了却没显示」的头号原因,所以开发环境应该设 app.jinja_env.undefined = StrictUndefined。context_processor 每次渲染都会执行,重计算要改成惰性调用或用 g 缓存。测试推荐用 template_rendered 信号断言模板名和上下文,比断言 HTML 字符串健壮得多。