Thymeleaf

Thymeleaf 是 Spring Boot 默认推荐的服务端模板引擎,模板文件是合法的 HTML,可在浏览器直接打开预览(自然模板)。与 MVC 配合,渲染后的 HTML 由服务端返回给客户端。


依赖与配置

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
spring:
  thymeleaf:
    prefix: classpath:/templates/   # 模板根目录
    suffix: .html
    encoding: UTF-8
    mode: HTML                      # HTML / XML / TEXT / JAVASCRIPT
    cache: true                     # 生产环境开启;开发时设 false 实时刷新
    check-template-location: true

模板文件放在 src/main/resources/templates/,静态资源(CSS/JS/图片)放在 src/main/resources/static/,详见 静态资源


Controller 返回视图

@Controller          // 注意:不是 @RestController
@RequestMapping("/users")
public class UserViewController {
 
    private final UserService userService;
 
    @GetMapping("/{id}")
    public String userDetail(@PathVariable Long id, Model model) {
        model.addAttribute("user", userService.findById(id));
        model.addAttribute("title", "用户详情");
        return "user/detail";   // 对应 templates/user/detail.html
    }
 
    @GetMapping
    public String userList(
            @RequestParam(defaultValue = "0") int page,
            Model model) {
        model.addAttribute("users", userService.findAll(page));
        return "user/list";
    }
}

核心语法

文本与表达式

<!-- 变量表达式 -->
<span th:text="${user.name}">占位文本</span>
 
<!-- 转义 HTML(默认)vs 不转义(th:utext,慎用) -->
<p th:text="${description}">描述</p>
<p th:utext="${richContent}">富文本</p>
 
<!-- 国际化消息 -->
<h1 th:text="#{page.title}">标题</h1>
 
<!-- URL 表达式 -->
<a th:href="@{/users/{id}(id=${user.id})}">查看详情</a>
<img th:src="@{/images/logo.png}" alt="logo">
 
<!-- 片段引用 -->
<div th:insert="~{fragments/header :: nav}"></div>

条件判断

<!-- if / unless -->
<div th:if="${user.admin}">管理员菜单</div>
<div th:unless="${user.admin}">普通用户菜单</div>
 
<!-- switch -->
<span th:switch="${order.status}">
    <span th:case="'PENDING'">待付款</span>
    <span th:case="'PAID'">已付款</span>
    <span th:case="'SHIPPED'">已发货</span>
    <span th:case="*">未知状态</span>
</span>

循环遍历

<table>
  <thead>
    <tr><th>序号</th><th>姓名</th><th>邮箱</th><th>状态</th></tr>
  </thead>
  <tbody>
    <!-- iterStat 是循环状态变量(可选) -->
    <tr th:each="user, iterStat : ${users}"
        th:class="${iterStat.odd} ? 'odd' : 'even'">
      <td th:text="${iterStat.count}">1</td>
      <td th:text="${user.name}">张三</td>
      <td th:text="${user.email}">test@example.com</td>
      <td>
        <span th:text="${user.active} ? '启用' : '禁用'"
              th:class="${user.active} ? 'badge-success' : 'badge-danger'">
          启用
        </span>
      </td>
    </tr>
  </tbody>
</table>

循环状态变量属性:index(0 起)、count(1 起)、sizefirstlastoddeven

属性操作

<!-- 动态属性 -->
<input th:value="${user.name}" th:placeholder="#{form.name.placeholder}">
 
<!-- CSS class 合并 -->
<div class="card" th:classappend="${user.vip} ? 'card-gold' : ''"></div>
 
<!-- 内联 style -->
<span th:style="'color:' + ${user.themeColor}">文字</span>
 
<!-- 布尔属性(checked / disabled / selected) -->
<input type="checkbox" th:checked="${user.active}">
<button th:disabled="${!canEdit}">编辑</button>
 
<!-- 自定义属性 -->
<div th:attr="data-id=${user.id}, data-role=${user.role}"></div>

内联表达式

在文本节点中嵌入表达式,避免 th:text 导致标签变形:

<!-- 文本内联 -->
<p>欢迎,[[${user.name}]]!您有 [[${msgCount}]] 条新消息。</p>
 
<!-- JavaScript 内联(自动 JSON 序列化) -->
<script th:inline="javascript">
    const currentUser = /*[[${user}]]*/ null;
    const config = /*[[${appConfig}]]*/ {};
</script>

模板片段与布局

定义片段(fragments/common.html)

<!-- templates/fragments/common.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
 
<!-- 导航栏片段 -->
<nav th:fragment="navbar">
    <a th:href="@{/}">首页</a>
    <a th:href="@{/users}">用户</a>
    <span th:text="${#authentication?.name}">用户名</span>
</nav>
 
<!-- 页脚片段 -->
<footer th:fragment="footer">
    <p th:text="'© ' + ${#dates.year(#dates.createNow())} + ' My App'">© 2024</p>
</footer>
 
</body>
</html>

引用片段

<!-- th:insert:插入片段(保留宿主标签) -->
<div th:insert="~{fragments/common :: navbar}"></div>
 
<!-- th:replace:替换宿主标签为片段 -->
<div th:replace="~{fragments/common :: footer}"></div>
 
<!-- 传参数给片段 -->
<div th:replace="~{fragments/common :: card(title='用户列表', count=${users.size()})}"></div>
 
<!-- 片段定义(带参数) -->
<div th:fragment="card(title, count)">
    <h3 th:text="${title}">标题</h3>
    <span th:text="${count}">0</span>
</div>

布局模板(Thymeleaf Layout Dialect)

<dependency>
    <groupId>nz.net.ultraq.thymeleaf</groupId>
    <artifactId>thymeleaf-layout-dialect</artifactId>
</dependency>
<!-- templates/layout/base.html -->
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
    <title layout:title-pattern="$CONTENT_TITLE - My App">My App</title>
    <link rel="stylesheet" th:href="@{/css/app.css}">
</head>
<body>
    <div th:replace="~{fragments/common :: navbar}"></div>
    <main layout:fragment="content"><!-- 子页面内容 --></main>
    <div th:replace="~{fragments/common :: footer}"></div>
</body>
</html>
 
<!-- templates/user/list.html(子页面) -->
<html layout:decorate="~{layout/base}">
<head><title>用户列表</title></head>
<body>
    <div layout:fragment="content">
        <!-- 这里写页面特有内容 -->
        <h1>用户列表</h1>
    </div>
</body>
</html>

表单处理

// Controller:绑定表单对象
@GetMapping("/create")
public String showCreateForm(Model model) {
    model.addAttribute("userForm", new UserForm());
    return "user/create";
}
 
@PostMapping("/create")
public String submitCreateForm(
        @Valid @ModelAttribute("userForm") UserForm form,
        BindingResult result, RedirectAttributes ra) {
    if (result.hasErrors()) {
        return "user/create";    // 有错误返回表单页
    }
    userService.create(form);
    ra.addFlashAttribute("success", "用户创建成功");
    return "redirect:/users";
}
<!-- templates/user/create.html -->
<form th:action="@{/users/create}" th:object="${userForm}" method="post">
 
    <div>
        <label for="name">姓名</label>
        <input id="name" type="text" th:field="*{name}"
               th:class="${#fields.hasErrors('name')} ? 'input-error' : ''">
        <!-- 显示字段错误 -->
        <span th:if="${#fields.hasErrors('name')}"
              th:errors="*{name}" class="error-msg"></span>
    </div>
 
    <div>
        <label for="email">邮箱</label>
        <input id="email" type="email" th:field="*{email}">
        <span th:errors="*{email}" class="error-msg"></span>
    </div>
 
    <div>
        <label for="role">角色</label>
        <select id="role" th:field="*{role}">
            <option th:each="r : ${T(com.example.Role).values()}"
                    th:value="${r}" th:text="${r.label}">
            </option>
        </select>
    </div>
 
    <button type="submit">提交</button>
</form>

th:field="*{name}" 等价于同时设置 idnamevalue(绑定到 th:object 指定的对象)。


Spring Security 集成

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
 
<!-- 根据认证状态显示 -->
<div sec:authorize="isAuthenticated()">
    欢迎,<span sec:authentication="name">用户名</span>
</div>
<div sec:authorize="!isAuthenticated()">
    <a th:href="@{/login}">登录</a>
</div>
 
<!-- 根据角色/权限显示 -->
<a sec:authorize="hasRole('ADMIN')" th:href="@{/admin}">管理后台</a>
<button sec:authorize="hasAuthority('order:delete')">删除订单</button>

完整安全配置详见 安全,方法级权限详见 方法级安全


内置工具对象

Thymeleaf 在 # 命名空间下提供工具类:

<!-- 日期格式化 -->
<span th:text="${#temporals.format(order.createdAt, 'yyyy-MM-dd HH:mm')}"></span>
 
<!-- 字符串工具 -->
<p th:if="${#strings.isEmpty(user.bio)}">暂无简介</p>
<span th:text="${#strings.abbreviate(content, 100)}"></span>
 
<!-- 集合工具 -->
<p th:text="'共 ' + ${#lists.size(users)} + ' 条'"></p>
 
<!-- 数字格式化 -->
<span th:text="${#numbers.formatDecimal(price, 1, 2)}">0.00</span>
 
<!-- 对象判空 -->
<div th:if="${#objects.isEmpty(user)}">用户不存在</div>

国际化

<!-- 使用消息键 -->
<h1 th:text="#{user.list.title}">用户列表</h1>
<p th:text="#{user.count(${users.size()})}">共 0 个用户</p>
# messages_zh_CN.properties
user.list.title=用户列表
user.count=共 {0} 个用户

国际化配置详见 国际化


开发调试

# 开发时关闭缓存,修改模板立即生效
spring:
  thymeleaf:
    cache: false
  devtools:
    restart:
      enabled: true

配合 热部署与DevTools 可以实现模板热更新而无需重启应用。


相关链接