news 2026/10/14 2:09:22

SpringBoot双模板引擎实战:Jsp与Thymeleaf共存方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot双模板引擎实战:Jsp与Thymeleaf共存方案

1. 项目缘起与整体设计思路

1.1 为什么要在 SpringBoot 里同时集成 Jsp 和 Thymeleaf

先把结论摆在前面:SpringBoot 官方对 Jsp 的态度一直很微妙。它内置的 Tomcat 对 Jsp 的支持需要额外引入tomcat-embed-jasper,而且打成可执行 Jar 包后 Jsp 的解析会出问题,必须打成 War 包才能正常跑。但现实项目里,尤其是从传统 SSM 迁移过来的老系统,Jsp 页面存量巨大,不可能一夜之间全部重写。与此同时,新开发的页面又希望用 Thymeleaf 这种“天然 HTML”的模板引擎,前后端协作更顺滑,还能直接双击打开预览。

所以“Jsp + Thymeleaf 双模板引擎共存”这个需求,本质上是新老交替期的过渡方案。它的价值在于:老页面不动,新页面用新引擎,两套视图解析器各管各的,互不干扰。适合谁参考?适合正在做老系统改造、又不想大动干戈的团队,也适合想搞清楚 SpringBoot 视图解析底层机制的同学。

我先把整体思路讲清楚。SpringBoot 的视图解析走的是ViewResolver接口,DispatcherServlet拿到 Controller 返回的逻辑视图名后,会遍历所有注册的ViewResolver,谁先能解析出结果就用谁。默认情况下 Thymeleaf 的解析器优先级是Ordered.LOWEST_PRECEDENCE,也就是最低。如果我们不做任何干预,Jsp 的InternalResourceViewResolver会先被调用,因为它默认优先级更高。这就带来一个坑:Jsp 解析器有个“兜底”特性——它几乎对任何视图名都返回一个非 null 的 View 对象,导致 Thymeleaf 永远轮不到。所以核心设计点就是控制两个解析器的优先级和视图名前缀,让它们各认各的路径。

我的方案是:Jsp 页面统一放在/WEB-INF/jsp/下,Thymeleaf 页面统一放在classpath:/templates/下,通过视图名前缀区分。同时把 Thymeleaf 解析器的 order 设为比 Jsp 更高(数值更小),让它先尝试解析,解析不到再交给 Jsp。这样新页面走 Thymeleaf,老页面走 Jsp,逻辑清晰。

1.2 双引擎共存的取舍与代价

任何方案都有代价,这里必须说清楚。第一,打包方式被迫改成 War。因为 Jsp 在可执行 Jar 里无法被内嵌 Tomcat 正确编译,这是硬性限制。改成 War 后,启动方式从java -jar变成丢进外部 Tomcat,或者用spring-boot-maven-plugin的repackage生成可执行 War。第二,Thymeleaf 的热更新优势在 Jsp 面前会被稀释,因为 Jsp 修改后需要重新编译,开发时如果频繁改老页面,体验会倒退。第三,依赖体积变大,多引入 jasper 和 jstl。

那为什么还要这么做?因为迁移成本。全量重写 Jsp 到 Thymeleaf 的工时,往往比维护双引擎的复杂度高一个数量级。我的经验是:过渡期控制在 3 到 6 个月,期间新功能一律用 Thymeleaf,老页面只做必要维护,逐步替换。超过这个周期,双引擎的维护成本就会反噬。

提示:如果你的项目是全新项目,没有任何 Jsp 存量,那就别折腾双引擎,直接用 Thymeleaf 或前后端分离,省心得多。双引擎是给“历史包袱”准备的方案。

2. 环境搭建与依赖配置的实操细节

2.1 依赖引入的完整清单与版本考量

先看pom.xml的关键部分。这里我用的是 SpringBoot 2.7.x 这条线,因为它对 Jsp 的支持相对成熟,网上资料也多。3.x 之后 Jakarta EE 命名空间变了,Jsp 相关依赖的 groupId 从javax变成jakarta,踩坑的人不少,新手建议先从 2.7 入手。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <!-- Web 基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf 模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- Jsp 支持:内嵌 Tomcat 的 Jasper 编译器 --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <scope>provided</scope> </dependency> <!-- JSTL 标签库 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies> <packaging>war</packaging>

这里有几个点必须解释清楚。tomcat-embed-jasper的 scope 设为provided,是因为运行时容器(外部 Tomcat)会自带 Jasper,如果打进 War 包反而可能冲突。但如果你用内嵌 Tomcat 跑 War,就得把它改成默认 scope。jstl的版本 1.2 是经典稳定版,配合javax.servlet命名空间。如果你用 SpringBoot 3.x,这里要换成jakarta.servlet:jakarta.servlet-api和对应的 JSTL 实现。

packaging必须是war,这是 Jsp 能跑起来的前提。很多人卡在这一步,Jar 包跑起来访问 Jsp 直接 404 或者报JasperException,就是因为打包方式不对。

2.2 目录结构的约定与视图解析器配置

目录结构我建议这样组织,清晰且符合两套引擎的默认约定:

src/main/ ├── java/ │ └── com/example/demo/ │ ├── DemoApplication.java │ └── controller/ │ ├── JspController.java │ └── ThymeleafController.java ├── resources/ │ ├── application.yml │ └── templates/ # Thymeleaf 页面 │ └── hello.html └── webapp/ └── WEB-INF/ └── jsp/ # Jsp 页面 └── legacy.jsp

注意webapp目录在 SpringBoot 里不是默认创建的,需要手动建,并且要确保 Maven 的war插件能识别它。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.prefix配置的是 Jsp 解析器的前缀,spring.thymeleaf.prefix是 Thymeleaf 的。两个前缀不同,这是区分的关键。cache: false在开发期一定要开,否则改了 HTML 不刷新,你会怀疑人生。生产环境记得改回true。

但光靠 yml 还不够,因为前面说的优先级问题。我们需要一个配置类来手动调整 Thymeleaf 解析器的 order:

@Configuration public class ViewResolverConfig { @Bean public ThymeleafViewResolver thymeleafViewResolver( SpringTemplateEngine templateEngine) { ThymeleafViewResolver resolver = new ThymeleafViewResolver(); resolver.setTemplateEngine(templateEngine); resolver.setCharacterEncoding("UTF-8"); // 关键:设为 0,比 Jsp 解析器优先级更高 resolver.setOrder(0); // 只解析以 "th:" 开头的视图名,避免误抢 Jsp resolver.setViewNames(new String[]{"th:*"}); return resolver; } }

这个viewNames是精髓。设置成th:*后,只有 Controller 返回th:hello这样的视图名,Thymeleaf 才会接管;返回legacy这种不带前缀的,就交给 Jsp。这样两套引擎彻底解耦,不会互相抢活。我试过不加viewNames只调 order,结果 Thymeleaf 解析不到时会抛异常而不是优雅地交给下一个解析器,所以viewNames是必须的。

注意:setOrder(0)的数值越小优先级越高。Jsp 的InternalResourceViewResolver默认 order 是Ordered.LOWEST_PRECEDENCE,也就是Integer.MAX_VALUE,所以 0 一定比它先执行。

3. 两套引擎的编码实操与核心环节

3.1 Jsp 页面的编写与 Controller 对接

先写一个老派的 Jsp 页面,放在src/main/webapp/WEB-INF/jsp/legacy.jsp:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>老页面</title> </head> <body> <h1>这是 Jsp 渲染的页面</h1> <p>用户名:${userName}</p> <c:if test="${not empty items}"> <ul> <c:forEach items="${items}" var="item"> <li>${item}</li> </c:forEach> </ul> </c:if> </body> </html>

对应的 Controller:

@Controller public class JspController { @GetMapping("/legacy") public String legacy(Model model) { model.addAttribute("userName", "某开发者"); model.addAttribute("items", Arrays.asList("条目A", "条目B", "条目C")); // 不带前缀,交给 Jsp 解析器 return "legacy"; } }

这里返回"legacy",Jsp 解析器会拼成/WEB-INF/jsp/legacy.jsp。注意WEB-INF下的页面无法通过浏览器直接访问,必须走 Controller 转发,这是安全设计,防止用户绕过权限直接看页面。

3.2 Thymeleaf 页面的编写与常用语法实战

Thymeleaf 页面放在src/main/resources/templates/hello.html。它的核心魅力是“模板即原型”,直接用浏览器打开也能看到大致效果,因为th:*属性在浏览器里会被忽略。

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Thymeleaf 页面</title> </head> <body> <h1 th:text="${title}">默认标题</h1> <p th:text="'欢迎,' + ${userName} + '!'">欢迎,访客!</p> <!-- 条件判断 --> <div th:if="${age >= 18}">已成年</div> <div th:unless="${age >= 18}">未成年</div> <!-- 循环 --> <ul> <li th:each="item, stat : ${items}" th:text="${stat.index + 1} + '. ' + ${item}"> 占位内容 </li> </ul> <!-- 链接 --> <a th:href="@{/legacy}">跳转到老页面</a> <!-- 表单 --> <form th:action="@{/submit}" method="post"> <input type="text" name="name" th:value="${userName}" /> <button type="submit">提交</button> </form> </body> </html>

Controller 这边:

@Controller public class ThymeleafController { @GetMapping("/hello") public String hello(Model model) { model.addAttribute("title", "Thymeleaf 演示"); model.addAttribute("userName", "A同学"); model.addAttribute("age", 20); model.addAttribute("items", Arrays.asList("苹果", "香蕉", "橙子")); // 带 th: 前缀,交给 Thymeleaf return "th:hello"; } }

注意返回的是"th:hello",配合前面viewNames的th:*,Thymeleaf 解析器会把它解析成classpath:/templates/hello.html。这个前缀是我自己定的约定,你也可以用别的,只要和viewNames匹配就行。

3.3 Thymeleaf 高频语法逐个拆解

Thymeleaf 的语法看着多,实际常用的就那几个,我按使用频率排一下。

th:text和th:utext:前者会转义 HTML,后者不转义。安全起见,用户输入的内容一律用th:text,防止 XSS。只有你完全信任的内容才用th:utext。

th:each:循环,支持状态变量stat,常用属性有index(从 0 开始)、count(从 1 开始)、first、last、odd、even。做斑马纹表格时stat.odd特别好用。

th:if/th:unless/th:switch:条件渲染。th:switch配合th:case做多分支,比一堆th:if清爽。

th:href/th:src/th:action:URL 处理,用@{...}语法。它的好处是会自动加上应用上下文路径,部署到不同 contextPath 下不用改代码。传参用@{/user(id=${userId})}。

th:object和th:field:表单绑定,配合 SpringMVC 的@ModelAttribute使用,能自动回显和显示校验错误。这是 Thymeleaf 相比 Jsp 的一大优势。

th:fragment和th:replace:页面片段复用。把页头、页脚抽成 fragment,其他页面引用,改一处全站生效。这个在 Jsp 里要用<jsp:include>,Thymeleaf 的写法更灵活。

<!-- 定义片段 --> <footer th:fragment="footer"> <p>版权所有</p> </footer> <!-- 引用片段 --> <div th:replace="~{fragments/common :: footer}"></div>

实操心得:th:replace和th:insert的区别经常有人搞混。th:replace是用片段整体替换掉当前标签,th:insert是把片段插入到当前标签内部。做布局时我一般用th:replace,语义更干净。

4. 常见问题排查与避坑经验实录

4.1 双引擎冲突的典型症状与解决

症状一:访问 Thymeleaf 页面报 404,日志显示去找 Jsp 了。这是最常见的。原因就是 Thymeleaf 解析器没生效,或者viewNames没配。排查步骤:先看ThymeleafViewResolver的 order 是不是比 Jsp 小;再看viewNames是否匹配你返回的视图名;最后确认spring-boot-starter-thymeleaf依赖真的引入了。我踩过一次,是因为自己 new 了ThymeleafViewResolver但没注入SpringTemplateEngine,导致解析器空转。

症状二:Jsp 页面报JasperException: The absolute uri cannot be resolved。这是 JSTL 标签库没引入,或者jstl依赖的 scope 不对。检查pom.xml里jstl是不是compile或runtime,别设成provided。

症状三:打成 Jar 包后 Jsp 全部 500。前面说过,Jsp 必须 War 包。如果你非要用 Jar,可以试试把 Jsp 预编译成 class,但那样就失去 Jsp 的意义了,不推荐。

症状四:Thymeleaf 页面改了不生效。检查spring.thymeleaf.cache是不是true。开发期设false,生产设true。另外 IDEA 里有时候需要Build -> Rebuild Project才刷新,这是 IDE 的锅,不是框架的。

4.2 常见问题速查表

问题现象可能原因排查方向解决方案
Thymeleaf 404解析器优先级低看 order 值设 order=0,配 viewNames
Jsp 404打包成 Jar看 packaging改成 war
JSTL 标签不识别依赖缺失看 pom引入 jstl 1.2
页面乱码编码不一致看 yml 和 page 指令统一 UTF-8
静态资源 404路径不对看引用方式用@{/...}
表单不回显没用 th:field看表单写法配合 th:object

4.3 独家避坑技巧

第一个技巧:开发期用两套 profile。application-dev.yml里 Thymeleaf cache 设 false,Jsp 用内嵌 Tomcat 跑;application-prod.yml里 cache 设 true,打 War 丢外部 Tomcat。这样开发和生产的差异被配置隔离,不会互相干扰。

第二个技巧:给 Jsp 解析器也加 viewNames。虽然 Jsp 解析器默认兜底,但你可以通过自定义InternalResourceViewResolver并设置viewNames为jsp:*,让两套引擎的边界更清晰。这样 Controller 返回jsp:legacy走 Jsp,返回th:hello走 Thymeleaf,一眼就能看出用的是哪个引擎,维护时不容易懵。

第三个技巧:Thymeleaf 的th:with可以定义局部变量,减少重复表达式。比如th:with="fullName=${firstName + lastName}",后面直接用fullName。在复杂页面里能显著提升可读性。

第四个技巧:Jsp 里的${}和 Thymeleaf 的${}语法相似但行为不同。Jsp 的 EL 表达式在页面编译时求值,Thymeleaf 在渲染时求值。迁移页面时别直接复制粘贴,容易出隐性 bug。我一般会先跑一遍对比渲染结果。

5. 从双引擎到单引擎的迁移路径建议

5.1 渐进式迁移的节奏把控

双引擎只是手段,不是目的。最终目标还是收敛到单引擎。我的建议是分三步走。第一步,新功能全部用 Thymeleaf,Jsp 只做 bug 修复。第二步,按业务模块逐个迁移老 Jsp,每迁完一个模块就删掉对应的 Jsp 文件,同时把 Controller 的返回值加上th:前缀。第三步,当 Jsp 文件清零后,移除 Jsp 相关依赖和配置,打包方式改回 Jar。

迁移顺序上,优先迁访问量低、逻辑简单的页面,比如错误页、帮助页。这些页面风险小,能快速积累迁移经验。核心业务页面放后面,等团队对 Thymeleaf 熟练了再动。

5.2 迁移过程中的兼容性处理

迁移时最大的坑是Jsp 自定义标签。很多老项目用了公司内部封装的 taglib,Thymeleaf 没有直接对应物。这时候有两个选择:一是用 Thymeleaf 的th:replace模拟,把标签逻辑改写成 fragment;二是保留这部分页面继续用 Jsp,等标签库也重构了再迁。我倾向于后者,别为了迁移而迁移,稳定优先。

另一个坑是页面间的 include 关系。Jsp 的<jsp:include>是运行时包含,Thymeleaf 的 fragment 是模板解析时包含,行为有差异。如果被包含的页面依赖父页面的变量,迁移时要显式传参,不能靠隐式作用域。

提示:迁移前先做一次全量页面清单,标注每个页面的依赖、访问量、复杂度,排个优先级。别上来就闷头改,容易改到一半发现依赖太深,进退两难。

5.3 迁移后的验证清单

每迁完一个页面,按这个清单过一遍:页面渲染是否正常、表单提交是否正常、参数回显是否正常、国际化是否正常、静态资源加载是否正常、权限拦截是否正常。这六项都过了,才算迁移完成。我见过太多人只看了页面能打开就宣布迁移成功,结果表单提交 400,回滚又得重来。

6. 一些个人体会与后续扩展方向

说实话,双模板引擎这套方案,用起来是有点“缝合”的味道,但它确实解决了很多团队的实际问题。我在几个老系统改造里用过这个模式,最大的感受是:别追求技术上的纯粹,要追求业务上的平稳。老系统能跑、新功能能加、团队能接受,比什么架构洁癖都重要。

如果后续要扩展,有几个方向可以考虑。一是把 Thymeleaf 的 fragment 做成组件库,统一页头页脚、表格、分页这些常用块,新页面开发效率会高很多。二是引入 Thymeleaf 的布局方言(Layout Dialect),做母版页继承,比手动 replace 更优雅。三是如果团队前后端分离意愿强,可以逐步把 Thymeleaf 也替换成纯静态 HTML + 接口,那时候模板引擎就彻底退出了。

最后分享一个小技巧:调试 Thymeleaf 时,在application.yml里加logging.level.org.thymeleaf=DEBUG,能看到模板解析的详细过程,定位问题比瞎猜快得多。Jsp 那边则是看 Tomcat 的localhost.log,编译错误都在里面。这两个日志开关,能帮你省下大量排查时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 2:07:43

Windows搭建Nginx流媒体服务器:RTMP与HLS直播配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 2:07:13

CDH 5.15.1离线部署完整指南:从介质制作到集群稳定运行

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华