
小A给我发来一段代码说他在接口里写了return index浏览器打开的页面上却只显示index这五个字母问我为什么页面没跳转。这个问题我见过太多次了。很多人从写 JSON 接口切到写页面时第一反应都是把RestController换掉、把返回类型调一下结果折腾半天页面要么显示一行字符串要么直接 404。其实 Spring Boot 从 Controller 返回到 HTML 页面关键不在于 return 了什么字符串而在于整套视图解析链路有没有配通Controller 用的是什么注解、项目里挂没挂模板引擎、模板文件放在哪个目录、视图名和文件怎么对应上。这篇文章我会把这条链路从头到尾拆开讲清楚把实际项目里踩过的坑和验证过的写法一并放出来适合刚用 Spring Boot 写页面或者想从纯 API 接口转到服务端渲染的朋友。1. 先搞清楚状况RestController 和 Controller 到底差在哪1.1 为什么 RestController 返回的是字符串而不是页面很多第一次写页面的人都会栽在这里。他们可能刚从 REST API 的项目里抄了一个现成的 Controller觉得把返回值改一改就能出页面结果永远只看到字符串。RestController public class DemoController { GetMapping(/hello) public String hello() { return hello; } }上面这段代码浏览器访问/hello时页面就是一个纯文本的hello没有样式、没有结构、什么模板都不会被解析。原因在于RestController其实是一个组合注解等于Controller加ResponseBody。ResponseBody的意思是你的返回值不是视图名而是直接写进 HTTP 响应体的内容。所以return hello就是字面意义上的字符串hello。这个机制背后是 Spring MVC 的两套返回值处理路线。方法上标了ResponseBody框架会走RequestResponseBodyMethodProcessor把返回值序列化后直接写到响应里完全不会走视图解析。方法上没有ResponseBody框架才会把返回值当作逻辑视图名交给后续的 ViewResolver 去解析。所以当你发现返回了字符串而不是页面时第一件事就去检查类上或者方法上是不是多了一个ResponseBody。1.2 视图解析器的原理从 hello 到 hello.html那我改成Controller以后为什么return hello就能渲染出一个页面这里真正干活的是视图解析器ViewResolver。一次完整请求的流程大概是这样的浏览器请求/helloDispatcherServlet找到对应的hello方法并调用方法往 Model 里放数据返回字符串helloDispatcherServlet把这个字符串当成逻辑视图名交给注册好的 ViewResolver 列表解析器拿着名字去拼模板路径比如 Thymeleaf 的解析器就会拼成classpath:/templates/hello.html找到模板文件后用 Model 里的数据渲染成 HTML再作为响应返回给浏览器。可以这样理解return hello不是把字母 hello端给用户而是告诉框架我要用名为 hello 的那张页面模板。至于这张模板在哪、用什么格式、怎么渲染是 ViewResolver 说了算。所以问题往往不在方法本身而是链路中某个环节断了。1.3 静态页面与模板页面的两种生态Spring Boot 的resources目录下有两个目录很容易搞混static和templates。这两个目录职责完全不同。static目录下放的是静态资源比如图片、CSS、JS也包括那种不需要动态数据的纯 HTML 文件。你放一个index.html到static里访问${端口}/index.html就能直接打开不需要任何 Controller 参与。templates目录则是模板引擎的专属领地里面的 HTML 文件不会直接通过 URL 暴露给外部必须经过 Controller 返回视图名、再交给模板引擎渲染才能对外输出。这是很多新手困惑的地方明明模板文件就在项目里浏览器直接访问却 404就是因为模板不是静态文件而是待渲染的原料。一句话记住static是给浏览器直接用的templates是给模板引擎加工用的。后面遇到页面找不到的问题先看看文件是不是放错了目录。2. 页面方案怎么选模板引擎不只有 Thymeleaf2.1 服务端渲染的几种主流选择Controller 想返回 HTML 页面项目里必须先有一个模板引擎。Spring Boot 生态里常见的选择有 Thymeleaf、FreeMarker、JSP以及干脆不用模板引擎、走纯静态页面加 Ajax 的半前后端分离方案。我先把它们的区别摆出来。方案语法风格打包方式上手难度适合场景Thymeleafth:text、th:each等属性jar / war 都行低新项目默认推荐FreeMarker${xxx}、#if指令jar / war 都行中老项目迁移、邮件模板JSP% %、${xxx}基本得用 war中存量老旧系统纯静态 JS 请求接口前端 fetch / axiosjar中已有一套 API 系统JSP 在 Spring Boot 里能跑但体验比较折腾官方 starter 已经不太往这个方向推打包成 jar 时 JSP 解析本身受限通常需要改成 war 包并在容器里部署除非是维护旧的 war 项目否则新项目我一般不推荐入坑。2.2 我为什么推荐 Thymeleaf以 Spring Boot 官方推荐的力度来看Thymeleaf 是现在服务端渲染页面最省心的选择。它最大的特点是自然模板模板文件本身就是合法且可以在浏览器里直接预览的 HTML。什么意思就是你打开hello.html不经过任何渲染也能看到一个完整样子因为每个动态位置都写着一份静态默认值。比如span th:text${userName}路人/spanThymeleaf 渲染时把路人替换成真实用户名没有渲染时浏览器看到的就是默认值。这个特性对团队协作非常友好——前端可以直接双击模板文件看视觉效果后端也不用等前端把页面抠出来再填数据。相比之下 FreeMarker 的${userName}直接写在文本里不渲染根本没法预览。而且 Thymeleaf 在 Spring Boot 里有完整的自动配置只要把依赖加进 pom几乎不用写任何配置Controller返回字符串就能出页面。2.3 依赖与自动配置加一个 starter 就行在 Spring Boot 工程里引入 Thymeleaf 只需要一个依赖。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency加上这个之后Spring Boot 会自动装配ThymeleafViewResolver默认的模板前缀是classpath:/templates/后缀是.html缓存默认打开。所以一个最小项目里Controller 返回hello它就会去templates/hello.html找文件。开发阶段我建议在application.properties里关掉模板缓存不然每次改模板都要重启服务spring.thymeleaf.cachefalse这个配置只是随手写一行的事作用非常大。缓存默认值是 true生产环境没问题开发时你在html里改了个标题刷新浏览器还是旧页面很多新手会以为是浏览器缓存折腾半天其实就是 Thymeleaf 的模板缓存。3. 手把手落地从请求到 HTML 页面的完整链路3.1 搭一个最小可跑的项目骨架先看目录结构一个标准的页面渲染项目长这样src/main/java/com/example/demo/PageController.java src/main/resources/templates/hello.html src/main/resources/static/css/style.css src/main/resources/application.properties注意PageController.java必须在 Spring Boot 启动类能扫描到的包路径下也就是主类所在包或子包。有人把 Controller 放在一个独立的包里又没写ComponentScan项目启动后请求路径一直 404这是新手常犯的问题。application.properties里至少要有上面说的缓存开关如果有需要也可以显式声明前缀后缀不过我一般不建议手动改默认值足够好用spring.thymeleaf.cachefalse spring.thymeleaf.prefixclasspath:/templates/ spring.thymeleaf.suffix.html3.2 Controller 怎么写Model 数据往哪里放先写一个最简单的 Controller。package com.example.demo; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; Controller public class PageController { GetMapping(/hello) public String hello(RequestParam(name name, defaultValue 路人) String name, Model model) { model.addAttribute(title, 欢迎页); model.addAttribute(userName, name); return hello; } }这里有几个细节值得说。类上的注解是Controller不是RestController这就是上一节讲的第一个大坑的规避方法。方法的返回值是hello不带.html后缀也不带templates/前缀那些由 ViewResolver 负责补全。如果你手滑写了return hello.htmlThymeleaf 会拿着这个名字去拼路径找的是templates/hello.html.html结果必然报模板找不到的错误。方法参数里的Model是专门用来往视图传数据的。model.addAttribute(userName, name)就相当于把一个变量塞进了渲染上下文模板文件里可以通过${userName}取出来。除了Model也可以用ModelAndViewreturn new ModelAndView(hello, modelMap)但日常开发里我更习惯写Model 字符串返回代码更简练也方便后面在同一个方法里处理跳转逻辑。3.3 模板文件怎么接收数据templates/hello.html的完整内容如下!DOCTYPE html html langzh xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title th:text${title}欢迎页/title link relstylesheet th:href{/css/style.css} /head body h1 th:text${title}欢迎页/h1 p你好span th:text${userName}路人/span/p /body /htmlth这个命名空间声明写不写其实不影响渲染但是很多 IDE 需要它才能对 Thymeleaf 属性做补全和校验建议保留。th:text${title}的意思是用表达式title的值替换标签内部的原本文本。所以渲染之后title内容会变成 Controller 里设置的 欢迎页span里的 路人 会被请求参数name替换掉。访问http://localhost:8080/hello?name张三页面上就会显示你好张三。th:href{/css/style.css}是 Thymeleaf 的 URL 表达式它会把网站根路径自动拼到资源地址前面。这样即使项目配置了/xxx这样的 context-path资源路径还是对的。这一点后面讲坑的时候还会再提。3.4 页面之间跳转redirect 与 forwardController 返回字符串除了直接渲染页面还可以做页面之间的跳转。常见的两种写法是redirect:和forward:。PostMapping(/product/save) public String save(ProductForm form) { productService.save(form); // 保存成功后重新发起一个请求跳到列表页 return redirect:/product/list; }redirect:/product/list是让浏览器重新请求/product/list这个地址地址栏会变化本质上是一个全新的 HTTP 请求。forward:/product/list则是服务端内部转发地址栏不变数据模型可以共享。写页面功能时典型场景是表单提交后重定向到列表页——这时候一定要用redirect因为如果直接 forward用户刷新页面可能会重复提交表单。涉及数据的传递可以参考后面 redirect 相关的坑我用RedirectAttributes来解决。4. 最容易踩的几个坑视图解析失败、404、样式丢失4.1 页面内容不对注解和依赖的锅第一种现象是浏览器里显示字符串。这种前面已经讲了根因就是RestController自带了ResponseBody返回值被当作纯文本写进响应体。排查方法很简单先确认类上用的是Controller再看有没有方法级ResponseBody比如把某个接口改成ResponseBody返回 JSON 后忘了删注解整个 Controller 的页面就全乱套了。第二种现象是抛错常见的有Could not resolve view with name hello、Error resolving template、Template might not exist之类。这类报错基本指向同一个根因项目里没有装模板引擎。如果 pom 里只有spring-boot-starter-web没有spring-boot-starter-thymeleafController 返回hello之后 DispatchServlet 不知道该找谁去解析视图就会直接给你 500。加上依赖重启一次问题就消失。有时候报错信息不会直接出现而是页面 404 或者变成空白。我排查时习惯先看一眼响应头的Content-Type——如果返回的是text/html;charsetUTF-8说明视图解析已经走通问题是模板文件本身如果返回application/json说明你走的是ResponseBody路线。4.2 页面找不到目录和请求路径对不上模板找不到的原因通常比较朴素文件放错位置了。我会反复确认的一点是模板文件必须放在src/main/resources/templates/下而不是static/下。放在static里Thymeleaf 解析hello时照样去找templates/hello.html找不到就报Template might not exist。你只是把文件放到了能被浏览器直接访问的地方但模板引擎根本不看那个目录。反过来也容易踩坑模板放在templates/下然后有人直接在浏览器输http://localhost:8080/hello.html去访问结果 404。因为templates目录不是静态资源目录它里面的文件必须经过 Controller 渲染后才对外。URL 跟我上面说的请求路径对应的是GetMapping(/hello)你可以把浏览器地址栏理解成路由的入口而不是磁盘文件的路径。还有一个小细节Linux 服务器上文件路径大小写敏感。模板文件叫Hello.htmlController 返回hello本地 Windows 开发环境跑得好好的一上 Linux 就报模板不存在。排查这种问题我会先ls一下目标目录确认文件名再去看返回的视图名逐字节核对这个名字。4.3 页面渲染了但体验差缓存与样式路径页面能打开了但样式全丢了也常见。我之前带过一个新人link relstylesheet href/css/style.css写得很正常样式文件也确实在static/css/style.css下但就是死活加载不出来。问了一圈才发现他的项目在application.properties里配置了server.servlet.context-path/shop网站根路径变了浏览器访问的其实是/shop/css/style.css而 href 写死的是/css/style.css。正确做法是用 Thymeleaf 的 URL 表达式th:href{/css/style.css}框架会自动把 context-path 拼进去。同样的道理也适用于script、img和a链接只要涉及站点资源路径尽可能用{}包一下。改模板内容不生效这个上面也说过大概率是 Thymeleaf 缓存。开发阶段把spring.thymeleaf.cachefalse打开改完模板刷新浏览器就能看到变化。我曾经因为忘了关缓存在模板里调了半天 CSS以为是浏览器缓存硬是把调试时间翻了好几倍。4.4 数据传不进去redirect 和 model 的坑model.addAttribute(successMsg, 保存成功)之后return hello数据能正常显示。但改成return redirect:/product/list之后模板里th:text${successMsg}就变成空的了。原因是 redirect 会发起一个新的 HTTP 请求原来的 Model 数据不会跟着第二次请求走相当于两个请求之间没有共享内存。想跨 redirect 传数据要用RedirectAttributesPostMapping(/product/save) public String save(ProductForm form, RedirectAttributes ra) { productService.save(form); ra.addFlashAttribute(msg, 保存成功); return redirect:/product/list; }addFlashAttribute的数据会暂时存在 session 里重定向后的页面上能取到一次刷新或再次访问后就消失。这个机制非常适合操作成功提示这类一次性消息。模板里配合th:if判断是否有消息再展示p th:if${msg} th:text${msg}/p注意这里的th:if${msg}意思是变量msg存在且非空时才渲染这个标签。这样没有消息时页面不会留一个空标签占位体验干净很多。5. 进阶用法让页面真正“活”起来5.1 高频 Thymeleaf 语法组合循环、条件、动态属性页面一旦牵扯列表数据th:each就是绝对的主角。比如商品列表页GetMapping(/product/list) public String list(Model model) { ListProduct products productService.findList(); model.addAttribute(products, products); return product/list; }模板里这样写table thead tr th名称/th th价格/th /tr /thead tbody tr th:eachp : ${products} td th:text${p.name}商品名/td td th:text${p.price}价格/td /tr /tbody /tableth:eachp : ${products}会遍历集合每次循环把当前元素绑定到变量p上表体里每一行都会被渲染一遍。配合条件控制也很常见比如没有商品时显示暂无数据可以用th:if和th:unless搭配。tr th:if${#lists.isEmpty(products)} td colspan2暂无商品/td /trth:unless是th:if的反面两者经常成对出现。还有一类动态属性需求比如当前页高亮导航菜单th:classappend可以往 class 后面追加值比在 Java 代码里拼 HTML 字符串干净太多。5.2 公共布局用 fragment 抽头部和底部页面多了以后每个页面都复制一份导航栏和页脚维护起来很痛苦。Thymeleaf 的 fragment 就是解决这个问题的。把公共区域抽到单独文件里。templates/fragments/header.htmlheader th:fragmentsiteHeader h1商城后台/h1 nav a th:href{/product/list}商品列表/a a th:href{/order/list}订单列表/a /nav /header然后在任意页面里引用body div th:replace~{fragments/header :: siteHeader}/div !-- 页面其他内容 -- /body~{fragments/header :: siteHeader}的意思是去fragments/header.html里找名为siteHeader的 fragment替换当前标签。改一次公共头所有页面同步更新这种收益用得非常明显。fragment 不只能做整块替换还能用来做局部返回比如某个接口只返回页面里的一段 HTML给走 Ajax 的前端用Controller 里return product/list :: table就行这招在列表局部刷新时很好使。5.3 错误页与收尾技巧Spring Boot 对错误页有约定在templates/error/下放404.html、500.html框架会自动在对应状态码出现时渲染它们。这是一个性价比很高的细节花十分钟做一个 404 页面比用户看到白屏或者一堆异常日志要舒服得多。写自定义错误页时模板里可以用${status}、${error}、${message}这些自动提供的变量。最后分享一个我自己常用的调试思路先用简单页面跑通链路再往里面加数据、加样式。遇到问题时不要一头扎进配置文件先确认请求有没有进 Controller——在方法第一行加个日志看DispatcherServlet有没有调到方法上。请求没进来问题在网络路径或类扫描请求进来了但页面出错问题在视图解析这半段。按这个顺序排查绝大多数页面渲染问题都能在十分钟内定位。我在这条道路上被RestController、templates目录放错、缓存不刷新这几个坑轮流绊过后来形成了一套固定节奏新页面先建一个空模板确认能渲染再逐步填充内容。Spring Boot 从 Controller 返回 HTML 页面说难并不难背后的原理归根到底就是视图名 解析器 模板文件三者的约定把这条约定想透很多问题根本不用查资料就能猜到原因。