1. 为什么要在SpringBoot项目里用Thymeleaf
先聊聊我自己的经历。做Java后端这几年,刚开始写页面用的还是JSP,后来前后端分离慢慢流行,大家张口闭口就是Vue、React。但实际做项目时你会发现,并不是所有场景都适合搞前后端分离。后台管理类系统、需要SEO的官网页面、还有一些中小型项目的快速交付,服务端渲染反而更省事。而SpringBoot官方推荐的模板引擎就是Thymeleaf,这一篇我就结合自己的实操经验,完整梳理一遍集成Thymeleaf的流程、语法细节和坑点。
Thymeleaf是一个Java模板引擎,核心能力是把页面模板和后台数据结合起来,在服务端生成最终的HTML返回给浏览器。跟JSP最大的区别在于,Thymeleaf的模板本身就是合法的HTML文件,静态原型可以直接在浏览器里打开看到效果,不需要启动Tomcat。这个特性在前后端协作时非常有用——前端把静态页面给你,你往里面填数据就行,不用像JSP那样搞一堆标签依赖容器环境才能预览。
这一篇适合什么人看?想在SpringBoot项目里快速接入服务端页面渲染的同学,对Thymeleaf一知半解想系统过一遍语法的同学,以及在集成过程中踩了坑不知道怎么排查的同学。我会从环境搭建讲到常用语法,再讲布局复用和实际案例,最后把常见问题整理成清单,基本覆盖你日常开发会用到的全部内容。
2. 集成前的准备与依赖配置
2.1 基础环境版本选择
先说版本,这块容易被忽略。Thymeleaf迭代挺快的,不同版本适配的SpringBoot版本有差异。我自己一般用SpringBoot 2.7.x搭配Thymeleaf 3.0.x,这套组合非常成熟稳定。如果你用的是SpringBoot 3.x,那对应的Thymeleaf也是新版本,包名路径和基本用法差异不大,但有些底层实现有变化,网上搜到的旧帖子的配置可能就不适用了。
建议你先确认一下自己项目的SpringBoot版本,再看对应的Thymeleaf版本适配。版本选不对,后面出现的诡异问题你根本想不到是版本兼容性导致的。我自己就遇到过SpringBoot 3.0的项目按照2.x的配置写,结果模板一直解析不出来,排查了半天原来是starter版本没对上。
2.2 引入依赖的两种方式
在SpringBoot项目里集成Thymeleaf,最省事的方式就是用官方starter。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>如果你用的是Gradle,对应的写法是:
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'一个starter就搞定了,不需要像JSP那样还要额外引入JSTL之类的依赖库。这也是SpringBoot脚手架思想的一个体现。用starter的好处是版本号不用自己写,依赖管理由SpringBoot的父工程统一控制,避免你手工指定版本引入冲突。
还有一种方式,就是不依赖starter,自己直接引Thymeleaf的核心包。这种做法我实际工作中没见过谁用,因为没必要。SpringBoot已经帮你封装好了自动配置类,你把starter引进来,它就会在classpath里检测到Thymeleaf的存在,自动创建模板引擎相关的Bean。自己手动配置的话,你得自己定义SpringTemplateEngine、TemplateResolver这些组件,纯属给自己找事。
2.3 配置文件里的关键项
依赖引进来之后,为了稳妥起见,建议在application.yml里加上基本配置:
spring: thymeleaf: prefix: classpath:/templates/ suffix: .html mode: HTML encoding: UTF-8 cache: false servlet: content-type: text/html这几个配置项的含义挨个说一下:
- prefix和suffix:模板文件的位置前缀和文件后缀,默认值实际上就是classpath:/templates/和.html,所以不配也能跑。但写出来有个好处,就是别人看你的配置就知道模板放哪、什么格式。
- mode:模板模式。写成HTML表示按照HTML5标准来解析。以前还有LEGACYHTML5之类的选项,是为了处理一些不够规范的老写法,现在基本都用HTML就行。
- encoding:编码必须设置成UTF-8,否则页面中文会乱码。这个不出问题你感觉不到,出了问题那是真的愁人。
- cache:开发阶段必须设为false。Thymeleaf默认是开启模板缓存的,生产环境有缓存可以提高性能。但开发时你改一个页面,结果浏览器里看不到变化,强制刷新也没用,就是因为缓存的锅。设成false以后每次请求都会重新解析模板文件,改完刷新立刻生效。
注意:cache: false只是关闭了Thymeleaf的模板缓存,还不够。浏览器自身也会有缓存,开发时最好也把浏览器缓存关掉,或者用无痕模式。
2.4 静态资源的处理原则
集成模板引擎之后,静态资源的管理也顺手理一下。JS、CSS、图片这些文件默认放在classpath:/static/目录下,通过访问路径直接映射,不经过Thymeleaf解析。比如static/js/main.js,你在页面里可以写:
<script th:src="@{/js/main.js}"></script>这里用th:src替代原生的src属性,@{}是Thymeleaf的链接表达式,它会自动帮你拼上项目的context path。如果项目配置了server.servlet.context-path,用@{/js/main.js}就能自动带上前缀。如果用原生的src属性,部署到有context path的环境就会404。
这个细节是很多人实际开发中容易踩的坑,本地开发没配context path,测试环境配了,页面样式全丢了,查半天发现是路径写死的问题。养成习惯,Thymeleaf模板里凡是涉及路径的地方,都用@{}表达式,不管是静态资源还是页面跳转链接。
3. Thymeleaf核心语法与表达式
3.1 标准表达式类型一览
Thymeleaf的表达式语法是核心中的核心,你不需要把官方文档全背下来,但常用的几种必须熟练掌握。我整理了一个对照表,方便你快速查阅:
| 表达式类型 | 语法 | 作用 | 典型示例 |
|---|---|---|---|
| 变量表达式 | ${...} | 获取后台传入的变量值 | ${user.name} |
| 选择表达式 | *{...} | 从当前选中对象中取值 | *{name} |
| 链接表达式 | @{...} | 生成URL地址 | @{/order/detail?id=1} |
| 消息表达式 | #{...} | 读取国际化消息 | #{order.title} |
| 片段表达式 | ~{...} | 引用模板片段 | ~{common/footer :: footer} |
变量表达式最常用,就是你在Controller里往Model里塞的数据,在模板里通过这个方式拿出来展示。选择表达式通常配合th:object使用,在一个对象上下文内部直接取值,写法更简洁。链接表达式的用途上面说过了,生成带context path的链接。消息表达式在要做国际化的时候用,普通项目可以先不管。片段表达式是布局复用的基础,后面单独展开讲。
3.2 常用th:属性的使用场景
Thymeleaf靠的是在HTML标签上添加命名空间属性来渲染动态内容,下面是使用频率最高的几个:
- th:text:替换标签内的文本内容
- th:href:替换a标签的href属性
- th:src:替换script、img等标签的src属性
- th:if / th:unless:条件判断
- th:each:循环遍历
- th:object:配合选择表达式使用
- th:class:动态设置class属性
看一个实际页面片段,感受一下怎么配合使用:
<div th:if="${!#lists.isEmpty(userList)}"> <table> <tr th:each="user, stat : ${userList}"> <td th:text="${stat.index + 1}">1</td> <td th:text="${user.name}">张三</td> <td th:class="${user.status eq 'ACTIVE' ? 'text-green' : 'text-gray'}" th:text="${user.status}">ACTIVE</td> <td> <a th:href="@{/user/detail(id=${user.id})}">查看详情</a> </td> </tr> </table> </div>这里有几个值得强调的点。th:each的语法是th:each="元素变量名, 状态变量名 : 集合",状态变量里包含index、count、first、last、odd、even这些属性,做序号和奇偶行样式很方便。链接表达式里传参数的写法是@{/user/detail(id=${user.id})},不是拼字符串那种写法,这是Thymeleaf推荐的方式。条件表达式的三元写法可以直接写在属性值里,结合th:class能实现动态样式切换,比在Java代码里拼class字段要直观得多。
3.3 内联表达式与文本拼接
除了th:text属性,Thymeleaf还有内联语法,直接在HTML文本节点里嵌入表达式,用[[...]]或者[(...)]包起来。区别是[[...]]会经过HTML转义,[(...)]不转义。从安全角度看,优先用[[...]],涉及用户输入的字段必须转义,防止脚本注入。
<p>你好,<span th:text="${user.name}">用户</span>,欢迎回来!</p>也可以直接写成:
<p>你好,[[${user.name}]],欢迎回来!</p>需要拼接多个变量的时候,内联写法明显更简洁。但要注意,内联语法只在开启了th:inline的标签内生效。HTML模式下,如果不放心,可以在外层加th:inline="text"。默认情况下,我在实际项目中用内联语法的场景不算多,因为团队里水平参差不齐,有些人容易把转义这件事忘了,所以统一约定用th:text属性写法更安全。
3.4 工具类对象和内置方法
Thymeleaf内置了好几个工具类对象,比如#strings、#lists、#maps、#dates、#numbers。直接在模板里调用方法,省去很多处理逻辑。举个例子,你要截取字符串、判空、格式化日期:
<p th:text="${#strings.substring(user.remark, 0, 10)}">备注信息</p> <p th:text="${#dates.format(user.createTime, 'yyyy-MM-dd HH:mm:ss')}">2024-01-01 12:00:00</p> <p th:if="${#strings.isEmpty(user.remark)}">暂无备注</p>这些工具方法就跟你写Java代码调用StringUtils差不多,但要注意的是,在模板里写复杂逻辑本身就是一个信号——说明你的数据处理应该在Controller层或者Service层先做好,而不是扔给模板去处理。模板里的逻辑越简单越好,保持视图的纯粹性。
3.5 条件判断和循环的进阶场景
th:if和th:unless是互补的,th:if后面的条件成立时渲染,th:unless反之。还有一个th:switch配合th:case的用法,适合多分支场景:
<td th:switch="${order.status}"> <span th:case="'PAID'" th:text="已支付"></span> <span th:case="'SHIPPED'" th:text="已发货"></span> <span th:case="'COMPLETED'" th:text="已完成"></span> <span th:case="*" th:text="未知状态"></span> </td>th:case="*"表示默认分支,跟Java switch里的default一个意思。这里需要注意字符串比较的写法,要用单引号把字符串值包起来,写成th:case="PAID"是错的。我第一次用的时候就犯了这个错误,页面直接报错,排查了半天才发现是引号的问题。
循环的进阶用法,最常见的是处理空集合的场景。直接用th:each遍历一个空集合,页面会不显示该区块,但有时候你需要给用户一个“暂无数据”的提示。常见做法是这样:
<tr th:each="item : ${itemList}" th:if="${!#lists.isEmpty(itemList)}"> ... </tr> <tr th:if="${#lists.isEmpty(itemList)}"> <td colspan="4">暂无数据</td> </tr>用#lists工具类判断集合是否为空,配合th:if做兜底提示,比在Java代码里塞一个造假的空对象要清晰得多。
4. 页面布局与公共片段的复用
4.1 手动抽公共页头页脚
做后台管理系统的时候,每个页面都有侧边栏、顶部导航、页脚这些公共部分。如果每个页面都复制粘贴一份,维护起来就是噩梦——你今天改了一行版权信息,得把所有页面都翻一遍。Thymeleaf解决这个问题的方式是模板片段。
核心机制就是把公共部分抽离成一个独立的HTML片段文件,用th:fragment声明片段名称,在用到的页面里用th:replace或th:insert引入。看一个实际例子。
公共片段文件common/fragments.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> </head> <body> <div th:fragment="header"> <nav> <span>后台管理系统</span> <a th:href="@{/logout}">退出登录</a> </nav> </div> <div th:fragment="footer"> <p>版权所有 © 2024 某公司</p> </div> </body> </html>然后在你自己的页面里引入:
<div th:replace="~{common/fragments :: header}"></div>语法结构是~{模板文件路径 :: 片段名}。模板文件路径相对于classpath:/templates/,不用带.html后缀。片段名就是th:fragment里定义的名字。
4.2 th:replace与th:insert的区别
th:replace和th:insert看着像,实际效果有细微差别。th:insert是保留外层标签,把片段内容插入到标签内部;th:replace是用片段直接替换掉当前标签。
我直接描述一下实际效果。假设片段的最终渲染结果是
公共内容
,用th:insert的话,最终HTML是公共内容
公共内容
,原来的div没了。平时开发我用得更多的是th:replace,因为外层包裹标签通常是临时写的,没必要保留。但如果你需要给公共片段包裹特定样式,用th:insert可能更合适。这个细节你只需要记住:insert是嵌进去,replace是换掉。
4.3 使用布局方言实现更优雅的布局
如果项目页面比较多,而且每个页面的骨架结构都一致,手动引入头部、侧边栏、页脚虽然能解决问题,但每个页面还是要写一大段模板代码。更优雅的方案是使用Thymeleaf Layout Dialect这个扩展库。
这个库允许你定义一个布局模板,把公共框架搭好,然后在每个页面里只填写内容区域,有点类似Vue里的插槽概念。先引入依赖:
<dependency> <groupId>nz.net.ultraq.thymeleaf</groupId> <artifactId>thymeleaf-layout-dialect</artifactId> </dependency>然后定义一个布局页layout.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"> <head> <meta charset="UTF-8"> <title layout:title-pattern="$CONTENT_TITLE - 后台管理系统">标题</title> </head> <body> <div th:replace="~{common/fragments :: header}"></div> <main layout:fragment="content"> 这里会被子页面内容替换 </main> <div th:replace="~{common/fragments :: footer}"></div> </body> </html>子页面只需要继承布局并填充内容:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout}"> <body> <section layout:fragment="content"> <h1>订单列表页面内容</h1> </section> </body> </html>layout:decorate声明使用哪个布局模板,layout:fragment="content"声明这个区域对应的布局模板里的哪块内容被填充。布局页里的title可以用layout:title-pattern表达式的模式来组合拼接,比如"$CONTENT_TITLE"占位符就是子页面里设置的内容。
这个方案在维护十几个后台页面的时候优势非常明显,你要改侧边栏导航,只需要动布局模板一个文件,所有页面同步生效。我后来维护的几个老项目都靠这个功能把公共模块的修改成本压到了最低。
5. 数据渲染与表单回显的完整案例
5.1 Controller里的数据传递方式
Thymeleaf通过SpringMVC的Model机制获取数据。Controller里常见的方式有几种:ModelAndView、Model、ModelMap。原理都一样,本质是把数据放进request作用域。看一个完整例子:
@Controller @RequestMapping("/order") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @GetMapping("/list") public String list(Model model) { List<OrderVO> orderList = orderService.queryOrderList(); model.addAttribute("orderList", orderList); model.addAttribute("pageTitle", "订单管理"); return "order/list"; } @GetMapping("/detail/{id}") public String detail(@PathVariable Long id, Model model) { OrderVO order = orderService.queryOrderById(id); model.addAttribute("order", order); return "order/detail"; } }return的是模板路径,前面说过的prefix和suffix配置会把"order/list"映射到classpath:/templates/order/list.html这个文件。model.addAttribute传的值,在模板里直接用${orderList}、${pageTitle}就能拿到,不用加前缀。
这里有个经验之谈:Controller里往Model放数据的时候,命名要语义化、统一化。我见过有人取名data、temp这类意义不明的名字,后来模板里引用经常出歧义,排查起来很痛苦。我的习惯是列表数据用xxxList、单个对象直接用xxx、页面标题用pageTitle,团队内部有统一的命名习惯,协作效率高不少。
5.2 列表页展示和操作列
现在写一个列表页,展示订单数据,带操作按钮。这是后台系统最典型的场景。
<table class="table table-striped"> <thead> <tr> <th>序号</th> <th>订单号</th> <th>客户名称</th> <th>订单金额</th> <th>状态</th> <th>创建时间</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="order, stat : ${orderList}"> <td th:text="${stat.count}">1</td> <td th:text="${order.orderNo}">ORD20240001</td> <td th:text="${order.customerName}">张三</td> <td th:text="${#numbers.formatDecimal(order.amount, 1, 2)}">0.00</td> <td> <span th:class="${order.status == 'PAID'} ? 'label label-success' : 'label label-default'" th:text="${order.statusText}">已支付</span> </td> <td th:text="${#dates.format(order.createTime, 'yyyy-MM-dd HH:mm:ss')}">2024-01-01 12:00:00</td> <td> <a class="btn btn-primary btn-sm" th:href="@{/order/detail(id=${order.id})}">详情</a> <a class="btn btn-danger btn-sm" th:href="@{/order/delete(id=${order.id})}" onclick="return confirm('确定删除该订单吗?')">删除</a> </td> </tr> <tr th:if="${#lists.isEmpty(orderList)}"> <td colspan="7" class="text-center">暂无订单数据</td> </tr> </tbody> </table>#numbers.formatDecimal处理金额格式化,第一个参数是数字,第二个参数是整数位数最少几位,第三个参数是小数位数。1表示整数部分至少1位,2表示保留两位小数。格式化日期用#dates.format,模式写法跟Java的SimpleDateFormat一致。
状态字段一般存的是英文字符串,比如PAID、SHIPPED,直接展示给用户不友好,所以在VO里我习惯加一个statusText字段,用来放对应的中文描述。这个字段可以在后端翻译好再返给页面,也可以在模板里写三元表达式判断,但后端处理更清晰,模板代码也更干净。
5.3 表单回显与编辑页处理
编辑页的关键点是把数据库里已有的数据回显到表单控件里。文本框用th:value设置值,下拉框和单选框要做选中状态的判断,文本域用th:text设置内容。
<form th:action="@{/order/save}" method="post"> <input type="hidden" name="id" th:value="${order != null ? order.id : ''}" /> <div class="form-group"> <label>订单号</label> <input type="text" name="orderNo" class="form-control" th:value="${order != null ? order.orderNo : ''}" /> </div> <div class="form-group"> <label>订单状态</label> <select name="status" class="form-control"> <option value="PAID" th:selected="${order != null && order.status == 'PAID'}">已支付</option> <option value="SHIPPED" th:selected="${order != null && order.status == 'SHIPPED'}">已发货</option> <option value="COMPLETED" th:selected="${order != null && order.status == 'COMPLETED'}">已完成</option> </select> </div> <button type="submit" class="btn btn-primary">保存</button> </form>编辑和新增共用一个表单页的时候,${order != null}这种判断必不可少。新增时order为空,表单各项都是空白;编辑时order有值,默认值回显。
有个小细节,隐藏域的写法。回显编辑页时id必须带上,否则保存时后端不知道你更新的哪条记录。我用hidden input存id,而不是把它放在URL里,主要是为了整洁,也在一定程度上避免参数暴露在地址栏。
th:selected控制下拉框选中状态,条件是status值等于当前option的值。新增状态下不需要选中任何一项,所以在条件判断里也带上order != null的校验。这样逻辑上才严密,不会出现空指针问题。
5.4 页面参数的URL拼接与表单提交
涉及URL拼接和表单提交时,Thymeleaf的处理方式也有一些讲究。链接表达式支持这样传参:
<a th:href="@{/order/detail(id=${order.id}, type='view')}">查看</a>多个参数用逗号分隔。参数值可以来自表达式。这种方式跟JSP里的c:url有点类似,好处是自动处理URL编码和context path。
表单提交的时候,th:action和th:href本质上没有区别,都是生成最终的提交地址。需要注意是POST提交时,Thymeleaf不支持直接生成带查询参数的表单action,正常情况下表单里都有隐藏域来带参,需要额外的参数时用隐藏input就行。
6. 常见问题与排查技巧实录
6.1 页面跳转返回字符串报错
这个问题我见过非常多新手遇到。Controller里return一个路径,结果页面直接输出路径字符串或者报错模板解析失败。
排查思路是这样的:首先确认return的路径是否有对应的模板文件。举例,return "order/list",就要看classpath:/templates/order/list.html是否存在。其次确认你是不是用了@RestController而不是@Controller,RestController会把返回值当JSON序列化输出,而不是解析视图。我遇到过一个情况,开发者在Controller类上标注了@RestController,页面就一直显示字符串,改成@Controller就正常了。
如果项目里既有接口又要返回页面视图,可以用@Controller标注页面跳转的Controller,另外写一个@RestController标注接口类,职责分开最清晰。
6.2 模板解析不出来或者报错500
模板解析异常通常会在页面直接抛错误信息,或者在日志里打出详细的栈信息。常见原因不外乎这几种:
一是模板文件路径不对。注意classpath:/templates/目录是固定的,别把模板文件放到classpath:/static/下面。static是静态资源的家,不会经过Thymeleaf解析。
二是表达式写错了。比如${orderList}写成${orderlist},大小写不匹配;或者调用了一个不存在的方法。仔细看日志信息,Thymeleaf的报错信息通常能定位到具体哪个模板、哪个行的哪个表达式出了问题。
三是HTML标签结构不闭合。Thymeleaf解析HTML时对标签的规范性有一定要求,特别是复杂的行内元素嵌套,没闭合的标签容易导致解析异常。我建议写完模板用IDE的HTML校验功能扫一遍。
6.3 开发时改了模板不生效
这个问题前面提到过,十有八九是缓存导致。application.yml里spring.thymeleaf.cache没设成false,或者设了false但还是不生效,就得刷新一下浏览器缓存。还有一个冷门原因:如果你用的是IDE内置的浏览器,IDE自身也可能有缓存,这种情况重启IDE就好。
如果项目是用热部署插件(比如devtools)在跑,有时候模板文件被占用没法重新加载,重启应用能解决。热部署对Java代码的变动感知比较灵敏,对静态资源文件就有点迟钝。
6.4 样式和图片加载不出来
排查看起来是模板没渲染的问题,实际上往往是静态资源路径404。检查思路很简单,打开浏览器的开发者工具看看Network面板,找到加载失败的资源路径,看它跟实际文件位置是否匹配。
如果页面里用的是原生路径比如/css/main.css,而项目配置了context path,那肯定加载不出来。全部改成th:href="@{/css/main.css}"的写法就解决了。static目录下的目录结构要跟访问路径对应,比如static/css/main.css对应的是/css/main.css。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 页面直接显示字符串 | Controller用了@RestController | 改成@Controller |
| 模板解析报500错误 | 表达式错误或模板路径不对 | 检查模板文件名、表达式语法 |
| 修改模板不生效 | Thymeleaf缓存未关闭 | 配置spring.thymeleaf.cache=false |
| 中文乱码 | 编码配置未设UTF-8 | 设置spring.thymeleaf.encoding=UTF-8 |
| 样式图片404 | 资源路径没走@{}语法 | 全部换成th:href/th:src |
| th:if判断不生效 | 表达式写法错误 | 检查表达式语法和引号使用 |
| 页面空白无报错 | 模板位置放错 | 确认模板在classpath:/templates/下 |
6.6 再看一眼:错误信息定位的技巧
Thymeleaf的报错信息里通常会带这样的提示:TemplateInputException、Error resolving template template"xxx"等。看到Error resolving template,几乎可以断定是模板路径找不到。去看文件是否存在、前缀后缀配置是否拼写有误。如果报错信息里是expression evaluation error,那就是表达式遍历或者取值为null的问题。
定位模板问题有个小技巧,把exception信息里的模板名和行号提取出来,直接去看对应模板的对应行,往往一眼就能发现是哪个表达式的问题。实际上,大部分Thymeleaf问题都是拼写错误、路径错误、或者语法细节没注意导致的,这些在排查时按部就班检查一遍就能解决。
7. 从实际项目里总结的经验
最后说点团队协作相关的体会。集成Thymeleaf本身不难,真正难的是让整个团队统一用一种规范的姿势来写模板。我在项目里逐渐沉淀下来一些约定,供你参考。
第一,模板文件命名和目录结构要有约定。我习惯按模块建目录,比如order/、user/、common/,每个模块的列表页和详情页放在模块目录下。公共片段集中放在common/目录下,统一管理。时间长了,找文件、改东西都非常方便。
第二,模板里不写复杂业务逻辑。这是原则问题。凡是涉及数据处理、判断、计算的,都在Controller层或Service层提前完成,塞进Model里的都是可以直接展示的数据。模板就干两件事:取数据、展示数据。这样出问题了排查起来思路非常清晰。
第三,列表页的序号、状态中文映射、日期格式化这类通用逻辑,我习惯在VO层面就处理好,而不是让模板分担。比如状态字段,我把status和statusText都放在VO里,模板那边只管展示。团队里有人写模板写歪了,一看代码规范就拉回来了。
第四,Closure相关的国际化资源文件,写之前先评估项目是否需要。有些内部系统用不着国际化,就别为了某种标准硬上,给自己增加维护成本。
集成Thymeleaf这件事本身平平无奇,但维护一个长线的后台系统就知道,模板统一、规范清晰带来的长期收益远大于刚开始节省的那点时间。所有公共部分抽成片段,所有的URL自动带上下文路径,所有的用户输入默认转义——Thymeleaf的这套机制天然就比硬拼字符串的模板方案更安全省心。