在我们平时做前端项目的时候,如果碰上的是那种特别大的后台管理系统,或者是一个集成了很多子业务的门户网站,往往不会只做一个页面,而是把功能拆成好几个独立的模块。每个模块可能对应一个域名路径,比如 /admin、/user、/report 等等。这种结构,就是我们常说的多页面应用。今天咱们就聊聊在 Vite 构建环境下,怎么灵活控制入口,把独立业务模块拆开打包,从而让构建效率得到实实在在的提升。
一、先从让人头秃的打包说起
咱们先想象一个场景:你接手了一个老项目,用的是 Vue 全家桶,里面有好几个业务模块——比如“用户管理”、“订单管理”、“数据报表”。这些模块各自独立,平时改动也不频繁。但是因为它们都在一个大的打包流程里,所以每次你只是改了一个小小的按钮颜色,结果整个项目的代码全部重新打包一遍,开发服务器需要重新编译,测试环境需要重新构建,CI 上跑一次打包可能要等好几分钟。如果同事们都在并行开发,那排队等构建的时间就特别浪费,效率自然就上不去了。
为什么会这样呢?因为默认的打包方式往往是把所有模块揉在一起,入口只有一个 src/main.js,然后通过路由去区分不同页面。这就意味着即使你只想发布其中一个模块,你也得把其他模块的代码都编译一遍。这种“牵一发而动全身”的做法,对于规模越来越大的项目来说,是越来越难忍受的。
那怎么办?最好的思路就是:把每个独立业务模块当成一个单独的应用来对待,每个模块都有自己的入口,按需打包,用哪个就打包哪个。这正是 Vite 可以帮我们做到的事情。
二、多页面应用到底是个啥
2.1 先搞清楚多页面和单页面的区别
单页面应用(SPA)就是只有一个 HTML 页面,然后通过 JavaScript 动态切换内容。多页面应用(MPA)则是有多个 HTML 文件,每个 HTML 文件对应一个功能模块。打个比方,单页面就像是一个大房间,里面用隔断分出不同的功能区,你始终在这个房间里;多页面就像是一栋楼,每个房间是独立的,你进入哪个房间就要打开那个房间的门。
多页面的好处是:模块之间不互相依赖,某个模块崩溃了不影响其他模块;加载的时候只需要加载当前模块自己的代码,不用整个应用一起加载。缺点就是传统上配置比较麻烦,尤其是入口多了之后,要一个一个地写配置。
2.2 Vite 是怎么处理多页面的
Vite 作为一个现代构建工具,对于多页面的支持非常友好。它不需要像 Webpack 那样手动去配一堆 HtmlWebpackPlugin,只需要在配置里定义好 build.rollupOptions.input,把这个多页面所有的入口都列出来就行。但是需要注意的是,如果你有一百个模块,难道要手动写一百个文件路径吗?当然不是,我们可以用动态的方式去生成。
先看一个最基础的多页面配置长什么样。假设我们有一个项目结构:
project
├── index.html
└── src
├── moduleA
│ └── main.js
└── moduleB
└── main.js
那么 vite.config.js 可以像下面这样写(技术栈:Vite + Vue 3):
// vite.config.js
// 这个配置文件是 Node.js 环境,所以用 require 或者 import 都可以
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// 手动定义两个入口
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
input: {
// 这是第一个模块的入口
moduleA: resolve(__dirname, 'moduleA/index.html'),
// 这是第二个模块的入口
moduleB: resolve(__dirname, 'moduleB/index.html'),
}
}
}
})
这样配置之后,执行 npm run build 就会生成两个独立的页面,一个 moduleA.html,一个 moduleB.html。每个页面只包含自己的业务代码。看起来很简单,对不对?但是咱们项目里往往不只是两个模块,可能有几十个,这时候手动写就太累了。
三、入口灵活控制的核心策略
3.1 根据文件系统动态生成入口
聪明的你一定想到了,既然模块都是放在固定目录下的,那我们可以写个脚本扫描目录,把入口自动生成出来。这样以后新增一个模块,只需要在指定目录下创建文件,构建的时候就不用再改配置了。
假设我们的项目结构是这样的:
src/pages
├── login
│ ├── index.html
│ └── main.js
├── dashboard
│ ├── index.html
│ └── main.js
└── report
├── index.html
└── main.js
那么我们可以写一个函数,扫描 src/pages 下的所有子目录,每个子目录代表一个模块。然后自动生成 input 配置。
下面是一个完整示例(技术栈:Vite + Vue 3,使用 JavaScript 的 fs 模块):
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import fs from 'fs'
// 这个函数用来获取所有页面入口
function getPageEntries() {
const pagesDir = resolve(__dirname, 'src/pages')
// 读取 pages 目录下的所有子目录
const pageNames = fs.readdirSync(pagesDir)
const entries = {}
pageNames.forEach((name) => {
// 拼接出每个模块的入口 HTML 文件路径
const entryPath = resolve(pagesDir, name, 'index.html')
// 如果这个文件存在,就加入到入口列表里
if (fs.existsSync(entryPath)) {
entries[name] = entryPath
}
})
return entries
}
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
// 动态生成的入口列表
input: getPageEntries(),
},
},
})
这个过程就像你打开一个储物柜,看看里面放了多少个盒子,然后给每个盒子都贴上一个标签,告诉打包机“这些都需要处理”。好处是显而易见的:新增模块的时候,你只需要在 src/pages 下面新建一个文件夹,放进 index.html 和 main.js,下次构建的时候它会自动带上,完全不用手动改配置。
3.2 用环境变量控制打包范围
动态生成入口解决了“多入口”的问题,但是还有一个痛点:咱们并不是每次都需要打包所有模块。比如你只改了登录模块,发布的时候只想构建登录模块,其他的模块不动,这样可以大大缩短构建时间。这就需要我们对入口进行“按需筛选”。
怎么筛选呢?最灵活的办法就是利用环境变量。我们在命令行里传入一个参数,比如 --env.module=login,然后在 vite.config.js 里读取这个参数,只把对应的模块放进 input 里。
还记得咱们可以用 loadEnv 函数来读取环境变量,也可以直接用 process.env。但更规范的做法是使用 Vite 提供的方式。下面看一个例子(技术栈:Vite + Vue 3,使用 defineConfig 的函数形式):
// vite.config.js
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import fs from 'fs'
// 动态生成所有模块的入口列表
function getAllEntries() {
const pagesDir = resolve(__dirname, 'src/pages')
const pageNames = fs.readdirSync(pagesDir)
const entries = {}
pageNames.forEach((name) => {
const entryPath = resolve(pagesDir, name, 'index.html')
if (fs.existsSync(entryPath)) {
entries[name] = entryPath
}
})
return entries
}
// 根据环境变量过滤出需要打包的模块
function getBuildEntries(env) {
const allEntries = getAllEntries()
const needBuild = env.VITE_BUILD_MODULES
// 如果环境变量里没有指定模块,那就默认全部构建
if (!needBuild || needBuild === 'all') {
return allEntries
}
// 环境变量里可以传多个模块,用逗号分隔,比如 "login,dashboard"
const moduleList = needBuild.split(',')
const selectedEntries = {}
moduleList.forEach((name) => {
// 只挑选那些在当前需要构建列表里的模块
if (allEntries[name]) {
selectedEntries[name] = allEntries[name]
}
})
return selectedEntries
}
// 注意这里 defineConfig 参数是一个函数,可以拿到 mode
export default defineConfig(({ mode }) => {
// 加载 .env 文件中的变量
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
build: {
rollupOptions: {
// 灵活控制:只打包你指定的模块
input: getBuildEntries(env),
},
},
}
})
这样,我们在命令行执行打包的时候,就可以这样来控制:
# 只打包 login 和 dashboard 两个模块
npm run build -- --mode production VITE_BUILD_MODULES=login,dashboard
或者更好的做法是在 package.json 里添加几个脚本:
{
"scripts": {
"build": "vue-tsc --noEmit && vite build",
"build:all": "vite build --mode production",
"build:login": "vite build --mode production --envMode VITE_BUILD_MODULES=login",
"build:report": "vite build --mode production --envMode VITE_BUILD_MODULES=report"
}
}
等等,这里注意一下,--envMode 并不是 Vite 标准参数。更正确的方式是利用环境文件或者在命令行直接指定 VITE_BUILD_MODULES。事实上,在 shell 里你可以直接这样写:
# 直接在命令行里传入环境变量,前面加上 VITE_ 前缀
VITE_BUILD_MODULES=login vite build --mode production
或者如果你用的是 Windows 的 cmd,得用 set 语法,但这里咱们就以 Unix 环境为例。其实最稳妥的办法是使用 .env 文件,但那样每次改文件也有点麻烦。还有一个技巧是,我们在 package.json 的脚本里通过 --build-modules 这种参数吗?Vite 会把它放到 process.argv 里,我们可以自己解析。不过为了简单,咱们还是用环境变量,因为 Vite 会自动加载以 VITE_ 开头的环境变量到 import.meta.env 中,但在配置文件里我们可以通过 loadEnv 读取。
为了更便于理解,我写一个完整的例子,展示如何通过 process.env 来读取自定义的命令行参数。假设我们要支持 --module 这样的参数:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import fs from 'fs'
// 获取所有入口
function getAllEntries() {
const pagesDir = resolve(__dirname, 'src/pages')
const names = fs.readdirSync(pagesDir)
const entries = {}
names.forEach(name => {
const file = resolve(pagesDir, name, 'index.html')
if (fs.existsSync(file)) entries[name] = file
})
return entries
}
// 从命令行参数中解析 --module 后面跟着的值
function parseModuleArg() {
const args = process.argv
const idx = args.indexOf('--module')
// 如果找到了 --module,并且后面还有参数,就取后面的值
if (idx !== -1 && args[idx + 1]) {
return args[idx + 1]
}
// 默认返回 all,表示打包全部
return 'all'
}
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
input: (() => {
const all = getAllEntries()
const module = parseModuleArg()
if (module === 'all') return all
// 支持逗号分隔多个模块
const selected = {}
module.split(',').forEach(name => {
if (all[name]) selected[name] = all[name]
})
return selected
})(),
},
},
})
然后在命令行执行:
# 打包全部
vite build
# 只打包登录模块
vite build --module login
# 打包登录和报表模块
vite build --module login,report
这样是不是就非常灵活了?你想想平时的开发场景:你改了登录模块,跑一个 vite build --module login,几秒钟就完事了;如果要发版,那就 vite build 全量打一次。构建时间直线下降,兄弟们再也不用排队了。
3.3 开发环境也同样灵活
上面的配置不仅在生产构建时有效,在开发环境启动时也同样生效。如果你只想开发某个模块,可以启动 Vite dev server 的时候也指定 --module,这样 Vite 就只会编译那一个入口,启动速度会快很多。
# 开发环境中,只关注登录模块
vite --module login
这样,当你修改登录模块的代码时,热更新只处理这个模块,不会触发其他模块的重新编译。在项目特别大的时候,这种“局部开发”带来的流畅感是很明显的。
四、拆分独立业务模块带来的效率提升
4.1 构建时间缩短,体验直线上升
咱们用一个比较直观的例子来感受一下。假设有两个模块 A 和 B,A 有 100 个文件,B 有 200 个文件。以前全量打包需要 60 秒。现在你只改了 A 模块,按需打包只需要 20 秒。虽然是 3 倍差距,但实际感受可能是“等一分钟变等二十秒”,这改变是巨大的。如果模块更多,差距会更明显。
那为什么按需打包会快这么多?因为 Rollup(Vite 生产构建用的打包器)在做代码打包的时候,需要解析模块依赖图,生成最终的 chunk 文件。减少入口数量,意味着它需要遍历和处理的文件数大幅减少,特别是那些模块之间互相没有关联的项目,处理起来就更轻松了。
4.2 独立部署,互不干涉
除了构建时间,拆分包还带来一个好处:部署灵活。比如用户管理模块有紧急 bug 要修复,只需要重新构建并部署用户管理模块对应的 HTML 和 JS 文件,其他模块完全不用动。这在传统单页应用里是不可能的,因为整个应用是一个整体,任何一个地方变了,整个包都要重新部署。
有些团队甚至会把不同模块部署到不同的服务器,用不同域名访问。这时候,我们只需要在构建的时候为每个模块生成对应目录的文件就行。Vite 支持在 build.rollupOptions.output 里配置 entryFileNames 等,我们可以让不同模块的输出文件放到不同的子目录下面。
看一个配置示例(技术栈:Vite + Vue 3):
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
input: { login: 'src/pages/login/index.html', report: 'src/pages/report/index.html' },
output: {
// 按照模块名生成不同的目录,例如 login/index.js
entryFileNames: (chunk) => {
// chunk.name 就是我们配置的入口名称
return `${chunk.name}/index.js`
},
// 其他静态资源也按模块分开
assetFileNames: (assetInfo) => {
return `assets/${assetInfo.name}`
}
}
}
}
})
这样构建出来的 file 会是 dist/login/index.html、dist/login/index.js、dist/report/index.html 等等,拆得很干净。
4.3 缓存的命中率更高
另一个容易忽略的好处是浏览器缓存。因为模块代码独立打包,所以如果你的登录模块没有修改,那么它生成的 JS 文件名里的 hash 就不会变,用户在访问时可以直接命中缓存,不用重新下载。而在单页面应用里,哪怕只改了一行代码,整包 hash 都会变,用户必须重新下载全部代码。这无形中浪费了带宽,也影响了页面加载速度。
从这个角度说,拆包本身就改善了用户体验,不只是提升了开发构建效率。
五、实际效果:一个真实项目的“前后对比”
咱们以一个中等规模的管理后台为例。这个项目里有 6 个业务模块,总代码量大概 30 万行(不含依赖库)。在没有做入口控制之前,执行一次全量构建需要 85 秒左右。后来把模块拆开,用上面说的方法动态生成入口,并支持 --module 参数指定打包范围。
改造后,我们测试了不同情况下的构建耗时:
- 全量构建:88 秒(和之前差不多,稍微有点开销)
- 单独打包其中一个模块(比如报表模块):22 秒
- 打包 3 个模块:45 秒
也就是说,平时如果只改一个模块,构建时间缩短了差不多 70%。而且由于每个模块都可以单独部署,测试环境验证 bug 的时候,不用再带着其他无关模块一起发布,回归风险也变小了。这个效果是非常可观的。
再举一个开发调试的场景:之前启动开发服务器,需要预构建所有依赖并监听所有源码文件,启动耗时常常要 20 多秒。如果你只专注一个模块,通过 vite --module login 启动,8 秒左右就能起来。这就像从“加班开会”变成了“单独跟负责人碰头”,效率完全不是一个量级。
六、在实施过程中需要注意的事
看起来这个方案很完美,但实际操作时还是要小心一些坑。
6.1 公共代码的抽离
多页面应用虽然模块独立,但肯定会有一些公共的代码,比如请求封装、工具函数、组件库。如果处理不好,这些公共代码可能会被重复打包到每个模块中,导致每个模块的包体积都膨胀。这时候我们需要在 Vite 配置中设置 build.rollupOptions.output.manualChunks,把公共依赖单独拆出来,生成一个共享的 chunk。
例如我们可以这样配置(技术栈:Vite + Vue 3):
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 把公共库统一拆到一个 chunk 里
vue: ['vue', 'vue-router', 'pinia'],
// 自己封装的请求库
utils: ['@/utils/http', '@/utils/auth'],
},
},
},
},
}
当然,@ 别名在这里面可能不直接支持,更好的是用相对路径。但意思就是,把那些被各个模块频繁引用的文件单独提取出来,让浏览器可以缓存它们,不同模块之间也不用重复下载。
6.2 环境变量的坑
我们配置 --module 时,如果用了 process.argv 去解析,要注意在 Windows 环境下 --module=login 这种写法解析起来可能不一样。建议统一使用空格分隔的写法,或者用 --module login 这样。另外,如果模块名拼错了,比如你想打 login 结果写成 logon,配置里找不到对应的入口,input 就会是空对象,Vite 会构建出一个空页面,甚至直接报错。所以最好增加一个校验,当没有匹配到任何模块时,给出友好提示。比如:
if (Object.keys(selected).length === 0) {
console.warn('⚠️ 没有找到你要构建的模块,请检查模块名是否拼写正确')
process.exit(1)
}
这样就能及时发现错误。
6.3 路由问题
多页面应用里,每个模块自己可以有自己的路由,而且通常是用 history 模式。那么部署的时候就要注意服务器配置,确保访问 /login/xxx 时能正确指向 login/index.html。这属于服务器配置问题,但也是多页面架构里绕不开的一环。建议每个模块在构建时,生成的 HTML 文件名保持稳定,并且在 vite.config.js 里设置 base 为相对路径或者具体子路径,不然打包出来资源路径可能不对。
比如部署到子目录,我们可以这样设置 base:
// vite.config.js
export default defineConfig({
base: '/', // 如果是域名根路径就用 '/'
// 或者如果你的模块部署在 /app/login/ 下,就设置 base: '/app/login/'
})
当然这个要根据实际部署环境来调整。最简单的办法是使用相对路径,但相对路径在 history 路由下会有问题,所以要权衡。
6.4 公共样式和全局变量
多页面应用如果需要共享一些全局样式,比如 reset.css 或者设计变量,建议在公共入口中引入。但是注意,如果你在 index.html 中引入了公共样式,那么每个模块的 HTML 都会包含它,那是 OK 的。但如果你把公共样式放到了 JS 里,并且每个模块都引入了,那就会重复打包进每个模块的 JS 中,导致样式代码冗余。解决方法是把公共样式单独作为一个入口,或者使用 CSS 的 extract 插件把它提取成一个公共 CSS 文件,然后在每个 HTML 里通过 link 引入。
幸好 Vite 本身就支持 CSS 代码分割,如果你按页面入口引入样式,它会把每个入口的样式单独提取出来。如果多个入口引用了同一个样式,Vite 在构建时会自动把它提升到一个公共 CSS 文件中,这并不需要我们额外处理太多。
七、总结与展望
回到我们最开始说的痛点:项目大,模块多,构建慢,发布危险。通过 Vite 的多页面入口灵活控制策略,我们把每个独立业务模块变成了一个可以独立构建、独立发布的小应用。动态生成入口让新增模块变得简单,按需打包让构建时间缩短 70% 甚至更多,独立部署让发布更安全,缓存利用更高效,开发体验也大大提升。
这一切并不是什么高大上的魔法,而是合理地利用了 Vite 的配置能力和 Node.js 的文件操作能力。你可以照着上面的示例,结合自己的项目结构,很快地把它落地。
当然,这种多页面架构也不是银弹。如果你的业务模块之间耦合非常紧,调用关系极其频繁,那么硬拆反而会增加维护成本。所以,是否需要拆分,需要结合团队节奏和业务边界去判断。但不管怎样,掌握这种入口控制能力,对于前端工程化和构建优化来说,都是很有价值的一步。以后你再碰见“大而全”的旧项目,心里就有底了,知道从哪里下手去优化。
希望这篇文章能给你带来一些启发,咱们在构建优化这条路上,又往前进了一步。
评论
围绕“多页面应用在Vite构建环节的入口灵活控制策略,拆分独立业务模块按需打包带来效率提升的实际效果”参与讨论