1. 集成前的思路拆解:Spring Boot项目为什么需要Thymeleaf
做后端开发的朋友应该都有过这种经历:项目做了一半,前端页面还没交付,后端接口倒是写好了,但想联调下效果,要么临时写个HTML硬拼字符串,要么开个前端代理转发请求,总觉得差了点意思。如果你用的是Spring Boot这类单体架构,其实有个更丝滑的选择——给后端配一个模板引擎,让服务端直接把页面渲染好了返回给浏览器。这篇要聊的Thymeleaf,就是Spring Boot生态里最主流的模板引擎之一。
核心关键词放在前面:Thymeleaf是一个Java模板引擎,它的最大特点是自然模板——HTML文件可以直接在浏览器里打开预览,不需要启动服务,因为它的标签都是合法的HTML属性。这意味着前端设计师拿到的页面文件,和后端工程师在服务端渲染用的文件,是同一份,不存在“前端一套模板、后端另一套模板”的割裂问题。
先说说几个模板引擎的选型逻辑,避免你走弯路。用过Java服务端渲染的老兵基本都接触过JSP,但那东西在Spring Boot里已经边缘化了,因为它对嵌入式容器的支持不友好。Freemarker也经常被拿来对比,它的模板语法更简洁,适合生成文本,但模板文件脱离浏览器预览,调试成本高。Vue、React这类前后端分离方案当然也是选项,但引入Node构建链路、跨域处理、权限校验逻辑前置,对只想快速交付一个管理后台或者内部系统的团队来说,成本和复杂度是明显往上走的。
所以Thymeleaf的优势就很具象了:它和Spring Boot的整合几乎零配置,依赖一加就能跑;标签语法语义化,th:text、th:each这些指令看着就知道在干嘛;它还承担了Spring生态里渲染邮件的任务,比如你用Spring Boot发HTML格式的邮件模板,Thymeleaf是默认推荐方案。一句话总结:如果你在做的是传统MVC模式的Web应用,或者需要在服务端动态生成页面内容,Thymeleaf是目前Spring Boot下实用性、上手成本平衡得最好的选择。
这一篇的实操目标很明确:通过一个完整的Spring Boot项目,从引入依赖开始,到配置、页面渲染、数据展示、表单提交、参数回显,把Thymeleaf在真实业务里高频用到的那部分能力全部过一遍。全文基于Spring Boot 2.7.x + JDK 8环境,版本差异会在关键位置单独标注。适合的人群是:已经会用Spring Boot写接口,但没碰过服务端渲染,或者写模板时总卡在语法细节上的同学。
2. 集成Thymeleaf的完整安装与基础配置
2.1 依赖引入:一个坐标搞定的事
在Spring Boot项目里引入Thymeleaf,Maven坐标就一个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>Gradle对应是:
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'这里有个很多人忽略的细节:如果你用的是Spring Boot 2.x,Thymeleaf版本是3.0.x;到了Spring Boot 3.x,Thymeleaf也升到了3.1.x。版本升级带来最明显的变化是Spring Security集成时的默认配置,以及内联JavaScript的处理方式,不过日常开发核心语法差异不大。我建议你直接跟着Spring Boot的版本走,不要自己手动指定Thymeleaf版本,父工程里已经管理好了版本号,手动指定反而容易踩到兼容性坑。
依赖加好之后,实际上不需要配置任何一行Java代码,Spring Boot的自动配置就会生效。它会把内置的ThymeleafProperties加载进上下文,这个类里维护了模板文件的默认路径和默认后缀。
2.2 目录结构与默认配置解读
刚接触Thymeleaf的人最先懵的往往就是这一块:我写的HTML文件应该放哪?Spring Boot的自动配置已经约定好了:
src/main/resources/ ├── static/ # 静态资源目录:JS、CSS、图片等 ├── templates/ # Thymeleaf模板目录:HTML文件放这里 ├── application.yml # 配置文件templates目录就是模板的家,默认情况下,控制器返回的字符串就是模板文件名,Spring Boot会自动拼接classpath:/templates/前缀和.html后缀。
举个例子,你的控制器写了return "index";,Thymeleaf就会去找templates/index.html。这个前缀和后缀都可以改,配置文件里对应的配置项是:
spring: thymeleaf: prefix: classpath:/templates/ suffix: .html除非有特殊需求,否则我不建议你改这两个配置,保持默认约定能让团队所有成员一眼看懂项目结构。另外几个配置项在实际项目中用得上,整理成表格供参考:
| 配置项 | 默认值 | 作用说明 |
|---|---|---|
| spring.thymeleaf.cache | true | 是否开启模板缓存。开发期必须设置为false,改完页面不用重启;生产环境保持true提升性能 |
| spring.thymeleaf.encoding | UTF-8 | 模板文件编码,涉及中文显示,千万别动 |
| spring.thymeleaf.mode | HTML | 模板模式,默认HTML即可 |
| spring.thymeleaf.check-template-location | true | 是否检查模板位置是否存在,默认true即可 |
| spring.thymeleaf.servlet.content-type | text/html | 响应Content-Type,一般不需要动 |
注意:在Spring Boot 2.x版本中,开发期模板缓存要手动关掉。直接在启动类里写
spring.thymeleaf.cache=false,或者用配置文件覆盖。改完模板文件Ctrl+F9重新编译,浏览器刷新就能看到变化,不用重启应用,这对调试效率的提升是质变级的。
2.3 第一个可以跑通的页面:创建并访问模板
下面把最基础的链路走一遍,确保项目能渲染出一个简单页面。
第一步:在templates/index.html下创建文件:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Thymeleaf集成测试</title> </head> <body> <h1>服务端渲染成功</h1> <p th:text="${message}">这里是默认文本</p> </body> </html>html标签上那一行xmlns:th="http://www.thymeleaf.org"是命名空间声明,很多教程会写它。它的真正作用是让IDE(比如IDEA)在编辑模板时能正确识别th:开头的属性并给代码补全提示。运行时没有它照样执行。对于代码洁癖比较重的同学,删掉也行,但留着能大幅提升编辑器体验,建议保留。
第二步:写一个Controller:
@Controller public class IndexController { @GetMapping("/") public String index(Model model) { model.addAttribute("message", "你好,Thymeleaf"); return "index"; } }这里有一个新手极其容易犯的错误:用了@RestController而不是@Controller。如果你把@RestController用在返回模板的控制器上,Spring MVC会直接把返回值当HTTP响应体写出去,浏览器显示的不是HTML页面,而是一行字符串 "index"。模板引擎的视图解析需要ViewResolver参与,只有@Controller才会触发视图解析流程,@RestController会跳过视图解析直接走消息转换器。
第三步:启动项目,浏览器访问http://localhost:8080/,看到页面显示“服务端渲染成功”和“你好,Thymeleaf”。
注意一个细节:<p th:text="${message}">这里是默认文本</p>这段代码,如果直接用浏览器打开index.html文件(不启动后端),页面也会正常显示“这里是默认文本”。这就是Thymeleaf自然模板特性的直观体现,也是它对比Freemarker这类引擎的核心优势:未经过服务端渲染的模板文件本身就是合法可预览的静态页面。
3. 核心语法实战:从数据展示到流程控制
3.1 标准表达式:${}、*{}、@{}、~{} 四种到底怎么选
Thymeleaf的表达式是模板的核心语法,理解这四类表达式的适用场景,比背一百个属性标签更有用。
第一种是变量表达式**${}**。它从容器上下文(Controller传入的Model)中取值,类似EL表达式。日常开发里90%的取值都靠它:
<p th:text="${user.name}">默认姓名</p> <p th:text="${userAge}">默认年龄</p>第二种是选择表达式**\*{}**。它的作用机制是上下游结合——通过th:object指定一个对象容器,然后*{属性}直接取这个对象的属性,不用再写前缀:
<div th:object="${user}"> <p th:text="*{name}">姓名</p> <p th:text="*{age}">年龄</p> </div>等价于th:text="${user.name}",但代码更整洁,多属性展示场景下优势明显。两种方式的取舍标准是:取值的对象只有一个且属性多,用*{};分散在不同对象之间各取一个,用${}。
第三种是链接表达式**@{}**。它专门用来生成URL。当你需要保证项目部署在任意上下文路径(比如加了server.servlet.context-path)下链接依然有效时,这个表达式就是解决方案:
<link th:href="@{/css/common.css}" rel="stylesheet"> <a th:href="@{/user/detail(id=${userId})}">用户详情</a>@{/user/detail(id=${userId})}会被渲染成/user/detail?id=1这样的完整查询串,参数既可以用${}引用变量,也可以写固定值。如果项目设置了上下文路径/app,Thymeleaf会自动在链接前面拼上/app,你不需要在模板里写死。
第四种是片段表达式**~{}**。它用于引用页面片段,常见于公共页面的复用,比如页头、页脚、分页组件这些多页面重复模块。这个后面第八节详细展开。
3.2 常用th属性:文本、循环、条件判断全覆盖
数据展示场景下,以下属性必须熟练到条件反射级别。
th:text是最基础的文本替换属性。它会把标签体内部的默认文本整体替换为表达式计算结果。表单输入框里的th:value作用类似,但它替换的是value属性:
<input type="text" name="username" th:value="${username}" value="默认值">th:each是服务端渲染最高频使用的属性,没有之一。它做的是循环遍历,既可以遍历集合,也可以遍历Map:
<table> <tr th:each="item : ${userList}"> <td th:text="${item.id}">ID</td> <td th:text="${item.name}">姓名</td> </tr> </table>循环变量后面还可以带一个状态变量,通过_iterStat的命名习惯获取序号、奇偶性等信息:
<tr th:each="item, stat : ${userList}"> <td th:text="${stat.count}">序号</td> <td th:text="${stat.odd} ? '奇数行' : '偶数行'">奇偶</td> </tr>stat.count从1开始计数,stat.index从0开始,stat.odd是布尔值,直接判断当前循环到奇数位置还是偶数位置。这个状态变量在列表背景色交替渲染时极其好用,省掉写JS的麻烦。
th:if和th:unless是条件判断标配。th:if为真时渲染标签,为假时整个标签不输出。th:unless正好相反,为真时不渲染。两者同时配合可以实现if-else效果:
<div th:if="${user != null}">用户已登录</div> <div th:unless="${user != null}">用户未登录</div>这里有个小陷阱要提醒:Thymeleaf对字符串判断的真假规则,'false'字符串会被当作假值处理,这是很多教程不提的细节。比如th:if="${flag}",如果flag = "false"(字符串),结果是不渲染的;如果flag = "off"或flag = "no",结果反而是真的,因为它不是false或null或数字0。这种语义在从后端传字符串标志位时容易翻车,建议后端统一用布尔类型传给模板。
th:switch/th:case是多分支选择,语法直观:
<div th:switch="${status}"> <p th:case="1">待处理</p> <p th:case="2">处理中</p> <p th:case="*">其他状态</p> </div>*表示默认分支,类似Java switch的default。这个语法在状态机展示场景里很清晰,比多个th:if嵌套优雅得多。
3.3 文本内联:[[...]]的简化语法
有些场景下你不想写一大串th:text属性标签,比如在JavaScript脚本里拼接数据,或者HTML文本中间夹一个动态值。Thymeleaf提供了内联表达式语法[[${表达式}]]:
<p>欢迎您,[[${userName}]],祝您今日工作愉快</p>更常见的使用场景是JavaScript里赋值:
<script th:inline="javascript"> var user = [[${userName}]]; console.log(user); </script>th:inline="javascript"开启了JavaScript内联模式,[[${...}]]会把服务端数据安全地转成JS变量。注意,这个模式下Thymeleaf会对特殊字符做转义,比如单引号、换行都会被安全处理,防止后端数据直接拼进JS导致的语法错误或者脚本注入风险。
这里必须提一个坑:在Spring Boot 3.x + Thymeleaf 3.1版本中,JavaScript内联模式默认是禁用的,需要在配置里开启spring.thymeleaf.render-hidden-markers-before-checkboxes=true之类的不相关配置,实际上是thymeleaf从3.1版本开始,#javascript命名空间的内联方式发生了变化。如果发现内联不生效或者编译报错,看一眼Thymeleaf版本,别浪费时间。
4. 与Spring Boot深度整合:表单提交、数据回显与参数绑定
4.1 th:object + th:field:表单回显的黄金组合
Web后端业务里,表单页面是最常见的模板场景。Thymeleaf配合Spring的@ModelAttribute机制,能实现一个几乎零手工代码的回显方案。
先定义一个表单对应的POJO:
public class UserForm { private String username; private String password; private String email; // getter/setter 省略 }Controller里展示表单页面时,先放一个空对象进Model:
@GetMapping("/user/add") public String addUser(Model model) { model.addAttribute("userForm", new UserForm()); return "user-form"; }表单页面用th:object绑定这个空对象,输入框用th:field映射属性:
<form th:action="@{/user/save}" th:object="${userForm}" method="post"> <input type="text" th:field="*{username}" /> <input type="password" th:field="*{password}" /> <input type="text" th:field="*{email}" /> <button type="submit">保存</button> </form>这里th:field="*{username}"做了两件事:其一是给input设置name="username",提交表单时Spring MVC能正确绑定;其二是如果Model里的userForm.username已有值,它会自动回显到value属性。配合@ModelAttribute注解,表单回显就全自动了:
@PostMapping("/user/save") public String saveUser(@ModelAttribute("userForm") UserForm userForm, Model model) { // 模拟保存成功 userForm.setUsername("保存后的用户名"); model.addAttribute("userForm", userForm); return "user-form"; }提交后端之后,返回模板时页面上的username输入框会自动显示“保存后的用户名”。这就是服务端渲染表单回显的经典链路,Thymeleaf + Spring的表单绑定机制配合得相当舒服。
4.2 用th:errors实现字段级校验提示
实际项目里表单不可能不校验。使用Spring的JSR-303校验注解(@NotBlank、@Size等),在Controller里通过@Valid开启校验,校验失败时BindingResult里会包含错误信息。问题是:错误信息怎么渲染到模板上?
Thymeleaf 3.x提供了一套完整的错误展示语法。先给POJO加上校验注解:
public class UserForm { @NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") @Size(min = 6, message = "密码至少6位") private String password; }Controller里处理校验失败:
@PostMapping("/user/save") public String saveUser(@Valid @ModelAttribute("userForm") UserForm userForm, BindingResult result, Model model) { if (result.hasErrors()) { return "user-form"; } // 校验通过,执行保存 return "redirect:/user/list"; }模板里通过th:errors输出某个字段的校验错误信息:
<form th:action="@{/user/save}" th:object="${userForm}" method="post"> <input type="text" th:field="*{username}" /> <p th:if="${#fields.hasErrors('username')}" th:errors="*{username}">用户名错误提示</p> <input type="password" th:field="*{password}" /> <p th:if="${#fields.hasErrors('password')}" th:errors="*{password}">密码错误提示</p> <button type="submit">保存</button> </form>#fields.hasErrors('字段名')判断该字段是否存在错误,th:errors="*{username}"会把错误消息列表用<br/>拼接渲染出来。全程不需要手动给Model塞error信息,Spring MVC的验证框架已经把它放进了BindingResult,Thymeleaf通过#fields工具对象可以随时访问。
这个机制里有一个很重要的实践心得:表单校验失败的返回不能是redirect,必须是forward(即直接返回视图名),否则BindingResult中的数据在重定向后会丢失,错误提示也就没了。如果你发现校验失败后页面没有错误提示,先检查返回方式是不是重定向了。
4.3 页面数据格式化与日期处理
拿到的业务对象里,日期、数字想要按指定格式展示,Thymeleaf提供了格式化工具对象#temporals和#numbers:
<p th:text="${#temporals.format(createTime, 'yyyy-MM-dd HH:mm:ss')}">创建时间</p> <p th:text="${#numbers.formatDecimal(salary, 1, 2)}">薪资</p>#numbers.formatDecimal(salary, 1, 2)表示保留整数部分至少1位、小数2位,三位一逗号分隔是它默认行为。这种格式化其实也可以用 Java 8的DateTimeFormatter在Controller里提前处理好再放进Model,但既然Thymeleaf自带工具对象,模板里的格式化操作灵活性更高,改格式不用改后端代码。
5. 静态资源处理与团队协作:模板里怎么引JS、CSS和图片
5.1 路径生成背后的基础知识
Spring Boot的资源放法是约定俗成的,JS、CSS、图片放在static目录下,模板引用时不需要写static前缀,直接写相对路径:
src/main/resources/static/ ├── css/ │ └── common.css ├── js/ │ └── common.js └── img/ └── logo.png模板中正确的引用方式:
<link th:href="@{/css/common.css}" rel="stylesheet"> <script th:src="@{/js/common.js}"></script> <img th:src="@{/img/logo.png}" alt="Logo">新手最常见的错误是写成@{/static/css/common.css}。Spring Boot的静态资源映射里,/**已经被映射到了classpath:/static/,所以你写的URL路径是以/css/...开头的。你硬加上static前缀反而会让路径变成/static/css/...,然后去static目录下找static文件夹,找不到404。这是个特别容易踩的低级坑,排查路径问题时优先想到它。
5.2 使用@{...}而不是写死路径的理由
我见过不少项目里有人直接写:
<link href="/css/common.css" rel="stylesheet">这样做在改动server.servlet.context-path(上下文路径)时会全线崩掉。Thymeleaf的@{...}会自动感知上下文路径并拼接,写死/css/...则不会。当你部署到/app子路径时,正确路径应该是/app/css/common.css,写死的方式访问到的是/css/common.css,404一片红。
所以规则就是:模板里所有资源的URL都用@{}生成,不用原生路径。这些遗留在前端开发时的习惯,在后端模板里要彻底丢弃。
5.3 开发期静态资源调试效率提升
如果开发时有静态资源更新,但页面一直显示旧样式旧脚本,浏览器缓存可以背一大半的锅。加一行配置就行:
spring: web: resources: cache: period: 0把资源缓存周期设置为0,开发期生效。或者更简单粗暴,在URL后面加个版本参数/css/common.css?v=20240301,发布时手动改版本号,强制浏览器重新拉取。这种做法在生产环境里其实更常用,因为静态资源做的是长效缓存,发布后靠版本号强制刷新。
6. 常用工具对象与国际化(i18n)支持
6.1 常用内置工具对象一表速查
Thymeleaf内置了一批#开头的工具对象,这些对象和后端工具类的定位类似,高频能用到的不多,但我把最常用的一批整理成表格:
| 工具对象 | 作用 | 示例 |
|---|---|---|
#strings | 字符串判空、拼接、截取、大小写等操作 | ${#strings.isEmpty(name)} |
#lists | List集合判断、元素包含等 | ${#lists.isEmpty(list)} |
#arrays | 数组工具 | ${#arrays.length(arr)} |
#maps | Map工具 | ${#maps.containsKey(map, 'key')} |
#numbers | 数字格式化 | ${#numbers.formatDecimal(num, 1, 2)} |
#temporals | 日期格式化(基于java.time) | ${#temporals.format(date, 'yyyy-MM-dd')} |
#dates | 日期格式化(旧版java.util.Date) | ${#dates.format(date, 'yyyy-MM-dd')} |
#calendars | Calendar工具 | ${#calendars.format(cal, 'yyyy-MM-dd')} |
#strings.isEmpty()在做条件判断时特别常用。很多人一开始会习惯性用name == null or name == ''这种写法,但在Thymeleaf里这容易踩到空指针坑——如果name为null,name == ''这个表达式会返回null,整个条件结果就不是明确布尔值了。用#strings.isEmpty(name)一把搞定,它内部把null和空串统一处理了。
6.2 国际化配置实操
多语言场景下,Thymeleaf对Spring的国际话支持开箱即用。配置文件里指定消息文件路径:
spring: messages: basename: i18n/messages然后在resources/i18n/下放语言文件:
i18n/ ├── messages.properties # 默认英文 ├── messages_zh_CN.properties # 简体中文 └── messages_en_US.properties # 美式英文内容格式就是键值对:
# messages_zh_CN.properties user.login=登录 user.register=注册 # messages_en_US.properties user.login=Login user.register=Register模板里通过#{}语法引用:
<a th:text="#{user.login}">登录</a> <a th:text="#{user.register}">注册</a>浏览器语言会根据Accept-Language请求头自动选择对应的properties文件。如果你在Controller里测试时发现切换Locale不生效,检查请求头里Accept-Language的设置。也可以在后端手动指定语言:
LocaleResolver localeResolver = new SessionLocaleResolver(); ((SessionLocaleResolver) localeResolver).setDefaultLocale(Locale.CHINA);但这种做法灵活性不高,实际开发更多是用CookieLocaleResolver或者接受请求参数切换,属于国际化进阶话题,初学者先把基础链路跑通就足够了。
7. 常见问题与排查技巧实录
7.1 常用问题速查表
把我在实际开发中遇到的几十个Thymeleaf问题做个梳理,高频问题集中在以下几类,直接给结论:
| 症状 | 可能原因 | 排查手段 |
|---|---|---|
| 页面不回显数据,显示的是HTML默认文本 | Controller返回了字符串但模板里没加th:text,或者用了@RestController | 检查视图解析是否生效,检查模板标签是否有对应th属性 |
| 访问页面报500,提示TemplateInputException | templates目录下没有对应的HTML文件,或者文件名/目录写错 | 核对Controller返回值和文件路径的映射关系 |
| 修改HTML但浏览器不变 | 模板缓存未关闭 | 配置spring.thymeleaf.cache=false后重启 |
| 页面显示敏感代码或HTML被原样输出 | 正在尝试内联脚本时用了[[${...}]]但没开th:inline模式 | JS里加th:inline="javascript" |
| 表单提交后404或字段没绑定上 | 表单没有加th:object,或input没有th:field,或name属性丢失 | 看浏览器开发者工具中请求参数是否带上了字段值 |
| 页面渲染好的链接404 | 使用了原生路径而不是@{} | 全局搜索/css/js等硬编码路径,全部替换成@{} |
| 比较两个字符串不相等时判断失效 | 字符串比较用了==而不是equals | 用${#strings.equals(str1, str2)}或者th:if="${str == 'xxx'}"时注意字符串字面量写法 |
最后那一行的字符串比较问题很典型。th:if="${str == 'abc'}"在Thymeleaf里是允许的,它内部做的是字符串equals比较,不是Java的引用比较。但要注意写法必须单引号包裹,写成'abc'而不是"abc"——在Thymeleaf表达式里,双引号是字符串定界符的另一种形式,容易语义混淆,建议统一用单引号。
7.2 模板渲染报错的三条定位思路
模板渲染报错有时给的信息很直白,比如TemplateInputException: Error resolving template [user-list],告诉你找不到模板文件了。但有时候报错很隐晦,尤其是表达式写错时,它可能给你抛一段很长的手写栈信息,真正的错误原因埋在最底下。我自己常用的定位方法论有三条。
第一条,看报错信息中是否包含org.thymeleaf.exceptions.TemplateProcessingException。如果出现了这个抛错,说明模板引擎已经找到了文件但处理过程出了问题,继续往下翻,找到第一个带line X - column X字样的日志,那里的行号列号直接指向模板中出错的标签位置。
第二条,在IDEA中安装Thymeleaf插件(现在新版IDEA已经内置了)。模板文件里表达式写错时,插件会在编辑器右侧直接标红报语法错误,很多低级错误根本到不了运行阶段就被拦住了。
第三条,返工法。模板渲染错误如果实在看不出问题在哪,就把模板内容精简到最小结构,只保留出问题的那个标签,其他全部注释掉,逐步加回内容定位,确认问题作用域在哪一个表达式上。这个方法虽然笨,但排查效率特别高,尤其是面对大段继承布局模板时,能把问题迅速收敛到小块区域。
7.3 前端原型演示的坑:为什么打开HTML文件样式全没了
这一点值得单独拎出来说。很多团队的工作流是:后端工程师拿到设计稿,先用纯HTML搭一个静态原型,交给业务方确认效果,然后再套Thymeleaf模板。如果直接在浏览器里双击打开index.html,你会发现th:href、th:src、th:text这些属性全部被浏览器忽略,原型页面本身显示的是标签里的默认文本和默认路径——这是Thymeleaf当作为自然模板被设计的初衷,默认文本充当降级显示。
但问题在于,@{/css/common.css}这种写法在静态打开时,href属性值是空的,因为浏览器不知道@{...}表达式是干嘛的。所以你静态预览时想要样式、图片正常显示,需要在href里同时写默认值:
<link th:href="@{/css/common.css}" href="/static/css/common.css" rel="stylesheet">这样静态打开时浏览器用href属性,服务端渲染时用th:href覆盖href。这个技巧能让原型阶段和后端渲染阶段共用同一份HTML,真正做到“自然模板”。要注意:/static/css/common.css这个路径是纯前端用的,前提是前端把静态文件也放在static目录下,并且用本地服务器预览(比如Live Server插件),否则文件协议下路径依然不通。
8. 页面公共片段复用:模板布局与th:fragment实践
8.1 th:fragment定义可复用片段
一个后台管理系统,十几个页面都有相同的顶部导航和底部版权栏,靠复制粘贴管理这些公共部分,改动一次要同步十几个文件,迟早会出问题。Thymeleaf的片段机制是专门解决这个问题的。
定义一个公共片段文件templates/fragments/common.html:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <body> <div th:fragment="header"> <h2>系统公共头部</h2> </div> <div th:fragment="footer(copyright)"> <p th:text="${copyright}">版权信息</p> </div> </body> </html>在其他页面引用:
<div th:replace="fragments/common :: header"></div> <footer th:replace="fragments/common :: footer('© 2024 某系统')"></footer>th:replace的语法是模板路径 :: 片段名,路径从templates根目录开始写,不用加templates/前缀,不用加.html后缀。
8.2 th:replace、th:insert、th:include三兄弟的差异
这三者都用于引入片段,差别在于DOM替换的粒度,很多文档讲得含糊。我用大白话解释:
th:insert:把片段插入到当前标签内部,当前标签还在,片段内容成为它的子节点。th:replace:直接用片段替换当前标签,当前标签没了,片段本身成了这个地方的DOM。th:include:只把片段内部内容包含进来,片段的根标签不保留(这个属性在Thymeleaf 3.0之后标记为废弃,官方推荐用insert/replace)。
实际使用中,th:replace是主力,因为替换逻辑更符合“模板子页嵌入布局”的直觉;th:insert适合在某个容器内追加多个片段时保持容器结构。弄清楚三者的差别,布局继承就不会出现“多了个div包了一层”的问题。
8.3 使用参数化片段提升复用率
片段还支持参数传递。上面的footer片段定义时已经写了footer(copyright)参数,引用时通过('内容')传入。更复杂的场景,参数甚至可以是一个对象、一个集合。这种参数化片段对开发维护的效率提升非常明显,比如一个分页组件,不同页面只传当前页数和总页数就行:
<div th:replace="fragments/common :: pagination(currentPage=1, totalPage=10)"></div>定义端对应:
<div th:fragment="pagination(currentPage, totalPage)"> <span th:text="${currentPage} + ' / ' + ${totalPage}">页码</span> </div>使用参数化片段时要注意:参数名两边不能有空格,currentPage=1这种写法是固定的,写currentPage = 1会解析失败。这个坑我踩过不止一次,报错信息还不明显,会给你抛一个SyntaxError,排查起来很烦。
9. 从开发到上线的几项收尾工作
9.1 生产环境缓存怎么开
前面开发期建议配置spring.thymeleaf.cache=false,但正式发布时一定要改回来。如果不改,每次请求都会重新解析模板文件,性能差距是数量级的。生产配置建议如下:
spring: thymeleaf: cache: true现在的Spring Boot约定是把配置写在application.yml里,通过Spring Profile切换:
# application-dev.yml spring: thymeleaf: cache: false # application-prod.yml spring: thymeleaf: cache: true启动时用--spring.profiles.active=prod指定环境即可。模板缓存还能配合模板文件修改的热加载手段使用,但在生产环境,模板基本是发布产物,不太会出现运行中改模板的需求,保持默认即可。
9.2 安全层面的注意事项
Thymeleaf默认开启了HTML转义,th:text输出的内容中,<script>、<img>等标签会以纯文本形式展示,不会作为HTML解析执行,这是它默认防XSS(跨站脚本攻击)的方式。但如果你使用了th:utext(不转义文本),输出内容会被当作HTML直接渲染。除非你明确知道内容来源可信,否则不要用th:utext展示用户输入的数据。
内联JavaScript场景下,[[${...}]]同样做转义处理,但如果你用了[(${...})]这种非转义语法,就相当于关闭了安全防护,用户输入拼接进JS脚本后可能导致脚本注入。要么想办法过滤内容,要么避免这种写法。
9.3 前后端职责划分的实践建议
服务端渲染模式下,前端工程师和后端工程师的分工容易模糊,我的实践建议是:前端负责静态原型页面和样式脚本的产出,静态原型里所有动态数据都用默认文本占位;后端负责把静态原型转换成Thymeleaf模板,把Controller渲染数据绑定到模板表达式中。这样切换时改动范围小,且模板保留了“浏览器可预览”的自然特性,双方都能验证工作成果。
如果项目已经开始使用前后端分离的架构,就没必要硬套Thymeleaf。Thymeleaf的核心价值就在于降低服务端渲染项目的构建复杂度,如果非要在一个纯API项目里引入它,只会增加维护成本,得不偿失。做技术选型时想清楚这个边界,比会写多少种语法重要得多。
10. 实操收尾总结与下一步方向
所有核心内容都跑完了,说说我在实际使用里的几点体会。
第一,Thymeleaf的学习曲线真的很平。你用一天时间把语法过一遍,第二天就能写出能跑通的页面。真正需要花时间的不是记标签,而是理解Spring MVC里视图解析、Model传参、表单绑定这些后端概念的运转方式。基础理解了,Thymeleaf只是把这些概念映射成模板属性而已。
第二,自然模板的特性一定要充分利用。开发阶段先在浏览器里打开纯静态页面确认布局效果,再启动服务端渲染看数据绑定结果,效率比直接猜表达式高很多。团队协作时,静态原型和服务端模板复用同一份HTML,也能省掉大量沟通成本。
第三,如果你在项目中遇到模板渲染性能问题,优先检查是否开启了缓存,其次才是优化模板解析逻辑。Thymeleaf的模板解析本身是重量级操作,模板数量多、页面复杂的情况下,关闭缓存带来的性能损失会非常明显。这也是为什么我反复强调开发和生产环境要分开配置缓存。
接下来能深入的方向还挺多的:Thymeleaf与Spring Security整合(在模板里根据用户角色控制菜单显示)、与AOP结合做统一的页面响应拦截、自定义方言(dialect)扩展语法等。这些都属于进阶玩法,先把这篇的基础链路跑通、踩过一遍坑,再研究进阶能力会顺很多。