FastAPI模板进阶:掌握Jinja2控制结构实现动态页面渲染

发布时间:2026/10/13 11:43:58
FastAPI模板进阶:掌握Jinja2控制结构实现动态页面渲染 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 classbadge badge-warning待支付/span {% elif order.status paid %} span classbadge badge-success已支付/span {% elif order.status cancelled %} span classbadge badge-secondary已取消/span {% else %} span classbadge 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判断真假的规则完全照搬PythonNone、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 }} classbtn {% if current_page 1 %}disabled{% endif %}上一页/a a href/list?page{{ current_page 1 }} classbtn {% if current_page total_pages %}disabled{% endif %}下一页/a /nav场景三Tab式筛选。订单列表页有全部/待支付/已支付/已取消几个Tab当前选中哪一个需要给当前Tab加高亮类。把当前状态从FastAPI传入模板里逐个判断div classtabs a href/orders?statusall class{% if current_status all %}active{% endif %}全部/a a href/orders?statuspending class{% if current_status pending %}active{% endif %}待支付/a a href/orders?statuspaid 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 %} /selectJinja2的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 classarrow/span {% else %} {{ crumb.name }} {% endif %} {% endfor %}在FastAPI渲染中loop.index还经常被用来生成表单控件的id避免多个元素id冲突比如{% for field in form.fields %} input idfield-{{ loop.index }} namefield_{{ 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( loaderFileSystemLoader(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_activefalse) %} {% 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 classbadge badge-success正常/span {% elif status disabled %} span classbadge badge-secondary停用/span {% else %} span classbadge badge-dark未知/span {% endif %} {% endmacro %}在页面里调用{{ badge(item.status) }}另一个高频用途是分页器。把分页条封装成一个宏所有列表页通用{% macro pagination(current, total) %} nav classpagination 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(directorytemplates) 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 langzh head meta charsetUTF-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 /htmlmacros.html定义一个状态徽章和一个优先级文本宏{% macro badge(done) %} {% if done %} span classdone-badge已完成/span {% else %} span classtodo-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 classpriority-{{ todo.priority }}{{ priority_text(todo.priority) }}/td td{{ todo.due | date }}/td /tr {% endfor %} /tbody /table {% else %} div classempty当前没有待办事项去休息一下吧。/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里必须用elifjinja2.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的继承深入用法和自定义过滤器如果你有更想看的主题评论区点题我排期继续往下写。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询