ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

webpack 5打包体积从1.1MB优化到210KB的完整实践

webpack 5打包体积从1.1MB优化到210KB的完整实践 前阵子一直在处理一个基于 webpack 5 的中后台项目打包体积从 gzip 后 800KB 一路涨到 1.1MB首屏在 4G 网络下已经卡到让人怀疑人生。优化完以后gzip 后最终落到 210KB 上下相当于总体砍掉了 80% 左右。这个过程里我还顺手解决了一堆奇奇怪怪的问题包括 Chrome DevTools 里经常出现的 “Could not read source map for webpack://xxx/node_modules/xxx” 这种报错。这篇文章就把这些经验完完整整串一遍给正在做 webpack 打包体积优化、或者刚接手老项目想快速瘦身的朋友一个可参考的路线。先说结论webpack 打包体积膨胀从来不是某一个配置项导致的而是一整套工程习惯出了问题。只要日常开发中没有及时关注包体变化过几个月再看 bundle 报告一定会被里面躺着的一堆“全量引入”坑到。下面这 5 招加上一开始的体检和后面 source map 的排查基本覆盖了从定位问题到最终交付的完整链路。1. 动手前先做一次包体“体检”没有数据就没有优化方向1.1 接入 webpack-bundle-analyzer三分钟看清包体构成很多人一上来就改配置、加压缩插件等改完发现体积没变化多少原因很简单你连大头在哪儿都不知道谈什么优化。我自己的习惯是先把 webpack-bundle-analyzer 接上让构建产物直接生成一份可视化报告然后再决定从哪儿动手。安装和执行都很快npm install -D webpack-bundle-analyzer然后在 webpack.config.js 的 plugins 临时加一段const { BundleAnalyzerPlugin } require(webpack-bundle-analyzer); module.exports { plugins: [ new BundleAnalyzerPlugin({ analyzerMode: static, reportFilename: bundle-report.html, openAnalyzer: false, generateStatsFile: true, }), ], };analyzerMode: static会在打包后生成一个 HTML 报告文件openAnalyzer: false避免打包完成自动弹浏览器。你只需要跑一次构建然后打开dist/bundle-report.html就能看到所有 chunk 的尺寸、依赖占比、被重复打包的问题。这一步非常重要因为它解决的是“优化方向”的问题。数据摆出来后你会发现很多直觉判断完全是错的。比如我一开始以为首屏慢是因为图片太多结果报告显示真正的元凶是echarts和moment两个库把主 chunk 撑爆了图片反而是小头。没有这步后面的一切优化都是蒙着眼睛瞎调。1.2 我那次体检看到了什么我当时那个项目的技术栈是 React React Router antd axios echarts很常见的中后台组合。第一次看报告的时候主 chunk 已经接近 900KB未压缩前甚至到了 2.4MB。打开可视化面板一眼扫过去最刺眼的就是moment.js 全量语言包被打进主包占了将近 60KBecharts 完整版引入包含所有图表类型但项目实际只用折线图和柱状图白白多出了 200 多 KBantd 的组件虽然走的是 ES module 按需编译但样式文件还是全量引入lodash 里有大量工具函数从来没有被用过但 webpack 的 tree shaking 没有把它们摇掉。这些都是特别典型的问题。中后台项目开发周期长、参与的人多维护阶段最容易犯的错就是图省事import moment from moment直接写图表用import * as echarts from echarts一把梭。久而久之包体就膨胀得根本拦不住。1.3 划分优化优先级哪些属于“高性价比改动”看完报告之后别急着动手。我习惯把问题分成三类再决定先后顺序问题类型典型例子性价比引入方式错误moment 全量语言包、echarts 全量图表高改动小收益大构建策略缺失没有路由级拆包、没有第三方依赖拆包高配置调整即可资源压缩不够没有 gzip、图片未压缩中依赖服务器/构建配合第一个版本先做高性价比改动把几行 import 换成按需引用再把 splitChunks 策略加上包体就能很直观地降下来。第二步再处理压缩和缓存问题优化收益也不会小但需要联动服务器配置周期会长一点。所谓 80% 的缩减通常就是这么一步步叠出来的不存在一个配置一夜间把包体砍到原来的五分之一这种魔幻剧情。2. 第一招路由级代码分割把首屏只需要的那部分先交给浏览器2.1 动态 import从“一次全打包”到“用的时候才加载”很多人对 webpack 的代码分割理解停留在“把 node_modules 拆出来”这个层面但真正影响首屏加载体验的是业务代码本身有没有按路由拆开。中后台项目通常有几十个页面如果所有页面都打进同一个主 chunk用户访问登录页也得下载完整包哪怕他根本不需要看那些报表。最基础也是最好用的拆法就是 React.lazy 动态 import。以前你可能这么写路由import Dashboard from /pages/Dashboard; import UserList from /pages/UserList; import Report from /pages/Report;改成这样const Dashboard React.lazy(() import(/* webpackChunkName: dashboard */ /pages/Dashboard)); const UserList React.lazy(() import(/* webpackChunkName: user */ /pages/UserList)); const Report React.lazy(() import(/* webpackChunkName: report */ /pages/Report));并在 App 外层包一个 Suspense 组件路由组件在加载完成之前会命中 fallback。这个方案对 webpack 和 React 都是原生支持不需要额外装插件成本极低。/* webpackChunkName: dashboard */这段魔法注释负责给生成的 chunk 命名如果你不写webpack 会按0.js、1.js这种纯数字命名排查问题时会很痛苦。加上名字以后dist 目录里看到的是dashboard.chunk.xxx.js一眼就知道对应哪个页面。2.2 命名 chunk 与 prefetch 预加载的使用细节路由拆完之后很多人的下一步是把webpackPrefetch: true加上。我第一次接触这个配置时也觉得很香以为浏览器会在空闲时把页面提前下载好自动变快。但 prefetch 代码分割会用 requestIdleCallback 在空闲时下载移动端、弱网环境下反而可能造成不必要的流量消耗。建议只在核心页面上加const Report React.lazy(() import(/* webpackChunkName: report */ /* webpackPrefetch: true */ /pages/Report));把这个权限交给最有可能被用户下一跳访问的页面而不是每一条路由都无脑 prefetch。如果项目里所有路由都 prefetch那就相当于把本来省下来的流量又全部花出去了只是把网络请求从“打开时”挪到了“空闲时”首屏体积没有本质变化。还有一点容易踩坑React.lazy 只能用在默认导出组件上。如果你的页面组件用的命名导出比如export function Dashboard需要先改造成export default Dashboard或者用一个中间对象转一下否则控制台直接报错。2.3 拆分粒度不是越细越好动态 import 确实能指定任意一个模块成为独立的 chunk但粒度不能无脑细。有一次我图省事把一个列表页里的每一个弹窗表单都拆成了单独 chunk结果用户打开列表页时浏览器同时发起七八个请求每个请求单独走一遍 TLS、建立连接反而比一个完整 chunk 更慢。我的经验是路由级别是天然合适的拆分边界因为路由就是业务上最自然的入口。组件级拆分只适合那些“触发后才出现”的模块比如富文本编辑器、小地图、复杂图表组件这些模块通常体积大、用户不一定用非常适合按需加载。对于普通按钮、弹窗这种几百行的代码放进所属路由 chunk 反而是最优解。3. 第二招让 tree shaking 真正生效而不是白配置3.1 前提是 ESM 模块空间CommonJS 的 require 会让摇树失败tree shaking 的原理很简单通过静态分析 ES Module 的 import/export 语法把那些没有真正被引用的函数和变量从最终产物中剔除。问题是很多人写的代码根本不是 ESM 语法自然就摇不掉。最常见的情况是 babel 处理。babel/preset-env默认会把 ES Module 转换成 CommonJS如果你在某处直接写import _ from lodash; const result _.chain(data).map(...).value();经过 babel 转译后import _ from lodash变成了require(lodash)webpack 再强大也没办法分析一个 CommonJS 模块的静态依赖关系tree shaking 直接失效。解决办法是在 babel 配置里保证 modules 不变{ presets: [ [babel/preset-env, { modules: false }] ] }modules: false的意思是保留 ES Module 语法给 webpack 处理babel 只负责语法降级不做模块系统转换。如果你用的构建链比较复杂也可以用babel/preset-typescript配合 babel-loader 的 ES module 支持原理都一样。3.2 package.json 里的 sideEffects很多人忽略的开关另外一个容易被忽略的配置是 package.json 里的sideEffects字段。默认情况下 webpack 不知道哪些文件有副作用会保守地把所有文件都保留下来。这个字段的作用是告诉 webpack“我这个包里的模块是纯正的没有被 import 的部分可以放心删掉。”对于业务项目根目录的 package.json 可以声明{ sideEffects: false }但要注意一点如果你的项目里有.scss、.css这类样式文件直接写false会把样式文件也摇掉因为 CSS 引入就是一种典型的副作用。正确写法是把样式文件列出来{ sideEffects: [ **/*.css, **/*.scss, ./src/polyfill.js ] }我在实际项目中就踩过这个坑。当时为了让 antd 样式能正常加载我把sideEffects: false换成了数组形式并把antd/dist/antd.css加了进去才恢复正常。如果你用第三方组件库最好检查一下它们自己的 package.json有些库没有声明 sideEffectstree shaking 效果会大打折扣。3.3 我踩过的 tree shaking 失效场景和检查方法tree shaking 失效这件事有时候肉眼看不出来。我常用的检查方式是直接搜构建产物grep -r createAnalytics dist/static/js/如果某个明显没被业务代码调用的函数名出现在产物里说明它没有被摇掉。这个方法比包体积数字更直接。还有一个常见场景是index.js里集中导出模块。比如你的 utils 目录下有一个 index.jsexport { formatDate } from ./formatDate; export { deepClone } from ./deepClone; export { debounce } from ./debounce;当你在业务里只 importdebounce时理论上其余两个应该被摇掉。但如果导出方式不够静态或者中间有相互依赖的隐式副作用webpack 就不敢删。我后来改成一个模块一个文件、内部不再互相参照tree shaking 的效果立刻好转。这里最重要的思路是tree shaking 的最终效果取决于模块能否被静态分析。任何可能引入运行时不确定性的写法比如条件导出、组合导出、改变全局对象的 init 函数都会让 webpack 选择保守处理。写业务代码的时候尽量单个函数单文件、纯函数优先收益不只在代码可维护性打包优化也会轻松很多。4. 第三招给第三方库做减重能换掉就不要将就4.1 moment.js 换成 dayjs一个能把包体砸掉上百 KB 的例子如果说有什么优化是我每次都跟朋友反复安利的那就是把 moment.js 换掉。moment.js 的功能确实强大但它有两个问题一是不可树摇因为整个库暴露一个全局对象语言包和核心逻辑全是运行时动态加载二是语言包体积感人一个全量语言包加核心库未压缩 400 多 KBgzip 后也能有 60 到 80KB。我那个项目里只用了日期格式化、日期加减、时间差计算这些最基础的能力完全用得上 dayjs。dayjs 的 API 设计跟 moment 几乎一致迁移成本极低// 改造前 import moment from moment; moment().format(YYYY-MM-DD HH:mm:ss); // 改造后 import dayjs from dayjs; dayjs().format(YYYY-MM-DD HH:mm:ss);配合中文语言包的时候注意按需引用import dayjs from dayjs; import dayjs/locale/zh-cn; dayjs.locale(zh-cn);如果想用相对时间这种高级功能也只用把对应插件加进去比如import relativeTime from dayjs/plugin/relativeTime; dayjs.extend(relativeTime);这一处改动做完我项目的 gzip 体积直接降了 60KB 以上。如果你项目里的日期需求主要就是格式化、加减、比较强烈建议先拿它开刀。4.2 lodash 的全量引入警告与按需引用lodash 是很典型的“容易拉爆包体”的库。如果遵循import _ from lodash这种整包引入方式即使只用其中一个_.debouncewebpack 也会把整个 lodash 模块链全部处理一遍。我见过一个项目光 lodash 就贡献了 gzip 后 40 多 KB。正确做法是替换为按需引入import debounce from lodash/debounce; import throttle from lodash/throttle;或者换成 lodash-es它原生支持 ES Module配合 tree shaking 能按函数级别摇树npm install lodash-esimport { debounce } from lodash-es;需要注意的是如果项目里有一些老代码是_.map、_.each这种写法直接用 lodash-es 替换可能因为 API 差异出问题。我的经验是先小范围替换跑通后再全量推广。还有一个便宜方案是 eslint 规则约束强制不允许整包引入 lodash最好是团队规范里就堵死。4.3 externals CDN把基本不变的依赖放到浏览器缓存里有些库无论怎么按需引入体积都很大比如 react、react-dom、echarts 这种基础依赖。一个思路是让 webpack 不再把它们打进产物而是通过 CDN 标签直接引入浏览器可以在多个页面之间共享缓存。webpack 配置 externalsmodule.exports { externals: { react: React, react-dom: ReactDOM, echarts: echarts, }, };然后在 HTML 模板中引入 CDN 地址script srchttps://cdn.example.com/react18.2.0/umd/react.production.min.js/script script srchttps://cdn.example.com/react-dom18.2.0/umd/react-dom.production.min.js/script script srchttps://cdn.example.com/echarts5.4.0/dist/echarts.min.js/script这样做的好处很直观这些依赖的代码完全不进入本地 bundle构建时间也会缩短很多。但要注意两个前置条件CDN 要稳定而且最好走 HTTP/2 甚至 HTTP/3否则一个script标签的下载就够拖垮首屏其次react-dom 的版本升级你得自己管理如果用 React 18 new API 但 CDN 内存的还是 17运行时极大概率会报错。externals 更适合那些确实很稳定、且不常升级的基础库。如果你的团队对版本升级非常敏感我建议只在核心业务依赖上使用别把整个 node_modules 都塞给 CDN否则拆包配置会变得极其复杂。5. 第四招splitChunks 分包把不经常改的代码和业务代码分开5.1 默认拆分的局限为什么 vendor 还是会重复打入webpack 4 开始内置了 splitChunkswebpack 5 延续了这套逻辑。但很多人的配置在实际项目中并没有起到预期效果原因在于默认配置里的chunks: async这一项。它默认只拆异步模块同步 import 的第三方依赖不会自动拆出来所以你页面里import moment from moment、import echarts from echarts这些代码依旧会老老实实待在大 chunk 里。另一个问题是如果你有多个路由 chunk 都引用了同一个库webpack 会把每个库打到各自的 chunk 里造成重复。比如 A 路由用了 echartsB 路由也用了 echarts如果不做缓存组处理最后产物里会有两份 echarts 逻辑整体体积不减反增。5.2 一份稳妥的 splitChunks 配置含缓存组思路我目前在 webpack 5 项目里常用的配置长这样module.exports { optimization: { moduleIds: deterministic, splitChunks: { chunks: all, cacheGroups: { react: { test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/, name: vendor-react, priority: 30, reuseExistingChunk: true, }, echarts: { test: /[\\/]node_modules[\\/](echarts|zrender)[\\/]/, name: vendor-echarts, priority: 30, reuseExistingChunk: true, }, antd: { test: /[\\/]node_modules[\\/](antd|ant-design)[\\/]/, name: vendor-antd, priority: 20, reuseExistingChunk: true, }, libs: { test: /[\\/]node_modules[\\/]/, name: vendor-libs, priority: 10, }, defaultVendors: false, default: false, }, }, }, };拆成这样以后最显著的变化是主 chunk 只剩下业务代码所有第三方依赖按功能族拆到独立的 vendor chunk。priority控制匹配优先级echarts 和 react 优先于 libs避免它们被淹没在通配规则里。reuseExistingChunk: true让 webpack 直接复用已经存在的 chunk不重复新建。moduleIds: deterministic是为了让模块路径和模块 ID 的生成是稳定的而不是每次构建都随机变。这给后面 contenthash 的缓存打底否则模块 ID 一变所有 chunk 的 hash 都要失效。5.3 contenthash 与缓存命中率的联动拆包不是终点缓存命中率才是。webpack 的[contenthash]是根据文件内容生成的 hash文件内容不变hash 就不变。配合 splitChunks 之后第三方库如果没升级vendor chunk 的 hash 不会变浏览器就可以一直用本地缓存业务代码更新时只有业务 chunk 的 hash 变化其余缓存全部命中用户体验差别巨大。在 output.filename 里加上 hash 就够了module.exports { output: { filename: static/js/[name].[contenthash:8].js, chunkFilename: static/js/[name].[contenthash:8].chunk.js, }, };需要注意的坑是如果你把路由级代码分割和 splitChunks 一起用不要让 webpack 生成那种纯数字命名的 runtime chunk否则每次构建 hash 都可能变。建议把 runtime 也单独拎出来module.exports { optimization: { runtimeChunk: single, }, };runtime 里的逻辑是模块加载调度代码跟业务和依赖版本都相关单独放一个很小且 hash 稳定的文件能有效提升缓存命中率。6. 第五招最后一公里压缩JS/CSS/图片/Gzip 一个都不能少6.1 TerserWebpackPlugin 的 parallel 与极致参数webpack 5 在 production 模式下默认就会开启压缩但默认的 terser 配置相对保守。如果想进一步压缩并且加快构建速度可以显式配置 TerserWebpackPluginconst TerserPlugin require(terser-webpack-plugin); module.exports { optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: true, terserOptions: { compress: { drop_console: true, drop_debugger: true, pure_funcs: [console.log], }, output: { comments: false, }, }, }), ], }, };parallel: true让 terser 多进程压缩构建速度能快不少。drop_console会把所有 console 输出全部删掉这个在生产环境没问题但如果你需要保留错误日志上报就别开这个或者只pure_funcs: [console.log]。这里有个特别容易忽略的细节压缩插件作用的顺序。如果你同时用 MiniCssExtractPlugin 提取 CSS再对 CSS 做压缩TerserPlugin 只负责 JSCssMinimizerPlugin 负责 CSS两者别搞混。以前踩过用 optimize-css-assets-webpack-plugin 但忘记配置 loader 顺序导致 CSS 压缩后样式错乱的问题。6.2 CSS 压缩与去重包含 mini-css-extract-plugin 的配合CSS 的压缩我建议跟 JS 一样独立出来。使用 CssMinimizerPlugin 和 MiniCssExtractPlugin 的组合npm install -D mini-css-extract-plugin css-minimizer-webpack-pluginconst MiniCssExtractPlugin require(mini-css-extract-plugin); const CssMinimizerPlugin require(css-minimizer-webpack-plugin); module.exports { module: { rules: [ { test: /\.css$/, use: [MiniCssExtractPlugin.loader, css-loader], }, ], }, plugins: [ new MiniCssExtractPlugin({ filename: static/css/[name].[contenthash:8].css, chunkFilename: static/css/[name].[contenthash:8].chunk.css, }), ], optimization: { minimizer: [ new CssMinimizerPlugin(), ], }, };CssMinimizerPlugin 在 webpack 5 里是官方维护的功能上兼容 optimize-css-assets-webpack-plugin但 API 更新也更勤。它能做 calc 合并、空格压缩、颜色值化简这些事虽然不如手写 CSS 精细但胜在稳定。CSS 压缩的收益一般不会像 JS 那么夸张但如果项目里有大量 antd 和业务样式混合gzip 后省下十几 KB 还是很正常的。6.3 Gzip 与 Brotli 预压缩服务器零成本提速很多人知道 Gzip但没用对。Gzip 有两种做法服务器实时压缩和构建时预压缩。实时压缩会消耗服务器 CPU而且每次请求都要重复压一遍构建时预压缩则是在构建产物里直接生成.gz文件服务器只需要启用静态 gzip 模块请求时直接返回.gz文件就行。构建时预压缩用 compression-webpack-pluginconst CompressionPlugin require(compression-webpack-plugin); module.exports { plugins: [ new CompressionPlugin({ test: /\.(js|css|html|svg|jpg|jpeg|png)$/, algorithm: gzip, threshold: 10240, minRatio: 0.8, filename: [path][base].gz, }), ], };threshold: 10240表示只有大于 10KB 的文件才会生成 gzip 版本小文件压缩收益有限不必浪费构建时间。minRatio: 0.8的意思是压缩后体积至少压低到原来的 80% 才会生成如果文件本身压缩效果不好比如已经压缩过的图片就不要强压。Brotli 的压缩率比 Gzip 更好Chrome 和 Firefox 都支持。如果你不确定服务器是否支持.br文件可以先只做 gzip。反正预压缩文件都在构建目录里后面想加 Brotli 就是换一下 plugin 的配置对业务代码无感。6.4 图片压缩有时包体大的元凶其实是静态资源webpack 打包体积的“包体”往往只统计 JS/CSS但图片如果被 import 进来同样会进入产物目录。有些项目的assets目录塞满了未经压缩的 PNG一张截图可以到几 MB。常见的优化方式是 imagemin 系列插件const ImageminWebpackPlugin require(image-minimizer-webpack-plugin); module.exports { plugins: [ new ImageminWebpackPlugin({ minimizerOptions: { plugins: [ [imagemin-gifsicle, { interlaced: true }], [imagemin-mozjpeg, { quality: 70 }], [imagemin-pngquant, { quality: [0.6, 0.8] }], ], }, }), ], };如果你的项目用的是 CDN 图片地址压根不走 webpack那这步可以跳过。但如果是本地静态资源尤其是 SVG 图标以 data URI 形式打到 CSS 里的压缩效果非常明显。不过图片压缩一定要看清楚输出格式曾经把一张封面图压成了糊成一片的色块后来把quality调高才解决。7. 优化后记与 source map 相关的那些坑7.1 优化前后对比数据别被百分比迷惑我这次优化最后的数据大概是这样维度优化前优化后主 chunk 未压缩2.4MB820KB所有 JS gzip 后1.1MB210KB首次请求数量1618首屏可交互时间4.6s1.3s“缩小 80%”这个数字听起来很刺激但这里面有个比例陷阱如果项目本来只有 100KB再怎么优化也变不出 5 倍速。所以做优化汇报时我更建议用绝对数值和用户可感知指标说话比如 gzip 后体积从 1.1MB 降到 210KB或者首屏 FCP 从 3.8s 降到 1.2s。百分比只是方便发文章标题真实价值在于体验变化和带宽节省。另外请求数量从 16 涨到 18是因为路由拆包和 vendor 分包后 chunk 数量变多了。这个不一定是坏事HTTP/2 下多几个小请求比一个超大文件好得多。如果你的服务器还在用 HTTP/1.1那就要小心了TCP 连接数被耗尽反而更慢。7.2 “Could not read source map” 到底是怎么回事很多人在构建完以后打开 Chrome DevTools会看到一条类似这样的错误Could not read source map for webpack://meai.web/node_modules/xxx/index.js这个提示看着吓人实际却不是 webpack 构建失败导致的。它的含义是 DevTools 想读取某个源文件的 source map但这个映射文件不存在、路径对不上或者对应源码已经变化了。常见场景包括本地调试时引用了 node_modules 里的文件而它的 source map 没有正确生成或者你习惯开着 “Enable JavaScript source maps” 调试线上打包后的代码但线上产物根本没有上传.map文件。遇到这个提示第一反应是调整构建配置而不是去改业务代码。开发环境用带 map 的模式生产环境如果不需要线上定位可以干脆不生成.map文件既减少构建时间又避免暴露源码。7.3 开发/生产环境的 devtool 分离顺带治好了我的调试头疼我现在的做法是开发环境和生产环境分开配置const isDev process.env.NODE_ENV ! production; module.exports { devtool: isDev ? eval-cheap-module-source-map : nosources-source-map, };开发环境用eval-cheap-module-source-map调试效率高报错能定位到文件和行号。生产环境用nosources-source-map或者直接关掉 source map前者能保留报错堆栈信息给监控平台但不暴露源码内容适合有线上错误监控需求的团队。如果线上确实需要 source map一定要配合output.sourceMapFilename设置明确的输出路径避免默认生成一堆散落文件。部署在上线环境里就非常容易被 SPA 路由拦截到导致 DevTools 读不到映射文件。这轮优化做完以后我最大的体会是webpack 打包体积优化从来不缺招缺的是按顺序把每一招都落在合适的位置。先体检、再按需拆、再压缩、再分包最终走完一趟哪怕项目规模不小包体也能肉眼可见地瘦下来。最后再分享一个土办法每次发版前都跑一次webpack-bundle-analyzer把 bundle-report.html 存下来对比时间一长你就知道团队最近是不是又引入了什么“重量级嘉宾”了。
返回列表