1. th:block 到底是什么:一个不“存在”的标签
1.1 为什么需要这样一个特殊标签
Thymeleaf 作为 Java 后端最常用的模板引擎,在大多数情况下我们用 th:text、th:each、th:if 就能解决页面渲染需求。但等模板页面真正复杂起来之后,几乎所有做过后端渲染的开发者都会遇到同一个尴尬场景:想把多个同级元素当作一个整体来控制循环或条件判断,但 HTML 结构根本不让你额外包一层标签。
我用一个实际案例来解释这个痛点。假设我们在做一个订单后台,需要在表格的每一行里显示多个单元格,而且这多个单元格需要整体循环输出。如果你顺手写了一个<div th:each="item : ${list}">包在外面,Thymeleaf 会老老实实渲染这个 div。但浏览器遇到<div>出现在<tr>和<td>之间时,轻则自动把 div 踢出表格结构,重则直接打乱整张表的渲染布局。这时候 th:block 就派上用场了。
th:block 本身不会被渲染到最终的 HTML 输出中,它仅仅在模板解析阶段充当一个逻辑容器。换句话说,你在模板里写<th:block>只是告诉 Thymeleaf“这几个元素是一伙的,请把控制指令作用在这群元素上”,但浏览器最终看到的 HTML 里不会有任何这个标签产生的痕迹。一句话概括:它在源码里存在,在页面上“隐身”。
打个比方:就好比搬家时用一个大袋子把零碎物品统一装好,搬运时很方便,到了新家后你只需要把物品取出来单独摆放,这个袋子本身不会留在房间里。th:block 就是搬运时那只用完就扔的袋子。
1.2 th:block 和普通标签的本质区别
刚接触 th:block 的人最容易把它和<div>、<span>甚至 HTML5 的<template>标签搞混。我之前带团队做项目时,有个同事为了图省事,把所有 th:block 都改成<div style="display:none">来处理,结果页面的表格布局出现了各种意想不到的错位问题,排查了很久才发现是隐藏 div 捣的鬼。这里我整理了一张对照表,方便直观区分:
| 标签/方式 | 是否输出到最终 HTML | 渲染层行为 | 典型适用场景 |
|---|---|---|---|
<div th:each="..."> | 输出 div 节点 | 不改变 DOM 数量 | 可以接受额外包裹层的场景 |
<span th:if="..."> | 输出 span 节点 | 增加行内元素 | 单元素条件渲染 |
<template> | 默认不渲染内容 | 浏览器原生特性,受限于兼容性 | 无后端逻辑的纯前端场景 |
<th:block> | 完全不输出任何节点 | 逻辑分组,无 DOM 副作用 | 多元素循环、多元素条件控制 |
重点说一下<template>标签。很多人觉得 th:block 和它很像,但实际上使用体感差异很大。<template>里面的内容默认就是不渲染的,需要通过 JavaScript 操作它的 content 属性才能动态拿出来用。如果你用<template th:each="...">去控制循环,那循环出来的所有内容都会被浏览器“藏起来”,除非你自己写 JS 去把它们搬回页面,非常绕。而且如果你的项目要兼顾老版本的浏览器,<template>的兼容性也是个隐患。
Thymeleaf 官方提供 th:block 这个特殊标签,本质上是把“逻辑分组”和“页面结构”彻底解耦了。你在模板阶段告诉引擎“这一段操作要应用在这几个兄弟节点上”,而不是为了让代码好看去硬生生制造一个多余的 DOM 层级。理解了这一点,后续所有 th:block 的玩法就都通了。
2. 核心应用场景拆解:这几个地方真的离不开它
2.1 表格数据分组循环:最刚需的使用场景
先聊表格,这是 th:block 出现频率最高、也最无可替代的战场。
假如你现在要渲染一个商品分类汇总表,后端返回的数据结构是一个List<List<Product>>,也就是按分类把商品分成若干组,每组内包含多个商品,页面需要每个分类显示一行,分类里的每个商品显示成一个<td>,当类别商品数量比较多时可以横向铺开。
新手最容易写错的是直接在<tr>上套 th:each:
<tr th:each="product : ${products}"> <td th:text="${product.name}">商品名</td> <td th:text="${product.price}">价格</td> </tr>这会循环生成多个产品行,而不是在一行里放多个产品。如果换一种思路,在<td>上套 th:each:
<tr> <td th:each="product : ${products}" th:text="${product.name}">商品名</td> </tr>这样虽然能横向铺开商品列,但你又丢掉了分组能力,因为整张表的<tr>数量没办法基于分组来动态控制。正确的姿势是用 th:block 在<tr>渲染完成后“包一层逻辑循环”:
<table> <th:block th:each="item : ${groupedProducts}"> <tr> <td th:text="${item.groupName}">分类名</td> <td th:each="product : ${item.products}" th:text="${product.name}">商品</td> </tr> </th:block> </table>细看这段代码,<th:block>本身不产生任何标签节点,但它的 th:each 循环每迭代一次,就会把块内部的一个<tr>整个输出一遍。这样做的好处是循环作用在整个<tr>上,却不需要给<tr>额外包一个 div 或者让<tr>自身去承载繁琐的循环逻辑。最终渲染出来的 HTML 就是一张完全合法、没有任何多余包裹元素的表格。
这个用法一定要自己动手敲一遍,因为它是 th:block 的核心逻辑基础,后面的条件渲染、模板片段复用都是建立在同一套思维上的。
2.2 多元素条件渲染:th:if 和 th:block 的黄金搭档
在普通情况下,th:if 可以直接加在一个标签上控制它是否渲染。但实际页面里很多条件不是作用于单个元素,而是一组兄弟元素要“同生共死”。
我在开发某个报表模块时遇到过一个需求:订单状态为“已支付”时,需要同时展示订单编号、支付时间、支付流水号这三条信息,且这三条信息是三个独立的<p>标签,中间还穿插着分割线。如果给每个<p>单独写th:if="${order.status == 'PAID'}",代码重复不说,以后需求变成“已退款”也要展示同一组信息时,你得改三个地方,漏改一个就出 bug。合理的写法是用 th:block 把这一组内容包起来,只写一次判断条件:
<th:block th:if="${order.status == 'PAID'}"> <p>支付时间:<span th:text="${order.payTime}">2024-01-01 10:30</span></p> <p>支付流水号:<span th:text="${order.paySerial}">T202401011030</span></p> <p>支付渠道:<span th:text="${order.payChannel}">微信支付</span></p> </th:block>注意,这三个<p>在最终渲染出来的 HTML 里是平级存在的,没有任何一层额外的包裹标签。这在语义化 HTML 上特别重要,既不影响搜索引擎和阅读器对文档结构的理解,也不会因为多了一层无意义的 div 导致后续写 CSS 选择器时多一道不需要的层级关系。
这个组合拳真的能省掉很多重复代码。另外 th:unless 也遵循同样的规则,比如“未支付”时展示另一组催促信息,直接再加一个互为镜像的 th:block 即可:
<th:block th:unless="${order.status == 'PAID'}"> <p>订单还未完成支付,请提醒用户尽快付款。</p> <p>支付入口:<a href="/pay/${order.id}">去支付</a></p> </th:block>两组条件完全互斥,但代码结构完全是平的,每个 p 标签都是干净的独立节点,维护起来非常轻松。
2.3 模板片段复用:组织 th:fragment 的多根节点
没有用过 th:fragment 的 Thymeleaf 项目是不完整的,而 th:block 在模板复用场景中扮演的角色也很关键。th:fragment 允许你定义一段可复用的模板内容,通过th:replace或th:insert在别处引入。但很多人在定义 fragment 时只会这样写:
<!-- fragment 文件:footer/content.html --> <div th:fragment="footerInfo"> <p>版权信息</p> <p>备案号信息</p> </div>这里为了把多个<p>包进一个 fragment,被迫加了一个 div 作为根节点。这个 div 在页面中被引入后是真实存在的 DOM 元素,你为了组织逻辑而多出来的这个节点在页面上其实没有任何意义,还得额外写 CSS 去清除它的样式影响,典型的“为了代码逻辑去污染页面结构”。
用 th:block 就能优雅解决:
<th:block th:fragment="footerInfo"> <p>版权信息</p> <p>备案号信息</p> </th:block>这样定义 fragment 的好处是显而易见的:fragment 的逻辑边界和 DOM 边界完全一致,使用时通过th:replace="~{content :: footerInfo}"引入,页面里直接就多了两个干净的<p>,没有多余的 div,不用额外写任何清除样式的处理。如果你的模板片段设计中经常包含多个并列的根节点,建议优先考虑用 th:block 作为 fragment 的载体。
3. 实操过程与关键难点:从零跑通一个 th:block 项目
3.1 最基础的做法:用一个 Controller 快速验证效果
理论说再多,不如亲手跑一次。我建议初学者在本地搭建一个最简单的 Spring Boot 项目,只写一个 Controller、一个 HTML 页面,看一遍 th:block 的渲染效果对比,比你读十遍文档都有用。
先写 Controller,用一个假数据模拟多组分组的场景:
@Controller public class DemoController { @GetMapping("/demo/block") public String demoBlock(Model model) { List<Goods> goodsList = new ArrayList<>(); goodsList.add(new Goods("苹果", 5.5, "水果")); goodsList.add(new Goods("香蕉", 3.5, "水果")); goodsList.add(new Goods("土豆", 2.0, "蔬菜")); goodsList.add(new Goods("番茄", 4.5, "蔬菜")); // 模拟按分类分组后的数据 Map<String, List<Goods>> groupMap = goodsList.stream() .collect(Collectors.groupingBy(Goods::getCategory)); model.addAttribute("groupMap", groupMap); return "demoBlock"; } }然后页面写一个包含 th:block 循环的表格,用刚才讲的表格分组循环思路。
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>th:block 演示</title> </head> <body> <h1>商品分类表</h1> <table border="1"> <thead> <tr> <th>分类</th> <th>商品列表</th> </tr> </thead> <tbody> <th:block th:each="entry : ${groupMap}"> <tr> <td th:text="${entry.key}">分类名</td> <td> <span th:each="goods : ${entry.value}" th:text="${goods.name} + ' ' + ${goods.price} + '元'">商品信息</span> </td> </tr> </th:block> </tbody> </table> </body> </html>启动项目后访问/demo/block,打开浏览器右键查看页面源代码,你会发现整个 HTML 中根本找不到任何<th:block>的痕迹,只有干净的<table>、<tr>、<td>结构。这就是 th:block 最核心、也最能给刚接触的人留下深刻印象的体验。我还记得我第一次看渲染结果时的感受:明明模板里有个标签名很奇怪的节点,怎么源代码里干干净净,跟没写过一样。这个特性也直接决定了很多前端同事在 review 模板代码时,第一眼看到<th:block>总以为是写错了,需要解释半天。
3.2 变量声明与局部变量的隐藏能力
th:block 除了跟 th:each、th:if 搭配,还有一个不太显眼但很好用的辅助能力:配合th:with声明局部变量,把多次重复使用的表达式提取成一个简短的变量名。
例如你想在页面某一段区域多次引用同一个经过复杂计算的金额值,又不想在 Controller 里再单独算一遍,可以在 th:block 上用 th:with 先算好,再在内部元素中反复使用:
<th:block th:with="totalPrice=${order.price} * ${order.quantity}, discount=${order.price * 0.1}"> <p>订单总额:<span th:text="${totalPrice}"></span> 元</p> <p>优惠金额:<span th:text="${discount}"></span> 元</p> <p>应付金额:<span th:text="${totalPrice - discount}"></span> 元</p> </th:block>这个逻辑如果用普通标签实现,通常是加一个 div 来承载 th:with,但页面又会多一个无意义的节点。th:block 包一层既把变量作用域圈了个范围,又不影响实际 DOM 结构,非常干净。在大型页面中这段代码尤其宝贵,它让模板的内部变量变得局部化,不会污染整页的上下文,也让后续维护的人能顺藤摸瓜找到每个局部变量的定义位置。
3.3 实际项目中的综合应用:分页栏目块
理论玩法清楚了,我们直接看一个综合性的项目片段。这是某个新闻资讯类网站的信息流列表页,页面上有置顶公告、常规新闻列表和广告位三个区域,需要根据后端数据动态决定是否显示整块区域。用 th:block 可以写得相当整齐:
<div class="news-wrap"> <!-- 置顶公告区域:有公告才显示 --> <th:block th:if="${not #lists.isEmpty(announcements)}"> <div class="announcement"> <h2>站内公告</h2> <ul> <li th:each="notice : ${announcements}" th:text="${notice.title}">公告标题</li> </ul> </div> </th:block> <!-- 新闻列表区域:非空才渲染 --> <th:block th:if="${not #lists.isEmpty(newsList)}"> <div class="news-list"> <h2>最新资讯</h2> <article th:each="news : ${newsList}" class="news-item"> <h3 th:text="${news.title}">新闻标题</h3> <p th:text="${news.summary}">新闻摘要</p> </article> </div> </th:block> <!-- 广告位:有广告位的页面才展示 --> <th:block th:if="${adEnabled}"> <div class="ad-banner" th:text="${adContent}">广告内容</div> </th:block> </div>这个例子最典型的特征是:每个 th:block 都只承担一个职责,要么是条件判断,要么是数据循环,从来没有混在一起写的情况。这套写法在我经历过的好几个项目里都成了团队约定俗成的模板规范,好处是代码可读性极高,新手接手也能快速理解页面区块的展示规则。
4. 常见问题与排查技巧实录
4.1 为什么我写的 th:block 标签不生效
最常见的原因就一个:HTML 页面的命名空间少了xmlns:th。有很多人从别的模板引擎转过来,习惯了不用声明命名空间,直接在 HTML 里裸写<th:block>,结果 Thymeleaf 引擎根本没有解析它,原样输出到了浏览器里,看起来就像“th:block 不生效”。
正确的页面头应该是:
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">这里再补充一条容易踩坑的细节:如果你用的是 HTML5 标准的<!DOCTYPE html>,并且页面是通过模板引擎来处理的,命名空间写不写其实不影响 Thymeleaf 解析,但建议还是写上。为什么?因为大多数 IDE(比如 IDEA)要靠这个命名空间提供属性补全和模板语法高亮,没有它很多 IDE 直接把<th:block>标红报错,团队协作时也容易让同事误以为代码有问题。
第二个可能的原因是 Thymeleaf 的版本过低。在老版本中,th:block的支持不如现在完善,个别场景下解析会异常。我建议至少使用 Thymeleaf 3.x,这里的 x 尽量用较新的小版本,现在 Spring Boot 2.x 和 3.x 内置的版本都完全兼容,不需要额外处理。
第四个容易忽略的问题是拼写大小写。HTML 标签不分大小写,但 Thymeleaf 属性是区分过着敏感习惯的,可 th:block 这个标签名最好严格使用小写,不要写成th:Block或者TH:BLOCK,虽然浏览器不挑刺,但 Thymeleaf 的解析器在匹配模板名时未必买账。
4.2 渲染结果里多出奇怪的 div 是什么原因
这个问题其实不是 th:block 的问题,是你自己中途把结构写乱了。我见过有人在 th:block 内部又嵌套了一个 div 来控制布局,结果渲染出来多了一层无意义的包裹节点。这类问题最典型的原因是对“th:block 本身不输出节点”这个特性理解不透彻——th:block 确实不输出节点,但它包裹的内部其他标签是正常输出的。如果你用 th:block 包了一大段带 div 的 HTML,那结果里当然会有 div,这跟 th:block 无关。
还有另一种情况,是用 th:insert 引入某个用 div 做根节点的 fragment 时,因为 fragment 内部自带 div,导致页面多了层级。解决办法前面已经提过:定义 fragment 时尽量用 th:block 作根节点,或者引入前评估这个 div 是否需要,能不能通过修改 fragment 结构来避免额外层级。
4.3 页面用了 CSS 框架,网格布局对不上号
如果你用 Bootstrap 这类框架,尤其留意 th:block 渲染出的平级节点数量。Bootstrap 的栅格系统基于row和col-*的父子层级关系,th:block 本身不产生节点,意味着你在 th:block 内写的多个col-*实际上会成为row的多个直接子节点,这正好符合框架预期。但如果你的 tr 循环中嵌套了 th:block,而 th:block 内部又有其他包裹节点,栅格层级就会被打破。处理原则非常简单:页面布局结构上需要的标签,老老实实写在模板里,不要试图用 th:block 替代布局工具;th:block 只负责逻辑分组,不负责视觉容器。
另外提一个排查思路:当渲染结果和自己预期不一致时,先右键查看网页源代码,看浏览器到底收到了什么样的 HTML,再反推是哪一层标签多出来的。浏览器开发者工具的 Elements 面板看到的 DOM 是已经过浏览器修正过的结构,与源代码不一定完全一致;用“查看网页源代码”的方式能更准确还原模板渲染的真实产物。
5. 性能与工程规范:关于 th:block 的一些心里话
5.1 th:block 对渲染性能的影响到底大不大
很多人担心模板里多了一层“隐形标签”是不是会拖慢渲染效率。我先说结论:在合理使用的前提下,th:block 对性能的影响微乎其微,完全可以忽略。Thymeleaf 解析模板的过程是把 HTML 结构解析成一棵节点树,然后在这棵树的基础上遍历计算各种 th 属性。th:block 仅仅是一个普通节点,解析和遍历过程中它的开销和其他标准标签没有本质区别,而且最终渲染时又不会输出到页面,少了生成额外 DOM 的开销,某些角度上看反而比用 div 包裹更轻量。
当然,如果你极端到在一个页面上用了几百个 th:block 还层层嵌套,那任何模板引擎都吃不消。但这种情况通常不是 th:block 的问题,而是你的模板结构设计需要重新考虑。我在性能测试中有过一次实测:一个包含上千个节点的复杂报表页面,用 th:block 替代一部分 div 包裹后,模板渲染耗时反而略有下降,原因就是最终生成的页面体积变小了,传输和浏览器解析的压力都减轻了。
5.2 团队协作中的代码规范建议
实践中我总结了几条 th:block 的使用规范,写进团队开发手册后被大家广泛采纳:
第一,th:block 内尽量保持一级结构平铺,不要嵌套太多。如果发现一个 th:block 内部还有 th:block,考虑是不是可以抽成独立的 fragment,而不是无限嵌套。嵌套太多会让模板的可读性断崖式下降,别人接手时根本分不清各层的作用范围。
第二,一个 th:block 只干一件事。要么只做循环,要么只做条件判断,要么只做变量声明。把 th:each 和 th:if 混写在同一个 th:block 上不是不行,但阅读成本会提高,将来要拆分也更麻烦。遵守“单一职责”原则,模板代码和 Java 代码一样需要维护。
第三,给 th:block 添加注释。HTML 不支持 th:block 自身的注释,但你可以紧挨着它写 HTML 注释来描述这段逻辑分组的用途,例如:
<!-- 商品推荐区域:有推荐数据时整体展示 --> <th:block th:if="${not #lists.isEmpty(recommendList)}"> <!-- 推荐商品循环 --> <div class="recommend-item" th:each="item : ${recommendList}" th:text="${item.name}">推荐位</div> </th:block>这些注释在最终渲染时也会保留的话尽量放在普通标签上,th:block 内外的注释策略可以按团队的常规 html 注释习惯来,不需要特别纠结。但注释习惯确实能省掉很多“这是干嘛的”的群聊提问。
第四,和前端同事约定好:凡是 HTML 结构中出现<th:block>,说明这里存在多元素逻辑分组,不是页面结构的一部分。前端在抠样式时不会因为多出来的节点而困惑,后端在改业务逻辑时也能快速定位分组位置。
5.3 另一个实战细节:在 th:block 中使用链接表达式
最后分享一个容易被忽略的小细节。有时我们在 th:block 中需要拼接 URL 链接,如:
<th:block th:each="item : ${menuList}"> <a th:href="@{/detail/{id}(id=${item.id})}" th:text="${item.name}">菜单项</a> </th:block>有些新手会把 th:block 错当成一个有属性的元素,试图把@{...}直接写在 th:block 上,比如<th:block th:href="@{/xxx}">。这是不生效的,因为 th:block 只是一个逻辑容器,本身不会生成节点,自然也没有 href 这种属性去绑定。所有需要在页面展示的属性都要写在具体的真实标签上。这个原则理解透了,大部分“为什么 th:block 上的属性没生效”这类问题就都迎刃而解了。
个人实操中的几点体会
用了几年 Thymeleaf,我把 th:block 视为模板代码中最不起眼但最“顺手”的一个工具。它不像 th:each、th:if 那么显眼,但它解决了模板语言里最现实的一个矛盾:后端逻辑需要组织分组,而 HTML 结构不允许无关的包装节点存在。没有 th:block 之前,我们不是容忍多余的 div,就是想尽办法拆分循环结构把代码写得很别扭;有了它之后,模板的思考方式和最终页面的结构终于可以统一了。如果你刚开始接触 Thymeleaf,希望这篇整理能帮你少走一些弯路,遇到表格分组、多元素条件渲染、fragment 多根节点的场景时,第一反应就应该是 th:block,而不是硬套一个 div 上去。