很多刚开始接触 Spring Boot 的朋友都会遇到同一个问题:Controller 写好了、接口通了,页面到底怎么渲染?公司老项目用 JSP,但 Spring Boot 默认给的模板方案里根本没有 JSP 的位置。Spring Boot 整合 Thymeleaf 是官方推荐里最顺手的组合,也是服务端渲染项目里最常见的搭配。本文我会从模板选型讲到依赖配置,再带你走通第一个页面,最后把 Thymeleaf 日常开发里最高频的语法、布局复用和联调排错坑一次性讲透,适合刚学 Spring Boot、想找一种可靠页面方案的初学者,也适合已经用过一点 Thymeleaf 但遇到各种隐性问题的开发者直接对照排查。
1. 为什么Spring Boot项目里模板引擎绕不开Thymeleaf
1.1 JSP在Spring Boot里的处境
在过去的 Java Web 项目里,JSP 几乎就是页面渲染的代名词。但 Spring Boot 对 JSP 的支持一直很别扭,核心原因是 JSP 最终要编译成 Servlet,而 Spring Boot 打包时常用可执行 jar 的方式启动,JSP 在这种打包结构下没法被容器正常识别。你用spring-boot-maven-plugin打出 jar 后,放在/WEB-INF/jsp下的页面经常直接 404,要么就得额外加一堆配置、把项目改成 war 包部署到外置容器。
也不是说 JSP 完全不能用,但和 Spring Boot 的设计取向明显冲突:Spring Boot 希望一切以 jar 方式独立运行,内嵌容器直接启动,而 JSP 依赖外部容器的 JSP 编译器。所以官方在 Starter 列表里给了 Thymeleaf、FreeMarker、Mustache 等服务端模板引擎,其中 Thymeleaf 是我个人觉得和 Spring Boot 协作最顺畅的一个。
1.2 Thymeleaf的设计哲学:页面本来就是模板
Thymeleaf 和其他模板引擎最大的不同在于,它不要求你单独写一套“模板语法文件”,而是直接在 HTML 标签的属性上做文章。比如你有一个<p>你好</p>,想显示后端传过来的用户名,只要写成<p th:text="${username}">你好</p>,这就是一个合法的 HTML 文件。
这一点在实际开发中价值很大:前端工程师可以直接用浏览器打开这个 HTML 文件看静态效果,看到的依然是“你好”;后端填了数据之后,Thymeleaf 会拿${username}的值替换标签体里的内容。页面不用编译、不用先启动服务器就能预览,原型设计和后端开发可以并行。对比 JSP 那种<%@ page %>指令和<% %>脚本片段,Thymeleaf 的模板更干净,页面结构不会被逻辑代码搅乱,这也是我后来在项目里越来越倾向它的原因。
1.3 先想清楚:你的项目该不该用模板引擎
Thymeleaf 适合的是服务端渲染场景,也就是“后端把数据拼进 HTML,返回完整页面”。典型的例子是后台管理系统、内容管理系统、活动运营页面、企业内部工具等。这类系统通常不需要极致的交互体验,页面数量多但结构相似,用模板引擎开发速度最快。
如果你的项目是纯接口项目,比如给 App 或小程序提供 JSON,那就不需要引入任何模板引擎;如果你做的是高交互单页应用,页面全部靠前端框架渲染,Thymeleaf 也不该出现在依赖里。很多新手容易犯的错是不管什么项目都先把 Thymeleaf 加进来,结果页面没用上,白白增加启动扫描成本。先把适用边界想清楚,后面踩坑会少一半。
2. 环境准备与依赖引入:版本兼容是第一个隐藏坑
2.1 最小Maven依赖与实际pom片段
搭建一个 Spring Boot 整合 Thymeleaf 的项目,依赖上其实特别简单,只需要一个启动器。这里给出一个我常用的最小配置:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies>只加spring-boot-starter-thymeleaf,Spring Boot 的自动配置就会帮你完成大部分工作:视图解析器、模板引擎、缓存开关、编码设置都会被注册到容器里。你不需要再手动创建SpringTemplateEngine或配置ThymeleafViewResolver,除非你有非常特殊的定制需求。
2.2 Spring Boot与Thymeleaf版本对应关系
Spring Boot 的 Starter 会锁定它所配套的 Thymeleaf 主版本,正常情况下你不需要关心具体版本号。但如果你在旧项目里升级 Spring Boot,或者因为其他依赖被迫手动指定了 Thymeleaf 版本,就要注意版本兼容性这一层。
比如 Spring Boot 2.x 全系列默认都搭配 Thymeleaf 3.0.x,而 Spring Boot 3.x 需要配合 Thymeleaf 3.1 以上,且整套包名迁到了jakarta.*。如果你在 Spring Boot 3 的项目里仍依赖了旧代码中基于javax.*的拦截器或过滤器,页面渲染会出现各种诡异的初始化异常。实际排查时,优先看控制台里模板引擎初始化时的报错,再检查 Spring Boot 与 Thymeleaf 的大版本是否匹配。建议直接用 Boot 的 BOM 管理版本,不要手写 Thymeleaf 的<version>。
2.3 手动调整版本号后的典型报错
我见过最典型的场景是:团队里有人图省事,在 pom 里显式写了thymeleaf-spring5的依赖,这个包其实是兼容层,直接加进去会导致容器里同时出现两个模板引擎实例,Spring Boot 自动配置反而失效。
还有一种报错是运行时提示找不到SpringSecurityDialect,这是你没引入thymeleaf-extras-springsecurity却用了sec:标签,问题也出在依赖缺失而非代码。所以如果你不是要扩展 Thymeleaf 方言,请一定保持spring-boot-starter-thymeleaf作为唯一来源,别额外拼凑版本号。
3. 第一个页面:Controller到模板解析的完整链路
3.1 模板文件存放位置与默认配置
Spring Boot 的 Thymeleaf 自动配置约定了一系列默认值,其中最重要的是前缀和后缀:模板文件默认放在classpath:/templates/目录下,默认后缀是.html。也就是说,当你访问一个 URL、Controller 返回字符串"index"时,实际定位到的是src/main/resources/templates/index.html。
对应的配置文件可以这样写:
# 页面模板位置与后缀 spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html # 开发阶段关闭缓存,改完模板刷新即可看到新内容 spring.thymeleaf.cache=false # 明确指定 UTF-8 编码 spring.thymeleaf.encoding=UTF-8cache=false在开发环境非常重要。Thymeleaf 在默认情况下会缓存解析后的模板,你改了 HTML 文件之后不重启服务就看不到效果,新手经常以为代码出错了。生产环境则相反,应该打开缓存来减少模板解析的重复开销。
3.2 Controller返回字符串怎么变成HTML
这里要理清一个流程,也是很多初学者对 Spring MVC “视图解析”最困惑的地方。看这个简单的 Controller:
@Controller public class IndexController { @GetMapping("/") public String index(Model model) { model.addAttribute("appName", "我的第一个Thymeleaf页面"); return "index"; } }当浏览器访问根路径/时,Spring MVC 会执行这个方法,返回"index"。这个字符串不是要直接写给浏览器的响应内容,而是视图名。接下来ThymeleafViewResolver会拿着视图名,结合前缀和后缀,拼成classpath:/templates/index.html,找到模板文件,再交给模板引擎渲染,最后把渲染后的 HTML 作为响应返回给浏览器。
这里容易踩的坑是:用@RestController而不是@Controller。@RestController会默认把返回值当作响应体直接序列化,结果浏览器里看到的不是页面,而是一段 JSON 文本"index"。想返回页面必须用@Controller。
3.3 静态资源与模板资源的边界
模板文件放在templates下,但 CSS、JavaScript、图片这些静态资源不能放在这里,否则不会被spring-boot-starter-web默认的静态资源映射处理。Spring Boot 默认将/static、/public、/resources、/META-INF/resources映射为静态资源目录,我一般统一用/static。
在模板里引用静态资源,推荐用 Thymeleaf 的链接表达式,它在渲染时会自动拼上上下文路径:
<link rel="stylesheet" th:href="@{/css/common.css}"> <script th:src="@{/js/app.js}"></script>如果你直接写死href="/css/common.css",在部署到带 context path 的服务器上时会找不到文件,而@{}表达式会自动处理应用上下文前缀,这是我从一开始就坚持用th:href的原因。
3.4 用Model把数据塞给页面:第一个th:text
model.addAttribute("appName", "...")把数据放进了请求域,模板里用th:text就能取出来:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>首页</title> </head> <body> <h1 th:text="${appName}">这是默认标题</h1> </body> </html>th:text的含义是“用表达式的值替换这个标签的文本内容”。如果后端传了appName,页面会显示传过来的值;如果没传,Thymeleaf 会把${appName}渲染成空字符串,而不是保留标签内的默认文本,这一点和普通 HTML 预览时看到的默认内容不同,需要留意。
4. Thymeleaf核心语法速成:工作里用得最多的那些
4.1 四种表达式类型的选择逻辑
Thymeleaf 有四种最常用的表达式:变量表达式${...}、选择表达式*{...}、链接表达式@{...}、消息表达式#{...}。
变量表达式用来读取后端放进 Model 里的数据,例如${user.name}表示读取user对象的name属性,底层走的是 JavaBean 的 getter 方法。选择表达式必须配合th:object使用,它作用于当前对象。链接表达式我前面已经提过,专门用来生成 URL。消息表达式则用来读取国际化资源文件,例如#{menu.home}对应messages.properties里的menu.home。
选择表达式和变量表达式看起来很像,容易混淆。记住一个核心区别:
<!-- 方式一:不断写user.前缀 --> <p th:text="${user.name}"></p> <p th:text="${user.age}"></p> <!-- 方式二:先指定对象,直接用属性名 --> <div th:object="${user}"> <p th:text="*{name}"></p> <p th:text="*{age}"></p> </div>当模板中需要反复读取同一个对象的多个属性时,th:object加*{}能让页面明显清爽。如果你的页面只有一处使用,直接用${}就行,不必为了“美观”强行引入选择表达式。
4.2 th:each循环和th:if条件判断的经典组合
后端传入一个用户列表,前端要循环渲染表格,这是后台管理系统最常见的需求。th:each的写法如下:
<table> <tr> <th>姓名</th> <th>状态</th> </tr> <tr th:each="user : ${userList}"> <td th:text="${user.name}"></td> <td th:text="${user.status}"></td> </tr> </table>循环变量user的作用域仅在当前<tr>标签内部。如果你还需要当前循环的下标或总数,Thymeleaf 会额外提供一个状态变量:
<tr th:each="user, iter : ${userList}"> <td th:text="${iter.index + 1}"></td> <td th:text="${user.name}"></td> </tr>iter.index从 0 开始,所以显示序号时加 1。注意这里的状态变量不只是下标,还包含count、size、even、odd等属性,用来做奇偶行样式很方便。
条件判断场景里,th:if和th:unless是组合使用的。th:if在表达式为真时渲染标签,th:unless则相反。比如列表为空时显示提示:
<div th:if="${#lists.isEmpty(userList)}"> 暂时没有用户数据 </div> <div th:unless="${#lists.isEmpty(userList)}"> 当前共有 <span th:text="${userList.size}"></span> 位用户 </div>4.3 表单回显与提交:th:object + th:field
表单回显是最容易让初学者绕晕的部分。所谓回显,就是打开编辑页面时,表单里的输入框要自动填上数据库中已有的值。Thymeleaf 提供了th:field,它会自动根据表单项的name属性和绑定对象的属性值回显数据。
假设后端传给 Model 一个userForm对象,前端写:
<form th:action="@{/user/save}" th:object="${userForm}" method="post"> <input type="hidden" th:field="*{id}"> <input type="text" th:field="*{name}" placeholder="请输入姓名"> <input type="text" th:field="*{phone}" placeholder="请输入手机号"> <button type="submit">保存</button> </form>th:field="*{name}"会自动做三件事:把name属性渲染成name、把id属性渲染成id、把userForm.name的值填入value属性。也就是说你不用再手动写th:value="${userForm.name}",th:field已经覆盖了。这个机制在新增和编辑共用一个表单时特别省事,编辑时自动带出旧值,新增时字段为空。
4.4 内联表达式:在JS里拿到后端数据
有时候页面里的 JavaScript 需要读取后端数据,最常见的是初始化某个编辑组件时传入一个对象。Thymeleaf 提供内联表达式,可以在文本节点里使用[[...]]:
<script th:inline="javascript"> var currentUser = [[${currentUser}]]; console.log(currentUser.name); </script>要注意th:inline="javascript"不是必须写在这段<script>标签上吗?是的,只有在脚本标签上声明th:inline="javascript",[[${...}]]才会被识别。另外,[[${currentUser}]]如果传给一个对象,Thymeleaf 会把对象序列化成 JavaScript 对象字面量,相当于直接生成一份 JSON 风格的代码。
这里有坑:如果对象属性值里包含特殊字符,比如内容里有单引号或换行,直接输出到<script>里可能破坏 JS 语法。稳妥做法是通过@JsonSerialize之类的序列化工具把对象先转成 JSON 字符串,再用th:attr存到标签的><div th:fragment="header"> <nav>这里是公共导航栏</nav> </div> <div th:fragment="footer"> <footer>版权所有</footer> </div>
然后在目标页面里引用:
<div th:insert="~{fragments :: header}"></div>th:insert和th:replace的区别是:insert把片段内容插入到当前标签内部,replace则把当前标签整个替换为片段内容。用insert时,上面的div会保留外层,片段内容被塞进这个 div;用replace时,这个 div 会被丢弃,只留下片段内容。在实际项目里我习惯用replace,因为这样页面最终的 HTML 结构里没有多余包装层,语义更干净。
5.2 参数化片段:把导航高亮传进公共组件
公共导航栏本身是固定的,但每个页面需要标记“当前菜单”处于高亮状态。如果每个页面都复制一份导航栏再改高亮,复用就没有意义了。Thymeleaf 的片段可以接收参数,这是布局复用里最实用的特性。
公共部分这样定义:
<div th:fragment="menu(activeMenu)"> <a th:classappend="${activeMenu == 'home'} ? 'active' : ''" href="/">首页</a> <a th:classappend="${activeMenu == 'user'} ? 'active' : ''" href="/user/list">用户管理</a> <a th:classappend="${activeMenu == 'order'} ? 'active' : ''" href="/order/list">订单管理</a> </div>使用方这样传参:
<div th:replace="~{fragments :: menu('user')}"></div>最终渲染时,用户管理这个菜单项会带上active类,其他菜单项不会。把动态变化的信息通过参数传进片段,片段只需要维护一份代码,多个页面共用且互不干扰。
5.3 实际项目里头尾部件的组织建议
关于文件组织,我在实际项目里建议单独建一个fragments目录,把公共头部、底部、侧边栏、分页组件都拆开。不要把所有片段堆在一个文件里,文件会迅速膨胀,维护成本上升。也不建议为了追求“页面干净”过度抽象,只把真正出现两次以上的部分抽成片段,否则一个页面由十几个片段拼装,调试时会头疼。
如果项目里需要一种母版页式的布局(页面继承基础布局),可以引入官方提供的thymeleaf-layout-dialect依赖,支持<head th:replace="layout :: head">和layout:fragment。这个依赖不是 Thymeleaf 官方核心的一部分,但成熟度很高,适合整体风格统一的后台项目。我的建议是:团队小、页面少用单纯 fragment 就够,页面多且严格统一再上 layout dialect,不要一开始就上复杂方案。
6. 前后端联调时最高发的三个问题:404、乱码、整页刷新
6.1 模板404的完整排查链路
页面访问 404,先别怀疑代码逻辑,按下面顺序排查下去,大多数问题五分钟内能定位:
第一,看 Controller 是不是用了@RestController。如果是,返回值会被当成响应内容,页面根本不会走视图解析,浏览器收到的是字符串而不是 HTML。
第二,看返回的视图名和文件名是否完全匹配,包括大小写。Linux 服务器上文件名是严格区分大小写的,本地 Windows 开发时大小写不敏感,本地好的页面一上服务器就 404,这种情况很常见。
第三,看文件是否真的放在templates目录下。有人误把 HTML 放到static目录里,Spring Boot 会把它当静态资源处理,此时访问路径不再是视图名,而是静态资源路径/index.html,Controller 返回"index"时找不到模板,自然 404。
第四,检查项目打包结构。在 IDEA 里运行没问题,打包成 jar 后 404,通常需要确认 Maven 的resources配置是否把templates目录漏掉了,检查 jar 包内是否包含templates/index.html即可。
6.2 中文乱码的真正作用点
Thymeleaf 页面中文乱码,这个问题看起来简单,但作用点其实有三个地方。
第一是模板文件本身的保存编码。IDE 默认可能是 GBK 或 UTF-8,你的spring.thymeleaf.encoding=UTF-8配置得再对,文件是 GBK 保存的,渲染出来一定是乱码。统一把 IDE 文件编码设置为 UTF-8。
第二是响应编码。Spring Boot 里spring.thymeleaf.encoding=UTF-8控制了模板引擎读取模板文件和处理响应时的字符集。写配置是最好的方式,比在 HTML 里手写<meta charset="UTF-8">更可靠,因为 meta 只影响浏览器解析,后端输出的字节流编码由服务器决定。
第三是浏览器解析。HTML 中最好也声明<meta charset="UTF-8">,保证响应头没带编码信息时浏览器也能正确解码。三个点都统一为 UTF-8,乱码基本绝迹。
6.3 模板项目的局部更新姿势
用 Thymeleaf 渲染页面后,很多人感觉“体验不够现代”,点一个按钮就整页刷新,页面状态全丢了。必须承认,模板引擎就是整页渲染的设计思路,如果你要求局部无刷新更新,正确姿势是补一点前端逻辑,而不是抛弃模板引擎。
我的做法是:页面结构仍然由 Thymeleaf 渲染,但局部交互交给 JavaScript 的fetch或成熟的 AJAX 库。比如用户列表的筛选按钮,点击后用 JS 请求后端接口拿到 JSON,再手工更新表格区域。Thymeleaf 负责首屏渲染和整体布局,JS 负责局部动态化,两者配合完全够用。不要试图用模板引擎模拟 SPA,也不要因为想要局部刷新就把整个项目改成前后端分离,成本反而更高。
7. 性能与上线:模板缓存、打包体积和适用边界
7.1 开发关闭缓存,生产打开缓存
前面提过开发环境要设置spring.thymeleaf.cache=false,这里再强调一下生产环境务必开缓存。Thymeleaf 解析模板文件是个相对耗时的过程,模板解析树会被缓存,后续请求直接复用。如果你生产环境忘了开缓存,代码效率会明显下降;而且默认情况下 Spring Boot 对 Thymeleaf 的缓存开关默认就是 true,只有本地调试时才会主动设成 false。
还有一个容易被忽略的配置项是spring.thymeleaf.render-hidden-markers-before-checkboxes,它影响隐藏标记的渲染方式,大多数项目不需要动,但如果你用了 Spring Security 的表单权限控制,这些模板细节会影响隐藏域输出行为,属于调试安全功能时才关注的配置。
7.2 静态资源本地化与打包检查
页面里如果引用公共 JS/CSS 库,我强烈建议下载到本地/static下,不要直接引用外网 CDN 地址。原因很现实:国内网络条件下外网公共库访问速度不稳定,影响页面加载;内网部署时如果目标服务器无法访问外网,页面样式会直接崩溃。
打包检查的重点是确认 templates 和 static 都进入了最终产物。用 Spring Boot 默认结构不会出问题,但如果你自定义过maven-resources-plugin的过滤规则,要注意排除规则是否覆盖了.html、.css、.js等文件。上线前可以把 jar 解压看目录结构,这个动作十秒钟时间,能省掉很多生产环境 404 的排查工作。
7.3 用了一年以后的真实体会
我现在做服务端渲染项目时,Thymeleaf 依然是我的默认选择,但我也清楚它不是万能药。纯接口服务、高并发大规模前端应用,不应该用服务端模板硬撑。模板引擎最适合的是那些“页面是给人看的、后端有数据要填充、要求快速交付”的项目,比如管理系统、运营后台、内部工具。这些系统页面数量多、结构重复、交互不算复杂,用 Thymeleaf 开发的速度和可维护性都很好。
如果真要在 Thymeleaf 里实现复杂前端交互,我的经验是不要硬撑,页面局部用 Vue 之类的轻量框架和 Thymeleaf 共存是可以的,只要接口数据结构设计好,渲染职责划分清楚,维护起来并不混乱。反而是一边追求前后端完全分离、一边仍想让 Java 维护页面结构,两头不讨好,这种情况我见得最多。
最后再分享一个小技巧:在绝大多数“模板渲染+局部交互”的项目里,我会刻意控制模板里的逻辑复杂度。Thymeleaf 的表达式能力不弱,但页面一旦出现大量的条件嵌套和循环嵌套,调试成本就会暴涨。把复杂逻辑放在 Controller 或 Service 层,先把数据结构整理好,模板里只做简单的取值和循环,模板文件会好读很多。Spring Boot 整合 Thymeleaf 这条路,整体上手成本低、配套资料多、踩坑规律也相对固定,值得花半天时间认真走通一遍,后续做服务端页面会顺畅很多。