FastAPI系列写到这里,前面一直在聊数据校验、依赖注入、异步接口这些后端能力。今天这篇转到页面渲染:FastAPI 配合 Jinja2 模板,重点展开模板语法里的控制结构——if 分支和 for 循环。如果你已经会用 FastAPI 返回 JSON,但还没正经写过 HTML 页面,那这篇正好可以补上拼图:从一个能跑的模板配置开始,把控制结构的语法、判断规则、作用域坑和实战场景一次说透。
Jinja2 的控制结构,说穿了就是模板里的逻辑层。接口返回 JSON 时,判断逻辑放在前端 JavaScript 里;服务端渲染页面时,判断逻辑就得放到 Jinja2 模板里。这一层管得好,页面代码干净、职责清晰;管不好,你就会在 HTML 里写出比业务代码还难维护的嵌套判断。下面直接进入正题。
1. 先把引擎挂上来:FastAPI里Jinja2模板的最小配置与渲染流程
1.1 装包、建目录、注册Jinja2Templates
FastAPI 默认不带模板渲染能力,它返回 JSON 给前端自然没问题,但要做服务端渲染的页面,就得把 Jinja2 接进来。FastAPI 底层基于 Starlette,而 Starlette 已经对 Jinja2 做了封装,提供了Jinja2Templates这个类,所以我们不需要直接操作jinja2.Environment,只要注册一下就行。
安装依赖很简单:
pip install fastapi jinja2如果你之前装的是精简版 FastAPI,没带 Jinja2,就用上面这条命令补上。接着,推荐把项目目录组织成这个样子:
project/ ├── main.py ├── templates/ │ └── index.html └── static/ └── style.csstemplates目录放模板文件,static目录放 CSS、JS、图片这类静态资源。然后在main.py里注册模板引擎:
from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") @app.get("/") async def index(request: Request): return templates.TemplateResponse( request=request, name="index.html", context={"title": "首页", "user": {"name": "访客", "is_admin": False}}, )注意两个细节。第一,路由函数必须声明request: Request,因为TemplateResponse需要当前请求对象,模板里访问request.url.path、生成链接都要靠它。第二,context是真正传给模板的数据字典,后面模板里用的变量全部从这里来。
1.2 新老写法的差异与request参数为什么不能漏
早期版本常见写法是templates.TemplateResponse("index.html", {"request": request, ...}),第一个参数是模板名,第二个参数是上下文字典。这套写法有历史包袱,Starlette 后来把request独立成参数,推荐这样写:
templates.TemplateResponse(request=request, name="index.html", context={...})如果你用的是较新的 FastAPI 版本,还在用老写法,启动时大概率会看到一条 DeprecationWarning。虽然不影响运行,但团队协作时最好统一成新写法,省得日志里天天飘警告。
为什么模板渲染必须拿到request?因为 Jinja2 模板里经常要调用url_for()生成链接、判断request.url.path来高亮当前导航,或者从 session 里读取登录状态。纯接口后端只需要关心数据,页面渲染则必须知道"当前请求是谁、正在访问哪个路径"这些上下文信息。新版TemplateResponse会自动把request注入模板上下文,所以模板里可以直接写{{ request.url.path }},不用自己在 context 里再塞一份。
2. 分支控制if/elif/else:模板里做条件判断的核心语法与判断规则
2.1 基础语法与FastAPI传值的配合
Jinja2 的条件判断和 Python 长得很像,但写起来带模板标签。从 Python 切过来的同学最容易记混的是:模板里的条件判断要写成{% if %}块,并且有{% elif %}和{% else %}。直接看例子:
{% if user.is_admin %} <p>管理员</p> {% elif user.is_guest %} <p>游客</p> {% else %} <p>普通用户</p> {% endif %}这里的user就是从路由 context 里传过来的。FastAPI 路由里怎么传,模板里就怎么用。相比 f-string 拼 HTML,这种块结构可以写出多行、带缩进的模板片段,阅读和维护都舒服得多。
条件表达式支持常见比较运算符:==、!=、<、>、<=、>=,也支持and、or、not、括号、in等组合。要注意的是,模板里逻辑运算符用的是单词,不是符号&&和||。
{% if user.age >= 18 and user.role == "member" %} <p>成年会员</p> {% endif %}判断变量是否为None也有专门写法:
{% if profile.bio is none %} <p>这个人很懒,没写简介</p> {% else %} <p>{{ profile.bio }}</p> {% endif %}is none是类型测试语法,比== None更常规。类似的关键字还有is defined、is undefined、is number、is string,会按需用就行。
2.2 真假值、未定义变量与default过滤器
Jinja2 沿用了 Python 的真假判断:None、False、0、空字符串、空列表、空字典都为假,其他值一般为真。有一个特别容易记错的点:字符串"0"是非空字符串,所以在模板里{% if "0" %}为真;而整数0为假。如果做开关值判断,后端最好显式传布尔值,别传"0"或空字符串这类容易引起歧义的数据。
再说不确定变量。Jinja2 默认情况下,当变量不存在时不会抛异常,而是渲染成空字符串,条件判断时按假处理。这个设计有好处也有坑。好处是写{% if user %}时即使user没传也不会报错,模板可以写得很防御性;坑在于变量名拼错了,页面会静默地空白,排查时要盯半天。
如果希望让这种错误显形,可以启用严格模式:
from jinja2 import StrictUndefined templates = Jinja2Templates(directory="templates", undefined=StrictUndefined)开启之后,模板里访问任何未定义变量都会抛异常。这个方法我一般只在调试期用,生产环境还是保持默认,因为一个边角变量缺失就导致整页 500,不值当。
处理"变量可能不存在"最优雅的方式是用default过滤器:
{{ user.name|default("游客") }}也可以写成{{ user.name or "游客" }}。两者略有区别:default只处理 undefined,or会把空字符串、空列表这些假值一并替换掉。到底用哪个,取决于你想兜底到什么程度。
2.3 条件判断在页面中的典型场景:导航高亮与权限显示
实战里条件判断最常见的场景,一个是导航高亮,一个是权限位控制。导航高亮可以这样写:
<a href="/" class="{{ 'active' if request.url.path == '/' else '' }}">首页</a>Jinja2 支持 inline if 表达式,语法是{{ 真值 if 条件 else 假值 }},适合这种短判断。如果判断逻辑超过一行,还是拆成{% if %}块更清晰。
权限控制的典型写法:
{% if user and user.is_admin %} <button class="btn-danger">删除</button> {% endif %}同一个模板,不同角色进来看到的操作区不同,而且不会把后端权限逻辑写到接口里。控制结构的作用不是把页面写复杂,而是把"什么时候显示什么内容"交代清楚。你的模板里 if 越多,越说明后端给的数据可能没有把显示状态提炼好。
3. for循环的完整用法:列表、字典、分页数据的遍历与控制
3.1 基本遍历与loop内置变量
Jinja2 的 for 循环专门为渲染而生,它遍历一个序列,每轮输出一段 HTML。基本写法:
{% for post in posts %} <div> <span>{{ post.title }}</span> </div> {% endfor %}循环体内可以直接使用loop对象,它记录了当前循环的状态。下面是常用属性:
| 属性 | 含义 | 起始值 |
|---|---|---|
loop.index | 当前迭代次数 | 从1开始 |
loop.index0 | 当前迭代次数 | 从0开始 |
loop.revindex | 到末尾还剩多少次 | 从1开始 |
loop.revindex0 | 到末尾还剩多少次 | 从0开始 |
loop.first | 是否为第一项 | 布尔值 |
loop.last | 是否为最后一项 | 布尔值 |
loop.length | 待遍历序列长度 | 数值 |
loop.previtem | 上一项元素 | 无则返回 undefined |
loop.nextitem | 下一项元素 | 无则返回 undefined |
loop.depth | 当前循环嵌套深度 | 最外层从1开始 |
loop.depth0 | 当前循环嵌套深度 | 最外层从0开始 |
列表渲染时经常用到这些属性。比如给每一项编号,或者给最后一项加样式:
<ul> {% for post in posts %} <li class="{{ 'last-item' if loop.last else '' }}"> <span>{{ loop.index }}. {{ post.title }}</span> </li> {% endfor %} </ul>还有一个宝藏用法是loop.cycle,专门做斑马纹:
<tr class="{{ loop.cycle('odd', 'even') }}"> <td>{{ item.name }}</td> </tr>loop.cycle会按顺序反复返回给定参数,第一行 'odd'、第二行 'even'、第三行 'odd',无需自己维护状态变量。
3.2 遍历字典与"没有数据"时的for-else
遍历列表大家都熟,遍历字典同样常见。Jinja2 里要同时拿 key 和 value,需要调用.items():
{% for key, value in user_info.items() %} <dt>{{ key }}</dt> <dd>{{ value }}</dd> {% endfor %}我遇到不少初学者直接写{% for key, value in user_info %},结果只迭代出 key,value 拿到的是 undefined。这里记一下:模板里访问无参方法是被允许的,.items()会正常执行。但更稳妥的做法是在后端就把要展示的字段整理成一个列表,模板只管渲染,少做"数据整形"的活。
for 循环还有个特别贴合页面的语法:for-else。当序列为空时,渲染{% else %}里的内容:
{% for post in posts %} <div>{{ post.title }}</div> {% else %} <p class="empty">暂无文章</p> {% endfor %}这个写法比{% if posts %}+{% for %}更加紧凑。如果后面还要遍历同一个列表,else 块里放空状态提示是最合适的。
3.3 嵌套循环与缺失的break/continue
Jinja2 设计上没有break和continue关键字。如果直接写{% break %},模板引擎会直接抛 TemplateSyntaxError。官方推荐的做法是在进入模板前就把数据过滤好,或在循环起始处加上过滤条件:
{% for post in posts if post.published %} ... {% endfor %}注意一点:加了过滤条件后,loop.index只对过滤后的序列计数,不再是原列表的绝对序号。如果你依赖原始下标,宁愿在后端先用列表推导式过滤,再传给模板。
嵌套循环时,内层循环里的loop指向内层自己,访问外层循环索引有个稳定方案:在外层循环开头用set存一下。
{% for group in groups %} {% set outer_loop = loop %} {% for item in group.items %} <p>第{{ outer_loop.index }}组 / 第{{ loop.index }}项</p> {% endfor %} {% endfor %}这个写法兼容性最好。不要指望变量名自动带你找到外层,模板引擎的作用域规则决定了它只认最近的loop,自己提前存才是正道。
4. 控制结构的作用域陷阱与可维护性:循环里改变量、set的使用边界、逻辑放置
4.1 循环内修改外部变量失败的经典场景
Jinja2 的 for 循环块会引入新的作用域,这一点和 Python 很不一样。很多人写过这样的代码:
{% set total = 0 %} {% for item in items %} {% set total = total + item.price %} {% endfor %} <p>合计:{{ total }}</p>结果页面永远输出 0。原因就是循环内set total创建的是循环块内的局部变量,循环外的total根本没有被修改。这是 Jinja2 控制结构里最容易踩的坑,而且错误表现是"不报错但结果不对",排查起来极度头疼。
解决办法是用namespace:
{% set ns = namespace(total=0) %} {% for item in items %} {% set ns.total = ns.total + item.price %} {% endfor %} <p>合计:{{ ns.total }}</p>namespace是 Jinja2 提供的可变对象,设计初衷就是为了在循环、include 之间保存和修改状态。它不仅限于数字累加,字符串拼接、标志位切换都可以这样处理。不过要注意,namespace 不是字典,不能循环.items(),只能访问已经定义过的属性。
4.2 什么逻辑该留在后端,什么逻辑放模板
用控制结构越多,越要警惕模板变成"半个业务层"。我在实际项目里见过几百行的模板,循环套判断、判断里再循环,改一个需求要同时动路由和模板,非常痛苦。模板里出现 overly complex 逻辑,通常说明后端给的数据结构设计得不够好。
我的判断标准是:凡是涉及计算、过滤、排序、分页的功能,全部在路由里做完,模板只做"展示"和"条件渲染"。比如分页数据,后端把当前页的数据 slice 好再传给模板,而不是把 5000 条数据全塞进 context,让模板自己切。这不仅让模板好读,也避免一次性把大列表渲染到 HTML 里拖慢响应。
另外,模板里的控制结构不要太深。if 块里套 for、for 里再套 if,一旦达到三层,可读性急剧下降。如果确实需要分层,优先考虑把子模块抽成{% include %}或宏,控制结构负责组织,宏负责复用。
4.3 空白控制与模板报错的排查思路
控制结构写多了,HTML 里会出现大量多余空行。原因是模板标签本身占了一行,渲染后这些换行符会被保留。Jinja2 提供了空白控制符号:{%-会吃掉标签前的空白,-%}会吃掉标签后的空白。
<ul> {%- for item in items -%} <li>{{ item }}</li> {%- endfor -%} </ul>这样能明显减少输出 HTML 的体积。不过别整个模板到处加,建议只在循环、判断这类会产生大量空白的结构上使用,否则代码紧贴在一起反而难读。
模板报错时,FastAPI debug 页面会直接显示模板文件路径和错误行号。常见的错误是:if没有闭合endif、for没有闭合endfor、elif写在了{% if %}块外、在循环里用了break。对照错误行号肉眼扫一遍就能找到。还有一种隐藏错误是变量名拼写错误,默认 Undefined 模式下不报错只输出空字符串,如果你发现页面某个变量总是空白,先检查 context 里的 key 名和模板里的是否一字不差。
5. 完整实战:带条件导航和分页列表的FastAPI页面
5.1 路由层准备数据
前面讲了一堆语法,下面用一个相对完整的例子收尾:一个博客列表页,包含导航高亮、文章列表、草稿标记和分页导航。后端代码我直接用模拟数据:
from fastapi import FastAPI, Request from fastapi.templating import Jinja2Templates app = FastAPI() templates = Jinja2Templates(directory="templates") @app.get("/blog") async def blog_page(request: Request, page: int = 1): all_posts = [ { "id": i, "title": f"文章 {i}", "published": i % 3 != 0, "tag": "后端" if i % 2 == 0 else "前端", } for i in range(1, 51) ] per_page = 10 total_pages = (len(all_posts) + per_page - 1) // per_page current_page = max(1, min(page, total_pages)) posts = all_posts[(current_page - 1) * per_page: current_page * per_page] return templates.TemplateResponse( request=request, name="blog.html", context={ "posts": posts, "current_page": current_page, "total_pages": total_pages, "user": {"name": "访客", "is_admin": False}, }, )这里有两个思路要说清楚。第一,分页切片在后端做,模板拿到的posts永远只有 10 条;第二,current_page做了边界收敛,用户输入page=999时不会越界,输入page=0时也不会出现空列表。
5.2 模板中if和for组合的完整代码
接下来是templates/blog.html,控制结构都集中在里面:
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>我的博客</title> </head> <body> <nav> <a href="/" class="{{ 'active' if request.url.path == '/' else '' }}">首页</a> <a href="/blog" class="{{ 'active' if request.url.path.startswith('/blog') else '' }}">博客</a> {% if user and user.is_admin %} <a href="/admin">后台</a> {% endif %} </nav> <main> {% if not posts %} <p>暂无文章</p> {% else %} <ul> {% for post in posts %} <li> <a href="/blog/{{ post.id }}">{{ loop.index }}. {{ post.title }}</a> {% if not post.published %} <span class="tag">草稿</span> {% endif %} </li> {% endfor %} </ul> {% endif %} {% if total_pages > 1 %} <div class="pagination"> {% if current_page > 1 %} <a href="?page={{ current_page - 1 }}">上一页</a> {% endif %} {% for p in range(1, total_pages + 1) %} <a href="?page={{ p }}" class="{{ 'active' if p == current_page else '' }}">{{ p }}</a> {% endfor %} {% if current_page < total_pages %} <a href="?page={{ current_page + 1 }}">下一页</a> {% endif %} </div> {% endif %} </main> </body> </html>这段模板把 if、for、inline if、loop.index、range 全串起来了。判断total_pages > 1控制分页器是否出现;current_page控制上一页/下一页链接是否可点;循环 + inline if 生成页码并高亮当前页;文章列表则用一个{% if not posts %}做空态展示。
5.3 运行效果与几个容易忽略的边界
启动应用后,访问http://127.0.0.1:8000/blog,会看到总共 5 页的分页器,当前页高亮,每页固定 10 篇文章。第 3 的倍数的文章显示"草稿"标签。此时user.is_admin为 False,后台入口不显示。把路由里的is_admin改成 True 再刷新,导航栏立刻多个后台链接。
几个边界值得自己动手试一下:
- 访问
/blog?page=999,页面会渲染最后一页,而不是报错或空白。 - 把
all_posts改成空列表,文章区出现"暂无文章",分页器自动消失。 - 访问
/blog?page=0,当前页被收敛到第 1 页。 - 删掉
posts这个 context 变量,再开 StrictUndefined,模板会直接报错;不开则 "暂无文章" 逻辑失效,因为not posts在 undefined 时为真。
这些边界行为不是偶然,而是 Jinja2 真假值规则和 FastAPI 参数解析共同作用的结果。理解了原理之后,遇到页面表现不对,你脑子里会先排查"是变量 undefined 了,还是条件写错了",而不是靠猜。
最后说一点个人体会。Jinja2 的控制结构非常强大,但它的强大是对"展示逻辑"而言的,不是让你把业务逻辑搬进模板。我在实际项目里的习惯是:模板中只保留两类控制结构——决定区块显示与不显示的 if,以及把列表变成 DOM 的 for。凡是涉及算数、聚合、排序、权限判断的业务处理,全部在路由层完成。这样做了几十个页面之后,维护成本明显比早期"模板什么都能干"的阶段低很多。如果你刚开始用 FastAPI 做服务端渲染,可以先从今天这个最小模板跑起来,再慢慢往项目里加控制结构,每次加之前问自己一句:这段逻辑放在后端是不是更合适。