拿一个同时存在用户门户、登录页、后台管理的 Vue 项目来举例:如果只用 Vite 默认的单页方式,所有页面都挤在一个index.html和一个App.vue里,路由、权限、构建体积全揉在一起。Vite 的多入口配置就是来解决这个问题的:一个工程维护多份 HTML 入口,一次构建输出多份独立页面,每个入口拥有自己的 JS 和 CSS。这篇就把多入口配置的完整思路、目录组织、开发调试、部署路径和打包优化一次讲清楚。
1. 先把多入口场景说清楚:为什么一个仓库会同时存在多份 HTML
1.1 单页应用在多模块组合场景下的约束
很多第一次接触多入口的人会问:门户和后台不是能用 Vue Router 切成两个路由模块吗?理论上可以,但实际上会让项目维护变得很别扭。
比如门户首页可能对首屏体积特别敏感,用户打开页面几秒钟内需要看到内容;而后台管理依赖的是 Element 这类重量级组件库,体积大、加载慢。如果门户、后台、登录页全在一个 SPA 里,用户访问门户时也会把后台的代码一起下载,首屏性能自然被拖累。
再比如权限隔离的问题。后台需要登录鉴权,门户通常是公开的,登录页完全独立。放在同一个 Router 里,路由守卫的代码会越写越复杂,要反复判断“当前是哪个业务模块”“这个模块需不需要登录”。一旦某个低优先级页面出了问题,整个前端应用可能都打不开。多入口的真正价值不是炫技,而是让每个页面从 HTML 层面就各自独立,互不拖累,互不阻塞。
1.2 Vite 多入口配置的底层逻辑
Vite 的多入口从原理上看很直白:一个入口等于一个 HTML 文件加一个 JS 启动文件。默认的index.html是单入口,多入口配置就是告诉 Vite“我不只有一个 HTML,还有admin.html、login.html、activity.html,请把每一份都单独打包”。
关键在build.rollupOptions.input字段,它是 Rollup 层面的入口配置,接受一个对象,key 是入口名称,value 是 HTML 文件的绝对路径。Vite 在构建时会根据这个对象对所有 HTML 分别做依赖分析,产出一个对应的 HTML 文件和各自的静态资源。
可以把它理解成在同一个工程里同时跑了好几个“小型单页应用”,但只执行一次依赖安装、一次 ESLint 检查、一次打包命令。共享的组件、工具函数可以放在公共目录里复用,业务页面则各管各的入口。
1.3 什么场景适合,什么场景别硬上
我判断一个项目要不要做多入口,通常看三条标准:
- 页面之间的业务隔离度高,比如门户是公开的、后台是私密的、登录页是纯粹的表单页。
- 各页面加载的资源差异明显,门户不需要后台的重量级 UI 库,活动页可能只用了几个小组件。
- 希望独立的 HTML 部署能力,比如运营活动页要单独发布、单独回滚,而不影响主站。
反过来,如果这个项目本质就是一个后台管理系统,所有功能都在登录之后访问,那老老实实用单入口加 Vue Router 更合适。多入口会引入额外的文件组织成本和部署配置成本,没有收益就不必硬上。
2. 文件组织先理清:多入口项目目录怎么摆最省心
2.1 推荐的工程目录结构
目录结构是决定多入口项目维护体验的关键。我见过有人把所有页面的代码都丢在src/views下,通过路由来区分,这不叫多入口,只是把一个 SPA 拆成了多个路由模块。真正的多入口需要对每个页面建立起完整的“入口意识”。
我的习惯是按照src/pages来划分,每个子目录代表一个入口页面的全部业务代码:
project-root/ ├── index.html ├── admin.html ├── login.html ├── vite.config.js ├── package.json └── src/ ├── common/ # 所有入口共享的组件、工具函数 │ ├── components/ │ │ ├── AppHeader.vue │ │ └── Loading.vue │ ├── composables/ │ │ └── useAuth.js │ └── utils/ │ └── request.js └── pages/ ├── portal/ # 门户首页入口 │ ├── main.js │ ├── App.vue │ └── views/ ├── admin/ # 后台管理入口 │ ├── main.js │ ├── App.vue │ ├── router/ │ ├── stores/ │ └── views/ └── login/ # 独立登录页入口 ├── main.js └── App.vue根目录下的index.html、admin.html、login.html是三个 HTML 容器,src 下的pages目录对应三个页面级入口。共享代码放src/common,页面私有的组件、路由、状态管理都放在各自的pages/xxx下。
这个结构的好处是:每个页面的边界清清楚楚,新增页面时只要复制一个目录再加一个 HTML 文件,不会动到其他页面的代码。
2.2 HTML 文件里写什么
每一份入口 HTML 都是一个完整的页面容器,标题、描述、入口脚本都是独立配置。以门户首页为例:
<!-- 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> <meta name="description" content="门户首页" /> </head> <body> <div id="app"></div> <script type="module" src="/src/pages/portal/main.js"></script> </body> </html>后台管理的admin.html也类似,只是标题、描述、脚本路径换成对应的页面入口:
<!-- admin.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> </head> <body> <div id="app"></div> <script type="module" src="/src/pages/admin/main.js"></script> </body> </html>注意 script 的src使用的是以/开头的绝对路径,指向src/pages下各个入口的main.js,不要写成相对自身目录的路径,否则开发服务器解析依赖时容易出现模块路径错乱。
2.3 入口 key 的命名对产物有影响
build.rollupOptions.input对象里的 key 不只是用在配置里,它还会影响打包产物的命名。比如我把index.html的入口 key 命名为portal,构建后assets目录下就会出现portal-xxxx.js;如果把后台入口命名为admin,对应产物就是admin-xxxx.js。
| 入口 HTML | input key | 产出的 JS 文件名(示意) |
|---|---|---|
| index.html | portal | portal-3f2a9b.js |
| admin.html | admin | admin-8c1d4e.js |
| login.html | login | login-1a0b7c.js |
命名保持一致能显著降低排查问题的成本。你在浏览器控制台看到报错来自admin-xxxx.js,立刻就知道是后台入口的问题,而不是要去一堆 hash 文件里猜。
3. 核心配置一次讲透:vite.config 里的 input 与构建产物
3.1 基础版配置:逐个声明入口
最直观的多入口配置就是手动把每一份 HTML 声明到 input 里:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'node:path' export default defineConfig({ plugins: [vue()], build: { rollupOptions: { input: { portal: resolve(__dirname, 'index.html'), admin: resolve(__dirname, 'admin.html'), login: resolve(__dirname, 'login.html'), }, }, }, })这份配置做完之后,直接运行vite build,dist 目录下就会同时产出index.html、admin.html、login.html三份可部署页面。Vue 插件会自动分析每个 HTML 里 script 指向的 main.js,把每个页面依赖的组件、CSS 都打包到一起。
基础版适合入口数量固定、很少增删的项目。如果入口会越来越多,手动维护 input 对象就显得笨重了。
3.2 自动扫描入口:避免每加一个页面就改一次配置
我更推荐把入口收集写成一个小函数,用 Node 的文件系统模块去扫描根目录下所有 HTML 文件。这样以后新增页面只需要增加一个 HTML 文件和对应的 src/pages 目录,无需改动vite.config.js。
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { readdirSync } from 'node:fs' import { resolve } from 'node:path' function collectEntries() { const entries = {} const files = readdirSync(__dirname) for (const file of files) { if (file.endsWith('.html')) { const name = file.replace(/\.html$/, '') entries[name] = resolve(__dirname, file) } } return entries } export default defineConfig({ plugins: [vue()], build: { rollupOptions: { input: collectEntries(), }, }, })如果你把 package.json 里的type设成了module,__dirname在 ESM 环境下不可用,可以换成import.meta.dirname或者用fileURLToPath(new URL('.', import.meta.url))来取项目根目录。扫描逻辑不变,只是一个取路径的差异。
自动扫描的唯一风险是会把一些临时生成的 HTML 也当成了入口。比如根目录下不小心留下test.html,构建后就会莫名其妙多出一个 test 页面。所以页面文件一定要放在固定目录,或者干脆把扫描范围限定在src/pages下面的子目录 HTML 里,同时增加过滤逻辑。
3.3 构建产物解析
多入口构建完成后,dist 目录大致是这样:
dist/ ├── index.html ├── admin.html ├── login.html └── assets/ ├── login-3f2a9b.js ├── login-3f2a9b.css ├── admin-8c1d4e.js ├── admin-8c1d4e.css ├── index-4f0c6a.js ├── index-4f0c6a.css └── vendor-9a1b2c.js每个 HTML 都对应独立的 JS 和 CSS,公共依赖会根据依赖关系被抽取到公共 chunk 中。这里的 vendor 默认不一定有,如果希望打包出来的公共依赖有个固定名字,就需要手动配置 chunk 拆分。这个优化我会在第 6 章详细展开。
4. 开发调试避坑:URL 访问、中间件与热更新
4.1 开发服务器下怎么访问各个页面
配置多入口后,开发服务器默认不需要额外调整。启动vite后,直接用浏览器访问:
http://localhost:5173/打开index.htmlhttp://localhost:5173/admin.html打开admin.htmlhttp://localhost:5173/login.html打开login.html
但有一个容易忽略的点:Vite 默认的appType是spa,意味着遇到不存在的路径时,开发服务器会回退到index.html。如果你直接访问http://localhost:5173/admin,大概率会看到门户首页的内容而不是后台页面,因为服务器把/admin当成了 SPA 路由里的一个路径,回退到了 index.html。
如果你的页面 URL 都带.html,那么不必改动,保持默认即可。如果你想在开发环境去掉.html后缀,让地址更干净,就需要显式配置appType: 'mpa'并加一个小中间件。
4.2 去掉 .html 的中间件写法
在 vite.config 的 plugins 数组里加一个自定义插件,中间件负责把没有扩展名的请求映射到对应的 HTML 文件:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { existsSync } from 'node:fs' import { resolve } from 'node:path' export default defineConfig({ appType: 'mpa', plugins: [ vue(), { name: 'mpa-rewrite', configureServer(server) { server.middlewares.use((req, _res, next) => { const pathname = (req.url || '').split('?')[0] // 不拦截接口请求 if (pathname.startsWith('/api')) { next() return } // 不带扩展名且不是根路径,尝试重写到对应 html if (pathname !== '/' && !pathname.includes('.')) { const target = pathname + '.html' if (existsSync(resolve(__dirname, '.' + pathname + '.html'))) { req.url = target } } next() }) }, }, ], })配置后访问http://localhost:5173/admin,开发服务器会直接把它当作admin.html处理,URL 看起来干净多了。这里要特别提醒:中间件拦截的是所有请求,如果不排除/api开头的接口,你的代理或 mock 请求会被错误改写成/api/login.html。
4.3 多入口热更新失效怎么排查
Vite 的 HMR 在多入口项目里默认就是生效的,因为它维护的模块依赖图覆盖所有入口。改了公共组件,只会刷新真正依赖它的页面;改了某个页面的 main.js,也只会影响对应入口。如果某天热更新突然失效,我一般按下面顺序排查:
- 先看浏览器控制台有没有红色的模块加载错误,如果有,多半是入口 main.js 路径在 HTML 里写错了。
- 确认是不是改了
vite.config.js,配置文件修改后必须重启开发服务器,旧的依赖预构建缓存可能导致页面报错。 - 如果新增了依赖包,Vite 的依赖预构建层(
.vite缓存目录)可能没有及时识别,停掉进程,删除项目根目录下的node_modules/.vite文件夹,再重新执行vite,基本能解决大部分“明明代码改了却不生效”的怪问题。
多入口的热更新本身不是难点,难点在于不要让多个入口共享冲突性的全局变量。比如所有页面都往window上挂载东西,或者都注册了一个同名全局事件,这些才是热更新和运行时表现异常的真正元凶。
5. 部署链路的关键变量:base 与资源路径
5.1 base 到底控制什么
base是 Vite 构建时资源路径的前缀,默认值是/。它影响的是构建产物里 JS、CSS、图片等资源的 URL 前缀。
举个例子,base保持默认时,构建出来的 admin.html 里 scripts 标签会是:
<script type="module" crossorigin src="/assets/admin-8c1d4e.js"></script>把base改成'./'后:
<script type="module" crossorigin src="./assets/admin-8c1d4e.js"></script>前者表示资源必须从域名根路径加载,后者表示从当前 HTML 所在路径的相对位置加载。多入口部署时,这个配置最容易被忽略。
5.2 部署在根路径和子目录的差异
如果你的站点部署在https://example.com/,HTML 放在域名根目录,base保持默认就能正常工作。但如果你把整个 dist 放到https://example.com/foo/这个子目录下,默认的/assets/xxx.js就会去请求https://example.com/assets/xxx.js,结果 404。
解决办法是把base设为'./',或者设为子目录的绝对路径/foo/。使用相对路径'./'是最省事的选择,HTML 里引用的资源都会自动基于当前 HTML 文件所在目录解析,不管部署到哪一层子路径都能用。
5.3 多入口路由模式与 base 的冲突
多入口里如果某个页面内部还用了 Vue Router 的 history 模式(createWebHistory),资源路径和路由路径会混在一起。比如后台入口的页面删除了.html,用/admin作为页面地址,同时路由又有/admin/orders,会出现“到底是找页面还是找路由”的歧义。
我的建议是:
- 入口页面级别,保留
.html或者用服务器重写规则来映射,不要和内部路由混在一个层级。 - 页面内部的 Vue Router 优先使用 hash 模式(
createWebHashHistory),避免和部署路径打架。 - 如果后台页面的地址固定在
/admin,需要部署服务器把/admin指到 admin.html,再通过 router 的base参数管理子路由。
多入口项目本质上已经按页面切分业务,每个页面的内部路由越简单越好。能不用 history 模式就不用,省下的是部署层的时间和精力。
6. 打包优化:公共依赖如何拆装才不会拖累首屏
6.1 先观察默认打包的公共依赖
多入口项目里,多个页面经常共享 Vue、Vue Router、Pinia 这些基础库。Rollup 默认会把被多个入口同时引用的同步模块提取成公共 chunk,避免每个入口都重复打包一份 Vue 源码。但自动提取的问题有两个:一是公共 chunk 的命名是个随机 hash,二是因为缺少分组策略,某些页面可能被迫加载了它根本用不到的公共库代码。
以门户和后台为例,门户页面只用到了 Vue 和一个按钮组件,后台页面引入了 Vue、Element Plus、Vue Router、Pinia。如果不配置 manualChunks,自动拆出来的公共 chunk 里可能同时包含 Vue 和 Element Plus,门户页面也需要下载这部分代码。这显然不合理。
6.2 manualChunks 精细分组
更可控的做法是使用build.rollupOptions.output.manualChunks,按依赖来源做分组:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { readdirSync } from 'node:fs' import { resolve } from 'node:path' function collectEntries() { const entries = {} const files = readdirSync(__dirname) for (const file of files) { if (file.endsWith('.html')) { const name = file.replace(/\.html$/, '') entries[name] = resolve(__dirname, file) } } return entries } export default defineConfig({ plugins: [vue()], build: { rollupOptions: { input: collectEntries(), output: { manualChunks(id) { if (id.includes('node_modules')) { if ( id.includes('/vue/') || id.includes('/@vue/') || id.includes('/pinia/') || id.includes('/vue-router/') ) { return 'vue-core' } if (id.includes('/element-plus/') || id.includes('/ant-design-vue/')) { return 'ui-vendor' } return 'vendor' } }, }, }, }, })这样构建产出的公共 chunk 就有了语义化的名字:vue-core、ui-vendor、vendor。门户页面因为不依赖 Element Plus,就不会被强制加载ui-vendorchunk。
注意manualChunks使用函数形式比对象形式更安全,尤其在 Rollup 版本升级后,函数形式能根据模块 ID 灵活判断,也避免对象形式在复杂依赖关系下的误匹配。
6.3 我验证过的几个分组细节
分组不是越多越好。把 Vue 全家桶放在一个 chunk 里,是因为 Vue、Vue Router、Pinia 通常一起被引用,版本联动,拆开反而会产生多个小 chunk,增加请求数。UI 组件库单独放,是因为它的体积一般较大,而且未必所有页面都用。
如果有页面只用了element-plus里的两个组件,却因此加载了整个 Element 包,那问题不在 chunk 策略,而在缺少按需导入。Vue 生态下配合自动导入插件把组件按需编译,搭配 manualChunks 才能获得比较理想的首屏体积。
CSS 也遵循类似的逻辑。Vite 默认开启cssCodeSplit,每个入口的 CSS 会跟着对应页面走,公共 CSS 会被提取。部署后如果发现页面引用了多余样式,先去检查是不是某个公共组件在入口 main.js 里被全量导入,而不是反过来责怪打包配置。
关于构建体积告警,可以适当调整chunkSizeWarningLimit。默认超过 500KB 会弹出警告,多入口项目里 vue-core 超过这个值很正常,可以视情况放宽到 700 或 800,再配合上面的分组策略,告警会明显减少。
结尾
我在实际配置多入口项目时已经形成了固定的套路:根目录 HTML 只做页面容器,业务代码全部按src/pages/xxx划分;入口通过扫描函数自动收集,不手工维护列表;base提前根据部署位置定死,能用'./'就用相对路径;manualChunks按 vue 生态、UI 库、业务公共代码三档来拆,保证页面只加载自己需要的东西。这套方案在多个项目里稳定运行过,碰到新需求就是复制一份 pages 目录、加一个 HTML、写一段业务逻辑的事。如果你打算从单入口切到多入口,按照这个思路去搭,基本不会再被配置文件困住。