说实话,vue3+vite做的项目,绝大多数时候单页就够了,一套路由、一个入口、一份构建产物,开发和部署都很省心。但等真正碰到“某个页面要独立上线”、“某个模块体积大到拖垮首屏”、“不同团队想拆分交付”这类需求时,你就会发现单页结构变成了短板。我前段时间刚把一个vite+vue3的管理系统从单页改造成多页,过程中踩了不少坑,也把Vite多页的机制摸透了,这篇就完整记录一下改造思路和实操细节,希望能帮到正在做同样事情的开发者。
1. 从SPA换道MPA:什么需求会逼你动手,什么场景其实不该改
1.1 非改不可的三类业务场景
先聊一个最实际的问题:什么情况下你才需要把单页改成多页?很多人看标题就冲进来,但我觉得先判断“要不要改”比“怎么改”更重要。
第一类是深链路和SEO需求。SPA虽然可以做客户端路由,但搜索引擎对JS渲染页面的收录一直不友好,而且某些页面希望直接被外部系统通过固定URL引用,比如给客户单独分享一个数据报告页、一个活动落地页,这时候如果所有内容都挂在同一个index.html下面,部署和权限控制都很别扭。多页结构可以让每个页面拥有独立的HTML文档,URL天然对应物理文件,引用和抓取都直白很多。
第二类是单页应用体量失控。典型的场景是:主管理后台用了大量表格和表单组件,而某个业务子模块又是纯展示型大屏,依赖独立的图表库(比如ECharts、AntV)。在SPA架构下,这些依赖很容易被打进同一个bundle或公共chunk里,导致用户即便只打开首页也要下载几兆字节的图表库代码。拆成多页后,每个页面只加载自己需要的代码,大屏页独立承担图表库体积,主后台可以保持轻量。
第三类是团队协作约束下的产物。有些大项目被多个小组共同维护,虽然公司在用微前端方案,但微前端的基础设施建设成本较高,不是所有团队都愿意立刻推行。这时把不同的业务线拆成独立页面,各小组各自维护入口文件和依赖清单,用相对独立的方式迭代,反而比硬凑微前端更务实。
1.2 并不建议改多页的两种特殊情况
有反面案例,我也得提醒你别乱改。
第一种是核心业务需要登录态平滑流转的中后台系统。如果A页面和B页面之间频繁切换,而且共享大量业务数据,比如一个“订单管理”里从列表页跳到详情页再跳到编辑页,这种强烈耦合的流程用多页会很痛苦——每次跨页面跳转都要重新初始化应用、重新拉取用户信息,操作体验割裂严重。这种场景更适合SPA加上合理的路由懒加载,而不是单纯为了“多页”而多页。
第二种是小型项目,总共三五个页面、两三个工程师维护,多页的收益根本体现不出来,反而因为多入口、多HTML模板的维护成本增加工作负担。改造是有代价的,得让收益大于代价再动手。
2. 先摸清Vite多页的底层机制:入口变了,打包结构才真正分家
2.1 rollupOptions.input是唯一的开关
Vite底层用Rollup做生产构建,所以多页改造的核心就是配置build.rollupOptions.input。很多人没意识到的一个重点是:Vite默认会把项目根目录下的index.html当作唯一入口,你写再多路由文件它也只打包成一个HTML。要让Vite生成多个HTML页面,必须显式声明多个HTML入口。
改造前典型的单页配置:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], })改造后的多页配置:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' export default defineConfig({ plugins: [vue()], build: { rollupOptions: { input: { main: resolve(__dirname, 'src/pages/main/index.html'), dashboard: resolve(__dirname, 'src/pages/dashboard/index.html'), admin: resolve(__dirname, 'src/pages/admin/index.html') } } } })input对象里的键名(main、dashboard、admin)影响着最终产物里资源的命名和组织方式。这不是一个无关紧要的细节,后面部署的时候你会深刻体会到。
另外一个关键点:HTML文件路径决定了打包后的目录层级。比如你把入口HTML放在src/pages/main/index.html,构建出来的页面路径会保留这种目录关系,通常是dist/src/pages/main/index.html这种形态,部署时候nginx得匹配这个路径;如果你希望产物路径干净,可以把页面级HTML放在根目录或更上层目录,比如根目录下的main.html,构建出来就是dist/main.html。我个人的建议是:不要纠结入口文件藏在哪,先确定你希望线上URL长什么样,然后反向设计目录结构和产物路径。
2.2 多入口下build配置的连带变化
入口从一个变成多个后,build里还有几个配置要同步处理。
build.outDir决定了输出目录,单页通常叫dist,多页可以继续用dist,也可以按业务拆成dist下的子目录,这取决于你HTML文件本身的路径。build.assetsDir是资源目录名,默认是assets,多页下资源会按照入口分组生成,因为不同入口可以配置不同的assetsDir。
还有个容易忽略的是build.sourcemap。多页项目里,线上排查问题的难度比SPA大,因为错误堆栈会指向不同的入口产物,建议在开发环境开启sourcemap,生产环境根据团队习惯决定是否保留,但至少构建时要有能力生成,不要等出了问题才后悔。
另外,如果你原本在root配置上做过文章,比如把项目根目录指向了src,注意多页入口的路径也要跟着变化,否则resolve出来的路径就不对。我的建议是:不要动root,就用默认的项目根目录,然后所有入口都按相对根目录的绝对路径来写,最简单也不会出错。
3. 实测改造全流程:目录重排、入口文件与HTML模板一次性拆干净
3.1 推荐目录结构:把pages作为第一级业务单元
改造前先看原有SPA结构,典型情况长这样:
project ├── index.html ├── vite.config.js └── src ├── main.js ├── App.vue ├── router │ └── index.js ├── views │ ├── Home.vue │ ├── Dashboard.vue │ └── Admin.vue ├── components ├── store └── api改造后,我建议按“页面作为独立单元”来组织:
project ├── vite.config.js └── src ├── common │ ├── components # 跨页面共享的组件 │ ├── api # 封装好的请求层 │ ├── store # 共享状态(如果跨页面确实需要) │ └── styles # 全局样式 └── pages ├── main # 主后台页面 │ ├── index.html │ ├── main.js │ ├── App.vue │ └── router.js ├── dashboard # 数据大屏页 │ ├── index.html │ ├── main.js │ ├── App.vue │ └── router.js └── admin # 独立管理页 ├── index.html ├── main.js ├── App.vue └── router.jssrc/common放的是所有页面都需要的基础代码,src/pages下每个目录是一个完整的可独立构建页面。这里的核心原则是:每个页面拥有自己的index.html、入口JS和根组件,页面之间除了common层之外,不再互相引用内部业务代码。如果哪天某个页面要从项目里独立出去,直接把对应目录拷走并补一个自己的vite配置就行。
3.2 逐个页面入口文件的写法
从原本的src/main.js转化为多个页面入口,其实改动不大,但要注意每个页面有自己“挂载根节点”的基线。先看一个典型的页面入口长什么样:
// src/pages/dashboard/main.js import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' const app = createApp(App) app.use(createPinia()) app.use(router) app.mount('#app')如果两三个页面的入口逻辑高度相似,你可以把公共初始化过程抽成一个函数放在common里:
// src/common/bootstrap.js import { createApp } from 'vue' import { createPinia } from 'pinia' export function bootstrap({ rootComponent, router }) { const app = createApp(rootComponent) app.use(createPinia()) if (router) { app.use(router) } return app }然后各页面入口变成:
import { bootstrap } from '@/common/bootstrap' import App from './App.vue' import router from './router' bootstrap({ rootComponent: App, router }).mount('#app')有人会问:这样抽一层会不会过度设计?我的观点是,超过三个页面入口工程这么做非常值得,因为后续想在初始化阶段统一接入埋点、全局错误处理、权限初始化时,只需要改bootstrap.js一个文件,不需要在三个入口里各自补代码。这也算是我实际改造中比较满意的一个决策。
3.3 index.html模板的拆分与公共头部处理
每个页面都需要自己的index.html,这是Vite的硬性要求。HTML模板可以参照原来的根index.html改,但有几个细节要注意。
<!-- src/pages/dashboard/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>数据可视化大屏</title> <script> // 每个页面独有的全局变量可以写在这里 window.__PAGE_NAME__ = 'dashboard' </script> </head> <body> <div id="app"></div> <script type="module" src="/src/pages/dashboard/main.js"></script> </body> </html>关键在<script src="/src/pages/dashboard/main.js">这个路径。它看起来很奇怪,/src/...可是在开发服务器里,路径就是映射到项目根目录的,所以可以正常访问;但在生产构建后,Vite构建插件会在HTML里对JS脚本路径做一次重写,依赖配置的base选项生成最终的资源URL。这里建议永远使用以项目根为基准的绝对路径,不要用相对路径./src/...,否则在部分目录嵌套的场景下,开发服务器会解析错位。
页面标题和meta描述要根据页面内容各写各的,这也是多页在SEO上的自然优势。如果你有很多页面HTML head结构是重复的,可以借助Vite的vite-plugin-html这类插件统一注入公共的meta或统计脚本,但要注意每个页面引入插件配置时的pages映射对应关系。
还有一点,原来的根目录index.html如果已经没人用,改造完成后建议删掉,或者把它作为main主后台页面的入口文件。因为只要项目根目录存在index.html,构建时就算你没在input里显式声明它,Vite在部分场景下仍然会默认处理它,从而生成多余的产物。最干净的做法是:入口HTML全部放进src/pages/*,根目录下不再留任何HTML文件。
4. 路由、共享组件与公共逻辑在多页架构里的正确姿势
4.1 一个页面一套路由,页面间跳转别再用Vue Router
多页模式下,路由职责要重新划分。Vue Router依然可以用,但它只负责管理单个页面内部的子路由。比如主后台页面里,菜单对应/user/list、/user/detail这些子页面,这仍然交给Vue Router处理;但大屏页和主后台页之间的跳转,就不再通过router.push('/dashboard')实现了,而是用window.location.href或者普通的<a href>标签。
具体到每个页面的路由配置:
// src/pages/main/router.js import { createRouter, createWebHistory } from 'vue-router' import UserList from './views/UserList.vue' import UserDetail from './views/UserDetail.vue' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', redirect: '/user/list' }, { path: '/user/list', component: UserList }, { path: '/user/detail/:id', component: UserDetail }, ], }) export default router如果你把主后台页面部署在服务器子路径下,记得给createWebHistory传入base参数,一般可以写成import.meta.env.BASE_URL,它能自动读取Vite的base配置,比较稳妥。比如Vite里配了base: '/admin/',那createWebHistory(import.meta.env.BASE_URL)对应的路由base就是/admin/。
页面间跳转推荐直接使用原生写法:
// 从主后台跳到大屏页 window.location.href = '/dashboard/dashboard.html' // 或者模板里 <a href="/dashboard/dashboard.html">打开大屏</a>有一个常见误区需要特别提一下:有些人会用router.push跳转到其他页面,表面看URL变了,实际只是修改了history,页面并不会重新加载,也不会跳转到另一个HTML页面,最终的结果要么是白屏要么是路由匹配不到。所以多页开发一定要养成习惯,跨页跳转只用原生链接,页面内子路由才交给Vue Router。
4.2 公共依赖拆包:既省重复代码又保证缓存效率
多页模式在构建时,会因为多个入口共享同一套基础库(vue、vue-router、pinia等)而产生公共依赖如何处理的问题。Vite/Rollup默认的产物策略不一定能最优处理,常见两种情况:要么公共库被重复打进每个页面,体积翻倍;要么全部抽成一个巨大的共享chunk,导致即便某个页面只用了Vue也非得加载一大份公共资源。
我的做法是手动配置manualChunks,把确定不变的基础库抽成稳定的独立chunk,页面业务代码再按入口自动分包:
// vite.config.js build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) { return 'vue-vendor' } if (id.includes('echarts') || id.includes('zrender')) { return 'chart-vendor' } if (id.includes('axios')) { return 'http-vendor' } return 'vendor' } }, }, }, }manualChunks支持对象写法和函数写法。对象写法适合静态列清单,函数写法可以按模块路径做更精确的分类。注意不要让npm包名之间出现父子字符串的交叉匹配过于宽泛的规则,否则会把你不想拆进vendor的内容也一并收走,具体表现是产物里的vendor chunk异常庞大。
经过这样的配置,大屏页面引用图表库时,图表库chunk和Vue基础chunk都具备了独立缓存能力,首次加载后浏览器能把公共部分缓存住,后续其他页面跳转时复用缓存,加载速度提升明显。这也是多页改造相比SPA更快更可感知的优化点。
4.3 状态共享和请求层怎么设计
单页时,Pinia store是全局单例,页面切换不会丢失数据;改成多页后,每个页面入口都独立创建了Vue应用示例,store天然互相隔离。这里要做出取舍:
如果两个页面确实需要共享用户登录态或基础配置,让它们各自去请求接口、各自维护store即可,不要幻想在某页内存一个状态,跳转到另一页后还能读取到——进程级别的隔离决定了这是不可能的。持久化的共享数据(比如token)必须主动写入localStorage或sessionStorage,需要时再读取。
当然,请求层是可以完全共享的。比如把axios实例和接口封装放在src/common/api里,每个页面都通过@/common/api/user的路径直接import使用,通过相同的拦截器统一处理token注入、错误提示、登录失效跳转逻辑。跨页面保持同一套接口调用方式和错误处理,开发体验就和SPA差不多。
// src/common/api/request.js import axios from 'axios' import { useToast } from '@/common/components/toast' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }) service.interceptors.request.use((config) => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( (response) => { return response.data }, (error) => { useToast().error(error.message) return Promise.reject(error) } ) export default service5. 公共资源、公共路径、base配置与多页打包的暗坑
5.1 base配置会决定你部署到子路径是否翻车
Vite的base配置决定了构建产物中所有静态资源引用的根路径。单页项目里,如果部署在域名根路径,base默认的'/'通常没问题;但多页项目经常会被部署在子路径下,比如https://example.com/admin/、https://example.com/dashboard/,这时base不配好,资源全部404。
我推荐两种策略,按你团队实际部署方式选一种。
如果你的页面要部署在任意子路径下,可以把base配置成'./',这样构建出来的HTML里引用资源的路径是相对路径,页面HTML在哪一层,资源就在它相对的那个assets目录下找,迁移目录比较灵活。
export default defineConfig({ base: './', plugins: [vue()], build: { rollupOptions: { input: { /* 多入口 */ }, }, }, })但要注意,base: './'环境下,如果你在代码里使用import.meta.env.BASE_URL,得到的就是'./',某些需要求绝对路径的场景(比如给后端传完整资源地址)需要再处理。
如果你的部署路径是固定的,建议直接写死实际子路径,比如base: '/admin/'。这样资源路径绝对清晰,也更符合静态托管/nginx alias的常规习惯。缺点是多页页面如果各自放在不同子路径下,要让所有页面都共用同一个base,反而会被约束住。我实测下来更推荐:不同业务页面放在不同子路径时用base: './',页面内部路由用createWebHistory(import.meta.env.BASE_URL)时自动匹配相对路径,部署最稳。
5.2 public目录的资源不能被多页的“相对路径”坑到
多页项目里,public目录里的静态资源(如favicon.ico、robots.txt、全局配置文件)依然会被原样复制到dist根目录,不受入口路径影响。也就是说,你的页面如果位于dist/src/pages/dashboard/index.html,直接引用/config.json可以拿到dist/config.json,但如果用了相对路径config.json,它会指向dist/src/pages/dashboard/config.json,找不到资源。
所以对public目录下的全局资源,建议始终用/xxx的绝对路径形式引用。这里注意与静态转移约定配合——如果你允许base影响最终URL,public下路径前缀也会被处理,引用路径遵循部署后的真实前缀即可。
5.3 环境变量在多页面中的注入边界
Vite的环境变量机制在多页模式下依然有效,但只有以VITE_开头的变量才会在构建时被暴露到客户端代码中。不同页面如果想使用不同的配置,可以在入口文件里通过import.meta.env读取,或者运行前在构建脚本里动态传入不同的--mode。
比较迷惑人的是.env文件的作用域。Vite会根据mode加载对应的.env.[mode]文件,但多页构建是单次执行,所有页面共用同一组环境配置。如果你需要A页面调用接口A、B页面调用接口B,建议把两套接口地址都放在同一份环境配置里,然后在各页面入口按页面标识自行选择。
// .env.production VITE_API_BASE_URL=https://api.example.com VITE_DASHBOARD_API_URL=https://dash-api.example.com// src/pages/dashboard/main.js const apiBase = import.meta.env.VITE_DASHBOARD_API_URL || import.meta.env.VITE_API_BASE_URL这种“一个项目多套接口地址”的场景,在多页项目里很常见,提前想好方案能省很多事。还有一个容易踩的坑:在index.html里直接写import.meta.env是不生效的,HTML模板内联脚本里读不到Vite的环境变量注入,要么把逻辑放在JS模块里,要么通过HTML插件的transformIndexHtml功能去处理。
6. 构建产物检查、本地预览与上线后最容易翻车的细节
6.1 先跑build,仔细检查dist目录结构
改完配置不要急着部署,先执行npm run build,然后认真看一下dist目录的真实结构。不同入口HTML的位置、assets的目录层级、chunk文件的命名规律,这些信息会让你对部署时应该怎么配nginx心里有数。
一个正常的构建产物可能长这样:
dist ├── src │ └── pages │ ├── main │ │ ├── index.html │ │ └── assets │ │ ├── index-xxxx.js │ │ └── index-xxxx.css │ ├── dashboard │ │ ├── index.html │ │ └── assets │ │ ├── dashboard-xxxx.js │ │ ├── chart-vendor-xxxx.js │ │ └── index-xxxx.css │ └── admin │ ├── index.html │ └── assets │ ├── admin-xxxx.js │ └── index-xxxx.css └── config.json如果你不喜欢这么深的目录,有两个方向可以调整:一是把页面级HTML入口上移到项目根目录(比如main.html、dashboard.html),构建产物就干净了;二是在构建完成后加一步脚本把文件挪到目标目录,但这属于“用魔法打败魔法”,维护成本高,不推荐。最干净的做法还是在项目结构设计阶段就规划好HTML入口的位置。
6.2 用vite preview验证部署形态
构建完毕,本地先用vite preview跑一遍,它能模拟生产服务器的静态资源服务行为。打开各个页面的URL,重点检查三件事:
- 页面HTML是否正常返回,标题是否匹配。
- 页面里的JS和CSS资源路径是否是正确的绝对/相对路径,有没有404请求。
- 页面内部的子路由在刷新后是否正确回退到对应HTML(即
try_files需要生效的部分)。
vite preview默认只服务dist目录,但它对路径回退的处理和真实nginx有一定差异,尤其是直接访问某个深层路由URL时行为不完全一致,所以最终还是要靠nginx来验证。
6.3 nginx多页部署的核心写法
如果直接在nginx里沿用单页时代的配置,你多半会翻车。单页时代逻辑是“所有URL最终回退到index.html”,多页时代必须“每个页面目录各自回退到自己的index.html”。实现思路是分location处理。
server { listen 80; server_name example.com; root /opt/www/dist; index index.html; # 主后台页面:对应 src/pages/main/index.html location /src/pages/main/ { try_files $uri $uri/ /src/pages/main/index.html; } # 大屏页面 location /src/pages/dashboard/ { try_files $uri $uri/ /src/pages/dashboard/index.html; } # 其他动态资源正常回退 location / { try_files $uri $uri/ =404; } }如果你的入口HTML提在根目录,配置更简单:
location /dashboard/ { try_files $uri $uri/ /dashboard.html; }部署完后,记得逐个页面刷新访问一次,尤其要测“页面内子路由的URL直接刷新”这种场景,这是多页最容易出问题的点。我遇到过不少线上事故,都是因为刷新某个详情页URL时被回退到首页HTML,导致页面白屏。
6.4 多页做完了,可以考虑的进阶优化
最后说几个多页改造完成后可以顺手做的优化,都是我在项目上线后逐步加上的,效果不错。
资源预加载配置可以按页面区分。大屏页依赖的图表库体积大,可以在大屏HTML的head里手动给关键的chunk加<link rel="modulepreload">,让浏览器尽快预取。构建时Vite已自动为当前页面引用的chunk添加了预加载,所以这个操作主要针对跨页跳转前需要提前加载的目标页面资源。
如果页面数量逐渐增多,可以考虑做一个自动化生成入口配置的小脚本。入口的input对象写起来还好,但当你有二三十个页面时,每次新增页面都要改vite.config.js就很繁琐。可以用Node脚本扫描src/pages目录下所有index.html,自动生成input映射:
import { globSync } from 'glob' import { resolve } from 'path' const htmlFiles = globSync('src/pages/*/index.html') const input = {} htmlFiles.forEach((file) => { const name = file.split('/')[2] input[name] = resolve(__dirname, file) }) export default defineConfig({ plugins: [vue()], build: { rollupOptions: { input, }, }, })这种做法我验证过,能省掉大量重复劳动,而且新页面只要按目录规范创建,就自动进入构建产物,很适合参与页面较多的中长期项目。改造多页不是把一个SPA简单劈成几份,而是重新梳理业务边界、构建策略和部署规则。如果你正在做类似的事情,按我上面这几步走,先把入口、base、产物路径和nginx回退这四个点搞定,主体工作就完成了七成,剩下就是根据自己项目的组件和依赖情况去调整分包策略。