☰
SpringBoot+Vue打war部署Tomcat与history路由避坑
2026/11/3 4:01:29 网站建设 项目流程

简介:围绕 SpringBoot 与 Vue 前后端分离项目在 Tomcat 下的部署落地,这份文档面向已具备 Java Web 基础、需要把本地联调通过的项目搬上服务器的后端与全栈开发者。内容以实际踩坑为线索,覆盖后端由 jar 改 war 打包、Vue 构建产物 dist 的放置位置、server.xml 中 Context 路径与 crossContext 配置、刷新页面出现 404 时借助 WEB-INF/web.xml 重定向首页的处理方式,以及调用百度地图等第三方 API 时在 SpringBoot 侧配置 CORS 解决跨域的思路,能帮助读者理解部署过程中的常见报错成因,减少反复试错。压缩包共 1 个文件,为一份 doc 格式的部署文档,体积约 72KB,篇幅紧凑、便于随查随用。目前已有 1458 人学习,可作为 Windows + Tomcat(无 Nginx)环境下前后端分离项目上线的参考手册。

1. 为什么现在还有人用 Tomcat 部署 Vue + SpringBoot

很多人接到「SpringBoot + Vue 部署到 Tomcat」这个需求时,第一反应是:都前后端分离了,为什么还要 Tomcat?但在企业内网、政务门户这类环境里,运维通常只给一台 Windows 机器和一个现成的 Tomcat,没有 Nginx,也不允许装 Node 运行时,war 包加静态目录就是唯一能落地的方案。这份文档记录的就是这条路:后端打成 war 放进 webapps,前端 dist 挂到同一个端口下,用 Host 里的一个 Context 节点和一份 web.xml,解决刷新 404 和登录后白屏这两个问题。适合手上有一个本地跑得起来、一部署就出问题的 SpringBoot + Vue 项目,且只能用 Tomcat 的人。

2. 从 jar 改 war:pom、启动类与外部容器的三处联动

打包方式从 jar 换成 war,看起来只改一行配置,实际涉及三个地方:pom.xml的依赖作用域、启动类的继承关系,以及打包产物的命名规则。这三处只要漏掉一处,Tomcat 日志里往往看不到 Java 异常,只有一片安静的 404,排查起来格外费劲。

2.1 内嵌容器和外置容器,差别不只是打包格式

SpringBoot 默认打的 jar 是「自带容器」的:spring-boot-starter-web会把spring-boot-starter-tomcat一起拉进来,java -jar启动时由main方法把内嵌的 Tomcat 启动起来,Servlet 容器的生命周期由 Spring 管理。而 war 包是「交出控制权」:容器由外部 Tomcat 提供,Spring 只是被容器加载的一个 ServletContextListener,Tomcat 启动时通过ServletContainerInitializer找到你。

这个差别会带来三个接力点。第一,内嵌 Tomcat 的依赖要改成provided,否则打出来的 war 里WEB-INF/lib会塞一份 tomcat-embed,外部 Tomcat 加载时会出现类冲突,典型症状是启动到一半卡住,或者报ClassNotFoundException但类明明在。第二,启动类必须继承SpringBootServletInitializer并重写configure,否则 Tomcat 只启动了一个空的 Context,Spring 的 Banner 一行都不会输出。第三,war 的文件名就是访问前缀,改名为prod-api.war,后端接口根路径就是/prod-api,application.yml里的server.port和server.servlet.context-path在外部容器模式下都不再起作用,端口归conf/server.xml的Connector管。

还有一个版本问题必须提前确认:SpringBoot 2.x 用的是javax.servlet,对应 Tomcat 9;SpringBoot 3.x 换成了jakarta.servlet,必须配 Tomcat 10.1 以上。如果项目是 2.x 却部署到 Tomcat 10,日志里会直接报NoClassDefFoundError: javax/servlet/Filter,这个报错跟代码没有关系,不要浪费时间去改业务代码。

2.2 改 pom 与启动类:一份可以直接照抄的改动清单

下面是pom.xml需要动的全部内容,重点是排除内嵌 Tomcat 再用provided引回来。provided的含义是「编译和测试时可见,打包时不进 war」,这样本地main方法仍然能跑,部署时又不会和外部容器冲突。

<!-- pom.xml:三处改动,缺一处都会在外部 Tomcat 里起不来 --> <packaging>war</packaging> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <!-- 关键:把内嵌 tomcat 从 web starter 里排掉 --> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <!-- provided:编译期可见,运行期由外部 Tomcat 提供 --> <scope>provided</scope> </dependency>

启动类的改动只有两个方法,但configure里传的sources必须是主配置类本身,不能换成别的类,否则扫描不到@Component:

// 启动类:外部容器靠这个类找到 Spring 上下文 @SpringBootApplication public class ProdApiApplication extends SpringBootServletInitializer { public static void main(String[] args) { // 保留,本地 IDEA 里直接 run 仍然走内嵌容器 SpringApplication.run(ProdApiApplication.class, args); } @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { // 外部 Tomcat 启动时回调这里,等价于 main 方法里的 run return builder.sources(ProdApiApplication.class); } }

如果这台机器只跑一次、不做本地调试,也可以不排除内嵌 Tomcat,只在打包时用spring-boot-maven-plugin的repackage跳过。但我不推荐这种「两套并行」的省事做法:war 包里同时存在两套 Tomcat 类,一旦版本不一致,报错会非常难读,而这类问题往往只在校验签名、上传文件这类边角功能上出现,排查成本很高。

2.3 打包、改名与产物自检

打包命令本身没难度,难的是产物检查。很多人mvn package完直接复制,结果部署上去发现配置文件是旧的,或者WEB-INF/lib里躺着不该有的 jar。

# 1. 跳过测试打包,测试阶段依赖内嵌容器,跳过更稳 mvn clean package -DskipTests # 2. 确认产物存在,并且是 war 不是 war.original ls -lh target/*.war # 3. 复制到 webapps,并在复制时就改名(改名会改变访问前缀) cp target/prod-api.war /d/tomcat9/webapps/prod-api.war # 4. 自检:解压看一眼,确认 tomcat-embed 没有被带进去 unzip -l target/prod-api.war | grep -i "tomcat-embed" # 期望:无输出。有输出说明 scope 没配对

三步操作里有两个容易忽略的点。一是不要在webapps里改 war 的文件名,Windows 下 Tomcat 正在运行时改名会失败,而且旧的解压目录webapps/prod-api/还会留着,Tomcat 优先加载解压目录,你改的新 war 根本不生效。正确顺序是先shutdown.bat,删掉旧的解压目录,再复制新 war。二是打包后顺手确认WEB-INF/classes/application.yml里的数据库地址是生产库,这一步用unzip -p target/prod-api.war WEB-INF/classes/application.yml就能看,比上线后被业务方打电话问「数据怎么是测试的」便宜得多。

3. Vue dist 落到 webapps:Context 配置与「登录后空白页」

前端这一步是整个部署里最容易出错的地方。后端 war 丢进webapps就完事,前端 dist 却涉及「放哪里、怎么指向、接口前缀怎么对齐」三个决定,任何一个错位,表现都是同一个:登录页能开,登录进去一片空白。

3.1 dist 该放哪:三种放法与取舍

文档里的做法是把 dist 复制到webapps下,再用Context的docBase指过去。这个做法能跑通,但三种落法各有代价,选之前想清楚后续谁维护。

放法docBase 写法优点代价
放webapps/distdocBase="dist"(相对路径)不需要绝对路径,换盘符不受影响每次重新部署前端要手动覆盖,容易被遗忘
放任意目录,如D:/app/distdocBase="D:/app/dist"前端与 Tomcat 解耦,更新只换目录路径写死在 server.xml,机器迁移要改配置
打进prod-api.war的static下不需要 Context一个 war 搞定,部署最简单每次改一行前端都要重新打包后端,CI 慢

我一般选第二种,把 dist 放到 Tomcat 之外的固定目录,server.xml里写绝对路径。原因是前端改动频率远高于后端,运维只需要替换目录内容、重启一次 Tomcat,不用碰 war 包。要注意 Windows 路径在 XML 里用正斜杠/更稳妥,反斜杠在某些 Tomcat 版本里会被当成转义符处理。

3.2 server.xml 里 Host 与 Context:每个属性都得知道它管什么

conf/server.xml要改两个地方:Connector的端口,以及Host下新增的Context。下面这份是可直接用的写法,注释写清了每个属性的实际作用。

<!-- conf/server.xml --> <!-- 1) 端口:外部容器模式下 application.yml 的 server.port 无效,端口看这里 --> <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" URIEncoding="UTF-8" redirectPort="8443" /> <Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> <!-- 2) path="/" 表示用根路径访问前端;docBase 指向 dist 所在目录 --> <Context path="/" docBase="D:/app/dist" reloadable="false" crossContext="true" /> </Host>

逐项说。path="/"是浏览器的访问路径,写成/意味着http://ip:8080/直接打开前端首页;如果写成/ui,访问地址就变成http://ip:8080/ui,此时前端构建的base也必须跟着改,否则 assets 全 404。docBase就是 dist 的物理位置,相对路径是相对于appBase(也就是webapps)解析的。reloadable="false"生产必须关掉,设成true时 Tomcat 会周期性扫描/WEB-INF/classes和/WEB-INF/lib下的 class 变化,内存和 CPU 开销都不小,而且它只对 class 生效、对前端静态文件毫无意义。

crossContext="true"这个属性,原文档里写的是「允许 SpringBoot 与 Vue 之间的跨上下文请求」,并且自己也标注了「个人猜想」。这里需要说明一下:crossContext的真实作用是允许在同一个 Tomcat 内,一个 Context 里的 Servlet 通过ServletContext.getContext("/other")拿到另一个 Context 的上下文,它跟浏览器发起的前端请求毫无关系,前端请求根本不经过这个机制。登录后空白页的真正原因,是前端用了 history 路由:登录成功后跳转到/dashboard这类路径,浏览器把这个路径当作后端资源去请求,Tomcat 的 DefaultServlet 找不到对应的物理文件,返回 404,页面自然就白了。Context path="/" docBase="dist"之所以能解决,是因为它把根路径下的请求交给了 dist 目录,让index.html有机会被返回。

3.3 同端口下的接口前缀:baseURL 必须和 war 名对齐

前后端跑在同一个端口后,最容易出错的是接口地址。后端 war 叫prod-api.war,接口真实前缀就是/prod-api;前端Context占了/,静态资源在根路径。两者不冲突的前提是:前端请求的baseURL老老实实写/prod-api。

// src/utils/request.js:把 baseURL 收口到一处,别在业务代码里拼字符串 import axios from 'axios' const request = axios.create({ // 生产环境走 /prod-api(等于 war 名),开发环境走 vite 代理 baseURL: import.meta.env.VITE_API_BASE || '/prod-api', timeout: 15000 }) request.interceptors.response.use( res => res.data, err => Promise.reject(err) ) export default request

配套的环境变量文件放在项目根目录,构建时会自动注入:

# .env.production:值必须等于 war 文件名,多一个字符就是全站 404 VITE_API_BASE=/prod-api

这里有一个容易忽略的冲突场景:如果Context的path不小心写成了/prod-api,它会和后端 war 的上下文路径重叠,Tomcat 启动时通常会在日志里报 Context 重复,即使侥幸起来了,前端页面也会被后端接口吞掉。检查方法是启动后看logs/catalina.out里所有 Context 的启动行,确认/指向 dist、/prod-api指向 war,两条各自独立。另外开发环境不要把这个值写死成/prod-api,本地没有 Tomcat 托管,用 vite 的server.proxy转发到localhost:8080更省事。

4. 刷新 404 与路由回退:web.xml 和 history 模式的两个开关

到这一步,页面能开、能登录,但用户在任意一个二级页面上按 F5,又回到白屏或 Tomcat 的 404 页面。这是前端路由模式和服务端静态资源处理之间的老矛盾。

4.1 404 到底是谁抛的:DefaultServlet 与 history 模式

浏览器访问http://ip:8080/order/detail,请求先到 Tomcat,Tomcat 按 Context 找到docBase指向的目录,用 DefaultServlet 去找order/detail这个文件。磁盘上没有这个文件,也没有对应的 Servlet 映射,于是返回 404。而前端用的是createWebHistory模式,路由跳转靠history.pushState改地址栏,不发起真实请求,所以点链接没事,一刷新就暴露了。

解决思路只有两种:让服务端把找不到的路径回退到index.html,或者干脆不用 history 模式。前者适合已经上线的系统,后者适合新项目。原文档选的是前者,用error-page把 404 重定向到首页,思路是对的,但要知道它的副作用,后面会讲。

4.2 WEB-INF/web.xml:位置、写法与生效条件

这份 web.xml 不放在 war 里,而是放在 dist 目录下,也就是和index.html同级建一个WEB-INF文件夹。Tomcat 把 dist 当作一个 web 应用目录来对待,会自动读取其中的WEB-INF/web.xml。

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1" metadata-complete="true"> <display-name>Router for Tomcat</display-name> <!-- 404 时回退到首页,交给前端路由接管 --> <error-page> <error-code>404</error-code> <location>/index.html</location> </error-page> </web-app>

几个参数值得说清楚。version="3.1"对应 Servlet 3.1 规范,Tomcat 8.5 和 9 都支持;如果部署在 Tomcat 10 上,命名空间要换成jakartaee且版本升到 5.0 以上,否则这份 web.xml 会被直接忽略,而且不会有任何报错,表现就是「配了没用」。metadata-complete="true"表示这个应用不扫描注解,纯静态目录没有注解可扫,设成 true 能缩短启动时间。location必须以/开头,写相对路径不生效。

必须提醒一个副作用:error-page对所有404 都生效,包括图片、字体、接口路径写错的情况。结果是前端请求一个不存在的logo.png,返回的是index.html的内容,Content-Type是text/html,浏览器控制台报的是「资源被解释为图片但传输的 MIME 类型是 text/html」。这个报错很容易把人带到错误的方向上去查构建配置。所以这个方案只适合纯静态托管、接口全部走/prod-api前缀的场景;如果接口和前端混在同一个路径下,宁可改 hash 模式。

4.3 hash 模式与 history 模式:什么时候可以不写 web.xml

如果项目还没上线,或者对 URL 美观度没有硬性要求,改一行代码就能把整个问题绕过去。

对比项history 模式hash 模式
URL 形态ip:8080/order/detailip:8080/#/order/detail
刷新行为打到服务端,需要 fallback只请求index.html,天然安全
是否需要 web.xml需要不需要
第三方回调地址干净,容易过校验部分平台不认#后的参数

改法是把createWebHistory()换成createWebHashHistory(),构建后重新覆盖 dist 即可,server.xml和web.xml都不用动。判断依据很简单:如果这个系统要对接第三方登录回跳、支付回调,#后面的参数经常被截断,那就老老实实上 history 模式加error-page;如果只是内网管理系统,hash 模式省下的排查时间足够你写完剩下的功能。

5. 部署避坑:五条出过问题的排查记录

下面五条都是我在这类部署里真实遇到过的,按「现象 → 原因 → 解决」写,遇到同类问题可以对着症状直接找。

5.1 访问与路由类

第一条:登录页正常,登录成功后一片空白,地址栏变成了/dashboard这类路径。

原因:前端 history 路由跳转后,浏览器地址是真实路径,但 Tomcat 的 DefaultServlet 在该路径下找不到物理文件,返回 404;如果连Context都没配,根路径本身就没有指向 dist,页面连加载的机会都没有。解决分两步:先在Host下加Context path="/" docBase="dist绝对路径",再在 dist 下建WEB-INF/web.xml配error-page。加完必须重启 Tomcat,只覆盖文件不重启,Context不会重新加载。

第二条:首页能打开,但 CSS/JS 全 404,控制台一堆资源加载失败。

原因:构建产物的资源引用路径是绝对路径/assets/xxx.js,而应用实际挂在/ui这类子路径下(Context的path不是/)。解决:把Context的path改成/,或者在构建配置里把base改成/ui/,两者必须一致。另外别忘了浏览器缓存,覆盖 dist 后按 Ctrl+F5 强刷,否则看到的还是旧文件。还有一种情况是webapps/dist和docBase指向的目录不是同一个,改了源目录自然没反应,核对server.xml里的路径即可。

5.2 接口与跨域类

第三条:本地调第三方地图、定位类 API 正常,部署到 Tomcat 后报跨域或 403。

原因:浏览器直连第三方接口时带的是部署机的域名或 IP,第三方平台的 Key 白名单、Referer 校验对不上;同时浏览器的同源策略会拦住响应。解决:不要让前端直连,改成后端转发。前端只请求自己的/prod-api/map/geocode,由后端带 Key 去调,同源问题自然消失,Key 也不必暴露在打包产物里。

// 后端代理:前端只认自己的域名,第三方 Key 留在服务端 @RestController @RequestMapping("/map") public class MapProxyController { @Value("${map.api-key}") private String apiKey; // 放在 application.yml,不进前端产物 @GetMapping("/geocode") public String geocode(@RequestParam String address) { // 用 RestTemplate/WebClient 转发,超时必须设,否则会拖住 Tomcat 线程 String url = "https://example.com/geocode?address=" + address + "&ak=" + apiKey; return restTemplate.getForObject(url, String.class); } }

这段的关键点是超时设置。外部接口慢的时候,如果不设连接和读取超时,Tomcat 的工作线程会被一直占用,并发上来后整个应用无响应,而日志里只有请求进来、没有异常,排查方向很容易跑偏。RestTemplate建议连接超时 3 秒、读取超时 10 秒,配在SimpleClientHttpRequestFactory上。

第四条:本地接口 200,部署后所有接口 404。

原因九成是baseURL和 war 名不一致,或者 war 解压目录残留导致新包没生效。解决:先看webapps下有没有同名解压目录,有就先停 Tomcat、删目录、再放新 war;再打开浏览器 Network 面板看请求的完整路径,确认前缀是/prod-api;最后确认application.yml里没有多余地配server.servlet.context-path,在外部容器模式下它会和 war 名叠加,变成/prod-api/prod-api。

5.3 启动与编码类

第五条:启动报端口占用,或者页面中文全是乱码。

原因:端口占用多半是上一次没正常关闭,残留的java.exe还在监听 8080;中文乱码是 GET 请求的查询参数编码没指定,Tomcat 默认按 ISO-8859-1 解析。解决:先找进程再决定处理方式,别直接重启机器。

# Windows 下定位占用 8080 的进程 netstat -ano | findstr :8080 # 拿到 PID 后确认是不是 java 进程,再决定是否结束 tasklist | findstr <PID>

编码问题在Connector上补一个URIEncoding="UTF-8"就够了,POST 请求走请求体,不受这个配置影响。如果补完仍有乱码,去查前端的Content-Type有没有漏掉charset=UTF-8,以及数据库连接串里的编码参数。

6. 上线前跑一遍全链路自检:五个点位的验收顺序

部署这事,我不看浏览器里能不能登录,那个信号太晚。我一般按下面这个顺序验收,任何一步不对就停下来查,别往下走。

顺序动作期望结果
1curl -I http://ip:8080/prod-api/actuator/health返回 200,body 是UP
2curl -I http://ip:8080/返回 200,Content-Type: text/html
3无痕窗口打开首页Network 里 assets 全部 200
4登录后跳转二级页并按 F5页面正常,Network 里该路径返回的是 index.html
5上传一次文件、调一次第三方接口都有响应,catalina.out无异常堆栈

第 1 步最关键,它把「Tomcat 起来了」和「Spring 上下文起来了」分开验证。很多 404 是因为 war 解压失败或启动类没继承SpringBootServletInitializer,health 一请求就暴露了。第 4 步看Content-Type比看页面更可靠,因为如果是error-page生效,返回的确实是index.html的内容。

还有一个排查技巧:logs/catalina.out里搜索Deploying web application和Context,能直接看到 Tomcat 认为你部署了哪些上下文、路径是什么,比猜配置快得多。前端缓存也是导致「明明改了没效果」的一大原因,验收一律用无痕窗口,别用日常浏览器。

从那以后我每次往webapps里放 war 之前,都强制先做三件事:停掉 Tomcat、删干净同名解压目录、把浏览器切到无痕窗口。这三步看着啰嗦,但能把「配置没问题、就是不生效」这类最容易消耗人的问题提前挡掉。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询