🐸 青蛙编程网
首页 / Java / Spring Boot / 4. 模板渲染

# 模板渲染: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 循环。

动手改

三个小活都围绕"多挂一条路由"展开,做完对"页面名 → 模板文件"的对应关系就彻底熟了:

  1. 加一行 <p th:text="'今天学会了:' + ${name} + '的模板渲染'"></p>
  2. 把 fruits 换成你的爱好列表(4 个以上),页面就会循环出 4 行
  3. 新建 /bye 路由 + bye.html,显示"下次再见",亲手走一遍"页面名 → 模板文件"的对应关系

下一章:表单与用户输入,让网页不只"会看",还能"会收" 🐸

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。