SpringBoot中JSP与Thymeleaf双模板引擎共存实战与避坑指南
1. 为什么现在还要在 SpringBoot 里折腾 JSP 和 Thymeleaf如果你最近几年新开的项目大概率是前后端分离Vue 或 React 一把梭后端只吐 JSON。但现实里有一类场景始终绕不开服务端渲染老系统迁移、内部管理后台、需要 SEO 的展示型站点或者团队里前端资源紧张后端顺手把页面也写了。这时候 SpringBoot 官方主推的 Thymeleaf 就成了首选而 JSP 则更多出现在祖传代码的维护现场。我最近接手的一个内部报表系统就是典型混合场景主体页面用 Thymeleaf 重写但有几个历史遗留的 JSP 页面因为依赖了老标签库短期内没法全部替换只能让两套模板引擎在同一个应用里共存。这个过程中踩的坑比想象中多——JSP 在 SpringBoot 内嵌容器下的路径问题、Thymeleaf 的缓存导致改页面不生效、两套引擎的视图解析器优先级冲突每一个都够排查半天。这篇内容就是把这套双模板引擎共存 Thymeleaf 实战的完整过程拆开讲清楚。适合正在做老系统改造的后端、需要快速搭管理后台的全栈以及想搞明白 Thymeleaf 到底比 JSP 强在哪的开发者。我会从依赖配置讲到视图解析器的优先级控制再到 Thymeleaf 的常用语法和那些文档里不会写的坑尽量让你看完就能直接抄作业。2. 依赖与目录结构JSP 和 Thymeleaf 共存的物理基础2.1 两套引擎的依赖到底该怎么引先说结论SpringBoot 对 JSP 的支持是能用但不推荐对 Thymeleaf 是亲儿子级支持。这个定位差异直接体现在依赖配置上。Thymeleaf 只需要一个 starterSpringBoot 会自动完成视图解析器的装配dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependencyJSP 就麻烦一些因为 SpringBoot 内嵌的 Tomcat 默认不支持 JSP 编译需要额外引入 Jasper 引擎。这里有个关键点必须用 provided 作用域否则打包成可执行 jar 时会和容器内置的类冲突。dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId /dependency我见过有人把tomcat-embed-jasper的 scope 写成默认的 compile本地跑没问题一打成 jar 部署就报NoClassDefFoundError排查半天才发现是类加载冲突。这个坑记住就行。另外如果你用的是 SpringBoot 3.x注意javax.servlet已经换成了jakarta.servletJSTL 的坐标也要跟着换成jakarta.servlet.jsp.jstl那套否则编译直接过不去。这是版本升级时最容易忽略的地方。2.2 目录结构JSP 必须放在 webapp 下这是 JSP 在 SpringBoot 里最反直觉的一点。Thymeleaf 的模板默认放在src/main/resources/templates下跟着 classpath 走打成 jar 也能正常读取。但 JSP 不一样它需要被 Servlet 容器编译必须放在src/main/webapp目录下而且这个目录不在 classpath 里。标准的结构长这样src/main ├── java │ └── com/example/demo │ └── controller ├── resources │ ├── templates - Thymeleaf 模板 │ │ └── index.html │ ├── static - 静态资源 │ └── application.yml └── webapp - JSP 页面 └── WEB-INF └── jsp └── legacy.jsp这里有个细节JSP 放在WEB-INF下面是为了防止被直接通过 URL 访问只能通过控制器转发。如果你把 JSP 直接放在 webapp 根目录用户输入/legacy.jsp就能绕过控制器这在安全上是不允许的。还有一个更隐蔽的坑用 Maven 打包成 jar 时webapp 目录默认不会被打进去。因为 jar 包规范里没有 webapp 这个概念。解决办法是要么打成 war 包要么在 pom 里配置资源拷贝。我一般建议涉及 JSP 的项目直接打成 war省心。packagingwar/packaging打成 war 后spring-boot-starter-tomcat也要改成 provided因为 war 包要部署到外部容器或者用内嵌容器但以 war 方式启动。2.3 配置文件里的路径约定application.yml里需要把两套引擎的路径都配好spring: mvc: view: prefix: /WEB-INF/jsp/ suffix: .jsp thymeleaf: prefix: classpath:/templates/ suffix: .html cache: false mode: HTML encoding: UTF-8注意spring.mvc.view这组配置是给 JSP 用的它对应的是InternalResourceViewResolver。而 Thymeleaf 有自己的ThymeleafViewResolver两者互不干扰但默认情况下 Thymeleaf 的优先级更高order 值更小。这就引出了下一节要讲的视图解析器冲突问题。cache: false这个配置在开发阶段一定要加上。Thymeleaf 默认会缓存模板你改了 HTML 刷新页面发现没变化八成就是缓存没关。生产环境再打开缓存提升性能。3. 视图解析器的优先级两套引擎如何各管各的3.1 默认行为下的抢活现象当你的项目里同时存在 Thymeleaf 和 JSP 的依赖时SpringBoot 会自动装配两个视图解析器。问题来了控制器返回一个视图名legacy到底由谁来解析默认情况下ThymeleafViewResolver的 order 是Ordered.LOWEST_PRECEDENCE - 5而InternalResourceViewResolverJSP 用的的 order 是Ordered.LOWEST_PRECEDENCE。数字越小优先级越高所以Thymeleaf 会先尝试解析。Thymeleaf 解析的逻辑是去classpath:/templates/下找legacy.html。如果找不到它会返回 null然后交给下一个解析器。听起来没问题对吧但实际踩坑点在于如果 templates 目录下恰好有个同名文件JSP 就永远轮不到了。我就遇到过templates/legacy.html和WEB-INF/jsp/legacy.jsp同名结果一直渲染的是 HTML排查了半天才发现是优先级问题。3.2 用视图名后缀显式区分最稳妥的方案不是去调 order而是用不同的视图名前缀或后缀来区分。比如约定 JSP 页面统一加jsp/前缀Controller public class PageController { GetMapping(/report) public String report() { return report; // 走 Thymeleaf找 templates/report.html } GetMapping(/legacy) public String legacy() { return jsp/legacy; // 走 JSP找 /WEB-INF/jsp/legacy.jsp } }但这里有个问题Thymeleaf 的 prefix 是classpath:/templates/它会去找templates/jsp/legacy.html找不到返回 null然后 JSP 解析器用 prefix/WEB-INF/jsp/拼出/WEB-INF/jsp/jsp/legacy.jsp路径就错了。所以更干净的做法是自定义一个视图解析器专门处理 JSP并给它更高的优先级Configuration public class ViewResolverConfig { Bean public InternalResourceViewResolver jspViewResolver() { InternalResourceViewResolver resolver new InternalResourceViewResolver(); resolver.setPrefix(/WEB-INF/jsp/); resolver.setSuffix(.jsp); resolver.setOrder(0); // 优先级最高 resolver.setViewNames(jsp/*); // 只处理 jsp/ 开头的视图名 return resolver; } }关键在setViewNames(jsp/*)它限定了这个解析器只处理以jsp/开头的视图名。控制器返回jsp/legacy时解析器会去掉前缀匹配部分拼成/WEB-INF/jsp/legacy.jsp。而返回report时这个解析器直接跳过交给 Thymeleaf。3.3 优先级配置的验证方法配完之后怎么确认生效了我一般用两个手段验证。第一在控制器里打日志看返回的视图名是什么。第二故意把 JSP 文件删掉如果访问时报的是 JSP 找不到而不是 Thymeleaf 找不到说明路由正确。还有一种更直观的方式给两个解析器都加上日志级别。Thymeleaf 在 DEBUG 级别会打印尝试解析视图 XJSP 解析器虽然日志少但可以通过断点确认。实测下来setViewNames这个方案是最稳的比单纯调 order 可靠得多因为它从谁能处理这个维度做了硬隔离而不是靠谁先试。提示如果你不想写 Java 配置也可以在 yml 里通过spring.mvc.view.prefix配合视图名约定来实现但灵活度差很多遇到复杂场景还是得回到 Java 配置。4. Thymeleaf 核心语法从取值到布局的实战用法4.1 表达式${}、*{}、#{}到底啥区别Thymeleaf 的表达式体系是它的灵魂但初学者最容易搞混几种写法。${...}是变量表达式从模型里取值最常用p th:text${user.name}默认名字/p*{...}是选择表达式配合th:object使用取值时省略对象名div th:object${user} p th:text*{name}名字/p p th:text*{age}年龄/p /div#{...}是消息表达式用于国际化从messages.properties里取p th:text#{welcome.message}欢迎/p{...}是链接表达式专门处理 URL它会自动加上应用上下文路径a th:href{/user/detail(id${user.id})}详情/a最后这个{...}特别值得说。很多人习惯直接写href/user/detail?id1在本地跑没问题一旦应用部署在非根路径下比如/app链接就全挂了。用{...}它会自动拼成/app/user/detail?id1这是生产环境必须养成的习惯。4.2 条件与循环th:if和th:each的细节条件判断用th:if它的真假判断规则和 JavaScript 类似null、空字符串、空集合、数字 0 都算 false。div th:if${user ! null} p th:text${user.name}/p /div div th:unless${user ! null} p用户不存在/p /divth:unless就是th:if的反义省得你写!。循环用th:each它有个很好用的状态变量tr th:eachuser, stat : ${userList} td th:text${stat.index}/td !-- 从 0 开始 -- td th:text${stat.count}/td !-- 从 1 开始 -- td th:text${user.name}/td td th:text${stat.first}/td !-- 是否第一条 -- td th:text${stat.last}/td !-- 是否最后一条 -- /trstat这个变量名可以自定义不写的话默认叫变量名Stat。我强烈建议显式写出来因为默认名在复杂嵌套循环里很容易搞混。stat.index和stat.count的区别是新手最容易踩的前者从 0 开始后者从 1 开始做序号展示时用count做数组下标时用index。4.3 片段与布局th:fragment和th:replaceThymeleaf 的布局能力是它比 JSP 优雅的核心原因。JSP 时代我们用jsp:include或者 Tiles配置繁琐。Thymeleaf 用片段fragment就能搞定。定义一个公共头部!-- templates/fragments/header.html -- header th:fragmentheader nav a th:href{/}首页/a a th:href{/about}关于/a /nav /header在页面里引用div th:replace~{fragments/header :: header}/divth:replace和th:insert的区别要记牢replace是用片段整体替换掉当前标签insert是把片段插入到当前标签内部。做布局时通常用replace因为不想多一层无意义的 div。更完整的布局方案是用th:fragment定义一个带参数的骨架!-- templates/layout.html -- html th:fragmentlayout(title, content) head title th:text${title}默认标题/title /head body div th:replace~{fragments/header :: header}/div main th:replace${content}/main div th:replace~{fragments/footer :: footer}/div /body /html子页面这样用html th:replace~{layout :: layout(用户列表, ~{::content})} body div th:fragmentcontent table.../table /div /body /html这套写法一开始会觉得绕但用熟了之后改一个布局文件就能影响所有页面比 JSP 的 include 灵活太多。我建议新项目直接上这套别再用 include 拼页面了。5. 那些文档里不写、但一定会踩的坑5.1 Thymeleaf 缓存导致改页面不生效这个坑我敢说每个用 Thymeleaf 的人都踩过。开发阶段改了 HTML刷新浏览器发现还是旧内容重启应用才生效。原因就是 Thymeleaf 默认开启模板缓存。解决方式是在开发环境的配置里关掉spring: thymeleaf: cache: false但要注意生产环境一定要把缓存打开否则每次请求都要重新解析模板文件性能损耗很明显。我一般的做法是用 profile 区分# application-dev.yml spring: thymeleaf: cache: false # application-prod.yml spring: thymeleaf: cache: true还有一个更隐蔽的情况如果你用了 IDE 的热部署但模板缓存没关热部署只重启了 Java 类模板还是缓存的旧版本。所以关缓存是开发阶段的第一要务。5.2 JSP 在 jar 包里找不到的根因前面提过 JSP 要打成 war但很多人不理解为什么。根本原因是JSP 需要被 Servlet 容器编译成 Servlet 类这个编译过程依赖文件系统上的真实路径。而 jar 包是一个压缩文件容器没法直接从里面读取 JSP 源文件进行编译。打成 war 后容器会先解压 warJSP 文件落在文件系统上编译才能正常进行。如果你非要用 jar有一种取巧的办法是把 JSP 预编译成 class但这需要额外的构建插件配置复杂且容易出错不推荐。所以结论很简单涉及 JSP 就用 war别跟 jar 较劲。这也是为什么新项目不建议用 JSP 的原因之一它和 SpringBoot 的 jar 部署理念天生不合。5.3 静态资源被 Thymeleaf 拦截有时候你会发现 CSS、JS 加载不出来浏览器报 404。这通常是因为静态资源的路径没配对。SpringBoot 默认把src/main/resources/static下的文件映射到根路径。比如static/css/main.css对应/css/main.css。在 Thymeleaf 里引用时要用{...}link relstylesheet th:href{/css/main.css}如果你直接写hrefcss/main.css相对路径在多层路由下就会出错。比如访问/user/detail/1时相对路径会解析成/user/detail/css/main.css自然 404。还有一种情况是自定义了WebMvcConfigurer的静态资源映射把默认规则覆盖了。这时候要检查addResourceHandlers里有没有把/css/**、/js/**这些路径加回去。5.4 表单提交的中文乱码JSP 和 Thymeleaf 都可能遇到中文乱码但原因不同。JSP 的乱码通常出在页面编码声明上需要在 JSP 顶部加% page contentTypetext/html;charsetUTF-8 languagejava %Thymeleaf 的乱码更多是请求编码问题。SpringBoot 默认的CharacterEncodingFilter是 UTF-8一般不用管。但如果你手动配了过滤器或者用了老版本的 SpringBoot可能需要显式配置server: servlet: encoding: charset: UTF-8 force: trueforce: true这个配置很关键它强制请求和响应都用 UTF-8能解决大部分乱码问题。我遇到过 POST 表单中文变问号的情况加上这个配置就好了。6. 从 JSP 迁移到 Thymeleaf 的实操路径6.1 迁移前先做页面盘点别一上来就动手改代码。先花半天时间把所有 JSP 页面列出来按复杂度分类页面类型特征迁移难度建议策略纯展示页只有静态内容和简单取值低直接重写列表页有循环、分页中重写 复用分页片段表单页有表单绑定、校验中高重写 注意字段名映射复杂交互页大量 JS 拼接、标签库高保留 JSP 或前后端分离这个盘点决定了你的迁移顺序。我一般建议从纯展示页开始快速积累 Thymeleaf 的写法经验再啃硬骨头。6.2 标签库的等价替换JSP 里最常用的 JSTL 标签在 Thymeleaf 里都有对应写法JSTL 写法Thymeleaf 写法c:out value${x}/th:text${x}c:if test${x}th:if${x}c:forEach items${list} varith:eachi : ${list}c:url value/path/th:href{/path}fmt:formatDate value${d}/${#temporals.format(d,yyyy-MM-dd)}注意最后一行日期格式化在 Thymeleaf 里用的是#temporals工具对象而且它只对 Java 8 的LocalDate、LocalDateTime生效。如果你模型里放的是老的java.util.Date得用#dates工具对象。这个区别在迁移时特别容易搞错因为 JSP 的fmt:formatDate对两种类型都支持。6.3 迁移后的回归验证清单页面改完之后别急着上线。我整理了一份必查清单所有链接是否用了{...}部署到子路径下是否正常表单提交后中文是否乱码列表为空时是否有兜底展示th:if判断日期格式是否和原来一致分页组件的页码链接是否正确静态资源是否 404页面标题是否动态设置这份清单看着简单但每一条我都真实踩过。尤其是第一条本地开发在根路径下永远发现不了问题一部署到测试环境的子路径就全挂了。7. 我个人的一些使用体会Thymeleaf 最让我舒服的一点是它可以直接用浏览器打开。因为模板本身就是合法的 HTML你双击templates/index.html就能看到静态效果不需要启动应用。这对调样式特别友好前端同学可以独立改页面不用等后端环境。JSP 就做不到这一点它必须经过容器编译。另一个体会是关于性能的。Thymeleaf 3.x 之后性能提升明显配合缓存渲染速度完全够用。但如果你的页面有大量循环比如一次渲染上千行表格还是要注意th:each的开销比想象中大。这种场景我一般会做分页或者干脆改成前端渲染。最后说个选型建议新项目如果要做服务端渲染直接上 Thymeleaf别碰 JSP。JSP 在 SpringBoot 体系里就是个能用但别扭的存在路径问题、打包问题、优先级问题每一个都在消耗你的时间。只有当你要维护老系统、短期内没法全部重写时才值得让两者共存。共存的核心就是那套setViewNames的隔离方案配好之后各管各的互不干扰。