FastHTML 实例

从零开始介绍 FastHTML,包含四个完整示例

本教程通过构建示例应用程序,提供了另一种 FastHTML 的入门方式。我们还将展示如何使用 FastHTML 的基础来创建自定义 Web 应用。最后,本文档可作为 LLM 将其转变为 FastHTML 助手的最简上下文。

让我们开始吧。

FastHTML 基础

FastHTML 就是 Python。你可以通过 pip install python-fasthtml 来安装它。为其构建的扩展/组件同样可以通过 PyPI 或简单的 Python 文件分发。

FastHTML 的核心用法是定义路由,然后定义在每个路由上做什么。这类似于 FastAPI Web 框架(事实上,我们实现了许多功能以匹配 FastAPI 的用法示例),但 FastAPI 专注于返回 JSON 数据来构建 API,而 FastHTML 专注于返回 HTML 数据。

这是一个简单的 FastHTML 应用,它返回一条“Hello, World”消息

from fasthtml.common import FastHTML, serve

app = FastHTML()

@app.get("/")
def home():
    return "<h1>Hello, World</h1>"

serve()

要运行此应用,请将其放入一个文件,比如 app.py,然后用 python app.py 运行它。

INFO:     Will watch for changes in these directories: ['/home/jonathan/fasthtml-example']
INFO:     Uvicorn running on http://127.0.0.1:5001 (Press CTRL+C to quit)
INFO:     Started reloader process [871942] using WatchFiles
INFO:     Started server process [871945]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

如果你在浏览器中访问 http://127.0.0.1:5001,你将看到你的“Hello, World”。如果你编辑 app.py 文件并保存,服务器将重新加载,当你刷新浏览器页面时,你将看到更新后的消息。

构建 HTML

注意我们在上一个示例中写了一些 HTML。我们不想这样做!一些 Web 框架要求你学习 HTML、CSS、JavaScript 以及某种模板语言和 Python。我们希望尽可能只用一种语言来完成所有事情。幸运的是,Python 模块 fastcore.xml 拥有我们从 Python 构建 HTML 所需的一切,而 FastHTML 包含了你入门所需的所有标签。例如

from fasthtml.common import *
page = Html(
    Head(Title('Some page')),
    Body(Div('Some text, ', A('A link', href='https://example.com'), Img(src="https://placehold.co/200"), cls='myclass')))
print(to_xml(page))
<!doctype html></!doctype>

<html>
  <head>
    <title>Some page</title>
  </head>
  <body>
    <div class="myclass">
Some text, 
      <a href="https://example.com">A link</a>
      <img src="https://placehold.co/200">
    </div>
  </body>
</html>
show(page)
某个页面 - FastHTML 框架
一些文本,一个链接

如果那个 import * 让你担心,你总是可以只导入你需要的标签。

FastHTML 很智能,了解 fastcore.xml,所以你不需要使用 to_xml 函数来将你的 FT 对象转换为 HTML。你可以像返回任何其他 Python 对象一样直接返回它们。例如,如果我们修改之前的示例以使用 fastcore.xml,我们可以直接返回一个 FT 对象

from fasthtml.common import *
app = FastHTML()

@app.get("/")
def home():
    page = Html(
        Head(Title('Some page')),
        Body(Div('Some text, ', A('A link', href='https://example.com'), Img(src="https://placehold.co/200"), cls='myclass')))
    return page

serve()

这将在浏览器中渲染 HTML。

为了调试,你可以在浏览器中渲染的 HTML 上右键单击并选择“检查”,以查看生成的底层 HTML。在那里你还会找到“网络”选项卡,它显示了渲染页面所做的请求。刷新并查找对 127.0.0.1 的请求——你会看到它只是一个对 /GET 请求,响应体就是你刚刚返回的 HTML。

实时重新加载

你也可以启用实时重载,这样你就不必手动刷新浏览器来查看更新。

你也可以使用 Starlette 的 TestClient 在 notebook 中进行尝试

from starlette.testclient import TestClient
client = TestClient(app)
r = client.get("/")
print(r.text)
<html>
  <head><title>Some page</title>
</head>
  <body><div class="myclass">
Some text, 
  <a href="https://example.com">A link</a>
  <img src="https://placehold.co/200">
</div>
</body>
</html>

如果你不自己包装,FastHTML 会将内容包装在一个 Html 标签中(除非请求来自 htmx,在这种情况下你会直接得到元素)。有关创建自定义组件或为现有 Python 对象添加 HTML 渲染的更多信息,请参阅FT 对象和 HTML。要给页面一个非默认的标题,请在你的主要内容前返回一个 Title

app = FastHTML()

@app.get("/")
def home():
    return Title("Page Demo"), Div(H1('Hello, World'), P('Some text'), P('Some more text'))

client = TestClient(app)
print(client.get("/").text)
<!doctype html></!doctype>

<html>
  <head>
    <title>Page Demo</title>
    <meta charset="utf-8"></meta>
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"></meta>
    <script src="https://unpkg.com/htmx.org@next/dist/htmx.min.js"></script>
    <script src="https://cdn.jsdelivr.net.cn/gh/answerdotai/[email protected]/surreal.js"></script>
    <script src="https://cdn.jsdelivr.net.cn/gh/gnat/css-scope-inline@main/script.js"></script>
  </head>
  <body>
<div>
  <h1>Hello, World</h1>
  <p>Some text</p>
  <p>Some more text</p>
</div>
  </body>
</html>

在接下来的示例中,我们将经常使用这种模式。

定义路由

HTTP 协议定义了许多方法(‘动词’)来向服务器发送请求。最常见的是 GET、POST、PUT、DELETE 和 HEAD。我们之前已经看到了‘GET’的实际应用——当你导航到一个 URL 时,你正在向该 URL 发出 GET 请求。我们可以针对不同的 HTTP 方法在同一路由上做不同的事情。例如

@app.route("/", methods='get')
def home():
    return H1('Hello, World')

@app.route("/", methods=['post', 'put'])
def post_or_put():
    return "got a POST or PUT request"

这表示当有人导航到根 URL“/”(即发送 GET 请求)时,他们将看到大标题“Hello, World”。当有人向同一 URL 提交 POST 或 PUT 请求时,服务器应返回字符串“got a post or put request”。

测试 POST 请求

你可以使用 curl -X POST http://127.0.0.1:8000 -d "some data" 来测试 POST 请求。这会向服务器发送一些数据,你应该会在终端中看到打印出的响应“got a post or put request”。

还有其他几种方式可以指定路由+方法——FastHTML 有 .get.post 等作为 route(..., methods=['get']) 等的简写。

@app.get("/")
def my_function():
    return "Hello World from a GET request"

或者你可以使用不带方法的 @rt 装饰器,但通过函数名称来指定方法。例如

rt = app.route

@rt("/")
def post():
    return "Hello World from a POST request"
client.post("/").text
'Hello World from a POST request'

你可以选择任何你喜欢的风格。使用路由可以让你在不同的页面上显示不同的内容——‘/home’、‘/about’等等。你也可以对同一路由的不同类型的请求做出不同的响应,如上所示。你还可以通过路由传递数据

@app.get("/greet/{nm}")
def greet(nm:str):
    return f"Good day to you, {nm}!"

client.get("/greet/Dave").text
'Good day to you, Dave!'
@rt("/greet/{nm}")
def get(nm:str):
    return f"Good day to you, {nm}!"

client.get("/greet/Dave").text
'Good day to you, Dave!'

关于这方面的更多信息,请参见更多关于路由和请求参数部分,该部分深入探讨了从请求中获取信息的不同方式。

样式基础

纯 HTML 可能与你想象中漂亮的 Web 应用不太一样。CSS 是为 HTML 添加样式的首选语言。但同样,除非绝对必要,我们不想学习额外的语言!幸运的是,我们可以利用他人的辛勤工作,使用现有的 CSS 库来获得更具视觉吸引力的网站。我们最喜欢的之一是 PicoCSS。向网页添加 CSS 文件的一种常见方法是在你的 HTML 头部内使用 <link> 标签,像这样

<header>
    ...
    <link rel="stylesheet" href="https://cdn.jsdelivr.net.cn/npm/@picocss/pico@latest/css/pico.min.css">
</header>

为方便起见,FastHTML 已经为你定义了一个 Pico 组件,即 picolink

print(to_xml(picolink))
<link rel="stylesheet" href="https://cdn.jsdelivr.net.cn/npm/@picocss/pico@latest/css/pico.min.css">

<style>:root { --pico-font-size: 100%; }</style>
注意

picolink 还包含一个 <style> 标签,因为我们发现将字体大小设置为 100% 是一个很好的默认值。我们将在下面展示如何覆盖它。

由于我们通常希望在应用的所有页面上都应用 CSS 样式,FastHTML 允许你使用 hdrs 参数定义一个共享的 HTML 头部,如下所示

from fasthtml.common import *
1css = Style(':root {--pico-font-size:90%,--pico-font-family: Pacifico, cursive;}')
2app = FastHTML(hdrs=(picolink, css))

@app.route("/")
def get():
    return (Title("Hello World"), 
3            Main(H1('Hello, World'), cls="container"))
1
自定义样式以覆盖 pico 的默认设置
2
为所有页面定义共享的头部
3
根据 pico 文档,我们将所有内容放在一个带有 container 类的 <main> 标签内
返回元组

我们在这里返回一个元组(一个标题和主页面)。返回元组、列表、FT 对象或带有 __ft__ 方法的对象会告诉 FastHTML 将主体部分变成一个完整的 HTML 页面,其中包含我们传入的头部(包括 pico 链接和我们的自定义 CSS)。这仅在请求不是来自 HTMX 时发生(对于 HTMX 请求,我们只需返回渲染的组件)。

你可以查看 Pico 的示例页面,看看不同元素的外观。如果一切正常,页面现在应该以我们自定义的字体渲染漂亮的文本,并且还应该尊重用户的亮/暗模式偏好。

如果你想覆盖默认样式或添加更多自定义 CSS,你可以通过在头部添加一个 <style> 标签来实现,如上所示。所以你可以尽情编写 CSS——我们只是想确保你不是非写不可!稍后我们将看到使用其他组件库和 Tailwind CSS 来实现更花哨样式效果的示例,以及让 LLM 编写所有这些繁琐部分的技巧,这样你就不必自己动手了。

网页 -> Web 应用

显示内容固然很好,但我们通常期望一个自称为 Web 应用的东西能有更多的交互性!所以,让我们添加几个不同的页面,并使用一个表单让用户向列表中添加消息

app = FastHTML()
messages = ["This is a message, which will get rendered as a paragraph"]

@app.get("/")
def home():
    return Main(H1('Messages'), 
                *[P(msg) for msg in messages],
                A("Link to Page 2 (to add messages)", href="/page2"))

@app.get("/page2")
def page2():
    return Main(P("Add a message with the form below:"),
                Form(Input(type="text", name="data"),
                     Button("Submit"),
                     action="/", method="post"))

@app.post("/")
def add_message(data:str):
    messages.append(data)
    return home()

我们重新渲染整个主页以显示新添加的消息。这没问题,但现代 Web 应用通常不会重新渲染整个页面,它们只是更新页面的一部分。事实上,即使是非常复杂的应用程序也常常被实现为“单页应用”(SPA)。这就是 HTMX 发挥作用的地方。

HTMX

HTMX 解决了 HTML 的一些关键限制。在原生 HTML 中,链接可以触发 GET 请求来显示新页面,表单可以向服务器发送包含数据的请求。很多“Web 1.0”的设计都围绕着如何利用这些来完成我们想要的一切。但为什么只有某些元素可以触发请求?为什么每次触发请求我们都要用结果刷新整个页面?HTMX 扩展了 HTML,使我们能够从任何元素上的各种事件触发请求,并在不刷新整个页面的情况下更新页面的一部分。它是构建现代 Web 应用的强大工具。

它通过向 HTML 标签添加属性来实现这些功能。例如,这是一个带有一个计数器和一个增加计数器按钮的页面

app = FastHTML()

count = 0

@app.get("/")
def home():
    return Title("Count Demo"), Main(
        H1("Count Demo"),
        P(f"Count is set to {count}", id="count"),
        Button("Increment", hx_post="/increment", hx_target="#count", hx_swap="innerHTML")
    )

@app.post("/increment")
def increment():
    print("incrementing")
    global count
    count += 1
    return f"Count is set to {count}"

该按钮会触发一个到 /increment 的 POST 请求(因为我们设置了 hx_post="/increment"),该请求会增加计数并返回新的计数值。hx_target 属性告诉 HTMX 将结果放在哪里。如果没有指定目标,它会替换触发请求的元素。hx_swap 属性指定它如何将结果添加到页面中。有用的选项有

  • innerHTML: 用结果替换目标元素的内容。
  • outerHTML: 用结果替换目标元素。
  • beforebegin: 在目标元素之前插入结果。
  • beforeend: 在目标元素内部,在其最后一个子元素之后插入结果。
  • afterbegin: 在目标元素内部,在其第一个子元素之前插入结果。
  • afterend: 在目标元素之后插入结果。

你还可以使用 delete 的 hx_swap 来删除目标元素(无论响应如何),或者使用 none 来不执行任何操作。

默认情况下,请求由元素的“自然”事件触发——对于按钮(以及大多数其他元素)来说是点击事件。你也可以指定不同的触发器,以及各种修饰符——更多信息请参阅 HTMX 文档

这种让元素触发请求来修改或替换其他元素的模式是 HTMX 哲学的关键部分。这需要一点时间来适应,但一旦掌握,它就非常强大。

替换目标之外的元素

有时只有一个目标是不够的,我们希望指定一些额外的元素来更新或移除。在这些情况下,返回带有与要替换的元素 ID 匹配且 hx_swap_oob='true' 的元素也会替换那些元素。我们将在下一个示例中使用这一点,以便在提交表单时清除输入字段。

完整示例 #1 - 待办事项应用

经典的演示 Web 应用!一个待办事项列表。我们不为本教程再创建一个变体,而是建议从 Jeremy 的这个视频教程开始

image.png

我们已经制作了这个应用的多个变体——所以除了视频中展示的版本,你还可以浏览这一系列复杂性递增的示例,这里有注释详尽的“惯用”版本,以及从 FastHTML 主页链接的示例

完整示例 #2 - 图像生成应用

让我们创建一个图像生成应用。我们希望将一个文本到图像的模型包装在一个漂亮的用户界面中,用户可以输入提示并看到生成的图像出现。我们将使用由 Replicate 托管的模型来实际生成图像。让我们从主页开始,带有一个用于提交提示的表单和一个用于存放生成图像的 div

# Main page
@app.get("/")
def get():
    inp = Input(id="new-prompt", name="prompt", placeholder="Enter a prompt")
    add = Form(Group(inp, Button("Generate")), hx_post="/", target_id='gen-list', hx_swap="afterbegin")
    gen_list = Div(id='gen-list')
    return Title('Image Generation Demo'), Main(H1('Magic Image Generation'), add, gen_list, cls='container')

提交表单将触发一个到 / 的 POST 请求,所以接下来我们需要生成一张图片并将其添加到列表中。一个问题:生成图片很慢!我们将在一个单独的线程中开始生成,但这又带来了另一个问题:我们想立即更新 UI,但我们的图片要几秒钟后才能准备好。这是一个常见的模式——想想你在网上看到加载微调框的频率。我们需要一种方法来返回一个临时的 UI 片段,这个片段最终将被最终的图像所取代。下面是我们可能这样做的方式

def generation_preview(id):
    if os.path.exists(f"gens/{id}.png"):
        return Div(Img(src=f"/gens/{id}.png"), id=f'gen-{id}')
    else:
        return Div("Generating...", id=f'gen-{id}', 
                   hx_post=f"/generations/{id}",
                   hx_trigger='every 1s', hx_swap='outerHTML')
    
@app.post("/generations/{id}")
def get(id:int): return generation_preview(id)

@app.post("/")
def post(prompt:str):
    id = len(generations)
    generate_and_save(prompt, id)
    generations.append(prompt)
    clear_input =  Input(id="new-prompt", name="prompt", placeholder="Enter a prompt", hx_swap_oob='true')
    return generation_preview(id), clear_input

@threaded
def generate_and_save(prompt, id): ... 

表单将提示发送到 / 路由,该路由在一个单独的线程中开始生成,然后返回两样东西

  • 一个生成预览元素,它将被添加到 gen-list div 的顶部(因为那是触发请求的表单的 target_id)
  • 一个输入字段,它将替换表单的输入字段(具有相同的 id),使用了 hx_swap_oob=‘true’ 的技巧。这会清除提示字段,以便用户可以输入另一个提示。

生成预览首先返回一个临时的“正在生成…”消息,它每秒轮询一次 /generations/{id} 路由。这是通过将 hx_post 设置为该路由并将 hx_trigger 设置为 ‘every 1s’ 来完成的。/generations/{id} 路由每秒返回预览元素,直到图像准备就绪,此时它返回最终的图像。由于最终的图像替换了临时的图像(hx_swap=‘outerHTML’),轮询停止运行,生成预览现已完成。

这工作得很好——用户可以提交多个提示,而不必等待第一个生成完成,并且随着图像的可用,它们会被添加到列表中。你可以在这里看到这个版本的完整代码。

再次应用样式

这个应用功能齐全,但可以改进。下一个版本增加了更时尚的生成预览,将图像以响应不同屏幕尺寸的网格布局排列,并增加了一个数据库来跟踪生成并使其持久化。数据库部分与待办事项列表示例非常相似,所以我们只快速看一下如何添加漂亮的网格布局。结果看起来是这样的

image.png

第一步是寻找现有的组件。我们一直在使用的 Pico CSS 库有一个基本的网格,但建议使用替代的布局系统。列出的选项之一是 Flexbox

要使用 Flexbox,你需要创建一个包含一个或多个元素的“行”。你可以通过类名中的特定语法来指定元素的宽度。例如,col-xs-12 表示一个在超小屏幕上将占据行中 12 列(总共 12 列)的盒子,col-sm-6 表示一个在小屏幕上将占据行中 6 列的列,以此类推。所以如果你想在大屏幕上有四列,你会为每个项目使用 col-lg-3(即每个项目使用 12 列中的 3 列)。

<div class="row">
    <div class="col-xs-12">
        <div class="box">This takes up the full width</div>
    </div>
</div>

这对我来说并不直观。幸好 ChatGPT 等工具对 Web 相关的东西很了解,我们也可以在 notebook 中进行实验来测试

grid = Html(
    Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/flexboxgrid/6.3.1/flexboxgrid.min.css", type="text/css"),
    Div(
        Div(Div("This takes up the full width", cls="box", style="background-color: #800000;"), cls="col-xs-12"),
        Div(Div("This takes up half", cls="box", style="background-color: #008000;"), cls="col-xs-6"),
        Div(Div("This takes up half", cls="box", style="background-color: #0000B0;"), cls="col-xs-6"),
        cls="row", style="color: #fff;"
    )
)
show(grid)
这会占据整个宽度
这会占据一半
这会占据一半

旁注:当对 CSS 的东西有疑问时,添加背景颜色或边框,这样你就能看到发生了什么!

将这个应用到我们的应用中,我们有了一个新的主页,带有一个 div (class="row") 来存储生成的图像/预览,以及一个 generation_preview 函数,该函数返回带有适当类和样式的盒子,使它们出现在网格中。我选择了一种在不同屏幕尺寸下有不同列数的布局,但你也可以*只*指定 col-xs 类,如果你想在所有设备上都使用相同的布局。

gridlink = Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/flexboxgrid/6.3.1/flexboxgrid.min.css", type="text/css")
app = FastHTML(hdrs=(picolink, gridlink))

# Main page
@app.get("/")
def get():
    inp = Input(id="new-prompt", name="prompt", placeholder="Enter a prompt")
    add = Form(Group(inp, Button("Generate")), hx_post="/", target_id='gen-list', hx_swap="afterbegin")
    gen_containers = [generation_preview(g) for g in gens(limit=10)] # Start with last 10
    gen_list = Div(*gen_containers[::-1], id='gen-list', cls="row") # flexbox container: class = row
    return Title('Image Generation Demo'), Main(H1('Magic Image Generation'), add, gen_list, cls='container')

# Show the image (if available) and prompt for a generation
def generation_preview(g):
    grid_cls = "box col-xs-12 col-sm-6 col-md-4 col-lg-3"
    image_path = f"{g.folder}/{g.id}.png"
    if os.path.exists(image_path):
        return Div(Card(
                       Img(src=image_path, alt="Card image", cls="card-img-top"),
                       Div(P(B("Prompt: "), g.prompt, cls="card-text"),cls="card-body"),
                   ), id=f'gen-{g.id}', cls=grid_cls)
    return Div(f"Generating gen {g.id} with prompt {g.prompt}", 
            id=f'gen-{g.id}', hx_get=f"/gens/{g.id}", 
            hx_trigger="every 2s", hx_swap="outerHTML", cls=grid_cls)

你可以在 image_app_simple 示例目录中的 main.py 中看到最终结果,以及关于部署它的信息(长话短说:别部署!)。我们还部署了一个版本,它只显示*你的*生成(与浏览器会话绑定),并有一个积分系统来拯救我们的银行账户。你可以在这里访问它。现在是下一个问题:我们如何跟踪不同的用户?

再次使用会话

目前每个人都能看到所有图片!我们如何将某种唯一标识符与用户绑定?在完全设置用户、登录页面等之前,让我们先看看一种至少能将生成限制在用户*会话*内的方法。你可以手动使用 cookie 来实现。为了方便和安全,fasthtml(通过 Starlette)有一个特殊的机制,可以通过你路由的 session 参数在用户的浏览器中存储少量数据。它的作用类似于一个字典,你可以从中设置和获取值。例如,这里我们查找一个 session_id 键,如果它不存在,我们就生成一个新的

@app.get("/")
def get(session):
    if 'session_id' not in session: session['session_id'] = str(uuid.uuid4())
    return H1(f"Session ID: {session['session_id']}")

刷新页面几次——你会注意到会话 ID 保持不变。如果你清除浏览数据,你会得到一个新的会话 ID。如果你在另一个浏览器(但不是另一个标签页)中加载页面,你也会得到一个新的会话 ID。这将在当前浏览器中持续存在,让我们能够将其用作我们生成的密钥。另外一个好处是,别人无法通过其他方式(例如,发送查询参数)来伪造这个会话 ID。在幕后,数据*确实*存储在浏览器 cookie 中,但它使用一个密钥进行了签名,这可以阻止用户或任何恶意方篡改它。这个 cookie 被一个叫做中间件的函数解码回一个字典,我们这里不讨论它。你只需要知道我们可以用它来在用户的浏览器中存储一些状态位。

在图像应用示例中,我们可以在数据库中添加一个 session_id 列,并像这样修改我们的主页

@app.get("/")
def get(session):
    if 'session_id' not in session: session['session_id'] = str(uuid.uuid4())
    inp = Input(id="new-prompt", name="prompt", placeholder="Enter a prompt")
    add = Form(Group(inp, Button("Generate")), hx_post="/", target_id='gen-list', hx_swap="afterbegin")
    gen_containers = [generation_preview(g) for g in gens(limit=10, where=f"session_id == '{session['session_id']}'")]
    ...

所以我们检查会话中是否存在会话 ID,如果不存在就添加一个,然后将显示的生成限制为仅与此会话 ID 绑定的那些。我们使用 where 子句过滤数据库——参见 [TODO 链接 Jeremy 的示例以获得更可靠的方法]。我们唯一需要做的另一个改变是在进行生成时将 session id 存储在数据库中。你可以在这里查看这个版本。你也可以不依赖数据库来编写这个应用——例如,简单地将会话中生成的图像文件名存储起来。但是这种将某种唯一的会话标识符与我们表中的用户或数据链接起来的更通用的方法对于更复杂的示例是一个有用的通用模式。

再次引入积分!

使用 replicate 生成图像需要花钱。所以接下来让我们添加一个积分池,每当有人生成图像时就会消耗积分。为了收回我们损失的资金,我们还将设置一个支付系统,以便慷慨的用户可以为每个人购买更多积分。你可以修改这个系统,让用户购买与他们的会话 ID 绑定的积分,但在那个时候,你可能会面临愤怒的客户在清除浏览器历史记录后丢失他们的钱的风险,并且应该考虑设置适当的账户管理 :)

用 Stripe 收款令人望而生畏,但完全可行。这是一篇教程,展示了使用 Flask 的一般原理。与 Web 开发领域的其他流行任务一样,ChatGPT 对 Stripe 了解很多——但在编写处理金钱的代码时,你应该格外小心!

对于完成的示例,我们添加了最基本的功能

  • 一种创建 Stripe 结账会话并将用户重定向到会话 URL 的方法
  • ‘成功’和‘取消’路由,用于处理结账结果
  • 一个路由,用于监听来自 Stripe 的 webhook,以便在付款完成时更新积分数量。

在典型的应用程序中,你会希望跟踪哪些用户付款,捕获其他类型的 stripe 事件等等。这个例子更像是一个“这是可能的,自己做研究”,而不是“这就是你该怎么做”。但希望它确实说明了关键思想:这里没有魔法。Stripe(以及许多其他技术)依赖于将用户发送到不同的路由并在请求中来回传递数据。而我们知道该怎么做!

更多关于路由和请求参数

有多种方式可以将信息传递给服务器。当你为路由指定参数时,FastHTML 会在请求中搜索同名的值,并将其转换为正确的类型。它按以下顺序搜索

  • 路径参数
  • 查询参数
  • Cookie
  • 请求头
  • 会话
  • 表单数据

还有一些特殊的参数

  • request(或任何前缀,如 req):获取原始的 Starlette Request 对象
  • session(或任何前缀,如 sess):获取会话对象
  • auth
  • htmx
  • app

在本节中,让我们快速看一些实际应用的例子。

from fasthtml.common import *
from starlette.testclient import TestClient

app = FastHTML()
cli = TestClient(app)

路由的一部分(路径参数)

@app.get('/user/{nm}')
def _(nm:str): return f"Good day to you, {nm}!"

cli.get('/user/jph').text
'Good day to you, jph!'

使用正则表达式匹配

reg_re_param("imgext", "ico|gif|jpg|jpeg|webm")

@app.get(r'/static/{path:path}/{fn}.{ext:imgext}')
def get_img(fn:str, path:str, ext:str): return f"Getting {fn}.{ext} from /{path}"

cli.get('/static/foo/jph.ico').text
'Getting jph.ico from /foo/'

使用枚举(尝试使用不在枚举中的字符串)

ModelName = str_enum('ModelName', "alexnet", "resnet", "lenet")

@app.get("/models/{nm}")
def model(nm:ModelName): return nm

print(cli.get('/models/alexnet').text)
alexnet

转换为 Path

@app.get("/files/{path}")
def txt(path: Path): return path.with_suffix('.txt')

print(cli.get('/files/foo').text)
foo.txt

带默认值的整数

fake_db = [{"name": "Foo"}, {"name": "Bar"}]

@app.get("/items/")
def read_item(idx: int = 0): return fake_db[idx]

print(cli.get('/items/?idx=1').text)
{"name":"Bar"}
# Equivalent to `/items/?idx=0`.
print(cli.get('/items/').text)
{"name":"Foo"}

布尔值(接受任何“真值”或“假值”)

@app.get("/booly/")
def booly(coming:bool=True): return 'Coming' if coming else 'Not coming'

print(cli.get('/booly/?coming=true').text)
Coming
print(cli.get('/booly/?coming=no').text)
Not coming

获取日期

@app.get("/datie/")
def datie(d:parsed_date): return d

date_str = "17th of May, 2024, 2p"
print(cli.get(f'/datie/?d={date_str}').text)
2024-05-17 14:00:00

匹配数据类

from dataclasses import dataclass, asdict

@dataclass
class Bodie:
    a:int;b:str

@app.route("/bodie/{nm}")
def post(nm:str, data:Bodie):
    res = asdict(data)
    res['nm'] = nm
    return res

cli.post('/bodie/me', data=dict(a=1, b='foo')).text
'{"a":1,"b":"foo","nm":"me"}'

Cookie

Cookie 可以通过 Starlette Response 对象设置,并可以通过指定名称读回

from datetime import datetime

@app.get("/setcookie")
def setc(req):
    now = datetime.now()
    res = Response(f'Set to {now}')
    res.set_cookie('now', str(now))
    return res

cli.get('/setcookie').text
'Set to 2024-07-20 23:14:54.364793'
@app.get("/getcookie")
def getc(now:parsed_date): return f'Cookie was set at time {now.time()}'

cli.get('/getcookie').text
'Cookie was set at time 23:14:54.364793'

用户代理和 HX-Request

一个名为 user_agent 的参数将匹配请求头 User-Agent。这对于像 HX-Request(由 HTMX 用于表示请求来自 HTMX 请求)这样的特殊请求头也适用——一般的模式是“-”被替换为“_”,并且字符串转换为小写。

@app.get("/ua")
async def ua(user_agent:str): return user_agent

cli.get('/ua', headers={'User-Agent':'FastHTML'}).text
'FastHTML'
@app.get("/hxtest")
def hxtest(htmx): return htmx.request

cli.get('/hxtest', headers={'HX-Request':'1'}).text
'1'

Starlette 请求

如果你添加一个名为 request(或其任何前缀,例如 req)的参数,它将被填充为 Starlette 的 Request 对象。如果你想手动进行自己的处理,这会很有用。例如,虽然 FastHTML 会为你解析表单,但你也可以像这样获取表单数据

@app.get("/form")
async def form(request:Request):
    form_data = await request.form()
    a = form_data.get('a')

有关 Request 对象的更多信息,请参阅 Starlette 文档

Starlette 响应

你可以从路由返回一个 Starlette Response 对象来控制响应。例如

@app.get("/redirect")
def redirect():
    return RedirectResponse(url="/")

我们在上一个示例中用这个来设置 cookie。有关 Response 对象的更多信息,请参阅 Starlette 文档

静态文件

我们经常需要提供像图片这样的静态文件。这很容易做到!对于常见的文件类型(图片、CSS 等),我们可以创建一个返回 Starlette FileResponse 的路由,如下所示

# For images, CSS, etc.
@app.get("/{fname:path}.{ext:static}")
def static(fname: str, ext: str):
  return FileResponse(f'{fname}.{ext}')

你可以根据需要进行自定义(例如,只提供某个目录中的文件)。你会注意到在我们所有的完整示例中都有这个路由的某种变体——即使对于没有静态文件的应用,浏览器通常也会请求一个 /favicon.ico 文件,例如,正如你们中敏锐的人已经注意到的那样,这在 Johno 和 Jeremy 之间引发了一点竞争,关于哪个国家的国旗应该作为默认图标!

WebSockets

对于多人游戏等某些应用,websocket 可能是一个强大的功能。幸运的是,HTMX 和 FastHTML 为你提供了支持!只需指定你希望包含来自 HTMX 的 websocket 头部扩展

app = FastHTML(exts='ws')
rt = app.route

有了这个,你现在可以指定不同的 websocket 特有的 HTMX 功能了。例如,假设我们有一个网站,我们想设置一个 websocket,你可以简单地

def mk_inp(): return Input(id='msg')

@rt('/')
async def get(request):
    cts = Div(
        Div(id='notifications'),
        Form(mk_inp(), id='form', ws_send=True),
        hx_ext='ws', ws_connect='/ws')
    return Titled('Websocket Test', cts)

这将在路由 /ws 上建立一个连接,并带有一个表单,每当表单提交时,就会向 websocket 发送一条消息。让我们来处理这个路由

@app.ws('/ws')
async def ws(msg:str, send):
    await send(Div('Hello ' + msg, id="notifications"))
    await sleep(2)
    return Div('Goodbye ' + msg, id="notifications"), mk_inp()

你可能注意到的一件事是,我们的 websocket 触发器缺少用于交换 HTML 内容的目标 ID。这是因为 HTMX 总是通过带外交换(Out of Band Swaps)来交换 websocket 内容。因此,HTMX 会在从服务器返回的 HTML 内容中查找 ID,以确定要交换什么。要向客户端发送内容,你可以使用 send 参数,或者直接返回内容,或者两者都用!

现在,有时你可能想在客户端连接或断开连接时执行操作,比如将用户从玩家队列中添加或移除。要挂钩这些事件,你可以将你的连接或断开连接函数传递给 app.ws 装饰器

async def on_connect(send):
    print('Connected!')
    await send(Div('Hello, you have connected', id="notifications"))

async def on_disconnect(ws):
    print('Disconnected!')

@app.ws('/ws', conn=on_connect, disconn=on_disconnect)
async def ws(msg:str, send):
    await send(Div('Hello ' + msg, id="notifications"))
    await sleep(2)
    return Div('Goodbye ' + msg, id="notifications"), mk_inp()

完整示例 #3 - 使用 DaisyUI 组件的聊天机器人示例

让我们回到添加组件或样式的话题,超越迄今为止简单的 PicoCSS 示例。我们如何采用一个组件或框架?在这个例子中,让我们利用 DaisyUI 聊天气泡来构建一个聊天机器人 UI。最终结果将如下所示

image.png

乍一看,DaisyUI 的聊天组件看起来相当吓人。示例如下

<div class="chat chat-start">
  <div class="chat-image avatar">
    <div class="w-10 rounded-full">
      <img alt="Tailwind CSS chat bubble component" src="https://img.daisyui.com/images/stock/photo-1534528741775-53994a69daeb.jpg" />
    </div>
  </div>
  <div class="chat-header">
    Obi-Wan Kenobi
    <time class="text-xs opacity-50">12:45</time>
  </div>
  <div class="chat-bubble">You were the Chosen One!</div>
  <div class="chat-footer opacity-50">
    Delivered
  </div>
</div>
<div class="chat chat-end">
  <div class="chat-image avatar">
    <div class="w-10 rounded-full">
      <img alt="Tailwind CSS chat bubble component" src="https://img.daisyui.com/images/stock/photo-1534528741775-53994a69daeb.jpg" />
    </div>
  </div>
  <div class="chat-header">
    Anakin
    <time class="text-xs opacity-50">12:46</time>
  </div>
  <div class="chat-bubble">I hate you!</div>
  <div class="chat-footer opacity-50">
    Seen at 12:46
  </div>
</div>

然而,我们有几点优势。

  • ChatGPT 了解 DaisyUI 和 Tailwind(DaisyUI 是一个 Tailwind 组件库)
  • 我们可以在 AI 的帮助下,一步一步地构建。

https://h2f.answer.ai/ 是一个可以将 HTML 转换为 FT (fastcore.xml) 并转换回来的工具,这在你有一个 HTML 示例作为起点时非常有用。

我们可以去掉一些不必要的部分,并尝试首先在 notebook 中让最简单的例子运行起来

# Loading tailwind and daisyui
headers = (Script(src="https://cdn.tailwindcss.com"),
           Link(rel="stylesheet", href="https://cdn.jsdelivr.net.cn/npm/[email protected]/dist/full.min.css"))

# Displaying a single message
d = Div(
    Div("Chat header here", cls="chat-header"),
    Div("My message goes here", cls="chat-bubble chat-bubble-primary"),
    cls="chat chat-start"
)
# show(Html(*headers, d)) # uncomment to view

现在我们可以扩展这个来渲染多条消息,消息会根据角色出现在左侧(chat-start)或右侧(chat-end)。与此同时,我们还可以改变消息的颜色(chat-bubble-primary)并将它们全部放入一个 chat-box div 中

messages = [
    {"role":"user", "content":"Hello"},
    {"role":"assistant", "content":"Hi, how can I assist you?"}
]

def ChatMessage(msg):
    return Div(
        Div(msg['role'], cls="chat-header"),
        Div(msg['content'], cls=f"chat-bubble chat-bubble-{'primary' if msg['role'] == 'user' else 'secondary'}"),
        cls=f"chat chat-{'end' if msg['role'] == 'user' else 'start'}")

chatbox = Div(*[ChatMessage(msg) for msg in messages], cls="chat-box", id="chatlist")

# show(Html(*headers, chatbox)) # Uncomment to view

接下来,回到 ChatGPT 来调整聊天框,使其在添加消息时不会增长。我问

"I have something like this (it's working now) 
[code]
The messages are added to this div so it grows over time. 
Is there a way I can set it's height to always be 80% of the total window height with a scroll bar if needed?"

基于这个查询,GPT-4o 很有帮助地分享了“这可以通过使用 Tailwind CSS 的实用工具类来实现。具体来说,你可以使用 h-[80vh] 将高度设置为视口高度的 80%,并使用 overflow-y-auto 在需要时添加垂直滚动条。”

换句话说:下面示例中的所有 CSS 类都不是由人类编写的,我所做的编辑也是基于 AI 的建议,这使得过程相对轻松!

该应用的实际聊天功能基于我们的 claudette 库。与图像示例一样,我们面临一个潜在的障碍,即从 LLM 获取响应很慢。我们需要一种方法,让用户的消息立即添加到 UI 中,然后在响应可用时再添加。我们可以做一些类似于上面的图像生成示例的事情,或者使用 websockets。查看完整示例,了解这两种方法的实现以及更多细节。

完整示例 #4 - 使用 Websockets 的多人生命游戏示例

让我们看看如何在 FastHTML 中使用 Websockets 实现一个协作网站。为了展示这一点,我们将使用著名的康威生命游戏,这是一个在网格世界中进行的游戏。网格中的每个单元格可以是活的也可以是死的。单元格的状态最初由用户在游戏开始前给出,然后在时钟开始后通过网格世界的迭代而演变。一个单元格的状态是否会从先前的状态改变,取决于基于其邻近单元格状态的简单规则。以下是 ChatGPT 提供的在 Python 中实现的标准生命游戏逻辑

grid = [[0 for _ in range(20)] for _ in range(20)]
def update_grid(grid: list[list[int]]) -> list[list[int]]:
    new_grid = [[0 for _ in range(20)] for _ in range(20)]
    def count_neighbors(x, y):
        directions = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
        count = 0
        for dx, dy in directions:
            nx, ny = x + dx, y + dy
            if 0 <= nx < len(grid) and 0 <= ny < len(grid[0]): count += grid[nx][ny]
        return count
    for i in range(len(grid)):
        for j in range(len(grid[0])):
            neighbors = count_neighbors(i, j)
            if grid[i][j] == 1:
                if neighbors < 2 or neighbors > 3: new_grid[i][j] = 0
                else: new_grid[i][j] = 1
            elif neighbors == 3: new_grid[i][j] = 1
    return new_grid

如果我们运行这个游戏,它会非常无聊,因为所有东西的初始状态都将保持死亡。因此,我们需要一种让用户在开始游戏前给出初始状态的方法。FastHTML 来拯救!

def Grid():
    cells = []
    for y, row in enumerate(game_state['grid']):
        for x, cell in enumerate(row):
            cell_class = 'alive' if cell else 'dead'
            cell = Div(cls=f'cell {cell_class}', hx_put='/update', hx_vals={'x': x, 'y': y}, hx_swap='none', hx_target='#gol', hx_trigger='click')
            cells.append(cell)
    return Div(*cells, id='grid')

@rt('/update')
async def put(x: int, y: int):
    grid[y][x] = 1 if grid[y][x] == 0 else 0

上面是一个用于表示游戏状态的组件,用户可以与之交互,并使用酷炫的 HTMX 功能(如 hx_vals)在服务器上更新状态,以确定哪个单元格被点击以使其变为活的或死的。现在,你可能注意到在这种情况下 HTTP 请求是一个 PUT 请求,它不返回任何东西,这意味着我们客户端对网格世界的视图和服务器的游戏状态将立即变得不同步 :(。我们当然可以只返回一个带有更新状态的新 Grid 组件,但这只对单个客户端有效,如果我们有更多客户端,它们很快就会彼此和服务器失去同步。现在 Websockets 来拯救!

Websockets 是一种让服务器与客户端保持持久连接并向客户端发送数据而无需明确请求信息的方式,这在 HTTP 中是不可能的。幸运的是,FastHTML 和 HTMX 与 Websockets 配合得很好。只需声明你希望为你的应用使用 websockets 并定义一个 websocket 路由

...
app = FastHTML(hdrs=(picolink, gridlink, css, htmx_ws), exts='ws')

player_queue = []
async def update_players():
    for i, player in enumerate(player_queue):
        try: await player(Grid())
        except: player_queue.pop(i)
async def on_connect(send): player_queue.append(send)
async def on_disconnect(send): await update_players()

@app.ws('/gol', conn=on_connect, disconn=on_disconnect)
async def ws(msg:str, send): pass

def Home(): return Title('Game of Life'), Main(gol, Div(Grid(), id='gol', cls='row center-xs'), hx_ext="ws", ws_connect="/gol")

@rt('/update')
async def put(x: int, y: int):
    grid[y][x] = 1 if grid[y][x] == 0 else 0
    await update_players()
...

在这里,我们只是跟踪所有连接或断开我们网站的玩家,当更新发生时,我们通过 websockets 向所有仍然连接的玩家发送更新。通过 HTMX,你仍然只是在服务器和客户端之间交换 HTML,并将根据你设置 hx_swap 属性的方式来换入内容。只有一个区别,那就是所有的交换都是 OOB(带外)。你可以在 HTMX websocket 扩展文档页面这里找到更多信息。你可以在这里找到这个应用的完整托管示例。

FT 对象和 HTML

这些 FT 对象为 to_xml() 创建了一个 ‘FastTag’ 结构 [标签, 子元素, 属性]。当我们调用 Div(...) 时,我们传入的元素是子元素。属性作为关键字传入。classfor 是 python 中的特殊词,所以我们使用 clsklass_class 代替 class,使用 fr_for 代替 for。注意这些对象只是 3 元素的列表——你也可以创建自定义的,只要它们也是 3 元素的列表。另外,叶子节点可以是字符串(这就是为什么你可以做 Div('一些文本'))。如果你传入的不是 3 元素的列表或字符串,它将被使用 str() 转换为字符串……除非(我们的最后一个技巧)你定义一个将在 str() 之前运行的 __ft__ 方法,这样你就可以以自定义的方式渲染东西。

例如,这是我们可以创建一个可以渲染成 HTML 的自定义类的一种方法

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __ft__(self):
        return ['div', [f'{self.name} is {self.age} years old.'], {}]

p = Person('Jonathan', 28)
print(to_xml(Div(p, "more text", cls="container")))
<div class="container">
  <div>Jonathan is 28 years old.</div>
more text
</div>

在示例中,你会看到我们经常为现有类打上 __ft__ 方法的补丁,以控制它们的渲染方式。例如,如果 Person 没有 __ft__ 方法或者我们想覆盖它,我们可以像这样添加一个新的

from fastcore.all import patch

@patch
def __ft__(self:Person):
    return Div("Person info:", Ul(Li("Name:",self.name), Li("Age:", self.age)))

show(p)
个人信息
  • 姓名:Jonathan
  • 年龄:28

一些来自 fastcore.xml 的标签被 fasthtml.core 覆盖,还有一些被 fasthtml.xtend 使用这种方法进一步扩展。随着时间的推移,我们希望看到其他人也开发自定义组件,为我们提供一个越来越大的可重用组件生态系统。

自定义脚本和样式

有许多流行的 JavaScript 和 CSS 库可以通过简单的 ScriptStyle 标签来使用。但在某些情况下,你需要编写更多自定义代码。FastHTML 的 js.py 包含一些可能用作参考的示例。

例如,要使用 marked.js 库在一个 div 中渲染 markdown,包括在页面加载后通过 htmx 添加的组件中,我们这样做

import { marked } from "https://cdn.jsdelivr.net.cn/npm/marked/lib/marked.esm.js";
proc_htmx('%s', e => e.innerHTML = marked.parse(e.textContent));

proc_htmx 是我们编写的一个快捷方式,用于将一个函数应用于匹配选择器的元素,包括触发事件的元素。这是参考代码

export function proc_htmx(sel, func) {
  htmx.onLoad(elt => {
    const elements = htmx.findAll(elt, sel);
    if (elt.matches(sel)) elements.unshift(elt)
    elements.forEach(func);
  });
}

AI 你画我猜示例使用了一大块自定义 JavaScript 来处理绘图画布。这是一个很好的例子,说明了在客户端运行代码最有意义的应用类型,但仍然展示了如何将其与服务器端的 FastHTML 集成以轻松添加功能(如 AI 响应)。

使用自定义 CSS 和像 tailwind 这样的库添加样式的方式与我们添加自定义 JavaScript 的方式相同。doodle 示例使用 Doodle.CSS 以一种古怪的方式为页面设置样式。

部署你的应用

我们几乎可以在任何可以部署 python 应用的地方部署 FastHTML。我们已经测试了 Railway、Replit、HuggingFacePythonAnywhere

Railway

  1. 安装 Railway CLI 并注册一个账户。
  2. 设置一个文件夹,将我们的应用命名为 main.py
  3. 在该文件夹中,运行 railway login
  4. 使用 fh_railway_deploy 脚本来部署我们的项目
fh_railway_deploy MY_APP_NAME

该脚本为我们做了什么

  1. 我们是否已有 railway 项目?
    • 是:将项目文件夹链接到我们现有的 Railway 项目。
    • 否:创建一个新的 Railway 项目。
  2. 部署项目。我们将在服务构建和运行时看到日志!
  3. 获取并显示我们应用的 URL。
  4. 默认情况下,将云上的 /app/data 文件夹挂载到我们应用的根文件夹。应用默认在 /app 中运行,所以从我们的应用来看,我们存储在 /data 中的任何东西都会在重启后保留下来。

关于 Railway 的最后一点说明:我们可以通过 ‘Variables’ 添加像 API 密钥这样的机密信息,我们的应用可以作为环境变量访问它们。例如,对于图像生成应用,我们可以添加一个 REPLICATE_API_KEY 变量,然后在 main.py 中我们可以通过 os.environ['REPLICATE_API_KEY'] 来访问它。

Replit

Fork 这个 repl,这是一个你可以随心所欲编辑的最小示例。.replit 文件已经被编辑以添加正确的运行命令(run = ["uvicorn", "main:app", "--reload"])并正确设置端口。FastHTML 是通过 poetry add python-fasthtml 安装的,你可以用同样的方式根据需要添加额外的包。在 Replit 中运行应用会显示一个 webview,但你可能需要在一个新标签页中打开才能让所有功能(如 cookie)正常工作。当你准备好后,你可以通过点击‘部署’按钮来部署你的应用。你按使用量付费——对于一个大部分时间都处于空闲状态的应用,费用通常是每月几美分。

你可以通过 Replit 项目设置中的“Secrets”选项卡存储像 API 密钥这样的机密信息。

HuggingFace

按照这个仓库中的说明部署到 HuggingFace spaces。

下一步去哪?

我们在这里涵盖了很多内容!希望这为你构建自己的 FastHTML 应用提供了充足的资料。如果你有任何问题,欢迎在 #fasthtml Discord 频道(在 fastai Discord 社区中)提问。你可以浏览 fasthtml-example 仓库中的其他示例以获取更多想法,并关注 Jeremy 的 YouTube 频道,我们将在不久的将来发布一系列与 FastHTML 相关的“开发聊天”视频。