做多页应用工程化很多人第一反应是“为什么不用 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 页面引入了 EChartsB 页面没引那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强制生成了独立 chunkHtmlWebpackPlugin 会按数组顺序注入 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_size4096 npm run build:prod更好的做法是在项目根目录加.env文件或者在 npm scripts 里显式带上参数。但如果构建机器内存本身不大硬调内存上限会导致系统卡死。这时候要从构建侧优化检查是不是 splitChunks 把太多东西塞进了同一个 chunk或者是不是有循环依赖导致模块被重复解析。5. 选型思考Vite 和 webpack 在多页场景下怎么选既然热搜词里不断出现“vue3 vite 和 webpack”我在这个节点上多说几句。Vite 在开发体验上的优势确实明显基于 ES Module 的按需加载让冷启动可以在秒级完成。但多页应用场景下Vite 的生产构建依然依赖 RollupRollup 的代码分割能力和 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 构建但整套多页架构的骨架不会动摇。