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 与 base
2.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" rel="stylesheet">构建工具内部默认把所有静态资源引用拼成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" rel="stylesheet">浏览器解析这些引用,请求路径变成了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。createWebHistory(history 模式)要求服务器配合重写规则才能支持刷新,createWebHashHistory(hash 模式)则天然免疫子路径问题。如果你服务器配置不便,或者不想折腾服务器的 try_files,hash 模式可以救急——它把路径放在#后面,浏览器的实际请求永远指向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/child,base会自动加前缀,你不需要在 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.html,Nginx 会去root + /my-app/index.html找文件,即/var/www/my-app/dist/my-app/index.html——路径多了个前缀,文件当然找不到。要改用alias:
location /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.js(Vue 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 |
| 资源请求路径正常但仍 404 | Nginx 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 大得多。按前面的排查表把每条对照着看,基本都能在十分钟内定位问题。非根目录部署这事儿,原理上和正确配置都摊开了说透,剩下就是细心。配置从构建、路由到服务器三处同步改齐,少一处都可能前功尽弃。