FastAPI 结合 Jinja2 模板开发入门指南
全文约5500字,阅读约18分钟,跟着操作约40分钟完成第一个带页面的 Web 应用。
写在前面
WEB项目地址:演示地址
安卓APP下载地址:演示地址
上一篇文章我们用 FastAPI 写了一套 API 接口,返回的是 JSON 数据。但现实中的 Web 应用,除了给前端工程师调用的接口,很多时候还需要直接返回 HTML 页面------比如管理后台、官网、工具类网站。
FastAPI 本身是一个 API 框架,但它可以轻松集成 Jinja2 模板引擎,实现服务端渲染(SSR)。Jinja2 是 Flask 作者开发的模板系统,它让你在 HTML 里嵌入动态数据和控制逻辑,把 Python 后端和 HTML 前端分离开。本文从零开始,带你一步步搭建一个带页面的 FastAPI + Jinja2 项目。
前置要求:会基础的 Python,懂一点 HTML 标签就行。没看过上一篇的也不影响,本文从头讲。
一、开发环境搭建与依赖安装
1.1 创建虚拟环境
跟之前一样,先建一个虚拟环境:
bash
# 创建
python -m venv jinja_env
# 激活(Mac/Linux)
source jinja_env/bin/activate
# 激活(Windows)
jinja_env\Scripts\activate
1.2 安装依赖
这里推荐一次性装好 fastapi[standard],它会把 FastAPI、Uvicorn 和 Jinja2 一起带上:
bash
pip install "fastapi[standard]"
如果你已经装了 FastAPI,也可以单独补装 Jinja2:
bash
pip install jinja2
注意 :Jinja2 不是 FastAPI 的默认依赖。如果后面遇到
jinja2 must be installed的报错,就是忘了装这个包。
验证一下:
bash
pip list | grep jinja2
能看到版本号就说明装好了。
二、Jinja2 模板引擎核心概念解析
在写代码之前,先搞清楚 Jinja2 是干什么的。
模板引擎的工作模式是"模板 + 数据 = 最终输出"。你把 HTML 页面写成模板,里面留一些"占位符",Jinja2 用 Python 传过来的数据替换这些占位符,生成完整的 HTML 字符串返回给浏览器。
Jinja2 的核心语法有三类:
| 语法 | 用途 | 示例 |
|---|---|---|
{``{ 变量名 }} |
输出变量的值 | {``{ username }} |
{% 语句 %} |
控制逻辑(判断、循环等) | {% if user %} |
{# 注释 #} |
模板里的注释,不输出到页面 | {# 这是一段注释 #} |
后面我们会逐个用到。
三、项目目录结构与模板文件配置
3.1 创建目录结构
在项目根目录下建这样的文件夹:
my_project/
├── app.py # 主应用文件
├── templates/ # 存放所有 HTML 模板
│ └── index.html
└── static/ # 存放 CSS、JS、图片等静态文件
└── style.css
templates 文件夹放 Jinja2 模板,static 文件夹放样式表、脚本、图片这些不需要服务端处理的文件。
3.2 编写第一个模板文件
在 templates/index.html 里写:
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>首页</title>
</head>
<body>
<h1>欢迎来到我的网站</h1>
<p>当前时间:{{ current_time }}</p>
</body>
</html>
这里的 {``{ current_time }} 就是一个占位符,等会儿我们从 Python 传值进去。
3.3 配置 Jinja2Templates
在 app.py 里配置模板引擎:
python
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
app = FastAPI()
# 配置模板目录
templates = Jinja2Templates(directory="templates")
Jinja2Templates(directory="templates") 告诉 FastAPI 去 templates 文件夹里找 HTML 文件。
四、基础路由创建与页面渲染流程
4.1 渲染第一个页面
python
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from datetime import datetime
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return templates.TemplateResponse(
request=request,
name="index.html",
context={"current_time": now}
)
逐行拆解:
@app.get("/", response_class=HTMLResponse)------ 路由装饰器,response_class=HTMLResponse告诉文档这个接口返回的是 HTMLrequest: Request------ 必须传入 Request 对象,模板渲染需要它templates.TemplateResponse()------ 渲染模板并返回响应name="index.html"------ 要渲染的模板文件名context={"current_time": now}------ 传给模板的数据字典,模板里的{``{ current_time }}会被替换成这个值
版本提醒 :在 FastAPI 0.108.0 之前,
name是第一个参数,request是作为 context 的一部分传递的。新版本里request要单独传。
4.2 启动服务
bash
uvicorn app:app --reload
访问 http://127.0.0.1:8000,你应该能看到页面显示当前时间。
整个流程 是这样的:用户访问 / → FastAPI 匹配路由 → 调用 home() 函数 → 获取数据 now → 把数据和模板一起交给 Jinja2 → Jinja2 渲染出完整 HTML → 返回给浏览器。
五、模板变量传递与动态内容展示
5.1 传递多个变量
context 字典可以传任意多个变量:
python
@app.get("/user/{user_id}", response_class=HTMLResponse)
async def user_profile(request: Request, user_id: int):
# 模拟从数据库查数据
user = {
"id": user_id,
"name": "张三" if user_id == 1 else "李四",
"age": 25,
"bio": "喜欢写代码"
}
return templates.TemplateResponse(
request=request,
name="profile.html",
context={"user": user, "title": f"用户{user_id}的个人主页"}
)
5.2 模板里使用变量
templates/profile.html:
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
</head>
<body>
<h1>{{ user.name }} 的个人资料</h1>
<p>用户ID:{{ user.id }}</p>
<p>年龄:{{ user.age }}</p>
<p>简介:{{ user.bio }}</p>
</body>
</html>
访问 http://127.0.0.1:8000/user/1,页面会显示张三的信息。{``{ user.name }} 这种点号语法可以访问字典的 key 或对象的属性。
5.3 变量不存在时怎么办
如果模板里引用了 context 中没有的变量,Jinja2 不会报错,而是显示空字符串。你也可以用 default 过滤器给默认值:
html
<p>{{ user.phone | default('未填写') }}</p>
六、控制语句使用:条件判断与循环遍历
6.1 条件判断 {% if %}
模板里可以做逻辑判断。比如用户列表页面,有数据时展示表格,没数据时显示提示:
html
{% if users %}
<ul>
{% for user in users %}
<li>{{ user.name }} - {{ user.age }}岁</li>
{% endfor %}
</ul>
{% else %}
<p>暂无用户数据</p>
{% endif %}
6.2 循环遍历 {% for %}
后端传一个列表过来:
python
@app.get("/users", response_class=HTMLResponse)
async def list_users(request: Request):
users = [
{"id": 1, "name": "张三", "age": 25},
{"id": 2, "name": "李四", "age": 30},
{"id": 3, "name": "王五", "age": 28},
]
return templates.TemplateResponse(
request=request,
name="users.html",
context={"users": users}
)
模板里循环输出:
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>用户列表</title>
</head>
<body>
<h1>用户列表(共 {{ users | length }} 人)</h1>
<table border="1">
<tr>
<th>ID</th>
<th>姓名</th>
<th>年龄</th>
</tr>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.age }}</td>
</tr>
{% endfor %}
</table>
</body>
</html>
{``{ users | length }} 用到了过滤器(filter),length 是 Jinja2 内置的,用来获取列表长度。类似的过滤器还有 upper、lower、default 等。
6.3 循环中的特殊变量
在 {% for %} 循环里,Jinja2 提供了一些特殊变量:
| 变量 | 说明 |
|---|---|
loop.index |
当前迭代次数(从1开始) |
loop.index0 |
当前迭代次数(从0开始) |
loop.first |
是否是第一个元素 |
loop.last |
是否是最后一个元素 |
html
{% for user in users %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ user.name }}</td>
</tr>
{% endfor %}
七、模板继承机制与代码复用技巧
7.1 为什么需要模板继承
一个网站通常有几十个页面,如果每个页面都写一遍完整的 HTML 结构(head、导航栏、页脚),维护起来非常痛苦。模板继承就是为了解决这个问题------把公共部分抽出来,子页面只写自己独有的内容。
7.2 创建父模板 base.html
在 templates/base.html 里写:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}我的默认网站{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- 公共导航栏 -->
<nav>
<a href="/">首页</a> |
<a href="/users">用户列表</a> |
<a href="/about">关于</a>
</nav>
<hr>
<!-- 核心内容区------子模板来填充 -->
<div class="content">
{% block content %}
{% endblock %}
</div>
<!-- 公共页脚 -->
<hr>
<footer>© 2026 我的网站</footer>
</body>
</html>
关键语法:{% block 块名 %}{% endblock %} 在父模板里预留一个"坑",等着子模板来填。
7.3 子模板继承父模板
templates/users.html:
html
{% extends "base.html" %}
{% block title %}用户列表 - 我的网站{% endblock %}
{% block content %}
<h1>用户列表</h1>
<table border="1">
<tr>
<th>ID</th>
<th>姓名</th>
<th>年龄</th>
</tr>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.age }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% extends "base.html" %}必须在第一行声明继承哪个父模板- 然后用
{% block title %}和{% block content %}重写父模板里对应的块 - 没重写的块(比如父模板里没有别的块了)就保持父模板的默认内容
注意 :Jinja2 默认不支持
{% extends "../base.html" %}这种带..的相对路径写法,请一律从模板根目录开始写路径。
7.4 在父模板里保留子模板的内容
有时候你想在父模板里包一层东西,同时保留子模板的内容,可以用 {``{ super() }}:
html
{% block content %}
<div class="container">
{{ super() }}
</div>
{% endblock %}
八、静态资源管理与样式表引入
8.1 挂载静态文件目录
在 app.py 里配置静态文件服务:
python
from fastapi.staticfiles import StaticFiles
app = FastAPI()
# 挂载静态文件目录
app.mount("/static", StaticFiles(directory="static"), name="static")
# 模板配置...
app.mount("/static", StaticFiles(directory="static"), name="static") 的意思是:访问 /static/xxx 时,去 static 文件夹找对应的文件。
8.2 在模板中引用静态文件
在 base.html 里引用 CSS:
html
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
url_for('static', path='/style.css') 会生成 /static/style.css 这个 URL。
注意 :
url_for的第一个参数'static'必须跟app.mount()里的name="static"保持一致。如果名字写错了,模板里会找不到路径。
8.3 写一点样式
static/style.css:
css
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
nav {
background-color: #333;
padding: 10px;
}
nav a {
color: white;
text-decoration: none;
margin-right: 15px;
}
.content {
background: white;
padding: 20px;
border-radius: 5px;
min-height: 300px;
}
footer {
text-align: center;
color: #666;
padding: 10px;
}
刷新页面,样式就生效了。
九、表单数据处理与用户交互实现
9.1 创建登录页面
templates/login.html:
html
{% extends "base.html" %}
{% block title %}登录{% endblock %}
{% block content %}
<h1>用户登录</h1>
{% if message %}
<p style="color: red;">{{ message }}</p>
{% endif %}
<form method="post">
<div>
<label for="username">用户名:</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">密码:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">登录</button>
</form>
{% endblock %}
表单里的 name="username" 和 name="password" 很重要------后端接收表单数据时就是根据这个 name 来取值的。
9.2 后端处理 GET 和 POST
python
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(
request=request,
name="login.html",
context={"message": None}
)
@app.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...)
):
# 简单验证------实际项目里应该查数据库
if username == "admin" and password == "123456":
# 登录成功,重定向到首页
return RedirectResponse(url="/", status_code=302)
else:
# 登录失败,回到登录页并显示错误信息
return templates.TemplateResponse(
request=request,
name="login.html",
context={"message": "用户名或密码错误"}
)
关键点:
@app.post("/login")处理表单提交username: str = Form(...)从表单里提取username字段的值Form(...)里的...表示该字段必填- 登录成功后用
RedirectResponse跳转,失败则重新渲染登录页并带上错误信息
9.3 表单提交的注意事项
- 表单的 method 必须是 post (跟后端的
@app.post对应) - input 的 name 属性 必须跟后端
Form(...)里的变量名一致 - FastAPI 接收表单数据和接收 JSON 是两套机制,用
Form就不能同时用Body
十、常见渲染报错排查与调试方法
10.1 jinja2 must be installed to use Jinja2Templates
报错 :AssertionError: jinja2 must be installed to use Jinja2Templates
原因:没装 Jinja2。
解决:
bash
pip install jinja2
10.2 TemplateNotFound: index.html
报错 :jinja2.exceptions.TemplateNotFound: index.html
原因:模板文件不存在,或者路径不对。
解决:
- 检查
templates/文件夹里有没有index.html - 检查
Jinja2Templates(directory="templates")的路径是否正确 - 确认启动命令是在项目根目录执行的
10.3 TypeError: unhashable type: 'dict'
报错 :渲染模板时报 TypeError: unhashable type: 'dict'
原因 :Starlette 新版本的 TemplateResponse 签名变了,跟旧版调用方式不兼容。
解决 :确保 TemplateResponse 的调用方式跟本文示例一致------request 作为单独的参数传入,不要塞进 context 里。
10.4 url_for('static', ...) 找不到路径
报错 :模板里 url_for('static', path='/style.css') 生成不了正确的 URL
原因 :app.mount() 里的 name 跟 url_for 的第一个参数不一致。
解决:保持名字统一:
python
app.mount("/static", StaticFiles(directory="static"), name="static") # name 是 "static"
html
<link href="{{ url_for('static', path='/style.css') }}" rel="stylesheet"> <!-- 第一个参数也是 "static" -->
10.5 模板修改后页面没变化
原因 :服务启动时没加 --reload。
解决:
bash
uvicorn app:app --reload
10.6 调试小技巧
在模板里打印变量 :Jinja2 没有 print,但可以用 {% debug %} 输出当前上下文的所有变量(需要开启调试模式)。
查看渲染后的 HTML:在浏览器里右键"查看网页源代码",看到的就是 Jinja2 渲染完成后的最终 HTML。如果哪里不对,对比源代码和模板就能定位问题。
后端打印日志:
python
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@app.post("/login")
async def login_submit(username: str = Form(...), password: str = Form(...)):
logger.info(f"登录尝试: {username}")
# ...
小结
到这里,你已经掌握了 FastAPI + Jinja2 的完整开发流程:
- 环境搭建 ------虚拟环境 + 安装
fastapi[standard] - 模板配置 ------
Jinja2Templates(directory="templates") - 页面渲染 ------
TemplateResponse(request, name, context) - 变量传递 ------
{``{ 变量名 }}输出数据 - 控制语句 ------
{% if %}和{% for %}做逻辑 - 模板继承 ------
{% extends %}+{% block %}复用布局 - 静态资源 ------
app.mount()+url_for() - 表单处理 ------
Form(...)接收表单数据
下一步可以做什么
- 把模板继承用起来,把导航栏、页脚、全局样式抽到
base.html - 接入数据库(SQLite 或 PostgreSQL),让用户数据和文章数据持久化
- 用
@app.middleware("http")加一个简单的登录状态校验 - 试试 HTMX ------ 在 Jinja2 模板里用 AJAX 做局部刷新,不用写 JavaScript
写代码遇到问题,先检查三点:虚拟环境激活了没、依赖装全了没、服务是不是带着 --reload 启动的。大部分坑都出在这三个地方。