这个问题我太有发言权了。差不多每隔一段时间,就能在技术群里看到有人发一张浏览器控制台截图,满屏红的404,配一句“本地好好的,一部署就废了”,然后底下清一色回复:检查下publicPath。但真去问publicPath怎么设,能一次说清楚的人并不多。
我自己第一次遇到这个坑时也折腾了一整天,从怀疑nginx配置、怀疑服务器路径权限,到怀疑是不是打包工具坏了,最后发现就是vue-cli里publicPath这个配置项搞的鬼。从那次之后,我对静态资源路径这件事就特别敏感,打包配置、部署方案、服务器转发规则会一起看。这篇文章就把我这些年在vue-cli项目里处理publicPath的经验做个系统总结,从原理到配置,从常见场景到疑难排查,一次性讲透。
如果你正在被“本地正常、上线404”折磨,或者正准备把vue项目部署到子目录、CDN、云存储这类非根路径,再或者你想搞明白vue-router的history模式和publicPath到底什么关系,这篇文章就是为你准备的。
1. publicPath到底是干什么的
1.1 一个打包产物引发的血案——从404说起
先还原一下最常见的翻车现场。
你在项目根目录执行:
npm run build一切顺利,dist目录生成,里面的结构大概是这样的:
dist/ ├── index.html ├── css/ │ └── app.3a2b4c.css ├── js/ │ ├── app.3a2b4c.js │ ├── chunk-vendors.7d8e9f.js │ └── ... └── static/ └── img/logo.xxx.png把dist目录传到服务器上,假设服务器地址是http://yourdomain.com,nginx把站点根目录指向dist文件夹。打开首页,咦?页面白屏。按F12,控制台一堆红色报错,比如:
GET http://yourdomain.com/js/app.3a2b4c.js 404 (Not Found) GET http://yourdomain.com/css/app.3a2b4c.css 404 (Not Found)但是你看服务器上的文件,js/app.3a2b4c.js明明就躺在那个位置。这就诡异了。
这时候你把浏览器地址栏里的网址复制出来看一眼,发现访问的并不是http://yourdomain.com/,而是类似http://yourdomain.com/some/path/这样的二级路径。或者你根本是把项目部署在了http://yourdomain.com/demo/这个子目录下。于是真相浮出水面:html文件被正确加载了,但这个html内部引用资源的路径全部指向了域名根目录/js/...,而不是当前所在的子目录/demo/js/...。
这个“资源引用路径的根”,就是publicPath控制的。
1.2 publicPath的三种典型姿势:绝对路径、相对路径、CDN路径
publicPath本质上就是webpack的output.publicPath,vue-cli把它提升到了顶层配置,让你在vue.config.js里就能改。它管的是打包后的index.html内部,所有静态资源的引用前缀。
它有三种典型的取值姿势。
第一种,默认值/。这也是vue-cli在没有额外配置时用的值,表示“所有资源从域名根目录开始找”。打包出来的index.html里,资源引用长这样:
<script src="/js/app.3a2b4c.js"></script> <link href="/css/app.3a2b4c.css" rel="stylesheet">这个配置适合项目放在域名根目录的场景。但是只要你的项目被嵌套在任意一级子路径下,这个配置必炸。
第二种,相对路径./。打包出来的资源引用变成:
<script src="js/app.3a2b4c.js"></script> <link href="css/app.3a2b4c.css" rel="stylesheet">注意,这里没有开头的斜杠。浏览器解析的时候,会基于当前页面URL的相对路径去拼接,所以无论你把dist文件夹放在哪个位置,只要整个目录原封不动地搬过去,资源路径就是对的。这个配置适合你不知道最终部署路径是什么,或者部署路径经常变化的场景。但相对的,如果用了history模式的路由,这种配置会导致二级路由下刷新时资源路径算错,这个问题后面细说。
第三种,完整的CDN绝对地址,比如https://cdn.example.com/my-app/。打包出来的资源引用长这样:
<script src="https://cdn.example.com/my-app/js/app.3a2b4c.js"></script>很明显,这是把静态资源托管到CDN或独立域名时用的,让浏览器直接从CDN拉资源,减轻源站压力。
这三种模式,就是publicPath最基础的全部样子。所有复杂的配置,都是在这三种基础上根据部署环境动态切换。
2. 不同部署场景下的publicPath配置
2.1 部署在域名根目录:最简单的情况
如果你的nginx配置是:
server { listen 80; server_name yourdomain.com; root /var/www/my-project/dist; index index.html; }这种情况下,直接用vue-cli的默认配置就行,什么都不用改。因为所有资源都以/开头,自动拼接成http://yourdomain.com/js/xxx.js,完全匹配服务器上的文件路径。
不过这里有个隐藏问题:如果你用了vue-router的history模式,还得加一条try_files规则,否则用户在http://yourdomain.com/about这种二级路由下刷新,nginx会去找/about这个文件,找到到就404。一般这么配:
location / { try_files $uri $uri/ /index.html; }这也是老生常谈了,这里提一下,是因为后面所有的部署场景都要叠加这个规则,但很多人只改了publicPath,忘了改nginx。
2.2 部署在子目录:nginx子路径场景
这是日常开发和测试环境里最容易遇到的场景。比如你在一台服务器上已经跑着一个主站http://yourdomain.com/,新项目要用http://yourdomain.com/demo/访问,dist文件也放在了服务器的/var/www/demo/dist目录下。nginx配置可能是:
location /demo/ { alias /var/www/demo/dist/; index index.html; try_files $uri $uri/ /demo/index.html; }这种情况下,如果publicPath还是默认的/,index.html加载没问题,但里面的<script src="/js/app.js"></script>会跑到http://yourdomain.com/js/app.js,自然就404了。
正确答案是把publicPath设成子路径:
// vue.config.js module.exports = { publicPath: '/demo/' }这样打包出来的资源引用就变成:
<script src="/demo/js/app.3a2b4c.js"></script>浏览器会正确请求http://yourdomain.com/demo/js/app.3a2b4c.js,nginx通过alias规则映射到服务器磁盘上的对应文件,一切正常。
其实这里很多人分不清root和alias的差异。简单说,root会把location后面的路径拼接在root目录后面,比如root/var/www+ location/demo/,最后去/var/www/demo/找文件;而alias是直接把location路径替换为alias指定的路径,alias/var/www/demo/dist/+ 请求/demo/js/app.js,最后找/var/www/demo/dist/js/app.js。理解了这个区别,你配置子目录部署时就不会一头雾水。
2.3 部署在CDN或对象存储:带跨域前缀的完整URL
再往后,如果你做的是独立的前端项目,打算把js、css、图片这些全部推送到CDN,或者直接用OSS/S3这类对象存储来做静态托管,那publicPath就需要设成完整的URL。
// vue.config.js module.exports = { publicPath: process.env.CDN_BASE_URL || '/' }在CI/CD的构建阶段,注入不同环境的CDN_BASE_URL环境变量,比如https://cdn.example.com/project-a/,打包出来的资源就全部带上CDN前缀。这样index.html可以放在任意位置,资源从CDN拉取,加载速度和并发能力都能得到保障。
这里有一个容易踩的坑:如果你用了对象存储,并且配置了CDN加速,CDN回源时如果没配好,资源路径会变成双重前缀,比如https://cdn.example.com/project-a/project-a/js/app.js,这就是base路径和CDN上的目录结构没对齐导致的。我的建议是,CDN上的存储路径前缀和publicPath保持一致,比如publicPath是https://cdn.example.com/project-a/,那文件在存储桶里也应该放在project-a这个目录下。
3. 用环境变量区分测试和生产配置
3.1 vue.config.js中的动态配置方案
实际项目里,很少只有一个部署环境。本地开发、测试服、生产服、预发布,可能路径都不一样。这时候硬编码publicPath就不合适了。
vue-cli原生支持环境变量文件机制,你可以创建:
.env # 所有环境都会加载的基础配置 .env.development # 仅开发模式加载 .env.production # 仅生产构建加载在文件里定义:
# .env.production VUE_APP_PUBLIC_PATH=/demo/然后在vue.config.js里读取:
// vue.config.js module.exports = { publicPath: process.env.VUE_APP_PUBLIC_PATH || '/' }注意了,vue-cli对VUE_APP_开头的变量会做静态替换,允许你在vue.config.js和业务代码里通过process.env.VUE_APP_PUBLIC_PATH访问。这样不同环境用不同.env文件配置不同的路径,构建脚本不用改。
如果你需要更灵活的控制,还可以直接结合打包命令传参。在package.json里配置:
{ "scripts": { "build:test": "vue-cli-service build --mode production --env-mode test", "build:prod": "vue-cli-service build --mode production" } }然后在构建脚本里根据环境变量再覆盖:
// vue.config.js const publicPathMap = { test: '/test-app/', prod: '/', cdn: 'https://cdn.example.com/app/' }; module.exports = { publicPath: publicPathMap[process.env.DEPLOY_ENV] || '/' };在CI管道里执行DEPLOY_ENV=test npm run build:test,就能灵活控制最终打出来的包用哪个publicPath。
3.2 基于shell脚本或CI流的完整打包方案
上面的环境变量虽然能区分,但如果你和我一样,经常同时维护好几个前端项目,每个项目的部署根路径都不一样,每次手改.env很容易出错。我自己的做法是写一个构建脚本,统一管理。
#!/bin/bash # build.sh DEPLOY_ENV=$1 case $DEPLOY_ENV in test) export VUE_APP_PUBLIC_PATH=/test/ ;; stage) export VUE_APP_PUBLIC_PATH=/stage/ ;; prod) export VUE_APP_PUBLIC_PATH=/ ;; cdn) export VUE_APP_PUBLIC_PATH=https://cdn.example.com/app/ ;; *) echo "Usage: ./build.sh [test|stage|prod|cdn]" exit 1 ;; esac npm run build然后执行:
chmod +x build.sh ./build.sh test这样打包前就把公共路径注入到环境变量,vue.config.js统一读取。好处是部署路径和打包脚本放在一起,换环境只需要改脚本里一个变量,不用每个项目都去翻配置。
如果你用GitLab CI或GitHub Actions,也可以把同样的逻辑搬到CI配置里,在构建阶段根据分支或tag设置环境变量。这样能达到的效果是:代码合并到dev分支自动构建并部署到/dev/路径,合并到main分支自动部署到根路径,全程不需要人工干预。
4. 实战:vue + django打包部署场景下的publicPath与跨域问题
4.1 前后端分离部署的两种模式
最近社区里vue+django打包部署的讨论很多,我发现很多人的问题其实包含了两个点:一个是publicPath,一个是跨域。这两个问题经常被混在一起,但其实一个是前端配置的事,一个是后端接口的事。
我先梳理一下vue+django常见的两种部署模式。
第一种,完全分离部署。前端dist部署到nginx,django用uwsgi跑在8080端口,前端通过axios直接请求http://api.domain.com/api/xxx,或者请求http://domain.com:8080/api/xxx。这种情况下前端和后端是两个独立的域名或端口,必然存在跨域,需要django配CORS。
第二种,统一域名部署。nginx同时接收前端页面请求和后端接口请求,通过location规则把/api/转发给uwsgi后端,前端页面仍然通过域名根路径或子路径访问,但是接口走相对路径/api/xxx,由nginx做反向代理。这种情况下因为页面和接口同源,不存在跨域问题。
很多人说“vue+django打包部署后无法跨域”,我猜大概率是用的第一种模式,然后django侧没配CORS,或者配了CORS但配置有误。
4.2 后端集成模式的publicPath设置
如果你选择的是第二种模式,而且django不是只提供API,还要负责渲染部分页面,把dist目录集成到django的模板里,那publicPath的设置又有讲究。
假设整个服务挂在http://yourdomain.com/下,django的静态文件收集功能把你的前端资源从dist目录收集到STATIC_ROOT,这里的publicPath设置为/static/就比较合理。
// vue.config.js module.exports = { publicPath: '/static/', outputDir: 'dist/' }打包后资源引用:
<script src="/static/js/app.3a2b4c.js"></script>然后django settings里配置:
STATIC_URL = '/static/' STATICFILES_DIRS = [ BASE_DIR / 'dist', ]运行python manage.py collectstatic时,因为django会把dist目录下的静态文件收集到静态目录,资源路径对齐。
但这里有个容易犯的错:如果你不加公众路径,用了相对路径./,打包出来的资源引用是js/app.xxx.js,当django渲染模板时,页面URL如果带了一级路径,比如http://yourdomain.com/some/page/,浏览器会把这个相对路径解析成http://yourdomain.com/some/page/js/app.xxx.js,然后404。这就是为什么后端集成方案里,publicPath强烈建议使用绝对路径,也就是以/开头的完整路径,而不要用相对路径。
4.3 跨域问题的解决思路
再说跨域的坑。
如果你用的是完全分离模式,即前端nginx和后端django(uwsgi)分别在不同域名或端口,那么后端必须启用CORS。django侧最简单的方案是安装django-cors-headers:
pip install django-cors-headers在settings.py里:
INSTALLED_APPS = [ ... 'corsheaders', ... ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ... ] CORS_ALLOWED_ORIGINS = [ 'http://yourdomain.com', 'https://yourdomain.com', ]注意CorsMiddleware的位置,文档建议放在所有能生成响应的中间件之前,尤其是CommonMiddleware之前,否则有时候跨域头会加不上。
但如果你和我一样是折腾型选手,其实更建议的做法是统一域名,用nginx把前后端放在同一个origin下。比如:
server { listen 80; server_name yourdomain.com; # 前端页面 location / { root /var/www/dist; try_files $uri $uri/ /index.html; } # 后端API location /api/ { include uwsgi_params; uwsgi_pass 127.0.0.1:8080; } }这样前端请求/api/xxx,和后端接口同源,axios不需要baseURL指向别的域名,也就没有跨域问题了。这个方案在部署层面的复杂度比“前端+后端+CORS”要低,也更不容易出幺蛾子,我个人比较推荐。
有同学可能会问:那我用第一种分离模式,把接口域名写在axios的baseURL里,不也一样能用吗?能用,但CORS配置、cookie跨域、预检请求这些都会带来额外的心智负担。你如果只是个人项目或者中小型应用,统一域名部署的收益是最明显的。
5. 常见问题与排查技巧实录
5.1 404问题速查表
我盘点了一下这些年帮人排查时遇到的典型问题,整理成一张速查表,遇到404先对照着查一下。
| 现象 | 深挖检查项 | 常见原因 | 解决方案 |
|---|---|---|---|
| 页面白屏,控制台报js/css 404 | 看看URL路径前缀是什么 | publicPath默认/,但项目部署在子目录 | publicPath改为子目录路径 |
| 页面能开,但图片/字体图标404 | 检查img、font文件引用 | 代码里用了相对路径引用静态资源 | 用require或import引用资源,或者统一走publicPath |
| 首页正常,二级路由刷新后404 | 看nginx日志和URL路径 | nginx没配try_files,或publicPath用了相对路径 | 加try_files规则,history模式用绝对路径publicPath |
| 部署在CDN/OSS后html里资源路径少了前缀 | 对比html和存储桶实际路径 | publicPath配置错误,没有带CDN前缀 | publicPath设成完整CDN地址 |
| 双击index.html本地打开白屏 | 看file协议下资源请求 | 绝对路径/在file协议下解析异常 | 本地预览用serve等http服务,或者构建时用./ |
| Django模板渲染后JS能加载但接口跨域 | 看浏览器Network里接口响应头 | 用了分离部署但django没配CORS | 配置django-cors-headers,或改统一域名部署 |
5.2 部署后发现css引用的字体图标跨域
这个坑比较隐蔽。页面功能和样式都正常,就是字体图标加载不出来,控制台提示font from origin 'http://yourdomain.com' has been blocked from loading by Cross-Origin Resource Sharing policy。
这个问题的根源在于你的字体文件是外链的,或者部署后字体路径变了,浏览器在加载字体文件时做了跨域校验。排查思路是打开Network,看字体文件的完整请求URL和响应头的Access-Control-Allow-Origin。一般处理办法就是让nginx在字体文件所在的location里加上:
location ~*\.(woff2?|eot|ttf|otf)$ { add_header Access-Control-Allow-Origin *; }但你别忘了,这个问题的前置条件是字体文件路径本身要正确,如果字体请求本身就是404,那得先解决publicPath的问题。
5.3 开发环境下publicPath怎么兼顾
有人问,publicPath改了,本地开发怎么办?其实开发模式下publicPath是另一个维度的逻辑。
vue-cli开发服务器的publicPath默认也是/,如果你在vue.config.js里把publicPath写成/demo/,开发服务器也会自动把路径映射到http://localhost:8080/demo/下,浏览器访问要加后缀。所以有些项目为了方便,开发环境的publicPath和生产环境是分开的。
// vue.config.js module.exports = { publicPath: process.env.NODE_ENV === 'production' ? '/demo/' // 生产打包用这个 : '/' // 本地开发保持根路径 }我在多个环境同时开发时,都是这么处理的。开发环境保持简单的根路径,避免HMR热更新出现路径错位,生产环境再用动态环境变量控制不同部署目标。
5.4 排查路径问题的三个高效命令
最后分享三个排查路径相关的命令行技巧。
第一个,构建后直接检查产物里的路径。用grep搜一下index.html里的前缀:
grep -o 'src="[^"]*"' dist/index.html | head -20一眼就能看出资源引用是/js/xxx.js还是./js/xxx.js还是https://cdn.../js/xxx.js。
第二个,启动一个本地静态服务,模拟服务器环境。不要再双击index.html了,那个file协议和线上的http(s)差异太大。
npx serve -s dist这个命令会把dist目录作为一个单页应用伺服起来,访问路径和线上环境接近,排查路径问题比打开file协议可靠太多。
第三个,nginx如果没起来或者配置不对,先看error.log:
tail -f /var/log/nginx/error.log很多404其实nginx已经在日志里写明了原因,比如文件不存在、目录权限不足、rewrite规则有误,这些信息往往比浏览器端的报错更有用。
6. 最后再分享一个小技巧
publicPath这个问题,表面上看是配置项的问题,实际上是对“浏览器如何根据URL解析资源路径”这件事的理解。
我个人这些年踩坑下来的体会是:如果你对路径没谱,先在服务器上建一个最简单HTML,里面写个<script src="/js/test.js"></script>,用不同的访问路径去访问这个HTML,看浏览器实际请求的URL是什么,一下就明白了。这个排查思路适用于任何静态资源路径问题,不局限于vue-cli。
还有,用了vue-router的history模式,千万别省nginx的try_files规则。publicPath解决的是“加载入口页后,入口页里引用的资源去哪找”,try_files解决的是“刷新某个路由时,服务器该怎么响应”。两个问题互为补充,少了哪个都会出幺蛾子。
如果项目后续要扩展多级目录部署,或者迁移到CDN,建议从一开始就把publicPath的配置抽出来,用环境变量控制,不要散落在业务代码里。等真正要换部署方式的时候,你要做的只是改一个变量,重新构建一次,而不是翻遍代码去改资源引用。