likes
comments
collection
share

从 Python 函数构建 HTML 组件

作者站长头像
站长
· 阅读数 3

从 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_itemslogin )。

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)

概括:

技术平均执行时间(微秒)
Jinja216.4
Jinja2 recreate353
LXML180
LXML cached22.2

jinja2 无疑更快。

其他想法、想法和链接

这篇文章主要是分享一种我用来从 Python 生成 HTML 的简单方法。对于简单的应用程序,我比将 jinja2 模板作为字符串更喜欢它,无论是在我的脚本中还是作为单独的文件 5 。但由于我们使用的是 LXML,它已经为我们构建了一个对象树,因此我们可以更花哨地创建一些仅呈现修改后的元素的树差异函数,在将某些元素序列化之前使用对象树对某些元素进行后处理:字符串等

生成 HTML 的 LXML / Python 函数方法使创建模板片段template fragments.变得简单。

我见过(但没有尝试过)的一些替代工具可以做类似的事情:

  • 来自 Shiny 团队的 py-htmltools 看起来有点缺乏文档。
  • domonic我第一次发现它时感觉很酷。我觉得它的功能超出了我的需要。

  1. 您可以将字典包装在 MappingProxyType 中以使其不可变[↩︎]ricardoanderegg.com/posts/pytho… ↩︎
  2. 这类似于渲染 jinaj2 模板时通常传递的上下文。 ↩︎ ↩︎
  3. 这是我喜欢只返回 HTML 的原因之一。我们可以在状态字典中存储很多东西来“声明式”生成 HTML,但随后我们只需将 HTML 发送到客户端即可。 与其他客户端方法相比,我们不必太担心状态的大小。另请参阅 HTMX HATEOAS 文章 the HTMX HATEOAS essay ↩︎ ↩︎
  4. 这是我构建的一个简单示例↩︎
  5. 请参阅单文件应用程序 Single file applications ↩︎ ↩︎ ↩︎ ↩︎

jupyter nbconvert --to markdown "从 Python 函数构建 HTML 组件.IPYNB"

UPDATE 2024-01-17 BY YULIKE