☰
Spring Boot整合Thymeleaf实战:服务端渲染的核心语法与最佳实践
2026/10/13 9:02:33 网站建设 项目流程

说到Spring Boot整合Thymeleaf,我第一次弄它其实挺不情愿的。那时候我满脑子都是前后端分离,一听到又要用模板引擎就下意识头大。但后来接了一个内部管理系统,前端人手不够,接口联调成本也高,我把心一横,重新捡起了服务端渲染,把页面直接用Thymeleaf渲染出来。结果意外地省事——加一个依赖、写几个html文件,数据就整整齐齐渲染到页面上了。

这篇文章我会用一个能直接跑通的小项目来做线索,从零把Spring Boot和Thymeleaf接在一起,然后把最常用的语法过一遍,最后把我踩过的坑和排查思路全部摊开。适合刚用Spring Boot做Web项目、想在页面展示数据的开发者看,也适合正在纠结模板引擎选型的Java后端。如果你之前只用过JSP或FreeMarker,看完大概能明白为什么不少人最后选了Thymeleaf。

1. 先搞清楚:好好的项目为什么非要选Thymeleaf

1.1 Thymeleaf到底是个啥

Thymeleaf说白了就是一个Java模板引擎,作用跟JSP、FreeMarker是同一类的,都是让你在HTML文件里塞动态数据,服务端解析后把最终渲染好的页面返回给浏览器。

它跟JSP最大的区别是,JSP允许你在页面里写一堆Java代码,跑起来是真能跑,但维护起来就像在牛排上浇了巧克力酱,味儿太冲了。Thymeleaf走的是另一个路子:在标准HTML标签上挂一堆th:开头的属性,由服务器解析之后替换成真实内容。模板本身还是干净的HTML。

它有个很特别的点是“自然模板”概念。模板文件直接用浏览器打开,看到的就是一个完整的静态页面,所有动态位置的默认内容都正常显示。只有当服务器处理时,才会把th:属性里的表达式解析掉、替换成真实数据。这个特性对页面设计师很友好,对后端调试也舒服——开发时可以双击html文件直接看布局效果,不用每次改动都起一个Web容器。

1.2 跟JSP和FreeMarker比,它凭什么赢

我用过这三种模板引擎,简单说下感受。JSP最老牌,但Spring Boot对JSP的支持一直有点尴尬,内嵌容器跑JSP需要搞额外的配置,部署jar包时更容易踩坑。而且JSP页面离开了后端环境就完全打不开,等于把页面和服务器绑死了。

FreeMarker的语法很强大,逻辑表达能力也强,但写多了很容易在模板里堆<#if>、<#list>,模板读起来像一门新语言,设计同学基本没法看懂。

Thymeleaf的优势恰好卡在它们中间:语法就是HTML属性,有天然的默认显示内容,前后端沟通页面效果时可以直接用浏览器打开同一个模板文件。再有就是Spring Boot官方对它支持得最顺,加一个starter依赖就能跑,连额外配置都很少。

我整理了一个简单对比,方便你直观感受:

对比项ThymeleafJSPFreeMarker
模板语法html属性th:*JSP标签+Java脚本自定义标签
浏览器直接打开能看静态效果不行不行
Spring Boot集成官方starter,配置少依赖内嵌容器,配置麻烦需要额外配置
前后端协作模板即静态页面必须起服务必须起服务
推荐场景服务端渲染页面老项目维护复杂渲染逻辑

1.3 自然模板和前后端分离信仰的一次和解

我必须承认,最初我站前后端分离那边,觉得模板引擎是上个时代的产物。可做了一段时间之后发现,前后端分离并不意味着永远正确。

前后端分离需要一个关键前提:前端资源充足、接口文档规范、跨域和鉴权体系健全。但真到了内部后台、运营管理系统、报表展示页这类场景,前端团队可能只有一两个人,或者压根没有专职前端。这时候搞前后端分离,光接口联调和环境部署就要花掉一大半时间,页面交互复杂度又不高,纯属杀鸡用牛刀。

Thymeleaf这种服务端渲染方案,Controller里查好数据直接往页面一丢,用户一访问就是完整页面,不需要等JS异步加载接口数据。页面照样可以引Bootstrap、Vue这类前端库,复杂交互在局部用JavaScript处理就行,灵活性并没有丢。我在实际项目里的折中做法是:首屏和列表页用Thymeleaf渲染,局部复杂交互再用Vue组件嵌进去,两边配合得挺顺。

所以我个人的结论是:别再纠结技术是不是“新”,只要场景匹配,Thymeleaf就是那个让你少掉头发的方案。

2. Spring Boot整合Thymeleaf:五步走完从零到页面渲染

2.1 第一步:把依赖加进pom.xml

我用的是Maven项目,加依赖就一行:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>

如果你用的是Spring Boot 2.x,这套依赖默认引入的是Thymeleaf 3.0.x;Spring Boot 3.x默认带的是Thymeleaf 3.1.x,要求Java 17以上。版本号不需要你手动指定,Spring Boot的父POM已经帮我们管理好了。

注意:不要画蛇添足去单独加一个thymeleaf依赖,版本容易跟Spring Boot内置的冲突。除非你确实需要升级Thymeleaf版本,否则老老实实跟着Boot走。

2.2 第二步:配置文件和目录约定

Spring Boot里Thymeleaf有一套默认目录约定,大部分情况下你什么都不用配:

  • 模板文件放在src/main/resources/templates/下,默认后缀是.html
  • 静态资源(css、js、图片)放在src/main/resources/static/下

开发阶段我至少会在application.properties里做两个配置:

spring.thymeleaf.cache=false spring.thymeleaf.encoding=UTF-8

第一个是关闭模板缓存,这样开发时改了模板文件不用重启服务就能看到效果。第二个是强制模板编码为UTF-8,避免中文乱码。生产环境我再把cache改回true,或者直接删掉这行,默认就是缓存开启的。

更完整的配置项可以这样写:

spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.mode=HTML spring.thymeleaf.encoding=UTF-8 spring.thymeleaf.cache=false

prefix和suffix就是拼接视图名用的,比如Controller返回"index",最终会找classpath:/templates/index.html。除非你有特殊需求,默认值不用动。

2.3 第三步:写一个最简单的Controller

在你已有的Spring Boot项目里新建一个Controller,用来往页面上传数据:

import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @Controller public class IndexController { @GetMapping("/") public String index(Model model) { model.addAttribute("title", "Spring Boot 整合 Thymeleaf 入门"); model.addAttribute("message", "这是一个由 Thymeleaf 渲染的页面"); return "index"; } }

这里有两个很容易踩的坑。

第一个是类上用了@RestController。@RestController等于@Controller加@ResponseBody,它会把返回的字符串直接写成HTTP响应体,所以你访问页面看到的会是index这几个字母,而不是渲染好的HTML。记得用@Controller。

第二个是Model和ModelAndView的选择。新手用Model.addAttribute最常见,也最直观;ModelAndView也能实现同样效果,但写法上会多几行。我建议先把Model用熟。

2.4 第四步:创建页面模板

在src/main/resources/templates/目录下新建一个index.html:

<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title th:text="${title}">默认标题</title> </head> <body> <h1 th:text="${title}">页面标题占位</h1> <p th:text="${message}">这里显示一段消息</p> </body> </html>

xmlns:th="http://www.thymeleaf.org"是Thymeleaf的命名空间声明。它的作用是让IDE识别th:属性从而提供代码提示和校验,运行的时候并不是必须的。但建议写上,开发效率会高很多。

注意th:text这个属性,它的作用是把标签的文本内容替换成表达式的值。标签里原本写的“页面标题占位”就是静态默认内容,服务端渲染时会直接被Model里的title值替换掉。

2.5 第五步:关闭缓存并启动验证

启动项目,浏览器访问http://localhost:8080/。正常情况下你会看到:

  • 浏览器标签页标题是Spring Boot 整合 Thymeleaf 入门
  • 页面h1标题也是同样的文字
  • p标签里是一段消息文本

看到这个效果,就说明Spring Boot和Thymeleaf已经成功打通了。

如果页面上还是显示index字符串,检查Controller上是不是误加了@ResponseBody或用了@RestController。如果是404,看一下模板文件在不在templates目录下,文件名和后缀是否完全一致。这一步虽然简单,但排查起来有时比预想中花时间,我后面专门写一节踩坑总结。

3. Thymeleaf核心语法,看完这篇文章你就算入门了

3.1 表达式基础:${}、*{}、@{}、#{}分别怎么用

Thymeleaf的表达式有好几种,日常开发最常碰到的就是这四个。

${...}是变量表达式,从Model、Request域、Session域里取值,比如${user.name}、${userList.size()}。它支持链式调用对象属性,也支持调用Java对象的方法。

*{...}是选择表达式,需要配合th:object使用。比如th:object="${user}"之后,模板里写*{name}就等价于${user.name},省得每次写冗长的对象路径。

@{...}是链接表达式,用来生成URL。它最大的好处是会自动拼接应用的上下文路径,比如@{/css/style.css}最终会生成/项目名/css/style.css这种完整路径。带参数的写法很常见:@{/user/detail(id=${user.id})}。

#{...}是消息表达式,主要用来读取国际化资源文件。比如messages.properties里配置了login.title=登录,页面里就可以用#{login.title}引用。做多语言的场景会经常用到。

这些表达式经常混在一起用,比如:

<a th:href="@{/user/detail(id=${user.id})}" th:text="${user.name}">用户名</a>

这个例子里同时用到了变量表达式和链接表达式,是后端项目里出现频率最高的写法。

3.2 最常用的th:属性,日常开发八成靠它们

th:属性才是Thymeleaf真正干活的主力。我把高频属性整理成了一张速查表:

属性作用示例
th:text设置文本内容,HTML特殊字符会被转义th:text="${user.name}"
th:utext设置文本内容,不转义,直接输出HTMLth:utext="${content}"
th:if条件满足才渲染当前标签th:if="${user != null}"
th:unless条件不满足才渲染当前标签th:unless="${user == null}"
th:each循环渲染th:each="item : ${list}"
th:href生成href链接th:href="@{/user/list}"
th:src生成src资源地址th:src="@{/img/logo.png}"
th:class动态设置class属性th:class="${active ? 'on' : ''}"
th:value设置value属性th:value="${user.id}"
th:object指定表单提交绑定的对象th:object="${user}"
th:field绑定表单字段,常跟th:object一起用th:field="*{name}"
th:with定义局部变量th:with="total=${price * num}"

实际写页面的时候,不夸张地说,上面这张表里的属性已经占了所有使用量的80%以上。先把它们用熟练,基本就能应付绝大多数模板开发。

3.3 循环渲染th:each和条件判断th:if/th:unless

th:each是渲染列表的核心属性。它的格式是th:each="变量名 : ${集合}",例如:

<table> <tr> <th>序号</th> <th>姓名</th> <th>状态</th> </tr> <tr th:each="user, stat : ${userList}"> <td th:text="${stat.count}">1</td> <td th:text="${user.name}">张三</td> <td th:if="${user.status == 'active'}">正常</td> <td th:unless="${user.status == 'active'}">停用</td> </tr> </table>

第二个变量stat是循环状态对象,能引用当前迭代的状态信息:

  • stat.index:从0开始的下标
  • stat.count:从1开始的计数
  • stat.size:集合总大小
  • stat.odd/stat.even:当前行是否为奇数行/偶数行
  • stat.first/stat.last:是否第一行/最后一行

有了这些状态变量,做表格斑马纹、行号、首行末行特殊样式都非常方便。

条件判断要注意一点:th:if判断的是布尔值,也可以直接判断对象是否为空。比如th:if="${user}"表示user变量存在且不为空时才渲染。th:unless就是反过来的“除非”,条件为假时渲染。

3.4 字符串拼接、格式化与内联表达式

在模板里经常会遇到“把几个变量拼接起来显示”的需求,比如:

<p th:text="'姓名:' + ${user.name} + ',年龄:' + ${user.age}">用户信息</p>

这种写法能跑,但可读性一般。Thymeleaf提供了竖线表达式,更清爽:

<p th:text="|姓名:${user.name},年龄:${user.age}|">用户信息</p>

竖线里可以直接嵌${}表达式,字符串部分原样输出,是我最推荐的拼接方式。

格式化方面,Thymeleaf内置了一批工具类,放在#后面调用。常用的是#numbers和#dates:

<p th:text="${#numbers.formatDecimal(total, 1, 2)}">总金额</p> <p th:text="${#dates.format(createTime, 'yyyy-MM-dd HH:mm:ss')}">创建时间</p>

#strings、#lists、#maps也经常会用到,比如#strings.isEmpty()判断字符串是否为空、#lists.size()获取集合大小。

除了th:text,Thymeleaf还支持内联表达式,直接在文本节点里写:

<p>欢迎你,[[${session.loginUser.name}]]</p>

[[...]]等价于th:text,会转义HTML;[(...)]等价于th:utext,不转义。内联表达式在处理一段文本中穿插变量时特别方便,可以少写很多th:属性。

4. 页面布局与模板复用:告别每个页面重复写导航栏

4.1 th:fragment把公共部分抽出来

一个后台管理系统的页面,通常几十个页面都共用同一个顶部导航栏和底部版权信息。如果每个页面都复制粘贴一遍,改一行菜单就要全局搜索替换几十个文件,太痛苦了。

Thymeleaf里可以用th:fragment把公共部分抽成一个独立片段,定义在templates/fragments/common.html:

<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>公共片段文件</title> </head> <body> <header th:fragment="header"> <span>后台管理系统</span> <nav> <a th:each="menu, stat : ${menus}" th:href="${menu.url}" th:text="${menu.name}">菜单</a> </nav> </header> <footer th:fragment="footer"> <p>版权信息</p> </footer> </body> </html>

这里的th:fragment="header"就是给这个片段取了个名字,方便其他地方引用。需要注意的是,这个文件本身不需要被Controller正常渲染,它的作用就是存放片段。名字随意,能看懂就行。

4.2 th:insert、th:replace、th:include三种引用的区别

引用片段有三种方式,新手很容易混。

  • th:insert:把片段插入到当前标签的内部。也就是说当前标签会保留,片段内容成为它的子节点。
  • th:replace:用片段替换掉当前标签。当前标签本身会消失,被片段整体替换。
  • th:include:只把片段内部的HTML内容插入进来,不包括片段根标签。这个方式在新版本里其实已经不太推荐了,多数情况下会被th:insert替代。

区别用一个例子看更明白。假设有个片段:

<div th:fragment="tips">这是提示内容</div>

引用时这样写:

<div th:insert="fragments/common :: tips">占位内容</div>

最终渲染结果是:

<div> <div>这是提示内容</div> </div>

换成th:replace:

<div th:replace="fragments/common :: tips">占位内容</div>

最终渲染结果是:

<div>这是提示内容</div>

当前标签div直接被片段替换掉了。我平时用th:replace最多,因为它不会产生多余的嵌套层级,页面结构更干净。

4.3 用th:replace做一个完整的后台布局骨架

要做一个统一的页面布局,可以把公共部分全部抽到common.html里,每个业务页面只写自己独有的内容。

先给common.html的片段加上可传参数的能力:

<head th:fragment="head(title)"> <meta charset="UTF-8"> <title th:text="${title}">默认标题</title> <link rel="stylesheet" th:href="@{/css/common.css}"> </head> <header th:fragment="header"> <span>后台管理系统</span> </header> <footer th:fragment="footer"> <p>版本号 1.0.0</p> </footer>

然后在业务页面里引用:

<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head th:replace="fragments/common :: head('用户列表')"> <meta charset="UTF-8"> <title>用户列表</title> </head> <body> <div th:replace="fragments/common :: header"></div> <h1>用户列表</h1> <table> <!-- 业务内容 --> </table> <div th:replace="fragments/common :: footer"></div> </body> </html>

head('用户列表')这个写法是在引用片段时传参,片段里的${title}会被替换成用户列表。这样做的好处是每个页面的head标签都复用了同一套公共资源引用,以后要加一个全局JS,只需要改common.html一处,所有页面自动更新。

实际项目中我会把菜单数据放在公共拦截器或Controller的公共Model里,这样所有页面拿到的menus都是一致的,导航栏高亮状态也可以在Controller里根据当前请求路径算好再传。总之th:fragment是服务端渲染项目做布局复用的利器,谁用谁知道。

5. 表单绑定、参数传递与回显:一个新增用户场景的完整实现

5.1 POST表单提交,action和method怎么配

模板引擎的一大用途就是做表单页面。我以一个新增用户的场景来演示完整的流程。

先建一个新增页面templates/user/add.html:

<form th:action="@{/user/save}" method="post"> <div> <label>姓名</label> <input type="text" name="name" /> </div> <div> <label>邮箱</label> <input type="text" name="email" /> </div> <button type="submit">保存</button> </form>

表单提交的直接是POST请求,表单里的name="name"、name="email"提交后,后端用一个对象接收。Controller这样写:

@PostMapping("/user/save") public String save(User user, RedirectAttributes ra) { userService.save(user); ra.addFlashAttribute("msg", "保存成功"); return "redirect:/user/list"; }

Spring Boot会自动把请求参数填充到User对象的同名属性里,这一步不需要额外配置。RedirectAttributes的作用我下面会专门说,这里先记住它是重定向时带数据的好帮手。

注意:如果项目里引入了Spring Security且开启了CSRF防护,POST表单需要额外加上隐藏的CSRF Token字段,否则提交会被拦截。没引入安全框架的话可以先忽略这一点。

5.2 th:object和th:field实现实体绑定

上面的写法能完成提交,但有个缺点:如果填写出错,用户输入过的数据就丢了,返回表单页时又是一片空白,体验很差。Thymeleaf提供了th:object配合th:field来做表单回显。

改造一下Controller里的表单入口方法:

@GetMapping("/user/add") public String add(Model model) { model.addAttribute("user", new User()); return "user/add"; }

页面模板同步改成:

<form th:action="@{/user/save}" th:object="${user}" method="post"> <div> <label>姓名</label> <input type="text" th:field="*{name}" /> </div> <div> <label>邮箱</label> <input type="text" th:field="*{email}" /> </div> <button type="submit">保存</button> </form>

th:field="*{name}"会同时做三件事:自动设置id="name"、自动设置name="name"、把${user.name}的值作为value回显到输入框里。提交失败返回这个页面时,原来填的内容还在,用户不需要再全部重新输入。

如果提交时后端检测到校验错误,th:field还会自动把错误信息关联过来,配合下面的th:errors就能在对应字段下方显示错误提示。

5.3 Model、ModelAndView、RedirectAttributes这几种传参方式

后端往页面传数据的方式有好几种,我把常见的整理出来:

方式作用范围典型场景
Model.addAttribute请求域,仅本次请求有效渲染页面数据
ModelAndView请求域,同时指定视图老式写法,数据+视图一起返回
RedirectAttributes.addFlashAttribute一次重定向期间有效提交后跳转提示信息

Model.addAttribute是最常用的,页面渲染时数据都在。ModelAndView本质一样,只是把视图名和数据封装到一个对象里。RedirectAttributes.addFlashAttribute比较特殊,它是先重定向到另一个URL,然后在那个请求里能读取到一次数据,读完之后自动销毁。

我在提交表单后保存成功时最喜欢用FlashAttribute:

@PostMapping("/user/save") public String save(User user, RedirectAttributes ra) { userService.save(user); ra.addFlashAttribute("msg", "保存成功"); return "redirect:/user/list"; }

然后在列表页里读取:

<p th:if="${msg}" th:text="${msg}" class="success"></p>

这种写法可以避免用户刷新列表页时重复提交表单的问题,因为重定向之后是一次全新的GET请求,上一次POST请求已经结束了。

5.4 后端校验加前端提示:配合Validation做表单验证

做表单页面不能只靠前端验证,后端必须再做一次。Spring Boot整合校验框架很方便,先加依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

在User实体上添加校验注解:

public class User { @NotBlank(message = "姓名不能为空") private String name; @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") private String email; // getter / setter 省略 }

Controller里的保存方法改成:

@PostMapping("/user/save") public String save(@Valid @ModelAttribute("user") User user, BindingResult result, RedirectAttributes ra) { if (result.hasErrors()) { return "user/add"; } userService.save(user); ra.addFlashAttribute("msg", "保存成功"); return "redirect:/user/list"; }

注意两点:@Valid必须跟BindingResult成对使用,而且BindingResult必须紧跟在被校验的参数后面。@ModelAttribute("user")指定了表单绑定对象在Model里的名字是user,这样返回表单页时th:object="${user}"才能取到带错误信息的对象。

模板里加上错误提示:

<input type="text" th:field="*{name}" /> <p th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error"></p>

th:errors会自动显示校验框架返回的错误消息。#fields.hasErrors('name')用来判断这个字段到底有没有错误。这样整个流程就闭环了:用户提交无效数据,页面原样留在表单页,输入的内容还在,每个字段下方还会亮出具体错误原因。

6. 实战小项目扩展:把用户列表做成一个管理页

6.1 列表页:查询所有用户并渲染表格

有了表单提交能力,再补一个列表页,一个简单的用户管理模块就完整了。

Controller:

@Controller @RequestMapping("/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/list") public String list(Model model) { model.addAttribute("userList", userService.findAll()); return "user/list"; } }

模板user/list.html:

<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户列表</title> </head> <body> <h1>用户列表</h1> <table> <thead> <tr> <th>序号</th> <th>姓名</th> <th>邮箱</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="user, stat : ${userList}"> <td th:text="${stat.count}">1</td> <td th:text="${user.name}">张三</td> <td th:text="${user.email}">zhangsan@example.com</td> <td> <a th:href="@{/user/detail(id=${user.id})}">查看</a> <a th:href="@{/user/edit(id=${user.id})}">编辑</a> <a th:href="@{/user/delete(id=${user.id})}" onclick="return confirm('确定删除吗?')">删除</a> </td> </tr> <tr th:if="${#lists.isEmpty(userList)}"> <td colspan="4">暂无数据</td> </tr> </tbody> </table> <a th:href="@{/user/add}">新增用户</a> </body> </html>

#lists.isEmpty(userList)是工具类方法,用来判断集合是否为空。数据为空时显示“暂无数据”,这个细节在真实页面上很加分。

6.2 新增与详情:链接传参和对象传递

列表页里“查看”链接用的是@{/user/detail(id=${user.id})},这种带参数的链接最终会生成类似/user/detail?id=1的URL。

后端接收有两种常见方式。用@RequestParam直接取参数:

@GetMapping("/user/detail") public String detail(@RequestParam Long id, Model model) { model.addAttribute("user", userService.findById(id)); return "user/detail"; }

用@PathVariable取路径参数也可以,但链接表达式写法要换成:

<a th:href="@{/user/detail/{id}(id=${user.id})}">查看</a>

后端:

@GetMapping("/user/detail/{id}") public String detail(@PathVariable Long id, Model model) { model.addAttribute("user", userService.findById(id)); return "user/detail"; }

我一般更推荐@PathVariable,URL语义更清晰。总之Controller取到id之后查数据,放到Model里,再在详情页通过${user.name}、${user.email}输出即可。

6.3 使用th:block处理不产生HTML标签的循环

有一种场景很尴尬:你想循环渲染多个标签,但外面不想套一个多余的div。HTML本身是不能直接包多标签的,Thymeleaf提供了th:block来解决这个问题。

<th:block th:each="item : ${itemList}"> <h3 th:text="${item.title}">标题</h3> <p th:text="${item.summary}">摘要</p> </th:block>

th:block本身不会生成任何HTML标签,它只是一个逻辑容器。渲染完成后页面里只有h3和p,不会多一层div。这个特性在需要按组输出内容、又不想破坏结构完整性的时候非常管用。

6.4 分页和搜索:真实后台系统绕不开的需求

列表数据多了之后,必然会遇到分页和搜索。分页的Controller大致长这样:

@GetMapping("/user/list") public String list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "") String keyword, Model model) { PageResult<User> result = userService.pageQuery(page, 10, keyword); model.addAttribute("userList", result.getList()); model.addAttribute("page", result.getPage()); model.addAttribute("total", result.getTotal()); model.addAttribute("keyword", keyword); return "user/list"; }

模板里生成上一页、下一页链接:

<a th:href="@{/user/list(page=${page - 1}, keyword=${keyword})}">上一页</a> <a th:href="@{/user/list(page=${page + 1}, keyword=${keyword})}">下一页</a>

搜索表单就是普通的GET表单,提交之后参数拼在URL地址上:

<form th:action="@{/user/list}" method="get"> <input type="text" name="keyword" th:value="${keyword}" /> <button type="submit">搜索</button> </form>

这里th:value="${keyword}"保证搜索后输入框还保留着上次的关键词。分页和搜索是真实后台系统最常见的需求,用Thymeleaf原生的链接表达式就能轻松实现,不需要额外引入插件。

7. 我踩过的那些坑:常见问题与排查技巧实录

7.1 页面不生效或者老是旧页面

有一次我改了add.html里的一个按钮文案,刷新浏览器死活不出来,最后发现是模板缓存没关。这是新手必踩的坑。

解决办法:开发时在application.properties里加上spring.thymeleaf.cache=false。如果加了还不行,看看是不是同时存在target下的旧class文件,IDEA里就手动Build -> Rebuild Project一下,把target/templates里的旧模板清掉再重新编译。

7.2 模板文件放到哪、IDEA不编译target怎么办

模板文件必须放在src/main/resources/templates/这个目录下。有人会手快建到src/main/resources/template或者src/main/resources/static下面,Spring Boot按默认配置找不到,就会报Error resolving template [...] template might not exist or might not be accessible by any of the configured Template Resolvers。

另外,IDEA里如果你新建的目录没有被识别成Resource Root,编译到target/classes时可能不会带上模板文件。右键目录 ->Mark Directory as -> Resources Root可以解决。判断方法很简单:去target/classes/templates下看看文件在不在,不在就说明编译阶段就没拷过去。

7.3 中文乱码、特殊字符被转义

中文乱码大部分是编码不一致造成的。模板文件本身要用UTF-8编码保存,页面上<meta charset="UTF-8">别漏,配置里写上spring.thymeleaf.encoding=UTF-8,三条都满足基本不会乱。

特殊字符转义是另一个常见问题。th:text默认会把<、>这些符号转义成HTML实体,这是为了防XSS攻击。如果你的数据本身是富文本内容,比如一段<b>加粗</b>字符串,用th:text输出会看到一堆HTML源码,而不是加粗效果。这时候要改用th:utext。

但th:utext一定要慎用,只有你能确保数据来源可信、没有用户录入的不可信内容时才用,否则等于把XSS漏洞打开了一个口子。

7.4 404、500、无法解析模板的各种排查思路

我整理了一张速查表,遇到问题先照着对一遍:

现象可能原因解决方案
404 页面不存在Controller路径写错或模板文件位置不对核对@GetMapping路径和templates目录
500 TemplateInputException模板语法错误、表达式写错、变量为空看堆栈里标注的行号,检查模板该行
浏览器显示一串字符串Controller用了@RestController改成@Controller
页面渲染了但是th:属性没生效模板里写错表达式或依赖版本异常检查th命名空间和表达式写法
表单提交后返回404th:action生成URL不对用@{/user/save}而不是/user/save

出现TemplateInputException时,异常信息里通常会写着Line X,直接定位到对应行检查标签有没有闭合、表达式括号是否匹配、变量名是否大小写一致。这类报错我遇到过好多次,九成都是手滑写错变量名。

7.5 缓存与热部署的终极配置

如果你打算长期用Thymeleaf做开发,我建议把热部署一次配到位。先在pom.xml里加devtools依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency>

然后application.properties里配置:

spring.thymeleaf.cache=false spring.devtools.restart.enabled=true

这样改Java代码、改模板都能自动重启,配合IDEA的Build Project快捷键,开发体验会提升一大截。不过要记住:生产环境务必将spring.thymeleaf.cache设回true或者删掉那一行,否则每次请求都会重新解析模板,拖慢性能。

7.6 一个小技巧:给模板加类型提示,让IDEA帮你检查表达式

模板里写${user.name}时,IDEA不知道user是什么类型,没法给你提示,写错属性名只能运行时报错。其实可以用Thymeleaf的注释语法声明变量类型:

<!--/*@thymesVar id="user" type="com.example.model.User"*/-->

加了这个注释之后,模板里写${user.就会弹出User类的属性提示,字段名写错也能在编辑器里提前发现。我在写复杂页面时一定会带上这些注释,能省下大量反复重启调试的时间。

最后再分享一个我个人的使用习惯:凡是内部后台这类项目,只要交互不复杂、不需要大量异步刷新,我基本都会选Spring Boot加Thymeleaf的组合。它不需要额外搞一套前端工程,打包也是一个jar包直接扔服务器跑,页面渲染速度快,维护成本也低。技术选型这事儿,真的不是越新越好,合适才是最好的。你能把一套方案用透,遇到绝大多数需求就有足够多的解决办法,这就比那些花里胡哨但落不了地的方案强太多了。

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

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

立即咨询