几个月前,我发现自己对React热潮感到厌倦了,想要尝试一些不同的东西,一些不像React那么流行的东西。这时我发现了超媒体API回归的新热潮以及Htmx这种简洁的魅力。我决定试一下——很快我意识到自己错过了什么,在服务器端渲染和UI性能方面,与每天涌现的各种JavaScript前端框架相比,我感到自己失去了什么。
在这篇文章里,我将向您展示如何在我的单体架构的服务中使用FastAPI作为HATEOAS系统(以超媒体作为应用状态引擎的系统),使用Jinja模板(通过FastAPI的内置模板引擎来提供)并使用Htmx。
听我的,超级快!!!
你可以在这里查看这个专用的仓库页面:
github.com/ricardomrcruz/FastAPI_Jinja2_HTMX-Login_Form_Dashboard
fastapi jinja2 htmx, 服务器 架构图
在这篇文章里,我们不会深入探讨CRUD操作和SQLModels的原理;而是我们将主要关注于客户端界面。
无论你是否将应用的入口点集中在 main.py
或 server.py
文件中,你都将以导入 Jinja2Templates
并配置好模板文件夹为起点,该文件夹将存放大部分前端页面。
从 fastapi 导入 FastAPI, Request 作为 Request
从 contextlib 导入 asynccontextmanager
从 fastapi.staticfiles 导入 StaticFiles
从 fastapi.templating 导入 Jinja2Templates
从 fastapi.responses 导入 HTMLResponse, RedirectResponse, Response
从 app.api.routes 导入 router 作为 api_router
从 app.web.routes.htmx_components 导入 router 作为 htmx_router
从 app.web.routes.auth 导入 router 作为 auth_router
从 app.web.routes.scraper 导入 router 作为 scraper_router
...
定义 获取应用():
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
doc_url="/docs",
lifespan=lifespan,
)
...
返回应用
app = 获取应用()
应用挂载("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
建议将你要查询的不同类型的 HTML 块分开。例如,你可以像我一样为布局、组件和片段块创建子文件夹,或者根据你自己的喜好来自定义。
我从将我的Jinja2模板解耦开始,采用更可重用的方式,遵循DRY(不要重复自己)原则。只需在每个页面的body
标签内更改块内容。可以在head
中添加Htmx和Tailwind的CDN,以后也可以直接拉取并安装这两个依赖项到项目中。
<!DOCTYPE html>
<html lang="en">
<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/css/styles.css" />
...
<!-- htmx -->
<script
src="https://unpkg.com/htmx.org@2.0.0"
integrity="sha384-wS5l5IKJBvK6sPTKa2WZ1js3d947pvWXbPJ1OmWfEuxLgeHcEbjUUA5i9V5ZkpCw"
crossorigin="anonymous"
></script>
<!-- 尾部 -->
<script src="https://cdn.tailwindcss.com"></script>
{% block extra_scripts %}{% endblock %}
</head>
<body class="bg-black">
{% block content %} {% endblock %}
</body>
</html>
所以,现在我将向您展示如何使用同一个端点来做个动态的注册/登录页面——是的,不需要写任何JavaScript代码——同时保持界面的现代酷炫风格。
在模板文件夹的根目录下创建一个名为 signin.html
的文件。首先引入我们的 layout/base.html
模板,然后修改其内容以包含一个现代化的 Tailwind 登录表单,表单中包含两个输入框。
{% extends "./layout/base.html" %}
{% block title %}登录页面{% endblock %}
{% block content %}
<!-- 背景 -->
<div
class="absolute z-2 top-0 h-screen w-screen bg-black
bg-[radial-gradient(#ffffff33_1px,#000000_1px)] bg-[size:20px_20px]"
></div>
<div
class="relative mt-44 p-8 max-w-md mx-auto bg-black
bg-opacity-70 border border-sky-950 rounded-md"
hx-target="this"
hx-swap="outerHTML"
hx-ext="response-targets"
>
<div class="max-w-md mx-auto">
<a href="/">
<img
src="../static/img/logo.png"
class="w-16 h-16 mx-auto mb-1"
alt="logo"
/>
<div class="text-neutral-300 m-auto text-center text-xl">
<h1 class="">APPY®</h1>
<h2 class="text-sm">登录我们的页面获取更多信息。</h2>
</div></a
>
</div>
<!-- 登录表单 -->
<form
hx-post="/login/"
hx-target-error="#error-container"
hx-swap="innerHTML"
method="post"
class="mt-5 max-w-sm mx-auto"
>
<h1 class="text-white text-2xl">登录</h1>
<div class="mt-4 mb-2">
<label
for="email"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>您的电子邮件</label
>
<input
type="email"
id="email"
name="email"
class="bg-gray-800 text-white text-sm rounded-md block
w-full p-2.5 placeholder-gray-600 focus:outline-none
focus:ring-2 focus:ring-gray-900"
placeholder="name@flowbite.com"
required
/>
<span
class="helper-text"
data-error="请输入有效的电子邮件。"
data-sucess=""
>
</span>
</div>
<div class="mb-4">
<label
for="password"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>您的密码</label
>
<input
type="password"
id="password"
name="password"
class="bg-gray-800 text-white text-sm
rounded-md block w-full p-2.5 placeholder-gray-600
focus:outline-none focus:ring-2 focus:ring-gray-900"
required
/>
</div>
<div id="error-container" class="text-red-800"></div>
<button
type="submit"
name="action"
class="mt-4 text-white bg-blue-700 hover:bg-blue-800
focus:ring-2 focus:outline-none focus:ring-blue-300
font-medium rounded-md text-sm w-full px-5 py-2.5
text-center dark:bg-blue-600 dark:hover:bg-blue-700
dark:focus:ring-blue-800"
>
提交
</button>
</form>
<div class="flex items-start mb-5 m-auto max-w-sm mx-auto mt-9">
<span for="remember" class="font-normal text-gray-100"
>第一次使用Appy® 吗? </span
><button
class="ml-3 hover:underline text-blue-500 font-extrabold"
hx-get="/register_form"
>
创建账户
</button>
</div>
</div>
{% endblock %}
如你所见,我们已经在登录表单上加了一些炫酷的 htmx 属性。现在,让我们关注表单底部的 hx-get="/register_form"
属性。这个 HTTP GET 请求会让服务器用响应的 HTML 内容替换 hx-target
元素。挺酷的吧?
HATEOAS 在这里通过 htmx 实现的最大优点在于,我们始终处理的是 HTML 响应,而不是典型的 JSON API 响应。这一概念与 Roy Fielding 在他的博士论文中提出的 REST 定义紧密相连,在该定义中,他强调任何 REST 响应都应该是自包含的,并且客户端的行为应该由超媒体控制来指导。我们避免客户端对返回数据进行解释、路由或状态管理以构建 DOM,这与传统的单页面应用(SPA)模型和基于这种解释构建的 JSON API 形成了鲜明对比。这实际上是一个真正的服务器端渲染系统。这就是为什么在传统的现代 web 应用架构中,会出现“REST:一直以来都做错啦”的梗。
这样一来,由于我们在包含表单的这个 div 上添加了 hx-target="this"
和 hx-swap="outerHTML"
,整个登录表单容器就会被包含注册表单的 HTML 内容所替换。
<div
class="relative mt-28 p-8 max-w-md mx-auto bg-black bg-opacity-70 border border-sky-950 rounded-md"
hx-target="this"
hx-swap="outerHTML"
hx-ext="response-targets"
>
<div class="max-w-md mx-auto">
<img
src="../static/img/logo.png"
class="w-16 h-16 mx-auto mb-1"
alt="logo"
/>
<div class="text-neutral-300 m-auto text-center text-xl">
<h1 class="">APPY</h1>
<h2 class="text-sm">注册您的帐户以使用 APPY。</h2>
</div>
</div>
<!-- 注册表单 -->
<form
hx-post="/register/"
hx-target-error="#error-container"
hx-swap="innerHTML"
class="mt-5 max-w-sm mx-auto"
>
<h1 class="text-white text-2xl">注册</h1>
<div class="mt-4">
<label
for="username"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>用户名</label
>
<input
type="text"
id="username"
name="username"
class="bg-gray-800 text-white text-sm rounded-md block
w-full p-2.5 placeholder-gray-600 focus:outline-none
focus:ring-2 focus:ring-gray-900"
required
/>
<span
class="helper-text"
data-error="请输入有效的用户名。"
data-sucess=""
>
</span>
</div>
<div class="mb-2 mt-1">
<label
for="email"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>邮箱</label
>
<input
type="email"
id="email"
name="email"
class="bg-gray-800 text-white text-sm rounded-md
block w-full p-2.5 placeholder-gray-600 focus:outline-none
focus:ring-2 focus:ring-gray-900"
placeholder="example@mark3ts.com"
required
/>
<span
class="helper-text"
data-error="请输入有效的邮箱地址。"
data-sucess=""
>
</span>
</div>
<div class="mb-1">
<label
for="password"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>密码</label
>
<input
type="password"
id="password"
name="password"
class="bg-gray-800 text-white text-sm rounded-md
block w-full p-2.5 placeholder-gray-600 focus:outline-none
focus:ring-2 focus:ring-gray-900"
required
/>
</div>
<div class="mb-4">
<label
for="verify_password"
class="block mb-2 text-xs font-medium text-gray-900 dark:text-white"
>确认密码</label
>
<input
type="password"
id="verify_password"
name="verify_password"
class="bg-gray-800 text-white text-sm rounded-md
block w-full p-2.5 placeholder-gray-600 focus:outline-none
focus:ring-2 focus:ring-gray-900"
required
/>
</div>
<div id="error-container" class="text-red-800"></div>
<button
type="submit"
class="mt-4 text-white bg-blue-700 hover:bg-blue-800
focus:ring-4 focus:outline-none focus:ring-blue-300
font-medium rounded-md text-sm w-full px-5 py-2.5 text-center
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
提交
</button>
</form>
<div class="flex items-start mb-5 m-auto max-w-sm mx-auto mt-9">
<span for="remember" class="font-normal text-gray-100"
>已有账号? </span
><button
hx-get=""
class="ml-3 hover:underline text-blue-500 font-extrabold"
>
登录账户
</button>
</div>
</div>
太好了,我们现在已经用 htmx 在 html 中设置了模板和模块的位置——现在我们回到服务器那边创建路由吧。
因为这种方法主要关注的是Web应用,并且希望使其与API保持独立,以便将来扩展,最好把所有的端点放在一个单独的文件夹中。因此,在应用根目录下创建一个名为 web
的文件夹,并在 web
文件夹中创建一个名为 routes
的子文件夹。
如您所见,我创建了不同的路由器以便更好地用htmx组织UI逻辑的结构。pages.py
文件包含了所有用于全页面的渲染的路由,例如首页(或比如登录页面),在我们这个例子中。
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse
router = APIRouter(tags=["pages"])
templates = Jinja2Templates(directory="templates")
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse({"request": request}, name="index.html")
@router.get("/signin", response_class=HTMLResponse)
async def dashboard(request: Request):
return templates.TemplateResponse({"request": request}, name="login.html")
你现在可以通过浏览器访问这两个页面,并看到DOM上的这些HTML文件的显示结果。
因为我们处理的是认证流程,我决定创建一个专门处理登录、注册和注销的模块,命名为 auth.py
,位于 web/routes
文件夹中。这种方法有助于将认证逻辑与SQL模型、认证处理程序和中间件区分开。
通过这样的方式组织认证逻辑,我可以轻松地将 JWT安全脚本导入到各端点,而不会出现重大问题。JWT安全脚本位于 core
文件夹中。
现在,我们可以安全地为表单设置后端系统,每个表单都将有自己的独立端点,我们将通过表单标签中的 htmx 来指向这些端点。
from fastapi import APIRouter, Request, Form, HTTPException, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert
from datetime import datetime, timezone
from app.core.security import AuthHandler
from app.db.session import engine
from app.models.user import User as Userdb
from app.db.session import get_session
from app.crud.user import create_user
from app.models.user import UserCreate
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["身份验证"])
templates = Jinja2Templates(directory="templates")
auth_handler = AuthHandler()
# 路由
@router.post("/register/", response_class=HTMLResponse)
async def 注册用户(
request: Request,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
verify_password: str = Form(...),
session: AsyncSession = Depends(get_session)
):
if verify_password != password:
logger.info(f"密码输入不一致。")
return HTMLResponse(
content="<p class='text-red-600 '>密码不一致。
请重新输入。</p>",
status_code=422,
)
hashed_password = auth_handler.get_hash_password(password)
current_time= datetime.now(timezone.utc)
user = UserCreate(
email=email,
username=username,
hashed_password=hashed_password,
is_active=True,
updated_at=current_time,
)
try:
await create_user(session, user)
response = templates.TemplateResponse({"request": request}, name="login.html")
response.headers["HX-位置"] = "/signin"
return response
except HTTPException as e:
return HTMLResponse(
content="<p class='text-red-600 '>注册失败,请检查您的邮箱地址和用户名。</p>",
status_code=409,
)
@router.post("/login/")
async def 用户登录(
request: Request,
response: Response,
email: str = Form(...),
password: str = Form(...),
session: AsyncSession = Depends(get_session)
):
try:
logger.info(f"正在查找邮箱 {email} 的用户。")
# 查找用户邮箱
query = select(Userdb).where(Userdb.email == email)
result = await session.execute(query)
user = result.scalar_one_or_none()
if not user:
logger.info(f"此邮箱尚未注册。")
return HTMLResponse(
content="<p class='text-red-600 '>此邮箱尚未注册。</p>",
status_code=404,
)
logger.info(f"已找到用户:{user.email}")
authenticated_user = await auth_handler.authenticate_user(email, password)
if authenticated_user:
# 如果用户名和密码验证通过,创建 cookie
atoken = auth_handler.create_access_token(user.email)
logger.info(
f"用户:{user.email} 登录成功。
正在重定向到仪表板。"
)
response = templates.TemplateResponse(
{"request": request}, name="dashboard.html"
)
response.headers["HX-重定向"] = "/dashboard"
response.set_cookie(
key="Authorization", value=f"{atoken}", httponly=True
)
response.set_cookie(key="welcome", value="欢迎回到APPY")
return response
else:
logger.info(f"输入的密码无效。")
return HTMLResponse(
content="<p class='text-red-600 '>输入的密码无效。</p>",
status_code=401,
)
except Exception as err:
logger.info(f"发生意外错误:{err}")
return HTMLResponse(
content="<p class='text-red-600 '>表单提交出错。</p>",
status_code=500,
)
@router.get("/logout", response_class=HTMLResponse)
async def 用户注销(request: Request):
response = templates.TemplateResponse({"request": request},
name="index.html")
response.delete_cookie("Authorization")
return response
在 HATEOAS 中,一切都是以超媒体的形式提供给客户端的,所以不要惊讶于在路由中看到一些原始 HTML,就像我在错误处理中所做的那样。我感觉没有必要专门为这一点创建一个单独的 HTML 文件片段。
你可能对 hx-target-error
和普通的 hx-target
有什么区别感到好奇。在表单中,hx-target-error
主要用于在 DOM 中显示 4xx 和 5xx 错误。如果没有 response-targets 扩展,带有这些状态码的服务器响应内容将不会被显示。详情请参见官方文档:https://v1.htmx.org/extensions/response-targets/
为了测试或者展示成功的结果,您可以使用 hx-target
,hx-select
,或 hx-select-oob
来显示成功的状态码。
另一个重要的概念是这些路由中使用的 HX-Redirect
标头。Htmx 有一套响应头,用于帮助客户端导航 HTTP 路由。因为我们发送了一个 POST 请求,如果登录成功,用户需要重定向到仪表盘而不是停留在显示错误信息的同一页。你不能直接在模板里完成这个操作,因为 htmx 正忙着把表单数据发送到服务器。
你可以在官方文档htmx.org/reference/#headers中查看一些htmx集成的HTTP头部。
成功!在最佳情况下,我们现在可以登录新认证的用户,并引导他们进入仪表板。最后我要展示的是,介绍一种设置您SaaS界面仪表板的现代和专业的方法,以一种完全动态的方式。
在这个部分里,让我们假设一些元素已经就绪,比如像侧边栏和顶部导航栏这样的元素。当我正在仪表盘的主页上浏览时,并且想要切换到另一个标签,我不会简单地用锚点标签或 h-get
来创建一个新端点。这并不是 htmx 设计的目的。
相反,我们将让仪表板动态切换标签页,同时在顶部导航栏中为当前选中的标签页添加下划线效果。请参见下面的例子。
这次与上次不同,我在我的布局
文件夹(或者你更喜欢使用的任何文件夹)内创建了两个文件。一个文件用于main_dashboard.html
,另一个文件用于第二个文件,我们将其称为main_search.html
。我将在web/routes/pages.py
里的仪表板模板路由后面直接添加它们。
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
# 没有找到 Authorization cookie,重定向到登录页面
auth_token = request.cookies.get("Authorization")
if not auth_token:
return RedirectResponse(url="/signin", status_code=302)
# 没有 cookie 就无法访问页面
welcome = request.cookies.get("welcome", "")
response = templates.TemplateResponse(
{
"request": request,
"USERNAME": request.cookies.get("username", "User"),
"欢迎信息": welcome,
},
name="dashboard.html",
)
response.delete_cookie("welcome")
return response
@router.get("/main_search", response_class=HTMLResponse)
async def main_search(request: Request):
return templates.TemplateResponse({"request": request}, name="layout/main_search.html")
@router.get("/main_dashboard", response_class=HTMLResponse)
async def main_dashboard(request: Request):
return templates.TemplateResponse({"request": request}, name="layout/main_dashboard.html")
标签之间的路由管理现在已经设置好了。从模板仪表板上,我们将创建两个 htmx 按钮,每个对应一个标签页。请注意我们现在使用特定的 id
属性与 (OOB) 交换元素结合使用。
hx-select-oob
属性意味着 htmx 将会在 GET 请求返回的响应中查找这些 oob 元素,并相应地进行替换。在这种情况下,你需要使用 Jinja2 的 include 功能将所有这些 oob 元素添加到每个标签页的 HTML 内部。transition:true
选项添加了淡入淡出的效果,与我们通过超媒体 API 调用时获得的快速响应的效果形成对比。
.html 仪表板
{% extends "./layout/base_dash.html" %} {% block title %}Mark3ts{% endblock %}
{% block content %}
<div
class="absolute z-[-10] top-0 h-screen w-screen bg-black bg-[radial-gradient(#ffffff33_1px,#000000_1px)] bg-[size:20px_20px] animate-slow-pulse"
></div>
<div class="flex w-full h-screen m-auto justify-center">
<div id="toptoggle"></div>
<!-- a sidebar -->
{% include 'components/sidenav2.html' %}
<!-- main part of the screen -->
<div class="w-[100%] overflow-y-auto no-scrollbar">
<div
class="h-[8%] bg-black bg-opacity-80 text-white border-b-gray-700 border-b flex items-center justify-between"
>
<div><span class="ml-10 h-auto text-2xl">Mark3ts </span></div>
<div class="space-x-6 ml-10 justify-center">
<button
id="home_btn"
hx-get="/main_dashboard"
hx-target="#main_section"
hx-trigger="click"
hx-swap="innerHTML transition:true"
hx-select-oob="#home_btn"
class="underline underline-offset-4"
>
首页
</button>
<button
id="search_btn"
hx-get="/main_search"
hx-trigger="click"
hx-target="#main_section"
hx-swap="innerHTML transition:true"
hx-select-oob="#search_btn, #home_btn"
>
搜索
</button>
<span>我的关注</span>
<span>开发人员</span>
</div>
<div class="flex space-x-5 mr-11">
<button>
<svg
class="w-6 h-6 text-white hover:text-blue-500"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5.365V3m0 2.365a5.338 5.338 0 0 1 5.133 5.368v1.8c0 2.386 1.867 2.982 1.867 4.175 0 .593 0 1.292-.538 1.292H5.538C5 18 5 17.301 5 16.708c0-1.193 1.867-1.789 1.867-4.175v-1.8A5.338 5.338 0 0 1 12 5.365ZM8.733 18c.094.852.306 1.54.944 2.112a3.48 3.48 0 0 0 4.646 0c.638-.572 1.236-1.26 1.33-2.112h-6.92Z"
/>
</svg>
</button>
<button>帮助中心</button>
<img
class="w-10 h-10 rounded h-auto"
src="https://mighty.tools/mockmind-api/content/human/76.jpg"
alt="默认头像"
/>
</div>
</div>
<div id="main_section">{% include 'components/dashboard.html'%}</div>
</div>
</div>
{% endblock %}
layout/main_dashboard.html
(布局/主仪表板.html)
<!--顶部导航栏的首页按钮-->
<button
id="home_btn"
hx-get="/main_dashboard"
hx-target="#main_section"
hx-trigger="click"
hx-swap="innerHTML transition: true"
hx-select="#main_homepage"
hx-select-oob="#home_btn, #search_btn"
class="underline underline-offset-4 cursor-pointer"
>
首页
</button>
<!--顶部导航栏的搜索按钮-->
{% include 'partials/search_btn.html' %}
<!--主页的主要部分-->
{% include 'components/dashboard.html'%}
main_search.html (主搜索布局)
<!-- 顶部搜索导航链接 -->
<button
id="home_btn"
hx-get="/main_dashboard"
hx-target="#main_section"
hx-trigger="click"
hx-swap="innerHTML transition:true"
hx-select="#main_homepage"
hx-select-oob="#home_btn, #search_btn"
class=""
>
首页
</button>
<!-- 搜索导航顶部链接 -->
{% include 'partials/search_btn_underlined.html'%}
<!-- 主要搜索部分 -->
<div id="main_search">
<div
class="bg-black bg-opacity-80 text-white border-b-gray-700 border-full border-b"
>
...
/* 选择外部元素 */
</div>
我把 home 按钮留在 tab 模板里,这样你可以看到逻辑的实际运行。你可以注意到,在一个模板中存在 Tailwind 下划线类,而在另一个模板中则没有。这个差异意味着 htmx 会替换这些元素,因为它们共享同一个带外的 #id
。
更系统的方法是将这些小的HTML元素移到名为 partials
的文件夹,以存放不时用到的可复用的小组件。
想深入了解代码的话,你可以看看源代码库 github.com/ricardomrcruz/FastAPI_Jinja2_HTMX-Login_Form_Dashboard。在那里,我在那里放了一些在这篇文章中展示的一些代码片段和逻辑,我已经在我的一些其他项目中成功使用过这些代码。
通过这种方法,您可以构建一个快速、响应灵敏且现代的网页应用,保持前端逻辑简洁高效,同时利用FastAPI的速度和HTMX的简洁性。
而且是 HTML 文件们确实回来了!这次是真的!