先别急着写代码,我想先聊聊一个非常经典的场景:你刚把Spring Boot项目跑起来,在Controller里写了return "index",满心以为浏览器会跳出一个图文并茂的页面,结果现实给了你一记白屏暴击——要么看到一串纯文本形式的"index",要么就是白底黑字写着大大的Whitelabel Error Page。
这几乎是每个从传统JSP/Spring MVC转过来的开发者都会撞上的墙。Spring Boot默认按RESTful风格处理,Controller方法的返回值会被当成HTTP响应体内容,而不是视图名称。换句话说,框架没想到你会用字符串去指路一个HTML文件。于是"关于Spring Boot从Controller返回到HTML页面"这个需求,就成了所有起步阶段绕不开的关卡:你要么接受前后端分离、老老实实返回JSON让前端渲染;要么就得告诉Spring Boot"我这里要的是视图渲染,不是接口数据",让它去找到对应的HTML文件,拼好数据再交给浏览器。
这篇文章会把这层窗户纸彻底捅破。我会从原理、依赖、配置到实操一步步拆,把返回HTML的几种写法、静态资源目录怎么放、页面里怎么拿Controller传过来的参数、白标页和404的排查方法都过一遍。无论你是刚入门的初学者,还是被这个老问题折腾半天的初学者加强版,看完都能直接照抄作业跑通。
1. 先搞清楚:Controller为什么默认返回不了HTML页面
1.1 一个最简单demo的报错现场
很多朋友的第一步是这样操作的:新建一个Spring Boot项目,建一个Controller类,写一个接口方法,然后信心满满地启动项目。
@RestController @RequestMapping("/show") public class ShowController { @GetMapping("/index") public String goIndex() { return "index"; } }浏览器访问http://localhost:8080/show/index,运气好的话页面干干净净显示几个字母:index。运气不好的话,直接给你一张白底页面。
关键就在这里:@RestController注解说明这个类的每个方法返回值都直接作为响应体内容写回HTTP Response,Spring Boot根本不认为你返回的 "index" 是个需要解析的视图名称。你写的字符串等于什么?等于接口数据。这就好比你到餐厅点了一份"招牌炒饭",服务员直接把写着"招牌炒饭"四个字的纸条端上来,后厨压根没收到做菜指令。
这个问题跟Spring Boot自带的Web依赖配置有关。早期Spring MVC时代,框架内置了视图解析器,返回字符串会去/WEB-INF/views/下找同名JSP文件。Spring Boot把默认行为改了,默认优先做REST接口,需要你显式引入模板引擎或配置静态资源处理器,框架才知道"字符串返回的是逻辑视图名,得去某个目录找HTML文件"。
1.2 根因拆解:不要JSON时要向框架传递什么信号
要返回HTML页面,本质上就是切换Spring MVC处理响应结果的模式,让它走"视图解析流程"而不是"消息转换流程"。这个信号主要通过两方面传递:
类级别不要用
@RestController,改成@Controller。@RestController是一个组合注解,等于@Controller加@ResponseBody,后者强制把方法返回值写入响应体。去掉@ResponseBody之后,返回值才会被交给视图解析器处理。引入一个模板引擎依赖。Thymeleaf、FreeMarker都行,项目里通常二选一。没有模板引擎,Spring Boot虽然也能返回静态HTML,但路径解析和参数绑定都不如模板方便。引入依赖之后,Spring Boot自动配置了对应的视图解析器,例如Thymeleaf的默认配置会把逻辑视图名拼上
classpath:/templates/前缀和.html后缀,最终找到templates/index.html这个文件渲染出来。
这两个信号缺一不可。只改注解不引模板,视图解析器压根不存在,照样会把字符串直接响应出去;只引模板不改注解,@ResponseBody依然优先拦截,返回的内容还是字符串。
这也解释了为什么网上搜到的代码有的用"return "index"",有的写"return "index.html"",其实都能跑,区别在于视图解析器的配置方式。默认情况下Thymeleaf会补后缀,写"index"就够了,写了"index.html"反而可能因为拼接成index.html.html而报错,这是个非常典型的细节坑。
2. 环境准备与项目骨架搭建
2.1 引入starter依赖:少一行配置模板引擎就不生效
先从pom.xml说起。要在Spring Boot项目里引入Thymeleaf,官方早就准备好的启动器是最省事的选择。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>引入这行依赖之后,Spring Boot的自动配置类ThymeleafAutoConfiguration就会生效,自动帮你创建SpringTemplateEngine和ThymeleafViewResolver这两个核心对象。默认配置大致是:
- 前缀:
classpath:/templates/ - 后缀:
.html - 字符集:
UTF-8 - 缓存:默认开启
你的HTML文件放在src/main/resources/templates/目录下,模板视图解析器就能找到。
这里提醒一句:不要引入Thymeleaf之后又手动在配置类里@Bean创建一堆解析器,很多时候反而会盖掉自动配置,导致行为变得不可预测。我见过不少新手在启动类上写了一堆@Bean ViewResolver,结果项目连启动都报冲突。实际上你引入依赖之后什么配置都不用做,Spring Boot把默认值都给你定好了,真到需要定制的时候再动配置项。
如果是想用FreeMarker,对应依赖是:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-freemarker</artifactId> </dependency>FreeMarker默认前缀也是classpath:/templates/,后缀是.ftl,模板文件后缀不同而已。日常用Thymeleaf更主流,语法也更贴近HTML本身,下面我统一用Thymeleaf做演示。顺带一提,传统JSP方案在Spring Boot里不是不能做,但需要额外引入spring-boot-starter-tomcat的嵌入式容器支持、配置war打包结构、把JSP文件放到webapp目录下,各种容器扩展类处理起来麻烦得多,Spring Boot官方也不推荐。新时代新项目直接Thymeleaf起步,省心很多。
2.2 目录结构:静态资源到底该放哪
Spring Boot对资源目录的约定非常明确,很多页面白屏、样式丢失的问题,根源就是文件放错了地方。
默认情况下有这样的分工:
| 目录 | 用途 | 访问方式 |
|---|---|---|
src/main/resources/static/ | 静态资源:CSS、JS、图片、字体 | 直接通过根路径/css/style.css访问 |
src/main/resources/templates/ | 动态模板页面:HTML模板文件 | 由Controller返回逻辑视图名,由模板引擎解析渲染,如果直接访问路径是拿不到渲染结果的 |
src/main/resources/public/ | 也是静态资源目录之一,和static并列 | 直接通过根路径访问 |
src/main/resources/resources/ | 同样是静态资源目录 | 直接通过根路径访问 |
static目录和templates目录最容易混淆。你做个登录页,如果它不需要任何后端数据,纯静态页面,放在static下也能通过Controller响应——用@GetMapping("/login")方法里return "login",Spring Boot的静态资源处理器会自动去static目录映射login.html,也能展示。但一旦页面里需要th:each循环遍历数据、th:if判断条件、从Model取参数这种动态内容,就必须放templates目录,让模板引擎来解析。
从路径规划来说,我个人的习惯是:
- 需要Controller处理、带接口传参的页面 →
templates/目录 - 完全不需要后端数据的页面、以及所有CSS/JS/图片 →
static/目录
这样做的好处是职责清晰,而且当页面需要从纯静态升级为动态渲染时,只需要把HTML文件移动到templates目录并加上Thymeleaf属性,不用大改路径规划。
还有一个很多人忽略的细节:Spring Boot的自动配置会为static目录下的index.html提供欢迎页支持,服务器启动后直接访问根路径http://localhost:8080/就会展示这个页面。如果你把index.html放进static目录,同时又在Controller里写了一个@RequestMapping("/"),两者会冲突,访问根路径时框架先匹配到哪个都不好说,经常出现意料之外的空白页。解决办法很简单:要么只用静态首页,要么只用Controller返回的首页,不要两套机制同时描到同一个路径。
3. 实操:三种从Controller返回HTML的写法
3.1 写法一:@Controller + 字符串逻辑视图名
这是最常用的写法,也是理解Spring MVC视图解析的入门样例。Controller类标记为@Controller,方法返回值写成字符串,这个字符串就是逻辑视图名。实际操作如下。
先创建模板文件src/main/resources/templates/hello.html:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>你好页面</title> </head> <body> <h1 th:text="${message}">这里的内容会被Controller传过来的值替换</h1> <p>当前时间:<span th:text="${now}"></span></p> </body> </html>然后创建Controller:
import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @Controller @RequestMapping("/page") public class PageController { @GetMapping("/hello") public String hello(Model model) { model.addAttribute("message", "你好,Spring Boot!"); model.addAttribute("now", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); return "hello"; } }访问http://localhost:8080/page/hello,页面会显示"你好,Spring Boot!"和当前时间。这段代码背后的处理流程是这样的:@GetMapping映射到请求路径后,Spring MVC收下请求和方法参数,方法内部把数据塞进Model,然后返回字符串"hello";视图解析器拿到"hello",拼接前缀后缀得到classpath:/templates/hello.html,模板引擎加载文件、用Model里的数据解析th:text,最终生成完整HTML响应给浏览器。
这个"外置数据"的思路很关键。页面模板本身是半成品,带有th:text这样的占位属性,真正的内容由Controller在运行时填充。这也是模板引擎和纯静态HTML的本质区别:静态HTML是把所有内容写死,模板则是结构固定、数据可变。
注意一点,方法参数Model是Spring MVC注入进来的,你只要写在参数列表里就能直接用,不用自己new。Model的作用域是每次请求范围内,请求处理完就销毁,所以不同请求之间不会串数据。
3.2 写法二:ModelAndView返回
相比Model加字符串返回,ModelAndView是把数据和视图名称打包到一个对象里返回,更"重"一些,适合需要同时设置视图名称和数据的场景。代码长这样:
import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.ModelAndView; @Controller @RequestMapping("/mv") public class ModelAndViewController { @GetMapping("/show") public ModelAndView show() { ModelAndView mv = new ModelAndView("show"); mv.addObject("title", "ModelAndView示例"); mv.addObject("content", "这份数据是跟着视图名一起返回的"); return mv; } }模板templates/show.html相应位置用th:text="${title}"、th:text="${content}"渲染。这种写法的可读性比较好,一眼就能看到"我要返回哪个页面、带哪些数据"。项目里如果习惯了链式调用,甚至可以改成return new ModelAndView("show").addObject("title", "xxx"),不过可维护性并不比传统的分开写更好。
从实际工程经验来看,ModelAndView用在小方法上没问题,方法一多反而有点笨重。Controller里动辄几十个方法,如果每个都new一个ModelAndView,代码里会堆满这个对象。我自己的习惯是:单个数据传递用Model或ModelMap,需要重定向之后还要继续传递数据时考虑RedirectAttributes,ModelAndView则适合那种确实需要同时管理视图和数据、且方法数量不多的小项目。哪种好没有绝对标准,团队里保持一致就行。
3.3 写法三:@RequestParam和表单参数绑定进页面
真正写业务页面的时候,很少有不带外部参数直接渲染的。常见需求是:登录页接收用户名、查询页接收关键字、详情页接收ID。这时Controller需要从请求里取参数,塞进Model,再返回视图。
import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @Controller @RequestMapping("/user") public class UserPageController { @GetMapping("/detail") public String detail(@RequestParam("id") Long id, Model model) { // 模拟查询用户信息,实际这里可以调用Service model.addAttribute("userId", id); model.addAttribute("username", id == 1L ? "张三" : "访客"); return "user-detail"; } }模板里这样取:
<p>用户ID:<span th:text="${userId}"></span></p> <p>用户名:<span th:text="${username}"></span></p>请求http://localhost:8080/user/detail?id=1,页面会把ID和用户名都渲染出来。这里要注意@RequestParam的必填属性,默认情况下该参数是必填的,URL里缺了这个参数直接报400错误。如果某参数允许不传,要加required = false,并且给个默认值:
@GetMapping("/search") public String search(@RequestParam(value = "keyword", required = false, defaultValue = "全部") String keyword, Model model) { model.addAttribute("keyword", keyword); return "search-result"; }这种从URL获取参数塞进页面的方式,是模板渲染最常见的交互模式。页面之间跳转时,th:href也可以动态拼参数:
<a th:href="@{/user/detail(id=${userId})}">查看详情</a>这段代码会被渲染成/user/detail?id=1这样的完整链接。Thymeleaf的@{}语法还会自动处理URL编码,中文参数传参时尤其有用,手动拼接字符串很容易在浏览器里出现乱码或解析失败。
3.4 写法四:@RestController返回HTML字符串的特殊场景
有人会说,我都用@RestController了,但还是想返回一段HTML片段,比如Ajax请求需要服务器端渲染一小块列表然后插到页面里,这种场景能不能返回HTML?能。
@RestController返回String本质上是把字符串当作响应体写出去,只要字符串内容是HTML标签,浏览器照单全收,照样渲染。例如:
@RestController @RequestMapping("/ajax") public class AjaxController { @GetMapping("/list") public String listHtml() { return "<ul>" + "<li>条目一</li>" + "<li>条目二</li>" + "<li>条目三</li>" + "</ul>"; } }前端拿到这段字符串后,用innerHTML或者jQuery的html()直接插入到页面容器中,列表就出来了。这种写法绕过了模板引擎,数据是Java代码直接拼字符串,优点是不用建模板文件、响应快、适合小段HTML;缺点也很明显,拼接过程繁琐、可读性差、XSS风险得你自己控制,稍微复杂一点就变成灾难。
我的建议是:仅在确实不需要模板引擎解析、响应体又比较短的时候用这种方案。如果HTML片段超过十行,还是专门建一个模板文件,把数据塞到Model里交给Thymeleaf渲染,然后用返回逻辑视图名的方式处理更舒服。还有一种折中做法是让@RestController的接口返回ModelAndView,虽然名头有点不对,但Spring MVC的视图解析依然生效,本质上跟@Controller没太大区别,不推荐不常用的写法。
4. 静态资源与动态参数:页面里样式加载不出来怎么办
4.1 th:href、th:src的路径写法
页面动态渲染之后,紧接着的问题就是CSS和JS加载不了。你打开浏览器控制台,满屏红色404,路径全是http://localhost:8080/templates/css/style.css这样的鬼样子。原因几乎都是资源路径写成了相对路径或者带了templates前缀。
Thymeleaf模板页里,资源和链接的写法有自己的一套规范。正确的做法是用@{}表达式,页面里这样写:
<link rel="stylesheet" th:href="@{/css/style.css}"> <script th:src="@{/js/app.js}"></script> <img th:src="@{/images/logo.png}" alt="logo">@{/css/style.css}解析出来的路径是以项目根路径为开头的绝对路径。如果项目设置了server.servlet.context-path=/demo,Thymeleaf会自动把这个前缀拼进链接,生成/demo/css/style.css,不会因为项目部署在子路径下面导致资源全挂。这种帮我处理上下文路径的能力,是手动写相对路径完全比不了的。
静态文件本体放在src/main/resources/static/css/style.css、static/js/app.js、static/images/logo.png。静态资源处理器会自动把这些文件映射到根路径下。
有一种常见错误是在HTML里写死<link rel="stylesheet" href="../static/css/style.css">。这种写法依赖当前URL的层级,页面换一个路径层级就失效,而且static目录本身不是通过URL访问的根目录,它是类路径下的资源文件夹,真正对外暴露的根路径不包含static这个前缀。记住一句话:在Thymeleaf静态资源引用里,@{}里面写的是访问路径,不是文件路径;static目录在URL路径中不显示。
动态样式还有一种场景,页面根据后端传来的变量决定配色或隐藏某个元素。可以直接写th:style:
<div th:style="'background-color:' + ${bgColor} + ';'">颜色动态变化区块</div>或者更推荐用th:classappend来控制CSS类名:
<div th:classappend="${isActive} ? 'active' : ''">状态切换区块</div>这两种方式在模板渲染业务场景中用得很多,比在Java代码里拼style字符串干净得多,也方便后续维护。
4.2 Controller给页面传值的几种途径对比
页面要显示数据,就得有数据来源。Controller给模板传值常见的途径有:Model、ModelMap、ModelAndView、RedirectAttributes,通过请求转发和重定向传递。整理一个对比表,一眼看清区别。
| 传递方式 | 特点 | 适用场景 |
|---|---|---|
Model | 接口方法参数,Spring MVC自动注入,数据放在请求域中 | 最常用,绝大多数简单传值场景 |
ModelMap | 基本是Model的扩展,本质也是存放属性的Map | 需要链式put或者数据量略多的情况 |
ModelAndView | 数据和视图名打包在一个对象返回 | 需要同时指定视图和数据的场景 |
RedirectAttributes | 重定向后仍能携带参数,Flash属性存session | 提交表单后重定向,且要带提示信息 |
@ModelAttribute注解 | 把方法返回值或方法参数对象直接放Model | 表单对象绑定时常用 |
普通的页面刷新、跳转,用Model足够了。POST提交后想跳转详情页,同时带个提示消息,用RedirectAttributes会更可靠,因为重定向是一次新的请求,原来的Model数据就丢了,Flash属性却能在重定向后恢复。
@PostMapping("/save") public String save(@ModelAttribute UserForm form, RedirectAttributes ra) { // 模拟保存操作 ra.addFlashAttribute("successMsg", "保存成功"); return "redirect:/user/detail?id=" + form.getId(); }这里addFlashAttribute的数据存储在session中,重定向后的页面上能通过${successMsg}取到,取完一次就销毁,不会污染后续请求。模板侧加上判断就能做提示条:
<p th:if="${successMsg}" th:text="${successMsg}" style="color: green;"></p>这种传值的方式,顺序是先重定向到目标地址,再由目标地址的Controller方法承载Model数据渲染页面。数据中转过程是有损耗的,如果数据量大或者结构复杂,直接改成从数据库查详情页所需的全部数据,比闪存传对象省心得多。
4.3 页面里循环和条件渲染的控制方法
模板引擎除了取参数,还得有循环和条件判断,不然动态列表和权限控制就无从下手。Thymeleaf常用的两个属性,一个是th:each,一个是th:if。
Controller端准备数据:
@GetMapping("/news") public String newsList(Model model) { List<String> titles = List.of("消息一", "消息二", "消息三"); model.addAttribute("titles", titles); model.addAttribute("showBanner", true); return "news-list"; }模板端:
<div th:if="${showBanner}"> <p>这是顶部通知条,只有showBanner为true时显示</p> </div> <ul> <li th:each="title : ${titles}" th:text="${title}"></li> </ul>配合th:each的内置变量还可以实现序号效果:
<ul> <li th:each="title, st : ${titles}"> <span th:text="${st.index + 1}"></span>. <span th:text="${title}"></span> </li> </ul>st.index从0开始,所以显示序号时记得加1。这些语法初看有点多,但做两个页面就熟了,跟JSP里JSTL的forEach差不多一个套路。
需要注意数据安全的一个点:直接输出用户提交的内容时,Thymeleaf默认会做HTML转义,th:text会把<script>这种内容显示成纯文本。这是个很好的默认行为,能防大部分XSS。如果确实要输出HTML,比如富文本编辑器的内容,才用th:utext,但同时你自己得对数据来源做好充分信任或者经过格式清洗,否则等于给注入攻击开了一扇门。
5. 踩坑实录与排查技巧
5.1 返回值变成了字符串"index"而不是页面
症状:Controller返回"index",浏览器里显示index这几个字母,控制台网络面板里能看到响应的Content-Type是text/plain或application/json,而不是text/html。
原因排查顺序:
- 类上是不是用了
@RestController?如果是,改成@Controller。 - 确认
pom.xml里是否引入了Thymeleaf或FreeMarker依赖。没有依赖,视图解析器不会自动配置,字符串自然直接响应。 - 如果两个都没问题,看看Spring Boot启动类有没有被很远的包扫描。Spring Boot默认扫描启动类所在包及子包,Controller的包路径如果跟启动类不在同一个根包下,组件扫描不到,这个接口压根不会执行。
这类问题最容易被当作"玄学"来处理,实际上根因就是注解和依赖两个层面,用二分法逐个排查,五分钟内必定位。
5.2 白标页Whitelabel Error Page
访问后出现白底黑字的Whitelabel Error Page,这是Spring Boot内置的错误页。本质上说明服务器端抛出了未处理的异常,但具体问题被这个错误页盖住了。排查要点:
- 看控制台异常堆栈。Spring Boot会把异常打印在终端,先看第一行是什么异常类型。
- 常见情况之一是Thymeleaf模板语法写错,比如
th:text写成了th:class,或者引用了不存在的变量。模板引擎在渲染阶段抛异常,导致整个请求失败。 - 另一个常见原因是模板文件路径拼错。视图名返回的是
hello,但templates/下没有hello.html,或者文件名写成了hello.htm,视图解析器匹配不到文件就会报错。
以前调试这种问题,我习惯在application.properties里临时关掉Thymeleaf缓存:
spring.thymeleaf.cache=false开发阶段关闭缓存,每次修改模板后刷新浏览器就能看到新内容,不用重启服务。这个配置在application.yml中这样写:
spring: thymeleaf: cache: false这个设置仅限开发环境,生产环境务必恢复缓存开启,否则每次请求都重新解析模板,性能会差不少。
5.3 静态资源404,CSS和JS全部加载失败
看到CSS和JS404,先按下面这个顺序排查:
- 文件是否在
src/main/resources/static/下?不是在src/main/resources/templates/下?templates下的文件不会以静态资源方式直接对外提供URL。 - 页面里的引用路径是否用了
th:href、th:src?如果直接在HTML里写死href="/css/style.css",在项目没有context-path时确实能访问,但一旦加了context-path就会失效。用@{}表达式才能在路径变化后自动适配。 - 浏览器F12看Network面板,找到404的资源路径,对照实际文件路径。如果请求的是
/static/css/style.css而文件实际在static/css/style.css,多了一个static前缀,路径就不对了。
说实话这类问题90%是路径问题,10%是文件没编译到target/classes下。后者在IDE里点clean再重新启动就解决了。
5.4 中文乱码问题
模板页面中文乱码通常涉及三个层面:
| 层面 | 配置方式 | 出问题的原因 |
|---|---|---|
| 响应编码 | Spring Boot默认UTF-8 | 一般不会乱 |
| 文件编码 | IDE里HTML文件保存编码 | 如果用了GBK保存自然乱码 |
| 数据库字符集 | JDBC连接参数和数据库本身 | 中文存进去取出来全成问号 |
模板文件本身最容易踩坑。确保IDE右下角显示文件编码是UTF-8,新建文件时有些默认模板是GBK,这在中文Windows上特别普遍。另外HTML文件里的meta声明统一用:
<meta charset="UTF-8">还剩一种情况:请求参数中文乱码,Spring Boot内置的过滤器默认已处理POST表单UTF-8,但如果传的是GET请求中文参数,Tomcat默认的URI编码可能不是UTF-8,在application.properties里加一行配置即可:
server.tomcat.uri-encoding=UTF-8虽然Spring Boot新版本通常已经默认UTF-8,但以防老旧环境或某些部署方式,加上这行配置不算多余。
5.5 模板修改不生效的排查
开发环境修改HTML,刷新浏览器看不到变化。这个问题的根因基本就一个:模板缓存没关。Spring Boot默认spring.thymeleaf.cache=true,第一次渲染后模板内容被缓存,后面文件改了也不会重新加载。
除了关掉缓存,还有一点要注意:浏览器自身的缓存。有时候页面上的CSS和JS更新了,浏览器还在用旧版本,可以开F12勾选Disable cache,或者在URL拼一个版本参数。比如:
<script th:src="@{/js/app.js(v=${version})}"></script>${version}可以在Controller里传一个版本号,发布时改这个值,浏览器就不会再用旧缓存。这是生产环境刷新静态资源缓存的标准做法之一。
另外,IDEA等IDE环境下,如果改了templates目录下的文件没有同步到target/classes,也会出现改了半天没效果的假象。最典型的场景是用Spring Boot插件运行项目,有些版本资源拷贝策略不够及时,需要手动重新build。遇到这种问题,先看target/classes/templates下的文件是否更新了,没更新就build一次,不要浪费时间在无意义的刷新上。
5.6 一个容易被忽略的排序问题:多个视图解析器冲突
某些项目既引入了Thymeleaf,又保留了历史遗留的JSP相关依赖或手动注册了别的视图解析器,这时Spring MVC会同时存在多个ViewResolver。Spring MVC按Order属性排序,Order值小的优先级更高,如果JSP解析器排在前面且返回了"hello",它会尝试去/WEB-INF/views/hello.jsp,找不到就报错,根本不给你Thymeleaf处理的机会。
排查此类问题的方法很简单,看启动日志里打印的viewResolver列表,有很多解析器时检查是不是跟预期不符。Thymeleaf自动配置的解析器Order默认是Integer.MAX_VALUE,排最后,如果别的解析器抢先处理,这边就轮不到。这种历史包袱项目里偶尔碰到,处理手段是删掉多余的老依赖,或者手动指定Thymeleaf解析器的Order值。
6. 页面跳转:转发和重定向怎么选
Controller返回页面除了直接渲染,还有跳转需求。最常见的两个调用是forward和redirect。
forward:请求转发,浏览器URL不变,服务端内部转发到另一个资源,同一个请求内数据通过Model传递,一次请求一次响应。redirect:重定向,服务端返回302,浏览器重新发起新请求到目标地址,URL随之变化,是两次独立请求,通过RedirectAttributes或URL参数带数据。
Controller里写法示例:
// 转发 @GetMapping("/forward-page") public String forwardPage() { return "forward:/page/hello"; } // 重定向 @GetMapping("/redirect-page") public String redirectPage() { return "redirect:/page/hello"; }什么时候用哪个?表单提交之后建议用redirect,避免用户按F5刷新时重复提交表单;页面内部跳转、请求数据大或需要维持请求状态,用forward。这里有个容易踩的坑:redirect后面跟的路径要是浏览器实际访问的路径,也就是Controller映射路径,而不是模板文件名。写成redirect:/templates/hello.html直接就会404。
Thymeleaf页面内的跳转则用th:href:
<a th:href="@{/page/hello}">跳到你好页</a>如果是带参数的跳转:
<a th:href="@{/user/detail(id=${userId})}">查看用户详情</a>Thymeleaf会把这行渲染成/user/detail?id=1,参数值无论数字还是中文字符串都能正确编码。
7. 一个完整的页面渲染示例:用户列表页
把前面所有技术点串起来做一个真实用例,帮你把知识落成完整代码。目标:实现一个用户列表页面,表格展示用户信息,并提供跳转到详情页的链接。
先建UserController.java:
import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import java.util.List; @Controller @RequestMapping("/demo") public class DemoUserController { // 用户详情 @GetMapping("/user/detail") public String userDetail(@RequestParam("id") Long id, Model model) { model.addAttribute("userId", id); model.addAttribute("username", "用户" + id); return "user-detail"; } // 用户列表 @GetMapping("/users") public String userList(Model model) { List<DemoUser> users = List.of( new DemoUser(1L, "张三", "zhangsan@example.com"), new DemoUser(2L, "李四", "lisi@example.com"), new DemoUser(3L, "王五", "wangwu@example.com") ); model.addAttribute("users", users); return "user-list"; } }加一个简单的用户实体类,内部静态类即可:
public class DemoUser { private Long id; private String name; private String email; // 构造函数、getter、setter 省略,实际开发中建议用Lombok或者手写完整getter/setter }模板templates/user-list.html:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户列表</title> <link rel="stylesheet" th:href="@{/css/style.css}"> </head> <body> <h1>用户列表</h1> <table border="1"> <thead> <tr> <th>ID</th> <th>姓名</th> <th>邮箱</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="user : ${users}"> <td th:text="${user.id}"></td> <td th:text="${user.name}"></td> <td th:text="${user.email}"></td> <td><a th:href="@{/demo/user/detail(id=${user.id})}">查看</a></td> </tr> </tbody> </table> </body> </html>模板templates/user-detail.html:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户详情</title> </head> <body> <h1>用户详情</h1> <p>用户ID:<span th:text="${userId}"></span></p> <p>用户名:<span th:text="${username}"></span></p> <a th:href="@{/demo/users}">返回列表</a> </body> </html>跑起来之后整个流程是这样的:访问/demo/users,Controller把用户列表数据塞进Model,Thymeleaf用th:each循环渲染表格,列表每行有个"查看"链接,点击跳转到/demo/user/detail?id=1这样的地址,详情页Controller再接住id参数展示对应内容。这个例子可以当作一个小模板,后续加数据源、加编辑操作都是在这个骨架之上添砖加瓦。
8. 个人经验总结
做了这么长时间的后端开发,我自己的体会是:Spring Boot返回HTML这个需求本身并不复杂,但它能说明两个非常重要的工程习惯。第一个习惯是分清默认行为和定制行为。Spring Boot默认返回JSON、默认用@RestController、默认模板缓存开启,这些都是框架替你选择的默认值;你需要返回HTML时,不是去追着框架问"为什么不行",而是去修改哪几个开关。第二个习惯是搞清楚目录约定。static和templates区分、资源路径用@{}而不是写死相对路径、模板文件编码统一UTF-8,这几点做对了,几乎能消灭九成以上的页面渲染问题。
最后再分享一个小技巧:代码里同时用了@Controller返回页面和@RestController返回JSON接口时,最好按业务模块分文件,比如一个HomePageController专门管理页面路由,一个UserApiController专门管理JSON接口。这样代码结构清爽,其他人接手时一眼就能看出哪个是页面渲染、哪个是接口数据。等Spring Boot项目跑起来、第一个HTML页面被你亲手渲染出来之后,后面再遇到模板引擎的种种幺蛾子,你也会比一开始有底气得多。