做多页应用工程化,很多人第一反应是“为什么不用 Vite 非要碰 Webpack”。我这次在 elpis-core 里程碑 2 里做的恰好就是一次 webpack 多页工程化改造,把原来散落在一个单页壳子里、靠路由硬撑的十几个业务模块,拆成了真正独立的多页面入口。整个过程踩了不少坑,也把 webpack 的入口、插件、代码拆分、缓存这些机制翻来覆去理了好几遍。这篇不是我复制的配置文档,是我按真实落地过程梳理的一份总结,适合正要搞多页打包、或者对 webpack 配置还停留在“能跑就行”阶段的同学。
先说清楚 elpis-core 这个项目是什么定位。它不是普通的中台系统,而是一套内部业务组件的承载壳,除了公共组件库之外,还要承载多个业务方各自独立维护的页面模块。这些页面彼此之间没有强路由依赖,登录态、接口层、埋点逻辑倒是高度重合,但页面发布节奏和团队归属是分开的。把这样的模块全部塞进一个单页应用里,最大的问题是牵一发动全身——一个团队改坏了构建依赖,所有页面一起挂。里程碑 2 的目标很直接:把工程底座从单页改为多页,让每个业务页面拥有独立入口,但公共代码依然统一抽取,构建步骤不能变得反人类。下面我把完整的设计思路、配置拆解、性能优化和排坑过程展开讲。
1. 项目背景与整体设计思路
1.1 为什么单页壳子在这里撑不住了
elpis-core 最早是一个标准的 Vue 单页应用,所有业务页面挂在同一个路由表下,由vue-router统一分发。这个模式在小团队、页面少的时候很舒服:共享 Layout、共享 store、共享组件,开发时互相调用也方便。但到了里程碑 2,我面临三个绕不开的现实问题。
第一是发布粒度太粗。任何一个页面的改动都要求整个应用重新构建、重新部署。十几条业务线排队等发布窗口,光是沟通成本就压得人喘不过气。第二是团队协作的隔离性太差。大家都在一个src/views目录下写代码,git 冲突频繁,代码评审也很难判断某次改动到底影响了哪个业务域。第三是最致命的:某些页面需要嵌入到外部系统,通过 iframe 或者跳转对接,对方要求的是一个独立的静态页面地址,而不是/#/some/path这种 hash 路由地址。单页应用再怎么改路由模式,都摆脱不了“只有一个 index.html”的先天限制。
1.2 里程碑 2 的目标和边界
我给里程碑 2 定的目标不是单纯“把单页拆成多页”这么简单。目标拆开来看有四条:第一条,每个业务模块有独立入口和独立 HTML 文件,发布时按入口剥离,互不干扰;第二条,公共依赖(比如 Vue 全家桶、组件库、工具函数)要能自动提取,不能每个页面都打包一份,否则十几个页面的构建产物会大得离谱;第三条,开发环境要保留单页时代的心智模型,改代码立刻热更新,启动不能太慢;第四条,构建脚本要能自动发现新页面,新增业务模块时不需要手动改 webpack 配置。
基于这些目标,我选型时没有犹豫,直接用了 webpack 5。有人会问:既然 elpis-core 在技术栈上已经能用 Vite,为什么还要回到 webpack?这里我先不展开 Vite 和 webpack 的对比,后面单开一节谈。简单来说,多页场景下 webpack 的生态成熟度、HTML 插件体系、代码分割控制粒度,尤其在还要兼容老插件和存量构建链路的项目里,依然更稳。
1.3 整体目录结构与工程组织方式
多页工程化第一步是定目录规范。我参考了业界比较常见的src/pages约定:每个页面一个目录,目录内自包含入口文件、页面模板、页面级组件和私有资源。这样无论是人工排查还是脚本扫描,都能很快对页面清单建立全局认知。
src/ ├── pages/ │ ├── dashboard/ │ │ ├── index.html │ │ ├── main.js │ │ ├── App.vue │ │ └── assets/ │ ├── order-manage/ │ │ ├── index.html │ │ ├── main.js │ │ ├── App.vue │ │ └── assets/ │ └── user-center/ │ ├── index.html │ ├── main.js │ ├── App.vue │ └── assets/ ├── components/ // 跨页面公共组件 ├── utils/ // 跨页面公共工具 ├── api/ // 统一接口封装 └── common/ // 公共样式、常量等每新增一个业务页面,只需要在src/pages下新建一个目录并保证结构规范,webpack 配置通过 glob 扫描自动把它纳入构建。这也是多页工程化里最容易踩坑的点:如果目录规范不固定,脚本扫描的可靠性就无从谈起。
2. webpack 多页工程化的核心配置拆解
2.1 如何实现入口和 HTML 插件的自动生成
多页应用和单页应用在 webpack 配置上最明显的差异就是 entry 从一个固定文件变成一个动态对象。elpis-core 的页面列表是不断膨胀的,所以我没有把每个入口写死在配置里,而是用glob扫描目录,自动生成 entry 和HtmlWebpackPlugin实例。
// build/utils.js const path = require('path'); const glob = require('glob'); const HtmlWebpackPlugin = require('html-webpack-plugin'); function getMultiPageConfig() { const entry = {}; const htmlPlugins = []; const pageRoot = path.resolve(__dirname, '../src/pages'); const files = glob.sync('**/main.js', { cwd: pageRoot }); files.forEach((file) => { const pageName = file.replace('/main.js', ''); const entryKey = pageName.replace(/\//g, '_'); entry[entryKey] = path.join(pageRoot, file); htmlPlugins.push( new HtmlWebpackPlugin({ template: path.join(pageRoot, `${pageName}/index.html`), filename: `${pageName}.html`, chunks: [entryKey, 'vendor', 'commons'], inject: 'body', minify: process.env.NODE_ENV === 'production' ? { removeComments: true, collapseWhitespace: true } : false }) ); }); return { entry, htmlPlugins }; }这里有几个细节值得强调。首先是entryKey不能直接用order-manage这种带横线的名字吗?可以,但是要谨慎,chunk 名称在 webpack 内部会作为变量标识的一部分,横线一般来说没问题,但一旦页面路径有多级目录,比如user-center/settings/main.js,我就把斜杠替换成下划线,避免 webpack 因为 chunk name 里的非法字符报警。其次是filename我保留了原始目录层级,比如order-manage.html,这样生成的文件路径和源文件路径一一对应,排查问题时能少一层脑内转换。
还有一个很多人忽略的点:chunks数组里我写死了['vendor', 'commons'],这两个 chunk 来自后续的splitChunks。如果页面本身不需要抽公共代码,或者抽法改了,这里容易报chunk not found的错。所以chunks的排序和splitChunks的缓存组配置是联动的,改一边必须看另一边。
2.2 splitChunks 代码拆分策略的落地
多页应用最怕的就是公共代码重复打包。如果不做拆分,每个页面都把 Vue、axios、element-ui 打包一遍,十个页面就是十份框架代码,产物体积直接爆炸。webpack 5 内置的splitChunks是为数不多开箱即用但要微调的配置项。
// webpack.prod.js optimization: { splitChunks: { chunks: 'all', minSize: 20000, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor', priority: 10, enforce: true }, commons: { test: /[\\/]src[\\/]common[\\/]/, name: 'commons', minChunks: 2, minSize: 0, priority: 5 } } } }这个配置的核心逻辑是:把所有node_modules里的第三方依赖打包成一个vendor.js,所有来自src/common且被至少两个页面引用的公共模块打包成一个commons.js。minSize: 20000的意思是小于 20KB 的公共模块不会强制拆分,避免为了省一点重复代码反而多了一次额外的网络请求。
不过这里有个”理想很丰满、现实很骨感”的地方:test: /[\\/]node_modules[\\/]/匹配的是所有第三方库,但不同页面依赖的第三方库差异可能很大。比如 A 页面引入了 ECharts,B 页面没引,那vendor.js会把 ECharts 也塞进去,导致 B 页面白下载一个几百 KB 的图表库。更好的方式是拆分得更细,把体积大、使用面窄的库单独拆出来,比如echarts-vendor、markdown-vendor。我在这版里程碑 2 里暂时保留了统一 vendor 的粗暴方案,但已经留好了二次拆分的扩展位。
2.3 公共样式与图片资源的处理细节
样式和静态资源在多页场景下比单页更容易出问题。单页只有一个页面入口,样式全局引一次就行;多页会有多个入口,如果每个页面都各自引一套公共样式,构建产物体积会翻倍。我在项目里把公共样式提取成common.scss,在按需加载,而构建产物体积会翻倍。我在项目里把公共样式提取成common.scss,在页面入口里引入。同时把图片资源做了分类:小图片转 base64 内联,大图片输出到独立目录并加 hash。
// webpack.common.js module.exports = { module: { rules: [ { test: /\.(png|jpe?g|gif|webp)$/i, type: 'asset', parser: { dataUrlCondition: { maxSize: 4 * 1024 // 4KB 以内转 base64 } }, generator: { filename: 'assets/img/[name].[hash:8][ext]' } }, { test: /\.(woff2?|eot|ttf|otf)$/i, type: 'asset', generator: { filename: 'assets/font/[name].[hash:8][ext]' } } ] } };小图片转 base64 这条规则我在单页应用里也常用,但在多页场景更需要。原因很简单:多页应用的页面间路由切换不像 SPA 那样可以做图片懒加载,每个独立页面加载时就要把资源全部拿齐,base64 可以减少小图片的 HTTP 请求次数,对首屏体验影响很大。但要注意maxSize不能设得太大,否则所有图片都塞进样式文件或 JS 文件里,HTML 体积会膨胀得很厉害。4KB 是一个比较保守稳妥的阈值。
2.4 模板 HTML 里的资源注入顺序
HtmlWebpackPlugin 默认会把 JS 和 CSS 自动注入到 HTML 里,但注入顺序是有讲究的。公共代码比如vendor和commons必须优先于业务代码加载,否则业务代码执行时依赖的全局变量还没就绪,页面会白屏。
我在chunks数组里写了[entryKey, 'vendor', 'commons'],实际上 webpack 对 chunk 的注入顺序并不完全依赖这个数组的顺序,它还会看 chunk 之间的依赖关系。但因为我在 splitChunks 里开了enforce: true,强制生成了独立 chunk,HtmlWebpackPlugin 会按数组顺序注入 script 标签。经验是:这个顺序一定要把公共 chunk 放在业务 chunk 后面或前面要搞清楚,实际测试中我发现 HtmlWebpackPlugin 的注入顺序是正序的,即数组里的第一个会先注入。我配置的是业务入口在前、vendor 在后,理论上业务入口会先加载,但 webpack 运行时会把已经拆分出去的公共代码用异步加载方式补齐,所以最终执行时公共代码还是会先执行。为了不让后来的人看着困惑,我在代码注释里写了这一点。
3. 开发体验与构建效率优化
3.1 开发服务器的多页访问路径配置
多页应用的 dev server 行为和单页完全不同。单页里你访问http://localhost:8080就能渲染整个应用,页面内的路由由前端接管。多页应用则必须按页面路径访问,比如http://localhost:8080/order-manage.html。
webpack-dev-server 默认会把output.publicPath作为静态资源访问前缀。如果你的页面文件在子目录里,比如order-manage.html,而 dev server 的根目录是项目的 dist 目录,访问路径就得是http://localhost:8080/order-manage.html。这里最容易踩的坑是:historyApiFallback一旦开错,就会把所有请求都重定向到默认的 index.html,导致多页面访问 404 或者打开的全是同一个页面。
我在开发配置里是这样处理的:
// webpack.dev.js module.exports = { devServer: { static: path.join(__dirname, '../dist'), port: 8080, hot: true, historyApiFallback: false, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } };多页应用下historyApiFallback我直接关掉了。因为每个页面都是真实存在的 HTML 文件,不存在“路由不存在需要回退到主页面”的场景。如果真的开成true,你会发现访问order-manage.html也会被拦截回退,然后页面加载的是默认模板,JS 执行却找不到对应入口,控制台一堆报错。这一点是排坑时的高频问题,后面我会放到问题清单里细说。
3.2 持久化缓存与增量构建带来的体感变化
webpack 5 最让我满意的一个特性就是内置的持久化缓存。以前用 webpack 4 的时候,每次冷启动构建都要把整个依赖图重新解析一遍,多页项目动辄三四十秒起步。webpack 5 只需要在配置里开一个选项,就能把模块解析结果写入磁盘,二次构建直接从缓存恢复。
// webpack.common.js module.exports = { cache: { type: 'filesystem', buildDependencies: { config: [__filename] }, cacheDirectory: path.resolve(__dirname, '../node_modules/.cache') } };buildDependencies.config的作用是:当 webpack 配置文件本身发生变化时,自动使缓存失效。这个很关键,否则你改了配置,构建还在用旧缓存里的模块解析结果,跑出来的产物跟配置不一致。
开启持久化缓存之后,我的开发环境冷启动时间从原来的接近 20 秒降到 7 秒左右,增量构建基本稳定在 2 秒以内。多页应用动辄几十个入口,这个提升比单页应用更明显,因为入口多意味着依赖图更大,缓存的收益也更大。
3.3 多线程压缩和构建速度分析
生产构建的压缩阶段是最吃 CPU 的。elpis-core 的多页应用入口多、产物文件多,如果所有压缩任务都跑在单个进程里,构建时 CPU 占用虽然能拉满,但耗时也长。我用terser-webpack-plugin开启了多线程压缩。
// webpack.prod.js const TerserPlugin = require('terser-webpack-plugin'); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); module.exports = { optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: 4, terserOptions: { compress: { drop_console: true } } }), new CssMinimizerPlugin({ parallel: 4 }) ] } };parallel: 4表示用 4 个并发进程来压缩 JS。具体设多少要看构建机器有几个 CPU 核心,在 8 核 16 线程的开发机上,4 到 6 是比较稳的区间。设置太高反而会因为进程切换和内存消耗导致构建变慢。CssMinimizerPlugin默认只在生产构建开启,作用是压缩抽离出来的 CSS 文件,去掉空白和注释。
我还用webpack-bundle-analyzer做过一次产物分析,发现某个页面因为引入了完整的日期处理库导致体积异常大,后来换成了按需引入,体积直接砍掉 40%。多页应用的产物分析比单页更有价值,因为单页里只需要看一个 bundle,多页要看几十个 bundle 的体积分配,能直观看出哪个页面该做代码分割。
3.4 自动发现新页面:新增业务模块零配置接入
多页工程化最爽的体验之一,就是新增一个页面时不需要碰 webpack 配置。我在前面已经用 glob 扫描了src/pages目录,只要按照规范在src/pages/下面新建目录,提供main.js和index.html,构建脚本会自动生成入口和 HTML 插件。
这个机制在团队协作里省了非常多事。以前每次有人要加页面,都得去翻 webpack 配置,找到 entry 对象,加上一行,再找到 HtmlWebpackPlugin 数组,拷贝一段配置,稍微不注意就把别的页面配置弄坏了。现在只需要跑一条命令:
npm run build:prod新页面自动进入构建产物列表。这个设计的关键在于目录规范必须写的足够清楚,并且要在 README 里说明。如果谁随便建了一个src/pages/test/index.js而不是main.js,扫描就识别不到。我把入口文件名定成main.js,这是大多数前端项目的共识,删掉了识别失败的概率。
4. 常见问题排查与避坑实录
4.1 HtmlWebpackPlugin 注入 chunk 顺序导致白屏
有一天联调同学反馈,某个页面打开之后白屏,控制台没有任何报错,但 DOM 里空空如也。我排查了很久,最后发现是 HtmlWebpackPlugin 注入的脚本顺序出了问题:业务代码被注入到了 vendor 之前,导致业务代码执行时找不到 Vue 全局对象。
解决办法很简单:在 HtmlWebpackPlugin 的chunks配置里调整顺序,把公共代码放在续业务代码之前。但是前面也说了,webpack 实际加载时会根据 chunk 的依赖关系自动调整执行顺序,所以理论上即使不调整也问题不大。真正的问题出在某个页面手动在 HTML 模板里加了一个外链脚本,这个脚本又被 webpack 处理了,导致触发时机错乱。我的排查结论是:多页应用里尽量让 HtmlWebpackPlugin 全权管理 script 注入,手动在模板里加 script 要慎之又慎。
4.2 splitChunks 后公共代码没有正确提取
刚开始配置 splitChunks 的时候,我以为设置了chunks: 'all'就万事大吉了。结果产物里每个页面都还是带着一份 element-ui。排查后才发现,cacheGroups里test字段的正则写法不对。webpack 里test: /node_modules/和test: /[\\/]node_modules[\\/]/的匹配行为有细微差别,前者的node_modules出现在路径中间时可能匹配不到,后者的写法是官方推荐,匹配包含路径分隔符的目录结构。
另外,minChunks也是坑。minChunks: 2表示一个模块至少被 2 个 chunk 引用才提取到 commons 里。但如果某个公共模块恰好只被 1 个页面用到,就不会被提取,这是符合预期的。问题在于如果这个页面后来又删除了引用,而它还在 commons chunk 里,产物就会留下一个没被使用的死代码。我建议定期用webpack-bundle-analyzer检查公共 chunk 里是否有不再需要的模块。
4.3 dev server 热更新失效的排查思路
多页应用的热更新有一个特点:如果某个页面目录下新增了文件,或者删除文件,webpack 的 watch 机制可能会失效,导致这个页面热更新不动。我遇到的情况是:修改页面的main.js能触发热更新,但修改页面模板index.html却毫无反应。
原因在于HtmlWebpackPlugin对模板文件的监听不在 webpack 默认的 watch 范围里。解决方法是配置 dev server 的watchFiles:
// webpack.dev.js module.exports = { devServer: { watchFiles: ['src/**/*.html', 'src/**/*.vue'] } };这个配置让 dev server 额外监听 HTML 模板的变更。没有它的话,模板修改之后只能手动刷新页面,开发效率大打折扣。
4.4 路径配置错误导致页面 404 和静态资源丢失
多页应用里路径配置是最容易出问题的地方。我在早期把output.publicPath配成了'/',本地开发一切正常,但部署到服务器子目录之后,所有静态资源都指向了域名根路径,直接 404。
正确的做法是使用相对路径:
// webpack.common.js module.exports = { output: { publicPath: './' } };但相对路径也有副作用:如果页面路由是html5 history模式,或者页面需要通过 URL 参数追踪来源,相对路径可能会在多层嵌套路由下解析错误。我的选择是:开发环境用'/'保证 dev server 的路径干净,生产环境用'./'保证部署到任意子目录都能跑。两边分别配置,各取所需。
4.5 多页应用的公共请求上下文和登录态处理
多页应用和单页应用在登录态处理上有一个很大的不同。SPA 只需要在应用初始化的时候检查一次登录态,然后通过路由守卫控制访问。多页应用的每个独立页面加载时都要重新检查登录态,因为页面之间是独立的文档,内存里的状态完全隔离。
我在 elpis-core 里抽了一个通用的auth.js工具模块,供每个页面入口调用:
// common/auth.js import Cookies from 'js-cookie'; export async function checkAuth() { const token = Cookies.get('elpis_token'); if (!token) { location.href = '/login.html'; return false; } const userInfo = await fetch('/api/user/info', { headers: { Authorization: `Bearer ${token}` } }).then(res => res.json()); if (!userInfo.data) { location.href = '/login.html'; return false; } return userInfo.data; }页面入口里统一调用:
// pages/order-manage/main.js import { checkAuth } from '@/common/auth'; (async function init() { const user = await checkAuth(); if (!user) return; // 初始化 Vue 应用 })();这个模式的核心是把登录态检查放在业务代码执行之前,避免页面先渲染出完整的 UI、又被重定向到登录页,闪一下再跳走。除了登录态,每个页面还要自己处理接口请求的 baseURL、埋点脚本的初始化、公共组件的注册,这些都是从单页拆到多页时需要补位的。
4.6 构建内存不足的处理方法
多页应用入口多,构建内存需求比单页应用高不少。我在一次比较大的版本合入之后,跑生产构建直接报了JavaScript heap out of memory。这不是代码写错了,是 Node 默认内存上限(1.5GB 左右)不够用了。
临时解决办法是给 Node 加内存参数:
NODE_OPTIONS=--max_old_space_size=4096 npm run build:prod更好的做法是在项目根目录加.env文件,或者在 npm scripts 里显式带上参数。但如果构建机器内存本身不大,硬调内存上限会导致系统卡死。这时候要从构建侧优化,检查是不是 splitChunks 把太多东西塞进了同一个 chunk,或者是不是有循环依赖导致模块被重复解析。
5. 选型思考:Vite 和 webpack 在多页场景下怎么选
既然热搜词里不断出现“vue3 vite 和 webpack”,我在这个节点上多说几句。Vite 在开发体验上的优势确实明显,基于 ES Module 的按需加载让冷启动可以在秒级完成。但多页应用场景下,Vite 的生产构建依然依赖 Rollup,Rollup 的代码分割能力和 webpack 的 splitChunks 相比有一些差异。
elpis-core 选择 webpack 有几个现实的考量。第一,项目中存量代码和第三方依赖较多,很多老插件是基于 webpack 的 loader 体系写的,迁移到 Vite 需要重写一部分构建链路。第二,多页应用需要精细控制每个页面的 HTML 输出、公共 chunk 拆分、资源指纹,这些 webpack 的生态最成熟。第三,团队成员的构建知识储备集中在 webpack,换成 Vite 有学习成本。
但我并不是说 Vite 不行。如果是一个全新项目,团队没有历史包袱,页面数量也不是特别多,Vite 的开发体验确实更爽。我的建议是:存量项目继续用 webpack 做多页没有任何问题,新项目可以尝试 Vite,但要多页场景需要提前验证 Rollup 的代码分割策略是否能满足需求,尤其是公共 chunk 拆分粒度是否符合预期。
6. 写在最后:这些经验后续还能怎么用
做这个里程碑 2,我最大的体会是:多页工程化的难点不在“拆”,而在“合”。拆页面入口、拆公共代码、拆构建脚本,这些都是有章可循的。真正考验工程能力的是把所有拆开的东西用一套统一规范收拢到一起,让团队成员在新增页面时感受到零成本,在排查问题时不需要反复翻配置。
如果你接下来也要做类似的多页工程化改造,我建议从目录规范和动态入口脚本开始改造,不要太早陷入 optimization 细节。先把“能多页跑起来”这个底子打好,再逐步做产物体积和构建速度的优化。等到公共代码拆分、资源路径、热更新、登录态这些配套能力全部打通,多页工程化带来的独立部署和团队隔离收益就会非常明显。
这段基于 elpis-core 的 webpack 多页工程化实践,整体方案已经稳定跑过多次迭代,后续我还会持续优化拆分粒度,也会考虑在局部页面尝试 Vite 构建,但整套多页架构的骨架不会动摇。