使用htmx构建单页应用

文摘   2024-10-17 08:18   上海  

人们谈论htmx好像它正在拯救网络免于单页应用的困扰。React让开发人员陷入了复杂性的泥潭(据说是这样),而htmx则提供了一个急需的救生索。

htmx的创建者Carson Gross以一种幽默的方式 这样解释[1] 这种动态:

不,这是一个黑格尔辩证法:

  • 正题:传统的多页应用
  • 反题:单页应用
  • 合题(更高形式):具有交互性岛屿的超媒体驱动应用

好吧,看来我错过了备忘录,因为 我用htmx构建了一个单页应用[2]

这是一个简单的概念验证待办事项列表。一旦页面加载完成,就不再与服务器进行任何额外的通信。所有操作都在客户端本地进行。

考虑到htmx专注于管理网络上的超媒体交换,这是如何实现的呢?

通过一个简单的技巧:1"服务器端"代码在 service worker[3] 中运行。

简而言之,service worker充当网页和更广泛的互联网之间的代理。它拦截网络请求并允许你操作它们。你可以更改请求,缓存响应以便离线服务,甚至在不向浏览器之外发送请求的情况下凭空创建新的响应。

最后一种能力就是驱动这个单页应用的关键。当htmx发出网络请求时,service worker会拦截它。然后service worker运行业务逻辑并生成新的HTML,htmx再将其替换到DOM中。

与使用React等构建的传统单页应用相比,这还有几个优势。Service worker必须使用 IndexedDB[4] 进行存储,这在页面加载之间是有状态的。如果你关闭页面然后再回来,应用会保留你的数据 - 这是"免费"发生的,是选择这种架构的 成功陷阱[5] 结果。该应用还可以离线工作,这不是免费的,但一旦设置好service worker就很容易添加。

当然,service worker也有很多陷阱。一个是开发者工具中绝对糟糕的支持,它们似乎会间歇性地吞掉console.log,并且不可靠地报告service worker何时安装。另一个是Firefox缺乏对ES模块的支持,这迫使我将所有代码(包括 IDB Keyval[6] 的供应商版本,我包含它是因为IndexedDB同样令人烦恼)放在一个文件中。

这不是一个详尽的列表!我会将使用service worker的一般体验描述为"不好玩"。

但是!尽管如此,htmx单页应用还是可以工作。让我们深入了解一下!

幕后原理

让我们从HTML开始:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>htmx spa</title>
    <script src="https://unpkg.com/htmx.org@1.9.9"></script>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body
    hx-boost="true"
    hx-push-url="false"
    hx-get="./ui"
    hx-target="body"
    hx-trigger="load"
  >
    <script>
      // 注册service worker的代码
    </script>
  </body>
</html>

如果你曾经构建过单页应用,这应该看起来很熟悉:一个空壳的HTML文档,等待JavaScript填充。那个长长的内联<script>标签只是设置service worker,并且 大部分是从MDN偷来的[7]

这里有趣的部分是<body>标签,它使用htmx设置了应用的主要部分:

  • hx-boost="true"告诉htmx使用Ajax来交换链接点击和表单提交的响应,而不进行完整的页面导航
  • hx-push-url="false"防止htmx更新链接点击和表单提交的URL
  • hx-get="./ui"告诉htmx加载/ui页面并交换它
  • hx-target="body"告诉htmx将结果交换到<body>元素中
  • hx-trigger="load"告诉htmx在页面加载时执行所有这些操作

所以基本上:/ui返回应用的实际标记,此时htmx接管任何链接和表单以使其具有交互性。

/ui是什么?进入service worker!它使用一个小型的自制Express风格"库"来处理路由请求和返回响应的样板代码。该库的实际工作原理超出了本文的范围,但它是这样使用的:

app.get("/ui", async (req, res) => {
  const filter = new URL(req.url).searchParams.get("filter") ?? "all";
  await setFilter(filter);
  res.header("HX-Push", `?filter=${filter}`);
  const todos = await listTodos();
  const html = App({ filter, todos });
  return res.html(html);
});

当对/ui发出GET请求时,这段代码...

  1. 获取查询字符串中的过滤器
  2. 将过滤器保存在IndexedDB中
  3. 告诉htmx相应地更新URL
  4. 使用活动过滤器和待办事项列表将App"组件"渲染为HTML
  5. 将渲染的HTML返回给浏览器

setFilterlistTodos是包装IDB Keyval的简单函数:

async function setFilter(filter) {
  return set("filter", filter);
}

async function listTodos() {
  return get("todos") ?? [];
}

App组件看起来像这样:

function App({ filter, todos }) {
  const filtered = todos.filter(todo => {
    if (filter === "active") return !todo.done;
    if (filter === "completed") return todo.done;
    return true;
  });

  return html`
    <main>
      <h1>todos</h1>

      <form class="filters">
        <label>
          <input type="radio" name="filter" value="all" checked=${filter === "all"} />
          All
        </label>
        <label>
          <input type="radio" name="filter" value="active" checked=${filter === "active"} />
          Active
        </label>
        <label>
          <input type="radio" name="filter" value="completed" checked=${filter === "completed"} />
          Completed
        </label>
      </form>

      <ul class="todos">
        ${filtered.map(todo => Todo({ todo, editing: false }))}
      </ul>

      <form hx-post="/todos/add" hx-target=".todos" hx-select=".todos">
        <input type="text" name="text" placeholder="What needs to be done?" />
      </form>
    </main>
  `;
}

(同样,我们将跳过一些实用函数,如html,它只是在插值值时提供一些小便利。)

App可以大致分为三个部分:

  • 过滤器表单。这为每个过滤器渲染一个单选按钮。当单选按钮改变时,它将表单提交到/ui,后者使用上述步骤重新渲染应用。之前的hx-boost属性拦截表单提交,并将响应交换回<body>中,而不刷新页面。
  • 待办事项列表。这循环遍历所有匹配当前过滤器的待办事项,使用Todo组件渲染每个待办事项。
  • 添加待办事项表单。这是一个带有输入框的表单,将值提交到/todos/add。2 hx-target=".todos"告诉htmx替换页面上具有todos类的元素;hx-select=".todos"告诉htmx不要使用整个响应,而只使用具有todos类的元素。

让我们看看那个/todos/add路由:

app.post("/todos/add", async (req, res) => {
  const text = new URLSearchParams(await req.text()).get("text");
  const todos = await listTodos();
  todos.push({ id: crypto.randomUUID(), text, done: false });
  await set("todos", todos);
  const filter = await getFilter();
  const html = App({ filter, todos });
  return res.html(html);
});

非常简单!它只是保存待办事项并返回重新渲染的UI的响应,然后htmx将其交换到DOM中。

现在,让我们看看之前的Todo组件:

function Todo({ todo, editing }) {
  return html`
    <li>
      <input
        type="checkbox"
        checked=${todo.done}
        hx-get="/todos/${todo.id}/update?done=${!todo.done}"
        hx-target="body"
      />
      <button hx-delete="/todos/${todo.id}" hx-target="body">×</button>
      ${
        editing
          ? html`
              <form hx-put="/todos/${todo.id}/update" hx-target="body">
                <input type="text" name="text" value="${todo.text}" />
              </form>
            `
          : html`
              <span hx-get="/ui/todos/${todo.id}?editable=true" hx-target="closest li" hx-swap="outerHTML">
                ${todo.text}
              </span>
            `
      }
    </li>
  `;
}

这里有三个主要部分:复选框、删除按钮和待办事项文本。

首先是复选框。每次选中或取消选中时,它都会触发对/todos/${id}/updateGET请求,查询字符串done与其当前状态匹配;htmx将完整响应交换到<body>中。

以下是该路由的代码:

app.get("/todos/:id/update", async (req, res) => {
  const id = req.params.id;
  const done = new URL(req.url).searchParams.get("done") === "true";
  const todos = await listTodos();
  const todo = todos.find(todo => todo.id === id);
  if (!todo) return res.status(404);
  todo.done = done;
  await set("todos", todos);
  const filter = await getFilter();
  const html = App({ filter, todos });
  return res.html(html);
});

(注意,该路由还支持更改待办事项文本。我们稍后会讲到这一点。)

删除按钮更简单:它向/todos/${id}发出DELETE请求。与复选框一样,htmx将完整响应交换到<body>中。

以下是该路由:

app.delete("/todos/:id", async (req, res) => {
  const id = req.params.id;
  const todos = await listTodos();
  const index = todos.findIndex(todo => todo.id === id);
  if (index === -1) return res.status(404);
  todos.splice(index, 1);
  await set("todos", todos);
  const filter = await getFilter();
  const html = App({ filter, todos });
  return res.html(html);
});

最后一部分是待办事项文本,由于支持编辑文本而变得更加复杂。有两种可能的状态:"正常",只显示带有待办事项文本的简单<span>(我很抱歉这不可访问!)和"编辑",显示允许用户编辑的<input>Todo组件使用editing"prop"来确定要渲染哪个状态。

然而,与React等客户端框架不同,我们不能只是在某处切换状态并让它进行必要的DOM更改。htmx为新UI发出网络请求,我们需要返回一个超媒体响应,然后它可以将其交换到DOM中。

以下是该路由:

app.get("/ui/todos/:id", async (req, res) => {
  const id = req.params.id;
  const editable = new URL(req.url).searchParams.get("editable") === "true";
  const todos = await listTodos();
  const todo = todos.find(todo => todo.id === id);
  if (!todo) return res.status(404);
  const html = Todo({ todo, editing: editable });
  return res.html(html);
});

在高层次上,网页和service worker之间的协调看起来像这样:

  1. htmx监听待办事项文本<span>上的双击事件
  2. htmx向/ui/todos/${id}?editable=true发出请求
  3. service worker返回包含<input>而不是<span>Todo组件的HTML
  4. htmx将当前待办事项列表项与响应中的HTML交换

当用户更改输入时,会发生类似的过程,调用/todos/${id}/update端点并交换整个<body>。如果你使用过htmx,这应该是一个非常熟悉的模式。

就是这样!我们现在已经用htmx(和service workers)构建了一个不依赖远程web服务器的单页应用。为简洁起见省略的代码可以在 GitHub[8] 上找到。

总结

从技术上讲,这确实可行。但这是个好主意吗?这是基于超媒体应用的顶峰吗?我们应该放弃React而改用这种方式构建应用吗?

htmx通过为UI添加间接性来工作,从网络边界加载新的HTML。这在客户端-服务器应用中可能有意义,因为它通过将数据库与渲染放在一起来减少对数据库的间接访问。另一方面,像React这样的框架中的客户端-服务器模式可能会很痛苦,需要通过一个笨拙的数据交换通道在客户端和服务器之间进行仔细协调。

然而,当所有交互都是本地的时候,渲染和数据已经是(在内存中)并置的,用像React这样的框架同步更新它们很容易。在这种情况下,htmx所需的间接性开始感觉更像是一种负担而不是解放。3对于完全本地的应用,我认为这种方式不值得。

当然,大多数应用并不是完全本地的 - 通常会混合本地交互和网络请求。我的感觉是,即使在这种情况下, 交互性孤岛[9] 模式也比将你的"服务器端"代码分割在service worker和实际服务器之间要好。

无论如何,这主要是一个练习,看看使用超媒体而不是命令式或函数式编程来构建一个完全本地的单页应用会是什么样子。

请注意,超媒体是一种技术而不是特定的工具。我选择htmx是因为它是当前流行的超媒体~库~ 框架[10] ,我想尽可能地拓展它的能力。还有其他明确专注于这种用例的工具,如 Mavo[11] ,事实上你可以看到 Mavo实现的TodoMVC[12] 比我在这里构建的要简单得多。更好的是某种类似HyperCard的应用,你可以在其中以可视化的方式构建整个应用。

总的来说,我的这个小型htmx单页待办事项应用很有趣。如果没有其他收获,就把这当作一个提醒,你可以而且应该偶尔尝试以奇怪和意想不到的方式使用你的工具!

  1. React开发者讨厌他! ↩

  2. 你可能注意到表单方法是GET而不是POST。这是因为Firefox中的service workers似乎不支持请求体,这意味着我们需要在URL中包含任何相关数据。↩

  3. htmx实际上不是这种架构的必需组件。理论上,你可以构建一个完全客户端的单页应用,除了service worker之外不使用任何JavaScript,只需将每个按钮包装在一个<form>标签中,并在每个操作时替换整个页面。由于响应都来自service worker,它仍然会非常快;你甚至可以使用 跨文档视图转换[13] 添加一些流畅的动画。↩

参考链接

  1. 这样解释: https://twitter.com/htmx_org/status/1736849183112360293
  2. 我用htmx构建了一个单页应用: https://jakelazaroff.github.io/htmx-spa/
  3. service worker: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
  4. IndexedDB: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
  5. 成功陷阱: https://blog.codinghorror.com/falling-into-the-pit-of-success/
  6. IDB Keyval: https://github.com/jakearchibald/idb-keyval
  7. 大部分是从MDN偷来的: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers
  8. GitHub: https://github.com/jakelazaroff/htmx-spa
  9. 交互性孤岛: https://www.patterns.dev/vanilla/islands-architecture/
  10. 框架: https://htmx.org/essays/is-htmx-another-javascript-framework/
  11. Mavo: https://mavo.io/
  12. Mavo实现的TodoMVC: https://mavo.io/demos/todo/
  13. 跨文档视图转换: https://developer.chrome.com/docs/web-platform/view-transitions#cross-document_view_transitions

幻想发生器
图解技术本质
 最新文章