
这个项目是系列练手里的第 13 个场景是一个在线教育风格官网新东方样式首页、课程列表、老师介绍、常见问答这些页面。整体是典型的多页面静态站点数据不需要接后端样式要尽量还原构建这块全部交给我来做。我的任务是用 webpack 把全站资源统一管起来做到开发热更新顺手、生产包尽量小、缓存策略正确。今天把 webpack 配置、打包优化配置和踩坑过程完整梳理一遍正在学 webpack 配置或者准备做前端工程化的人可以参考。1. 项目背景与整体设计1.1 这是一个什么样的项目先说清楚场景。教育官网的特点是页面多、图片多、模块重复度高。比如首页有导航、轮播、课程推荐位、名师展示区、底部合作机构课程列表页有筛选侧栏、分页组件、课程卡片每个老师详情页又复用了一批组件。这种项目交给 webpack 来打包核心要解决三个问题多页面入口怎么组织、公共代码怎么抽离、几十张图片和若干第三方库怎么把体积控制在合理范围。整个项目采用多页模式MPA每个页面一个 HTML对应一个 JS 入口。这和现在很多纯 SPA 练手项目不一样。教育官网的 SEO 需求本来就重多页模式更贴近真实场景。我最终把页面分成四个入口首页、课程页、老师页、详情页每个入口目录下面放自己的 html 和 js公共组件统一放 src/components公共样式放 src/styles。目录结构大概这样src/ ├── pages/ │ ├── home/ │ │ ├── index.html │ │ └── index.js │ ├── course/ │ │ ├── index.html │ │ └── index.js │ ├── teacher/ │ │ ├── index.html │ │ └── index.js │ └── detail/ │ ├── index.html │ └── index.js ├── components/ │ ├── Header.js │ ├── Footer.js │ └── CourseCard.js ├── styles/ │ ├── base.css │ └── variables.css └── utils/ └── request.js1.2 为什么用 webpack而不是 Vite这个问题我开头先给结论练手可以用 Vite但很多公司存量项目、特别是 2018 年到 2022 年之间搭建的中后台和官网项目全是 webpack 体系。你会配置 webpack进项目能直接上手改配置、做性能优化你只会 Vite遇到老项目会卡壳。另一个原因是教育官网这种多页场景webpack 的成熟生态很合适。HtmlWebpackPlugin 逐页生成 HTML、splitChunks 自动分 vendor、MiniCssExtractPlugin 抽 CSS这些能力都是经过大量线上项目验证的。webpack 5 还有个非常大的变化内置了持久化缓存二次构建速度提升明显这在一定程度上弥补了它冷启动慢的短板。2. 基础配置看懂每行代码在干什么2.1 多页面入口和出口的命名策略webpack 的配置核心先看 entry 和 output。多页面场景entry 不能用单字符串要传对象key 是页面名value 是入口文件路径。// webpack.base.js const path require(path); const HtmlWebpackPlugin require(html-webpack-plugin); module.exports { entry: { home: ./src/pages/home/index.js, course: ./src/pages/course/index.js, teacher: ./src/pages/teacher/index.js, detail: ./src/pages/detail/index.js }, output: { path: path.resolve(__dirname, dist), filename: static/js/[name].[contenthash:8].js, clean: true } };这里有两个值得解释的点。第一filename 里的 [contenthash:8] 是内容哈希取 8 位。它和 hash、chunkhash 的区别是只要文件内容没变构建生成的哈希就不变浏览器就能继续用缓存内容变了哈希变浏览器重新拉取。真实项目里 JS 必须用 contenthash不要用 [hash]否则打包任意文件都会导致所有文件名变化缓存全部失效。第二output.clean 是 webpack 5 自带功能替代了之前的 CleanWebpackPlugin每次构建前自动清空 dist 目录。老项目里如果还在手动删 dist说明配置停留在 webpack 4 时代。2.2 loader 选型与文件处理规则webpack 本身只知道 JS其他文件都要靠 loader 或内置模块来解析。我这个项目的规则分三类JS 用 babel-loaderCSS 根据环境不同走 style-loader 或 MiniCssExtractPlugin图片用 webpack 5 的 asset modules。module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: babel-loader, options: { presets: [babel/preset-env], plugins: [babel/plugin-transform-runtime] } } }, { test: /\.css$/, use: [style-loader, css-loader] // 开发环境用法生产环境会换 }, { test: /\.(png|jpe?g|gif|webp)$/, type: asset, parser: { dataUrlCondition: { maxSize: 8 * 1024 } }, generator: { filename: static/img/[name].[hash:8].[ext] } } ] }asset modules 是 webpack 5 替代 file-loader 和 url-loader 的写法。我用的 type: asset 是个智能模式小于 8KB 的图片自动转 base64 内联减少 HTTP 请求大于 8KB 的走独立文件。8KB 这个阈值不是拍脑袋定的我实测过转 base64 会让 HTML 和 CSS 体积增加约 33%小图内联收益明显大图内联反而拖慢首屏解析。8KB 是社区验证下来比较均衡的阈值。babel-loader 里我加了 transform-runtime因为教育官网这种页面会用到 Promise、Object.assign 这类新语法预设没有 polyfill 功能如果不做处理低版本浏览器直接白屏。注意 exclude: /node_modules/ 一定不能去掉否则 node_modules 里的大量代码也会被 babel 转译一遍构建慢得离谱。2.3 插件组合与开发服务器插件这边HtmlWebpackPlugin 是关键。多页面场景要写多个实例每个实例指向自己的模板用 chunks 字段指定这个页面要加载哪些 JS。plugins: [ new HtmlWebpackPlugin({ template: ./src/pages/home/index.html, filename: home.html, chunks: [home, vendor, common] }), new HtmlWebpackPlugin({ template: ./src/pages/course/index.html, filename: course.html, chunks: [course, vendor, common] }) ]chunks 一定要手动指定不指定的话每个 HTML 会引入所有入口的 JS这是多页面项目最容易犯的错误之一。我见过有人配完发现 course.html 里挂着 home.js首屏加载了 800KB 没用的代码还以为 webpack 坏了。开发服务器用 webpack-dev-server 4配置里开热更新和代理devServer: { port: 8080, hot: true, open: true, historyApiFallback: true, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } }这个项目没接真实后端但代理配置我还是留了。教育官网如果涉及登录、课程报名前端请求 /api 开头开发环境直接代理到本地 mock 服务或者后端联调地址生产环境再走 Nginx 转发。前后端分离项目的标配提前配好不亏。3. 打包优化配置从 2 分钟到 30 秒的实战3.1 Tree Shaking 生效条件与 sideEffectsTree Shaking 是 webpack 最常被面试问到的优化点之一但实际项目中很多人配了没生效问题往往出在 sideEffects 字段。Tree Shaking 的意思是在打包时把没用到的代码“摇掉”。ES Module 的 import/export 是静态结构webpack 能分析出哪些导出没被引用从而删除。CommonJS 的 require 是运行时加载没法做静态分析所以 tree shaking 对 CJS 无效。这是用 webpack 做优化时必须记住的第一条业务代码尽量写 ESM第三方库要看它有没有提供 ESM 版本。第二条是 package.json 里配 sideEffects: false。这个字段告诉 webpack我这个包的副作用可以安全移除。副作用指的是模块加载时对外部造成影响比如修改全局变量、改变原型链。如果 package.json 没配webpack 为了安全会保留所有代码tree shaking 白做。{ name: edu-web, sideEffects: [**/*.css, **/*.less] }注意我故意把 CSS 列进了 sideEffects。因为很多组件库的样式是在 JS 里 import 的比如 import element-plus/dist/index.css如果 package.json 里写 sideEffects: falsewebpack 会认为这个 CSS 是没用的直接把样式删了页面就光了。这是新手碰到“配了 tree shaking 页面样式全没”的经典原因。3.2 splitChunks 分包策略教育官网的第三方库主要有一两个 UI 库、axios、一些工具函数。不分包的话vendor 代码和业务代码全打在一个文件里改一行业务代码整个大文件哈希变用户要重新下载所有 JS。分包之后vendor 文件几乎不动长期被浏览器缓存。我用的是 splitChunks 自定义缓存组optimization: { splitChunks: { chunks: all, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendor, priority: 10, reuseExistingChunk: true }, common: { minChunks: 2, minSize: 0, name: common, priority: 5, reuseExistingChunk: true } } }, runtimeChunk: single }chunks: all 意味着异步加载的模块也参与分包。老项目很多写 chunks: initial只处理首屏同步模块路由懒加载的代码不走分包结果就是异步 chunk 里又塞了一份第三方库体积翻倍。cacheGroups 按优先级匹配vendor 先匹配 node_modules看一个模块在多少个入口被用到。common 这个组处理业务公共代码minChunks: 2 表示至少被两个入口引用才抽出来。runtimeChunk: single 单独抽 webpack 的运行时代码这部分代码不涉及业务逻辑抽出来可以稳定缓存避免每次构建因 runtime 变化导致业务 chunk 哈希变化。分包这块我吃过的亏是vendor 包拆得太粗。一开始我没有细分组所有 node_modules 打成一个 vendor结果达到 1.2MB。后来拆成 vendor 和 vendor-echarts 两组把高频图表库单独隔离首屏只加载业务真正需要的优化效果立竿见影。3.3 静态资源体积压缩教育官网图片多打包产物大头就是图片和字体。这一块不是配置能完全解决的但 webpack 层面可以做的有去掉图片多余元数据、压缩质量、CSS 和 JS 压缩。optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: true, terserOptions: { compress: { drop_console: true } } }), new CssMinimizerPlugin() ] }TerserPlugin 是 webpack 生产模式的默认压缩器我显式配置主要是为了两件事开 parallel: true多进程并行压缩多核机器上收益明显以及 drop_console 去掉 console.log 调试输出。线上环境打 console 是非常不专业的而且 console 代码在低端机上还影响性能。图片压缩我用的是 image-webpack-plugin 系但真实建议项目里图片建议在进仓库前就压好别每次都让 webpack 现压。我后来改成在 npm scripts 里加一条压缩命令产出优化图片后再进 dist构建速度提升了一大截。webpack 层面只处理 asset 的哈希和输出路径就够了。3.4 持久化缓存与并行构建webpack 5 的开发模式默认开启了持久化缓存生产环境建议显式配置。原理是把模块的编译结果序列化到磁盘默认 node_modules/.cache下一次构建如果文件没变直接从磁盘读取不用重新编译。对大的存量项目这个优化能直接把二次构建时间砍半。cache: { type: filesystem, buildDependencies: { config: [__filename] } }buildDependencies 告诉 webpack配置文件本身发生变化时缓存要失效重建。不配的话你改了 loader 配置webpack 还拿着旧缓存给你跑出现改配置不生效的怪问题。并行构建还可以用 thread-loader把耗时 loader比如 babel、ts放到 worker 线程但线程启动有开销项目小于 20 个模块时反而更慢。我的建议是中小项目别上大项目先用持久化缓存还不够再考虑 thread-loader。4. 多环境配置与工程化规范4.1 用 webpack-merge 拆分配置工程化规范的做法是把配置拆成三份基础配置、开发配置、生产配置。webpack-merge 负责合并。// webpack.dev.js const { merge } require(webpack-merge); const base require(./webpack.base.js); module.exports merge(base, { mode: development, devtool: eval-cheap-module-source-map, devServer: { port: 8080, hot: true, open: true }, module: { rules: [ { test: /\.css$/, use: [style-loader, css-loader] } ] } });dev 环境用 style-loader因为热更新时 style-loader 可以直接操作 style 标签实现 CSS 热替换不需要刷新页面。生产环境换成 MiniCssExtractPlugin.loader把 CSS 抽成独立文件可以利用浏览器 CSS 并行加载能力还能单独缓存。合并的时候有讲究merge 默认对数组是直接替换。module.rules 如果 base 里有一组 JS 规则dev 里又来一组 CSS 规则没问题因为 test 不会冲突。但如果 dev 想覆盖 base 里的某个 loader 配置数组顺序和覆盖关系就容易出问题要小心处理。建议 base 里只放 JS、图片这些各环境一致的规则CSS 这种分环境不同的放到各自配置文件里从源头避开坑。4.2 生产环境特殊处理生产配置主要加四样CSS 抽取、资源压缩、环境变量注入、缓存策略。// webpack.prod.js const { merge } require(webpack-merge); const MiniCssExtractPlugin require(mini-css-extract-plugin); const CssMinimizerPlugin require(css-minimizer-webpack-plugin); const TerserPlugin require(terser-webpack-plugin); const base require(./webpack.base.js); module.exports merge(base, { mode: production, devtool: false, output: { publicPath: https://cdn.example.com/edu/ }, module: { rules: [ { test: /\.css$/, use: [ MiniCssExtractPlugin.loader, css-loader, { loader: postcss-loader, options: { postcssOptions: { plugins: [postcss-preset-env] } } } ] } ] }, plugins: [ new MiniCssExtractPlugin({ filename: static/css/[name].[contenthash:8].css }) ], optimization: { minimizer: [ new TerserPlugin({ parallel: true }), new CssMinimizerPlugin() ] }, cache: { type: filesystem, buildDependencies: { config: [__filename] } } });publicPath 直接指到了 CDN这个要配合部署策略。资源放 CDN 的好处是浏览器对同域并发请求数有限制HTTP/1.1 是 6 个左右静态资源走不同域名可以并行加载而且 CDN 节点离用户近首屏更快。开发环境不配这个配了会把图片地址都指向 CDN如果 CDN 上没有这些资源页面全挂。process.env.NODE_ENV 在 mode 设为 production 时会自动设置为 production业务代码里的三目表达式如 process.env.NODE_ENV ! production 会被静态替换压缩阶段把 false 分支删掉。这是 webpack 做环境注入的基本原理省了不少调试代码。4.3 产物分析工具配置写完了怎么看优化效果我用了两个工具speed-measure-webpack-plugin 统计各 loader 和插件耗时webpack-bundle-analyzer 输出产物依赖关系图。bundle analyzer 用起来很简单在分析模式下浏览器会自动打开 localhost:8888。图上每个矩形是一个包大小和颜色直观。我第一次看这个项目产物图的时候发现团队一个公共组件库被打了三份进三个入口就是因为 splitChunks 没配置好一眼就抓住了问题。优化完再看vendor 变成一根粗柱业务代码均匀分布心里就踏实了。speed-measure 现在对 webpack 5 的兼容要留意很多版本有适配问题建议只在大项目排查构建慢的时候用平时不开。5. 实战排查记录5.1 HMR 不生效改了样式页面整页刷新这个问题的表现是改了 CSSwebpack 提示 Updated modules但页面白屏刷新。首先确认 devServer 里 hot 是否设为 true其次确认没有开 react-refresh 之类的插件却又不匹配当前框架。我这个项目因为是原生 JS 加 CSS问题出在 style-loader 和模块标识上解决方法是把 devServer 的 hot 改成 true 后重启 devServer 而不是只保存文件。另外一个容易忽略的点HMR 用的是 websocket如果你用了代理而且 agent 配置把 Upgrade 头处理掉了也会静默失败。5.2 JS 打包后 contenthash 没变化我遇到过改完代码dist 里的文件名哈希还是老的刷新后页面还是旧代码。排查方向一是 check 文件是否真的被打进产物比如入口引用路径写错bundle 里根本没有那段代码二是 webpack 5 缓存没失效改了配置或代码它还在用磁盘缓存。我那次是代码写进了另一个分支文件没被入口引用哈希自然不变。还有一个类似的坑开发环境用 [hash] 而非 [contenthash]会导致所有 chunk 共用一个哈希随便改一行全部变。生产配置务必用 contenthash。5.3 vendor 包太大首屏加载慢教育官网首屏问题是 3 秒内要能看。vendor 包从 1.2MB 压到 400KB 的过程做了三件事一是检查是否有重复引用的库两个 UI 库同时存在的情况必须砍掉一个二是按需引入很多 UI 库支持 babel 插件自动把 import 转换成组件级引入不用全量打包三是合理分包把图表库单独拆出去不阻塞首屏逻辑。这里补充一句首屏优化不是只靠 webpack。图片懒加载、骨架屏、资源 preload 这些配合起来效果才明显。webpack 解决的是“别把不必要的东西塞进首屏包”应用层还要做“首屏之外的内容延后加载”。5.4 生产环境 devtool 该选什么devtool 分两种思路。配合 source map 是为了线上排错但会把源码暴露出去懒加载 chunk 映射文件体积还会拖慢加载。我的选择是生产环境 devtool 设为 false 或者 nosources-source-map。后者会提供栈追到源码行列但不包含源码内容对线上错误监控友好。开发环境用 eval-cheap-module-source-map构建快、栈信息准确够用了。真有生产排错需求建议把 source map 单独上传到监控平台不要把 map 文件直接放在 CDN 上。这是我学到的一个重要习惯source map 一旦公开就等于把源码开源项目里如果有接口签名、加密逻辑等于直接交底。5.5 拆分公共组件后页面样式顺序错乱抽离 common chunk 之后有的页面样式加载顺序变了覆盖关系乱了。原因是 CSS 加载顺序取决于 JS chunk 的引用顺序分包后引用顺序变了。解决方式是检查 HtmlWebpackPlugin 的 chunks 顺序确保公共样式所在的模块先于业务样式加载或者把全局样式单独用 MiniCssExtractPlugin 抽成一个 CSS直接 link 引入用 HTML 里的顺序保证优先级。5.6 最后的实用小技巧再补几个这个项目里实际用到的经验。npm scripts 把构建命令分清楚dev、build、build:analyze别混在一个配置文件里靠注释切环境。生产构建前跑一次 ESLinterror 级别直接报错退出不让问题代码进产物。图片资源集中放一个目录方便统一压缩和走 CDN 规则。配置里的路径尽量用 path.resolve(__dirname, ...) 而不是相对路径避免入口引用的文件换位置之后路径错乱。我在这个项目上最大的体会是webpack 的配置本身并不难背难的是理解它背后的编译链路。entry 怎么变成 chunkchunk 怎么变成文件loader 在哪个阶段起作用缓存为什么失效这些都是实际改配置时绕不开的问题。配好一个教育官网这样的多页面项目你就把 webpack 80% 的工程场景都过了一遍。后面再去做大型中后台项目无非是加一些框架适配和更多缓存组思路是完全一致的。这套配置和排查方法我会继续沿用下去。