最近一直关注网站搭建的相关讯息,前面学习了 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 项目
1 2 3 4
| $ python3 -m venv .venv $ source .venv/bin/activate $ pip install robyn $ python -m robyn --create
|
这会开启一个交互式对话,我们一次选择即可创建一个项目:
1 2 3 4 5 6 7 8 9 10
| $ 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 生成的代码粘贴进去。
1 2
| $ mkdir -p templates $ touch templates/index.html
|
我们得到的 html 文件如下:
查看代码 codeblock:true
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
| <!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>标签内:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="https://unpkg.com/[email protected]" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous" ></script> <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js" ></script>
|
3. 用 Robyn 创建一个跟路由来托管该页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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 标签修改如下:
1
| <body x-data="{ theme: false }" :data-theme="theme ? 'dark' : 'light'"></body>
|
然后,我们修改切换按钮的代码,添加一个x-model="theme"属性,这样就可以实现主题的切换。
1 2 3 4 5 6
| <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 }标签。
1 2 3 4 5 6 7 8 9 10
| <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"属性,这样就可以实现滑块的交互。
1 2 3 4 5 6 7 8 9 10 11
| <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"来绑定滑块的值。
1 2 3 4 5 6 7 8 9 10 11
| <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属性,这样就可以实现后端的交互。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <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 来创建后端路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| 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,这样好处是我们的密钥是保存在后端服务器的,不会暴露给前端。
然后,我们创建一个路由来处理请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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)
|
我们看到,我们的接口返回的是html的字符串,而不是通常的JSON数据,这是因为html期望我们返回的是html字符串,而不是JSON数据,并且用返回的字符串去替换`hx-target`指定的元素。
另外,这里用到了hx-swap-oob属性,这个属性的作用可以帮助我们在替换掉需要替换的元素后,可以顺便将id为modal_image_content的组件也替换掉,因为我们前端有个点击生成的图片会放大预览的效果,我们使用的是模态框,需要同步替换里面的内容。
最终呈现:
我们运行
1
| $ python3 -m robyn app.py
|
然后我们打开 http://localhost:8080, 就可以看到我们创建的页面了。

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