# 模板渲染:Thymeleaf
这是什么:模板引擎(Template Engine) = 页面还是正常 HTML,只在要填数据的地方留个「空」,运行时把数据填进去;本章主角是 Thymeleaf。
为什么用:上一章的控制器只会返回文字和 JSON,那是给「程序」看的,给「人」看的网页需要 HTML。可硬拼字符串写三行就眼冒金星,改一个标签要在乱麻里找半天。
如何用:本章先加 Thymeleaf 依赖,再让 @Controller 往模板传数据、写出页面模板,最后用 th:if 做条件渲染。
不用模板引擎,页面只能这样硬拼:
return "<h1>你好," + name + "!</h1><ul><li>" + fruit1 + "</li><li>" + fruit2 + "</li></ul>";
第一步:加 Thymeleaf 依赖
最省事的路子:回到 start.spring.io 重新生成项目时,把 Thymeleaf 和 Spring Web 一起勾上。
也可以直接在 pom.xml 的 <dependencies> 里补一段(补完让 IDE 重新加载 Maven,或重启应用):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
眼熟吗?这就是上一章说的起步依赖(starter):一段声明拉来一整套零件——Thymeleaf 引擎本身 + 和 Spring MVC 对接的胶水 + 默认配置(模板放在 templates/、后缀 .html),全都不用你操心。版本号都不用写,Spring Boot 统一调配。这正是"约定优于配置"的日常体验:约定已经内置,你只管往约定的位置放东西。
第二步:@Controller 传数据
注意变化:这次用 @Controller(不带 Rest),因为方法要返回的是"页面名",不是内容本身。先把上一章 HelloController 里 /hello 那个方法删掉(不然两个类抢同一条路由,启动会报错),再新建 PageController.java:
package com.example.demo;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@Controller
public class PageController {
@GetMapping("/hello")
public String hello(Model model) {
model.addAttribute("name", "小青蛙");
model.addAttribute("fruits", List.of("苹果", "香蕉", "草莓"));
return "hello"; // 对应 templates/hello.html
}
}
两种控制器注解怎么选,一张表定夺:
| 对比项 | @RestController |
@Controller |
|---|---|---|
| 返回值是什么就是什么 | ✅ 直接写给浏览器 | ❌ 返回值被当成"页面名" |
| 适用场景 | 返回文字、JSON | 返回 HTML 页面 |
| 想返回文字 JSON 又用它? | —— | 方法上加 @ResponseBody 例外 |
两件事:
Model是"数据背包":addAttribute("名字", 值)把数据装进包里,带给页面。方法参数里写上Model model,框架自动递给你一个空包- 返回值
"hello"是页面名:Spring Boot 自动去templates/文件夹找hello.html
名字对得上是整个机制的关键:addAttribute 的第一个参数、模板里 ${} 里的名字、你心里的预期,三处必须一模一样——差一个字母,页面拿到的就是空。建议养成习惯:写完 addAttribute 立刻跳到模板里把 ${} 写上,趁名字还热乎,别留到最后回头对暗号。
第三步:写模板
在 src/main/resources/templates/ 下新建 hello.html。留意这个文件是完整的、正常的 HTML——不是什么特殊格式,只是多了几个 th: 开头的属性:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
<h1 th:text="'你好,' + ${name} + '!'">你好,访客!</h1>
<ul>
<li th:each="fruit : ${fruits}" th:text="${fruit}">苹果</li>
</ul>
</body>
</html>
重启应用,访问 http://localhost:8080/hello,浏览器看到(示意):
你好,小青蛙!
• 苹果
• 香蕉
• 草莓
右键"查看网页源代码"会发现:th: 标记不见了,${name} 的位置已经填上真实数据——模板 = 带空格的 HTML,渲染 = 填空。
把整个过程连起来看一遍,就四步:
浏览器请求 /hello
↓
控制器方法执行:往 Model 背包装数据
↓
返回 "hello" → 找到 templates/hello.html
↓
Thymeleaf 边解析边填空,输出最终 HTML 给浏览器
对比上一章:返回 JSON 时"模型数据直接给人看",这一章是"模型数据先填进页面再给人看"。数据流没变,只是多了一道"填空"工序。
语法解读
| 写法 | 作用 |
|---|---|
xmlns:th="http://www.thymeleaf.org" |
声明 th: 命名空间,编辑器才认识这些属性 |
th:text="${name}" |
把标签文字替换成变量值(自动转义,防注入) |
${name} |
从 Model 背包里取数据 |
th:each="fruit : ${fruits}" |
循环:列表里有几个元素,这个标签就重复几次 |
| 标签里的"苹果"等默认文字 | 模板直接双击打开也能预览,渲染后被替换 |
最后一点是 Thymeleaf 的特色:它是"自然模板"——不启动程序,浏览器直接打开 HTML 也能看到大致样子。因为 th: 属性对浏览器来说只是"不认识的未知属性"(直接忽略),标签里的默认文字照常显示。团队里前端设计师改静态稿、后端工程师填数据,两边改的是同一份文件,谁也不阻塞谁——比起"模板里全是 <% %> 代码块"的老式引擎,这是实打实的工程福利。
循环还想拿到"第几条"?th:each 支持第二个变量:
<li th:each="fruit, stat : ${fruits}" th:text="${stat.count} + '. ' + ${fruit}">1. 苹果</li>
stat.count 是从 1 开始的序号(stat.index 从 0 开始)——排行榜、编号列表全靠它。
模板表达式里还能做拼接和算术,写法接近直觉:
| 写法 | 结果 |
|---|---|
'你好,' + ${name} + '!' |
文字拼接,单引号包字面量 |
${score} + 10 |
算术运算,${score} 是 88 就输出 98 |
${score} >= 60 ? '过' : '挂' |
三元表达式,等价于简版的 th:if |
<p th:text="'下次考试目标:' + (${score} + 10) + ' 分'">目标</p>
你会看到:
下次考试目标:98 分
引号里单引号包文字、加号连数据,和 Java 字符串拼接一个手感——只是这些表达式跑在模板里,由 Thymeleaf 负责求值。
第四步:条件渲染 th:if
除了填空和循环,模板还能判断。给页面加个"及格徽章"试试——控制器里多装一份数据:
@GetMapping("/profile")
public String profile(Model model) {
model.addAttribute("name", "小青蛙");
model.addAttribute("score", 88);
return "profile";
}
templates/profile.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>个人主页</title>
</head>
<body>
<h1 th:text="${name} + ' 的成绩单'">访客的成绩单</h1>
<p th:if="${score} >= 60" th:text="'✅ 及格,分数:' + ${score}">及格</p>
<p th:unless="${score} >= 60" th:text="'❌ 不及格,分数:' + ${score}">不及格</p>
</body>
</html>
访问 /profile,你会看到(score 是 88,及格行显示,不及格行整个消失):
小青蛙 的成绩单
✅ 及格,分数:88
th:if 条件成立标签才渲染,th:unless 反着来——两个都写上,"二选一"的效果就有了。把 score 改成 45 再重启,页面只剩不及格那一行。判断逻辑写在模板里还是 Java 里? 简单的显示开关放模板,业务判断放 Java——这个分寸以后写多了自然有手感。
顺便补一句安全账:th:text 是自动转义的——就算有人故意在留言里写 <script>,也会被转成普通文字显示出来,脚本不会执行。这也是"别用字符串手拼 HTML"的又一个理由:模板引擎顺手帮你把注入攻击的门关上了。
❓ 常见问题
Q1:启动或访问时报 Error resolving template [hello]?
框架按名字去找模板文件没找到。三处对一遍:文件在 src/main/resources/templates/ 目录里吗(放 static/ 里不行,那是静态文件的门牌);文件名是 hello.html 吗;方法返回的是 "hello" 吗——返回名、文件名,一字不差。这个报错在模板学习期出勤率最高,三处对完基本必好。
Q2:页面上原样显示 ${name},数据没填进去?
多半是标签上忘了写 th:——<h1>${name}</h1> 里的 ${name} 对浏览器来说只是普通文字。改回 <h1 th:text="${name}"> 就好。另外检查 xmlns:th 命名空间声明还在不在,IDE 靠它才认识 th: 属性。
Q3:访问路由直接 404?
和上一章的排查思路一样:改完代码重启了吗?@Controller 类在 com.example.demo 包里吗?注意区分——如果看到的是 Whitelabel 页面加 Error resolving template,那是 Q1 的模板问题,不是路由问题。
Q4:改了模板文件,重启太烦,能不能改完刷新就生效?
默认模板有缓存,改完要重启才生效。开发阶段可以在 application.properties 里加一行 spring.thymeleaf.cache=false 关掉缓存,刷新即见新版;正式上线记得删掉这行,不然每次渲染都重新读文件,白白浪费性能。
⚠️ 新手翻车现场
翻车一:该用 @Controller 却用了 @RestController
@RestController // ❌ 返回值会直接写给浏览器
public class PageController {
@GetMapping("/hello")
public String hello(Model model) {
model.addAttribute("name", "小青蛙");
return "hello"; // 浏览器显示的是文字:hello
}
}
问题:@RestController 把 "hello" 当内容原样输出,页面上只有一个小写的 hello,模板文件根本没被打开。
修正:返回页面就老实用 @Controller。
翻车二:模板放错文件夹
把 hello.html 放进了 static/ 而不是 templates/。访问 /hello 直接报 Error resolving template——templates/ 是模板的法定地址,static/ 里的文件按"文件名直接访问"的逻辑走(/hello.html 能开,但不会有任何数据填充)。
翻车三:th:text 里漏了 ${}
<h1 th:text="name">访客</h1> <!-- ❌ 显示字面量 name -->
问题:不带 ${} 的 name 是字符串字面量,页面显示字母 n-a-m-e。
修正:th:text="${name}"——${} 是"从数据背包里取"的暗号,不能省。
🎯 模板渲染 vs 返回 JSON
两条输出路线怎么选,一张表说清:
| 对比项 | 模板渲染(本章) | 返回 JSON(上一章) |
|---|---|---|
| 产出 | 完整 HTML 页面 | 纯数据 |
| 给谁看 | 人(浏览器直接显示) | 程序(前端/APP 再加工) |
| 注解 | @Controller |
@RestController |
| 典型场景 | 官网、后台管理页、个人博客 | 手机 APP 接口、前后端分离项目 |
本系列走模板路线——学完就能做出"看得见的网页",成就感来得快;将来转前后端分离,@RestController 那套随时捡起来就能用。
📝 本章小结
- 模板引擎解决"Java 拼字符串写 HTML"的痛苦:页面是正常 HTML,留空等数据
- 加依赖用起步依赖
spring-boot-starter-thymeleaf,模板放templates/,返回页面名找文件 @Controller+Model背包传数据;@RestController是返回数据用的,别混用- 四个核心语法:
th:text填空(自动转义)、th:each循环(可带 stat 拿序号)、th:if/th:unless条件渲染 - 三处名字必须一致:
addAttribute的键、${}里的名字、模板文件名对返回名 - 记忆口诀:Controller 返回页面名,Model 背包装数据;templates 里放 html,th:text 填空 th:each 循环。
记忆口诀
Controller 返回页面名,Model 背包装数据;templates 里放 html,th:text 填空 th:each 循环。
动手改
三个小活都围绕"多挂一条路由"展开,做完对"页面名 → 模板文件"的对应关系就彻底熟了:
- 加一行
<p th:text="'今天学会了:' + ${name} + '的模板渲染'"></p> - 把 fruits 换成你的爱好列表(4 个以上),页面就会循环出 4 行
- 新建
/bye路由 +bye.html,显示"下次再见",亲手走一遍"页面名 → 模板文件"的对应关系
下一章:表单与用户输入,让网页不只"会看",还能"会收" 🐸