
前阵子接手一个官网后台管理系统的项目两个模块要分开访问但组件库、请求封装、工具函数全在同一套代码里。本来想拆成两个 vue-cli 工程结果算了一下公共代码的同步成本果断放弃。最后选了在同一个 vue-cli 工程里做多页面MPA再给 vue-router 配上 history 模式把地址栏里的 # 去掉。整套跑下来之后我把完整配置和踩过的坑整理成这篇文章从目录结构、vue.config.js、路由实例、devServer 到 Nginx 部署都覆盖给出可以直接复制的写法。适合正在做官网中后台混合项目或者被多入口history 模式折腾得够呛的前端同学。先解释一下为什么值得用这套组合。默认的 vue-cli 是单页应用SPA不管业务拆多少页面最终都挂在一个 index.html 上。多页面MPA的本质是让构建产物同时输出多个 HTML每个 HTML 对应一个独立的应用入口。history 模式则是 vue-router 的两种路由方式之一和 hash 模式的区别在 URL 形态前者是/admin/login后者是/#/admin/login。多页面解决工程层面的事历史模式解决体验层面的事两个搭在一起正好覆盖我遇到的场景。1. 为什么需要多页面 history 路由这套组合1.1 多页面解决的核心痛点单页应用最大的问题是什么东西都往一个入口里塞。你打开一个官网首页浏览器可能要把整套后台管理系统的代码都下载一遍虽然组件是按需加载但公共的 dependencies、业务公共组件、工具库这些很难拆分干净。页面多起来之后单单一个 chunk-vendors 就能到 1MB 以上首屏体验直线下降。多页面把问题从根源上切开了。每个页面有独立的入口文件、独立的 HTML、独立的 router 实例构建时按入口拆分包。官网首页只需要官网的代码后台管理系统只加载后台的代码二者互不污染。另外还有一个经常被忽略的场景独立部署。单页应用部署出去之后改一个页面要全量发布多页面可以做到某个页面单独发布、单独回滚对于多人协作的大项目来说这个能力很实用。1.2 history 模式解决什么问题hash 模式的路由长这样https://example.com/index.html#/about。浏览器不会把#后面的内容发送给服务器所以它天生不需要服务端配合但缺点也明显URL 丑、分享出去别人看到一堆乱码、公众号或者一些平台内置浏览器对带 hash 的链接识别不友好、对于有 SEO 诉求的页面基本等于放弃。history 模式的路由长这样https://example.com/about。它利用的是 HTML5 History APIURL 干净、可读性强、对搜索引擎友好链接就是正常的路径。代价是路由切换虽然是前端完成的但用户手动刷新、直接输入地址访问时服务器必须正确回退到对应的 HTML 页面否则就是 404。这也是很多人配置 history 模式时最头大的地方。1.3 什么场景适合这套方案场景是否推荐原因官网首页 后台管理系统代码同仓库推荐两个独立入口独立部署共用组件库一个项目里既有 PC 端又有移动 H5推荐多入口天然隔离移动端代码不会拖慢 PC需要某些页面不被框架管理如活动页推荐活动页可以纯静态或者用别的技术栈纯内部中后台不关心 URL 和 SEO不推荐hash 模式省掉全套服务端回退配置只有一个入口的小项目不推荐多页面只会增加配置复杂度2. 整体思路与目录结构设计2.1 从 SPA 到 MPA 的最小改造如果你已经有一个 vue-cli 工程改成多页面思路并不复杂vue-cli 的vue.config.js里有一个pages字段官方的解释是多页面应用的 pages 配置只是文档写得比较简约很多人没注意。它的作用是把一个入口变成多个入口每个入口都有独立的 entry、template、filename。每个入口就是一个完整的 Vue 应用有自己的main.js、自己的App.vue、自己的router。这也是多页面和单页面最大的差异——单页面只有一个 root Vue 实例多页面有几个入口就有几个 Vue 实例互不干扰。2.2 推荐的项目目录结构多页面项目最怕目录乱我整理了一个经过实际验证的结构按页面横向切分├── public │ ├── index.html # 官网页面模板 │ └── admin.html # 后台页面模板 ├── src │ ├── common # 跨页面公共代码 │ │ ├── components │ │ ├── utils │ │ └── api │ └── pages │ ├── index # 官网入口 │ │ ├── main.js │ │ ├── App.vue │ │ ├── router │ │ │ └── index.js │ │ └── views │ │ ├── Home.vue │ │ └── About.vue │ └── admin # 后台入口 │ ├── main.js │ ├── App.vue │ ├── router │ │ └── index.js │ └── views │ ├── Login.vue │ └── Dashboard.vue ├── vue.config.js └── package.json注意src/common放的是真正跨页面共享的代码我建议只放组件、工具函数、API 封装这类稳定内容。不要什么都往 common 里塞否则多页面拆分包的意义就没了。2.3 每个页面独立的 router 实例多页面下最忌讳共用一个 router 实例。原因很简单官网的 router 和后台的 router 路由表完全不同官网不需要/dashboard这种路径后台也不该出现/about。如果共用一个 router两个页面加载的是同一份路由表访问后台的/admin/login时官网的 router 反而先匹配到了*通配符直接给你重定向。正确做法是在每个页面的入口文件里创建自己的 router 实例就像下面这样// src/pages/index/main.js import Vue from vue import App from ./App.vue import router from ./router Vue.config.productionTip false new Vue({ router, render: h h(App) }).$mount(#app)src/pages/admin/main.js结构完全一样唯一区别是 import 的是 admin 目录下的 router。这个一页面一实例的原则对 Vuex 同样适用每个页面的 store 也要独立创建页面之间需要共享数据时走 localStorage、sessionStorage 或者后端接口不要试图在多个页面之间共享内存里的状态。3. vue.config.js 多页面配置详解3.1 pages 字段入口、模板、文件名、chunks这是整套配置的核心直接把vue.config.js里的pages键打开就行const path require(path) module.exports { publicPath: /, outputDir: dist, pages: { index: { entry: src/pages/index/main.js, template: public/index.html, filename: index.html, title: 示例官网, // 页面要引用的 chunk默认就是 vendor/common/入口名建议显式写出来 chunks: [chunk-vendors, chunk-common, index] }, admin: { entry: src/pages/admin/main.js, template: public/admin.html, filename: admin/index.html, title: 后台管理系统, chunks: [chunk-vendors, chunk-common, admin] } } }关键点一个个说。filename决定了构建产物里 HTML 的位置。我把后台的 HTML 放在admin/index.html也就是 dist 目录下会多出一个admin文件夹。这样做的好处是后台页面部署后访问路径就是/admin/配合后面的 history 回退和 Nginx 配置URL 语义非常清晰。如果不需要子目录filename直接写admin.html也行但对应的服务器回退配置和路由 base 都要跟着调整。entry指向的是每个页面自己的main.js。这里我推荐用绝对路径vue-cli 对相对路径有时解析出来的结果和预期不一致我踩过一次之后直接统一用path.resolve(__dirname, src/...)或者上面的相对写法相对 vue.config.js 所在目录。chunks是很多人忽略的字段。vue-cli 默认会做代码分割把 node_modules 里的依赖打进chunk-vendors把公共逻辑打进chunk-common。如果这个字段漏写了HTML 文件引用的 chunk 可能不是你想要的多页面之间甚至会出现互相引用对方 chunk 的诡异情况。建议始终显式指定顺序固定成[chunk-vendors, chunk-common, 页面名]。3.2 模板文件与页面标题每个页面最好有自己的模板文件因为不同页面通常有不同的 meta、favicon、甚至不同的 CDN 外链需求。官网模板里可以引统计脚本后台模板里引一套内部监控互不干扰。模板里的标题我建议用 vue-cli 注入的变量而不是写死。vue-cli 会把pages配置里的title字段作为 html-webpack-plugin 的title参数模板里用% htmlWebpackPlugin.options.title %输出!-- public/admin.html -- !DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width,initial-scale1.0 title% htmlWebpackPlugin.options.title %/title /head body div idapp/div /body /html这样每个页面的标题在vue.config.js里统一管理不会改一处漏一处。favicon 如果各页面不同可以在模板的head里用相对路径指向 public 目录下的文件比如link relicon href./favicon.ico。这里要注意相对路径和publicPath的配合后面专门的坑。3.3 公共依赖抽离多页面最担心的就是公共代码重复打包。vue-cli 内置的 splitChunks 已经帮你做了 chunk-vendors 和 chunk-common 的拆分大多数情况不需要手动干预。如果你的页面之间公共代码特别多可以通过chainWebpack细分缓存组module.exports { chainWebpack: config { config.optimization.splitChunks({ cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: chunk-vendors, chunks: all, priority: 10 }, common: { name: chunk-common, minChunks: 2, chunks: all, priority: 1 }, uiLib: { // 例如把 element-ui 单独抽出来后台页面和官网页面都能命中缓存 test: /[\\/]node_modules[\\/](element-ui)[\\/]/, name: chunk-ui-lib, chunks: all, priority: 20 } } }) } }splitChunks 的几个关键参数在做多页面时值得主动理解而不是全靠默认值参数含义多页面项目里的作用minChunks至少被几个 chunk 引用设为 2 时只被一个页面引用的代码不会被抽到 commonmaxInitialRequests页面初始化时最多请求数设置太小会把公共代码打散导致请求过多priority缓存组匹配优先级数值越大的规则越先匹配用于控制抽到哪个组name抽出来的 chunk 文件名必须和 pages 里的 chunks 字段对应我一般不会把 splitChunks 调得太激进。多页面本身已经按入口拆开了公共依赖抽得太碎反而增加 HTTP 请求数首屏速度不一定变快排查问题还麻烦。优先保证 chunk-vendors 和 chunk-common 两个基本组正确即可。3.4 publicPath 全局一致性的坑publicPath决定构建产物中所有静态资源引用的根路径它必须和你最终的部署域名路径一致。举两个常见场景场景一整个站部署在域名根目录https://example.com下那么publicPath: /入口 HTML 里引用的 js/css 路径是/js/chunk-vendors.js。场景二整个站部署在某个子路径下比如通过 nginx 把前端放在https://example.com/portal/那么publicPath: /portal/所有资源引用都会带上/portal/前缀。vue-cli 的publicPath是全局生效的也就是说所有页面共用同一个值。这里就暴露了多页面的一个先天限制如果后台要部署在https://admin.example.com官网部署在https://www.example.com那么单个 vue-cli 工程的publicPath就满足不了这种跨域域名部署因为两个页面引用的资源路径完全不同。这种情况下要么拆工程要么用chainWebpack对每个 entry 单独处理 output成本很高。我对这类需求的态度一向是能用同一个域名下的不同路径就别硬拆不同域名。4. history 模式路由配置与开发环境4.1 在每个页面初始化 routerhistory 模式只需要在创建 router 时把mode改成history同时根据页面的部署路径设置base。官网部署在根路径base 是/后台部署在/admin/下base 就是/admin/// src/pages/index/router/index.js import Vue from vue import VueRouter from vue-router import Home from ../views/Home.vue Vue.use(VueRouter) const router new VueRouter({ mode: history, base: /, routes: [ { path: /, name: home, component: Home }, { path: /about, name: about, component: () import(/* webpackChunkName: about-view */ ../views/About.vue) }, { path: *, redirect: / } ] }) export default router// src/pages/admin/router/index.js import Vue from vue import VueRouter from vue-router import Login from ../views/Login.vue Vue.use(VueRouter) const router new VueRouter({ mode: history, base: /admin/, routes: [ { path: /, redirect: /dashboard }, { path: /login, name: login, component: Login }, { path: /dashboard, name: dashboard, component: () import(/* webpackChunkName: dashboard-view */ ../views/Dashboard.vue) }, { path: *, redirect: /dashboard } ] }) export default routerbase的值不是拍脑袋定的它必须和实际访问的 URL 前缀对应。后台部署在/admin/目录base 就是/admin/此时路由{ path: /login }最终对应的完整 URL 是/admin/login。vue-router 会自己处理 base 和 path 的拼接你在代码里写跳转时只需要写 route name 或者相对路径不需要手动拼/admin/。4.2 devServer 的 historyApiFallback rewrites这是开发阶段最容易踩坑的地方。默认情况下vue-cli 的 devServer 在找不到文件时返回 404但你用 history 模式访问/admin/login时Webpack Dev Server 在内存里的确没有这个文件于是直接 404。不做配置的话你会看到路由跳转没问题一刷新就白屏报错。解决办法是在vue.config.js里配置historyApiFallback的rewritesmodule.exports { // ... devServer: { port: 8080, historyApiFallback: { rewrites: [ { from: /^\/admin\/.*/, to: /admin/index.html }, { from: /^\/admin$/, to: /admin/index.html }, { from: /.*/, to: /index.html } ] } } }这里有两个关键点。第一rewrites是有顺序的要具体规则在前兜底规则在后。如果把{ from: /.*/, to: /index.html }放在最前面那么/admin/login也会被匹配返回的是官网的 index.html后台页面加载的却是官网的入口 JS结果就是白屏加一堆报错。第二from的正则要和实际访问路径匹配。同时处理/admin和/admin/两种情况因为用户可能会直接输入不带尾斜杠的地址。至于为什么尾斜杠重要下一节细说。4.3 base 参数与尾斜杠的讲究vue-router 的base配置在设置时有一个容易踩的细节base值尽量不要省略尾部的/。base: /admin/表示所有后台路由都挂在/admin/这个路径前缀下面如果写成base: /admin匹配边界会变得很微妙有时候/adminpage这种路径也会被误判进后台路由。同时要注意用户直接访问/admin没有尾斜杠时浏览器 URL 是/admin而 router 的 base 是/admin/此时 vue-router 在解析路径时会提示类似[vue-router] Missing base /admin/ in URL的警告页面可能不会按预期渲染。解决方式有两条路一是让服务器把/admin301 重定向到/admin/二是把页面文件名直接做成admin.html并用base: /admin。我在实际项目里更倾向第一种Nginx 加一条跳转开发环境在 devServer 里也尽量让用户习惯用带斜杠的地址。URL 规整统一路由行为最稳定。直接把 devServer 配置里的正则处理好后续生产环境的 Nginx 也做同样的重定向两边行为就一致了。5. 生产环境部署前端与 Nginx 联动5.1 构建产物长什么样执行npm run build之后dist目录大致长这样dist ├── admin │ └── index.html # 后台页面入口 ├── css │ ├── index.[hash].css │ └── admin.[hash].css ├── js │ ├── chunk-vendors.[hash].js │ ├── chunk-common.[hash].js │ ├── index.[hash].js │ └── admin.[hash].js ├── favicon.ico └── index.html # 官网页面入口注意这里admin/index.html引用资源时用的是绝对路径/js/admin.[hash].js正是因为publicPath是/。只要所有静态资源都在 dist 根目录的 js/css 文件夹下子目录里的 HTML 引用根目录资源完全没有问题。5.2 Nginx 双页面回退配置生产环境的 history 模式路由核心就一句话对于那些不存在的路径服务器要正确返回对应的 HTML。官网的路径回退到/index.html后台的路径回退到/admin/index.html不能笼统地全部回退到/index.html。我惯用的 Nginx 配置如下server { listen 80; server_name example.com; root /var/www/html; # 官网页面所有未命中的请求回退到 index.html location / { try_files $uri $uri/ /index.html; } # 后台页面所有 /admin/ 下的未命中请求回退到 /admin/index.html location /admin/ { root /var/www/html; try_files $uri $uri/ /admin/index.html; } # 处理不带尾斜杠的 /admin location /admin { return 301 /admin/; } }这里有两个细节必须说清楚。一个是location /admin/内部用的是root而不是alias。很多人习惯在这个位置写alias /var/www/html/admin/但 alias 和 try_files 搭配时经常出现路径拼错或者回退失效的问题。用root /var/www/html加try_files ... /admin/index.html的写法语义直白、行为稳定我后来一直用它。另一个是location /admin的精确匹配跳转。这一条对应上文说的尾斜杠问题把/admin301 到/admin/避免 vue-router 报 base 的警告同时也保证后台页面的相对资源路径解析正确。5.3 多页面/多应用扩展策略这套配置的扩展性很好。如果后面再加一个移动端 H5 页面只需要三步在src/pages下新建mobile目录、在vue.config.js的pages里加一个mobile入口、在 Nginx 里加一个location /mobile/。三个入口、五个入口都是同一个套路。如果是多个前端项目共用同一台服务器比如官网、后台、H5 分别构建成不同的 dist部署时直接在 nginx 里分别指向不同目录即可互不干扰。多页面场景下我建议每个页面都把静态资源上传到独立的服务器目录并且给 js/css 设置长缓存这样后续某个页面发布时只有对应的 HTML 文件会变化其他页面继续命中缓存性能和安全都兼顾。6. 常见问题与排查记录6.1 刷新页面直接 404症状路由跳转正常点浏览器刷新变成 404控制台看到的是 Nginx 的 404 页面。原因history 模式下刷新会向服务器发起真实请求比如请求/admin/login。如果服务器没有配置回退它会在文件系统里找login这个文件找不到就 404。排查顺序很固定先看开发环境的historyApiFallback.rewrites配了没有再看生产环境的try_files配了没有。两处都配了还有问题大概率是正则匹配顺序不对具体路径被兜底规则提前拦截了。6.2 首屏白屏 控制台资源 404症状页面能打开但一片空白控制台一堆Failed to load resource: the server responded with a status of 404报错的全是 js/css。原因大概率是publicPath和实际部署路径不一致。比如资源路径是https://example.com/js/xxx.js但你的前端部署在https://example.com/project/下资源实际地址是https://example.com/project/js/xxx.js当然 404。解决办法回到vue.config.js确认publicPath是否携带部署子路径如果用的是 Nginx 的 alias 而不是 root优先检查 alias 路径是否拼错。这个问题我排过很多次九成是 publicPath一成是 alias。6.3 页面互相串路由症状访问后台的/admin/login页面加载的却是官网内容或者两个页面都能路由到对方的页面。原因historyApiFallback的兜底规则把后台路径也回退到了/index.html官网入口加载后官方 router 把/admin/login匹配给了*通配路由于是跳到了官网的 404 页。解决办法调整rewrites的顺序把后台的/admin/规则排在兜底之前。另外确认每个页面的 router 是独立实例不要共用一个 router 配置。6.4 页面后台跳转后 JS 报Unexpected token 或Loading chunk failed症状某个页面跳转时控制台报类似于ChunkLoadError或返回的内容是 HTML 但 JS 在解析时报错。原因通常是服务器把本应返回 HTML 的路径错误返回成了入口 HTML但该 HTML 又不符合当前 JS 模块的预期或者资源引用路径不对。另一个常见场景是部署后 HTML 被缓存引用了旧的 hash 文件名而服务器上旧 chunk 已经被清理。解决办法排查try_files和rewrites的路径是否精确给 HTML 文件配置no-cache给带 hash 的 js/css 配置长缓存必要时在构建脚本里加一步清理旧文件的逻辑。6.5 常见问题速查表表现大概率原因处理方向刷新 404服务器未配置 history 回退检查 devServer rewrites / Nginx try_files首屏白屏静态资源 404publicPath 与部署路径不一致修正 vue.config.js 的 publicPath后台页面加载官网内容historyApiFallback 兜底规则太靠前调整 rewrites 顺序后台规则放前面路由跳转正常刷新后 URL 变带 #页面入口没设置 mode: history检查每个页面的 router 配置/admin 访问异常报 Missing base尾斜杠没有被服务器处理Nginx 加 location /admin 的 301 跳转多个页面 chunk 互相加载pages 配置里的 chunks 没显式指定给每个页面写上 chunk-vendors/chunk-common/入口名最后分享两个自查技巧。一是打开浏览器控制台 Network看 HTML 文档请求返回的是什么文件如果是 Nginx 404 或者不是目标 HTML问题就锁定在服务器回退层二是看页面实际加载的 JS 文件名对比dist目录里的产物名字对不上就查缓存和 publicPath。多页面history 模式的坑来来回回就这几个理解原理之后排查起来比背书式试错快得多。