FastAPI系列更新到第15篇,后台一直有人催更。前两篇聊了Jinja2模板的变量替换与过滤器,很多同学留言说,只知道把变量塞进HTML里远远不够——页面上有列表要循环生成,有按钮要根据状态显示或隐藏,有公共头部要复用。这部分内容正好就是Jinja2模板语法里的控制结构,也是今天这篇的核心。
先说清楚这篇适合谁。你正在用FastAPI做Web页面而不是只返JSON;你已经在模板里写过{{ username }},想再进一步;你想让同一个列表页在数据为空、有一项、有几十项时都能正常展示,不需要写多个模板。下面所有内容都会结合FastAPI + Jinja2Templates的实际渲染流程来写,代码直接可以拷到自己项目里跑。
控制结构,说白了就是模板里的一排"逻辑机关":{% if %}对状态做分叉,{% for %}对集合做重复,{% set %}给模板内定义临时变量,{% macro %}把重复的HTML片段封装成函数。掌握了这四样,配合{% extends %}和{% block %}的模板继承,一个可维护的FastAPI前端模板体系就立起来了。
1. 先搞清楚:控制结构在Jinja2里到底扮演什么角色
想用好控制结构,得先建立一个认知:模板渲染不是简单的字符串拼接。
Jinja2的工作方式分两步。第一步把模板文本编译成Python字节码,第二步把编译后的模板函数塞入上下文变量去执行。所以模板里的{% if %}和{% for %}最终都会变成真正的Python判断和循环,只是语法长得像HTML注释而已。这一点很重要——它决定了你在模板里能写什么表达式,不能写什么表达式。
举一个场景你就明白了。假设你要做订单列表页,服务端从数据库查出订单50条,页面需要渲染50行;下次查出来是0条,页面不能是空白,你得给用户一句"暂无订单"。没有控制结构,你只能让服务端预先构造好两套HTML,再按数据量挑一套返回,模板存在的意义就被削弱了一大半。有了控制结构,服务端只需要把数据原样扔给模板,模板自己根据列表长度选择渲染循环体还是空状态,整个职责边界就非常清晰。
从FastAPI的角度看,控制结构是你把动态数据交付给浏览器前的最后一层加工。FastAPI的API接口返回JSON很方便,模型校验、序列化都是强项,但一旦你决定用Jinja2渲染HTML页面,模板内部的控制结构就决定了页面的表达上限。
我把Jinja2控制结构里最常用的四类能力整理成一个对照表,先建立整体印象:
| 语法 | 作用 | 闭合标签 | 常见场景 |
|---|---|---|---|
{% if %} / {% elif %} / {% else %} | 条件选择 | {% endif %} | 状态判断、权限区分、空数据占位 |
{% for %} / {% else %} | 集合遍历 | {% endfor %} | 列表渲染、表格、下拉框选项 |
{% set %} | 定义模板变量 | 无 | 简化表达式、缓存计算结果 |
{% macro %} | 自定义可复用片段 | {% endmacro %} | 分页器、标签、重复组件 |
四个能力各司其职:if负责决策,for负责重复,set负责给模板填充"草稿纸",macro负责消除重复。后面几节逐个展开。
2. 条件分支:if / elif / else 的语法要点与实战姿势
2.1 基本语法与Python表达式支持
Jinja2的条件判断极像Python,只是把冒号和缩进换成了标签闭合。最简单的形态:
{% if current_user %} <p>欢迎回来,{{ current_user.name }}</p> {% endif %}多分支用elif,注意不是else if:
{% if order.status == "pending" %} <span class="badge badge-warning">待支付</span> {% elif order.status == "paid" %} <span class="badge badge-success">已支付</span> {% elif order.status == "cancelled" %} <span class="badge badge-secondary">已取消</span> {% else %} <span class="badge badge-dark">未知状态</span> {% endif %}表达式部分支持Python里的andornot、比较运算符、in、is,甚至支持方法调用。比如判断一个字符串是否以某个前缀开头:
{% if item.code.startswith("SKU-") %} 这是标准商品 {% endif %}这里有个细节容易忽略:Jinja2里的is除了表示身份判断,还配合测试器使用,比如{% if loop.first is defined %}。但我们日常用得最多的is就是is true、is none、is string这类,用起来和Python的is是两回事,判断真假不要依赖它,直接用变量本身即可。
2.2 真假值规则:千万别把空对象当假
Jinja2判断真假的规则完全照搬Python:None、False、0、空字符串、空列表、空字典、空集合、空元组都是假,其余是真。这条规则大家都能背,但实际写模板时经常翻车。
最经典的坑是对象本身。假如你从FastAPI传进来一个ORM对象user,这个user可能是None,也可能是一个数据库里查出来的真实对象。{% if user %}判断的是"user是否为空对象",不是"user是否存在且有效"。如果你传进来的是一个空对象但Python里bool(user)被重写成了True,那{% if user %}也会走真分支。ORM模型一般不这么干,但自定义的类要小心。
另一个经典场景是列表。用户搜索无结果时,FastAPI路由可能返回results = [],也可能返回None,取决于你怎么写查询。模板里应该先处理None再处理空列表:
{% if results is none %} <p>请求异常,未拿到数据</p> {% elif results %} <p>共 {{ results|length }} 条结果</p> <ul> {% for result in results %} <li>{{ result.title }}</li> {% endfor %} </ul> {% else %} <p>没有找到匹配内容</p> {% endif %}三种状态分开处理,比一上来就{% for result in results %}健壮得多。顺便提一句,如果列表为空,直接进入for循环虽然不会报错,但页面会渲染成"空壳"——循环体不执行,什么内容都不输出。所以空状态占位放在for循环的外面,也就是if/else里处理。
2.3 条件判断在FastAPI页面中的3个实战场景
场景一:登录态头部导航。页面顶部通常要区分"未登录显示登录按钮"和"已登录显示用户名加退出按钮":
{% if request.session.get("user_id") %} 你好,{{ request.session.get("username") }} <a href="/logout">退出</a> {% else %} <a href="/login">登录</a> {% endif %}场景二:分页按钮的禁用状态。第一页时"上一页"不可点,最后一页时"下一页"不可点,用条件输出disabled属性:
<nav> <a href="/list?page={{ current_page - 1 }}" class="btn {% if current_page <= 1 %}disabled{% endif %}">上一页</a> <a href="/list?page={{ current_page + 1 }}" class="btn {% if current_page >= total_pages %}disabled{% endif %}">下一页</a> </nav>场景三:Tab式筛选。订单列表页有"全部/待支付/已支付/已取消"几个Tab,当前选中哪一个,需要给当前Tab加高亮类。把当前状态从FastAPI传入,模板里逐个判断:
<div class="tabs"> <a href="/orders?status=all" class="{% if current_status == 'all' %}active{% endif %}">全部</a> <a href="/orders?status=pending" class="{% if current_status == 'pending' %}active{% endif %}">待支付</a> <a href="/orders?status=paid" class="{% if current_status == 'paid' %}active{% endif %}">已支付</a> </div>这三类场景覆盖了大部分业务里的"状态差异化展示"。核心思路都一样:把状态字段从后端传到模板,用if/elif/else决定最终输出的HTML结构。
3. 循环输出:for等内置变量的进阶玩法
3.1 基础遍历:列表、字典与对象集合
for循环是控制结构里使用频率最高的,因为没有哪个Web页面能绕开列表渲染。最基础的写法:
{% for user in user_list %} <li>{{ user.username }} - {{ user.email }}</li> {% endfor %}如果user_list是一个字典列表,字段名就按字典的key写;如果是一个ORM对象列表,字段名就是属性名,点号连接即可。Jinja2里点号会自动做属性查找、字典key查找甚至方法调用,所以{{ user.username }}三种情况都能工作,你不用担心类型问题。
遍历字典本身要用.items():
{% for key, value in stats.items() %} <tr> <td>{{ key }}</td> <td>{{ value }}</td> </tr> {% endfor %}遍历数字范围也很常见,比如生成日期下拉框:
<select> {% for year in range(2020, 2026) %} <option value="{{ year }}">{{ year }}</option> {% endfor %} </select>Jinja2的for循环还支持在列表表达式里带过滤条件——注意,是for本身自带的过滤条件:
{% for user in user_list if user.is_active %} <li>{{ user.username }}</li> {% endfor %}这一步在模板里完成过滤,虽然有模板逻辑侵入的嫌疑,但胜在快速。如果过滤条件复杂,我更建议在FastAPI路由里先过滤好,模板只负责展示。
3.2 loop内置变量:序号、奇偶、首尾的完整清单
Jinja2给每个for循环都绑定了一个特殊的loop变量,里面装着当前循环的状态。这是整个模板控制结构里最实用的设计,表格列一下:
| 变量名 | 含义 | 示例用途 |
|---|---|---|
loop.index | 当前迭代次数,从1开始 | 列表序号 |
loop.index0 | 当前迭代次数,从0开始 | 与数组索引对齐 |
loop.first | 是否为第一次迭代 | 首行特殊样式 |
loop.last | 是否为最后一次迭代 | 分隔符最后一个不加 |
loop.length | 序列元素总数 | 计算总数 |
loop.revindex | 到循环结束还有多少项,从1开始 | 倒序序号 |
loop.cycle | 在给定列表之间循环取一个值 | 表格斑马纹 |
斑马纹是loop.cycle的经典场景。循环每一行,在odd和even两个类名之间交替:
{% for item in items %} <tr class="{{ loop.cycle('odd', 'even') }}"> <td>{{ loop.index }}</td> <td>{{ item.name }}</td> </tr> {% endfor %}loop.first和loop.last常用于在循环体内部判断是否需要加分隔符。比如渲染面包屑,最后一个不需要箭头:
{% for crumb in breadcrumbs %} {% if not loop.last %} {{ crumb.name }} <span class="arrow">></span> {% else %} {{ crumb.name }} {% endif %} {% endfor %}在FastAPI渲染中,loop.index还经常被用来生成表单控件的id,避免多个元素id冲突,比如:
{% for field in form.fields %} <input id="field-{{ loop.index }}" name="field_{{ loop.index }}" /> {% endfor %}3.3 循环与条件的组合、循环后的else、break与continue的坑
for循环可以和else搭配,当循环没有被执行时,else块会输出。注意这个行为与Python不同:Jinja2的for/else是在序列为空或不满足过滤条件时输出,不是"循环没有被break中断"时输出。所以在Jinja2里,for/else本质上是"空状态占位"的语法糖。我个人还是更喜欢写for循环外面套if,语义更清楚。
for循环里想break或者continue,是很多刚从Python转过来的同学的第一反应。但Jinja2默认不支持{% break %}和{% continue %},只有显式启用jinja2.ext.loopcontrols扩展后才支持。在FastAPI里配置方法是在创建templates的时候往Jinja2Environment里加扩展:
from jinja2 import Environment, FileSystemLoader env = Environment( loader=FileSystemLoader("templates"), extensions=["jinja2.ext.loopcontrols"], )不过,一旦启用了break/continue,模板的可读性和可测试性会下降。我的建议是能不用就不用——过滤条件(for x in items if ...)已经能解决绝大多数"跳过某些元素"的需求,真碰到必须提前中断的场景,说明"是否要中断"这个判断更适合放在FastAPI路由层去算。
循环里还有一个必须提的细节:loop变量只在for循环内部可用,出了循环体立刻失效。如果你想在循环外记录"最后一个元素的index",是做不到的——因为循环外拿不到loop。解决办法稍后在set作用域那一节一起说。
4. 辅助控制:set、macro、include与模板继承的合理分工
4.1 用set定义模板内部变量
{% set %}是模板里的变量声明,用来把一段表达式的结果缓存下来。最简单的是写死常量:
{% set site_name = "某商城管理系统" %}更常见的是计算一个稍复杂的值,避免在多个地方重复拼逻辑:
{% set discount = price * 0.2 if price > 100 else 0 %} <p>优惠金额:{{ discount }}</p> <p>实付金额:{{ price - discount }}</p>注意set的赋值表达式里也可以嵌套三元表达式、过滤器。一个常用技巧是给一个长变量起短名字:
{% set user = request.session.get("user") %} {% if user %} <p>{{ user.name }},欢迎回来</p> {% endif %}这样写比到处写request.session.get("user").name干净得多。
set作用域是模板新手最容易踩的坑:在for循环内使用set,循环结束后变量在外层是看不到的。Jinja2的set默认是块级作用域,for循环是一个块,循环内的变量不会带出循环。举个例子:
{% set has_active = false %} {% for user in users %} {% if user.is_active %} {% set has_active = true %} {% endif %} {% endfor %} {% if has_active %} 有好消息 {% endif %}这段代码运行后has_active还是false,因为它在外层定义的值并没有被循环内的赋值覆盖。这是很多人反复踩的坑。Jinja2官方也承认这个设计让一些开发者困惑,解决办法通常是把判断逻辑放到Python那边,或者用namespace()对象:
{% set ns = namespace(has_active=false) %} {% for user in users %} {% if user.is_active %} {% set ns.has_active = true %} {% endif %} {% endfor %}namespace是Jinja2提供的一种可变容器,set可以修改它的属性,从而穿透循环作用域。这种方式能做到"模板内解决问题",但我还是更推荐让你在FastAPI路由里预先计算好标志位,再传给模板,模板里只做展示。
4.2 用macro封装可复用的HTML片段
macro是Jinja2模板里的函数,专门用来消除重复的HTML结构。它跟include最大的区别是:macro接受参数,可以根据参数生成不同的输出,而include是被动引入一个相对固定的文件。
一个最简单但非常实用的macro——状态徽章:
{% macro badge(status) %} {% if status == "active" %} <span class="badge badge-success">正常</span> {% elif status == "disabled" %} <span class="badge badge-secondary">停用</span> {% else %} <span class="badge badge-dark">未知</span> {% endif %} {% endmacro %}在页面里调用:
{{ badge(item.status) }}另一个高频用途是分页器。把分页条封装成一个宏,所有列表页通用:
{% macro pagination(current, total) %} <nav class="pagination"> <a href="?page={{ current - 1 }}" class="{% if current <= 1 %}disabled{% endif %}">上一页</a> <span>第 {{ current }} / {{ total }} 页</span> <a href="?page={{ current + 1 }}" class="{% if current >= total %}disabled{% endif %}">下一页</a> </nav> {% endmacro %}宏支持默认参数,你可以给分页器加上每页大小等配置。定义宏的字段访问和普通模板一样,没有类型约束,这既是灵活,也是隐患——传错参数类型时页面上不会报致命错误,只会渲染出空变量。
macro的一个使用建议:如果同一个宏在多个模板里都要用,把它单独放在一个_macros.html文件里,用{% import %}导入。FastAPI项目里我一般会建一个templates/macros.html,然后在需要的页面顶部:
{% import "macros.html" as ui %} ... {{ ui.badge(item.status) }}这样模板里的重复代码可以大幅减少。
4.3 include、extends与block的分工
三个复用机制的定位必须分清:include是"引入公共片段",extends + block是"继承骨架后填内容",macro是"传参生成片段"。
include适合导航栏、页脚、弹窗这类每页基本不变的公共模块:
{% include "partials/header.html" %} {% include "partials/footer.html" %}extends/block适合搭建一个统一的页面骨架。base.html里定义好整体布局和可替换的slot:
<!-- base.html --> <!DOCTYPE html> <html> <head> <title>{% block title %}默认标题{% endblock %}</title> </head> <body> {% include "partials/nav.html" %} <main> {% block content %}{% endblock %} </main> {% block footer %}{% endblock %} </body> </html>子模板通过extends继承骨架,用block填充内容:
{% extends "base.html" %} {% block title %}订单列表{% endblock %} {% block content %} <p>这里是订单列表内容</p> {% endblock %}include的模板默认能访问当前上下文变量,所以导航栏里想显示当前用户名这种动态数据没问题。如果你在include的文件里需要传递特定变量,也可以写成{% include "partials/card.html" with context %},不过这不常用。
在FastAPI项目里,我会把模板目录组织成这样:
templates/ ├── base.html # 全站骨架 ├── index.html # 首页 ├── orders/ │ ├── list.html # 订单列表 │ └── detail.html # 订单详情 ├── partials/ │ ├── header.html │ ├── footer.html │ └── pagination.html └── macros.html # 全局宏分目录管理配合include和extends,页面越多,这种结构的收益越明显。
5. 完整示例:FastAPI + Jinja2 控制结构渲染一个待办事项页面
为了把前面这些语法串起来,我准备了一个可以直接运行的完整例子。页面是待办事项列表,数据用Python里的字典模拟,不接数据库,重点看控制结构怎么协同工作。
5.1 项目结构
todo_project/ ├── main.py └── templates/ ├── base.html ├── todo_list.html └── partitals_note: 注意这里为了演示方便,直接在todo_list里写我简化一点,只用两个模板:base.html和todo_list.html。再单独建一个macros.html放状态徽章宏。
5.2 FastAPI服务端代码
main.py里创建templates实例,注册一个自定义过滤器,写一个路由返回模拟数据。
from fastapi import FastAPI, Request from fastapi.templating import Jinja2Templates from datetime import date app = FastAPI() templates = Jinja2Templates(directory="templates") def format_due_date(value): if isinstance(value, date): return value.strftime("%Y-%m-%d") return value templates.env.filters["date"] = format_due_date @app.get("/todos") async def todo_list(request: Request): todos = [ {"id": 1, "title": "整理周报", "done": True, "due": date(2026, 3, 20), "priority": "high"}, {"id": 2, "title": "修复登录Bug", "done": False, "due": date(2026, 3, 21), "priority": "high"}, {"id": 3, "title": "写测试用例", "done": False, "due": date(2026, 3, 22), "priority": "medium"}, {"id": 4, "title": "跑一趟银行", "done": False, "due": date(2026, 3, 18), "priority": "low"}, ] return templates.TemplateResponse("todo_list.html", {"request": request, "todos": todos})这个路由把todos列表传给模板。注意templates.TemplateResponse的第一参数是模板名,第二参数是上下文,里面必须要有request键。不同FastAPI版本对第二个参数的写法略有差异,但这个写法在老版本和大部分主流版本里都兼容。
5.3 模板实现
base.html定义整体骨架:
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>{% block title %}待办事项{% endblock %}</title> <style> body { font-family: sans-serif; max-width: 800px; margin: 40px auto; } table { width: 100%; border-collapse: collapse; } td, th { border: 1px solid #ccc; padding: 8px; } .done { text-decoration: line-through; color: #aaa; } .priority-high { color: #c00; } .priority-medium { color: #c90; } .priority-low { color: #070; } .empty { text-align: center; color: #888; padding: 30px; } </style> </head> <body> <h1>{% block heading %}待办事项{% endblock %}</h1> {% block content %}{% endblock %} </body> </html>macros.html定义一个状态徽章和一个优先级文本宏:
{% macro badge(done) %} {% if done %} <span class="done-badge">已完成</span> {% else %} <span class="todo-badge">未完成</span> {% endif %} {% endmacro %} {% macro priority_text(level) %} {% if level == "high" %} 高优先级 {% elif level == "medium" %} 中优先级 {% else %} 低优先级 {% endif %} {% endmacro %}todo_list.html继承base,然后使用for、if、set、macro一起渲染:
{% extends "base.html" %} {% block title %}待办清单{% endblock %} {% block heading %}我的待办清单{% endblock %} {% block content %} {% set done_count = todos | selectattr("done", "equalto", true) | list | length %} {% set total_count = todos | length %} <p>总共 {{ total_count }} 项,已完成 {{ done_count }} 项。</p> {% if todos %} <table> <thead> <tr> <th>序号</th> <th>标题</th> <th>状态</th> <th>优先级</th> <th>截止日期</th> </tr> </thead> <tbody> {% for todo in todos %} <tr class="{% if todo.done %}done-row{% endif %}"> <td>{{ loop.index }}</td> <td>{{ todo.title }}</td> <td> {% if todo.done %} 已完成 {% else %} 进行中 {% endif %} </td> <td class="priority-{{ todo.priority }}">{{ priority_text(todo.priority) }}</td> <td>{{ todo.due | date }}</td> </tr> {% endfor %} </tbody> </table> {% else %} <div class="empty">当前没有待办事项,去休息一下吧。</div> {% endif %} {% endblock %}这个模板把本文讲到的控制结构全用上了:set统计数字、if/else空状态判断、for循环渲染表格、loop.index生成序号、macro输出优先级文案。逻辑清晰,状态分明。
5.4 运行效果与扩展思路
在http://127.0.0.1:8000/todos访问后,页面会渲染出一个4行的表格,第一行带删除线,高优先级标红,日期格式化输出。如果你把todos改成空列表,表格消失,显示"当前没有待办事项",页面的结构依然完整。
这个例子再往后扩展的方向很明确:把模拟数据换成SQLAlchemy查询结果,优先级的宏换成真正的状态徽章组件,加一个表单支持新增待办,配合FastAPI的Form表单提交。控制结构本身的语法不用变,变的只是数据来源。
6. 新手最容易翻车的几个坑与调试思路
6.1 常见报错速查表
| 报错信息 | 出现原因 | 解决思路 |
|---|---|---|
TemplateNotFound: xxx.html | 模板路径错误或文件不存在 | 检查Jinja2Templates(directory=...)的目录配置 |
UndefinedError: 'xxx' is undefined | 模板里访问了一个没传入的变量 | 回看路由传出的上下文键名 |
TemplateSyntaxError: expected token 'end of statement block' | 标签忘记闭合或关键字写错 | 优先检查{% endif %}和{% endfor %}是否齐全 |
TemplateSyntaxError: Encountered unknown tag 'elif' | 把Python的else if带进模板 | Jinja2里必须用elif |
jinja2.exceptions.TemplateAssertionError: no filter named 'xxx' | 使用了未注册的过滤器 | 在templates.env.filters里注册 |
6.2 调试三板斧
第一板斧:在模板里临时输出变量。找不到某个变量值是什么类型,直接在页面里写{{ 变量 }}甚至{{ 变量 | pprint }},渲染出来看一眼。pprint过滤器是自带的,输出格式比直接显示友好。
第二板斧:利用FastAPI的异常重试。模板渲染报错时,FastAPI会在开发模式下显示完整的堆栈信息,里面能看到是在哪一行标签挂的,比猜要快得多。
第三板斧:在路由里提前打印上下文。模板太难定位时,直接在所有templates.TemplateResponse调用前加一行print(context),看看到底传了哪些键。注意打印里如果有关键数据记得脱敏。
6.3 三处高频踩坑点
第一处:templates.TemplateResponse里漏传request键。FastAPI的Jinja2Templates要求从上下文里拿到request对象,如果漏了,模板里所有用到request的地方都会是undefined。老版本甚至会在渲染时报请求对象缺失。我的习惯是把request固定放在上下文第一个键,一次写对。
第二处:for循环内set不生效。正文里详细说过了,别指望循环里的赋值能带出循环。如果你非要在模板里解决,用namespace()。但从工程角度,我更建议在Python侧先算好标志位,模板保持纯粹的展示逻辑。很多团队禁止在模板里做复杂的计算,就是为了避免这种理解成本和调试成本。
第三处:少写闭合标签。模板越写越长,{% if %}和{% for %}嵌套多了以后漏写{% endif %}或{% endfor %}是高频事故。我自己的习惯是写开标签的立刻把闭合标签写出来,再往中间填内容,这样基本不会漏。
最后再分享一个我自己实践下来的小原则:控制结构本身不复杂,真正复杂的是业务逻辑。Jinja2模板控制结构的写法再花哨,也不如让FastAPI路由层把数据状态算好、把需要判断的字段准备好,模板里只保留最轻的决策。这样维护起来省心,排查问题时也不会跟模板语法纠缠太久。下一篇可以聊聊Jinja2的继承深入用法和自定义过滤器,如果你有更想看的主题,评论区点题,我排期继续往下写。