htmx 和 Spring Boot 入门

htmx 是一个 JavaScript 库,允许您通过 HTML 访问 AJAX 请求、WebSocket 等。

什么是渲染片段
使用 htmx 的一个典型场景是使服务器端渲染网页 (SSR) 更加动态。

  • 在传统的 SSR 中,每个操作都会将整个页面发送回客户端
  • 但是,使用 htmx,服务器可以重新呈现特定片段,仅发送回更新的 HTML。

这使得网页响应速度更快、效率更高。由于 htmx 依赖于 HTML 属性和 HTTP 标头,因此它可以与任何渲染框架或语言一起使用。

项目设置
在此示例中,我将使用 htmx 与 Spring Boot 和 Thymeleaf。为此,请添加以下依赖项:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>

然后在 src/main/resources/templates/index.html 中创建 Thymeleaf 索引页:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
</head>
<body>
<h1>My tasks</h1>
</body>
</html>

创建Spring控制器:

@Controller
@RequestMapping("/")
@RequiredArgsConstructor
public class IndexController {
    @GetMapping
    public String index() {
        return "index";
    }
}

启动应用程序、访问 http://localhost:8080 并登录后,我们就会看到一个标题为 "我的任务 "的页面。

注:在本教程中,除了 htmx 特有的内容外,我不会涉及其他安全配置。我假设你已经按照自己的喜好配置了 Spring Security。


创建 Thymeleaf 片段
现在,让我们用三个组件创建一个待办事项应用程序:

  • 显示当前认证用户的标题和注销按钮、
  • 任务列表,每个任务都有一个复选框用于标记任务已完成和一个删除按钮、
  • 以及一个向列表中添加新任务的表单。

我要创建的第一个片段是任务列表。为此,请创建一个名为 src/main/resources/templates/fragments/items.html 的文件:

<!DOCTYPE html>
<html
  lang="en"
  xmlns:th="http://www.thymeleaf.org">
<body>
<table
  id="items"
  th:fragment="items (items)">
  <tbody>
  <tr th:each="item : ${items}">
    <td class="checkbox-column">
      <input
        th:id="'complete-' + ${item.id}"
        type="checkbox"
        th:checked="${item.completed}" />
      <label th:for="'complete-' + ${item.id}"></label>
    </td>
    <td th:text="${item.task}"></td>
    <td>
      <a>
        Delete
      </a>
    </td>
  </tr>
  </tbody>
</table>
</body>
</html>

这个 Thymeleaf 片段包含一个项目列表,每个项目都显示一个复选框,如果 item.completed 为 true,复选框将被标记,它还将显示任务本身(item.task)和一个删除链接。该类的 Java 表示形式如下:
public record TodoItemDTO(UUID id, String task, boolean completed) { }

另一件需要注意的事情是,我给表格赋予了一个 ID (id="items")。这样,我们就可以使用 htmx 重新渲染应用程序的这一部分。

当应用程序首次打开时,服务器将渲染整个页面。为了实现这一点,我将在 index.html 中包含这个片段:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
</head>
<body>
<div class="card">
  <h1>My tasks</h1>
  <!-- Add this -->
  <table th:replace="~{fragments/items :: items (items=${items})}"></table>
</div>
</body>
</html>

此外,我还要将items 传递给 IndexController:

@Controller
@RequestMapping("/")
@RequiredArgsConstructor
public class IndexController {
    private final TodoItemManagement todoItems;

    @GetMapping
    public String index(Model model, @AuthenticationPrincipal AccountUserDTO user) {
        UUID accountId = user.id();
        // Retrieve items from service
        model.addAttribute("items", todoItems.findAllByOwnerId(accountId));
        return "index";
    }
}

注意:我使用 @AuthenticationPrincipal 来检索当前用户。AccountUserDTO 类实现了 UserDetails 接口。由于该接口与 htmx 无关,本教程将不再介绍其实现。

使用 htmx 添加交互性
我要添加的第一个交互性是,当点击删除链接时,项目列表将被刷新。为此,我首先需要创建一个单独的控制器方法,用于删除项目并渲染片段:

@DeleteMapping("/{id}")
public String deleteItem(Model model, @PathVariable UUID id, @AuthenticationPrincipal AccountUserDTO user) {
    UUID ownerId = user.id();
    items.delete(new TodoItemIdentity(ownerId, id));
    model.addAttribute("items", items.findAllByOwnerId(ownerId));
    return "fragments/items";
}

因此,如您所见,我调用了服务的 delete() 方法,然后再次调用 findAllByOwnerId() 方法来检索用户的当前任务。最后,我告诉 Spring 只呈现 fragments/items.html 片段。

我们要做的另一部分工作是告诉 htmx,每当删除链接被点击时,都应调用该控制器。这可以通过使用 hx-delete 属性来实现:

<a
    th:hx-delete="'/todoitem/' + ${item.id}">
    Delete
</a>

现在的情况是,当点击链接时,htmx 将执行 HTTP 请求,并用 HTTP 请求的响应替换删除链接的内部 HTML。这并不是我们想要的,因为我们不想在删除链接中渲染表格。

要解决这个问题,我们需要告诉 htmx 以特定元素为目标,这可以通过 hx-target 属性来实现。例如

<a
    th:hx-delete="'/todoitem/' + ${item.id}"
    hx-target="items">
    Delete
</a>

我们现在遇到的另一个问题是,htmx 会替换给定目标的内部 HTML。但是,由于目标是主 <table>,而我们又要再次返回 <table>,因此会产生类似的结果:

<table id="items">
  <table id="items">
    <!-- ... -->
  </table>
</table>

要解决这个问题,我们可以将 ID 放在包装元素上,或者告诉 htmx 替换外层内容。要替换外部内容,我们可以将 hx-swap 属性设置为 outerHTML:

<a
    th:hx-delete="'/todoitem/' + ${item.id}"
    hx-target="items"
    hx-swap="outerHTML">
    Delete
</a>

htmx 的好处在于,这些属性可用于每个元素。因此,为了实现复选框切换行为,我们可以做一些类似的事情:

<input
    th:id="'complete-' + ${item.id}"
    type="checkbox"
    th:checked="${item.completed}"
    th:hx-put="'/todoitem/' + ${item.id} + '/toggle'"
    hx-target="items"
    hx-swap="outerHTML" />

现在我们需要做的就是实现另一个控制器方法,切换项目已完成字段并再次渲染片段:

@PutMapping("/{id}/toggle")
public String toggleItem(Model model, @PathVariable UUID id, @AuthenticationPrincipal AccountUserDTO user) {
    UUID ownerId = user.id();
    items.toggleComplete(new TodoItemIdentity(ownerId, id));
    model.addAttribute("items", items.findAllByOwnerId(ownerId));
    return "fragments/items";
}

重置表单
接下来我们要做的是通过表单添加新项目。这个表单可以像这样添加到 index.html 中:

<form>
  <label for="new-task">New task</label>
  <input type="text" name="new-task" id="new-task" />
  <button type="submit">Add</button>
</form>

要实现这一点,我们首先需要添加另一个控制器方法来添加新项目:

@PostMapping("/add")
public String createItem(Model model, @RequestParam("new-task") String newTask, @AuthenticationPrincipal AccountUserDTO user) {
    UUID ownerId = user.id();
    items.create(new CreateTodoItemRequestDTO(ownerId, newTask));
    model.addAttribute("items", items.findAllByOwnerId(ownerId));
    return "fragments/items";
}

然后,我们就可以像之前做的那样,在表单提交后立即更新项目片段:

<form
  hx-post="/todoitem/add"
  hx-target="items"
  hx-swap="outerHTML">
  <label for="new-task">New task</label>
  <input type="text" name="new-task" id="new-task" />
  <button type="submit">Add</button>
</form>

在这里我还想做一件事,那就是在表单提交后立即重置表单。为此,我们可以使用表单的 reset() 方法。要触发重置,我们可以使用 hx-on::after-request 属性:

<form
  hx-post="/todoitem/add"
  hx-target="items"
  hx-swap="outerHTML"
  hx-on::after-request="this.reset()">
  <label for="new-task">New task</label>
  <input type="text" name="new-task" id="new-task" />
  <button type="submit">Add</button>
</form>

使用 htmx 的 CSRF 保护
如果使用的是默认的 Spring Security 配置,你会发现之前的调用都无法正常工作。这是因为任何 POST、PUT 或 DELETE 操作都受跨站请求伪造(CSRF)保护。

CSRF 的工作原理是,利用者可以制作一个带有隐藏表单的外部网页,向你的应用程序发送 HTTP 请求,例如 DELETE-请求。问题是,如果已通过身份验证的用户访问该外部网页,DELETE-request 就会以他们的名义执行,因为网络浏览器会自动发送他们的 cookie。

防止这种情况发生的方法是,在每次访问时为每个访问者生成一个唯一的标记。

对于随后的每个非 GET 请求(POST、PUT、DELETE、PATCH......),都应将此标记作为头发送回应用程序。
由于外部网页无法访问用户的令牌,因此 HTTP 请求会失败。

要使用 htmx 防止 CSRF,首先要做的就是向客户端发送 CSRF 标记。一种方法是在 index.html 中添加一些 <meta> 标记:
<meta name="csrf-token" th:content="${_csrf.token}" />
<meta name="csrf-header" th:content="${_csrf.headerName}" />

下一步是定义一段 JavaScript 代码,读取这些元标记,然后配置 htmx,将 CSRF 标记作为标头发送回去:

document.body.addEventListener('htmx:configRequest', (evt) => {
  evt.detail.headers['accept'] = 'text/html-partial';
  if (evt.detail.verb !== 'get') {
    const csrfHeader = document.querySelector('meta[name=csrf-header]').getAttribute('content');
    const csrfToken = document.querySelector('meta[name=csrf-token]').getAttribute('content');
    if (csrfHeader != null && csrfToken != null) {
      evt.detail.headers[csrfHeader] = csrfToken;
    }
  }
});


这些 JavaScript 代码必须包含在 index.html 中。

使用 htmx 注销
我想实现的最后一步是在项目表上方显示一个标题,显示当前已验证的用户和注销链接。要实现这一点,我首先需要更改 IndexController,使其包含用户信息:

@Controller
@RequestMapping("/")
@RequiredArgsConstructor
public class IndexController {
    private final AccountManagement accounts;
    private final TodoItemManagement todoItems;

    @GetMapping
    public String index(Model model, @AuthenticationPrincipal AccountUserDTO user) {
        // Add this:
        UUID accountId = user.id();
        model.addAttribute("account", accounts.findById(accountId));
        model.addAttribute("items", todoItems.findAllByOwnerId(accountId));
        return "index";
    }
}

之后,我们就可以修改 index.html 以包含标题:

<h1>
  Hello,
  <span th:text="${account.username}">username</span>
  (<a>Log out</a>)
</h1>

使用 Spring Security 注销时,需要向 /logout 发送 POST 调用(默认行为)。要发送 POST 调用,我们可以再次使用 htmx:
<a hx-post="/logout">Log out</a>

这样做的问题是 Spring 会自动重定向到登录页面。正如我们已经看到的,这意味着 htmx 将在注销链接所在的位置包含登录页面。

这不是我们想要的。

相反,我们希望用登录页面替换整个页面。为此,我们可以在 Spring Security 配置中设置注销操作成功时的 HX-Redirect 头信息:

.logout(logout -> logout
    .logoutUrl("/logout")
    // Add this handler
    .logoutSuccessHandler((request, response, authentication) -> response.addHeader("HX-Redirect", "/login?logout"))
    .invalidateHttpSession(true)
    .deleteCookies("JSESSIONID"))

第二个问题是,如果我们在多个标签页中打开应用程序,并在其中一个标签页中注销,那么其他标签页中的所有请求都会因为会话失效而失败。为了解决这个问题,我们可以让 htmx 在我们未通过身份验证时刷新页面。一旦我们刷新页面,Spring 就会自动将我们重定向到登录页面,因为我们已不再通过身份验证。

最简单的实现方法是创建自定义 AuthenticationEntryPoint。例如

public class htmxRefreshAuthenticationEntryPoint extends Http403ForbiddenEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        response.addHeader("HX-Refresh", "true");
        super.commence(request, response, authException);
    }
}

最后,我们还需要在安全配置中配置这个入口点:

.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
    new htmxRefreshAuthenticationEntryPoint(),
    new RequestHeaderRequestMatcher("HX-Request")
))

为了确保只有在执行 htmx 请求时才调用该入口点,我们可以检查 HX-Request 头信息。每个 htmx 请求都会出现该标头。

如果你不想每次都自己实现这个功能,也可以使用内置了这个功能的 htmx-spring-boot 库。该库由 Wim Deblauwe 开发,他在一篇博客文章中对此做了详细介绍

异常处理
到目前为止,我们还没有介绍过异常处理程序的使用。例如,假设我们删除了一个已经删除的任务。在这种情况下,我们可以抛出某种异常。

为了捕获它,我们可以编写一个异常处理程序。例如

@ExceptionHandler({InvalidTodoItemException.class, TodoItemNotFoundException.class})
public ModelAndView handleError(Throwable ex) {
    return new ModelAndView("fragments/error", Map.of(
        "error", ex.getMessage()
    ));
}

在这个异常处理程序中,我将返回一个名为 error.html 的新片段。这个片段的内容可以是这样的

<!DOCTYPE html>
<html lang="en">
<body></body>
</html>

这里的特别之处在于我们使用了 hx-swap-oob 属性。OOB 是 Out Of Bounds 的缩写,意思是允许我们将内容换成与正常情况下不同的元素。

在本例中,它的目标元素是 id="error"。为了实现这一功能,我们还需要在 index.html 中为该元素添加一个空的占位符:

<!-- ... --->
<div class="card">
  <!-- Add this -->
  <div id="error"></div>
  <table th:replace="~{fragments/items :: items (items=${items})}"></table>
  <form th:replace="~{fragments/new-task :: new-task"></form>
</div>
<!-- ... -->

如果我们现在运行应用程序并发生错误,我们会发现错误出现在屏幕顶部,但原来的表格也不见了。
出现这种情况是因为 htmx 仍会将原始内容与响应中的内容进行交换。由于异常处理程序的响应不包含 items,应用程序的这一部分就被删除了。

为了解决这个问题,我们需要告诉 htmx 在发生错误时不要重新交换。为此,我们可以将 HX-Reswap 头设置为 "无":

@ExceptionHandler({InvalidTodoItemException.class, TodoItemNotFoundException.class})
public ModelAndView handleError(Throwable ex, HttpServletResponse response) {
    // Add this
    response.setHeader("HX-Reswap", "none");
    return new ModelAndView("fragments/error", Map.of(
        "error", ex.getMessage()
    ));
}

如果我们现在运行应用程序,并做了一些导致异常的操作,我们会发现在错误信息旁边保留了原始表格。

隐藏错误
仍然存在的一个问题是,除非我们刷新页面,否则错误信息会一直显示在屏幕上。我们有几种方法可以解决这个问题:

  • 我们可以在一段时间后自动删除该元素。
  • 我们可以添加一个按钮来隐藏错误。

如果想自动移除元素,可以使用 remove-me 扩展。要使用该扩展,我们可以在 <head> 中添加以下内容:
<script src="https://unpkg.com/htmx.org/dist/ext/remove-me.js"></script>

然后,我们可以在错误片段中添加 hx-ext 和 remove-me 属性:

<div
  th:fragment="error"
  id="error"
  class="danger alert"
  role="alert"
  hx-swap-oob="true"
  hx-ext="remove-me"
  remove-me="3s"
  th:text="${error}">
</div>

或者,我们可以在错误片段中添加一个按钮:

<div
  th:fragment="error"
  id="error"
  class="danger alert"
  role="alert"
  hx-swap-oob="true">
  <span th:text="${error}"></span>
  <button type="button">
    &times;
  </button>
</div>

不过,用 htmx 实现这样一个按钮并不容易。要解决这个问题,"htmx 方法 "是在应用程序中添加一个可以返回空错误的端点。然后,按钮就会触发端点,并像我们之前所做的那样交换错误内容。

在我看来,这样做并不干净,因为我们会无缘无故地执行一个请求。另一种方法是使用 JavaScript 代码。htmx 的创建者意识到了这一点,并创建了另一个名为 _hyperscript 的库。通过该库,您无需编写 JavaScript 代码即可为应用程序添加交互功能。

它的工作原理是 _hyperscript 自带语言,可以通过添加属性应用于任何元素。要使用 _hyperscript,我们可以将以下库添加到 <head> 中:
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>

现在,我们必须编写一些 _hyperscript 标记:

<button 
  type="button"
  _="
    on click put '' into error 
    then remove .danger from error 
    then remove .alert from error">
    &times;
  </button>

我们在这里要做三件事:

  • 首先,我们删除 error 的内部 HTML。这意味着文本和按钮都将消失。
  • 然后,我们删除 error 中的 .danger 类。我使用该元素为警报添加红色。
  • 最后,我们删除 error 中的 .alert 类。我使用该类为 <div> 元素设置了右边的 padding、margin...。

如果我们现在运行应用程序,出现错误并点击按钮,警报就会消失。

结论
通过 Htmx,我们可以只重新渲染应用程序的一部分,从而改进服务器端渲染应用程序。通过这种方法,我们可以增加应用程序的交互性。

最棒的是,只需添加一些 HTTP 头信息,我们就能在 HTML 中实现所有这些功能。从理论上讲,您无需编写任何 JavaScript 代码,正如我们迄今为止所看到的那样。

不过,由于 htmx 主要关注的是与后台的交互,例如使用 AJAX、WebSockets 等,它并不涵盖交互的所有用例。比如显示或隐藏元素,你仍然需要编写一些客户端代码。

我个人觉得有趣的是,现在有一种库趋势,可以让你增强 HTML。首先是 Tailwind,现在又有了 htmx 和 _hyperscript。人们似乎很喜欢这种趋势,因为在 GitHub 上的所有开源前端框架中,htmx 获得了第二多的星星,仅次于 React(源代码)。

源码: GitHub.