最近一直关注网站搭建的相关讯息,前面学习了 Reflex 框架,其核心是通过 FastAPI 作为后端,然后前端通过 nextjs 渲染静态页面并调取后端数据完成交互,是一个开箱即用的包。但是我在使用过程中也发现了一个问题,那就是所有的交互都依赖于后端,如果网络连接不顺畅,或者你距离后端服务器太远,那么用户的交互是非常卡顿的。

我举个简单的例子,一个滑动组件,当用户滑动的时候,前面页面需要实时显示滑动到的数字,如果使用Reflex,

那么过程是:前端滑动到一个位置,把数据发送到后端,后端计算出新的数据,更新到前端(基于 WebSocket)。但其实这个过程完全不需要进入后端的,例如Vue的数据双向绑定,直接前端完成整个过程会相对更加流畅。

最近,我发现了几个轻量化的库,一个是Robyn, 这个库是一个基于 Rust 的 Http 服务器,其性能很强,据官网测评是远强于 FastAPI 的。而且相比于 FastAPI, Robyn 更简单,文档就几页,拿来就可以用。
对于前端的交互,Alpine.js则是 VueJS 的替代,我们既然使用了 Python 来搭建网站,那么最好就是尽量少碰前端框架,像 Vuejs 或者 React 更倾向于用前端的技术栈来搭建全栈服务,这意味着你要起一个 nodejs 的服务和后端交互,这无疑增加了复杂度。

image

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>
/* Google Font for a more modern look - optional */
@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; /* Apply modern font */
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;
}
/* Custom scrollbar for prompt textarea (optional, but nice for aesthetics) */
.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; /* Adjust max height in main view area */
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>
<!-- <img id="generated_image_display" src="./btygir.png" alt="生成的图像" class="cursor-pointer"/> -->
</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")

##只需要使用serve_html即可以将整个页面返回给用户,这里我们不用模板,因为用不到
@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:指定要发送请求的 URL
  • hx-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:
# raise error
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属性,这个属性的作用可以帮助我们在替换掉需要替换的元素后,可以顺便将idmodal_image_content的组件也替换掉,因为我们前端有个点击生成的图片会放大预览的效果,我们使用的是模态框,需要同步替换里面的内容。

最终呈现:

我们运行

1
$ python3 -m robyn app.py ##假如我们的文件名是app.py

然后我们打开 http://localhost:8080, 就可以看到我们创建的页面了。

image

总结

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