先把话说清楚:把 Vue3 项目打包后放进 Spring Boot 项目里运行,不是架构上的最优解,但绝对是最省事的部署方式之一。尤其适合个人项目、后台管理系统、毕业设计这类场景——不用单独准备服务器装 Nginx,不用配置 HTTPS,不用维护两套进程,最后交付就是一个 jar 包,扔到服务器上java -jar就跑起来了。
Vue3 打包本质上是把.vue组件、JS、CSS 编译成浏览器能直接识别的静态文件,构建产物叫 dist;而 Spring Boot 内置了 Tomcat,天然支持静态资源托管。把 dist 里的内容放进src/main/resources/static,前端页面由 Spring Boot 提供,接口也由同一个后端处理,天然同源,跨域问题直接消失。
如果你正在用 Vite 构建 Vue3 项目,又恰好有个 Spring Boot 后端,或者你只是想把手头一个 Vue3 学习项目、后台管理系统快速上线,这篇内容可以帮你把整条链路走通。我会把 Vite 构建配置、路由模式、Spring Boot 静态资源规则、history 路由刷新 404 这几个最大的坑全部讲清楚,并给出可以直接照抄的代码。
1. 整体设计思路:一个 jar 包解决前后端
1.1 为什么我推荐这种部署方式
Vue3 项目开发时跑的是 Vite 开发服务器,页面热更新、接口代理很方便,但开发服务器不是生产环境该用的东西。上线前必须执行构建,把源码编译成 HTML、CSS、JS 静态文件,这些文件集中在 dist 目录。
Spring Boot 内嵌了 Tomcat,默认情况下src/main/resources/static目录下的文件会被直接映射为根路径资源。所以最朴素的思路就是:
- 前端执行
npm run build,得到 dist; - 把 dist 内容复制到 Spring Boot 的 static 目录;
- 打包后端为 jar;
- 运行后直接访问
http://ip:8080即可。
这套方案优势明显:第一,部署成本低,一个 jar 包搞定所有;第二,前后端同源,不用处理 CORS;第三,对小型系统来说,运维负担最小。
当然短板也客观存在。前端需要高频独立发版、后端要横向扩展、静态资源访问量极大时,前后端分开部署依然是更合理的架构。我的建议是:项目初期、内部工具、演示项目、个人作品,用这种方式快速上线完全没问题;等团队规模变大、发版节奏变快,再拆分也不迟。
1.2 一次构建,两处"装修"
很多人以为前端打包就是执行npm run build完事,实际上 Vite 构建时会读取 base、public 目录、资源哈希等一堆配置。真正决定部署后能不能正常跑的核心只有两个:资源路径和路由模式。
资源路径决定了 index.html 里引用的 JS/CSS 去哪找;路由模式决定了你在浏览器地址栏直接访问/foo这个路由时,服务器如何响应。后者处理不当,就会出现"首页能打开、一刷新就 404"的经典问题。
这两个点又恰好和 Spring Boot 侧的配置强相关。所以我认为正确顺序是:先理解 Vite 构建逻辑,再动 Spring Boot 配置,最后用实际访问验证。别先把前端一顿打包,扔进去发现到处 404,再回来改配置,那样最容易把自己绕晕。
2. 先把 Vue3 项目的构建环境调顺
2.1 构建前检查清单
在跑npm run build之前,建议先确认这几件事,免得构建出来之后反复重来:
package.json里有没有 build 脚本,一般是vite build;- 是否已经装好依赖,node_modules 是否完整;
- 代码里有没有直接写死开发环境的接口地址,比如
http://localhost:5173; - 是否用了环境变量,
.env.production文件有没有配置正确。
这个清单看起来基础,但我见过太多人打包半天,最后发现接口地址还是 localhost,线上页面请求全打回本地去了。这类问题通常不报错,只能打开浏览器 Network 一条条看,最耽误时间。
2.2 Vite 配置里最关键的两个参数
第一个是base。Vite 默认是/,表示构建出来的资源路径是绝对路径,比如/assets/index-abc123.js。如果你的前端应用部署在域名根路径下(比如直接访问ip:8080打开页面),默认值就够了。但如果 Spring Boot 设置了 context-path,或者想把应用放到某个子路径下,就必须把base改成对应子路径,否则资源请求路径对不上。
第二个是路由模式。vue-router 有两种模式:hash 和 history。hash 模式的 URL 带#,比如/#/login,刷新时浏览器不会向服务器请求这个路径,几乎不会出 404,但 URL 丑;history 模式 URL 干净,但刷新时浏览器会真实请求服务器上的这个路径,Spring Boot 默认不认,于是返回 404。
我的建议:如果只求部署省心,hash 模式是零配置选择;如果想要干净的 URL 或者业务上必须用 history,那就按后面 3.3 节的方式给 Spring Boot 加转发规则。
一个最基础的 Vite 配置长这样:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], base: '/', build: { outDir: 'dist', assetsDir: 'assets' } })2.3 构建产物长什么样
执行npm run build后,dist 目录下一般会有:
index.html:整个单页应用的入口;assets目录:打包后的 JS、CSS、图片等静态资源;public目录里的文件会被原样复制到 dist 根目录。
此时可以顺手打开dist/index.html看一眼,重点确认script标签的src和link标签的href是否是你期望的路径。如果base是/,会看到/assets/xxx.js;如果看到的是./assets这种相对路径,说明 base 配置和预期不一致,后续要特别留意。
2.4 接口地址怎么处理
前端请求接口有两条路:开发环境用 Vite 的 proxy 代理解决跨域,生产环境直接访问同源接口。开发时大家通常在vite.config.js里配server.proxy,把/api代理到后端的 8080 端口。这个配置只在开发服务器生效,构建出来的生产代码里并不包含它。
所以部署到 Spring Boot 后,前端请求应该写成相对路径。比如 axios 的 baseURL 设置为/,请求/api/user/list,就会打到当前域名和端口下的接口,也就是 Spring Boot 自己的接口,同源,无跨域问题。
如果你确实有独立后端地址,也可以在.env.production里配置VITE_API_BASE,构建时写死。但既然都放进 Spring Boot 了,我个人更推荐相对路径,简单直接,少一个变量就少一个隐患。
3. Spring Boot 侧引入前端产物的几种姿势
3.1 最没技术含量但最可靠的方式:手动拷贝
手动把 dist 里所有文件复制到src/main/resources/static下,这是最朴素也最不容易出错的方式。直接复制,没有中间层,构建产物什么样,后端就原样提供什么。
但手动复制的问题在于容易忘。每改一次前端代码,就要重新构建、复制、再打包后端,流程一多就乱。我更建议用构建插件把这一步自动化,把"人肉流程"变成"机器流程"。
3.2 用 Maven 插件自动拷贝前端产物
以 Maven 为例,可以用 maven-resources-plugin,在打包阶段把前端构建产物复制到 static 目录。假设前端 dist 目录和后端项目在同一级目录下,后端pom.xml里可以这样配置:
<build> <resources> <resource> <directory>src/main/resources</directory> </resource> <resource> <directory>../frontend/dist</directory> <targetPath>static</targetPath> </resource> </resources> </build>注意:这种方式要求frontend/dist目录在package之前已经存在,也就是说前端必须先构建好。这不算彻底的自动化,但已经能避免"忘了复制"这种低级错误。
如果想让流程更完整,可以引入 frontend-maven-plugin,在 Maven 的 generate-resources 阶段自动执行npm install和npm run build,再配合 resources 复制,真正实现一条命令出 jar。这样不管是本地打包还是 CI 流水线,都能保证前端产物是最新的。
3.3 核心:解决 history 路由刷新 404
把静态文件放进 static 之后,首页能打开,但直接访问/login或刷新页面,大概率返回 Whitelabel Error Page。原因前面说了:Tomcat 找不到/login这个路径对应的资源。
解决办法是实现一个 WebMvcConfigurer,把所有非接口路径转发到 index.html。我常用的写法是在addViewControllers里添加转发:
import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}") .setViewName("forward:/index.html"); } }这种方式把"所有不带点号的路径"都转发到 index.html。像/assets/xxx.js、/favicon.ico这种带点号的路径会直接命中静态资源,不会被转发;而/login这种纯路径就会交给前端路由去处理。
但这条规则会把/api/xxx也转发到 index.html,所以必须把接口路径排除掉。两种办法:一是让接口路由前缀独立,比如统一走/api,在转发规则里用正则限定不包含api的路径;二是在 Controller 拦截器、过滤器层面做排除。更稳妥的做法是只用这个配置转发页面路由,同时在 Spring Security 或者自定义拦截器里放行/index.html、/assets/**以及接口前缀。
如果你用了 Spring Security,尤其要注意匿名放行的路径是否齐全,否则会出现前端页面加载到了,控制台却疯狂报 401 的怪现象。这种情况不是路由没转发成功,而是安全配置把静态资源也拦了。
3.4 接口路径规则和 context-path 的坑
有人喜欢在application.yml里配server.servlet.context-path,让所有接口统一带前缀,比如/api。这个做法在前后端完全分离时挺好用,但一旦前端静态文件也要从 Spring Boot 根路径访问,就会产生一连串问题:页面本身从根路径访问,接口却在/api下,Vite 的base也必须跟着变,资源引用路径全部要调整。
我的经验是:用这种"塞进 jar"的部署方式时,尽量不要配 context-path,保持根路径。接口前缀由 Controller 的@RequestMapping("/api/...")自己管理,前端请求同样以/api开头,所有路径清清楚楚,不用来回绕。省掉 context-path,等于省掉一整套路径换算的麻烦。
3.5 打包运行与最终验证
后端执行:
mvn clean package -DskipTests生成xxx-0.0.1-SNAPSHOT.jar。上传到服务器,执行:
java -jar xxx-0.0.1-SNAPSHOT.jar然后用浏览器访问http://ip:8080。如果端口被占用,修改application.yml里的server.port。确认首页能打开、刷新不 404、接口能正常返回数据,就算部署完成。
到这里整个流程已经通了,但这只是"能跑"。真正到了现场,还会碰上一堆据说"玄学"的问题,下面把我踩过的坑都列出来。
4. 常见问题与排查技巧实录
4.1 首页能打开,刷新子路由就 404
这是 history 模式最经典的坑。先说结论:要么改用 hash 模式,要么加上 3.3 节的转发。如果加了转发还不行,先确认 Spring Boot 版本。Spring Boot 3.x 的路径匹配规则和老版本有差异,如果规则写法不对,多试几种模式;其次确认forward:/index.html别写成redirect:/index.html,redirect 会导致刷新时路由信息丢失,看起来还是 404。
排查时打开浏览器 Network,看刷新后页面请求的 URL,再对照 Spring Boot 日志里的 404 记录,基本能一眼定位。
4.2 接口路径没问题,但前端访问 404 或 405
这类问题大概率是 context-path 造成的。比如接口实际是/api/user/list,但你配了context-path: /api,那么访问/api/user/list会变成/api/api/user/list,前端写的是/api/user/list,自然 404。
另一个隐蔽问题是 Spring Boot 3 中 ant 路径匹配器的变化。如果你在拦截器里写了/*这种匹配规则,要注意 Spring Boot 3 用的是 PathPatternParser,行为和老版本不同。遇到这种情况把日志打开,看具体被拦截的路径是什么,再调整匹配规则。
4.3 图片、JS、CSS 加载 404,但 HTML 是正常的
先看浏览器 Network 里 404 资源的完整 URL。如果请求的是/assets/xxx.js,但base配成了'./',资源路径会变成相对于当前路由的路径,比如/login/assets/xxx.js,自然找不到。解决办法是把base统一设置为'/',并且取消 context-path,让资源和接口都保持在根路径。
如果你必须部署在子路径,那么base、路由createWebHistory的参数、Spring Boot 的 context-path 三处必须协同一致,缺一个就出问题。这类问题往往要改三处,漏一处就报错,所以能不用子路径就不用。
4.4 接口请求打到了错误的端口
开发时接口走的是 Vite proxy,部署后开发服务器不存在了。如果代码里的请求地址写死了localhost:5173或localhost:8080,部署到服务器后就会出现"页面能开但数据全挂"的情况。检查 axios 的 baseURL 和所有 fetch 的 URL,确认生产环境用相对路径。排查方法最笨但最有效:打开 Network 看请求 URL,再和后端实际路由比对,一目了然。
4.5 jar 包里找不到前端文件
执行java -jar访问页面时发现 404,可以先解压 jar 看看 static 目录里有没有文件。如果没有前端产物,说明 Maven 打包时没把 static 资源打进去;如果 static 目录存在但文件是旧的,检查 resources 配置是否正确,以及执行 package 之前前端是否真的构建过。
这里有个经验:mvn clean会把 target 整个清掉。如果你用脚本把 dist 复制到target/classes/static的方式做自动化,clean 之后这些文件就没了,所以必须用 resources 插件在打包时重新复制,不能在 target 目录里手动操作。
4.6 部署后偶尔报 chunk 加载失败
用户访问旧版本页面时,浏览器缓存了旧的 index.html,里面引用的 JS 文件名是旧的,部署新版本后这些旧文件被清掉,于是加载失败。说到底就是缓存和文件名带 hash 的问题。
解决思路:对 index.html 设置 no-cache,对带 hash 的静态资源设置长缓存;升级时不要直接覆盖同目录,也可以带版本号目录发布。对 Spring Boot 内嵌 Tomcat 来说,index.html 在 static 根目录,没有额外缓存配置时影响不大,但如果你前面挂了代理,就得注意。
4.7 日志乱码、控制台中文变问号
有些 Windows 服务器上跑java -jar,启动日志里的中文会乱码。可以在启动命令里加-Dfile.encoding=UTF-8,或者把日志编码统一设置为 UTF-8。这个和部署本身无关,但一旦遇到会让你误以为程序出问题了,很干扰排查。
4.8 快速排查速查表
| 症状 | 大概率原因 | 处理建议 |
|---|---|---|
| 刷新子路由 404 | 路由 history + 无转发 | 改用 hash 模式或加 ViewController 转发 |
| 页面能开、接口 404 | context-path 配置错乱 | 去掉 context-path,接口统一写前缀 |
| 静态资源 404 | Vite base 配置错误 | 统一 base 为/ |
| 请求打到本地 | axios baseURL 写死 | 生产环境使用相对路径 |
| jar 里缺少前端文件 | 打包复制配置不对 | 用 resources 插件在打包时拷贝 dist |
| chunk 加载失败 | 缓存残留 | index.html 不缓存,带 hash 资源长缓存 |
这份速查表基本覆盖了我实际部署中遇到的大部分情况。问题永远比预设多,但核心逻辑是一致的:资源路径要对,路由要通,接口要同源。
最后分享一点个人体会。把 Vue3 塞进 Spring Boot 这种部署方式,我用过很多次,包括一些要交付给客户演示的后台系统。它最大的价值是让你在前期快速跑通整个流程,不被运维细节拖垮。但如果你已经感觉到前端迭代频繁、静态资源暴涨、后端需要多实例部署,那就该考虑把前端拆出来单独部署,静态资源交给 Nginx 之类处理,后端专注接口。
还有一个实用小技巧:在 static 目录放一个version.json,每次构建时由脚本自动写入版本号;后端提供一个/api/version接口,前端启动时拉一下,就能知道线上到底是哪次构建。排查缓存和版本问题时,这个小习惯能节省大量时间,我从踩过几次线上版本混乱的坑之后就一直保留着。