☰
SpringBoot整合Thymeleaf实战:从选型到模板语法全解析
2026/10/1 18:15:52 网站建设 项目流程

先把一个最关键的事实说清楚:SpringBoot整合Thymeleaf属于服务端渲染(SSR)的经典组合,它解决的是“后端有数据、要快速出页面”的问题。如果你在做后台管理系统、内容展示型门户、带SEO需求的官网,或者不想把前端拆成Vue/React工程,SpringBoot + Thymeleaf几乎是最省事的选择。Thymeleaf在SpringBoot官方推荐里地位很稳,它有starter,配置少,模板还能直接用浏览器打开看效果,不需要额外启动容器。这篇文章我会从选型逻辑讲起,带你把依赖引入、配置项、Controller对接、模板语法、热更新、常见报错全部过一遍,整个过程结合我实际踩过的坑,尽量让你看完能直接照着做一个能跑的页面出来。


1. 为什么选Thymeleaf而不选JSP或FreeMarker

1.1 JSP的衰落和Thymeleaf的崛起

SpringBoot官方文档里明确建议不要再优先考虑JSP。原因是JSP最终会被编译成Servlet类,依赖Servlet容器,打包成可执行Jar后JSP文件放在src/main/webapp里,用SpringBoot内嵌Tomcat跑起来经常遇到模板找不到、类加载器错乱这类问题。而且JSP在前后端分离的大趋势下生态已经明显退化,写起来也没有任何模板引擎层面的“现代感”。

Thymeleaf能上位,核心是它和HTML标准兼容。模板文件本身就是合法的HTML页面,你用浏览器直接打开.html,看到的是静态效果,后端数据一旦渲染进来,页面自动替换成动态内容。这种特性对前端切页面的配合、对后端的自测、对调试来说,都省了一大截事。FreeMarker的语法符号和真实HTML混在一起,不启动应用根本看不出渲染结果,这一点没法跟Thymeleaf比。这里有很实际的价值:前端给你一套静态页面,你直接把html文件丢进模板目录,把CSS、JS路径改对,把数据标签替换进去,页面样式基本不会乱。

1.2 服务端渲染在什么场景下依然不可替代

现在很多人一听到模板引擎就问“你怎么不用Vue”,实际上要看场景。内部管理系统、运营后台、报表系统这类项目,用户量不大,页面交互简单,用SPA反而要处理跨域、Token刷新、路由守卫、构建部署一整套工程化问题。SpringBoot + Thymeleaf天然就是同源部署,Controller返回一个视图名,浏览器直接拿HTML,任何接口权限都能通过原生Session、拦截器或Spring Security控制,几乎没有额外学习成本。

另外一个不可替代的场景是SEO。爬虫对单页应用的渲染支持虽然一直在进步,但服务端直接输出完整HTML始终是搜索引擎最愿意看到的形态。Thymeleaf能把首屏内容和整页数据一次性输出到HTML里,方便搜索引擎抓取,这是前后端分离方案天然吃亏的地方。所以,你评估技术选型时,先想清楚你的项目是给谁用的:给运营和内部人员用的后台,SSR的开发和维护成本远低于一套独立前端工程;给公网用户且看重搜索引擎来源的,Thymeleaf也比SPA友好得多。


2. 环境准备与依赖引入

2.1 版本选型和“版本太高”问题

先做环境规划。JDK建议8起步,当前大多数企业的生产环境还在JDK8和JDK11之间,SpringBoot2.7.x是兼容性最舒服的版本序列。如果你的公司强制要求SpringBoot3.x,那JDK至少要到17,且Thymeleaf的spring-boot-starter-thymeleaf会自动适配对应版本,不需要手动指定Thymeleaf版本号,这点放心交给SpringBoot的依赖管理机制处理。

我要专门说一下热词里出现的“SpringBoot版本太高”这类问题。很多人从SpringBoot2.x升到3.x之后发现页面渲染500,多半不是Thymeleaf本身的问题,而是Javax到Jakarta命名空间迁移带来的连锁反应。SpringBoot3.x里javax.servlet全部变成了jakarta.servlet,如果你项目里还手动引了旧的javax.servlet-api依赖,或者有一些老的自定义拦截器、过滤器用了旧的Servlet API,直接报Bean创建异常或者类型不匹配。我的建议是:新项目用SpringBoot3.x + JDK17,老项目升版本的时候先检查一遍项目里有没有直接引用javax.*,有就先清理干净,再去处理代码兼容。

2.2 Maven和Gradle依赖坐标

Maven项目里只需要一个starter即可搞定,坐标如下:

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

Gradle版本:

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

除了这个starter之外,正式写页面通常还需要引入spring-boot-starter-web,这个不用多解释,Controller依赖它。如果你想做表单校验,再补一个spring-boot-starter-validation,Thymeleaf的th:errors可以很优雅地展示校验信息,这个后文会提。

还有一个小细节:如果你的项目里同时有spring-boot-starter-web和spring-boot-starter-thymeleaf,并且用了HTML5模式,不需要手动引入thymeleaf-layout-dialect之外的布局依赖。SpringBoot已经把默认模板位置classpath:/templates/、默认后缀.html、默认编码UTF-8这些配置项全部预置好了,零配置就能跑起来第一个页面。


3. 配置文件详解:路径、缓存和视图解析

3.1 application.yml里的核心配置

SpringBoot对Thymeleaf的自动化配置足够多,但有几个配置项我建议每次新建项目都随手写上,避免后面调试时被缓存和编码折磨。

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

prefix指定模板所在目录,SpringBoot默认就是classpath:/templates/,可以省略不写。suffix默认.html,也可以省略。真正关键的是cache: false和mode: HTML。cache: false告诉模板引擎不要缓存渲染结果,这样开发阶段改完HTML刷新页面就能看到效果,不需要重启应用。生产环境记得改回true,否则每次请求都重新解析模板,性能和磁盘IO都会受影响。mode: HTML是告诉解析器按HTML5解析模板文件,默认值就是HTML,但在某些旧版本里默认值是HTML5,写法已经废弃,你手动配成HTML最保险。

3.2 模板目录和静态资源目录的不同职责

很多人最容易困惑的一件事是:CSS、JS、图片到底放哪里,HTML模板又放哪里。SpringBoot的约定是:

  • src/main/resources/templates/:存放Thymeleaf模板文件,即动态页面主体。
  • src/main/resources/static/:存放CSS、JS、图片、字体等静态资源。

模板文件放在templates下,Controller返回视图名时会自动拼上前缀和后缀,比如返回index就去找templates/index.html。静态资源放在static下,浏览器直接通过根路径访问,比如static/css/style.css,页面上写成/css/style.css就能引到。Thymeleaf模板在引用静态资源时,建议用th:href="@{/css/style.css}"而不是href="/css/style.css"。

@{...}这个语法是Thymeleaf上下文路径相关的URL表达式。如果你的应用后来部署在某个二级路径下,比如http://ip:8080/myapp/,用原生/css/style.css会直接404,但用@{/css/style.css},Thymeleaf会自动把项目根路径拼上去。很多人测试环境一切正常,部署到Nginx二级目录下面全部样式丢失,十有八九就是这里写死了绝对路径。

3.3 多个视图解析器时的行为差异

实际项目里偶尔会遇到SpringBoot同时接Thymeleaf和FreeMarker,或者同时有JSP和Thymeleaf的场景。SpringBoot默认按依赖顺序和Order属性自动装配ViewResolver。Thymeleaf的ThymeleafViewResolver默认order是Integer.MAX_VALUE中的减一,优先级比较低。如果你项目里同时存在JSP和Thymeleaf两个视图解析器,返回的视图名会先被InternalResourceViewResolver拦截,导致Thymeleaf模板永远不被命中。

我遇到过最典型的情况是公司老项目从JSP向Thymeleaf迁移,两边依赖都留着,Controller返回index时直接去解析成了/WEB-INF/jsp/index.jsp,然后报404。排查了半天才发现是视图解析器顺序的问题。解决办法是手动设置ThymeleafViewResolver.setOrder(1),把优先级提到最前面。你要是准备从JSP平滑迁移到Thymeleaf,这一条务必记住。


4. 第一个能跑的页面:Controller与模板对接

4.1 写一个最简单的Controller

我先从最基础的案例讲起,这个过程我建议你亲手敲一遍,感受Thymeleaf的数据传递模式。

@Controller public class IndexController { @GetMapping("/index") public String index(Model model) { model.addAttribute("title", "SpringBoot整合Thymeleaf第一课"); model.addAttribute("tips", "把数据放进Model,模板里用th:text输出"); return "index"; } }

对应页面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="${tips}">没有数据时会展示这段静态内容</p> </body> </html>

这里有一个精髓:xmlns:th="http://www.thymeleaf.org"这行命名空间声明,作用不是帮后端渲染,而是让IDEA编辑器对th:开头的标签不做红色报错,同时让静态页面在浏览器里也能正常显示。标签里的纯文本部分是“静态兜底内容”,后端数据渲染时会被替换掉。你前端切页面的时候可以把这部分当成占位,后端接数据时再把th:属性补上,配合效率很高。

Controller里的Model是SpringMVC传给视图的数据容器,一个addAttribute就是一个键值对,模板里${title}就直接取出这个值。这一步是整个整合的核心链路:Controller放值,模板取值。

4.2 Request域和ModelAndView的另一种写法

除了Model,还可以直接通过ModelAndView完成数据和视图的封装,这一个在旧代码里非常常见。

@GetMapping("/modelAndView") public ModelAndView modelAndView() { ModelAndView mv = new ModelAndView("detail"); mv.addObject("user", new User("张三", 20)); return mv; }

ModelAndView的构造函数第一个参数就是视图名,addObject等价于model.addAttribute。这种写法好处在方法内部自包含,数据和视图打包在一起,函数式代码里看起来更直观。不过它对返回值类型有约束,后端接口想返回JSON时就得另写方法,所以现在的项目更倾向于直接返回String+Model的组合。两种方式都理解,看到老代码不慌就够了。

4.3 模板里遍历集合和分支判断

实际开发里大部分页面都要循环输出列表。假设你从数据库查出用户列表放进Model,模板里的写法如下:

<table> <thead> <tr> <th>姓名</th> <th>年龄</th> <th>状态</th> </tr> </thead> <tbody> <tr th:each="user : ${userList}"> <td th:text="${user.name}"></td> <td th:text="${user.age}"></td> <td> <span th:if="${user.status == 1}" th:text="启用"></span> <span th:unless="${user.status == 1}" th:text="禁用"></span> </td> </tr> </tbody> </table>

th:each是循环指令,语法含义是遍历userList集合,每次迭代的项名称为user。th:if和th:unless是条件判断的一对组合,th:if条件成立就输出该标签,th:unless相反。这里值得说的是,判断条件里推荐用==比较数字,用equals比较字符串。比如${user.name == 'admin'},Thymeleaf表达式引擎底层会调用Object.equals做字符串比较,所以直接写==是安全的,不会像Java一样比较引用地址,这一点比JSP EL表达式要友好。


5. 核心语法盘点与应用场景

5.1 表达式类型:变量表达式、选择表达式和URL表达式

Thymeleaf表达式主要分成几类,初学时可以把它们记成三种符号:${...}、*{...}、@{...}。

${...}叫变量表达式,从Model或请求域中读取数据,这是用得最多的一个。*{...}叫选择表达式,必须配合th:object使用。比如:

<div th:object="${user}"> <p th:text="*{name}"></p> <p th:text="*{age}"></p> </div>

th:object把user对象选出来,内部子标签就能用*{name}替代${user.name},写法更简洁。当你一个页面要展示某个对象的多个字段时,选择表达式能省下重复写对象名的功夫。@{...}就是前文说的URL表达式,专门用来生成链接、拼接上下文路径,用在th:href、th:src等属性上。

大家把这三类记忆清楚之后,看模板里的标签就不会懵了,凡是属性名带th:开头,右边写的是表达式语法,不再是我们Java代码里的字符串拼接。

5.2 文本输出、属性替换和HTML片段引入

文本输出最基础的方式是th:text,它会HTML转义,把<script>之类的字符转成安全字符串,防止XSS注入。如果你的后台数据是富文本编辑器生成的,要输出带HTML标签的内容,就得用th:utext,它不转义,直接输出原始HTML。这里是一个安全分水岭:用户输入内容永远优先用th:text,只有你确信内容是可信来源时再用th:utext,否则等于给攻击者开了一扇门。

属性替换同样频繁。最常见的按钮禁用、表单回显、图片地址替换:

<input type="text" th:value="${user.name}" /> <button th:disabled="${user.status == 0}">提交</button> <img th:src="@{/images/avatar.png}" alt="头像" />

th:value用于给表单控件的value属性赋值,th:disabled会根据表达式结果动态决定是否给按钮添加disabled属性,th:src则常配合URL表达式使用。

页面复用方面,Thymeleaf原生的方式有th:insert和th:replace。两者区别是th:insert是在标签内部插入引入的片段,th:replace是直接用引入的片段替换整个标签。实际项目里通常配th:fragment定义一个公共片段:

<!-- commons.html --> <div th:fragment="header"> <h2>公共页头</h2> </div> <!-- 使用页面 --> <div th:replace="commons :: header"></div>

这里的commons :: header意思是:从commons.html模板里找名字为header的片段,然后(th:replace)替换掉当前div。公共导航栏、页脚、版权信息都可以抽到这种公共模板里,后续维护只需要改一个文件,能少走很多弯路。


6. 热更新配置:改完页面立刻生效

6.1 纯Thymeleaf层面怎么开启热更新

先讲最核心的配置,前文已经提过spring.thymeleaf.cache: false。只要有这一条,模板文件每次请求都会被重新解析,改HTML的内容保存后直接刷新浏览器就能看到新效果,完全不需要重启。但要注意,这个只对模板文件生效,Controller代码、Java类的修改还是需要重新编译并重启应用。

还有一个容易被忽略的点:IDEA里写templates目录下的HTML,修改后如果不触发编译,保存文件并不能让SpringBoot感知文件变更。有些场景下你需要手动Build -> Build Project(Ctrl+F9),把文件重新复制到target/classes/templates下,刷新页面才看到变化。如果你嫌手动麻烦,后面讲DevTools可以彻底解决。

6.2 SpringBoot DevTools的完整配置方案

SpringBoot提供的开发者工具(DevTools)可以在代码文件变更后自动重启应用,模板文件变更后还能配合LiveReload让浏览器自动刷新页面。依赖坐标如下:

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

引入DevTools后,Java代码有改动时SpringBoot会自动重启(不是重新编译,它只是用两个类加载器快速重启),模板文件改动时因为cache: false直接生效。这里要特别说明:DevTools的自动重启不等于我们手动重启,启动速度通常非常快,因为大部分类都被缓存了。生产环境不要带DevTools,它的optional标签和runtime范围就是确保常规打包时不会进入生产Jar包。

如果你用IDEA,还需要开启自动编译。设置路径是Settings -> Build, Execution, Deployment -> Compiler,勾选Build project automatically。老版本还要在Settings -> Advanced Settings里勾选Allow auto-make to start even if developed application is currently running。不勾这个,DevTools感知不到文件变化。

6.3 浏览器自动刷新插件和实战体验

DevTools会启动一个LiveReload服务器,浏览器安装LiveReload扩展后,页面文件变化就能自动刷新。Chrome商店直接搜LiveReload即可。实际体验是:改了CSS、HTML,保存后1秒左右页面自动刷新,这个效率提升对调整页面样式来说非常明显。

但我必须说一个我自己的习惯:开发阶段还是倾向于手动刷新浏览器。原因很现实——LiveReload在同时开了多个页面的时候全部页面一起刷新,有时候后端返回一个异常页,自动刷新反而干扰定位问题。你可以先用cache: false + DevTools的组合,确定模板和静态资源改动都生效了,再到需要频繁改样式的阶段把LiveReload打开。


7. 常见问题排查与避坑指南

7.1 页面404和500的定位方法

碰到页面404,先看Controller是否标注了@Controller而不是@RestController。@RestController是@Controller加@ResponseBody的组合,方法返回值会被当JSON写进响应体,而不会去找视图渲染。Thymeleaf开发里最常见的第一坑就是习惯性把@Controller写成@RestController,浏览器里显示SpringBoot默认JSON结构,页面路径却始终404。

排除这个因素后再检查返回的视图名和模板文件路径是否一致。比如Controller返回index,模板文件必须是templates/index.html,少一个层级都不行。还有一种情况是模板文件放到了static目录下,这种直接404,因为Thymeleaf只认templates目录。

页面500则通常分两类:一类是模板语法错误,比如th:each写成了each,或者表达式里的变量名和Model里的key不一致,页面报SpelEvaluationException之类的错误,这类能看到异常堆栈,定位相对容易;另一类是模板内部数据对象为null,比如${user.name}但user是null,直接NPE。后者建议在Controller里提前判空,页面里也可以用${user != null ? user.name : '未知'}这种三元表达式兜底。

7.2 静态资源404和CSS样式丢失

页面能打开但样式全丢了,优先检查HTML里引用CSS的方式。实际审查时,直接在浏览器开发者工具里看CSS请求的URL,如果显示带项目context-path的路径,而你的CSS引用还是绝对路径,那基本可以断定是路径问题。把href="/css/style.css"改成th:href="@{/css/style.css}"即可。

另一个常见情况是静态资源文件确实存在,但Maven打包后没被复制到target/classes/static目录。检查pom.xml里是否正确配置了资源目录。默认SpringBoot应该会自动处理,但某些公司内网私服或者自定义parent pom会覆盖资源定义。我建议你在项目根目录执行一次mvn clean package,然后查看target/classes/static目录下文件是否存在,这一步能快速排除打包问题。

7.3 中文乱码的前后端正解

页面和接口都出现中文乱码时,大概率是编码不一致。前端的HTML里要有<meta charset="UTF-8">,后端配置文件里spring.thymeleaf.encoding: UTF-8,同时确保Java源文件本身是UTF-8编码。如果你用IDEA,右下角查看文件编码格式是否为UTF-8。三个位置的编码保持一致之后,乱码基本消失。如果你服务器是Linux且部署后乱码,还要检查启动参数里有没有-Dfile.encoding=UTF-8,某些发行版默认是POSIX或者ANSI_X3.4-1968,需要在启动脚本里强制执行UTF-8。

7.4 Thymeleaf与SpringBoot3.x的兼容问题

SpringBoot3.x中使用Thymeleaf需要注意,Spring Security相关的表达式还是写sec:authorize,但依赖不再是thymeleaf-spring5,而是thymeleaf-spring6包名。如果你从网上复制的老配置,引了thymeleaf-extras-springsecurity5,SpringBoot3.x下直接启动失败。正确做法是引入:

<dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity6</artifactId> </dependency>

如果你的项目没有用Spring Security做权限控制,那连这个依赖都不用引,sec:相关语法也用不上。

7.5 视图解析器顺序和多余依赖

前文提到新旧项目迁混合场景下视图解析器优先级问题,这里给出一个快速检查思路:如果项目同时引入了spring-boot-starter-thymeleaf和spring-boot-starter-web,而templates目录下模板就是找不到,试着在启动日志里搜索ViewResolver相关的输出,看看注册了哪些视图解析器。通常Thymeleaf会注册一个名字为thymeleafViewResolver的Bean,如果日志里没有,说明starter未被加载。再检查pom.xml里是否存在spring-boot-starter-thymeleaf被exclusion排除的情况。只要确认依赖存在、目录正确、Controller为@Controller,90%的模板404问题都能被这几个步骤命中。


最后再分享一个我自己折腾出来的经验。Thymeleaf的模板代码要养成“静态兜底”习惯——th:text标签之间一定要写默认文案,不要留空。A标签的th:href配上默认href="#",图片的th:src配一个默认占位图。这样做最直接的好处是前端拿到HTML就能预览整体效果,不用起后端服务;后端调试时如果数据缺失,页面也不会丑陋到没法看。这样一个组合,在SpringBoot整合Thymeleaf的开发流程中反而不是可有可无的点,它直接决定了页面开发阶段前后端协作是否顺畅。你在自己项目里按这个标准去写模板,坚持一个月,会发现页面和数据的调试效率比大多数人要高出一截。

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

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

立即咨询