从 Python 函数构建 HTML 组件
从 Python 函数构建 HTML 组件
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
参数):
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))
<!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>
元素。
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))
<!DOCTYPE html>
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
创建我们的第一个视图
现在我们可以使用 <head>
元素创建一个索引视图,该元素分隔在不同的函数中,以及一个从 Python 对象生成的列表。在这里,我正在创建一个 FastAPI 应用程序来呈现内容。
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()
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 附带了一些向元素添加属性的函数,但我决定编写自己的函数以获得更好的人体工程学效果。
# 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()}
有了这些函数,我们现在可以构建如下元素:
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 以进行样式设置。我将展示所有代码以及一些注释,然后我们将查看特定部分:
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… ↩︎
- 这类似于渲染
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
转载自:https://juejin.cn/post/7326546769982472218