History模式的Vue应用开发环境跑得欢路由跳来跳去一点毛病没有。等npm run build打完包把dist目录扔进Spring Boot的static目录java -jar一启动首页能开子路由能翻但只要在子路由上按一次F5或者直接在地址栏输入一个子路径回车浏览器就把Whitelabel Error Page甩到你脸上。这个404问题几乎每个用Vue Spring Boot做过前后端一体部署的人都会撞上一次根子不在Vue Router的mode配置也不在打包参数而在服务器端没有人告诉Spring Boot这些路径不是真实文件请你把首页还给浏览器。这篇文章会把为什么会404、怎么一步步定位、以及我实测下来可行的兜底方案全部摊开讲清楚。前后端都沾点的朋友、第一次接这种部署任务的Java开发都能直接照着改。1. 先把锅分清楚History模式在浏览器里做了什么Spring Boot又为什么接不住1.1 History模式到底改了什么Vue Router默认有两种工作模式。Hash模式在URL后面挂一个#比如http://localhost:8080/#/login这个#之后的内容浏览器压根不会发给服务器所以你在任何子路径刷新服务器看到的请求始终是GET /自然永远能命中首页。代价就是URL里带个#既不好看分享链接时也容易被各种系统截断。History模式去掉#URL变成http://localhost:8080/login。它靠的是history.pushState这套浏览器原生API路由跳转时浏览器地址栏变了、历史记录多了但实际上根本没向服务器发请求。这也是为什么在应用内部“点着跳转”一切正常因为整个切换过程是前端自己用JS在内存里完成的服务器全程不知情。问题就出在这里。当你按下F5或者复制链接到新标签页打开浏览器不再给你演什么pushState了而是实打实发一个GET /login请求出去。服务器拿到这个请求开始在自己的文件系统里找叫login的东西。找不到就返回404。这和你用History模式没有关系换成任何SPA框架、任何服务端语言只要用了HTML5 History API都必须解决“服务器端对未知路径的兜底重写”。1.2 刷新那一瞬间服务器经历了什么用Spring Boot跑一个前后端一体的Jar包静态资源默认放在classpath:/static/下面。Vue打包出的index.html、js、css都在这目录里。当浏览器发起GET /login时请求进了DispatcherServletSpring MVC先拿着/login和所有Controller、RequestMapping比对没有匹配的Handler。接着轮到资源处理器ResourceHttpRequestHandler它负责把/**映射到classpath:/static/去查文件。查了static/login这个文件不存在于是返回404最终渲染出默认的Whitelabel Error Page。整个过程可以概括成一句话Spring Boot把/login当成一个“文件路径”来解析而不是一个“应用内路由”。服务端眼里根本没有“login页面”这种概念它只认识能落盘的物理文件或者注册过的接口。前端路由是自己构建出来的虚拟状态服务器必须有意识地把这些虚拟路径“翻译”回index.html再由Vue Router根据URL去渲染对应组件。1.3 这是“静态资源路由”和“页面路由”两套模型打架很多第一次遇到这个问题的同学会去改Vue Router配置试各种base、mode、createWebHistory参数折腾半天没用。原因在于问题的本质是服务端模型和前端模型不一致前端认为URL是页面地址后端认为URL是资源定位符。要解决思路只有一条在所有正常静态资源和后端接口都匹配不到的情况下给浏览器返回index.html并附带text/html的内容类型。至于返回之后Vue Router要跳到哪个页面那是前端的事。这个思路转换过来对应到Spring Boot里就是“兜底资源解析”或者“转发规则”。后面的所有方案其实都是这个思路的不同实现姿势。2. 复现与确认从点下F5到看见Whitelabel Error Page的完整链路2.1 最小复现五步空口无凭先把问题按最简路径复现一遍前端执行npm run build得到dist目录里面有index.html和assets子目录。把dist里的内容复制到src/main/resources/static下。后端执行mvn clean package -DskipTests打出可执行Jar。java -jar target/xxx.jar启动浏览器打开http://localhost:8080/首页正常。在应用里点击进入/login路由页面正常按F5刷新404。如果你不想每次改前端都重新打一次Jar可以在Spring Boot的配置里临时加一行spring.web.resources.static-locationsfile:./dist/这样Spring Boot直接读磁盘上的dist目录前端改完重新build后端重启或者甚至不用重启刷新就能看到效果。这个组合是我在排查这类问题时的首选调试环境比一遍遍打Jar高效得多。2.2 确认根因的三条线索不要凭感觉确认根因用这三条线索第一条浏览器开发者工具Network面板。选中那个404请求名称就是login状态码404响应类型是document。点开响应体一看是Whitelabel Error Page的HTML。第二条服务器日志。Spring Boot 3.2之后的版本遇到静态资源没找到会打一条类似No static resource login.的WARN日志。如果你的Spring Boot版本比较老可以在application.yml里临时开一下logging.level.org.springframework.webDEBUGResourceHttpRequestHandler找不到资源时的debug日志会原形毕露。第三条命令行直接验证这个最快curl -sI http://localhost:8080/login | head -n 5修复前你会看到HTTP/1.1 404和Content-Type: text/html。修复后同一个命令应该返回200。2.3 别把“刷新404”和“打包后白屏”混为一谈排查时容易踩一个坑明明刷新后页面是空白的和404表现不一样。这种“白屏”通常不是路由兜底问题而是Vue打包后的资源路径不对。具体来说index.html加载到了但它里面引用的/js/app.xxxx.js、/css/app.xxxx.css这些资源请求全部404页面自然渲染不出来。控制台会报Failed to load module script或者Loading chunk N failed。判断方法很简单看Network里失败的请求是.js、.css这类带后缀的静态资源还是不带后缀的HTML文档请求。如果失败的是静态资源优先检查Vue的base配置比如Vite里设置为base: ./或者对应部署子路径的/portal/如果失败的是文档请求那才是本文讨论的路由404问题。两者可以同时出现所以排查时一定先把失败请求的URL后缀看清楚别混为一谈。2.4 让前端构建产物自动进Jar而不是靠手拷既然是“打包后”的问题打包环节本身也值得工程化一下。手工复制dist到static目录一次两次可以团队协作时会反复出错。可以在pom.xml里用maven-resources-plugin在prepare-package阶段把前端构建产物自动拷贝到target/classes/staticplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-resources-plugin/artifactId version3.3.1/version executions execution idcopy-vue-dist/id phaseprepare-package/phase goals goalcopy-resources/goal /goals configuration outputDirectory${project.build.outputDirectory}/static/outputDirectory resources resource directory../frontend/dist/directory /resource /resources /configuration /execution /executions /plugin路径按你自己的项目结构调整。用这套配置之后CI里跑一次mvn clean package前端产物和Java代码一起进Jar省掉大量“是不是又忘拷了”的扯皮。3. 四种兜底方案实测我最后留下了PathResourceResolver下面这几种方案都是这类项目里最常见的处理套路我按实战踩坑经验排了个序并给出代码和取舍理由。3.1 方案A自定义PathResourceResolver兜底推荐这是我最推荐的做法。核心思路是给Spring Boot的静态资源处理器加一个自定义的PathResourceResolver在它找不到真实文件时判断当前请求是不是“页面导航请求”是就返回index.html不是就继续返回404。import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.resource.PathResourceResolver; import java.io.IOException; Configuration public class SpaWebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/**) .addResourceLocations(classpath:/static/) .resourceChain(true) .addResolver(new SpaPathResourceResolver()); } static class SpaPathResourceResolver extends PathResourceResolver { Override protected Resource getResource(String resourcePath, Resource location) throws IOException { // 第一步真实文件优先 Resource requested location.createRelative(resourcePath); if (requested.exists() requested.isReadable()) { return requested; } // 第二步只有长得像“页面路由”的请求才兜底 boolean looksLikeFile resourcePath.contains(.); boolean looksLikeApi resourcePath.startsWith(api) || resourcePath.startsWith(actuator); if (!looksLikeFile !looksLikeApi) { Resource index location.createRelative(index.html); if (index.exists() index.isReadable()) { return index; } } return null; } } }这段代码的意图分三层。第一次createRelative是正常解析/login就是一个不存在的文件返回的requested不存在往下走。接着用contains(.)判断请求路径是不是带扩展名带扩展名的一律视为静态资源请求比如/js/app.js、/favicon.ico这类请求不该被兜底否则JS文件收到HTML内容会直接崩溃。不带扩展名且不是api开头的路径才认为是页面路由返回index.html。为什么这个方案最稳因为它没有碰Controller层后端接口的映射优先级完全不受影响。而且它会先做真实文件的存在性判断假设你项目里恰好有一个静态的login.html它仍然会被正常返回不会被粗暴地一刀切换到index.html。resourceChain(true)开了资源链缓存静态资源的性能也不会受多大影响。需要注意一个边界如果业务路由本身包含.比如/article/hello.world这种路径会被误判成静态资源请求。真遇到这种路由把looksLikeFile的判断换成白名单正则即可比如明确排除assets、js、css等目录其余都算页面路由。3.2 方案B写一个SpaForwardFilter另一个直观做法是写一个OncePerRequestFilter在请求进入DispatcherServlet之前判断GET请求、不是API、不是静态资源就forward到index.html。import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; Component public class SpaForwardFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String path request.getRequestURI().substring(request.getContextPath().length()); boolean isGet GET.equals(request.getMethod()); boolean isApi path.equals(/api) || path.startsWith(/api/) || path.startsWith(/actuator); boolean isAsset path.contains(.) !path.endsWith(.html); if (isGet !isApi !isAsset) { request.getRequestDispatcher(/index.html).forward(request, response); return; } filterChain.doFilter(request, response); } }这套代码看起来很直接但它有一个明显缺点filter在Controller之前执行会把后端所有无后缀的GET请求也拦截下来。假设你的后端有一个/health探活接口、一个/captcha验证码生成接口只要它们是无后缀GET请求就会在这个filter里被静默转去index.html。你必须在isApi判断里把这类后端路径都手动列出来等于每次新增接口都要想一遍要不要加白名单长期维护很容易漏。所以我对这个方案的态度是适合快速验证思路或者后端接口极少、路径规则非常清晰的项目。3.3 方案CErrorPageRegistrar一行流简单但慎用Spring Boot在嵌入容器场景下还可以直接用ErrorPageRegistrar把404错误页指到index.htmlimport org.springframework.boot.web.server.ErrorPage; import org.springframework.boot.web.server.ErrorPageRegistrar; import org.springframework.boot.web.server.ErrorPageRegistrarBeanPostProcessor; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; Component public class SpaErrorPageConfig { Bean public ErrorPageRegistrar spaErrorPageRegistrar() { return registry - registry.addErrorPages(new ErrorPage(HttpStatus.NOT_FOUND, /index.html)); } }这是我见过最短的解决方案。但它把所有404都重定向到了index.html包括后端API返回的404。假设前端请求/api/user/999后端本应返回JSON格式的{code:404}结果被重定向成了一段HTML。前端联调时根本分不清“接口不存在”和“路由兜底”排查问题反而更乱。这个方案只适合一个完全没有独立后端API的纯展示项目或者你确定API 404可以全部返回HTML的极简场景。也有人会尝试重写ErrorController来做更精细的控制思路是捕获/error请求判断原始URI是否是api开头不是的话forward到index.html是的话返回JSON。技术上可行但你要同时把Spring Boot默认/error的JSON结构、状态码逻辑全部手工复刻一遍否则Web层报错格式会和以前不一致。我一直不建议为了一个兜底需求去动全局错误处理除非你本来就要重构异常响应结构否则性价比太低。3.4 方案DController正则映射能跑但埋雷还有一个在网上流传很广的做法写一个Controller用正则匹配所有不带点的路径并转发到index.htmlimport org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.server.ResponseStatusException; import jakarta.servlet.http.HttpServletRequest; Controller public class SpaPageController { GetMapping(value {, /{path:[^\\.]*}, /**/{path:[^\\.]*}}) public String forward(HttpServletRequest request) { String uri request.getRequestURI(); if (uri.startsWith(/api)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND); } return forward:/index.html; } }这个方案的隐患在于Controller的匹配优先级高于静态资源处理器。/js/app.js这种带点的资源不会进来但任何不带点的后端GET接口都会被这个Controller抢走。你今天能手动排除/api明天来了个/graphql、/metrics、/sso/callback只要忘加判断就会被这个转发规则吞掉表现为“接口返回了HTML”。它比Filter方案更隐蔽因为代码看起来语义很清晰坑却埋在后端路由的全集里。除非你完全掌控这个项目的所有后端路径否则我不建议大家选它。3.5 选型结论方案侵入性静态资源影响API 404口径推荐程度PathResourceResolver低无影响真实文件优先保持404 JSON推荐SpaForwardFilter中需手动排除真实文件需手动放行API备选ErrorPageRegistrar最低无影响被污染成HTML快速过渡Controller正则高容易吞新接口需手动排除不建议我的结论很简单单Jar部署、前后端放在一个Spring Boot进程里的场景直接用方案A。它把“兜底”限制在静态资源解析的最末端既能满足SPA路由刷新又不干扰Controller和API的正常行为。4. 生产环境还要处理的三件事API口径、MIME类型和Context-path4.1 API路径必须被排除否则前后端404口径会互相污染方案A里我只排除了api和actuator两个前缀实际项目里按需调整。判断API路径的原则是只要这个前缀由后端Controller真正处理就不许进入兜底逻辑。你可以维护一个简单的前缀数组或者直接按业务约定统一为/api/**。如果项目里接入了Spring Security还要同步确认安全配置不会拦截对/index.html的转发否则刷新时会变成302跳登录页表现是另一个“没登录取不出来页面”的问题排查起来又绕一大圈。安全过滤链里记得放行首页、静态资源和转发目标路径http.authorizeHttpRequests(auth - auth .requestMatchers(/, /index.html, /assets/**, /api/**).permitAll() .anyRequest().authenticated() );具体配置取决于你的认证模型。核心就一句话兜底逻辑和你自己的安全规则、API路由三件事必须在同一张白名单里对齐少一个都会在奇怪的地方冒泡。4.2 把index.html当资源文件返回给JS的经典MIME事故如果你用了方案B或方案D并且漏掉了“带后缀路径不兜底”的判断会出现一个很微妙的翻车现场浏览器请求/js/app.js服务器返回的是index.html的内容同时响应头Content-Type是text/html。ES Module规范里对模块脚本有严格的MIME检查浏览器控制台会报Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of text/html。第一次看到这个报错很多人会以为是前端打包产物丢了或者Nginx配错了其实根因就是服务器把HTML内容塞给了JS请求。方案A天然规避了这个问题因为带.的路径在第二步就被拦住了压根不会去取index.html。如果你发现页面里“时不时白屏”看一眼Network里失败的请求是不是.js结尾但响应内容变成了HTML就能基本锁定是这个原因。4.3 设置Context-path后有三个连带调整很多系统不是部署在根路径而是带一个上下文路径比如server.servlet.context-path/portal。这时候三个地方必须保持一致缺一个就会出现另一种404第一Spring Boot的context-path决定后端服务的访问前缀访问入口变成http://host:8080/portal/。第二Vue构建时的资源前缀要跟着变Vite里设置base: /portal/。第三Vue Router创建History实例时的base要和它一致createWebHistory(/portal)。这里最容易漏的是第二个。如果只知道设置router的base忘记改构建base打包出来的index.html里引用的JS路径全部是/js/xxx.js而不是/portal/js/xxx.js。浏览器在/portal/下打开页面去请求/js/xxx.js服务器根路径下没有这个资源于是白屏加一片404。这个问题的表现和“打包后刷新404”完全不同但经常在同一套部署改造里一起出现所以每次改了context-path记得三个地方一起检查。4.4 如果部署形态允许Nginx替代方案其实更省心如果你的运维体系允许把前端静态文件放到Nginx、Spring Boot只负责API那根本不需要在Java里写任何兜底逻辑。Nginx在location /里加一行server { listen 80; root /opt/vue-app/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; } }try_files三个参数从左到右依次尝试先查真实文件$uri再查目录$uri/都没有就用/index.html兜底。这其实和方案A的思路完全一致但发生在更靠前的Web层而且Nginx处理静态文件性能更佳。问题是现实里有些内部项目的部署形态很固定就是要求一个可执行Jar跑起来运维不给加Nginx转发层。这时候Spring Boot内部的兜底方案就成了唯一选择。两条路都走一遍你就能理解为什么这问题在不同人嘴里会有完全不同的解法。4.5 部署后的验收清单不管你用了哪个方案部署完最好按这个清单验一遍# 1. 首页 curl -sI http://localhost:8080/ | head -n 3 # 期望 200Content-Type: text/html # 2. 子路由直接访问核心验收点 curl -sI http://localhost:8080/login | head -n 3 # 修复后期望 200Content-Type: text/html 而不是 404 # 3. JS静态资源 curl -sI http://localhost:8080/js/app.js | head -n 3 # 期望 200Content-Type: text/javascript响应内容绝不能是HTML # 4. 不存在的API curl -s http://localhost:8080/api/not-found # 期望 JSON 404而不是一段HTML这四条分别覆盖了SPA路由、静态资源、API隔离三个核心维度。任何一条不符合预期都能精准定位是哪一层的兜底条件写错了。5. 最后分享三个排查这类问题的小习惯5.1 把“本地文件式调试”跑起来我接手过好几个类似项目上来第一件事就是在application.yml里把spring.web.resources.static-locationsfile:./dist/配上。好处是前端改完后端不用反复打Jar改一下引用路径就能复现和验证。配合方案A的代码改完刷新一次立竿见影整个调试闭环从“改Java代码打Jar重启”缩短到“刷新浏览器”效率差别非常大。5.2 先看失败请求的后缀再下结论排查这类问题我已经养成一个固定动作打开Network面板先把失败请求的URL后缀看清楚。带.js、.css、.png的是资源问题往base、构建产物路径方向查不带后缀、Accept头包含text/html的是路由问题往服务器兜底方向查。这个分类能帮你避开至少一半的弯路。5.3 把部署形态当成第一手信息现在接部署类问题我习惯先问清楚两件事部署包是单Jar还是前后端分开有没有子路径部署。这两个答案基本能预判问题方向单JarSpa大概率是本文讨论的路由兜底问题前后端分开大概率是Nginx那层没配try_files带子路径部署还要多查一层Vite的base和Router的base。说到底这类问题的共同点是前端认为自己是个应用服务器却只认文件。你能做的就是在系统边界处把这两套认知翻译好。我自己第一次处理这个问题时前前后后折腾了大半天先怀疑Vue Router的mode写错了又怀疑静态资源Location配错了最后看了日志才确认是资源解析器根本没有兜底能力。后来我干脆把这个“刷新404”当成前后端一体部署的必修课每换一个项目只要看到History模式就先在代码里部署好方案A那套兜底逻辑后面基本再没被这类问题卡过。
阅读完成 · 觉得有帮助?