在带有Spring Boot和Thymeleaf的TodoMVC中,我们使用Spring MVC和Thymeleaf实现了TodoMVC克隆。 在此类设置中,每个操作都会触发页面刷新。 虽然这工作得很好,但您可能希望为体验提供更多的单页应用程序 (SPA) 趣味并避免页面刷新。 这篇博文将展示如何使用 HTMX 来实现这一点。
什么是 HTMX?
HTMX是一个JavaScript库,允许通过在HTML元素上添加属性来直接在HTML中触发AJAX调用。
一个非常简单的例子,直接来自他们的主页:
<button hx-post="/clicked" hx-swap="outerHTML">
Click Me
</button>
单击该按钮时,将完成HTTP POST /clicked
,响应将用该调用的HTML响应替换DOM中的<button>
元素。
因此,使用 HTMX,您不是在构建 JSON API,而是在构建返回 HTML 片段的 API。
你可以在网站上找到一些关于HTMX可以做什么的很好的例子:</> htmx - Examples
我们将从todomvc-thymeleaf的最终代码开始,并使用HTMX逐步添加功能。
将 HTMX 添加到项目中
将 htmx 添加到 Spring Boot 项目中,方法是添加 webjars Maven 依赖项:
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>htmx.org</artifactId>
<version>1.6.0</version>
</dependency>
在 index.html
中,添加对库的引用:
<script type="text/javascript" th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
HTMX 提升
最简单的入门方法之一是使用hx-boost
“提升”常规 HTML 锚点和表单。 我们可以添加hx-boost
到页面的顶级元素,HTMX 将拦截表单提交,将它们转换为 AJAX 请求,并使用响应 HTML 动态更改当前页面而无需刷新页面。 无需在服务器端更改任何内容,重定向可以保留在原位。 HTMX 将正确处理它们。
我们唯一需要做的就是替换:
<section class="todoapp">
为:
<section class="todoapp" hx-boost="true">
JavaScript 还需要进行一项额外的更改,当选中复选框以切换待办事项的完成状态时,它会触发表单提交。 我们需要替换onchange="this.form.submit()"
为onchange="this.form.requestSubmit()"
:
<input th:id="|toggle-checkbox-${item.id}|"
class="toggle" type="checkbox"
onchange="this.form.requestSubmit()"
th:attrappend="checked=${item.completed?'true':null}">
否则,HTMX 无法拦截表单提交。
重新启动应用程序,并注意浏览器如何从不重新加载页面,一切似乎都发生了,就好像这是一个完整的JavaScript构建的应用程序一样。
底部的过滤器过滤所有项目,活动项目和已完成项目也可以正常工作。HTMX 还将更新浏览器中的 URL,以反映浏览器通常会重定向到的路径。
细粒度的 HTMX 实现
虽然使用 hx-boost 工作正常,但您可能希望更好地控制通过网络发送的内容。 例如,当创建新的待办事项时,您可以只返回该新待办事项的 HTML,而不是完整的页面。
让我们看看这是如何工作的。
如果您按照此部分进行操作,本节请再次删除 |
添加待办事项
要添加一个 SPA 风格的项目,我们将向 HTML 添加一些 htmx 属性。 这是我们目前拥有的:
<form th:action="@{/}" method="post" th:object="${item}">
<input class="new-todo" placeholder="What needs to be done?" autofocus
th:field="*{title}">
</form>
这是它需要更改为的内容:
<form id="new-todo-form" th:action="@{/}" method="post" th:object="${item}">
<input id="new-todo-input" class="new-todo" placeholder="What needs to be done?" autofocus
autocomplete="false"
name="title"
th:field="*{title}"
hx-target="#todo-list"
hx-swap="beforeend"
hx-post="/"
hx-trigger="keyup[key=='Enter']"
>
</form>
4个hx-…
要素解释:
-
hx-trigger
:htmx 将在按下回车键时执行请求。 -
hx-post
:htmx 将向/
-
hx-target
:应将 POST 请求的 HTML 响应添加到页面上存在 id待办事项列表
的 HTML 元素中。 -
hx-swap
:必须在目标 HTML 元素的末尾之前添加 HTML 响应。
我们不能使用默认的控制器方法,因为该方法在开机自检后重定向。 我们需要一个新的,它返回呈现单个待办事项所需的 HTML 片段。
为此,我们在Spring MVC控制器中添加了这个新方法:
@PostMapping(headers = "HX-Request")
public String htmxAddTodoItem(TodoItemFormData formData,
Model model) {
TodoItem item = repository.save(new TodoItem(formData.getTitle(), false));
model.addAttribute("item", toDto(item));
return "fragments :: todoItem";
}
我们希望此方法对 aon 做出反应,但仅在设置标头时(htmx 添加到所有请求中的内容)。POST / HX-Request | |
执行将待办事项保存在数据库中的实际工作。 | |
在模型中添加转换为 DTO 的项,以便 Thymeleaf 可以使用它来渲染模板。 | |
要求百里香叶渲染片段todoItem fragments.html |
这里的第4点特别重要。 我们已经使用了一个百里香叶片段在我们的模板中有一个很好的结构:index.html
<ul id="todo-list" class="todo-list" th:remove="all-but-first">
<li th:insert="fragments :: todoItem(${item})" th:each="item : ${todos}" th:remove="tag">
</li>
</ul>
非常好的是,我们现在可以通过从控制器方法返回来重用此片段来返回将单个待办事项呈现为 HTML 所需的 HTML。fragments :: todoItem
如果您正在继续操作,您还需要进行以下编辑以使其完全正常工作:
-
将
id=“todo-list”
添加到保存待办事项的<ul>
元素中,因为这是我们 htmx 调用的目标。 -
确保主要部分存在,但隐藏在 HTML 中。 取代:
<section class="main" th:if="${totalNumberOfItems > 0}">
跟
<section id="main-section" class="main" th:classappend="${totalNumberOfItems == 0?'hidden':''}">
-
页脚也是如此:
<footer class="footer" th:if="${totalNumberOfItems > 0}">
成为:
<footer id="main-footer" class="footer" th:classappend="${totalNumberOfItems == 0?'hidden':''}">
-
由于输入字段现在不再重置,因为没有页面刷新,我们需要添加一些 JavaScript 来清除输入:
<script> htmx.on('#new-todo-input', 'htmx:afterRequest', function (evt) { evt.detail.elt.value = ''; }); </script>
注册一个回调函数,该函数在项目上发生的每个请求后触发。 new-todo-input
将值设置为触发回调的元素上的空字符串,从而有效地清除文本输入。 -
为了避免表单提交仍然发生,因为我们仍然有该表单,我们可以从 JavaScript 中禁用它:
<script> document.getElementById('new-todo-form').addEventListener('submit', function (evt) { evt.preventDefault(); }) </script>
这完全是可选的。 我们可以完全删除
<form>
元素,它仍然可以工作。 但是通过此设置,当禁用JavaScript时会使用该表单。 当启用JavaScript时,使用htmx。也可以添加
hx-...
表单本身<属性>
如下所示:<form id="new-todo-form" th:action="@{/}" method="post" th:object="${item}" hx-target="#todo-list" hx-swap="beforeend" hx-post="/"> <input id="new-todo-input" class="new-todo" placeholder="What needs to be done?" autofocus autocomplete="false" name="title" th:field="*{title}" > </form>
在这种情况下,HTMX 将禁用表单提交,我们不必在 JavaScript 中手动执行此操作。
添加第一个待办事项后,主部分和主页脚应可见。 我们可以通过添加以下自定义 JavaScript 来实现这一点:
<script>
htmx.on('htmx:afterSwap', function (evt) {
let items = document.querySelectorAll('#todo-list li');
let mainSection = document.getElementById('main-section');
let mainFooter = document.getElementById('main-footer');
if (items.length > 0) {
mainSection.classList.remove('hidden');
mainFooter.classList.remove('hidden');
} else {
mainSection.classList.add('hidden');
mainFooter.classList.add('hidden');
}
});
</script>
定义每次 htmx 在 DOM 树中进行交换时调用的回调函数。 | |
计算元素中的项目数<li> todo-list | |
检查是否有待办事项添加或删除CSS类。hidden |
另一种实现方式是针对 HTML 的更大部分,不仅返回待办事项本身的 HTML,而且还包括完整的主部分和页脚。 我发现这种方法在这里更好,因为从控制器方法返回的 HTML 代码段仅包含呈现待办事项本身的内容。 即使我必须编写这一小段 JavaScript 才能使其工作。<li>
说明它在运行时的工作方式
为了更详细地解释事情,这就是它在运行时的工作方式。
当页面第一次加载时,Thymeleaf 呈现模板,HTML 如下所示:
<form id="new-todo-form" action="/" method="post">
<input id="new-todo-input" class="new-todo" placeholder="What needs to be done?" autofocus="" autocomplete="false"
name="title"
hx-target="#todo-list"
hx-swap="beforeend"
hx-post="/"
hx-trigger="keyup[key=='Enter']"
value="">
</form>
<ul id="todo-list" class="todo-list">
</ul>
现在,我们可以通过在输入中键入一些文本并按 Enter 来添加新项目。 完成此操作后,htmx 将发送 POST 请求并在返回的 HTML 中进行交换。
我们可以在开发人员工具中看到这一点:
它显示带有 HTML 代码段作为响应的请求。 HTMX 采用该响应并将其交换到浏览器中已存在的 HTML 中,以便为最终用户创建这种类似 SPA 的体验。POST
结果是在不刷新页面的情况下添加待办事项。 生成的 HTML 为:
<ul id="todo-list" class="todo-list">
<li>
<div class="view">
<form action="/1/toggle" method="post"><input type="hidden" name="_method" value="put">
<input class="toggle" type="checkbox" onchange="this.form.submit()">
<label>Learn htmx</label>
</form>
<form action="/1" method="post"><input type="hidden" name="_method" value="delete">
<button class="destroy"></button>
</form>
</div>
<input class="edit" value="Create a TodoMVC template">
</li>
</ul>
从 POST 返回的 HTML 代码段由 htmx 添加到元素中。todo-list |
当新的 HTML 被交换到 DOM 中时,JavaScript 回调被触发以使 theandelements 可见。main-section
main-footer
添加第一个待办事项后,应用程序如下所示:
如果您尝试此操作,您会注意到没有页面刷新。 您也可以尝试禁用JavaScript,它应该仍然可以工作(当然需要刷新页面)。
更新项目数
我们现在可以通过 htmx 在待办事项列表中添加项目,而无需刷新任何页面,但页脚中的活动项目数量不会更新。
为了再次执行此操作,我们可以在 htmx 中使用事件。
首先,将显示活动项目数的 HTML 提取到 Thymeleaf 片段中:
<span th:fragment="active-items-count"
id="active-items-count"
class="todo-count"
hx-get="/active-items-count"
hx-swap="outerHTML"
hx-trigger="itemAdded from:body">
<th:block th:unless="${numberOfActiveItems == 1}">
<span class="todo-count"><strong th:text="${numberOfActiveItems}">0</strong> items left</span>
</th:block>
<th:block th:if="${numberOfActiveItems == 1}">
<span class="todo-count"><strong>1</strong> item left</span>
</th:block>
</span>
请注意,我们添加了 3 个 htmx 属性:
-
hx-get
:指示 htmx 执行 HTTP GET on/active-items-count
-
hx-swap
:指示 htmx 将整个跨度替换为我们从 GET 请求中返回的内容。 -
hx-trigger
:当有事件项添加
来自<body>
的任何子元素时触发 HTTP GET。
因此,每当某处有发送时,这两个属性将确保将有一个自动 GET 请求来更新项目数。 GET 的响应返回将用于在 DOM 中替换自身的 HTML 代码段。itemAdded
我们希望在添加新项目时发送事件。 我们通过在响应中添加一个特殊的标头来做到这一点:HX-Trigger
@PostMapping(headers = "HX-Request")
public String htmxAddTodoItem(TodoItemFormData formData,
Model model,
HttpServletResponse response) {
TodoItem item = repository.save(new TodoItem(formData.getTitle(), false));
model.addAttribute("item", toDto(item));
response.setHeader("HX-Trigger", "itemAdded");
return "fragments :: todoItem";
}
注入以便能够添加自定义标头HttpServletResponse | |
添加为响应标头的值itemAdded HX-Trigger |
通过返回标头,htmx 将触发事件,该事件被小片段捕获,它将更新活动项目的数量。itemAdded
最后,使用页面中的片段:index.html
<footer id="main-footer" class="footer" th:classappend="${totalNumberOfItems == 0?'hidden':''}">
<span th:replace="fragments :: active-items-count"></span>
...
这样,只要添加新项目而不刷新页面,活动项目的数量就会正确更新。
将项目标记为已完成
我们可以通过使用 HTMX 实现切换项目的完成状态来继续使我们的应用程序更具交互性(更少的页面重新加载)。
首先添加新的控制器方法:
@PutMapping(value = "/{id}/toggle", headers = "HX-Request")
public String htmxToggleTodoItem(@PathVariable("id") Long id,
Model model,
HttpServletResponse response) {
TodoItem todoItem = repository.findById(id)
.orElseThrow(() -> new TodoItemNotFoundException(id));
todoItem.setCompleted(!todoItem.isCompleted());
repository.save(todoItem);
model.addAttribute("item", toDto(todoItem));
response.setHeader("HX-Trigger", "itemCompletionToggled");
return "fragments :: todoItem";
}
标头确保仅对 HTMX 完成的请求调用此方法。HX-Request | |
切换待办事项后,将 DTO 添加到该项,以便片段可以使用 DTO 中的信息正确呈现。Model | |
发回响应标头,以便页面的其他部分可以对项目的切换做出反应。 在这种情况下,我们将有一个标签,显示活动项目更新的数量。 | |
使用 Thymeleaf 片段将 HTML 片段发送回浏览器。 |
在 HTML 方面,我们将替换它:
<li th:fragment="todoItem(item)" th:classappend="${item.completed?'completed':''}">
<div class="view">
<form th:action="@{/{id}/toggle(id=${item.id})}" th:method="put">
<input class="toggle" type="checkbox"
onchange="this.form.submit()"
th:attrappend="checked=${item.completed?'true':null}">
<label th:text="${item.title}">Taste JavaScript</label>
</form>
<form th:action="@{/{id}(id=${item.id})}" th:method="delete">
<button class="destroy"></button>
</form>
</div>
<input class="edit" value="Create a TodoMVC template">
</li>
跟:
<li th:fragment="todoItem(item)" th:classappend="${item.completed?'completed':''}" th:id="|list-item-${item.id}|">
<div class="view">
<input th:id="|toggle-checkbox-${item.id}|" class="toggle" type="checkbox"
th:attrappend="checked=${item.completed?'true':null}"
th:attr="hx-put=@{/{id}/toggle(id=${item.id})},hx-target=|#list-item-${item.id}|"
hx-trigger="click"
hx-swap="outerHTML"
>
<label th:text="${item.title}">Taste JavaScript</label>
<form th:action="@{/{id}(id=${item.id})}" th:method="delete">
<button class="destroy"></button>
</form>
</div>
<input class="edit" value="Create a TodoMVC template">
</li>
这些是详细的更改:
-
删除周围的内容,因为我们现在将使用HTMX,不再是表单提交。
<form>
<input>
-
阿尼斯添加到该项目上。 这是必需的,因为 HTMX 会将 completeitem 替换为更新的项目,它将作为对 AJAX 调用的响应接收。 HTMX需要能够知道它需要替换哪些。
id
<li>
<li>
id
<li>
-
添加属性,以便 HTMX 在单击项目时开始执行其工作。
hx-trigger="click"
<input>
-
添加属性,以便 HTMX 将当前完全替换为 AJAX 响应中的接收片段。 默认情况下,HTMX 使用这将使响应成为目标元素的子元素。
hx-swap="outerHTML"
<li>
<li>
innerHTML
-
添加完成 PUT 请求。 我们需要使用因此,我们可以使用 Thymeleaf 片段的参数来动态构建要使用的正确 URL。
hx-put=…
th:attr
item
-
添加到指向元素的 id。 这将指示 HTMX 使用该元素作为替换目标。
hx-target=…
<li>
这已经可以切换单个待办事项的已完成状态。 但是,活动项目的数量尚未更新。 这是因为我们仅在添加项目后触发一个新请求来获取当前活动项目数:
<span th:fragment="active-items-count"
id="active-items-count"
class="todo-count"
hx-get="/active-items-count"
hx-trigger="itemAdded from:body">
<th:block th:unless="${numberOfActiveItems == 1}">
<span class="todo-count"><strong th:text="${numberOfActiveItems}">0</strong> items left</span>
</th:block>
<th:block th:if="${numberOfActiveItems == 1}">
<span class="todo-count"><strong>1</strong> item left</span>
</th:block>
</span>
我们需要更新属性以对新事件做出反应:hx-trigger
itemCompletionToggled
<span th:fragment="active-items-count"
id="active-items-count"
class="todo-count"
hx-get="/active-items-count"
hx-trigger="itemAdded from:body, itemCompletionToggled from:body">
...
</span>
完成此操作后,我们可以切换待办事项的完成状态,并且活动计数也会更新。 所有这些都无需页面刷新。
删除待办事项
我将用最后一个示例结束这篇博文:实现删除待办事项。
我们再次从向控制器添加新方法开始:
@DeleteMapping(value = "/{id}", headers = "HX-Request")
@ResponseBody
public String htmxDeleteTodoItem(@PathVariable("id") Long id,
HttpServletResponse response) {
repository.deleteById(id);
response.setHeader("HX-Trigger", "itemDeleted");
return "";
}
确保该方法通过标头用于 HTMX 请求。HX-Request | |
我们需要返回一个空的正文,因为我们想用任何内容替换 HTML 页面上的项目。 HTMX 将空响应解释为什么都不做,但什么都没有的响应基本上必须从 HTML 中删除目标项,这就是我们在这里想要的。<li> | |
让 HTMX 在浏览器中发送事件,以便我们可以更新活动项目的数量。itemDeleted | |
返回一个空字符串(请参阅第 2 点)。 |
在 HTML 端,我们替换:
<form th:action="@{/{id}(id=${item.id})}" th:method="delete">
<button class="destroy"></button>
</form>
跟:
<button class="destroy"
th:attr="hx-delete=@{/{id}(id=${item.id})},hx-target=|#list-item-${item.id}|"
hx-trigger="click"
hx-swap="outerHTML"
></button>
这与我们为切换项目完成状态所做的非常相似。 唯一的区别是我们现在使用的 URL 略有不同。hx-delete
为了确保活动项目也正确更新,我们向那里添加了另一个事件:hx-trigger
<span th:fragment="active-items-count"
id="active-items-count"
class="todo-count"
hx-get="/active-items-count"
hx-trigger="itemAdded from:body, itemCompletionToggled from:body, itemDeleted from:body">
...
</span>
再次启动应用程序,并在添加项目、切换其完成状态并删除它们时享受没有页面刷新的情况。
结论
完全有可能有一个交互式应用程序,该应用程序避免使用Spring Boot,Thymeleaf和HTMX的某些操作的页面刷新。 使用使它变得非常容易,或者如果你想更好地控制发生的事情,这也不是那么难。hx-boost
一开始确实会习惯一些。 要记住的最重要的一点是从控制器返回 HTML 片段,而不是 JSON。 并确保 HTML 上的元素具有属性,以便 HTMX 可以定位它们。id
有关完整源代码,请参阅 GitHub 上的 todomvc-htmx-boost 和todomvc-htmx。
如果您有任何问题或意见,请随时在GitHub 讨论中发表评论。