最近一直关注网站搭建的相关讯息,前面学习了Reflex框架,其核心是通过FastAPI作为后端,然后前端通过nextjs渲染静态页面并调取后端数据完成交互,是一个开箱即用的包。但是我在使用过程中也发现了一个问题,那就是所有的交互都依赖于后端,如果网络连接不顺畅,或者你距离后端服务器太远,那么用户的交互是非常卡顿的。
我举个简单的例子,一个滑动组件,当用户滑动的时候,前面页面需要实时显示滑动到的数字,如果使用Reflex, 那么过程是:前端滑动到一个位置,把数据发送到后端,后端计算出新的数据,更新到前端(基于 WebSocket)。但其实这个过程完全不需要进入后端的,例如Vue的数据双向绑定,直接前端完成整个过程会相对更加流畅。
最近,我发现了几个轻量化的库,一个是Robyn, 这个库是一个基于Rust的Http服务器,其性能很强,据官网测评是远强于FastAPI的。而且相比于FastAPI, Robyn更简单,文档就几页,拿来就可以用。
对于前端的交互,Alpine.js则是VueJS的替代,我们既然使用了Python来搭建网站,那么最好就是尽量少碰前端框架,像Vuejs或者React更倾向于用前端的技术栈来搭建全栈服务,这意味着你要起一个nodejs的服务和后端交互,这无疑增加了复杂度。

Alpine.js则是一个轻量级的库,其核心是使用JavaScript来完成前端的交互,我们设置只需要添加一行 CDN在我们的html文件里就可以使用它的功能了。虽然 Alpine.js可以使用 Javascript来使用 Ajax获取后端数据,但是HTMX则更简单,我们甚至不需要写太多的JS代码即可以完成页面的交互以及数据的获取。那么接下来,我们用一个简单的例子来看看如何整个这三个库,来创建一个AI绘画页面。
创建一个AI绘画页面
1. 创建一个Robyn项目
$ python3 -m venv .venv $ source .venv/bin/activate $ pip install robyn $ python -m robyn --create
|
这会开启一个交互式对话,我们一次选择即可创建一个项目:
$ python3 -m robyn --create ? Directory Path: . ? Need Docker? (Y/N) Y ? Please select project type (Mongo/Postgres/Sqlalchemy/Prisma): ❯ No DB Sqlite Postgres MongoDB SqlAlchemy Prisma
|
2. 用AI帮我们写一个纯HTML的前端页面
我们像Gemini Pro 2.5提问:
使用 DaisyUI 创建一个AI 绘画的 HTML 页面,该页面包含 Navbar, Body 和 Footer, Body 分为两栏,分为 Sidebar 和 View aera, 其中 SideBar 负责收集用户提交的参数(Propmt, Steps, Button),而 View aera则负责画面呈现,要求生成的图可以被用户点击放大预览。要求整个页面的 UI 现代化,简约但不失美观。”
然后,我们创建一个templates的目录,并创建一个index.html文件,把AI生成的代码粘贴进去。
$ mkdir -p templates $ touch templates/index.html
|
我们得到的html文件如下:
查看代码
<!DOCTYPE html> <html lang="zh-CN" data-theme="light"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>AI 绘画</title> <style> @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); body { display: flex; flex-direction: column; min-height: 100vh; font-family: 'Inter', sans-serif; background: linear-gradient(135deg, hsl(var(--b2)) 0%, hsl(var(--b3)) 100%); } .main-content-wrapper { flex-grow: 1; } .modal-image-preview { max-width: 90vw; max-height: 85vh; object-fit: contain; border-radius: 0.5rem; } .prompt-textarea::-webkit-scrollbar { width: 8px; } .prompt-textarea::-webkit-scrollbar-thumb { background-color: hsl(var(--bc) / 0.4); border-radius: 4px; } .prompt-textarea::-webkit-scrollbar-track { background-color: hsl(var(--b1)); border-radius: 4px; }
#generated_image_display { max-width: 100%; max-height: 65vh; object-fit: contain; border-radius: 0.5rem; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); transition: transform 0.3s ease; } #generated_image_display:hover { transform: scale(1.02); } #initial_placeholder_text { color: hsl(var(--bc) / 0.6); } .sidebar { transition: width 0.3s ease-in-out; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); }
[data-theme="dark"] .sidebar, [data-theme="synthwave"] .sidebar { background: rgba(0, 0, 0, 0.2); border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5); }
.custom-range { -webkit-appearance: none; appearance: none; height: 8px; border-radius: 4px; background: linear-gradient(to right, hsl(var(--p)) 0%, hsl(var(--p)) var(--range-value, 20%), hsl(var(--bc) / 0.2) var(--range-value, 20%), hsl(var(--bc) / 0.2) 100%); outline: none; transition: all 0.3s ease; }
.custom-range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 50%; background: hsl(var(--p)); cursor: pointer; border: 3px solid hsl(var(--b1)); box-shadow: 0 2px 8px rgba(0,0,0,0.2); transition: all 0.3s ease; }
.custom-range::-webkit-slider-thumb:hover { transform: scale(1.2); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
.custom-range::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: hsl(var(--p)); cursor: pointer; border: 3px solid hsl(var(--b1)); box-shadow: 0 2px 8px rgba(0,0,0,0.2); transition: all 0.3s ease; }
.custom-range::-moz-range-thumb:hover { transform: scale(1.2); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
.range-labels { display: flex; justify-content: space-between; font-size: 0.75rem; color: hsl(var(--bc) / 0.6); margin-top: 0.5rem; padding: 0 0.25rem; }
.theme-toggle { transition: all 0.3s ease; }
.theme-toggle:checked { background-color: hsl(var(--p)); } </style> </head> <body>
<div class="navbar bg-base-100/80 backdrop-blur-md shadow-lg sticky top-0 z-50 border-b border-base-300/50"> <div class="flex-1"> <a class="btn btn-ghost text-xl normal-case"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-2"><path d="M12 3c.302 0 .59.03.871.089A7.008 7.008 0 0 1 21 7a3 3 0 0 1-3 3h-1.26a4 4 0 1 0-7.48 0H8a3 3 0 0 1-3-3 7.008 7.008 0 0 1 7.129-3.911A3.979 3.979 0 0 0 12 3Z"></path><path d="M12 18c-3.5 0-6.243-2.594-6.921-6h13.842c-.678 3.406-3.421 6-6.921 6Z"></path></svg> AI 绘画工坊 </a> </div> <div class="flex-none"> <label class="flex cursor-pointer gap-2 items-center"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg> <input type="checkbox" class="toggle theme-controller theme-toggle toggle-sm" id="theme-toggle"/> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg> </label> </div> </div>
<div class="main-content-wrapper container mx-auto p-4 sm:p-6 lg:p-8"> <div class="flex flex-col md:flex-row gap-6 lg:gap-8"> <aside class="md:w-[320px] lg:w-[380px] w-full sidebar p-6 rounded-xl space-y-6 flex-shrink-0"> <h2 class="text-2xl font-semibold mb-4 border-b border-base-300/50 pb-3 text-primary">创作参数</h2> <form class="space-y-6"> <div> <label for="prompt" class="label"> <span class="label-text text-base font-medium">魔法咒语 (Prompt) ✨</span> </label> <textarea name="prompt" id="prompt" class="textarea textarea-bordered textarea-lg w-full h-36 prompt-textarea bg-base-100/50 backdrop-blur-sm" placeholder="例如:一只戴着宇航员头盔的猫漂浮在宇宙中,背景是绚丽的星云,数字艺术" required></textarea> <p id="prompt_error_msg" class="text-error text-sm mt-1 h-4"></p> </div>
<div > <label for="num_steps" class="label justify-between"> <span class="label-text text-base font-medium">绘画步数 (Steps) 🖼️</span> <span class="label-text-alt text-lg font-semibold text-primary" id="steps_value_display">4</span> </label> <input type="range" name="num_steps" min="1" max="8" value="4" step="1" class="custom-range w-full" id="steps_input" /> <div class="range-labels"> <span>快速 (1)</span> <span>精细 (8)</span> </div> </div> <button type="submit" class="btn btn-primary btn-lg w-full mt-4 group bg-gradient-to-r from-primary to-secondary border-0"> <span class="group-hover:scale-110 transition-transform duration-300">开始创作</span> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline-block ml-2 group-hover:animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg> </button> </form> <div id="loading_indicator" class="htmx-indicator text-center mt-4 space-y-2"> <span class="loading loading-dots loading-lg text-primary"></span> <p class="text-primary animate-pulse">正在召唤灵感缪斯...</p> </div> </aside>
<main class="flex-grow bg-base-100/50 backdrop-blur-sm border border-base-300/50 p-6 rounded-xl shadow-xl flex flex-col justify-center items-center min-h-[60vh] lg:min-h-[70vh]"> <div x-data id="view_area_image_container" class="w-full h-full flex justify-center items-center" @click="my_modal_2.showModal()"> <p id="initial_placeholder_text" class="text-xl text-center">您的 AI 画作将在此惊艳登场!🚀</p> </div> </main> <dialog id="my_modal_2" class="modal"> <div class="modal-box w-11/12 max-w-5xl p-0 relative bg-transparent shadow-none"> <img id="modal_image_content" src="" alt="图像预览" class="modal-image-preview mx-auto"/> </div> <form method="dialog" class="modal-backdrop"> <button>close</button> </form> </dialog> </div> </div>
<footer class="footer footer-center p-6 bg-base-300/80 backdrop-blur-md text-base-content mt-auto border-t border-base-300/50"> <aside> <p>版权所有 © <span id="current_year"></span> AI 绘画工坊 - 由先进的 AI 技术驱动</p> <p class="text-xs opacity-70">简约设计,无限创意</p> </aside> </footer> </body> </html>
|
注意,为了下面的方便,我们直接将需要的库全部引入在<head>
标签内:
<link href="https://unpkg.com/daisyui@5" rel="stylesheet" type="text/css" /> <script src="https://unpkg.com/@tailwindcss/browser@4"></script> <script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script> <script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
3. 用 Robyn 创建一个跟路由来托管该页面
from robyn import Robyn, serve_html import pathlib import os
app = Robyn(__file__)
current_file_path = pathlib.Path(__file__).parent.resolve() html_path = os.path.join(current_file_path, "templates")
@app.get("/") async def index(): return serve_html(os.path.join(html_path, "index.html"))
if __name__ == "__main__": app.start(host="0.0.0.0", port=8080)
|
4. 使用Alpine.js来完成前端的交互
我们现在打开http://localhost:8080, 可以看到我们创建的页面。但是我们发现前端是没有交互的,例如切换主题的按钮无效,滑块滑动后,数值也不会变化。下面我们来通过添加一些标签就可以实现需要大量 JS 代码来实现的操作。
主题切换优化
DaisyUI主题切换需要在跟组件添加一个属性data-theme
,我们在<body>
标签添加它,并让切换按钮可以控制他,这就是数据绑定。
Body标签修改如下:
<body x-data="{ theme: false }" :data-theme="theme ? 'dark' : 'light'">
|
然后,我们修改切换按钮的代码,添加一个x-model="theme"
属性,这样就可以实现主题的切换。
<input x-model="theme" type="checkbox" class="toggle theme-controller theme-toggle toggle-sm" id="theme-toggle"/>
|
这里的逻辑是:input
标签的值会绑定到x-model=theme
的变量上,所以input
的变化会改变X-data中的数据,然后通过:data-theme=theme ? 'dark' : 'light'
属性来控制主题。
滑块交互优化
对于滑动组件,我们首先添加一个x-data="{ steps: 4 }
标签。
<div x-data="{ steps: 4 }"> <label for="num_steps" class="label justify-between"> <span class="label-text text-base font-medium">绘画步数 (Steps) 🖼️</span> <span class="label-text-alt text-lg font-semibold text-primary" id="steps_value_display"></span> </label> ... </div>
|
然后是input标签,添加一个x-model="steps"
属性,这样就可以实现滑块的交互。
<input x-model="steps" type="range" name="num_steps" min="1" max="8" value="4" step="1" class="custom-range w-full" id="steps_input" />
|
最后在<span>
使用x-text="steps"
来绑定滑块的值。
<div x-data="{ steps: 4 }"> <label for="num_steps" class="label justify-between"> <span class="label-text text-base font-medium">绘画步数 (Steps) 🖼️</span> <span class="label-text-alt text-lg font-semibold text-primary" id="steps_value_display" x-text="steps"></span> </label> ... </div>
|
这里的原理就是:x-data
标签会创建一个数据对象,x-model=steps
会绑定到这个数据对象的steps
属性,x-text=steps
会绑定到这个数据对象的steps
属性。
5. 使用HTMX来完成后端的交互
这个就很简单了,我们假设需要向后端/generate
路由发送Post
请求,并获取返回的图片,那么我们只需要在<form>
标签添加一个hx-post
属性,并添加一个hx-target
属性,这样就可以实现后端的交互。
<form id="generate_form" hx-post="/generate" hx-target="#initial_placeholder_text" hx-indicator="#loading_indicator" hx-swap="outerHTML" class="space-y-6"> <div> <label for="prompt" class="label"> <span class="label-text text-base font-medium">魔法咒语 (Prompt) ✨</span> </label> <textarea name="prompt" id="prompt" class="textarea textarea-bordered textarea-lg w-full h-36 prompt-textarea bg-basebackdrop-blur-sm" placeholder="例如:一只戴着宇航员头盔的猫漂浮在宇宙中,背景是绚丽的星云,数字艺术" required></textarea> <p id="prompt_error_msg" class="text-error text-sm mt-1 h-4"></p> </div> ... </form>
|
我们依次解释一下含义:
hx-post
:指定要发送请求的URLhx-target
:指定要更新的目标元素hx-indicator
:指定要显示的加载指示器,在请求过程中会显示loading状态,请求完成后会隐藏hx-swap
:指定要更新的方式
这里我们使用hx-swap="outerHTML"
,这意味着当后端返回数据时,会替换掉<div>
标签的内容。
6. 使用Robyn来创建后端路由
import httpx
def get_flux_image_data(prompt, num_steps): endpoint = "https://api.cloudflare.com/client/v4/accounts/{accountid}/ai/run/@cf/black-forest-labs/flux-1-schnell" headers = { "Authorization": "Bearer {key}", "Content-Type": "application/json", } if isinstance(num_steps, str): num_steps = int(num_steps) payload = { "prompt": prompt, "num_steps": num_steps } response = httpx.post( endpoint, headers=headers, json=payload, timeout=None) if response.status_code == 400: return None else: image_data = response.json()['result']['image'] return image_data
|
首先,我们创建一个函数来代理CF的API,这样好处是我们的密钥是保存在后端服务器的,不会暴露给前端。
然后,我们创建一个路由来处理请求:
from robyn import html from urllib.parse import parse_qs
@app.post("/generate") async def generate(request): body = parse_qs(request.body) prompt = body["prompt"][0] num_steps = body["num_steps"][0] image_data = get_flux_image_data(prompt, num_steps) html_str = f""" <img id=\"generated_image_display\" src=\"data:image/png;base64,{image_data}\" alt=\"生成的图像\" class=\"cursor-pointer\"/> <img hx-swap-oob="true" id="modal_image_content" src="data:image/png;base64,{image_data}" alt="图像预览" class="modal-image-preview mx-auto"/> """ return html(html_str)
|
info:我们看到,我们的接口返回的是html的字符串,而不是通常的JSON数据,这是因为html期望我们返回的是html字符串,而不是JSON数据,并且用返回的字符串去替换hx-target
指定的元素。 另外,这里用到了hx-swap-oob
属性,这个属性的作用可以帮助我们在替换掉需要替换的元素后,可以顺便将id
为modal_image_content
的组件也替换掉,因为我们前端有个点击生成的图片会放大预览的效果,我们使用的是模态框,需要同步替换里面的内容。
最终呈现:
我们运行
$ python3 -m robyn app.py
|
然后我们打开http://localhost:8080, 就可以看到我们创建的页面了。

总结
我们通过使用Robyn, HTMX 以及 Alpine.js 创建了一个AI绘画页面,整个过程非常简单,只需要几个步骤就可以完成。