Spring Boot 和 Thymeleaf 实现 Java 版 HTMX

HTMX是否有潜力成为实现以Java为中心的Ajax(Asynchronous JavaScript and XML,异步JavaScript和XML)开发模式的关键组件。
  • Ajax是一种在不重新加载整个页面的情况下,能够与服务器交换数据并更新部分网页的技术。
  • HTMX可能成为将Java后端与前端动态交互功能紧密结合的工具。

让我们通过基于 HTMX、Spring Boot 和 Thymeleaf 的示例应用程序来一探究竟。

什么是HTMX?
HTMX是一种较新的技术,它采用普通的 HTML,并赋予其 Ajax 和 DOM 交换等额外功能。它被列入我个人的好主意列表中,因为它消除了典型 Web 应用程序中的整个复杂性。HTMX​​ 通过在 JSON 和 HTML 之间来回转换来工作。可以将其视为一种声明式 Ajax。

Java、Spring 和 Thymeleaf
而 Java 则是另一个选择:它是最成熟且最具创新性的服务器端平台之一。Spring 是添加一系列基于 Java 的功能的简单选择,包括用于处理端点和路由的 精心设计的Spring Boot Web 项目。

Thymeleaf是一款完整的服务器端模板引擎,也是 Spring Boot Web 的默认引擎。与 HTMX 结合使用时,您可以构建全栈 Web 应用,而无需使用大量 JavaScript。 

案例
我们将构建标准的 Todo 应用:我们列出现有的待办事项,并允许创建新的待办事项、删除待办事项以及更改其完成状态。

概述
完成的 Todo 应用程序在磁盘上的样子如下:


$ tree
.
├── build.gradle
├── gradlew
├── gradlew.bat
├── settings.gradle
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── iwjavaspringhtmx
        │               ├── DemoApplication.java
        │               ├── controller
        │               │   └── MyController.java
        │               └── model
        │                   └── TodoItem.java
        └── resources
            ├── application.properties
            ├── static
            │   └── style.css
            └── templates
                ├── index.html
                ├── style.css
                └── todo.html

因此,除了典型的 Gradle 内容外,应用程序还有两个主要部分包含在 /src 目录中:/main 目录包含 Java 代码,而 /resources 则包含属性文件以及 CSS 和 Thymeleaf 模板的两个子目录。

您可以在 GitHub repo 代码库中找到该项目的源代码。要运行它,请访问根目录并键入 $ gradle bootRun。然后就可以在 localhost:8080 上使用该应用程序了。

如果想从头开始启动应用程序,可以从以下步骤开始:$ spring init --dependencies=web,thymeleaf spring-htmx。这将把 Thymeleaf 和 Spring Boot 安装到 Gradle 项目中。

该应用程序是由 DemoApplication.java 运行的普通 Spring Boot 应用程序。

Java Spring HTMX 模型类
让我们首先看看我们的模型类:com/example/iwjavaspringhtmx/TodoItem.java。这是代表待办事项的服务器端模型类。它看起来如下:

public class TodoItem {
  private boolean completed;
  private String description;
  private Integer id;
  public TodoItem(Integer id, String description) {
    this.description = description;
    this.completed = false;
    this.id = id;
  }
  public void setCompleted(boolean completed) {
    this.completed = completed;
  }
  public boolean isCompleted() {
    return completed;
  }
  public String getDescription() {
    return description;
  }
  public Integer getId(){ return id; }
  public void setId(Integer id){ this.id = id; }
  @Override
  public String toString() {
    return id + " " + (completed ? "[COMPLETED] " : "[ ] ") + description;
  }
}

这是一个带有 getter 和 setter 的简单模型类。没什么特别的,但这正是我们想要的。

Java Spring HTMX 控制器类
在服务器上,控制器是老板。它接受请求、编排逻辑并制定响应。在我们的例子中,我们需要四个端点,用于列出项目、更改其完成状态、添加项目和删除项目。这是控制器类:


@Controller
public class MyController {

  private static List<TodoItem> items = new ArrayList();
  static {
    TodoItem todo = new TodoItem(0,"Make the bed");
    items.add(todo);
    todo = new TodoItem(1,
"Buy a new hat");
    items.add(todo);
    todo = new TodoItem(2,
"Listen to the birds singing");
    items.add(todo);
  }

  public MyController(){ }

  @GetMapping(
"/")
  public String items(Model model) {
    model.addAttribute(
"itemList", items);
    return
"index";
  }

  @PostMapping(
"/todos/{id}/complete")
  public String completeTodo(@PathVariable Integer id, Model model) {
    TodoItem item = null;
    for (TodoItem existingItem : items) {
      if (existingItem.getId().equals(id)) {
        item = existingItem;
        break
      }
    }
    if (item != null) {
      item.setCompleted(!item.isCompleted());
    }
    model.addAttribute(
"item",item);
    return
"todo"
  }

  @PostMapping(
"/todos")
  public String createTodo(Model model, @ModelAttribute TodoItem newTodo) {
    int nextId = items.stream().mapToInt(TodoItem::getId).max().orElse(0) + 1;
    newTodo.setId(nextId);
    items.add(newTodo);
    model.addAttribute(
"item", newTodo);
    return
"todo";
  }

  @DeleteMapping(
"/todos/{id}/delete")
  @ResponseBody
  public String deleteTodo(@PathVariable Integer id) {
    for (int i = 0;  i < items.size(); i++) {
      TodoItem item = items.get(i);
      if (item.getId() == id) {
        items.remove(i);
        break;
      }
    }
    return
"";
  }
}

您会注意到,我刚刚创建了一个静态变量List来保存内存中的项目。在现实生活中,我们会使用外部数据存储。

首先,端点使用 @GetMapping、@PostMapping 和 @DeleteMapping 进行注解。这就是将 Spring Web 路径映射到处理程序的方法。每个注解对应其 HTTP 方法(GET、POST、DELETE)。

Spring Boot 还可以使用参数注解 @PathParameter 从路径中轻松获取参数。因此,对于 /todos/{id}/delete 路径,@PathVariable Integer id 将包含路径 {id} 部分的值。

在 createTodo() 方法中,注释为 @ModelAttribute TodoItem newTodo 的参数将自动接收 POST 主体并将其值应用于 newTodo 对象。(这是一种将表单提交转化为 Java 对象的快速而简单的方法)。

接下来,我们使用项目 ID 来操作项目列表:这是标准的 REST API 方法。

发送响应有两种方式。如果方法上有 @ResponseBody 注解(比如 deleteTodo()),那么返回的内容将逐字发送。否则,返回字符串将被解释为 Thymeleaf 模板路径(稍后您将看到)。

Model 模型参数比较特殊。它用于为移交给 Thymeleaf 的作用域添加属性。我们可以把下面的项目方法理解为给定一个指向根/路径的 GET 请求,将 items 变量添加到作用域中,命名为 "itemList",然后使用 "index "模板渲染响应。

@GetMapping("/")
  public String items(Model model) {
    model.addAttribute(
"itemList", items);
    return
"index";
  }

在 HTMX 处理从前端发送的 AJAX 请求时,HTMX 组件将使用响应来更新用户界面。我们很快就能在实践中很好地了解这一点。

Thymeleaf 模板
现在让我们来看看 Thymeleaf 的索引模板。它位于 /resources/templates/index.html 文件中。Spring Boot 使用约定将 items() 方法返回的 "index "字符串映射到该文件。下面是我们的 index.html 模板:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset=
"UTF-8">
    <title>Items List</title>
    <script src=
"https://unpkg.com/htmx.org@1.9.12"></script>
    <link rel=
"stylesheet" href="style.css">
  </head>
  <body>
    <h1>Stuff To Do</h1>
      <ul>
        <th:block th:each=
"item : ${itemList}">
          <th:block th:replace=
"~{'todo.html'}" th:args="${item}"></th:block>
        </th:block>
      </ul>
      <hr>
      <form hx-post=
"/todos" th:object="${newTodo}" hx-target="ul" hx-swap="beforeend">
        <input type=
"text" name="description" placeholder="Add a new item..." required>
        <button type=
"submit">Add</button>
      </form>
  </body>
</html>

Thymeleaf 的基本思想是在 HTML 结构中使用 Java 变量。(这相当于使用 Pug 这样的模板系统)。

Thymeleaf 使用以 th: 为前缀的 HTML 属性或元素来表示其工作位置。请记住,当我们在控制器中映射根/路径时,我们在作用域中添加了 itemList 变量。在这里,我们在带有 th:each 属性的 th:block 中使用该变量。th:each 属性是 Thymeleaf 中的迭代器机制。我们用它来访问 itemList 中的元素,并将每个元素作为名为 item 的变量公开:item : ${itemList}。

在 itemList 的每次迭代中,我们都将渲染工作交给另一个模板。这种模板重用是避免代码重复的关键。
行:

<th:block th:replace="~{'todo.html'}"th:args="${item}"></th:block>

告诉 Thymeleaf 渲染 todo.html 模板,并将项目作为参数。

接下来我们将了解 todo 模板,但首先要注意的是,我们在控制器中的 completeTodo 和 createTodo 中都使用了相同的模板,以提供在 Ajax 请求期间发送回 HTMX 的标记。换句话说,我们将 todo.html 用作初始列表渲染的一部分,并在 Ajax 请求期间向用户界面发送更新。重复使用 Thymeleaf 模板可以使我们保持 DRY。

待办事项模板
这是 todo.html:

<li>
  <input type="checkbox" th:checked="${item.isCompleted}" hx-trigger="click" hx-target="closest li" hx-swap="outerHTML" th:hx-post="|/todos/${item.id}/complete|">
  <span th:text=
"${item.description}" th:classappend="${item.isCompleted ? 'complete' : ''}"></span>
  <button type=
"button" th:hx-post="|/todos/${item.id}/delete|" hx-swap="outerHTML" hx-target="closest li"></button>
</li>

您可以看到,我们提供了一个 list-item 元素,并使用一个变量 item 来填充值。在这里,我们将使用 HTMX 和 Thymeleaf 进行一些有趣的工作。

首先,我们使用 th:checked 将 item.isComplete 的选中状态应用于复选框输入。

点击复选框时,我们会使用 HTMX 向后端发出 Ajax 请求:

  • hx-trigger="click" 告知 HTMX 在点击时启动 Ajax。
  • hx-target="closest li "告诉 HTMX 将 Ajax 请求的响应放在哪里。在我们的例子中,我们要替换最近的 list-item 元素。(请记住,我们的删除端点会返回该项目的整个列表项目标记)。
  • hx-swap="outerHTML "告诉 HTMX 如何替换新内容,在本例中就是替换整个元素。
  • th:hx-post="|/todos/${item.id}/complete|"告诉 HTMX,这是一个活动的 Ajax 元素,会向指定的 URL(我们的 completeTodo 端点)发出 POST 请求。

将 Thymeleaf 与 HTMX 结合使用时需要注意的一点是,您最终会使用复杂的属性前缀,就像您在 th:hx-post 中看到的那样。从本质上讲,Thymeleaf 首先在服务器上运行(th: 前缀)并填充 ${item.id} 插值,然后 hx-post 在客户端上正常工作。

接下来,对于 span,我们只需显示 item.description 中的文本。(请注意,Thymelef 的表达式语言允许我们访问字段而无需使用 get 前缀)。同样值得注意的是,我们如何在 span 元素中应用已完成的样式类。下面是我们的 CSS 将用于为已完成的项目添加删除线装饰:

th:classappend="${item.isCompleted ? 'complete' : ''}"

使用 Thymeleaf 属性,可以根据 item.isComplete 等布尔条件有条件地应用类。

我们的删除按钮与完整复选框的工作原理类似。我们使用 Thymeleaf 提供的 item.id 向 URL 发送 Ajax 请求,当响应返回时,我们更新列表项。请记住,我们从 deleteTodo() 发回的是空字符串。因此,其效果是从 DOM 中移除列表项。

CSS 样式表
CSS 样式表位于 src/main/resources/static/style.css,没什么特别的。唯一有趣的是处理跨页上的span样式:

span {
  flex-grow: 1;
  font-size: 1rem;
  text-decoration: none;
  color: #333;
  opacity: 0.7;
}

span.complete {
  text-decoration: line-through;
  opacity: 1;
}

结论
将 HTMX、Java、Spring 和 Thymeleaf 结合在一起,可以用最少的模板代码构建相当复杂的交互。我们可以在不编写 JavaScript 的情况下实现大量典型的交互。

乍一看,Java-HTMX 协议栈似乎终于兑现了以 Java 为中心的 Ajax 的承诺;就像 Google Web Toolkit 曾经设定的目标一样。

但事实并非如此。

HTMX 试图将Web应用程序重新定位到 REST 的真正本质,而这个协议栈为我们指明了方向。

HTMX 与服务器端无关,因此我们可以毫无困难地将其与 Java 后端集成。