通过示例快速HTML

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

本教程通过构建示例应用程序提供了对FastHTML的另一种介绍。我们还展示了如何使用FastHTML基础知识来创建自定义Web应用程序。最后,本文档为LLM提供了最基本的背景,以将其转化为FastHTML助手。

让我们开始吧。

FastHTML基础

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

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

这是一个简单的FastHTML应用程序,返回“你好,世界”的消息:

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)
Some page
Some text, A link

如果那个 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 在笔记本中试用它:

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,在这种情况下你会直接获取元素)。有关创建自定义组件或将 HTML 渲染添加到现有 Python 对象的更多信息,请参见 FT objects and 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/gh/answerdotai/[email protected]/surreal.js"></script>
    <script src="https://cdn.jsdelivr.net/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 请求。这将向服务器发送一些数据,您应该在终端上看到响应“收到了一个 POST 或 PUT 请求”。

还有其他几种方法可以指定路线+方法 - 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可能不是你想象中的美丽网络应用的样子。CSS是为HTML样式化的首选语言。但我们并不想学习额外的语言,除非我们绝对需要!幸运的是,通过依赖他人的辛勤工作,使用现有的CSS库,有办法使网站看起来更加美观。我们最喜欢的库之一是 PicoCSS。一种常见的方法是在您的HTML头部中使用一个标签来添加CSS文件,如下所示:

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

为了方便,FastHTML 已经为您定义了一个 Pico 组件 picolink

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

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

picolink 还包括一个

由于我们通常希望在应用的所有页面上使用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
标签内:
返回元组

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

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

如果您想要 覆盖默认样式 或添加更多自定义CSS,您可以通过在头部添加

网页 -> 网络应用

显示内容很好,但是我们通常期望从自称为网络应用的东西中获得更多的交互性!因此,让我们添加几个不同的页面,并使用一个表单让用户将消息添加到列表中:

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()

我们重新渲染整个主页以显示新添加的消息。这很好,但现代网络应用程序通常不会重新渲染整个页面,它们只是更新页面的一部分。实际上,即使是非常复杂的应用程序也常常被实现为“单页应用”(SPAs)。这就是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}"

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

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

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

默认情况下,请求是由元素的“自然”事件触发的 - 在按钮(和大多数其他元素)情况下为点击。您还可以指定不同的触发器,以及各种修饰符 - 请参阅 HTMX docs 了解更多。

通过元素触发请求来修改或替换其他元素的这种模式是HTMX理念的关键部分。这需要一些时间适应,但一旦掌握,就会非常强大。

替换目标之外的元素

有时候,仅仅有一个目标是不够的,我们希望指定一些其他的元素进行更新或删除。在这些情况下,返回与要替换的元素匹配的 id 并且 hx_swap_oob='true' 也会替换那些元素。我们将在下一个例子中使用这个功能,以便在提交表单时清除输入字段。

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

规范的演示网页应用程序!一个TODO列表。我们建议从Jeremy的这个视频教程开始,而不是为这个教程创建又一个变体:

image.png

我们制作了多个版本的这个应用程序 - 除了视频中展示的版本,您还可以浏览这一系列随着复杂性增加的示例,这个 heavily-commented “惯用的”版本在这里,以及从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 设置为“每 1秒”来完成的。/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等对网络内容非常了解,我们还可以在笔记本中进行实验以测试一些内容:

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)

您可以在 main.pyimage_app_simple 示例目录中查看最终结果,以及有关如何部署的信息(简而言之,别这样做!)。我们还部署了一个只显示 您的 生成版本(与浏览器会话相关联),并具有一个信用系统来保护我们的银行账户。您可以在 这里 访问它。现在,下一问题是:我们如何跟踪不同的用户?

再次使用会话

目前每个人都能看到所有图片!我们如何将某种唯一标识符与用户绑定?在完全设置用户、登录页面等之前,让我们先看看如何至少将生成限制在用户的 session。你可以通过手动使用 cookies 来做到这一点。为了方便和安全,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 link Jeremy’s example for a more reliable way to do this]。我们需要进行的唯一其他更改是,在生成时将会话 ID 存储在数据库中。您可以在 这里 查看这个版本。您也可以选择完全不依赖数据库编写这个应用程序 - 例如,仅在会话中存储生成图像的文件名。但将某种唯一会话标识符链接到我们的用户或数据表中的数据的这种更一般的方法,对于更复杂的示例来说是一个有用的一般模式。

再次,谢谢!

使用replicate生成图像是需要花费金钱的。因此接下来让我们添加一个信用池,每当有人生成图像时,这些信用就会被消耗。为了弥补我们的损失,我们还将建立一个支付系统,以便慷慨的用户可以为大家购买更多的信用。您可以修改这部分,让用户购买与他们的会话ID相关联的信用,但在那时您有可能会面临愤怒的客户在清除浏览器历史后失去他们的钱,因此应该考虑建立适当的账户管理 :)

使用Stripe收款让人感到紧张,但实际上非常可行。这是一个教程,展示了使用Flask的一般原理。与网络开发领域的其他热门任务一样,ChatGPT 对Stripe了解很多——但在编写处理金钱的代码时,您应该特别小心!

对于完成示例,我们添加了基本的最小内容:

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

在一个典型的应用中,您会想要跟踪哪些用户进行支付,捕捉其他种类的Stripe事件等等。这个例子更像是“这是可能的,自己研究一下”,而不是“这是你怎么做的”。但希望它能说明关键思想:这里没有魔法。Stripe(和许多其他技术)依赖于将用户发送到不同的路由,并在请求中来回传递数据。我们知道如何做到这一点!

关于路由和请求参数的更多信息

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

  • 路径参数
  • 查询参数
  • 饼干
  • 标题
  • 会话
  • 表单数据

还有一些特殊参数

  • 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

强制转换为路径:

@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"}'

.cookies

可以通过 Starlette Response 对象设置 Cookies,并通过指定名称读取。

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-请求

参数 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 可以是一个强大的功能。幸运的是,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()

你可能注意到的一件事是我们用于交换HTML内容的websocket触发器缺少目标id。这是因为HTMX始终使用带有带外交换的websockets来交换内容。因此,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聊天气泡。最终结果将如下所示:

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组件库)
  • 我们可以在人工智能的帮助下,一点一滴地构建事物。

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

我们可以去掉一些不必要的部分,先尝试在笔记本中运行最简单的示例:

# Loading tailwind and daisyui
headers = (Script(src="https://cdn.tailwindcss.com"),
           Link(rel="stylesheet", href="https://cdn.jsdelivr.net/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?"

根据这个查询,GPT4o 友好地分享了“这可以通过使用 Tailwind CSS 实用类来实现。具体来说,您可以使用 h-[80vh] 将高度设置为视口高度的 80%,并使用 overflow-y-auto 在需要时添加垂直滚动条。”

换句话说:以下示例中的CSS类没有一个是由人类编写的,而我所做的任何编辑都是根据人工智能的建议进行的,这使得这一过程相对轻松!

应用程序的实际聊天功能基于我们的 claudette 库。与图像示例一样,我们面临一个潜在的问题,因为从LLM获取响应的速度很慢。我们需要一种方法,将用户消息立即添加到用户界面中,然后在响应可用时再添加响应。我们可以做一些类似于上面的图像生成示例,或者使用websockets。查看full example以获取两者的实现及更多详细信息。

完整示例 #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 请求,它不会返回任何内容,这意味着我们客户端对网格世界的视图和服务器的游戏状态会立即变得不同步 :(。当然,我们可以返回一个新的网格组件,带有更新的状态,但这只适用于单个客户端,如果我们有更多客户端,它们迅速就会与彼此和服务器不同步。现在 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”结构 [tag,children,attrs]。当我们调用 Div(...) 时,我们传入的元素是子元素。属性作为关键字传入。 classfor 是 python 中的特殊词,因此我们使用 clsklass_class 替代 class,使用 fr_for 替代 for。请注意,这些对象仅是 3 元素列表 - 只要它们也是 3 元素列表,您也可以创建自定义列表。或者,叶节点也可以是字符串(这就是您可以做 Div('some text') 的原因)。如果您传入的不是 3 元素列表或字符串,它将使用 str() 转换为字符串……除非(我们最后的技巧)您定义一个 __ft__ 方法,该方法将在 str() 之前运行,因此您可以以自定义方式渲染内容。

例如,这里有一种方法可以创建一个可以渲染为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)
Person info:
  • 姓名: 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/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 Pictionary example 使用了一大块自定义的JavaScript来处理绘图画布。这是一个很好地展示了在客户端运行代码最有意义的应用程序类型的例子,但仍然展示了如何将其与FastHTML在服务器端集成,以轻松添加功能(例如AI响应)。

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

部署您的应用

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

铁路

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

脚本为我们做了什么:

  1. 我们是否有现有的铁路项目?
    • 是:将项目文件夹链接到我们现有的铁路项目。
    • 否:创建一个新的铁路项目。
  2. 部署项目。我们将查看服务构建和运行时的日志!
  3. 获取并显示我们应用的 URL。
  4. 默认情况下,将云端的 /app/data 文件夹挂载到我们应用的根文件夹。应用默认在 /app 中运行,因此从我们的应用中存储在 /data 中的任何内容将在重启之间持续存在。

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

Replit

克隆 这个 repl 以获取一个你可以随意编辑的最小示例。 .replit 已经被编辑以添加正确的运行命令 (run = ["uvicorn", "main:app", "--reload"]) 并正确设置端口。 FastHTML 是通过 poetry add python-fasthtml 安装的,你可以按照同样的方式根据需要添加额外的包。 在 Replit 中运行应用程序会显示一个网页视图,但你可能需要在新标签页中打开,以便所有功能(例如 Cookie)正常工作。 当你准备好后,可以通过点击“部署”按钮来部署你的应用程序。 使用是收费的 - 对于一个大部分时间处于闲置状态的应用程序,费用通常是每月几分钱。

您可以通过Replit项目设置中的“Secrets”选项卡存储诸如API密钥之类的秘密。

HuggingFace

按照这个仓库中的说明在HuggingFace空间中部署。

接下来做什么?

我们在这里覆盖了很多内容!希望这能为您构建自己的 FastHTML 应用程序提供很多帮助。如果您有任何问题,请随时在 #fasthtml Discord 频道中询问(在 fastai Discord 社区中)。您可以浏览fasthtml-example repository中的其他示例以获取更多想法,并关注 Jeremy 的YouTube channel,我们将在不久的将来发布许多与 FastHTML 相关的“开发聊天”。