从 Python 函数构建 HTML 组件
python
from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity = "all"
这篇文章也可以称为"或者如何在 Python 中进行 React"或"HTML 作为状态的函数"。
大多数人使用像 jinja2 这样的模板库来渲染 HTML。我认为这可能是在生产中实现这一点的最佳方法。然而,对于非常简单/内部/概念验证的应用程序,我想直接从 Python 函数生成 HTML 以避免需要额外的文件。
我尝试使用 f 字符串来做到这一点,但它很快就会变得混乱。我最近发现了一种使用 lxml 渲染 HTML 的好方法。一个很好的副作用是,整体架构类似于 React,其中函数变成了 UI 组件。同时,它允许轻松地仅渲染单个组件。当与 HTMX 一起使用时,这会特别有用。
一个基本组件,渲染字符串
lxml 已经附带了一个类和一些实用程序来生成 HTML 元素并将它们序列化为字符串。
这将生成以下 HTML(在现实场景中,您可以删除 pretty_print=True
参数):
python
from lxml.html import HtmlElement
from lxml.html import tostring
from lxml.html.builder import E as e
def s(tree: HtmlElement) -> str:
"""
Serialize LXML tree to unicode string. Using DOCTYPE html.
"""
return tostring(tree, encoding="unicode", doctype="<!DOCTYPE html>", pretty_print=True)
def head(title: str):
return e.head(
e.meta(charset="utf-8"),
e.meta(name="viewport", content="width=device-width, initial-scale=1"),
e.title(title),
)
tree = head("Hello")
print(s(tree))
xml
<!DOCTYPE html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Hello</title></head>
我们现在有了一个从 Python 对象生成的简单但有效的 HTML。
将 Python 对象转换为 HTML
通常,您将拥有某种状态或上下文,并根据该上下文呈现 HTML。我们可以使用任何 Python 对象来生成 HTML。在这里,我们将元素列表转换为 <ul>
元素。
python
from lxml.html import HtmlElement
from lxml.html import tostring
from lxml.html.builder import E as e
def s(tree: HtmlElement) -> str:
"""
Serialize LXML tree to unicode string. Using DOCTYPE html.
"""
return tostring(tree, encoding="unicode", doctype="<!DOCTYPE html>", pretty_print=True)
def list_items(items: list[str]):
return e.ul(*[e.li(item) for item in items])
tree = list_items(["foo", "bar", "baz"])
print(s(tree))
xml
<!DOCTYPE html>
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
创建我们的第一个视图
现在我们可以使用 <head>
元素创建一个索引视图,该元素分隔在不同的函数中,以及一个从 Python 对象生成的列表。在这里,我正在创建一个 FastAPI 应用程序来呈现内容。
python
import asyncio
import random
import uvicorn
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from lxml.html import HtmlElement
from lxml.html import tostring
from lxml.html.builder import E as e
app = FastAPI()
def s(tree: HtmlElement) -> str:
"""
Serialize LXML tree to unicode string. Using DOCTYPE html.
"""
return tostring(tree, encoding="unicode", doctype="<!DOCTYPE html>")
def head(title: str):
return e.head(
e.meta(charset="utf-8"),
e.meta(name="viewport", content="width=device-width, initial-scale=1"),
e.title(title),
)
def list_items(items: list[str]):
return e.ul(*[e.li(item) for item in items])
def index(items: list[str]):
return e.html(
# generate <head> element by calling a python function
head("Home"),
e.body(
e.h1("Hello, world!"),
list_items(items),
),
)
@app.get("/", response_class=HTMLResponse)
def get():
items = [str(random.randint(0, 100)) for _ in range(10)]
tree = index(items)
html = s(tree)
return html
# if __name__ == "__main__":
# # run app with uvicorn
# uvicorn.run(
# f'{__file__.split("/")[-1].replace(".py", "")}:app',
# host="127.0.0.1",
# port=8000,
# reload=True,
# workers=1,
# )
if __name__ == "__main__":
config = uvicorn.Config(app)
server = uvicorn.Server(config)
await server.serve()
vbnet
INFO: Started server process [10016]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: 127.0.0.1:55395 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:55395 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [10016]
安装 FastAPI, uvicorn 和 lxml 后,您可以运行您的应用程序(将 file.py
更改为您的 Python 脚本的名称):
它看起来是这样的:
添加更多实用程序
lxml 附带了一些向元素添加属性的函数,但我决定编写自己的函数以获得更好的人体工程学效果。
python
# handle some Python / HTML keywords.
def replace_attr_name(name: str) -> str:
if name == "_class":
return "class"
elif name == "_for":
return "for"
return name
def ATTR(**kwargs):
# Use str() to convert values to string. This way we can set boolean
# attributes using True instead of "true".
return {replace_attr_name(k): str(v) for k, v in kwargs.items()}
有了这些函数,我们现在可以构建如下元素:
python
e.html(
ATTR(lang="en"),
head("Hello"),
e.body(
# we use `class` because `class` is a Python keyword
e.main(ATTR(id="main", _class="container")),
),
)
添加更多组件和状态
我们已准备好所有基本部件。我们可以开始构建更多组件并将它们组合在一起。在此示例中,我将生成一个 state
字典并将其传递给 1 2 ,而不是传递所有元素参数。我还将向 <head>
添加 picocss 以进行样式设置。我将展示所有代码以及一些注释,然后我们将查看特定部分:
python
import random
# import MappingProxyType for "frozen dict"
from types import MappingProxyType
import uvicorn
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from lxml.html import HtmlElement
from lxml.html import tostring
from lxml.html.builder import E as e
app = FastAPI()
# Type alias. State can be a dict or a MappingProxyType.
State = dict | MappingProxyType
def replace_attr_name(name: str) -> str:
if name == "_class":
return "class"
elif name == "_for":
return "for"
return name
def ATTR(**kwargs):
# Use str() to convert values to string. This way we can set boolean
# attributes using True instead of "true".
return {replace_attr_name(k): str(v) for k, v in kwargs.items()}
def s(tree: HtmlElement) -> str:
"""
Serialize LXML tree to unicode string. Using DOCTYPE html.
"""
return tostring(tree, encoding="unicode", doctype="<!DOCTYPE html>")
def base(*children: HtmlElement, state: State):
return e.html(
ATTR(lang="en"),
head(state),
e.body(
e.main(ATTR(id="main", _class="container"), *children),
),
)
def head(state: State):
return e.head(
e.meta(charset="utf-8"),
e.title(state.get("title", "Home")),
e.meta(name="viewport", content="width=device-width, initial-scale=1"),
e.meta(name="description", content="Welcome."),
e.meta(name="author", content="@polyrand"),
e.link(
rel="stylesheet",
href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css",
),
)
def login_form(state: State):
return e.article(
ATTR(**{"aria-label": "log-in form"}),
e.p(
e.strong(ATTR(style="color: red"), "Wrong credentials!")
if state.get("error")
else f"{state.get('user', 'You')} will receive an email with a link to log in."
),
e.form(
e.label("Email", _for="email"),
e.input(
ATTR(
placeholder="Your email",
type="email",
name="email",
required=True,
)
),
e.button("Log In"),
action="/login",
method="post",
),
)
def view_index(state: State):
return base(
e.section(
e.h1("Page built using lxml"),
e.p("This is some text."),
),
list_items(state),
login_form(state),
state=state,
)
def list_items(state: State):
return e.ul(*[e.li(item) for item in state["items"]])
@app.get("/", response_class=HTMLResponse)
def idx(error: bool = False):
items = [str(random.randint(0, 100)) for _ in range(4)]
state = {
"title": "Some title",
"items": items,
"user": "@polyrand",
}
if error:
state["error"] = True
tree = view_index(MappingProxyType(state))
html = s(tree)
return html
# if __name__ == "__main__":
# uvicorn.run(
# f'{__file__.split("/")[-1].replace(".py", "")}:app',
# host="127.0.0.1",
# port=8000,
# reload=True,
# workers=1,
# )
if __name__ == "__main__":
config = uvicorn.Config(app)
server = uvicorn.Server(config)
await server.serve()
我们来看一些部分。
return e.article( ATTR(**{"aria-label": "log-in form"}), e.p( e.strong(ATTR(style="color: red"), "Wrong credentials!") if state.get("error") else f"{state.get('user', 'You')} will receive an email with a link to log in." ),
这里我们在元素上设置属性 aria-label="log-in form"
。然后,我们将根据状态渲染文本(参见下面的屏幕截图)。
return base( e.section( e.h1("Page built using lxml"), e.p("This is some text."), ), list_items(state), login_form(state), state=state, )
在这里,我们渲染基本模板并传递一些子对象。请注意每个元素都是一个 Python 函数( list_items
和 login
)。
tree = view_index(MappingProxyType(state)) html = s(tree)
我们使用此代码来呈现 HTML 字符串。最好的部分是我们可以使用以下代码只渲染登录表单:
tree = login_form(MappingProxyType(state)) html = s(tree)
现在我们可以返回部分 HTML 块。
这是页面现在的样子。每次刷新时数字都会改变:
如果我们添加 /?error=1
作为 URL 参数,状态字典将包含 "error": True
,它应该显示不同的消息 3 :
转义
构建 HTML 时,将用户生成的数据传递到模板时应小心。您可以使用 MarkupSafe 转义所需的 HTML 值。您可以修改 lxml.html.builder.E
类来转义所有字符串值 4 。 Jinja2 does not escape by default 默认情况下不会转义。
架构
此时,您可以使用不同的方法来构建 Python-HTML 组件。例如,您将所有组件函数放在一个类中。然后,该类可以将 state
字典作为属性保存。这样,您就不必传递它。这允许将所有 UI 函数保留在单独的命名空间中,同时仍然能够将所有代码保留在单个文件 5 中。我使用这种方法构建了相同的应用程序;这是源代码here。
或者您可能希望每个函数显式列出所有必需的参数。尽管这可能会变成"Prop Drilling",正如 React 世界中所说的那样。
与 Jinja2 的性能比较
我运行了一个简单的基准测试benchmark,它根据 Python 列表生成 HTML 列表。使用 jinja2
比使用 LXML 更快,尽管与应用程序的其他部分相比,性能差异可能并不那么重要。由于 jinja2
会在第一次解析模板后对其进行缓存,因此我还对一个函数进行了基准测试,该函数在每次调用时都会重新创建模板(这就是 LXML 方法所做的)。然后我还创建了一个(使用起来不太方便)函数,它使用 LXML 生成元素,但它会在首次创建后缓存每个生成的元素。
结果如下:
fallback Jinja 16.4 µs ± 51.7 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
Jinja recreate template 353 µs ± 4.41 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
LXML 180 µs ± 744 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
LXML cached builder 22.2 µs ± 220 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
概括:
技术 | 平均执行时间(微秒) |
---|---|
Jinja2 | 16.4 |
Jinja2 recreate | 353 |
LXML | 180 |
LXML cached | 22.2 |
jinja2
无疑更快。
其他想法、想法和链接
这篇文章主要是分享一种我用来从 Python 生成 HTML 的简单方法。对于简单的应用程序,我比将 jinja2
模板作为字符串更喜欢它,无论是在我的脚本中还是作为单独的文件 5 。但由于我们使用的是 LXML,它已经为我们构建了一个对象树,因此我们可以更花哨地创建一些仅呈现修改后的元素的树差异函数,在将某些元素序列化之前使用对象树对某些元素进行后处理:字符串等
生成 HTML 的 LXML / Python 函数方法使创建模板片段template fragments.变得简单。
我见过(但没有尝试过)的一些替代工具可以做类似的事情:
- 来自 Shiny 团队的 py-htmltools 看起来有点缺乏文档。
- domonic我第一次发现它时感觉很酷。我觉得它的功能超出了我的需要。
- 您可以将字典包装在 MappingProxyType 中以使其不可变[↩︎]ricardoanderegg.com/posts/pytho...%25E3%2580%2582 "https://ricardoanderegg.com/posts/python-build-html-components-lxml/#fnref:1)%E3%80%82") ↩︎
- 这类似于渲染
jinaj2
模板时通常传递的上下文。 ↩︎ ↩︎ - 这是我喜欢只返回 HTML 的原因之一。我们可以在状态字典中存储很多东西来"声明式"生成 HTML,但随后我们只需将 HTML 发送到客户端即可。 与其他客户端方法相比,我们不必太担心状态的大小。另请参阅 HTMX HATEOAS 文章 the HTMX HATEOAS essay ↩︎ ↩︎
- 这是我构建的一个简单示例 。 ↩︎
- 请参阅单文件应用程序 Single file applications ↩︎ ↩︎ ↩︎ ↩︎
jupyter nbconvert --to markdown "从 Python 函数构建 HTML 组件.IPYNB"
UPDATE 2024-01-17 BY YULIKE