ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Thymeleaf th:each 实战:从 List 到 HTML 表格的完整渲染

2026/9/19 10:10:05 拓冰建站 浏览量
Thymeleaf th:each 实战:从 List 到 HTML 表格的完整渲染 简介面向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 中把数据传给视图的方式主要有三种Model、ModelAndView和Map。本例后台代码用的是方法参数Map map然后直接map.put(commanders, commanders)。很多新人会问这里为什么不用Model其实在 Spring MVC 的RequestMappingHandlerAdapter内部Model、Map、ModelMap最终都会被封装成同一个BindingAwareModelMap实例。也就是说你写Map和写Model在当前版本里效果几乎一样都能被 Thymeleaf 通过${commanders}取到。不过更推荐的做法是显式声明Model model因为它的语义更清晰——Model是专属于 web 层的绑定对象而Map会让人误以为可以随便往里塞业务数据。当然如果你的项目里已经在用ModelAndView那也完全可以只是代码会比上面的方案多一行Controller public class HelloController { Autowired private CommanderDao commanderDao; RequestMapping(/list) public ModelAndView queryAll() { ListCommander commanders commanderDao.queryAll(); ModelAndView mav new ModelAndView(myView); mav.addObject(commanders, commanders); return mav; } }这段代码的逻辑很简单queryAll()方法调用CommanderDao查询全表得到ListCommander再通过addObject把集合塞进ModelAndView最后指定视图名myView。相比Map写法ModelAndView把视图和数据封装在一个对象里适合需要同时修改视图路径和数据的场景但在简单的 Controller 中略显得冗余。我一般建议只需要传少量数据时用Map或Model如果还要设置跳转状态码、处理重定向再用ModelAndView。2.2 Commander 实体与 DAO 层的对应关系Commander类必须和commanders表字段保持一一对应Thymeleaf 才能通过getter方法拿到属性值。表的字段假设为id、name、age那么实体类至少要有以下结构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()方法返回的集合类型最好声明成ListCommander而不是ArrayListCommander这样接口语义更宽泛后续替换实现类时不需要修改调用方。2.3 常见陷阱Map 的 key 写错导致模板取不到值如果模板里写${commanders}但页面渲染为空先检查 Controller 里map.put(commanders, commanders)的 key 是否和模板中的表达式一致大小写、拼写、空格都会导致静默失败。另一个常被忽略的问题是RequestMapping路径和视图名不要混淆。RequestMapping(/list)是浏览器访问的 URLreturn myView是模板文件的名字默认对应templates/myView.html。如果返回的字符串带了前缀如redirect:list或forward:/listThymeleaf 解析逻辑就完全不同了。提示如果你发现调用了/list返回的是 500 错误优先看控制台有没有TemplateInputException它通常会明确告诉你模板文件路径或表达式哪一行出错。3. th:each 遍历集合表格渲染的核心语法与参数3.1 从普通 HTML 表格到动态表格的改造在没有任何模板引擎时表格行数是写死的数据需要预先拼好。Thymeleaf 的th:each本质上是一种迭代指令它会复制当前标签及其内部内容对集合中的每个元素执行一次渲染。看下面的模板!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head titleCommanders List/title /head body table thead tr th编号/th th姓名/th th年龄/th /tr /thead tbody tr th:eachcommander : ${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:eachcommander : ${commanders}冒号左边是迭代变量名右边是从模板上下文取出的集合。th:text用于替换标签内的文本内容${commander.id}会调用Commander的getId()方法。为什么能这样写因为 Thymeleaf 的表达式语法遵循 JavaBean 规范commander.id会被解析为commander.getId()commander.name对应getName()。如果实体类里没有对应的 getter渲染时会直接报SpelEvaluationException或PropertyNotFoundException。表格中显示的“1”和“John Doe”只是静态占位符方便浏览器直接打开文件时查看布局服务端渲染时会被真实数据完全替换。3.2 th:each 的状态变量index、count、size 等如果你需要在表格中展示序号而不是直接用数据库的id字段可以使用th:each的状态变量。它通过在迭代变量后追加stat来声明tbody tr th:eachcommander,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:eachcommander : ${commanders}想在循环里用commander.index但commander是Commander对象没有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:eachcommander : ${commanders} td th:text${commander.id}/td td th:text${commander.name}/td td th:text${commander.age}/td /tr /tbody注意这里用了commanders.isEmpty()如果commanders为null直接调用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:class或th:style可以轻松实现行级条件渲染。比如commanders表里有status字段1 表示在职0 表示离职希望离职人员的姓名显示为灰色并有删除线。可以在tr上做判断tr th:eachcommander : ${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属性如果你需要保留原来的类比如classbase-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里还有一个ListSkill属性表格的某一列要展示该成员的所有技能就需要两层th:each嵌套。外层循环遍历commanders内层循环遍历当前commander.skillstr th:eachcommander : ${commanders} td th:text${commander.name}/td td span th:eachskill : ${commander.skills} th:text${skill.name} /span /td /tr内层th:each的迭代变量skill只作用于当前span标签内部不会影响外层的commander。这是 Thymeleaf 的变量作用域设计每个标签的th:each会开启一个新的作用域内层可以访问外层变量但反过来不行。如果某个commander.skills为null内层循环直接跳过不会影响其他行的渲染。需要特别注意的是内层行内不要同时使用th:text和th: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.cachefalse开发时每次修改模板都可以立即生效否则需要重启应用才能看到变更。5. 让表格模板更易维护片段引用与内联表达式当你需要在一个页面上重复展示多个列表或者多个页面共用同一个表格结构时把表格抽成 Thymeleaf 片段是更优雅的做法。在templates/fragments/commander-table.html中定义片段div th:fragmentcommanderList(commanders) table thead trth编号/thth姓名/thth年龄/th/tr /thead tbody tr th:eachcommander : ${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:replace或th:insert引入这个片段并传入集合div th:replace~{fragments/commander-table :: commanderList(${commanders})}/divth: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:inlinetext才能使用。如果你整个页面都用内联可以设置为th:inlinetext如果只是某一段用就在对应标签上声明。不过内联表达式在代码高亮和格式化工具里识别度较差多人协作时容易漏掉转义逻辑我建议只在写邮件模板或简单页面时使用正式的表格列表还是用th:text更明确。最终验证时直接启动应用访问/list右键浏览器查看源代码确认tr的数量是否与数据库行数一致再检查字段顺序是否错位。整个过程不需要任何 JavaScript纯 Thymeleaf 就能完成。本文还有配套的精品资源点击获取