1. 从新手到高手:重新认识Thymeleaf的条件与循环
如果你刚开始接触Spring Boot项目,尤其是像若依这样的快速开发框架,前端页面里那些以th:开头的标签可能会让你有点懵。我记得我第一次看到th:if和th:each的时候,心里就在想:“这不就是JSP里的JSTL标签吗?怎么又换了一套写法?” 但用了一段时间后,我发现Thymeleaf的设计确实更优雅,特别是它支持HTML原型直接在浏览器里预览,这对前端调试来说太友好了。
很多新手教程只会告诉你th:if是用来做条件判断的,th:each是用来循环遍历列表的,然后给几个简单的例子就结束了。但实际开发中,我们面对的场景要复杂得多。比如,你可能需要根据用户的角色、订单的状态、数据的有效性等多个条件组合来决定一个按钮是否显示;又或者,你需要遍历一个嵌套了好几层的复杂对象,比如一个包含商品列表的订单,而每个商品又有自己的规格属性列表。如果只掌握基础用法,遇到这些情况就会束手无策。
这篇文章,我就结合自己这些年踩过的坑和积累的经验,跟你深入聊聊th:if和th:each的进阶玩法。我不会只讲语法,而是会通过一个个真实的开发场景,告诉你如何用它们写出更简洁、更高效、也更易于维护的模板代码。我们的目标不是记住几个标签,而是掌握一种解决问题的思路。
2. th:if条件判断:不止是“显示”与“隐藏”
th:if的基础用法很简单:当表达式的结果为true时,渲染这个HTML元素;为false时,整个元素都不会出现在最终的HTML里。但它的威力,远不止于此。
2.1 多条件组合的优雅写法
原始文章里提到了用and来连接多个条件,比如(${t.pid}==${s.id}) and ${t.recycle!=1}。这没问题,但当条件越来越多时,一行代码会变得又长又难读。我推荐两种更清晰的做法。
第一种,利用Thymeleaf的内置工具对象。 比如,你经常需要判断一个集合是否为空,或者一个字符串是否有内容。与其写冗长的${list != null and !list.isEmpty()},不如直接用#lists和#strings工具类:
<div th:if="${#lists.isEmpty(userList)}">
<p>暂时没有用户数据。</p>
</div>
<div th:if="${#strings.isEmpty(searchKeyword)}">
<p>请输入搜索关键词。</p>
</div>
#lists.isEmpty()会同时处理null和空集合的情况,比你自己写判断更安全、更简洁。#strings工具类还能做很多事,比如判断开头结尾、计算长度、安全比较等。
第二种,将复杂条件判断移到后端。 这是很多新手容易忽略的一点。模板引擎是为了展示逻辑,而不是承载复杂的业务逻辑。如果一个元素的显示与否,需要依赖五六个字段的计算结果,那么最好在后端Controller或Service层就计算好这个布尔值,然后以一个简单的属性传给前端。
// 在Controller或Service中
model.addAttribute("showAdvancedOption", user.isVIP() && order.getAmount() > 1000 && config.isFeatureEnabled());
<!-- 在前端模板中 -->
<button th:if="${showAdvancedOption}" class="btn btn-premium">VIP专属功能</button>
这样,前端的模板会非常干净,而且业务逻辑的修改只需要在后端进行,不会影响到视图层。
2.2 善用th:unless和三元运算符
th:unless是th:if的反义词,当表达式为false时渲染元素。它特别适合用于“默认显示,仅在特定条件下隐藏”的场景。比如,一个删除按钮,只有管理员能看到:
<!-- 使用 th:if -->
<button th:if="${user.role == 'ADMIN'}" class="btn btn-danger">删除</button>
<!-- 使用 th:unless,逻辑更直接:非管理员不显示 -->
<button th:unless="${user.role != 'ADMIN'}" class="btn btn-danger">删除</button>
虽然这个例子中两者差异不大,但th:unless有时能让意图更明确。
对于简单的“二选一”内容渲染,三元运算符是更紧凑的选择。它可以直接用在属性值里:
<span th:class="${user.active} ? 'text-success' : 'text-muted'">
[[${user.active} ? '在线' : '离线']]
</span>
<p th:text="${task.priority > 5} ? '高优先级任务' : '普通任务'"></p>
注意,这里我用的是[[...]]内联表达式来输出文本,它和th:text效果类似,但可以直接写在HTML标签体内,对于简单的文本插值非常方便。
2.3 处理null值的安全策略
空指针异常是Java开发者的“老朋友”,在模板里也不例外。直接使用可能为null的属性,比如${user.profile.address.city},如果profile为null,页面就会渲染错误。
Thymeleaf提供了一种安全的导航操作符?.,它和Java 8的Optional有点像。如果.前面的对象为null,整个表达式会优雅地返回null,而不会抛出异常。
<!-- 危险写法,profile可能为null -->
<p>城市:[[${user.profile.address.city}]]</p>
<!-- 安全写法 -->
<p>城市:[[${user?.profile?.address?.city}]]</p>
结合th:if,我们可以先判断再使用:
<div th:if="${user?.profile?.address != null}">
<p>地址:[[${user.profile.address.fullAddress}]]</p>
</div>
<div th:unless="${user?.profile?.address != null}">
<p>该用户未填写地址。</p>
</div>
这种写法虽然安全,但嵌套太深也会影响可读性。所以,再次强调,对于特别复杂的对象图,最好在后端就处理好,把最终需要展示的、已经确保非空的字符串传给前端。
3. th:each循环遍历:驾驭复杂数据结构
th:each是处理列表数据的利器,但它的能力远不止遍历一个简单的List<String>。
3.1 理解状态变量,获取循环元信息
在th:each="item : ${items}"这个基本语法里,我们只声明了一个迭代变量item。但Thymeleaf还允许我们声明第二个变量,即状态变量。这个名字你可以自己定,通常叫iterStat、status或者loop。
<tr th:each="product, stat : ${productList}">
<td>序号:[[${stat.index + 1}]]</td>
<td>商品名:[[${product.name}]]</td>
<td>是否是偶数行:[[${stat.even}]]</td>
<td>是否是第一项:[[${stat.first}]]</td>
</tr>
状态变量提供了丰富的元信息,我整理了一个表格,方便你查阅:
| 属性名 | 说明 | 示例值(假设遍历5个元素) |
|---|---|---|
index | 当前迭代的索引,从0开始 | 0, 1, 2, 3, 4 |
count | 当前迭代的计数,从1开始 | 1, 2, 3, 4, 5 |
size | 被迭代对象的总大小 | 5 |
current | 当前迭代变量本身,等同于item | product对象 |
even / odd | 布尔值,当前索引是否为偶数/奇数 | true/false交替 |
first / last | 布尔值,当前项是否为第一项/最后一项 | 仅首尾为true |
这些信息非常实用。比如,用stat.even和stat.odd来实现表格行的斑马纹交替背景色:
<tr th:each="item, stat : ${list}"
th:class="${stat.odd} ? 'table-row-odd' : 'table-row-even'">
...
</tr>
或者,在遍历时,给最后一项添加一个特殊的样式或操作:
<div th:each="step, iter : ${steps}">
<span>步骤 [[${iter.count}]]</span>
<hr th:if="${!iter.last}" /> <!-- 如果不是最后一项,才显示分隔线 -->
</div>
3.2 遍历Map和嵌套集合
实际数据很少是平坦的。我们经常需要处理Map<String, Object>或者对象里嵌套集合的情况。
遍历Map时,每次迭代会得到一个Map.Entry对象,它有key和value两个属性:
<dl th:each="entry : ${categoryMap}">
<dt>分类键:[[${entry.key}]]</dt>
<dd>分类名:[[${entry.value}]]</dd>
</dl>
遍历嵌套集合是更常见的挑战。比如,一个订单列表,每个订单里又有商品列表。这里的关键是理清层次关系,为每一层的迭代变量取一个清晰的名字。
<!-- 假设 orders 是一个 List<Order>,每个Order有一个 items 属性是 List<OrderItem> -->
<div th:each="order, orderStat : ${orders}">
<h3>订单号:[[${order.orderNo}]] (共 [[${order.items.size()}]] 件商品)</h3>
<table>
<tr th:each="item, itemStat : ${order.items}">
<td>[[${itemStat.count}]]</td> <!-- 商品在当前订单内的序号 -->
<td>[[${item.productName}]]</td>
<td>[[${item.quantity}]]</td>
<!-- 注意:这里要获取“祖父级”的orderStat.index,需要谨慎 -->
<td>订单索引:[[${orderStat.index}]],商品索引:[[${itemStat.index}]]</td>
</tr>
</table>
</div>
在这个例子里,order和item分别代表外层和内层的当前对象,orderStat和itemStat是它们各自的状态变量。你可以通过orderStat.index和itemStat.index清楚地知道当前商品位于哪个订单、以及在该订单中的位置。这种清晰的命名能极大避免在复杂循环中搞混变量。
3.3 性能优化与分页思考
虽然th:each用起来很方便,但直接把成千上万条数据的列表丢给它遍历,肯定会严重影响页面渲染性能。对于大数据集,分页是必须的。
通常,分页逻辑在后端完成,Controller会查询当前页的数据(比如一个Page<User>对象)和分页信息(总页数、当前页码等)传给前端。前端模板的职责主要是展示当前页的数据和渲染分页控件。
<!-- 遍历当前页的数据 -->
<tr th:each="user : ${page.content}">
<td>[[${user.name}]]</td>
...
</tr>
<!-- 渲染分页导航 -->
<nav>
<ul class="pagination">
<li th:class="${page.first} ? 'disabled' : ''">
<a th:href="@{/users(page=${page.number - 1})}">上一页</a>
</li>
<!-- 这里可以再用一个th:each来生成页码,但注意控制显示的页码数量,避免过多 -->
<li th:each="i : ${#numbers.sequence(1, page.totalPages)}"
th:class="${(page.number + 1) == i} ? 'active' : ''">
<a th:href="@{/users(page=${i - 1})}">[[${i}]]</a>
</li>
<li th:class="${page.last} ? 'disabled' : ''">
<a th:href="@{/users(page=${page.number + 1})}">下一页</a>
</li>
</ul>
</nav>
这里用到了另一个有用的工具#numbers.sequence(start, end)来生成一个数字序列,用于循环生成页码链接。同时,通过判断page.first和page.last来禁用“上一页”和“下一页”按钮。
注意:在模板中进行复杂的计算(比如生成分页序列)会增加模板的复杂度。更常见的做法是,在后端将计算好的分页导航数据(如一个包含页码范围、是否有上一页/下一页的VO对象)直接传给前端,前端只负责渲染。这再次印证了那个原则:让模板保持简单,把复杂逻辑留给后端。
4. 实战融合:th:if与th:each的组合拳
单独使用th:if和th:each已经很强大了,但当它们组合在一起时,才能解决真正复杂的UI逻辑。
4.1 在循环内部进行条件判断
这是最常见的组合场景。在遍历列表时,根据每一项的特定属性,决定如何渲染该项。
<ul>
<li th:each="notification : ${notifications}">
<div th:if="${notification.type == 'ALERT'}" class="alert alert-warning">
<strong>警告!</strong> [[${notification.content}]]
</div>
<div th:if="${notification.type == 'INFO'}" class="alert alert-info">
<i class="icon-info"></i> [[${notification.content}]]
</div>
<div th:unless="${notification.type == 'ALERT' or notification.type == 'INFO'}">
普通消息:[[${notification.content}]]
</div>
</li>
</ul>
在这个通知列表的例子中,我们根据type字段,为不同类型的通知渲染不同的HTML结构和样式。th:unless在这里用于兜底,处理其他未知或普通的类型。
4.2 循环前/后的条件控制
有时候,我们不仅要在循环内部判断,还要控制整个循环是否执行,或者在循环结束后做一些事情。
<!-- 先判断列表是否存在且非空,再循环 -->
<div th:if="${not #lists.isEmpty(products)}">
<table>
<tr th:each="prod : ${products}">
<td>[[${prod.name}]]</td>
</tr>
</table>
<p class="text-muted">共找到 [[${#lists.size(products)}]] 条记录。</p>
</div>
<div th:if="${#lists.isEmpty(products)}">
<div class="alert alert-info">没有找到相关商品。</div>
</div>
这个模式非常实用:先检查数据,如果有数据就渲染表格和统计信息;如果没有数据,就显示一个友好的空状态提示,而不是一个空荡荡的表格或者直接报错。
4.3 实现复杂的行内操作按钮
结合th:each和th:onclick,我们可以为列表的每一行动态生成操作按钮,并将当前行的数据作为参数传递给JavaScript函数。这是实现“编辑”、“删除”等行内操作的关键。
原始文章里给出了一个th:onclick传递多参数的例子,用的是removeFile([[${userStat.id}]],[[${userStat.returnContractPath}]])。这里有个非常重要的细节:参数被[[...]]包裹。这是Thymeleaf的内联表达式,它确保在JavaScript上下文中,这些值被正确地输出为JavaScript字面量(字符串会被加上引号,数字则不会),避免了语法错误。
我们来写一个更完整的例子,包含编辑和删除:
<table>
<tr th:each="item, stat : ${dataList}">
<td>[[${item.name}]]</td>
<td>
<!-- 编辑按钮:跳转到编辑页面 -->
<a th:href="@{/item/edit(id=${item.id})}" class="btn btn-sm btn-outline-primary">编辑</a>
<!-- 删除按钮:触发JS确认并调用API -->
<button type="button" class="btn btn-sm btn-outline-danger"
th:onclick="'confirmDelete(\'' + ${item.id} + '\', \'' + ${item.name} + '\')'">
删除
</button>
<!-- 启用/禁用状态切换按钮 -->
<button type="button" class="btn btn-sm"
th:class="${item.enabled} ? 'btn-success' : 'btn-secondary'"
th:onclick="'toggleStatus(\'' + ${item.id} + '\', ' + ${item.enabled} + ')'"
th:text="${item.enabled} ? '已启用' : '已禁用'">
</button>
</td>
</tr>
</table>
<script th:inline="javascript">
/*<![CDATA[*/
function confirmDelete(id, name) {
if (confirm('确定要删除“' + name + '”吗?此操作不可恢复。')) {
// 这里发起Ajax请求到后端删除
fetch('/api/item/' + id, { method: 'DELETE' })
.then(response => {
if (response.ok) {
alert('删除成功');
location.reload(); // 刷新页面
} else {
alert('删除失败');
}
});
}
}
function toggleStatus(id, currentStatus) {
var newStatus = !currentStatus;
// 发起Ajax请求更新状态
// ...
}
/*]]>*/
</script>
注意th:onclick里字符串的拼接。为了生成正确的JavaScript函数调用字符串,我们需要用单引号'将字符串类型的参数(如id, name)包裹起来。对于布尔值或数字类型的参数(如item.enabled),则不需要加引号。这种拼接虽然有效,但容易出错且不美观。更推荐的做法是使用内联表达式的另一种形式,或者将逻辑移到单独的JavaScript文件中,通过data-*属性来传递参数。
5. 避坑指南与最佳实践
掌握了高级技巧,还要懂得如何避开常见的陷阱,并遵循一些最佳实践,才能写出健壮的代码。
5.1 警惕th:each与th:onclick的常见陷阱
陷阱一:在th:onclick中错误地调用后端方法。 我见过有人这样写:th:onclick="${item.deleteMethod()}"。这是完全错误的!${}表达式会在服务器端、页面渲染时就被执行。这意味着页面每次加载(包括刷新),都会调用这个deleteMethod(),而不是在用户点击按钮时才调用。th:onclick的属性值应该是一个JavaScript函数调用字符串,它只在浏览器端被点击时执行。
陷阱二:循环中生成ID的重复问题。 如果你在循环内为元素设置id,比如th:id="'item-' + ${item.id}",这本身没问题。但如果你在循环内又用JavaScript通过这个id去获取元素,就要小心了。更好的做法是使用class配合data-*属性,或者利用事件委托。
<!-- 不太好的做法:每个按钮都有一个唯一的id,JS需要循环绑定 -->
<button th:each="item : ${list}" th:id="'btn-' + ${item.id}" class="btn">删除</button>
<script>
// 需要为每个按钮单独绑定事件
</script>
<!-- 更好的做法:使用相同的class和data属性 -->
<button th:each="item : ${list}" class="btn-delete" th:data-id="${item.id}">删除</button>
<script>
// 事件委托,只需绑定一次
document.addEventListener('click', function(e) {
if (e.target.classList.contains('btn-delete')) {
var id = e.target.dataset.id;
confirmDelete(id);
}
});
</script>
5.2 保持模板简洁可维护
- 最小化逻辑:模板的主要职责是展示。尽可能将复杂的判断、计算、数据转换逻辑放到后端的Controller、Service或者一个专门的工具类中。传给模板的数据应该是“准备好被展示”的形态。
- 使用片段(Fragment)复用代码:如果某段带有复杂
th:if和th:each的HTML结构在多个页面出现,不要复制粘贴。使用Thymeleaf的th:fragment和th:replace(或th:insert)来定义和引用代码片段。 - 注释是必要的:对于特别复杂的条件判断或嵌套循环,添加简单的Thymeleaf注释(
<!--/* 这是解释 */-->)可以帮助你和其他开发者理解代码意图。
5.3 调试技巧
当th:if或th:each没有按预期工作时,别慌。首先,检查浏览器中最终生成的HTML源代码(不是Thymeleaf模板文件),看看元素是否被渲染出来,属性值是否正确。其次,可以在Controller中打印或调试传到模型(Model)里的数据,确保数据本身是正确的。有时候,问题不是出在模板语法上,而是后端传的数据不对。
Thymeleaf的条件判断和循环遍历,就像是你手中的瑞士军刀,基础功能简单,但组合起来能应对各种复杂场景。核心思想永远是:让模板专注于视图渲染,将业务逻辑后置。多练习,多思考如何将复杂的UI需求拆解成简单的th:if和th:each的组合,你会发现自己处理前端动态页面的能力越来越强。
1191



被折叠的 条评论
为什么被折叠?



