Vue 项目部署到非根目录:publicPath、路由 base 与 Nginx 配置全解
说实话做前端这些年部署环节翻车的场景我见得太多。最常见的一种本地npm run build一切正常高高兴兴把 dist 扔到服务器子目录结果页面白屏打开控制台一眼望去全是 404JS、CSS 资源全从根路径去请求了。如果你也正被“Vue 项目部署到非根目录”这个问题卡住这篇就针对这个场景把构建配置、路由 base、服务器回退这些底层逻辑一次讲透。所谓非根目录指的是访问入口不是https://域名/而是https://域名/app/这样的子路径。这在企业应用里极其常见一套服务器部署多个系统、客户要求用子路径区分生产与测试环境、静态资源托管也要挂到某个目录下面。你只要能理解“publicPath、router base、服务器目录必须对齐”这一个核心后面所有配置都是顺水推舟的事。1. 非根目录部署的两个“根”问题1.1 部署后白屏问题到底出在哪把项目放到子路径下白屏的本质通常是下面两种“根”没对齐。第一种是资源加载失败。浏览器打开https://域名/app/HTML 文件正常加载但 HTML 里引用的 JS、CSS 是按绝对路径https://域名/js/app.js去请求的服务器在根路径下根本没有这个文件于是加载失败页面没有脚本驱动自然白屏。这种问题最直接的判断方式就是打开 Network 面板看一眼 JS/CSS 请求的 URL 前缀是不是已经带上了/app/。第二种是路由匹配失败。项目用的是 history 模式路由浏览器里访问https://域名/app/user/list服务器没有对应的物理文件也没有做任何回退直接就返回 404 页面。这个情况的死法跟资源加载失败不一样页面有样式但刷新或直接访问深层链接时是 404。这两种“根”分别由两个不同配置控制。资源加载路径由构建工具的publicPathVue CLI 叫 publicPathVite 里叫 base控制路由匹配前缀由 Vue Router 的base控制。如果只改了路由 base 而忘了 publicPath那资源还是从根路径找反过来只改 publicPath 而忘了 router base那刷新深层路由就会出问题。两个配置通常必须同时改再配合服务器目录结构才能真正跑起来。1.2 三条路径必须对齐publicPath、router base、服务器目录我把这个要求叫“三对齐”。publicPath打包后资源加载的根路径。默认是/你部署到/app/就要改成/app/。router base前端路由匹配的基准路径。默认/同时也要改成/app/。服务器部署目录静态文件实际放在哪个目录下。你 Nginx 里 location 配置的是/app/那么文件就要放在对应目录。这三个任何一处对不上呈现出来的症状都不一样。publicPath 没改资源 404router base 没改路由 404 或刷新 404服务器目录没放对直接 404 或目录列表。提示这三者必须以“生产环境最终访问路径”为基准而不是本地开发路径。很多人先在本地把 publicPath 改成子路径结果开发服务器也跟着错位反而排查半天。通常更合理的做法是让开发环境保持默认仅生产环境启用子路径前缀。2. 路由模式怎么选hash 还是 history2.1 history 模式更适合工程化场景history 模式Vue Router 里对应createWebHistory是大多数后端可配置场景下的首选因为 URL 干净、不带#也更贴近普通网站的观感。但它有个前提——服务器必须配合做回退。你要让服务器在收到类似/app/foo且找不到对应文件时统一回退到/app/index.html由前端路由自己接管。Nginx 的try_files干的就是这个活。很多第一次接触非根目录的人会问history 模式部署到子路径为什么配置了路由器 base 还不够这其实没冲突前端路由管的是“浏览器地址栏变化之后怎么渲染组件”但浏览器地址栏变化之后发出去的 HTTP 请求得先经过服务器。服务器不认识这个路径请求就断了。所以 history 模式依赖“服务器回退 前端路由 base”两者配合。2.2 静态托管或不想动服务器时hash 模式更省心如果你部署在静态托管平台、对象存储、内网静态目录里或者你根本改不了服务器规则直接上 hash 模式createWebHashHistory最省心。hash 模式的特点就是路径变化发生在#之后而#后面的内容根本不会发送到服务器所以不存在“刷新 404”的问题。hash 模式有个附带好处当你要把同一套前端包部署到多个路径下时也可以把构建的 publicPath 设成相对路径./配合 hash 路由打包产物几乎可以在任何目录下运行无需为每一个部署路径分别构建一次。缺点就是 URL 里多个#看起来不够正式部分内部系统不介意倒也无所谓。对比项history 模式hash 模式URL 形式/app/user/list/app/#/user/list是否需要服务器回退需要不需要部署到多路径的灵活性需要分别构建或动态 publicPath可以用相对路径通用美观程度好一般适用场景自己可控 Nginx、Tomcat静态托管、对象存储、无法改服务器规则如果你拿不准我给个参考部署在 Nginx 上且自己掌控规则优先 history部署在同事只给你一个文件夹、服务器规则不让碰的场景直接用 hash。3. Vue CLI 项目完整改造实录3.1 第一步把 publicPath 从根路径改到子路径拿 Vue CLI 项目举例根目录下找vue.config.js核心就是调publicPath。// vue.config.js const { defineConfig } require(vue/cli-service) module.exports defineConfig({ publicPath: process.env.VUE_APP_PUBLIC_PATH || / })我在项目里更习惯把路径放进环境变量这样本地开发和多种环境构建可以灵活切换。比如根目录下建两个文件# .env.development VUE_APP_PUBLIC_PATH/# .env.production VUE_APP_PUBLIC_PATH/app/然后 vue.config.js 里读取这个变量。这样本地开发完全不碰子路径打包生产时自动变成/app/。const { defineConfig } require(vue/cli-service) module.exports defineConfig({ publicPath: process.env.VUE_APP_PUBLIC_PATH || / })注意publicPath的值以/开头并以/结尾。写成/app/而不要写/app否则拼接资源路径时容易出斜杠问题。3.2 第二步路由 base 和生产路径保持一致在 Vue Router 入口文件里加上base// src/router/index.js import Vue from vue import VueRouter from vue-router import routes from ./routes const router new VueRouter({ mode: history, base: process.env.VUE_APP_PUBLIC_PATH || /, routes }) export default router这里我用的是同一个环境变量VUE_APP_PUBLIC_PATH目的就是保证 base 和 publicPath 永远一致。如果环境变量有多个来源最好只维护一个避免改漏。加了 base 之后代码里的router.push(/login)、router-link to/home这些写法完全不需要改Vue Router 会自动在跳转路径前面拼接 base。你千万不要自己去拼/app/login那样一旦以后换部署路径所有代码里的路径都要跟着改。真正要留意的是那些绕过路由直接操作地址的代码比如window.location.href /xxx、a href/xxx这些不会经过 router base 拼接部署在子路径下就全错了。项目里建议全局搜索一下location.href和href/统一改成 router 跳转或基于环境变量拼接。3.3 第三步index.html 模板里的资源引用Vue CLI 的 html-webpack-plugin 会自动处理打包后的 JS/CSS 注入所以模板里不需要手写 script 标签。但如果你在public/index.html里写了 favicon、字体等静态资源的绝对路径也要跟着改。Vue CLI 在模板里注入了一个变量BASE_URL它对应的就是 publicPath 的值link relicon href% BASE_URL %favicon.ico link relstylesheet href% BASE_URL %css/app.css这样打包部署后资源都会自动加上/app/前缀。尤其 favicon 这个坑很隐蔽很多人的 favicon 一直在报 404。3.4 第四步本地开发如何模拟子路径如果你希望在本地也模拟生产环境的子路径其实 Vue CLI 也支持在 vue.config.js 里配置 devServer 的 historyApiFallback。但说实话这个配置有时候会干扰正常开发体验我一般只在联调阶段才打开。// vue.config.js const path require(path) const publicPath process.env.VUE_APP_PUBLIC_PATH || / module.exports defineConfig({ publicPath, devServer: { historyApiFallback: { index: publicPath index.html } } })注意 historyApiFallback 的index要写成拼接后的路径。如果你只想让开发环境保持干净不配这一段代码也可以只要确保构建产物在生产环境正确加载就行。很多人过度配置 devServer反而导致localhost:8080直接打开是空白还要多绕一步访问localhost:8080/app/。3.5 Vite 项目怎么处理Vite 项目和 Vue CLI 思路一致只是配置字段叫base而不是publicPath。在vite.config.js里// vite.config.js export default defineConfig(({ command }) { return { base: command serve ? / : /app/ } })路由部分如果是 Vue Router 4配置更简单直接使用 Vite 暴露的BASE_URL// src/router/index.js import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })import.meta.env.BASE_URL是 Vite 根据配置的base自动生成的变量生产构建时自动带上/app/前缀。这个设计很贴心你只要在vite.config.js里把base配好路由这边的 base 基本不用手动再写一遍。4. 服务器端配合Nginx 与静态托管方案4.1 Nginx 子路径部署alias 和 try_files 缺一不可如果你用的是 Nginx核心配置在 server 块里加一个 location。假设前端文件放在/opt/www/app/访问路径是https://域名/app/server { listen 80; server_name your-domain; location /app/ { alias /opt/www/app/; index index.html; try_files $uri $uri/ /app/index.html; } }这里的alias会把请求的/app/前缀替换成/opt/www/app/。如果你写成root那请求/app/js/xxx.js会把 URL 原样拼到 root 后面变成/opt/www/app/js/xxx.js目录结构不同就容易 404。两者别混用。try_files最后一项/app/index.html务必写完整路径它是处理 history 模式刷新问题的核心。如果只写了try_files $uri $uri/ /index.html;刷新/app/user/list时会回退到根目录的 index.html同样加载异常。如果你的站点还需要同时服务根路径下的其他静态资源可以把两者分开配置。location /配根目录location /app/配子目录互不干扰。4.2 对象存储和静态托管的兜底方案如果你用的是对象存储这类没法自己写 try_files 的平台history 模式会非常痛苦。这种场景下我用得最多的还是 hash 模式解决方案就是前面说的publicPath 设为/app/router 用 hash 模式浏览器地址栏里是https://域名/app/#/user/list。请求到服务器的始终只有/app/对象存储能找到 index.html其他交给前端路由处理。部分对象存储支持配置“404 重定向规则”和“错误码重定向”也能实现把/app/xxx的请求重写到/app/index.html但不同的平台配置项差异较大需要具体情况具体排查。我的个人建议是除非实在没办法否则优先 hash 模式省事也稳定。4.3 多套系统共用一个域名时的部署划分再说一种很常见的场景一台服务器上要跑管理员后台、前台门户、还有个移动端 H5全部用同一个域名区分路径。合理结构是/opt/www/ ├── admin/ # 对应 https://域名/admin/ ├── portal/ # 对应 https://域名/portal/ └── mobile/ # 对应 https://域名/m/每个前端项目在构建时把publicPath分别配成/admin/、/portal/、/m/Nginx 里分别做 location 指向对应目录。每个项目配置相互独立前端路由 base 也在各自项目里配置好。这样一套 Nginx 就能承载多个系统升级和回滚也可以按目录独立操作比把多个应用混在同一个根目录下干净得多。5. 踩坑清单常见的非根目录部署问题排查5.1 Network 面板先定位是哪一类 404遇到部署后白屏我习惯先看浏览器 Console 和 Network 面板。如果直接能看到js、css请求是https://域名/js/xxx.js这种开头没有/app/前缀那就是 publicPath 没设对。如果请求地址已经是/app/js/xxx.js但 404那就是服务器目录结构或者 alias/root 配置有问题。如果页面能加载但刷新子路由 404再看是不是 router base 或 try_files 的问题。判断顺序很关键不要一上来就改 Nginx 或改配置先用 Network 定位是哪层出了问题效率高很多。5.2 public 目录下的资源引用public目录下的文件打包时不会经过编译处理部署后一般位于子路径的根下。假设 publicPath 是/app/那 public 目录里的图片部署后的访问路径就是/app/img/logo.png。如果你在代码里直接写src/img/logo.png那请求就跑到根路径去了这也是常见坑。解决办法也很简单图片资源能走import或 require 的就走打包流程走不了的就用环境变量拼路径比如const BASE_URL process.env.BASE_URL || / return ${BASE_URL}img/logo.pngVue CLI 在代码里可以通过process.env.BASE_URL获取 publicPath 的值。Vite 则对应import.meta.env.BASE_URL。5.3 axios 请求路径也要跟着环境走部署到非根目录后接口请求路径有时候也会跟着出问题。如果 axios 请求写死了/api/login在根路径部署时没问题一旦页面在/app/下请求还是会发到https://域名/api/login。多数时候这恰好是后端接口地址所以很隐蔽但有些后端接口是挂在子路径下的请求就会 404。更值得关注的是场景是当前后端接口路径和前端子路径耦合在一起的情况。建议把 axios 的baseURL也抽成环境变量VUE_APP_API_BASE_URLhttps://api.example.com然后 axios 里引用axios.defaults.baseURL process.env.VUE_APP_API_BASE_URL这样部署路径怎么改都不影响接口请求。5.4 Cookie 作用域与登录态问题这是一个非常隐蔽但高频出现的坑部署在子路径后页面能打开但登录状态时灵时不灵。原因很可能是后端在设置 Cookie 时把Path写成了/app而某些请求走的却是/api或根路径浏览器不会把/app下的 Cookie 发给/api的请求。这个不是纯前端配置能解决的通常需要和后端协调把 Cookie 的 Path 设为/或者保持前端所有请求和 Cookie 作用域一致。我遇到过不止一次这种问题前端部署配置全对结果后端同事悄悄把 Session 的 Cookie 作用域给限定了排查了半天。所以如果你部署子路径后发现登录态反复出问题先让后端看一眼 Set-Cookie 的 Path 属性。5.5 部署后出现不可预测的“拼接路径”问题有时候你把 publicPath 和 router base 都配置正确了但项目代码里依然出现类似${publicPath}api或者字符串拼接的路径。这种问题很难通过全局搜索一网打尽因为你不知道哪些地方拼漏了/。经验之谈代码里所有路径都尽量交给 router 或环境变量解决少手动拼接。像location.href /这种写法定式一旦部署子路径必挂。如果历史代码里这种写法太多优先重构掉实在来不及改可以定义一个全局方法统一出子路径前缀至少将来换路径时只改一处。最后关于这套方案的几个实操心得我从实战里总结下来非根目录部署不是一个“改一处就行”的配置项而是“前端构建、路由、服务器、接口、Cookie”五层之间的协调。把这几个点都纳入改造范围部署基本一次就能过环境变量统一管理 publicPath生产和开发分开router base 从同一个环境变量读取保证和 publicPath 一致服务器配置Nginx try_files、alias和目录结构确定后再改前端配置接口和 Cookie 问题部署时提前跟后端对齐不要等上线后再补。我个人在项目里的习惯是先在本地用一个纯静态目录模拟一遍生产环境确认资源路径、路由刷新都没问题再去找运维要服务器配置。这样能提前暴露八成以上的部署坑。你按这个思路走Vue 项目部署到非根目录这件事基本就只是“改配置 验证”的机械操作了。