
前后端分离的项目做多了你会发现一个特别现实的问题前端 Vue 打包出来的 dist 目录到底怎么交给 Spring Boot 才算优雅我前阵子帮人收尾一个小项目前端同学把 vue 打包完的静态文件甩过来后端这边就开始纠结了——塞进 jar 里放到外部目录还是干脆让运维上 Nginx最后我们选了 Spring Boot 托管静态资源的方案一部分内容打进 jar 包内一部分通过配置直接读 jar 包外部的文件。这套做法在单机部署、中小项目里非常实用今天就把它彻底拆开讲清楚。这篇内容覆盖 Spring Boot 静态资源映射的底层机制、jar 外部静态资源的三种接法、Vue 前端打包产物与后端路由的联动配置以及我在 Windows 和 Linux 上实跑踩过的一堆路径坑。适合正在做前后端分离部署、被刷新 404静态资源找不到更新前端必须重新打 jar折磨过的同学收藏。1. 静态资源映射的核心机制类路径里的资源是怎么被翻译成URL的1.1 四个默认目录与它们的优先级顺序Spring Boot 对静态资源的处理底层用的是WebMvcAutoConfiguration里的资源映射逻辑。默认情况下它把所有继承自WebMvcConfigurer的配置合在一起然后对/**这个路径模式做资源查找。查找顺序非常固定按优先级从高到低排列classpath:/META-INF/resources/classpath:/resources/classpath:/static/classpath:/public/也就是说你在浏览器里输入http://localhost:8080/test.pngSpring Boot 会依次去上面四个目录里找叫test.png的文件找到第一个就直接返回后面的目录不会再看了。这里有一个很容易被忽略的细节如果我们把 Vue 打包出来的 index.html 放进static目录然后请求根路径/Spring Boot 会启动WelcomePageHandlerMapping它优先查找static目录下的index.html找到就作为欢迎页返回。这也是为什么把 dist 内容丢进 static 就能直接跑的原因——你根本没有手动写任何跳转逻辑。1.2 优先级顺序在实际部署中的意义上面的顺序不是随便定的它影响的是同名资源到底返回哪个。举个例子如果static/images/logo.png和public/images/logo.png同时存在你会拿到static下的那份。这个特性在 Vue 项目部署里有一个很常见的用法把框架级的、几乎不变的公共资源放在static把需要频繁替换的版本化资源指向外部目录。通过合理的目录设计你可以让升级前端变成一次纯文件操作而不是动一次 jar。1.3 classpath: 协议与 Jar 内虚拟文件系统还有一个基础知识我需要提一下因为很多人死在这一步。jar 内的静态资源在运行时处于一个虚拟文件系统里它不是一个真实的磁盘目录所以不能在代码里用new File(classpath:static/index.html)去读Java 的 File 类根本不认识 classpath 这个协议。正确的做法是使用 Spring 提供的ClassPathResource或者干脆让 Spring MVC 的资源解析器去处理它会自动处理 classpath 和 jar 内路径的访问。这一点如果你手动继承了WebMvcConfigurationSupport去重写addResourceHandlers特别容易踩到因为你一旦脱离 Spring 的资源解析体系jar 内路径就全归你手动管理了。2. 三种实操方案对比从打进去到读外部2.1 方案一把 dist 目录直接拷进 src/main/resources/static这是最简单也最直接的方式。前端npm run build完成后把生成的dist下的所有内容拷贝到后端src/main/resources/static目录下然后重新打包 jar。这样所有静态资源都会跟着 jar 一起发布。操作步骤大概是这样前端执行npm run build得到 dist 目录。后端删除src/main/resources/static里的旧文件把 dist 里的内容全部复制进去。重新mvn clean package。启动 jar访问http://ip:port/就能看到页面。优点很直观部署简单整个项目就是一个 jar不依赖外部目录不容易出环境问题。缺点也很致命前端的每次变更哪怕只是改一个页面文案都要重新走一次前端构建、文件拷贝、后端打包、重新发布的完整流程。如果前后端团队协作频繁这种方式会把人逼疯。所以我一般只在纯个人项目、或者前端极少变动的场景下才会建议这么干。2.2 方案二WebMvcConfigurer 自定义映射把 jar 外目录变成虚拟路径这一步是解决前端更新频繁的核心方案。思路很简单Vue 打包出来的文件不放进 jar而是放到 jar 同级或者某个约定的目录里。Spring Boot 启动时通过配置把那个磁盘目录映射成一个 URL 路径。package com.example.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class StaticResourceConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 假设外部目录结构是jar 所在目录 / frontend-dist String externalPath System.getProperty(user.dir) java.io.File.separator frontend-dist java.io.File.separator; registry.addResourceHandler(/web/**) .addResourceLocations(file: externalPath); } }这里的关键点是addResourceHandler(/web/**)和addResourceLocations(file: externalPath)。/web/**是你对外暴露的 URL 路径也就是说前端资源访问前缀是/web/。file:前缀告诉 Spring 这个资源位置是磁盘上的真实路径而不是 classpath 内部。Vue 打包的产物放到这个目录后你访问http://ip:port/web/index.html就能打开。实际部署时目录结构大致是这样的/opt/myapp/ ├── myapp.jar └── frontend-dist/ ├── index.html ├── static/ └── ...前端更新时只需要用新的 dist 内容覆盖frontend-dist目录然后刷新浏览器。后端 jar 完全不用动零重启对线上环境非常友好。如果你已经有一个挂在 classpath 下的/static/旧目录也不要慌addResourceHandler是追加式的它不会覆盖 Spring Boot 默认的静态资源映射。两套资源可以共存。2.3 方案三改配置文件 static-locations一并保留默认位置不想写 Java 配置的话Spring Boot 也提供了纯配置文件的方式。在application.yml里加上spring: web: resources: static-locations: - classpath:/static/ - file:${user.dir}/frontend-dist/这里有一个非常容易踩的坑一旦你显式配置了static-locations它会整个替换掉默认的四个 classpath 目录而不是追加。所以你必须在列表里手动把classpath:/static/再加进去否则原来放在 static 下的资源就全访问不到了。另外要注意Spring Boot 1.x 和 2.x/3.x 的配置项前缀不一样Spring Boot 版本配置前缀1.xspring.resources.static-locations2.xspring.web.resources.static-locations3.xspring.web.resources.static-locations如果用错前缀比如在 2.x 里写了 1.x 的配置配置会静默失效效果就是我明明配了外部目录为什么还是 404。排查的时候先想一下是不是这个原因。三种方案怎么选我的建议很直接内部系统、更新极少方案一。需要频繁更新前端、单机部署方案二灵活可控。想最快验证外部目录能不能通方案三改完配置重启一次就能看效果比写代码快。3. 部署 Vue 项目时的链路问题路由模式、publicPath 与接口前缀3.1 history 模式刷新 404 的根本原因与兜底方案Vue Router 默认是 hash 模式URL 长这样http://ip:port/#/home。你把它部署到 Spring Boot 里是不会有刷新问题的因为#后面的内容不会发给后端。但项目一正经起来基本都会换到 history 模式。这时候 URL 变成http://ip:port/home刷新浏览器的时候浏览器真的会去请求服务端的/home路径。而 Spring Boot 的静态资源映射只能匹配真实存在的文件比如/index.html、/static/js/app.js它不会凭空把一个/home路由翻译成 index.html。结果就是刷新白屏返回 404。解决方案叫做 SPA 兜底转发把那些没有文件扩展名的路径统一 forward 到 index.html由 Vue Router 自己去处理路由渲染。package com.example.config; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; Controller public class SpaForwardController { RequestMapping(value {/, /{path:[^\\.]*}}) public String forwardIndex() { return forward:/index.html; } }注意正则[^\\.]*的意思是路径里不能带点。这样/home、/user/list这类前端路由会被转发到 index.html而/static/js/app.js、/api/user/list这种带点或带接口标识的路径不会被误伤。但如果你访问的是一级路由下面嵌套的路径比如/user/123上面的方案还不够需要改成/{path:[^\\.]*}/**或者更完整的拦截。实际项目中我一般这么写RequestMapping(value {/, /{path:[^\\.]*}, /**/{path:[^\\.]*}})不过这里有个隐患如果接口路径不是以/api开头会被这个兜底转发接到。所以后端接口统一加/api前缀不仅是为了规范更是为了给 SPA 兜底让路。3.2 publicPath 与 router base 两个最容易配错的地方Vue 项目里有两个配置很多人一辈子没动过一旦部署到子路径就开始出事。第一个是vue.config.js里的publicPath。它决定了打包出来的 HTML 里引用的 JS、CSS 路径前缀。默认是/这要求你的前端资源挂在域名根路径下。如果你的 Spring Boot 静态资源映射在/web/下面publicPath 应该配置成/web/否则 index.html 里会去请求/static/js/app.js而你的资源实际在/web/static/js/app.js直接找不到。// vue.config.js module.exports { publicPath: process.env.NODE_ENV production ? /web/ : / }当然最简单粗暴的办法是让 Vue 的静态资源也挂在根路径下也就是方案二里把addResourceHandler(/**)映射到外部目录。但对于线上多应用共存的情况用/web/这种前缀更稳妥。第二个是vue-router的base配置。如果路由启用了 history 模式服务端 URL 前缀正好也是一个子路径那么 router 的 base 要和 publicPath 保持一致const router new VueRouter({ mode: history, base: /web/, routes })这两个配置不一致表现出的症状非常诡异页面能打开但路由跳转后的 URL 缺前缀或者刷新之后资源 404。排查的时候先检查它俩是否对齐。3.3 接口跨域与 /api 前缀的统一设计部署阶段还有一个很容易翻车的位置接口请求地址。开发环境前端跑在 8080 端口后端跑在 8081 端口最常见的手段是 Vue CLI 的 devServer 代理。生产环境如果前后端同源部署也就是浏览器访问的域名和 Spring Boot 是同一个那根本不需要处理跨域。这时候最优雅的做法是让前端所有请求都走/api前缀然后后端 Controller 也统一加/api前缀或者通过 context-path 统一处理server: servlet: context-path: /Spring Boot 3.0 之后spring.mvc.servlet.path单独存在跟server.servlet.context-path是两码事。如果配置混乱会出现前端能打开静态页面、但接口全部 404 的怪现象。排错时先看一眼控制台实际请求的 URL 路径。4. 我在 Windows 和 Linux 上实测踩到的路径坑4.1 user.dir 是运行目录不是 jar 所在目录这是我踩得最深的一个坑没有之一。很多教程里喜欢用System.getProperty(user.dir)来拼外部目录路径看起来没毛病实际上user.dir是当前工作目录是由启动进程时所在的目录决定的不是 jar 文件所在的目录。举个具体例子jar 放在C:\deploy\myapp.jar你从C:\Users\admin执行java -jar C:\deploy\myapp.jar这时候user.dir是C:\Users\admin系统会去这个目录下面找frontend-dist肯定找不到静态资源全部 404。更稳妥的做法是用启动脚本显式指定外部目录或者通过配置文件传入绝对路径app: frontend: dir: /opt/myapp/frontend-dist/代码里直接读这个配置值不依赖 user.dir。Windows 开发环境就指定D:/workspace/frontend-distLinux 生产环境就指定/opt/myapp/frontend-dist各管各的一劳永逸。4.2 Windows 反斜杠与 file: 协议的兼容问题Windows 下的路径天然带反斜杠比如D:\deploy\frontend-dist\。如果你把它拼成String path file: D:\\deploy\\frontend-dist\\;最后得到的是file:D:\deploy\frontend-dist\Spring 的 Resource 实现类UrlResource解析这个字符串时大概率出问题表现症状是资源全部 404 或者启动时的警告日志。正确做法有两种一是写代码时用Paths.get(...).toUri().toString()自动转换二是干脆在配置里统一用正斜杠app: frontend: dir: file:D:/deploy/frontend-dist/Windows 的 JVM 和 Spring 对这个兼容性处理得还不错正斜杠在 Windows 上也能正常访问文件系统。我所有项目的外部目录路径现在都统一用正斜杠表达既能在 Windows 上跑也能在 Linux 上跑。4.3 外部目录不存在、没权限、被缓存时的现象与处理外部目录如果不存在Spring Boot 启动阶段不会报错只会当你真正访问一个资源的时候静默返回 404。这个静默失败很坑因为日志里什么都不会有。排查方法很简单在配置类里加一行启动校验PostConstruct public void checkDir() { File dir new File(externalPath); if (!dir.exists()) { throw new IllegalStateException(静态资源目录不存在: externalPath); } }启动时直接暴露问题比上线后莫名其妙 404 强一百倍。权限问题主要体现在 Linux 上Tomcat 进程用户对目录没有读权限时访问静态资源同样 404 或 403。用ls -ld frontend-dist看一下目录权限确保运行用户至少有读和进入目录的权限。缓存问题更隐蔽你更新了前端文件浏览器还显示旧页面。Spring 的资源处理器默认对静态资源设置了Cache-Control响应头里带了 max-age浏览器就会缓存。解决方案是在addResourceHandlers里设置合理的缓存周期或者干脆发布会让前端在静态资源文件名加上 hashVue CLI 默认会加。5. 给外部静态资源加一道安全校验路径穿越与编码绕过5.1 风险来源开放目录与路径穿越把静态资源放在 jar 外部的第一反应是方便但风险也随之而来。最常见的攻击方式是路径穿越攻击者通过构造../或者编码后的%2e%2e/这样的路径片段尝试跳出你设定的静态资源根目录去读服务器上的其他文件。Spring 自带的PathResourceResolver其实内置了一部分防护逻辑会对资源的真实路径做normalize()校验防止通过..跳出资源目录。但如果你自己实现了资源解析或者用了一些老版本的依赖防护可能不完整。我见过一个项目把所有映射都配成了file:/也就是说整个根目录都是静态资源根随便一个请求都能读到服务器上任意文件。这种开放程度在真实环境基本等于裸奔。5.2 用 Servlet Filter 校验真实路径的落地写法如果你的外部静态资源目录里存了相对敏感的文件或者你单纯想多一道保险可以自己写一个简易 Filter对进入外部资源目录的请求做一次真实路径校验。package com.example.filter; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; Component Order(1) public class StaticPathValidateFilter implements Filter { private final String baseDir System.getProperty(user.dir) File.separator frontend-dist; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); if (!uri.startsWith(/web/)) { chain.doFilter(request, response); return; } // 将 URL 路径映射为磁盘真实路径并做规范化 String relative uri.substring(/web/.length()); Path basePath Paths.get(baseDir).toAbsolutePath().normalize(); Path realPath basePath.resolve(relative).normalize(); // 校验真实路径必须在允许的根目录之内 if (!realPath.startsWith(basePath)) { resp.setStatus(HttpServletResponse.SC_FORBIDDEN); return; } // 可选校验文件确实存在 if (!Files.exists(realPath)) { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } chain.doFilter(request, response); } }这段代码的核心动作有两个。第一resolve之后紧接着normalize()把路径里的..和多余的.全部消解掉让路径变成最朴素的形式。第二用startsWith(basePath)判断真实路径是否还在允许的根目录内如果跳出根目录直接返回 403。关于编码绕过getRequestURI()在不同容器中拿到的可能已经是解码过的路径也可能还是编码形态。稳妥的做法是再加一次解码再 normalize。但更省心的是直接用 Spring 的PathResourceResolver并确保它的校验逻辑开启它是经过大量生产实践验证的比我手写任何东西都可靠。5.3 不要过度开放目录设计与 Spring Security 放行粒度设计外部静态资源目录的时候最好遵循最小权限原则。不建议直接映射一个范围巨大的目录比如把整个/home/user或者磁盘根目录暴露出去。更合理的做法是单独建一个目录只放前端产物部署时由发布脚本维护它的内容运维侧的权限也是隔离的。如果你的项目集成了 Spring Security记得对前端静态资源路径和 SPA 兜底路径做放行否则会出现静态资源加载失败、接口正常的诡异现象。放行规则建议精准匹配http.authorizeHttpRequests(auth - auth .requestMatchers(/web/**, /, /index.html).permitAll() .requestMatchers(/api/**).authenticated() .anyRequest().permitAll() );注意放行/web/**前缀的静态资源而不是一把梭把根路径全放行。对于一个带登录功能的后台系统最理想的状态是页面本身可以访问、但所有接口必须带 token。最后再分享一个部署经验。之前我为了追求前端和后端彻底解耦甚至想过把静态资源直接扔给 Nginx 处理Spring Boot 只负责 API。但对中小团队来说多一套 Nginx 配置就多一个故障点而且很多客户环境根本不给你装 Nginx 的机会。Spring Boot 直接托管 Vue 产物配合 jar 外部静态资源目录已经能在绝大多数中小项目的部署中做到前端热更新、后端不重启。关键是把目录规范、路由兜底、安全校验这三件事一次做对后面就真的只是覆盖文件的事了。