Vue项目非根目录部署完整方案:解决静态资源404与路由白屏
Vue 项目从开发环境挪到服务器上跑多数人第一步都是把构建产物丢到根目录配好 Nginx 直接location /指向dist完事。但实际工作里哪有这么顺——公司域名下已经跑着别的系统或者客户要求应用挂在某个子路径下又或者你想把前端塞进对象存储拿 CDN 分发这些场景都绕不开**“非根目录部署”**。后台数据我反复配过好几回这玩意儿说难不难但坑是真的多静态资源 404、路由刷新白屏、接口路径错位一个问题一个坎全踩一遍才摸清门道。这篇就把 Vue 项目部署到非根目录的完整方案掰开揉碎讲清楚。先给没遇到过这需求的朋友解释下非根目录部署是啥意思。正常部署是https://www.example.com/访问你的站点非根目录则是https://www.example.com/my-app/这种——你的项目放在域名某个子路径底下整个应用要在这个子路径下正常工作。别小看这个变化前端构建默认生成的都是绝对路径引用/js/app.js路由默认也绑定/服务器一开子目录全部错位。这篇内容适合谁看自己搭博客、做外包项目要给不同客户部署前端的开发者以及公司内部在统一域名下挂多个前端应用的运维和全栈。我会从为什么要这样做、构建配置、路由配置、服务器配置到连带的后端接口修改把整条链路完整走一遍最后汇总那份踩坑清单照着改基本能一次跑通。1. 部署在子路径下的真实需求与方案选型1.1 哪些场景逼着你非这么做不可先说场景不然你看完觉得折腾半天没必要。最典型的就是域名资源有限多个应用共用一个域名。比如某公司只有一个主域名www.example.com但内部有管理系统、数据看板、官网后台三套前端每套都是独立 Vue 项目。用子路径区分就是最省事的方案/admin、/dashboard、/cms各指一路大家互不干扰运维只要维护一个 Nginx 配置文件就行。第二个场景是CDN 加速与对象存储部署。现在很多静态站点直接把构建产物传到对象存储桶里绑个 CDN 域名访问。但对象存储的目录结构天然就是个路径你不可能保证桶的根目录恰好对应当前项目——总有个project-a、project-b这样的前缀目录这时候子路径部署就是默认形态。第三个场景是避免多版本网关或统一入口的路径冲突。有些团队习惯在网关层做路由分发/api开头转后端服务/app1/开头转前端服务 A/app2/开头转前端服务 B。你的前端项目本身根本不关心路径但接入这种体系就必须让构建产物适配前缀。还有一种很冤的情况原本部署在根目录后来因为新项目要把位置让出来。我接过一个某项目原来在根目录跑得好好的公司又上了新系统必须占根路径整个项目挪到/legacy/下。迁移那天手忙脚乱就是因为没提前规划非根部署方案。1.2 不改配置直接硬上的后果与排查路径不修改任何配置构建完直接把整个dist目录丢到服务器的子目录下会发生什么三连击第一首页能打开但样式全丢。因为 CSS 和 JS 文件的引用路径写死了/css/app.xxx.css浏览器访问https://www.example.com/my-app/时会去https://www.example.com/css/app.xxx.css找资源。服务器对应去根目录的css文件夹里找——那自然不存在全部 404。第二点击路由跳转后一片空白。vue-router默认绑定的路由根路径是/你在https://www.example.com/my-app/下访问页面会匹配不到任何路由显示空白或者直接 404 报错页面。第三直接访问子页面 URL 刷新就 404。比如你用https://www.example.com/my-app/user/list访问用户列表浏览器发给服务器的真实请求是这个完整路径。服务器只能按子目录匹配它找不到对应的文件或内部路由规则直接给你一个 404。这些问题背后的本质是页面跳转靠的是前端路由静态资源加载靠的是绝对路径二者在非根目录部署时都必须知道“我还有一层前缀”。前端三层配置——构建、路由、服务器——缺一层链路就断。下面按顺序一层一层补。2. 构建路径配置publicPath 与 base2.1 Vue CLI 工程与 Vite 工程的配置差异如果你用的是 Vue CLI即vue-cli-service构建的工程配置在vue.config.js里核心选项叫publicPath这是整个非根部署方案里最要命的一个参数。// vue.config.js module.exports { publicPath: process.env.NODE_ENV production ? /my-app/ // 生产环境强制使用子路径 : / // 开发环境保持根路径省得本地调试出问题 }关键规则publicPath必须以/开头并以/结尾。/my-app这种写法不够标准某些版本构建后会有路径拼接怪问题。如果你项目最终部署的位置可能变化比如测试环境是/test/my-app/生产环境是/my-app/建议直接用环境变量控制// .env.production PUBLIC_PATH/my-app/// vue.config.js module.exports { publicPath: process.env.PUBLIC_PATH }这样换环境只改配置文件不用动代码。如果你用的是 Vite 构建 Vue 3 工程配置项换了个名字叫base写在vite.config.js里// vite.config.js export default defineConfig({ base: /my-app/ })Vite 的base同样要求前后斜杠齐全。默认值是/如果你不改成子路径构建后的index.html里资源引用就是/assets/index.xxx.js跟 Vue CLI 不配置时一个下场——全部绝对路径指向根。再补充一个沿革要点Vue CLI 老版本3.x 早期里这配置项还叫baseUrl没多久就废弃改名为publicPath。如果你翻老项目看到baseUrl不用犹豫那是老写法新版本里已不生效升级构建工具链时报错不要觉得奇怪。2.2 publicPath 内部机制与资源引用变化配置publicPath到底改了什么拿构建产物说话最清楚。配置前资源引用长这样注意没有前缀!-- dist/index.html -- script src/js/app.js/script link href/css/app.css relstylesheet构建工具内部默认把所有静态资源引用拼成publicPath 文件名此时publicPath为/结果就是/js/app.js。所以访问https://www.example.com/my-app/时/js/app.js的完整 HTTP 请求是https://www.example.com/js/app.js错位。配置publicPath: /my-app/后构建产物变成!-- dist/index.html -- script src/my-app/js/app.js/script link href/my-app/css/app.css relstylesheet浏览器解析这些引用请求路径变成了https://www.example.com/my-app/js/app.js正好落在服务器子目录下的真实文件名位置——资源加载就通了。这里还得注意一个细节process.env.BASE_URL在 Vue CLI 里就是publicPath的值很多组件里引用图片资源时会写src${process.env.BASE_URL}images/logo.png这个环境变量会自动跟随publicPath变化不要硬编码/assets相对路径除非你确认构建工具帮你处理了。Vite 里对应的是import.meta.env.BASE_URL。// 组件内写法Vite 工程 const imgSrc ${import.meta.env.BASE_URL}images/logo.png实际经验里还有个困扰路由懒加载分包的名字也会带上 publicPath。配置生效后动态import()拆出来的 chunk 文件在网络面板里请求的是/my-app/js/index.abc123.js这证明路径对了。如果只看到/js/index.js说明publicPath没生效先去查环境变量或配置读取是否有问题。3. 前端路由的 base 参数设置3.1 vue-router 的 base 配置与两种模式选择构建路径配好了前端路由还认的是/。这时候vue-router实例需要显式设置base为实际部署的子路径。import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(/my-app/), // 传入子路径 routes: [ { path: /, component: Home }, { path: /user/list, component: UserList } ] })这个base参数的作用是路由跳转时vue-router 会在你写的路由路径前自动拼接这个前缀。比如你调用router.push(/user/list)地址栏最终变成/my-app/user/list而不是裸的/user/list。没有这个参数点击跳转会跑到根路径下在新路径下没有项目部署直接 404 或者被别的应用截胡。如果你用的是 Vue 2 与 vue-router 3写法略有差异// Vue Router 3 const router new VueRouter({ mode: history, base: /my-app/, routes: [...] })这里还有个经典选择题用createWebHistory还是createWebHashHistory。createWebHistoryhistory 模式要求服务器配合重写规则才能支持刷新createWebHashHistoryhash 模式则天然免疫子路径问题。如果你服务器配置不便或者不想折腾服务器的 try_fileshash 模式可以救急——它把路径放在#后面浏览器的实际请求永远指向index.html子路径怎么套都不会破。// 省心方案hash 模式 const router createRouter({ history: createWebHashHistory(), routes: [...] })但你得接受 URL 里带个#比如https://www.example.com/my-app/#/user/list。做内部系统无所谓对外产品或对 URL 颜值有要求的场景还是建议 history 模式加服务器配置。3.2 严格路由路径与嵌套子路由的注意点设置了base之后有个容易误解的地儿路由表里的 path 不要加子路径前缀。比如路由定义应该保持{ path: /user/list, component: UserList }千万不要写成{ path: /my-app/user/list, component: UserList }。vue-router 会拿base和path自动拼接你再写一次就变成/my-app/my-app/user/list这个错误我亲眼见着不少人犯过。嵌套路由children也同理。父级路由写了{ path: /parent, children: [{ path: child }] }最终一条完整路径是/parent/child。部署到/my-app/下真实地址是/my-app/parent/childbase会自动加前缀你不需要在 children 里动什么手脚保持相对路径写法就行。动态路由和懒加载组件也由 base 联动处理。比如路由配置component: () import(./views/Dashboard.vue)构建后产生额外 chunk 文件其请求路径跟随publicPath路由跳转则在base下工作。两者配合好了刷新子页面、跳转子页面、切换到深层页面都能正常渲染。一个我常用的策略是把 base 参数也做成由环境变量驱动这样不同部署环境下地址栏路径自动适配不用每换一次环境改一次代码。// 用环境变量动态确定 base const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })Vite 的import.meta.env.BASE_URL默认取base配置值这就实现了 Vue Router 与构建配置同步推荐优先这么做。Vue CLI 则用process.env.BASE_URL同样跟publicPath联动。4. 服务器端配置Nginx 子路径部署方案4.1 root、alias 与 try_files 的正确打开方式前端两关过了第三关在服务器。拿使用率最高的 Nginx 举例。目标是把https://www.example.com/my-app/映射到服务器上的/var/www/my-app/dist目录。这里有个大坑root 和 alias 的区别。很多人上来就写location /my-app/ { root /var/www/my-app/dist; try_files $uri $uri/ /my-app/index.html; }用root配置时请求/my-app/index.htmlNginx 会去root /my-app/index.html找文件即/var/www/my-app/dist/my-app/index.html——路径多了个前缀文件当然找不到。要改用aliaslocation /my-app/ { alias /var/www/my-app/dist/; try_files $uri $uri/ /my-app/index.html; }alias会把location匹配的前缀/my-app/替换为指定路径请求/my-app/js/app.js时实际读取/var/www/my-app/dist/js/app.js正确。try_files的坑更隐蔽。history 模式下的前端路由要求刷新/my-app/user/list时必须返回index.html内容而不是 404。判定顺序是先看物理文件$uri比如确实存在的js/app.js再找目录$uri/都不存在就回退到子路径下的index.html。location /my-app/ { alias /var/www/my-app/dist/; try_files $uri $uri/ /my-app/index.html; }注意try_files最后一个参数写的是/my-app/index.html不是$uri/index.html。如果你写成/my-app/或者直接/index.html效果完全不同——前者如果目录不存在就直接 404后者会去根路径找index.html导致又错位。这条规则记忆法try_files 兜底永远是指向子路径下的首屏入口。4.2 完整 Nginx 配置示例与 multi-app 场景如果域名下只有一个应用最简单的整站配置长这样server { listen 80; server_name www.example.com; location /my-app/ { alias /var/www/my-app/dist/; index index.html; try_files $uri $uri/ /my-app/index.html; } # 其他路径照常比如根路径下的静态页 location / { root /var/www/root-site; index index.html; } }多个应用共享域名时每个应用一个 location 块server { listen 80; server_name www.example.com; location /admin/ { alias /var/www/admin/dist/; try_files $uri $uri/ /admin/index.html; } location /dashboard/ { alias /var/www/dashboard/dist/; try_files $uri $uri/ /dashboard/index.html; } location /cms/ { alias /var/www/cms/dist/; try_files $uri $uri/ /cms/index.html; } }配置完记得先nginx -t检查语法再重载nginx -s reload。我见过不少人改完忘了重载一脸懵地在那查代码问题实际 Nginx 还是旧配置——这类低级错误最浪费时间。有个特殊场景需要特别提醒如果你的子路径应用不是直接由 Nginx 托管静态文件而是通过 Node 服务或反代到其他端口那 location 块的写法不同location /my-app/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }此时 Node 服务内部如果自己路由到某个 HTML那它自己也必须知道前缀存在Nginx 只是把/my-app/原样转发过去。这种场景下前端路由配置和 publicPath 更得仔细因为后端服务还牵扯到 Vue 的 express 中间件historyApiFallback或对应的 history 模式支持不再展开知道有这层就行。5. 接口请求的连带修改与环境变量管理5.1 API 请求路径与跨域代理的适配前端页面能加载了接口请求又可能出现新问题。核心原则是部署子路径只影响静态资源与路由接口路径能不能共用取决于后端服务的承载方式。如果你的接口本来就部署在完全独立的域名比如api.example.com那不存在路径冲突前端通过环境变量切换接口地址就行。但如果接口与前端共用同一域名比如接口路径是https://www.example.com/api/user/list而你的前端挂在/my-app/下就得确认两件事前端 axios 请求的 baseURL 是否配置为完整接口路径/api子路径跟接口路径之间没有任何关系axios.defaults.baseURL /api不会自动加子路径前缀这没问题。如果后端接口实际也有前缀比如整体迁移到了/my-api/v2那就按后端实际路径配置。开发环境调试时通常会配 devServer 代理// vue.config.jsVue CLI devServer: { proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } }非根目录部署不影响这个代理——开发时你的前端仍跑在localhost:8080根路径下代理根据域名配置把/api转发到后端服务。这个配置只服务于开发环境生产环境 Nginx 侧如果有同源反代需求则要在 server 块里加上location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; }5.2 登录回调地址与第三方跳转非根目录部署还容易漏掉一个环节登录回调。单点登录或第三方授权登录时跳转地址必须和实际部署地址一致。比如回调地址配的是https://www.example.com/callback但项目实际部署在/my-app/下跳回来找到不到对应页面又双叒叕是一个 404。这种情况有两种解法。第一个是回调地址跟着部署前缀走前端代码里读取当前的完整路径去拼回调 URL第二个是后端 / 网关层配代理把根路径下的回调地址转发到子路径下的真实页面。具体用哪种得看登录服务支持什么。我的建议是回调地址用相对路径或前端动态拼接别把回调地址写死在配置文件里。比如前端代码const redirectUri ${window.location.origin}/my-app/login/callback这样不管部署在哪一层回调地址总能落在正确位置。其他连带依赖路径的还有 WebSocket 连接路径、上传文件的 action 地址、分享链接的跳转地址。原则就一条任何需要拼接 URL 的地方优先从 window.location、import.meta.env.BASE_URL、process.env.BASE_URL 取值不要写死常量。搜索替换所有硬编码路径比想象中值钱。6. 非根目录部署的常见故障排查清单6.1 白屏、404、资源加载失败逐一定位把几类最常见的问题和判定方法列一张速查表照着从上往下查基本能覆盖 80% 的故障现象可能原因定位方法解决措施首页打开样式全没静态资源引用路径从根路径加载浏览器 F12看 CSS/JS 请求的实际 URL检查 publicPath / base 是否配置为子路径首页正常点击跳转白屏路由 base 没配置跳转跑根路径去了地址栏 URL 是否有/my-app/前缀配置 vue-router 的 base 参数子页面地址刷新 404服务器 try_files 没有回退到 index.html看响应状态码确认请求实际到达 Nginx 的哪个 location检查 try_files 兜底是否写子路径下的 index.html资源请求路径正常但仍 404Nginx root/alias 拼错路径检查服务器磁盘上文件是否在对应位置用 alias 而不是 root构建产物路径报 Cannot GET /abc后端没有 fallback 规则或者 base 带了多余字符对比实际请求 URL 与路由表中的 path检查 base 末尾斜杠、path 是否误加前缀部分图片加载不了组件内硬编码了绝对路径/images/logo.png查代码里的src绑定改用process.env.BASE_URL或import.meta.env.BASE_URL拼接个人经验里最容易忽视的是首页能正常打开但跳转后白屏的案例。因为你不说外部看真看不出问题——首页加载正常点个按钮挂了用户以为功能 bug实际就是 vue-router 的 base 没配置。排查时第一步永远先看地址栏路径里有没有子路径前缀比看代码快得多。6.2 缓存、版本更新与历史遗留问题部署子路径后另一种隐蔽故障来自浏览器缓存。尤其用过 service worker 或强缓存策略的项目旧资源可能一直被浏览器使用改完路径配置访问到的还是老版本。解决方案有几个发布时修改构建产物文件名Vue 默认带 contenthash只要你没配filenameHashing: false通常没事Nginx 加 Cache-Control 头对index.html禁止强缓存location /my-app/ { alias /var/www/my-app/dist/; try_files $uri $uri/ /my-app/index.html; location ~* \.(html)$ { add_header Cache-Control no-cache, no-store, must-revalidate; } }另外有个容易忽略的细节构建产物里如果有绝对 URL 的 meta 标签或重定向逻辑比如某些 SEO 插件生成 preload 资源它的路径也会基于publicPath。检查构建产物时从dist/index.html开始通读一遍看到任何href/或src/开头的都要警惕。最后补充一个很多人问的问题为什么我把 Nginx 配置改好了访问不到 404文件也确实存在但控制台一直报 MIME 类型错误这往往是因为 Nginx 的include mime.types;没配或者配置被覆盖了。没有正确的 MIME 映射浏览器拿到 JS 文件但拒绝执行报的全是Failed to load module script之类的错。给location里加上include mime.types;别小看这一行少了它你就算路径全对应用照样跑不起来。6.3 宽容路径的调试技巧调试非根目录部署别直接在服务器上瞎改推荐一个低成本高收益的办法本地起一个 Nginx 完全复刻线上配置。在本地机器上建一个/var/www/my-app/dist目录把构建产物丢进去配置一样上去本地先访问localhost/my-app/验证全流程再去线上改改完出错的概率大幅下降。如果线上环境不方便直接操作也可以用 Node 写个简易静态服务器模拟子路径const express require(express) const path require(path) const fs require(fs) const app express() const root /var/www/my-app/dist app.use(/my-app, express.static(root)) // 模拟前端路由的 fallback app.get(/\/my-app\/.*/, (req, res) { res.sendFile(path.join(root, index.html)) }) app.listen(8080)这是开发阶段调试的好工具——它可以验证前端配置是否准确唯一没法模拟的是 Nginx 的try_files和 server 块匹配优先级但已经能覆盖 80% 的路径问题。在实际操作中我还有个习惯改完配置后别急着关 Network 面板一条一条看清楚请求与响应。资源加载路径、状态码、响应体内容信息量比 console 大得多。按前面的排查表把每条对照着看基本都能在十分钟内定位问题。非根目录部署这事儿原理上和正确配置都摊开了说透剩下就是细心。配置从构建、路由到服务器三处同步改齐少一处都可能前功尽弃。