演示三:Thymeleaf 模板引擎Java Web 后端开发 · 第4章

配套案例:图书管理(templates/books.html + BookController),跟着章节走一遍就能看懂案例源码
1 是什么 2 表达式 3 常用属性 4 内联表达式 5 案例走查 6 请求流程

Thymeleaf 是什么?

一个服务端模板引擎:把 Controller 放进 Model 的数据,套进 HTML 模板,渲染出完整的页面再发给浏览器。

核心定位 —— 页面负责"长什么样"(HTML 模板),Controller 负责"给什么数据"(Model),Thymeleaf 负责把两者拼起来。
JSP(老方案)Thymeleaf(本课程方案)
页面里写什么Java 代码嵌在页面里(<% %>、<%= %>)HTML 标签 + th: 属性,页面本身是合法 HTML
浏览器直接打开打不开(必须服务器编译)可以,未渲染的属性被忽略,页面不报错
前后端分离Java 逻辑与页面混杂数据用 ${} 占位,职责清晰
引入方式容器内置pom.xml 加 spring-boot-starter-thymeleaf
记住两个约定 —— ① 模板文件必须放在 src/main/resources/templates/ 目录;② 静态资源(css/js/图片)放 src/main/resources/static/ 目录。

一次渲染的完整过程

点击"下一步"逐步看(以访问 /book/list 为例):

1
浏览器发起请求访问 /book/list,携带登录后的 session
2
Spring MVC 路由找到 BookController.findBook() 方法
3
控制器准备数据:往 session 放用户,往 model 放 books 图书列表
4
返回视图名字符串 "books"(不是页面内容,只是一个名字)
5
Thymeleaf 定位模板,根据视图名找到 templates/books.html
6
用 Model 数据填充模板,生成完整 HTML,返回给浏览器显示
核心:Controller 返回的是"视图名",不是页面。真正干活的是 Thymeleaf —— 它拿视图名去 templates 找文件,拿 Model 数据替换 th: 属性。

标准表达式(最常用的 4 种)

表达式是 Thymeleaf 的"取数语法",写在 th: 属性或 ${} 里。点每个演示的按钮,看模板怎么变成最终页面。

① ${...} 变量表达式 —— 取 Model / Session 里的值
<td th:text="${book.name}">图书名称</td>
<td th:text="${#session.getAttribute('user').name}">用户名</td>
<td>楚辞</td>
<td>zhangsan</td>
${book.name} 取 Model 中 books 集合里当前 book 的 name 属性;${#session.getAttribute('user')} 是取 Session 的固定写法。
② @{...} URL 表达式 —— 生成链接 / 资源地址,自动加项目根路径
<link rel="stylesheet" th:href="@{/css/bootstrap.css}">
<form th:action="@{/book/search}" method="post">...</form>
<link rel="stylesheet" href="/css/bootstrap.css">
<form action="/book/search" method="post">...</form>
推荐所有 href / src / action 都用 @{} 写:有项目名时它会自动补上,不用手工拼路径。
③ *{...} 选择表达式 —— 配 th:object 简化取值
<div th:object="${book}">
  <p th:text="*{name}">书名</p>
  <p th:text="*{author}">作者</p>
</div>
<div>
  <p>楚辞</p>
  <p>屈原</p>
</div>
*{name} 等价于 ${book.name},只是省了"book."——对象明确时更简洁。
④ #{...} 消息表达式 —— 读取国际化消息文件(i18n)
// 配置文件 application.yml:spring.messages.basename: msg/home
// 资源文件 msg/home.properties 里:welcome=欢迎使用图书管理系统
<h1 th:text="#{welcome}">标题</h1>
<h1>欢迎使用图书管理系统</h1>
消息表达式用于多语言切换。本案例的 application.yml 已配置 basename,但页面未使用,属于预留的国际化能力。

常用 th: 属性

th: 属性就是"告诉 Thymeleaf 怎么处理这个标签"的指令。先看总表,再看两个重点演示。

属性作用案例中的用法
th:text输出文本,替换标签内容th:text="${book.name}"
th:href / th:src生成链接 / 资源地址(配 @{})th:href="@{/css/bootstrap.css}"
th:action表单提交地址(配 @{})th:action="@{/book/search}"
th:each循环遍历集合,重复渲染当前标签th:each="book : ${books}"
th:if / th:unless条件成立 / 不成立才渲染th:if="${book.status == '0'}"
th:switch / th:case多分支判断(等价 if-else if)th:switch="${book.status}"
th:onclick绑定点击事件(常配内联表达式)th:onclick="findBookById([[${book.id}]])"
th:block隐形容器:只控制渲染,不产生 HTML 标签<th:block th:each="...">
th:each + th:if —— 图书列表里最核心的组合
<th:block th:each="book : ${books}">
  <tr>
    <td th:text="${book.name}"></td>
    <td>
      <th:block th:if="${book.status == '0'}">可借阅</th:block>
      <th:block th:if="${book.status == '1'}">借阅中</th:block>
    </td>
  </tr>
</th:block>
<tr>
  <td>楚辞</td>
  <td>可借阅</td>
</tr>
<tr>
  <td>纳兰词</td>
  <td>借阅中</td>
</tr>
外层 th:each 让 tr 重复出现(3 本书 = 3 行);内层 th:if 按 status 值决定显示哪个状态文字。注意 th:block 本身不生成任何标签。
th:if 控制按钮 —— 图书状态不同,按钮可用/禁用
<button th:if="${book.status == '0'}"
        th:onclick="findBookById([[${book.id}]]);">借阅</button>
<button th:if="${book.status == '1' || book.status == '2'}"
        disabled="true">借阅</button>
<button onclick="findBookById(1);">借阅</button>   (楚辞,状态0 → 可点)
<button disabled="true">借阅</button>          (纳兰词,状态1 → 禁用)
条件满足才渲染这个标签;不满足就整段消失。|| 表示"或"条件。内联表达式 [[${book.id}]] 把 Java 数字直接写进 JS 代码。
th:if 判断 Session 权限 —— 管理员才显示"新增"按钮
<div th:if="${#session.getAttribute('user').role == 'ADMIN'}">
  <button>新增</button>
</div>
<div>
  <button>新增</button>
</div>
Controller 里 session.setAttribute("user", new User("zhangsan","ADMIN")) 放了角色,模板据此控制权限。role 不是 ADMIN 时这段 HTML 直接不输出。

内联表达式 [[...]]

除了在 th: 属性里写表达式,Thymeleaf 还允许把表达式"内联"进普通文本或 JS 代码里。

写法含义区别
[[${...}]]转义内联HTML 特殊字符会被转义(更安全,推荐)
[(${...})]不转义内联原样输出,可渲染 HTML 标签(有注入风险,慎用)
典型场景:把后端数据传进 JS —— 点击"借阅"按钮
<button th:onclick="findBookById([[${book.id}]]);">借阅</button>

<script>
  function findBookById(id) {
    $.get("/book/find/" + id);   // 请求后端,带上图书编号
  }
</script>
<button onclick="findBookById(1);">借阅</button>

<script>
  function findBookById(id) {
    $.get("/book/find/" + id);
  }
</script>
[[${book.id}]] 渲染后被替换成真实数字 1,页面变成 onclick="findBookById(1);"。这就是"后端数据 → 前端 JS"的桥。
注意 —— 内联表达式只在 Thymeleaf 渲染后生效。若直接用浏览器打开 .html 源码文件,[[${book.id}]] 会原样显示,这正是 Thymeleaf "纯 HTML 可预览"特性:不渲染时它只是普通文本,不报错。

案例走查:books.html 是怎么工作的

下面是本案例 templates/books.html 的真实代码骨架。对照左侧模板、右侧渲染结果,逐段看懂。

① 头部:引入样式 / JS(th:href、th:src)
<html xmlns:th="http://www.thymeleaf.org">
<link th:href="@{/css/bootstrap.css}" rel="stylesheet">
<link th:href="@{/css/AdminLTE.css}" rel="stylesheet">
<script th:src="@{/js/jquery.min.js}"></script>
<link href="/css/bootstrap.css" rel="stylesheet">
<link href="/css/AdminLTE.css" rel="stylesheet">
<script src="/js/jquery.min.js"></script>
② 搜索区:th:action 提交到 /book/search
<form th:action="@{/book/search}" method="post">
  图书名称:<input name="name">
  图书作者:<input name="author">
  <input type="submit" value="查询">
</form>
<form action="/book/search" method="post">
  图书名称:<input name="name">
  图书作者:<input name="author">
  <input type="submit" value="查询">
</form>
③ 表格主体:th:each 遍历 + th:text 输出
<th:block th:each="book : ${books}">
  <tr>
    <td th:text="${book.name}"></td>
    <td th:text="${book.author}"></td>
    <td th:text="${book.press}"></td>
  </tr>
</th:block>
<tr><td>楚辞</td><td>屈原</td><td>中国文联出版社</td></tr>
<tr><td>纳兰词</td><td>纳兰性德</td><td>中国文联出版社</td></tr>
<tr><td>西游记</td><td>吴承恩</td><td>中国文联出版社</td></tr>
④ 状态列:th:if 三选一 + 借阅按钮
<th:block th:if="${book.status == '0'}">可借阅</th:block>
<th:block th:if="${book.status == '1'}">借阅中</th:block>
<th:block th:if="${book.status == '2'}">归还中</th:block>
<button th:if="${book.status == '0'}"
        th:onclick="findBookById([[${book.id}]]);">借阅</button>
楚辞(status=0)→ 可借阅 + <button onclick="findBookById(1);">借阅</button>
纳兰词(status=1)→ 借阅中 + <button disabled="true">借阅</button>
西游记(status=2)→ 归还中 + <button disabled="true">借阅</button>
总结走查规律:模板里凡是想"动态"的地方——文字、链接、循环、判断、事件——都用 th: 属性或 ${} 表达式占位;Controller 负责把这些空填上。页面本身始终是合法 HTML,这也是 Thymeleaf 相比 JSP 最大的优点。

串联起来:登录 → 图书列表 → 借阅

把过滤器(上一章)、Controller、Thymeleaf 串成一条完整链路。点击"下一步"逐步走:

1
注册 / 登录:/doRegister 存用户,/dologin 把用户名存进 session(loginUser)
2
浏览器访问 /book/list,请求先到 LoginFilter(上一章的登录过滤器)
3
过滤器校验:session 里有 loginUser → 放行;没有 → 重定向 /login.html
4
BookController.findBook():session 放 user(zhangsan / ADMIN)、model 放 books 图书列表
5
返回 "books",Thymeleaf 找到 templates/books.html,用 model 数据渲染
6
页面显示图书表格;点"借阅" → JS 调 /book/find/{id},后端打印借阅编号
配套代码:BookController(本案例新增)
@Controller
@RequestMapping("book")
public class BookController {
    ArrayList<Book> books = Data.getData();        // 3 本静态图书

    @RequestMapping("list")                        // GET /book/list
    public String findBook(Model model, HttpSession session){
        session.setAttribute("user", new User("zhangsan","ADMIN"));
        model.addAttribute("books", books);        // 数据放进 Model
        return "books";                            // 返回视图名 → templates/books.html
    }

    @RequestMapping("search")                      // POST /book/search
    public String searchBook(Book book, Model model, HttpSession session){
        // 按 name / author 过滤后,重新放进 model 返回同一模板
        ...
        return "books";
    }

    @RequestMapping("find/{id}")                   // GET /book/find/1
    public String findBook(@PathVariable("id") Integer id){
        System.out.println("申请借阅图书的id:" + id);
        return "books";
    }
}
三个接口的职责:
· /book/list    → 查全部图书,进列表页
· /book/search  → 按名称/作者模糊查询,结果仍渲染同一张 books.html
· /book/find/1  → 借阅接口:路径上的 1 是图书编号,由 th:onclick 里的内联表达式传过来

为什么都是 return "books"?
因为三个接口共用 templates/books.html 这一张模板,
只是 model 里的 books 集合内容不同(全量 or 过滤后的结果)。
常见报错 —— 若模板里写了 ${books} 但 Controller 忘了 model.addAttribute("books", ...),页面会报 Exception evaluating SpringEL expression。原则:模板用到的每个变量,Controller 都必须先放进去。
配套练习:启动项目 → 注册 → 登录 → 访问 http://localhost:8080/book/list,亲手改 books.html 里的 th:text / th:if 看页面变化。