Thymeleaf th:each 实战:从 List 到 HTML 表格的完整渲染
2026/9/19 10:10:00 网站建设 项目流程

简介:面向Spring MVC开发者,这份PDF以从 commanders 表查询数据并呈现为例,讲解如何用 Thymeleaf 循环遍历集合并以表格形式动态展示,可作为 JSP 转向 Thymeleaf 的快速参考。文件为单个PDF,体积仅72KB,内容紧凑,便于随时查阅。目前已有4763人学习下载。PDF中给出了后台 Controller 中使用 CommanderDao 查询整表并存入 Map 的代码,以及前端 myView.html 中通过 th:each 遍历 commanders 列表、借助 th:text 输出对象属性的表格写法,完整演示了从数据库查询结果到浏览器表格渲染的链路,同时覆盖数据库表结构、后台数据传递与前端页面展示等关键步骤。读者可借此掌握 Thymeleaf 类似 foreach 的迭代方式,理解集合数据在 Model 与模板之间的传递原理,适合正在学习 Spring Boot/Spring MVC 及模板引擎的初中级开发者。

1. 从 JSP 到 Thymeleaf:为什么模板引擎选它

大多数从 SSM 或 Spring Boot 早期版本过来的开发者,第一次接触 Thymeleaf 时都会有一个疑问:JSP 用了那么多年,为什么新项目里越来越少见?答案很直接——JSP 在前后端分离和静态化部署面前过于笨重,而 Thymeleaf 允许你直接用浏览器打开 HTML 文件看静态效果,再交给服务端渲染动态数据。这种“自然模板”特性,让前端切图、后端填数据、联调排错三个环节都变得更省事。本文要解决的就是一个最常见的需求:数据库里有张commanders表,后台查出 List 集合,然后用 Thymeleaf 的th:each把集合渲染成网页表格。你不需要额外引入复杂的前端框架,一个 HTML 文件加几行标签就能完成整张表的动态展示。

2. Controller 数据装配:集合是怎么进模板的

2.1 Model、Map 与 ModelAndView 的选型差异

在 Spring MVC 中,把数据传给视图的方式主要有三种:ModelModelAndViewMap。本例后台代码用的是方法参数Map map,然后直接map.put("commanders", commanders)。很多新人会问:这里为什么不用Model?其实在 Spring MVC 的RequestMappingHandlerAdapter内部,ModelMapModelMap最终都会被封装成同一个BindingAwareModelMap实例。也就是说,你写Map和写Model在当前版本里效果几乎一样,都能被 Thymeleaf 通过${commanders}取到。

不过更推荐的做法是显式声明Model model,因为它的语义更清晰——Model是专属于 web 层的绑定对象,而Map会让人误以为可以随便往里塞业务数据。当然,如果你的项目里已经在用ModelAndView,那也完全可以,只是代码会比上面的方案多一行:

@Controller public class HelloController { @Autowired private CommanderDao commanderDao; @RequestMapping("/list") public ModelAndView queryAll() { List<Commander> commanders = commanderDao.queryAll(); ModelAndView mav = new ModelAndView("myView"); mav.addObject("commanders", commanders); return mav; } }

这段代码的逻辑很简单:queryAll()方法调用CommanderDao查询全表,得到List<Commander>,再通过addObject把集合塞进ModelAndView,最后指定视图名myView。相比Map写法,ModelAndView把视图和数据封装在一个对象里,适合需要同时修改视图路径和数据的场景,但在简单的 Controller 中略显得冗余。我一般建议:只需要传少量数据时用MapModel,如果还要设置跳转状态码、处理重定向,再用ModelAndView

2.2 Commander 实体与 DAO 层的对应关系

Commander类必须和commanders表字段保持一一对应,Thymeleaf 才能通过getter方法拿到属性值。表的字段假设为idnameage,那么实体类至少要有以下结构:

public class Commander { private Integer id; private String name; private Integer age; public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } }

注意,这里age用的是包装类型Integer,而不是基本类型int。原因是数据库中的age字段可能为NULL,如果用int接收,MyBatis 或 JdbcTemplate 在映射时可能直接抛异常。包装类型允许null,配合 Thymeleaf 的默认显示会更安全。CommanderDao可以是 MyBatis 的 Mapper 接口,也可以是 Spring JDBC 的 Repository,这不影响模板层的写法。关键是 DAO 的queryAll()方法返回的集合类型最好声明成List<Commander>,而不是ArrayList<Commander>,这样接口语义更宽泛,后续替换实现类时不需要修改调用方。

2.3 常见陷阱:Map 的 key 写错导致模板取不到值

如果模板里写${commanders}但页面渲染为空,先检查 Controller 里map.put("commanders", commanders)的 key 是否和模板中的表达式一致,大小写、拼写、空格都会导致静默失败。另一个常被忽略的问题是:@RequestMapping路径和视图名不要混淆。@RequestMapping("/list")是浏览器访问的 URL,return "myView"是模板文件的名字,默认对应templates/myView.html。如果返回的字符串带了前缀如"redirect:list""forward:/list",Thymeleaf 解析逻辑就完全不同了。

提示:如果你发现调用了/list返回的是 500 错误,优先看控制台有没有TemplateInputException,它通常会明确告诉你模板文件路径或表达式哪一行出错。

3. th:each 遍历集合:表格渲染的核心语法与参数

3.1 从普通 HTML 表格到动态表格的改造

在没有任何模板引擎时,表格行数是写死的,数据需要预先拼好。Thymeleaf 的th:each本质上是一种迭代指令,它会复制当前标签及其内部内容,对集合中的每个元素执行一次渲染。看下面的模板:

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Commanders List</title> </head> <body> <table> <thead> <tr> <th>编号</th> <th>姓名</th> <th>年龄</th> </tr> </thead> <tbody> <tr th:each="commander : ${commanders}"> <td th:text="${commander.id}">1</td> <td th:text="${commander.name}">John Doe</td> <td th:text="${commander.age}">30</td> </tr> </tbody> </table> </body> </html>

这里最关键的语法是th:each="commander : ${commanders}",冒号左边是迭代变量名,右边是从模板上下文取出的集合。th:text用于替换标签内的文本内容,${commander.id}会调用CommandergetId()方法。为什么能这样写?因为 Thymeleaf 的表达式语法遵循 JavaBean 规范,commander.id会被解析为commander.getId()commander.name对应getName()。如果实体类里没有对应的 getter,渲染时会直接报SpelEvaluationExceptionPropertyNotFoundException。表格中显示的“1”和“John Doe”只是静态占位符,方便浏览器直接打开文件时查看布局,服务端渲染时会被真实数据完全替换。

3.2 th:each 的状态变量:index、count、size 等

如果你需要在表格中展示序号,而不是直接用数据库的id字段,可以使用th:each的状态变量。它通过在迭代变量后追加stat来声明:

<tbody> <tr th:each="commander,stat : ${commanders}"> <td th:text="${stat.index + 1}">1</td> <td th:text="${commander.name}">John Doe</td> <td th:text="${commander.age}">30</td> </tr> </tbody>

状态变量暴露的属性包括:index(从 0 开始的下标)、count(从 1 开始的计数)、size(集合总大小)、even/odd(是否为偶数/奇数行)、first/last(是否为首行/末行)。例如,想给奇数行加背景色,可以配合th:class使用,${stat.odd} ? 'row-odd' : 'row-even'这样的三元表达式。需要注意,状态变量名可以任意起,但不要和迭代变量名重复,否则会覆盖掉原来的Commander对象。最常见的误用是只写了th:each="commander : ${commanders}",想在循环里用commander.index,但commanderCommander对象,没有index属性,自然取不到。必须显式声明第二个变量stat才能访问这些循环元数据。

3.3 空集合与 null 的边界处理

queryAll()返回null而不是空集合时,th:each会直接报错吗?答案是:Thymeleaf 会将null视为空集合,不渲染循环体,也不会抛异常。但如果commanders这个 key 本身不存在,也就是 Controller 里压根没有put这个值,${commanders}解析结果是null,同样不会报错。所以为了页面友好性,建议在表格上方加一个空数据提示:

<p th:if="${commanders == null || commanders.isEmpty()}">暂无数据</p> <tbody> <tr th:each="commander : ${commanders}"> <td th:text="${commander.id}"></td> <td th:text="${commander.name}"></td> <td th:text="${commander.age}"></td> </tr> </tbody>

注意这里用了commanders.isEmpty(),如果commandersnull,直接调用isEmpty()会引发空指针。所以先判断== null,再用||短路。或者更稳妥的写法是!commanders?.isEmpty(),Thymeleaf 的安全导航操作符?.会在左侧为null时返回null,不会继续调用方法。这种方式比上面那串判断更简洁,也更符合 Thymeleaf 的表达习惯。在实际项目中,很多团队习惯在 Service 层就返回空集合而不是null,这样模板层就不需要额外判断。

4. 表格实战:数据格式化、条件样式与复杂集合

4.1 日期、金额等字段的格式化处理

commanders表如果只有 id、name、age 三个字段,展示起来很简单。但真实项目里往往有createTime(创建时间)、salary(薪资)这样的字段。th:text直接输出java.util.Date对象时是默认的英文格式,比如Sat Nov 16 15:30:00 CST 2025,显然不适合阅读。常见的做法是在实体类中返回格式化好的字符串,或者在模板里用#temporals工具类。Spring Boot 的 Thymeleaf 集成了 Java 8 Time API,如果字段类型是LocalDateTime,可以这样写:

<td th:text="${#temporals.format(commander.createTime, 'yyyy-MM-dd HH:mm:ss')}"></td>

#temporals.format是 Thymeleaf 内置的日期时间格式化函数,第一个参数是时间对象,第二个是格式模板。注意它只支持java.time包下的类型,如果你用的是java.util.Date,需要换成#dates.format(commander.createTime, 'yyyy-MM-dd HH:mm:ss')。很多人在这里踩坑:后台字段是Date,模板里用#temporals,结果报错。这跟 Thymeleaf 版本无关,是类型不匹配导致的。金额字段建议在后端用BigDecimal,模板输出前可以用#numbers.formatDecimal(commander.salary, 1, 'COMMA', 2, 'POINT'),分别表示最少整数位数、千分位分隔符、小数位数、小数点符号。不过我更推荐把金额格式化放在后端 DTO 里完成,模板只负责展示,这样也方便单元测试。

4.2 根据数据动态设置行样式

th:each结合th:classth:style可以轻松实现行级条件渲染。比如commanders表里有status字段,1 表示在职,0 表示离职,希望离职人员的姓名显示为灰色并有删除线。可以在<tr>上做判断:

<tr th:each="commander : ${commanders}" th:class="${commander.status == 0} ? 'disabled-row' : ''"> <td th:text="${commander.id}"></td> <td th:text="${commander.name}"></td> <td th:text="${commander.age}"></td> </tr>

这里的三元表达式是整个th:class的值,当status == 0时返回disabled-row,否则返回空字符串。disabled-row这个 CSS 类需要在你的样式表中定义,比如text-decoration: line-through; color: #999;。注意,th:class会替换原有的class属性,如果你需要保留原来的类,比如class="base-row",就得写成th:class="${cond} ? 'base-row disabled-row' : 'base-row'",因为 Thymeleaf 的th:class不做追加,它是整体覆盖。如果想追加而不是覆盖,可以使用th:classappend,但要注意继承顺序,它在渲染时会叠加在已有class之后。

更复杂的场景是每五行的颜色不同,用模运算即可:${stat.index % 5 == 0} ? 'group-start' : ''。状态变量和多元表达式组合起来,基本能覆盖表格行样式的大部分需求。对于单元格级别的条件,可以把三元表达式写在th:text或者th:style上,原理完全一样。

4.3 嵌套集合:一行数据带一个子列表

如果每个Commander里还有一个List<Skill>属性,表格的某一列要展示该成员的所有技能,就需要两层th:each嵌套。外层循环遍历commanders,内层循环遍历当前commander.skills

<tr th:each="commander : ${commanders}"> <td th:text="${commander.name}"></td> <td> <span th:each="skill : ${commander.skills}" th:text="${skill.name} + ' '"></span> </td> </tr>

内层th:each的迭代变量skill只作用于当前<span>标签内部,不会影响外层的commander。这是 Thymeleaf 的变量作用域设计:每个标签的th:each会开启一个新的作用域,内层可以访问外层变量,但反过来不行。如果某个commander.skillsnull,内层循环直接跳过,不会影响其他行的渲染。需要特别注意的是,内层行内不要同时使用th:textth:each在同一个标签上,因为th:each会重复渲染整个标签,而th:text只会执行一次,两者同时存在时行为可能不符合预期。一般做法是让内层th:each挂在子标签上,就像上面把<span>作为循环载体。

4.4 常见报错与排查思路

先看两个高频错误。第一个是Exception evaluating SpringEL expression: commander.name,原因通常是Commander类没有getName()方法,或者方法名拼写错误,比如写成了getname()。第二个是Caused by: java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'commanders' available as request attribute,这个往往是用th:object绑定时写错了对象名,或者对象根本没有放进 model。排查时先在浏览器查看源码,能直接看到渲染后的 HTML 才能定位问题。如果表格内一行数据都没有,先检查集合是否为空,可以在模板里临时加一行${commanders}输出整个集合的大小,确认数据是否已经进入上下文。另一个技巧是打开 Thymeleaf 的缓存开关:在application.properties里设置spring.thymeleaf.cache=false,开发时每次修改模板都可以立即生效,否则需要重启应用才能看到变更。

5. 让表格模板更易维护:片段引用与内联表达式

当你需要在一个页面上重复展示多个列表,或者多个页面共用同一个表格结构时,把表格抽成 Thymeleaf 片段是更优雅的做法。在templates/fragments/commander-table.html中定义片段:

<div th:fragment="commanderList(commanders)"> <table> <thead> <tr><th>编号</th><th>姓名</th><th>年龄</th></tr> </thead> <tbody> <tr th:each="commander : ${commanders}"> <td th:text="${commander.id}"></td> <td th:text="${commander.name}"></td> <td th:text="${commander.age}"></td> </tr> </tbody> </table> </div>

然后在myView.html中通过th:replaceth:insert引入这个片段,并传入集合:

<div th:replace="~{fragments/commander-table :: commanderList(${commanders})}"></div>

th:replace会直接用片段替换当前标签,th:insert则会把片段插入当前标签内部。传参时用~{模板路径 :: 片段名(参数列表)}的语法,模板路径相对于templates目录,不需要写.html后缀。这样写的好处是,当表格结构需要增加一列时,只需改片段文件,所有引用页面自动更新。如果只给片段做局部修改,还可以用th:insert配合th:block包裹额外内容,但要注意片段内部的变量作用域是独立的,传下去的集合名不能变。

另一个提升效率的技巧是使用内联表达式。th:text写多了以后,标签会显得很密集,内联写法可以更直观地看到数据和 HTML 标签的关系:

<td>[[${commander.name}]]</td> <td>[( ${commander.age} )]</td>

[[...]]是转义内联,等价于th:text,输出 HTML 会转义特殊字符;[(...)]是不转义内联,等价于th:utext,适合输出富文本。需要注意,内联表达式默认是不开启的,需要在 html 根标签加上th:inline="text"才能使用。如果你整个页面都用内联,可以设置为th:inline="text";如果只是某一段用,就在对应标签上声明。不过内联表达式在代码高亮和格式化工具里识别度较差,多人协作时容易漏掉转义逻辑,我建议只在写邮件模板或简单页面时使用,正式的表格列表还是用th:text更明确。最终验证时,直接启动应用访问/list,右键浏览器查看源代码,确认<tr>的数量是否与数据库行数一致,再检查字段顺序是否错位。整个过程不需要任何 JavaScript,纯 Thymeleaf 就能完成。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询