
1. 为什么 FastAPI 项目迟早要引入模板引擎刚接触 FastAPI 的人大多是从纯 REST 接口起步的。写几个app.get返回 JSON用 Swagger UI 自动生成文档前后端分离干净利落。但只要项目稍微往“给人看”的方向走一步——比如做一个后台管理页、一个内部工具面板、一个需要服务端渲染的落地页——纯 JSON 接口就不够用了。你总不能让运维同事对着{status: ok}去点按钮。这时候模板引擎就登场了。FastAPI 本身不绑定任何模板方案它把选择权交给你而Jinja2是官方文档里第一个被点名的搭档。原因很实在Jinja2 是 Flask 时代的默认模板引擎语法成熟、生态庞大、文档齐全几乎每个 Python Web 开发者都或多或少见过{{ variable }}和{% for %}这种写法。FastAPI 通过starlette.templating.Jinja2Templates把它接进来几行代码就能跑通服务端渲染。我最初也犹豫过都 2026 年了前端框架这么成熟还有必要在 FastAPI 里塞模板吗实测下来有几类场景模板反而更省事。第一类是内部工具用户就十几个人不值得为它单独起一个前端工程第二类是SEO 敏感页面服务端直出 HTML 对搜索引擎更友好第三类是快速原型产品经理要看效果你半小时内得把页面怼出来。这些场景下Jinja2 的投入产出比高得离谱。这篇文章面向的是已经会写 FastAPI 基础接口、但还没系统用过模板的开发者。我会从目录结构、模板语法、上下文传递、静态资源、继承复用一路讲到踩坑排查尽量把每个“为什么这么设计”讲透。看完你应该能独立搭出一个结构清晰、可维护的模板化 FastAPI 项目。2. 项目结构设计与 Jinja2 接入思路2.1 目录结构怎么摆才不乱FastAPI 官方教程给的例子往往很简陋一个main.py加一个templates文件夹就完事。但真实项目里模板、静态文件、路由、数据模型混在一起很快就会变成一锅粥。我踩过几次坑之后固定下来一套结构基本能撑到中等规模项目project/ ├── app/ │ ├── __init__.py │ ├── main.py # 应用入口挂载路由和静态资源 │ ├── routers/ │ │ ├── __init__.py │ │ ├── pages.py # 返回 HTML 页面的路由 │ │ └── api.py # 纯 JSON 接口路由 │ ├── templates/ │ │ ├── base.html # 基础骨架模板 │ │ ├── index.html │ │ └── partials/ │ │ └── nav.html │ └── static/ │ ├── css/ │ ├── js/ │ └── img/ ├── requirements.txt └── run.py这么分的逻辑是页面路由和接口路由物理隔离。pages.py里全是返回TemplateResponse的api.py里全是返回 dict 的。好处是排查问题时一眼就知道该去哪个文件找而且将来如果要把接口拆成微服务直接搬api.py就行不会牵连模板逻辑。templates目录里我习惯再分一层partials放导航栏、页脚、卡片这类可复用片段。Jinja2 的include和extends都支持相对路径分目录不会增加复杂度反而让模板树更清晰。2.2 Jinja2Templates 的初始化与挂载接入 Jinja2 的核心就一个类Jinja2Templates。它来自fastapi.templating底层其实是 Starlette 的实现。初始化时传入模板目录路径from fastapi import FastAPI, Request from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles app FastAPI() templates Jinja2Templates(directoryapp/templates) app.mount(/static, StaticFiles(directoryapp/static), namestatic)这里有两个细节值得说。第一directory参数用的是相对路径它相对于你启动应用的当前工作目录。如果你用uvicorn app.main:app启动工作目录是项目根目录那app/templates就对但如果你在app目录里直接uvicorn main:app路径就得改成templates。我建议统一用绝对路径避免部署时因为工作目录不同而报TemplateNotFoundfrom pathlib import Path BASE_DIR Path(__file__).resolve().parent templates Jinja2Templates(directorystr(BASE_DIR / templates))第二StaticFiles的挂载必须在路由定义之前或之后都行但namestatic这个参数很重要它决定了模板里怎么引用静态文件。挂载后模板中写{{ url_for(static, pathcss/style.css) }}就能生成正确的 URL。url_for是 Jinja2 环境里注入的全局函数FastAPI 会自动提供不用自己配。2.3 为什么选 Jinja2 而不是别的模板引擎Python 生态里模板引擎不少Mako、Chameleon、Jinja2 各有拥趸。FastAPI 官方示例选 Jinja2我分析下来有三个现实原因。一是语法亲和力。Jinja2 的{{ }}和{% %}几乎成了模板语法的代名词前端开发者即使没写过 Jinja2看一眼也能猜个八九不离十。团队协作时学习成本低意味着沟通成本低。二是功能完备度。模板继承、宏、过滤器、自动转义、沙箱执行这些 Jinja2 全都有。尤其是自动转义默认开启 HTML 转义能挡掉大部分 XSS 攻击。你写{{ user_input }}如果user_input里含scriptJinja2 会自动转成lt;scriptgt;。这个默认行为救过我不止一次。三是生态兼容性。很多第三方库比如某些后台管理框架直接依赖 Jinja2选它意味着将来集成时少一层适配。而且 Jinja2 的模板文件可以被很多编辑器和 IDE 识别语法高亮、格式化都现成。当然Jinja2 也不是没缺点。它的性能不如一些编译型模板引擎但在绝大多数 Web 场景下模板渲染根本不是瓶颈——数据库查询和网络 IO 才是。所以这个缺点在实际项目中几乎可以忽略。3. 模板语法核心细节与实操要点3.1 变量、表达式与过滤器Jinja2 最基础的语法就是变量输出{{ variable }}。FastAPI 把上下文以字典形式传给模板字典的 key 就是模板里的变量名。比如路由里写return templates.TemplateResponse( index.html, {request: request, username: 张三, items: [苹果, 香蕉]} )模板里就能用{{ username }}和{{ items }}。注意request是必须传的Starlette 的TemplateResponse需要它来构建 URL 和处理一些内部逻辑。忘了传会直接报错这是新手最常见的坑之一。变量之外Jinja2 支持完整的表达式算术、比较、逻辑运算都能写。比如{{ price * quantity }}、{{ yes if flag else no }}。但我要提醒一句模板里别写复杂业务逻辑。模板的职责是展示不是计算。如果你发现模板里出现了三层嵌套的条件判断那说明该把逻辑挪到路由或服务层了。过滤器是 Jinja2 的亮点用管道符|调用。常用的有过滤器作用示例default变量为空时给默认值{{ name | default(匿名) }}length求长度{{ items | length }}join拼接列表{{ tags | join(, ) }}upper/lower大小写转换{{ title | upper }}safe关闭转义{{ html_content | safe }}tojson转成 JSON{{ data | tojson }}safe过滤器要特别小心。它告诉 Jinja2“这段内容我担保安全别转义”。如果你对用户输入用了safe等于亲手打开了 XSS 的大门。我个人的原则是除非内容完全由后端生成且不含用户输入否则绝不用safe。3.2 控制结构if、for 与循环变量条件判断和循环是模板的骨架。语法上{% if %}、{% elif %}、{% else %}、{% endif %}成对出现{% for %}配{% endfor %}。这些和 Python 很像但有个关键区别Jinja2 的 for 循环没有 break 和 continue。这是设计上的取舍模板里不该有太复杂的控制流。for 循环内部有个特殊变量loop提供循环状态loop.index当前迭代序号从 1 开始loop.index0从 0 开始loop.first是否第一次迭代loop.last是否最后一次loop.length总长度这个loop变量在渲染表格时特别好用。比如给奇数行加不同背景色{% for item in items %} tr class{{ odd if loop.index % 2 1 else even }} td{{ loop.index }}/td td{{ item.name }}/td /tr {% endfor %}还有一个容易忽略的点for 循环的 else 分支。当循环的序列为空时{% else %}块会执行。这比在循环外再写一个{% if items %}判断要简洁{% for item in items %} li{{ item }}/li {% else %} li暂无数据/li {% endfor %}3.3 模板继承与 include 的取舍模板继承是 Jinja2 最强大的功能没有之一。它的思路是定义一个base.html作为骨架把公共部分头部、导航、页脚写死留出若干{% block %}占位子模板用{% extends base.html %}继承然后只填自己关心的 block。base.html典型写法!DOCTYPE html html langzh-CN head meta charsetUTF-8 title{% block title %}默认标题{% endblock %}/title link relstylesheet href{{ url_for(static, pathcss/style.css) }} {% block extra_head %}{% endblock %} /head body {% include partials/nav.html %} main {% block content %}{% endblock %} /main footer© 2026 我的项目/footer {% block extra_script %}{% endblock %} /body /html子模板{% extends base.html %} {% block title %}首页 - {{ super() }}{% endblock %} {% block content %} h1欢迎{{ username }}/h1 {% endblock %}这里有个细节{{ super() }}会渲染父模板中该 block 的原始内容。上面例子里标题就变成了“首页 - 默认标题”。这个技巧在需要保留父级内容又追加东西时很有用。那什么时候用include什么时候用extends我的判断标准很简单extends 是“我是一个页面基于某个骨架”include 是“我是一块零件被嵌进某个位置”。导航栏、页脚、分页组件用 include具体页面用 extends。两者不冲突一个页面可以既 extends 又 include 多个片段。3.4 宏与模板中的“函数复用”宏macro相当于模板里的函数适合封装重复的 UI 片段。比如一个渲染用户头像的宏{% macro avatar(user, size40) %} img src{{ user.avatar_url }} alt{{ user.name }} width{{ size }} height{{ size }} classavatar {% endmacro %}定义后在同一个模板或其他模板里{% from macros.html import avatar %}导入使用调用方式像函数{{ avatar(current_user, 60) }}。宏和 include 的区别在于include 是“把另一个文件的内容原样搬过来”宏是“带参数的、可复用的代码块”。如果一段 UI 需要根据参数变化用宏如果就是固定内容用 include。我见过有人把所有东西都写成宏结果模板文件变成了函数库可读性反而下降。适度使用就好。4. 完整实操从零搭一个模板化页面4.1 路由层如何正确返回 TemplateResponse先看一个完整的页面路由写法。假设我们要做一个用户列表页from fastapi import APIRouter, Request from fastapi.templating import Jinja2Templates from pathlib import Path router APIRouter() BASE_DIR Path(__file__).resolve().parent.parent templates Jinja2Templates(directorystr(BASE_DIR / templates)) router.get(/users) async def user_list(request: Request): users [ {id: 1, name: 张三, email: zhangsanexample.com}, {id: 2, name: 李四, email: lisiexample.com}, ] return templates.TemplateResponse( requestrequest, nameusers/list.html, context{users: users, page_title: 用户列表} )注意TemplateResponse的参数形式。老版本 Starlette 的签名是TemplateResponse(name, context)其中 context 里必须包含request。新版本0.29推荐用关键字参数requestrequest, name..., context...这样更清晰也不容易漏传。如果你用的是较新的 FastAPI建议统一用新写法。这里有个性能相关的点Jinja2Templates实例应该全局创建一次而不是每个请求都新建。它内部会缓存已编译的模板重复创建会丢掉缓存白白浪费 CPU。我习惯在模块级别创建一个templates对象各个路由模块从公共模块导入。4.2 模板文件的具体编写users/list.html继承基础模板填充内容块{% extends base.html %} {% block title %}{{ page_title }} - 我的项目{% endblock %} {% block content %} div classpage-header h1{{ page_title }}/h1 a href/users/new classbtn btn-primary新建用户/a /div table classtable thead tr th序号/th th姓名/th th邮箱/th th操作/th /tr /thead tbody {% for user in users %} tr td{{ loop.index }}/td td{{ user.name }}/td td{{ user.email }}/td td a href/users/{{ user.id }}查看/a /td /tr {% else %} tr td colspan4 classtext-center暂无用户数据/td /tr {% endfor %} /tbody /table {% endblock %}这段模板里用到了继承、block、for-else、loop.index、变量输出基本覆盖了日常开发 80% 的语法。写的时候有个小技巧表格的 colspan 要和表头列数一致否则空数据提示会错位。这种细节在纯 JSON 接口里根本不存在但模板开发里天天遇到。4.3 静态资源的正确引用方式静态文件CSS、JS、图片的引用是模板开发里最容易出问题的地方。核心原则永远不要硬编码路径用url_for生成。link relstylesheet href{{ url_for(static, pathcss/style.css) }} script src{{ url_for(static, pathjs/main.js) }}/script img src{{ url_for(static, pathimg/logo.png) }} altLogourl_for(static, path...)里的static就是app.mount时name参数的值。如果你挂载时写的是nameassets那这里就得改成url_for(assets, path...)。这个对应关系搞错了页面会 404但浏览器控制台不一定报明显错误排查起来挺烦。还有一个部署时的坑如果你把应用挂在反向代理的子路径下比如/myapp/硬编码的/static/css/style.css会失效而url_for生成的路径会自动带上前缀。所以用url_for不只是习惯问题是正确性问题。4.4 上下文数据的组织与传递随着页面变复杂传给模板的上下文会越来越多。我的经验是在路由里把上下文组装成一个字典而不是散落一堆变量。这样模板里用起来清晰也方便复用。context { request: request, page_title: 用户列表, users: users, current_user: current_user, nav_active: users, } return templates.TemplateResponse(users/list.html, context)如果多个页面共享一些上下文比如当前用户、导航状态可以写一个辅助函数统一注入def base_context(request: Request, **kwargs): ctx { request: request, current_user: get_current_user(request), app_name: 我的项目, } ctx.update(kwargs) return ctx这样每个路由只需要传自己特有的数据公共部分自动带上。这个模式在项目变大后能省很多重复代码。5. 常见问题与排查技巧实录5.1 TemplateNotFound 的三种成因jinja2.exceptions.TemplateNotFound是最高频的报错。我总结下来无非三种原因。第一种是路径不对。前面说过directory参数是相对工作目录的。解决办法是用Path(__file__).resolve().parent拼绝对路径。第二种是模板文件名拼写错误包括大小写。Linux 服务器区分大小写本地 Windows 开发时List.html和list.html都能找到部署后就 404。第三种是子目录路径没写全。模板在templates/users/list.html路由里就得写users/list.html不能只写list.html。排查时可以在启动日志里打印模板目录的绝对路径确认它指向的位置和你以为的一致print(f模板目录: {templates.env.loader.searchpath})5.2 变量未定义与 UndefinedErrorJinja2 默认对未定义变量是“宽容”的——{{ undefined_var }}渲染成空字符串不报错。但如果你对未定义变量做操作比如{{ undefined_var.name }}或{{ undefined_var | length }}就会抛UndefinedError。这个默认行为有利有弊。好处是模板不会因为某个可选字段缺失就崩掉坏处是错误被隐藏了你可能过了很久才发现某个变量名拼错了。我的做法是在开发环境开启严格模式from jinja2 import StrictUndefined templates Jinja2Templates(directory...) templates.env.undefined StrictUndefined这样任何未定义变量都会立刻报错逼你在开发阶段就把问题解决掉。生产环境再换回默认的Undefined保证页面健壮性。5.3 静态文件 404 的排查顺序静态文件加载不出来按这个顺序查基本能定位确认app.mount(/static, StaticFiles(directory...), namestatic)里的目录路径存在且正确。确认url_for里的 name 和 mount 的 name 一致。打开浏览器开发者工具看 Network 面板里请求的实际 URL 是什么和文件系统里的路径对一下。确认文件权限Linux 下静态目录需要有读权限。我遇到过一次特别隐蔽的StaticFiles挂载在/static但模板里写的是url_for(static, path/css/style.css)path 前面多了个斜杠。生成的 URL 变成/static//css/style.css双斜杠导致 404。去掉 path 开头的斜杠就好了。5.4 模板缓存与热重载开发时改了模板刷新页面却没变化多半是缓存问题。Jinja2 默认会缓存已编译的模板。开发环境建议关掉自动重载的缓存templates Jinja2Templates(directory...) templates.env.auto_reload Trueauto_reloadTrue会让 Jinja2 每次检查模板文件的修改时间有变化就重新编译。生产环境则应该保持默认关闭 auto_reload靠缓存提升性能。另外如果你用uvicorn --reload启动它监控的是 Python 文件变化不会监控模板文件。所以改了 HTML 后即使 auto_reload 开着有时也需要手动刷新。这个不算 bug是预期行为。5.5 常见问题速查表问题现象可能原因解决方向TemplateNotFound路径错误/文件名拼写/子目录未写全用绝对路径核对文件名大小写UndefinedError对未定义变量做操作开发环境开 StrictUndefined检查变量名静态文件 404mount name 不匹配/path 多斜杠核对 url_for 与 mount 的 name模板改动不生效缓存未刷新开 auto_reload或重启服务中文乱码响应头编码问题确保模板文件 UTF-8HTML 声明 charsetXSS 风险滥用 safe 过滤器移除不必要的 safe依赖自动转义6. 几个容易被忽略的进阶细节6.1 自定义过滤器与全局函数Jinja2 允许你注册自定义过滤器和全局函数这在格式化日期、金额时特别有用。比如注册一个格式化日期的过滤器def format_date(value, fmt%Y-%m-%d): if not value: return return value.strftime(fmt) templates.env.filters[format_date] format_date模板里就能用{{ user.created_at | format_date }}或{{ user.created_at | format_date(%Y年%m月%d日) }}。全局函数则用templates.env.globals[now] datetime.now模板里直接{{ now() }}调用。这个机制的价值在于把展示层的格式化逻辑从路由里解放出来。路由只管传原始数据怎么显示交给模板和过滤器决定。职责分离得更干净。6.2 模板中的 URL 生成除了静态文件页面之间的跳转链接也建议用url_for。FastAPI 的路由如果有name参数就能在模板里引用router.get(/users/{user_id}, nameuser_detail) async def user_detail(user_id: int, request: Request): ...模板里a href{{ url_for(user_detail, user_iduser.id) }}查看详情/a这样即使将来路由路径从/users/{user_id}改成/member/{user_id}模板不用动url_for会自动生成新路径。硬编码/users/{{ user.id }}就没这个好处。6.3 模板继承的层级设计项目大了之后模板继承可能不止两层。常见的是三层base.html全局骨架→layout.html某个模块的布局比如后台布局→ 具体页面。这种分层能让同类页面共享更多结构。但层级也不是越多越好。超过三层后追踪一个 block 到底被谁覆盖会变得困难。我的经验是最多三层且每层的职责要明确。base 管全局layout 管模块页面管自己。如果发现需要四层多半是模块划分有问题该重构了。6.4 与纯 REST 接口的共存策略一个项目里同时有模板页面和 JSON 接口是很常见的。我的做法是路由前缀区分页面路由挂/或/pages接口路由挂/api。这样前端调用接口时路径清晰也方便将来做 Nginx 层面的分流。另外模板页面里如果需要异步加载数据可以在页面里嵌一小段 JS 去调/api接口而不是把所有数据都塞进模板上下文。这样首屏用服务端渲染保证速度后续交互用接口保证灵活。两种方式结合比纯模板或纯前端都更实用。7. 我在实际项目中的几点体会用 Jinja2 做 FastAPI 模板这段时间最大的感受是模板引擎的价值不在于技术多先进而在于它让“快速出活”变得可能。一个内部管理页从路由到模板到样式熟练之后半小时能搞定这在纯前端方案里是不可想象的。但也要清醒地认识到它的边界。当页面交互变得复杂——大量表单联动、实时更新、复杂状态管理——模板就会力不从心这时候该上前端框架就上别硬扛。我的判断标准是如果一个页面的 JS 代码超过了 HTML 代码就该考虑换方案了。最后分享一个我踩过的坑模板里的注释用{# ... #}不要用 HTML 注释!-- --。因为 HTML 注释会被发送到浏览器用户查看源码就能看到如果注释里写了敏感信息比如“这里暂时硬编码了管理员密码”那就尴尬了。{# #}是 Jinja2 层面的注释渲染时直接丢弃不会出现在最终 HTML 里。这个细节虽小但涉及安全值得记牢。