ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从零搭建Webpack多页应用工程化:动态入口、构建优化与性能实践

从零搭建Webpack多页应用工程化:动态入口、构建优化与性能实践 1. 项目背景与多页工程化的核心思路1.1 elpis-core 里程碑2要解决什么问题elpis-core 这个项目走到里程碑2核心目标很明确从单一页面的demo形态转变为一个能支撑多个独立业务页面的工程基座。简单说就是把“能跑”变成“能打仗”。为什么会在这个时间点引入多页工程化我自己在接手这个里程碑时总结下来有三个直接原因项目里同时存在多个相对独立的页面比如登录页、数据看板页、配置管理页它们的入口、样式、脚本都不完全共享。如果强行做成单页应用路由状态管理、权限控制、代码分割的成本都会成倍上升。团队成员多人并行开发单页应用容易出现路由冲突、全局状态互相污染的麻烦。多页架构天然隔离每个页面拥有独立的入口和构建上下文协作边界清楚得多。部分页面的首屏加载有硬性要求单页应用首屏要等整个bundle初始化多页则可以直接只加载当前页面资源。多页应用MPA和单页应用SPA的取舍网上已经讨论烂了。这里我只说工程化视角下的结论如果你的业务里有多个低耦合、逻辑独立、甚至归属不同团队维护的页面多页架构比单页架构省心得多。每个页面独立发布、独立灰度、独立回滚发布系统那边也好配合。1.2 为什么这个项目不用vite、继续用webpackelpis-core 选型的时候团队内部正好也在争论要不要换vite。标题的热搜词里也有“vue3 vite 和webpack”说明这个纠结是普遍存在的。我当时的判断是继续用webpack理由有三条都很实际。第一存量兼容。项目里已经有大量基于webpack的loader、plugin配置和第三方集成代码。迁移到vite意味着这些配置全部推倒重来而里程碑2的排期不允许这种大动作。第二生态宽度。webpack的生态在复杂构建场景下依然是最全的。我们当时需要处理多入口模板注入、按需拆包、复杂目录结构扫描这些用webpack找现成方案都相对容易踩坑也少。第三可控粒度。vite在开发阶段确实快但它底层的依赖预构建和产物优化逻辑在复杂多页场景下会有一些黑盒行为。多页工程恰恰是最需要精确控制每一个chunk产出和注入顺序的场景webpack在这件事上透明得多。这不代表vite不好。如果你的项目是全新项目、页面数量少、团队没有历史包袱vite一定更香。但对elpis-core这种需要承接现有代码、又有多页强诉求的项目来说webpack是最稳的答案。2. 从零搭建多页工程目录结构与基础配置2.1 目录设计用文件即路由的约定消灭配置地狱多页工程和单页工程在目录结构上最大的区别就是每个页面是一个完整的“自包含”目录而不是一个路由组件。目录结构直接决定后续所有配置的复杂程度我在elpis-core里采用了约定式目录核心思路是“页面即目录目录即入口”。具体的结构我当时是这样组织的src/ ├── pages/ │ ├── login/ │ │ ├── index.html │ │ ├── index.js │ │ ├── style.css │ │ └── assets/ │ ├── dashboard/ │ │ ├── index.html │ │ ├── index.js │ │ └── assets/ │ └── setting/ │ ├── index.html │ ├── index.js │ └── assets/ ├── common/ │ ├── utils/ │ └── components/ └── static/这样做的好处非常明显新增一个页面只需要在pages下面新建一个目录放上index.html和index.js所有构建配置里关于入口的东西都是自动识别的不需要去webpack配置里手动加一遍entry。这对团队扩张期特别友好新人做新页面时基本不需要碰构建配置。每个页面的index.html是独立的模板页面与页面之间通过chunks字段关联自己的js和css。公共代码抽到common目录构建时通过splitChunks提取成共享包这个后面详细说。2.2 基础webpack配置把entry从“写死”变成“扫出来的”webpack多页配置第一个要解决的问题就是entry。如果页面数量少手动写几十个entry虽然丑但能忍。问题是elpis-core后续是要支撑一个不断增长的业务中台页面数量过十之后手写entry就是给自己挖坑。我采用的方式是用glob模块去扫描pages目录动态计算出entry对象。这是多页工程化的标配做法不是什么高深技巧但很实用// build/utils.js const glob require(glob); const path require(path); const PAGE_PATH path.resolve(__dirname, ../src/pages); exports.getPages function () { const entryFiles glob.sync(PAGE_PATH /*/index.js); const entries {}; entryFiles.forEach((filePath) { const pageName filePath.match(/\/([^/])\/index\.js$/)[1]; entries[pageName] filePath; }); return entries; };这段代码做的事情很简单把src/pages下所有包含index.js的目录都视为一个页面目录名就是该页面的key。拿到这个entries对象之后直接交给webpack的entry字段即可。这样做有个很顺带的收益将来某个页面下线只需要删除对应目录构建产物里就不会再出现这个页面不需要额外维护一个“启用的页面列表”配置文件。配置和实际的代码结构保持一致永远不会出现“列表里写着有页面A但代码里A已经删了”的脏数据。2.3 区分开发与生产公共配置、开发配置、生产配置三件套多页项目比单页项目更容易遇到“在开发环境好好的打包上线就出问题”的情况核心原因就是开发与生产两个场景的诉求差异巨大。elpis-core从最开始就按webpack常规实践拆分了三份配置webpack.base.js公共配置entry、resolve、loader等两边都一样的部分放这里。webpack.dev.js开发环境配置重点在devServer、热更新、source-map不做压缩不做hash。webpack.prod.js生产环境配置重点在代码压缩、hash命名、资源抽取、体积优化。公共配置里需要特别注意resolve的extensions和alias。多页项目里每个页面的引用路径深度不同比如login页里写../../common/utilsdashboard页里可能要写../../../common/utils很容易写晕。加一个alias就清爽很多// webpack.base.js const path require(path); module.exports { resolve: { extensions: [.js, .json, .vue], alias: { : path.resolve(__dirname, ../src), common: path.resolve(__dirname, ../src/common), pages: path.resolve(__dirname, ../src/pages) } } };这样页面里引用公共模块时不管当前在哪一层直接写common/utils/format.js就行路径问题一劳永逸重构目录时也不会因为相对路径断掉一片import。3. 动态入口与HTML模板生成实践3.1 HtmlWebpackPlugin动态实例化的正确姿势entry解决了入口文件的问题但还有一个关键问题打包生成js文件之后怎么把js注入到对应页面的HTML里手动写HTML引用路径是不可行的因为生产环境的文件名带着hash路径每次构建都可能不同。这个问题的标准解法是HtmlWebpackPlugin但它默认面向单页。多页场景下要为每个页面动态创建一个plugin实例并且把当前页面需要共享的chunk正确注入进去。elpis-core里我是这样写的// build/getHtmlPlugins.js const HtmlWebpackPlugin require(html-webpack-plugin); const { getPages } require(./utils); const pages getPages(); function getHtmlPlugins() { return Object.keys(pages).map((pageName) { return new HtmlWebpackPlugin({ filename: ${pageName}.html, template: pages[pageName].replace(index.js, index.html), chunks: [common, pageName], minify: { removeComments: true, collapseWhitespace: true } }); }); }关键点有两个。一个是chunks字段数组里的顺序就是注入顺序所以公共chunk必须排在前面否则页面会先执行业务代码里对公共库里模块的引用而此时公共库还没有加载完直接报错白屏。另一个是template路径因为每个页面自己的index.html里只需要写页面自身的DOM结构和根节点不需要手动引任何js注入的事情全交给这个插件处理。3.2 公共chunk拆分避免每个页面都打包一遍React或Vue多页项目里最容易被忽略的坑如果不在splitChunks里做公共模块提取每个页面的bundle都会把node_modules里的核心库重新打包一份。想象一下你有10个页面每个页面都打一个2MB的bundle前端加载的是每个页面自己的2MB虽然看起来能用但性能和缓存复用率都极其糟糕。elpis-core的splitChunks配置核心部分是这样做的// webpack.base.js optimization: { splitChunks: { cacheGroups: { common: { name: common, test: /[\\/]src[\\/]common[\\/]/, minSize: 0, minChunks: 2, priority: -5, reuseExistingChunk: true }, vendor: { name: vendor, test: /[\\/]node_modules[\\/]/, chunks: all, priority: -10 } } } }common组负责提取项目里被多个页面引用的公共业务代码vendor组负责把第三方依赖拆到独立的vendor包里。这样浏览器在访问第二个页面时vendor和common包已经缓存过了只需要新下载当前页面自己的逻辑代码这比每个页面都全量加载要少得多。比拆包更重要的一个经验是chunk名字一旦被HtmlWebpackPlugin引用就别随意修改splitChunks的name字段否则很容易出现页面里chunk引用对不上、加载404的情况。我在这上面踩过坑后面问题排查章节会细说。3.3 多页模板里的性能细节给页面添加头部信息与公共资源模板生成的细节里还有两个容易被忽略的点。一是每个页面的index.html里title和meta描述不能写死成一样的。如果搜索引擎收录这个页面或者同事从收藏夹里打开一串标签页每个标签页都叫“elpis-core”会非常难区分。我的做法是在每个页面的index.html里写死各自的title因为多页项目的模板本来就是页面自己维护的不交给HtmlWebpackPlugin动态注入标题会更直观。当然如果你有统一改标题的需求也可以用HtmlWebpackPlugin的templateParameters往模板里注入变量。二是公共静态资源是否需要全部内联。首屏性能敏感的场景可以考虑把体积小的公共脚本内联到HTML里减少请求数。但内联会破坏浏览器缓存策略所以一般只针对首屏必要脚本做不要在里程碑里轻易大面积使用除非你已经用性能分析工具确认了瓶颈就是请求数过多。4. 开发体验优化与热更新配置4.1 devServer配置一个端口服务所有页面多页开发模式和单页最大的区别在于URL访问方式单页只要访问一个入口靠前端路由切换多页在开发环境需要能通过http://localhost:8080/login.html、http://localhost:8080/dashboard.html这样的地址访问不同页面。webpack-dev-server默认就支持这种多入口静态访问但你需要在devServer里配置两件关键的事// webpack.dev.js devServer: { historyApiFallback: false, compress: true, port: 8080, hot: true, static: { directory: path.join(__dirname, ../src/pages) }, proxy: { /api: { target: http://your-api-server.com, changeOrigin: true } } }historyApiFallback这里要设置成false因为多页应用如果开启historyApiFallbackdevServer在遇到不存在的路径时会统一返回入口页的HTML多页场景下这就乱了页面路由会互相覆盖。如果项目里也确实需要用HTML5 history模式的页面级路由那要针对具体页面做rewrite规则但elpis-core没有走到那一步直接关掉最省事。另一个细节是static.directory指向src/pages。这可以让devServer直接访问到源码目录下的静态资源。比如你想临时看一张图片、一个字体文件直接拼URL就能访问不用额外起服务。4.2 开发代理解决跨域前后端并行开发多页项目的前后端分离协作中跨域是最常见的困扰。开发环境localhost:8080去请求线上API域名必然跨域。给devServer配proxy是最优雅的解法。我在elpis-core里一开始写的是固定后端地址后来发现前端团队和后端团队联调时会频繁切换环境就把代理目标改成从环境变量读取// webpack.dev.js const BACKEND_URL process.env.BACKEND_URL || http://dev-api.elpis.local; module.exports { devServer: { proxy: { /api: { target: BACKEND_URL, changeOrigin: true }, /upload: { target: BACKEND_URL, changeOrigin: true } } } };启动命令也随之变成BACKEND_URLhttp://192.168.x.x:8088 npm run dev不设置就默认走dev环境。这个小改动虽然不起眼但在多环境联调时能省下大量沟通成本后端同事再也不用说“你改一下你本地host吧”这种话。4.3 开发期构建速度的实测优化缩小loader范围、缓存、多线程多页项目开发期最痛的事情是构建慢。elpis-core页面数量上到10个以上后冷启动要30秒左右改了样式热更新也要等2-3秒非常影响心态。我做了三个优化见效快且稳定。第一缩小loader处理范围。babel-loader只处理src/pages和src/common下的源码node_modules完全交给webpack内部机制处理。多页项目对node_modules再次编译基本是浪费计算资源还容易踩到奇怪的兼容性bug。第二开启持久化缓存。webpack 5内置的cache: { type: filesystem }是一个非常省心的配置。开启之后二次冷启动时间直接从30秒降到8秒左右。如果项目还在用webpack 4可以用cache-loader但体验远不如webpack 5的内置方案。第三对耗时的loader做多进程。其实在webpack 5里thread-loader用起来很简单代价是它本身也有开销所以只建议给babel-loader这种重型loader使用。如果项目构建时间还能忍不要滥用thread-loader小项目反而会变慢。实测下来这三个配置组合之后elpis-core的冷启动时间从大约30秒降到了10秒以内热更新稳定在1秒左右。这个体感差异非常巨大建议所有多页项目都可以优先做这三件事。5. 生产构建与打包优化5.1 产物命名策略搞清楚hash的三种含义和适用场景多页项目上线后最怕的一个现象代码更新了但用户浏览器还在用旧的缓存文件导致页面白屏或者行为异常。要解决这个问题必须正确使用文件指纹。webpack里hash有三种hash、chunkhash、contenthash。很多教程一句话带过但实际用错会踩深坑。hash是整个构建级别的一次构建所有产物hash都一样缓存复用价值极低直接不要用。chunkhash是按chunk区分的entry之间彼此不同但同一chunk里css和js的hash是绑定在一起的。contenthash是按文件内容生成的css文件内容变了只有css的hash会变js不受影响。elpis-core里js文件用chunkhashcss文件用contenthash这样改业务代码时不变的公共库和样式文件还能继续命中缓存// webpack.prod.js output: { filename: js/[name].[chunkhash:8].js, path: path.resolve(__dirname, ../dist) }单独抽出来的css用MiniCssExtractPlugin配合contenthashnew MiniCssExtractPlugin({ filename: css/[name].[contenthash:8].css })顺便说一个细节hash长度用8位足够再长意义不大还会让文件名看起来很吓人。如果真的上了CDN缓存策略8位hash完全能保证每个构建版本的重名概率低到可以忽略。5.2 静态资源处理webpack 5的asset module替代旧的file-loader/url-loaderwebpack 5之后图片、字体等静态资源不再需要单独引loader直接用内置的asset module处理。elpis-core里的配置是这样的module: { rules: [ { test: /\.(png|jpe?g|gif|svg)$/i, type: asset, parser: { dataUrlCondition: { maxSize: 4 * 1024 } } }, { test: /\.(woff2?|eot|ttf|otf)$/i, type: asset/resource } ] }type: asset是自动模式小于4KB的图片自动base64内联减少请求数大于4KB的交给asset/resource正常输出成文件。字体文件因为一般不会被多次复用直接用asset/resource输出即可。一个容易忽略的点是图片输出路径可配置在output的assetModuleFilename里也可以用generator.filename针对不同资源类型设置输出目录。elpis-core里为了后续CDN迁移方便统一把静态资源输出在static/目录下output: { assetModuleFilename: static/[name].[hash:8][ext] }这种层级规划上线后做CDN分流会非常顺只需要在CDN配置里把/static/路径指向源站的dist下的static目录就行。5.3 压缩与产物分析别让一个页面打出一个巨型chunk多页应用生产构建优化里必须做的一件事是构建完检查每个chunk的体积。elpis-core在里程碑2刚完成时产物里有的页面chunk达到了500KB以上这明显是不可接受的。压缩插件方面我用的是压缩js的terser-webpack-plugin和压缩css的css-minimizer-webpack-plugin。注意在生产配置的optimization.minimizer里覆盖默认配置时一定要把terserOptions设置好const TerserPlugin require(terser-webpack-plugin); const CssMinimizerPlugin require(css-minimizer-webpack-plugin); optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: true, terserOptions: { compress: { drop_console: true } } }), new CssMinimizerPlugin() ] }这里drop_console: true会在生产环境去掉所有console.log打印对代码清洁和性能都有意义。但注意这会连console.warn和console.error一起去掉如果线上的错误上报依赖了console输出要慎重可以先只dropconsole.log。分析chunk体积我用webpack-bundle-analyzer在build:analyze脚本里打开一个可视化面板每个chunk里包括什么依赖、体积多大一目了然。多页项目里因为页面多分析面板会非常有信息量能快速定位到哪些页面引了不该引的大库然后针对性地做按需加载。5.4 多页场景下的代码分割按需加载与公共资源预加载多页应用里做按需加载的思路和单页应用有一些区别。单页应用里你路由懒加载很自然多页项目里每个页面本身已经是一个独立入口了但页面内部如果有一些非关键的模块比如弹窗里的富文本编辑器、某个数据大屏里才需要的图表库仍然建议用动态import拆出来。elpis-core里的做法是// 某个页面内部按需引入 const Chart () import(common/components/Chart);webpack遇到动态import会自动生成独立的chunk并在运行时异步加载。这些异步chunk不需要加入entry它们的加载由页面自己的运行时决定。另一个优化点是在HTML里给公共chunk加relpreload。HtmlWebpackPlugin生成的head里默认只引用当前页面的chunks。如果你希望用户访问页面时更快地加载其他页面共享的公共库可以用preload相关插件配置但要注意预加载的量和时机过度预加载反而会浪费带宽。这个需要配合性能分析数据来判断不要盲目加。6. 多页项目中的常见问题与排查实录6.1 部署后页面白屏chunks引用顺序错了elpis-core第一次打生产包发测试环境打开页面直接白屏控制台报错提示某个公共模块里引用的变量undefined。查了半天发现是HtmlWebpackPlugin的chunks顺序问题。我前面提到过chunks: [common, pageName]这个顺序非常关键。common包如果排到了pageName后面或者直接没有包含common包页面先执行了业务代码里面import的公共模块还没有定义必然报错。排查思路也很简单打开部署后的HTML源码看script标签的引入顺序。再对比webpack配置里chunks数组的顺序应该完全一致。这里也顺便印证了一个工程化经验多页配置里任何涉及“顺序”的字段都应该用显式配置写明不要依赖webpack的默认排序默认排序在页面数量变多后可能不是你期望的。6.2 修改公共代码所有页面没生效或构建报错这个问题我踩过两次现象是改了src/common里的工具函数两个页面引用它但构建结果里有一个页面还是旧逻辑。后来定位到原因splitChunks配置里reuseExistingChunk: true在某种边界情况下会把多个入口对公共包的引用复用到一个非预期的chunk上导致其中一个页面加载的公共包不是最新构建产物。这类问题确认起来比较麻烦因为它不是每次都必现。我的建议是多页项目里如果把公共代码拆成独立chunk一定要抽查两个以上页面的产物内容。如果发现某个chunk没有按预期更新先把reuseExistingChunk关掉重建一次确认问题是不是它引起的再决定是否重新开启。6.3 两个页面引用了同一个第三方库体积翻倍这个问题在elpis-core里也出现过。两个页面各自import了同一个图表库但splitChunks的vendor分组因为某种原因没有把它们归到同一个chunk导致两个页面各打包了一份图表库的代码。排查方法是用webpack-bundle-analyzer看依赖分布确认图表库出现在哪几个chunk里。解决方向有两个一是调整splitChunks的minChunks和minSize让公共模块更容易被提取二是在两个页面里用统一的引用路径避免一个写成import A from lib、另一个写成import A from ../../node_modules/lib这种不一致的引用方式后者会让webpack判定它们是不同的模块。6.4 开发环境正常、生产环境请求404publicPath配置问题多页项目部署到子目录或者CDN后最容易碰到的坑就是静态资源路径不对。页面能打开但js、css、图片全部404。这时候要检查output.publicPath。elpis-core在开发环境不设置publicPath时默认相对路径能正常工作但生产构建时要根据实际部署位置设置。如果部署在CDN域名下设置为https://cdn.elpis-core.com/如果部署在域名子目录下设置为/sub-dir/。关于publicPath有一个常见坑如果设置为相对路径./多页应用里页面路径层级不同相对路径解析出来的绝对地址可能不一致导致部分页面资源加载失败。所以实践上多页项目最好统一使用绝对路径的publicPath。6.5 多页问题排查速查表现象可能原因优先排查方向页面白屏控制台报模块未定义chunks顺序错误或公共chunk缺失检查HTML script标签顺序修改公共代码后部分页面未更新splitChunksreuseExistingChunk边界情况关闭该配置重建验证页面能打开js/css全404publicPath配置错误检查生产部署路径与publicPath是否一致构建产物巨大公共库被重复打包或多页面重复引用大库用bundle-analyzer定位大chunk依赖热更新失效页面模板修改不受HMR监控重启devServer或确认chunk配置新增页面后构建报错glob扫描到没有index.html的目录确认pages下目录结构完整性7. 里程碑2学习心得与后续规划7.1 配置驱动开发的思维方式转变这个里程碑对我来说最大的收获不是学会了某几个webpack配置项而是建立了一种“配置驱动开发”的思维方式。在elpis-core之前我写前端项目的时候通常是页面开发优先构建配置都是拷贝来得差不多就行。但在多页工程里构建配置本身就是产品架构的一部分。每新增一个页面如果构建层面没有自动适配方案开发效率就会明显下降。所以做多页架构第一步想的不应该是“我现在有哪些页面”而是“我这个项目未来会以什么方式新增页面”然后把这种新增方式固化成约定再让构建工具去自动适应这个约定。7.2 动态入口方案可以继续扩展的方向elpis-core现在的动态入口只是基于目录结构的简单扫描。如果后续页面数量继续增长可以考虑在此基础上扩展支持页面级独立配置每个页面的index.js旁边放一个page.config.js用来设置该页面独立的title、描述、公共依赖等构建时读取这些配置覆盖全局默认。支持页面级自定义webpack配置比如某个页面需要特殊的外部全局变量、特定的polyfill可以在page.config.js里做局部扩展。支持按页面目录输出构建产物某些页面归属不同产品线需要分别部署可以通过entry路径前缀来区分配置输出目录。这些扩展方向本质上都是同一件事让“新增页面”的成本趋近于零让“页面个性化配置”的维护成本不被隐藏到全局配置文件里。7.3 最后分享两个真实的小技巧写配置久了有一些看似不起眼但实际很救命的经验。一个是webpack配置文件里打印process.env.NODE_ENV时常常是undefined因为webpack的DefinePlugin替换是编译期的行为不会改node端的环境变量。排查环境变量相关问题时别在配置文件里打日志先在命令行里echo验证能省很多时间。另一个是无论配置多复杂建议保留一个build/config-validator.js之类的自查脚本在构建前检查entry、html、chunks之间的对应关系。elpis-core的页面数量到10个以上时这种自动校验的价值会越来越明显。脚本里可以用Node的断言库把getPages()拿到的页面列表和fs实际存在的文件做一次比对有缺失就主动报错退出构建而不是等上线后白屏了再回来查配置。
返回列表