尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Webpack构建性能优化实战:借鉴Rollup思想,打包体积和构建速度双提升

Webpack构建性能优化实战:借鉴Rollup思想,打包体积和构建速度双提升 接手过一个不大不小的中后台项目Webpack 4 的老配置业务代码三十万行左右。每次npm run build要跑 3 分 27 秒产物主包 4.8MB首屏加载在低端机上白屏四五秒。当时正好在调研 Rollup 的打包思路越看越觉得 Rollup 那种“把没用代码彻底摇掉”的静态分析方式正是 Webpack 工程里最缺的一环。后面花了大概两周时间把 Webpack 构建链路整体做了一轮优化最终构建时间压到 48 秒产物降到 1.2MB首屏时间砍掉了将近一半。这篇文章不是单纯罗列配置项而是把我踩过的坑、想明白的原理、抄完能直接用的配置都整理出来。面向的读者是那些被 Webpack 打包体积和构建速度困扰的前端工程师以及准备做前端工程规范、想搞清楚“Rollup 这套思路到底能不能移植到 Webpack 工程里”的团队负责人。内容围绕模块打包优化、性能提升、前端工程落地三个关键词展开全部是实测结果和可复现的操作。1. 先别急着调配置把“打包变慢、产物变大”的账算清楚很多同学拿到一个慢的 Webpack 工程第一反应是上网搜“webpack打包优化配置”然后抄一堆插件回来。装上happypack、dll-plugin、各种 cache-loader结果要么没效果要么构建报错。我自己也走过这个弯路后来才明白不先摸清瓶颈在哪所有优化都是在猜。1.1 症状目录构建时长、产物体积、运行时性能先说三个最典型的症状你可以对照自己的项目看看中了哪几条。第一构建时长失控。增量编译都要十几秒全量构建动辄几分钟改一行代码等半分钟才能看到效果团队成员每天都在这种节奏里消耗耐心。构建慢的本质不是单条 loader 处理慢而是大量模块被重复解析、重复转换、重复压缩且这些操作是串行或没有缓存的。第二产物体积膨胀。用webpack-bundle-analyzer一看node_modules里的库占了 70% 以上moment整包进来了lodash整包进来了antd整包进来了还有一堆永远用不到的locales。体积膨胀的直接后果是首屏下载慢、解析慢、执行慢尤其是低端安卓机JS 解析时间随体积线性增长。第三运行时性能差。一个路由对应一个几百 KB 的 chunk 还算正常但主包把所有的页面代码全部打进一个文件首屏要把所有页面的代码都下载并执行完才能渲染。这个问题的诊断比体积更隐蔽很多人只注意到“Network 面板里文件很大”没意识到“明明只开了首页为什么把所有页面的代码都跑了一遍”。1.2 优化前的数据采集与基线建立不做数据采集就开始改配置等于不给病人做检查就开药。我强烈建议在优化之前先花半小时把基线数据记录下来后面每一步优化改了多少一对比就清清楚楚。需要采集的数据至少有这几项指标采集方式记录值全量构建时长time npm run build3m27s增量编译时长改一行代码后手动计时约15s产物总大小ls -lh dist/统计19.6MB主包大小webpack-bundle-analyzer查看4.8MB首屏可交互时间Chrome DevTools Performance 面板5.2s首屏 JS 体积Network 面板过滤 JS3.8MB最大 chunk 文件dist 目录下找最大文件1.4MB这里给一个最容易操作的方式把webpack-bundle-analyzer作为插件挂到生产构建里构建完自动打开大小分布图构建时间直接用终端工具记录。数据建好之后再开始动手。这个习惯帮我避免了很多次“感觉优化了但不知道优化了什么”的尴尬。1.3 为什么 Webpack 会有这么多“历史包袱”有一个问题值得先想明白Webpack 为什么默认行为这么“重”一句话回答Webpack 为了兼容所有模块规范把所有东西都当成可能被修改的对象来处理。比如 CommonJS 的require是运行时动态执行module.exports可以被任意重新赋值Webpack 在静态分析时无法保证某个导出不会被改掉干脆一股脑全打进产物。再加上默认对node_modules里的代码也要做完整解析和转换依赖越多构建越慢体积越大。相比之下Rollup 在分析 ES Module 时非常激进因为它假设import是静态的、export是不可变的所以能够精准地把没有用到的导出“摇掉”。这个差异是理解后面所有优化方案的钥匙Webpack 工程里的大部分体积问题都是因为“没法确认没用”而“只能全部保留”大部分速度问题都是因为“每次都要重新做一遍”而“完全不给缓存”。搞清楚了这两点优化的方向就很明确了。2. Rollup 派和 Webpack 派的核心差异为什么 Rollup 的产物更“干净”标题里写了“Rollup方案实战”不少人可能会疑惑Rollup 和 Webpack 不是两个工具吗怎么混在一起讲其实我这里想表达的是把 Rollup 的打包哲学搬到 Webpack 工程里。在动手改配置之前有必要把两者的核心差异掰开揉碎讲清楚这样你后面看配置才不会觉得莫名其妙。2.1 Rollup 的静态分析与 tree-shaking 逻辑Rollup 的核心理念是“尽可能让产物保持原代码的形态同时去掉没用的部分”。它只处理 ES Module而 ES Module 的import和export都是顶层静态声明不依赖运行时逻辑。这个特性让 Rollup 可以建立完整的模块依赖图并沿着各个入口往下走只要某个export没有被任何模块import它就能理直气壮地把这段代码从产物中删除。举个例子某个工具库utils.js导出了formatDate、parseJSON、debounce三个函数业务代码只用到了debounce。Rollup 打包后产物里只有debounce函数的实现和它依赖的代码另外两个函数连影子都找不到。它的实现机制也不复杂把所有模块拍平到一个作用域里通过变量名和引用关系判断“谁被用了、谁没被用”这个过程叫 Scope Hoisting。这是 Rollup 产物干净的最大功臣。Webpack 同样支持 tree-shaking但它的推导条件更加保守。在 Webpack 4 里tree-shaking 需要你把mode设为production并且要保证代码里没有“副作用”side effects否则一条import ./style.css或者一个能修改全局变量的模块就会让 Webpack 放弃整棵树的摇动。很多人开了production却发现体积没变多半就是这个原因。2.2 Webpack 5 如何吸收 Rollup 思路sideEffects、module concatenation、ESM 产物Webpack 5 在很多设计上明显吸收了 Rollup 的长处我们在做 Webpack 工程优化时应该把这几项能力完全用起来。第一sideEffects字段。在package.json里声明哪些文件是“纯副作用”的Webpack 就能更放心地跳过对它们的 tree-shaking 限制。比如你用了babel/polyfill这种必须整体引入的模块就要把它的路径配到sideEffects数组里对于你自己的业务代码如果全部是纯函数和纯组件可以直接设成false告诉 Webpack “这个包里的文件都没有副作用没被引用的部分可以随便删”。第二Module Concatenation模块串联。Webpack 4 之后就有了concatenateModules配置生产模式默认开启效果是把多个模块合并成一个闭包减少模块间的函数调用开销同时也为 tree-shaking 提供更完整的分析范围。这个特性在有大量小模块的工程里尤其有效不仅能减小体积还能提升运行时执行速度。如果你的产物里经常出现大量(function(module, exports, __webpack_require__) { ... })包裹说明串联没有完全生效可以检查一下是否有动态require或非 ESM 依赖打断了串联。第三ESM 产物输出。Webpack 5 支持通过experiments.outputModule输出真正的 ES Module 格式现代浏览器可以直接原生加载避免了对__webpack_require__这种运行时胶水代码的反复执行。不过这个选项对代码库的 ESM 纯度要求较高旧项目要改造才能用新项目可以从一开始就规划进去。2.3 两者选型什么时候用 Rollup什么时候该在 Webpack 里做 Rollup 式优化理清差异后工具选型也就自然清晰了组件库、工具函数库、SDK这类产物希望尽可能小、尽可能干净而且没有复杂的代码分割需求Rollup 是更合适的方案。你发布到 npm 的包体积越小使用方的体验越好。Web 应用、中后台系统、有大量业务页面的项目需要代码分割、按需加载、资源处理、开发服务器热更新这一整条链路Webpack 的生态和灵活度更匹配。这种场景下要做的是把 Rollup 的静态分析思路吸收进来让 Webpack 的产物尽量接近 Rollup 的“干净”程度。新起的 Vite 项目Vite 在开发阶段用原生 ESM在生产构建阶段底层调用的其实是 Rollup等于把两者优点合到一起了。如果团队从零开始可以直接选 Vite如果是在存量 Webpack 工程上做优化那就老实做 Webpack 的 Rollup 式改造。我自己做选型时有一条原则不要因为“流行”而换工具要看团队维护成本和项目结构。存量的 Webpack 工程硬切 Vite 或纯 Rollup业务代码里的require调用、各种自定义 loader 都会成为改造路上的坑远不如在 Webpack 里把优化做透来得实在。3. 给 Webpack 装上“Rollup 式”的瘦身引擎体积优化实操上一节讲了原理这一节全是操作。下面每一段配置都来自我实际验证过的项目可以直接往你的webpack.prod.js里搬。但注意每个项目的情况不一样搬完之后要用 analyzer 和构建日志验证效果不要看都不看就当完事了。3.1 让 tree-shaking 真正生效配置 package.json 的 sideEffects很多项目做了体积优化没效果第一步就栽在sideEffects上。检查一下你的根目录package.json如果完全没有sideEffects字段那 Webpack 会默认所有文件都有副作用tree-shaking 跟着失效一半。正确的姿势是分场景配置。如果你是纯业务项目所有业务代码都是通过import引用的纯函数/纯组件可以在根package.json里加{ name: your-project, version: 1.0.0, sideEffects: false }这个配置告诉 Webpack这个包里的所有模块都没有副作用任何没被引用的导出都可以安全删除。但业务项目里通常会有全局样式和 polyfill它们必须保留所以要改成数组形式{ sideEffects: [ **/*.css, **/*.scss, **/*.less, ./src/polyfills.js, ./src/global.ts ] }注意sideEffects不是只对当前项目生效它在你安装的 npm 包里同样起作用。有些老库的package.json里没声明sideEffectsWebpack 只能保守处理。你可以在配置里用module.rules的sideEffects: false来手动标记某些纯工具的包可安全摇树。比如lodash-es本身是 ESM 且无副作用但如果某个历史版本没有声明你可以这样补module.exports { module: { rules: [ { test: /[\\/]node_modules[\\/]lodash-es[\\/]/, sideEffects: false } ] } };3.2 再也不要全量引库antd/lodash/dayjs 按需加载体积优化里收益最直观的就是把“整包引入”改成“按需引入”。我见过太多项目antd是一整包import antd/dist/antd.css然后页面里import { Button, Table, Modal } from antd。这种写法在 Babel 编译时如果不做处理Webpack 会把整个 antd 的组件代码全部打进产物。处理方式有两种。第一种是babel-plugin-import。在.babelrc或babel.config.js里配置module.exports { plugins: [ [import, { libraryName: antd, libraryDirectory: es, style: css }] ] };它的原理是把import { Button } from antd编译成import Button from antd/es/button加对应的样式引入从根源上避免整包引入。同样地lodash可以用babel-plugin-lodash把具名导入转换成按路径导入配合lodash-es效果更好。第二种是手动路径引用适合不想加 Babel 插件的场景import Button from antd/es/button; import antd/es/button/style;这种写法比较繁琐不推荐全项目手写但某些特殊场景比如某个组件只在某一个很重的页面里用到可以用它配合require.ensure做局部按需加载。dayjs的优化思路类似。dayjs默认只带核心包但你如果引入了import dayjs/locale/zh-cn那没事如果直接把所有 locale 都引入了体积会很夸张。正确的做法是用dayjs/locale/zh-cn精确引入并且配置 webpack 的resolve.alias把dayjs指向dayjs/esm能利用上 tree-shaking。我把这段实际配置贴出来这是当时优化体积时用的核心 loader 规则之一// webpack.prod.js 中的关键配置 module.exports { mode: production, module: { rules: [ { test: /\.(js|mjs|jsx|ts|tsx)$/, exclude: /node_modules/, use: [babel-loader] }, { test: /\.(js|mjs)$/, include: /node_modules/, // 对 node_modules 里的 ESM 包也做一次轻量转译保障兼容性 use: { loader: babel-loader, options: { babelrc: false, configFile: false, presets: [ [babel/preset-env, { modules: false }] ] } } } ] }, resolve: { alias: { dayjs$: dayjs/esm, lodash$: lodash-es }, extensions: [.ts, .tsx, .js, .jsx, .json] } };3.3 拆包策略splitChunks 的一次成功调参拆包的目的有两个一是减少单文件体积让浏览器并行下载二是利用浏览器缓存公共库不随业务代码变而重新下载。splitChunks是 Webpack 做这件事的核心配置网上很多教程喜欢把chunks: all一抄了之但实际效果往往不佳。先看一个基本可用、效果还不错的配置module.exports { optimization: { splitChunks: { chunks: all, minSize: 20000, maxSize: 400000, minChunks: 1, cacheGroups: { react: { test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom|redux)[\\/]/, name: react-core, priority: 40, chunks: all }, antd: { test: /[\\/]node_modules[\\/]antd[\\/]/, name: antd, priority: 30, chunks: all }, vendors: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10, chunks: all }, common: { name: common, minChunks: 2, priority: 5, chunks: all } } } } };一个容易踩的坑是maxSize设置得太小会导致 Webpack 把一个大文件拆成无数个小碎片结果请求数爆炸首屏反而变慢。另一个坑是cacheGroups里的name如果写死了不同 chunk 引用的公共模块会被强制合并到一个文件造成“无关代码也被提前下载”。我的建议是react、antd这种特别大且稳定的库单独拆出来并且文件名带上contenthash剩余node_modules依赖放进一个vendors包业务公共代码抽一个common包并设置minChunks: 2至少两个页面用到的模块才抽出来避免首屏加载了一堆只有某几个页面才用的代码。3.4 压缩与传输优化gzip、brotli、消除冗余代码体积优化到一定程度后还可以在压缩和传输层面再榨出 30% 左右的空间。这里的核心是三层压缩第一层是JavaScript 压缩。生产模式默认用terser-webpack-plugin有几个参数值得调const 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] }, format: { comments: false } }, extractComments: false }) ] } };drop_console在生产环境删掉所有console.*这一点要提前和团队确认有没有线上日志排查需求。parallel开启多进程压缩能显著缩短构建时间代价是多占一些 CPUCI 机器的核数不够时要谨慎。第二层是CSS 压缩。使用css-minimizer-webpack-plugin它会用cssnano做极致的去空格、去注释、合并规则。样式文件量大时收益很明显。第三层是传输层压缩。在 Nginx 或网关层开启 gzip 或 brotli。如果团队能控制服务器配置建议直接用 brotli它的压缩率比 gzip 再高 15% 到 20%。前端这边配合做的是在构建时提前生成.gz和.br文件减少服务器实时压缩的 CPU 开销const CompressionPlugin require(compression-webpack-plugin); module.exports { plugins: [ new CompressionPlugin({ filename: [path][base].gz, algorithm: gzip, test: /\.(js|css|html|svg)$/, threshold: 10240, minRatio: 0.8 }), new CompressionPlugin({ filename: [path][base].br, algorithm: brotliCompress, test: /\.(js|css|html|svg)$/, threshold: 10240, minRatio: 0.8 }) ] };注意compression-webpack-plugin的版本不同algorithm参数写法有差异新版本要写成algorithm: brotliCompress老版本可能要用zlib.brotliCompress。看官方文档确认不然会构建报错。4. 把构建速度从分钟级拉到秒级缓存与并行的正确姿势体型优化解决的是“用户下载多少”的问题构建速度优化解决的是“开发者和 CI 等多久”的问题。构建提速的底层逻辑比体积优化简单得多无非是三件事省掉重复工作、把串行变并行、让 Webpack 少管闲事。4.1 Webpack 5 持久化缓存一行配置搞定二次构建提速Webpack 4 时代我们为了缓存费尽心机cache-loader、hard-source-webpack-plugin、babel-loader的cacheDirectory参数各管一段配置起来非常割裂。Webpack 5 直接内置了持久化文件缓存只要在配置里加一行module.exports { cache: { type: filesystem, buildDependencies: { config: [__filename] }, cacheDirectory: path.resolve(__dirname, node_modules/.cache/webpack) } };这里解释一下为什么后面的buildDependencies很关键。这个配置是告诉 Webpack如果配置文件本身变了缓存就失效重来。如果不加你可能改了 loader 配置却还在用旧缓存构建出来的产物和你期望的不一致排查起来极其浪费时间。Webpack 5 的持久化缓存有两个作用一是模块级别的缓存开发者第二次构建时所有内容没有变化或没有依赖变化的模块直接从磁盘缓存恢复不再执行 loader 转换二是产物级别的缓存打包算法会记住之前的 chunk 生成结果只有真正改动的模块才参与新的代码生成和压缩。实测下来缓存命中的热更新可以压到 1 秒以内全量构建提速 50% 以上。4.2 并行压缩和 loader 并行thread-loader/cache-loader 与 terser 并行Webpack 默认的构建流程是单线程的loader 对文件一个一个处理。要提速最直接的办法是让多个文件同时处理。这里有两个常用的方案。thread-loader可以把消耗较大的 loader通常是babel-loader和ts-loader放到 worker 池里并行执行。用法是在 loader 数组最前面加上它module.exports { module: { rules: [ { test: /\.(js|jsx|ts|tsx)$/, exclude: /node_modules/, use: [ { loader: thread-loader, options: { workers: 4 } }, babel-loader ] } ] } };thread-loader不是所有场景都适合。它本身有进程通信和 worker 启动的开销如果你的单个文件本身很小、文件数量不多加了反而更慢。我一般只在node_modules里的大依赖比较多、或者构建时babel-loader耗时超过 5 秒的项目里开启而且workers数量不要超过 CPU 核数减一。压缩阶段同样可以并行。terser-webpack-plugin默认就是parallel: trueWebpack 5 中默认开启但要注意的是并行压缩很吃内存。如果你的 CI 机器内存只有 2GB并行压缩可能导致构建直接 OOM这种情况下建议手动设为parallel: false或者换更大的机器。4.3 缩小解析范围resolve、loader 的 include/exclude、noParse这部分是“让 Webpack 少管闲事”的典型操作。Webpack 在做模块解析时默认会递归解析所有import/require的文件路径如果解析范围太大性能会指数级下降。第一loader 只处理该处理的文件。Babel 转换是很重的操作一定要用include限定范围{ test: /\.(js|jsx|ts|tsx)$/, include: path.resolve(__dirname, src), exclude: /node_modules/, use: [babel-loader] }这个配置能让 Webpack 跳过node_modules里成千上万的 JS 文件不把它们交给 Babel 做转译。注意如果你用了现代浏览器不需要转译的语法跳过node_modules的转译是完全安全的。只有当你需要兼容老浏览器且个别 npm 包发布了 ES2015 语法时才需要额外处理但也要用include精确指定是哪些包不要放开全局。第二合理配置 resolve。resolve.modules告诉 Webpack 去哪些目录找第三方模块默认会一层层向上找node_modules比较慢。可以指定项目根目录的node_modulesresolve: { modules: [path.resolve(__dirname, node_modules)], extensions: [.ts, .tsx, .js, .jsx, .json], alias: { : path.resolve(__dirname, src) } }extensions列表不要给太多每多一个扩展名Webpack 在解析无后缀导入时就要多尝试一次文件查找。一般写在最前面的扩展名频率越高解析越快。第三module.noParse告诉 Webpack 某些文件不需要解析依赖关系。适合那些本身没有模块依赖、或已经打包过的库module: { noParse: /\.min\.js$|lodash\.min\.js|jquery\.min\.js/ }用了noParse后Webpack 不会分析这些文件内部require了什么也不会做 tree-shaking但你也不能通过它们再动态引入其他模块。核心的库如果已经是 min 版本加noParse收益很高。4.4 老项目的渐进式改造别一次性全换优化配置最忌讳的是“一天全上”。如果你在一个几十万行的老项目里一次性把splitChunks、sideEffects、thread-loader、cache全改完构建一跑报错都不知道是哪一步引入的。我最开始改造时吃过这个亏后来总结出的节奏是先加 Webpack 5 自带的cache花两分钟确认构建正常加sideEffects和 Babel 按需引入跑一次完整构建用 analyzer 看体积变化再改splitChunks重点看打包出来的 chunk 数量和大小是否合理最后考虑并行和 loader 优化因为这两个对正确性影响最小但需要关注机器性能。每一步都要提交代码、记录数据、写清楚改动原因。这样万一线上出了问题可以快速回滚到上一个稳定点而不是在一堆新配置里大海捞针。5. 运行时性能同样重要按需加载、缓存命中与 CDN 分发打包体积缩小了构建速度快了但用户实际的加载体验还取决于运行时到底加载了哪些资源、加载顺序对不对、缓存命不命中。这一节讲的都是运行时层面的优化它们和 Webpack 配置直接相关。5.1 动态 import 与懒加载首屏只加载该加载的最影响首屏的往往不是主包体积大而是路由组件全量打在主包里。中后台项目有几十个页面每个页面都引了自己的表格、图表、编辑器组件如果不做按路由拆分首屏要把所有页面的代码全部下载。Webpack 对动态import()的支持非常成熟配合 React Router 或 Vue Router 可以优雅地实现路由级代码分割。以 React 为例const Dashboard React.lazy(() import(/pages/Dashboard)); const UserManage React.lazy(() import(/pages/UserManage)); const OrderDetail React.lazy(() import(/pages/OrderDetail));只要在路由注册时使用React.lazy包一层Webpack 在构建时就开始按照import()的边界自动把代码拆成独立 chunk。Vue 项目则可以用() import(/views/xxx.vue)效果类似。拆完之后还要配合Suspense或路由守卫做加载态处理不然用户切换路由时会白屏一两秒。这一步对首屏的优化效果非常明显原来首屏要下载 3.8MB拆了路由之后首屏只加载当前页面的代码变成了 800KB 左右。React.lazy是运行时懒加载的入口但真正决定 chunk 是否被拆分的还是构建时 Webpack 对import()的静态分析。只要你在代码里用了import()Webpack 一定会把它作为 split 边界来处理不需要额外配置。5.2 contenthash 与长期缓存让用户更新只拉 diff浏览器缓存是双刃剑。文件没变用户可以直接用本地缓存加载快文件变了但文件名还一样用户就会拉到旧版本。Webpack 的解决方案是用[contenthash]作为文件名的一部分文件内容变了文件名就变缓存自然失效内容没变文件名不变缓存继续命中。我的做法是三类资源用不同策略资源类型文件名模板缓存策略公共库react、antd[name].[contenthash:8].js长缓存几乎不变化业务公共代码[name].[contenthash:8].js随业务变化但变更频率低于页面页面级 chunk[name].[contenthash:8].js随页面更新精准失效这里有一个必须避开的坑把runtimeChunk单独抽出来。runtimeChunk是 Webpack 用来加载和管理 chunk 的运行时代码如果不单独抽它会被打进每个 chunk一旦任何模块变化所有 chunk 的 runtime 部分都会变导致所有文件名变化、缓存全部失效。单独抽出之后大部分用户更新时只需要重新下载runtime和一个页面 chunk其他公共资源还是走缓存。module.exports { optimization: { runtimeChunk: single } };5.3 externals 与 CDN把大依赖扔出打包链路有一种更极致的瘦身方式把某些体积大、更新频率低、又必须用的库从打包链路里拿出来直接走 CDN。比如echarts完整版动辄 1MB 以上、mapbox-gl、monaco-editor它们在业务里被大量使用但又不需要随着业务每次发版。Webpack 里用externals声明这些库不打包运行时从全局变量获取module.exports { externals: { echarts: echarts, monaco: monaco } };然后在 HTML 模板里通过script标签引入对应的 CDN 文件script srchttps://cdn.example.com/echarts.min.js/script script srchttps://cdn.example.com/monaco.min.js/script这样一来构建产物体积直接砍掉一大块而且 CDN 通常有独立的缓存和多节点分发用户加载这些库的速度可能比从你的源站加载更快。唯一的代价是你不能按需引入这些库的子模块只能全量加载。所以要不要用externals要对比“按需打包的体积收益”和“CDN 全量加载的传输成本”如果某个库你每页都要用而且整体体积不大放本地构建还是 CDN 都行如果只是偶尔用还是动态import()按需加载更合适。另外用externals时要注意开发环境也得有对应的全局变量来源可以在public/index.html里统一引入 CDN 文件这样本地开发跑起来能直接使用全局变量不会报错。6. 实测对比与效果量化优化前的 3 分 27 秒优化后的 48 秒理论讲了半天最终还是要落到数据和配置上。这一节我把当时项目的完整改造结果放出来包括最终配置清单、前后指标对比以及几个让很多人翻车的细节。6.1 最终配置清单可以直接抄这是我在 React TypeScript Webpack 5 项目中最终稳定运行的webpack.prod.js核心片段压缩掉不必要的插件保留主干const path require(path); const { merge } require(webpack-merge); const common require(./webpack.common.js); const TerserPlugin require(terser-webpack-plugin); const MiniCssExtractPlugin require(mini-css-extract-plugin); const CssMinimizerPlugin require(css-minimizer-webpack-plugin); const { BundleAnalyzerPlugin } require(webpack-bundle-analyzer); module.exports merge(common, { mode: production, devtool: hidden-source-map, cache: { type: filesystem, buildDependencies: { config: [__filename] }, cacheDirectory: path.resolve(__dirname, node_modules/.cache/webpack) }, output: { filename: static/js/[name].[contenthash:8].js, chunkFilename: static/js/[name].[contenthash:8].chunk.js, assetModuleFilename: static/media/[name].[contenthash:8][ext], path: path.resolve(__dirname, dist), clean: true }, optimization: { runtimeChunk: single, moduleIds: deterministic, minimizer: [ new TerserPlugin({ parallel: true, terserOptions: { compress: { drop_console: true, drop_debugger: true }, format: { comments: false } }, extractComments: false }), new CssMinimizerPlugin({ parallel: true }) ], splitChunks: { chunks: all, minSize: 20000, maxSize: 400000, minChunks: 1, cacheGroups: { react: { test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom|redux|react-redux)[\\/]/, name: react-core, priority: 40, chunks: all }, antd: { test: /[\\/]node_modules[\\/]antd[\\/]/, name: antd, priority: 30, chunks: all }, vendors: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10, chunks: all }, common: { name: common, minChunks: 2, priority: 5, chunks: all } } } }, module: { rules: [ { test: /\.(js|jsx|ts|tsx)$/, include: path.resolve(__dirname, src), use: [thread-loader, babel-loader] }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, css-loader, postcss-loader] }, { test: /\.(png|svg|jpg|jpeg|gif|webp)$/i, type: asset, parser: { dataUrlCondition: { maxSize: 4 * 1024 } } } ] }, plugins: [ new MiniCssExtractPlugin({ filename: static/css/[name].[contenthash:8].css, chunkFilename: static/css/[name].[contenthash:8].chunk.css }), new BundleAnalyzerPlugin({ analyzerMode: static, openAnalyzer: false }) ] });需要提醒的是thread-loader配置在部分场景下反而会增加开销如果你的项目源文件只有几百个直接去掉thread-loader可能更快。当时我评估之后还是留着因为那个项目依赖了大量图表和复杂组件单文件转译耗时差异明显。6.2 前后指标对比优化做完之后我把整个过程的基线和结果做成了对比表当时发在团队周报里的现在直接放出来指标优化前优化后提升幅度全量构建时长3m27s48s提升76.8%增量编译改一行约15s约2s提升86.7%产物总大小19.6MB10.3MB减少47.4%主包大小4.8MB1.3MB减少72.9%首屏 JS 体积3.8MB812KB减少78.6%首屏可交互时间低端机模拟5.2s2.6s减少50%优化效果最夸张的是首屏 JS 体积从 3.8MB 直接砍到 812KB。主要功劳来自三件事按路由拆 chunk、公共库独立拆包、externals把 echarts 扔出打包链路。如果项目里没有 echarts 这种超大依赖可能效果会更小一些但拆包和按需引入在任何项目中都有收益。6.3 过程中踩的坑sideEffects 误伤、splitChunks 分组过度给这个项目做完优化后有几件事差点让生产环境出问题写出来帮大家避坑。第一个坑是sideEffects 误伤全局样式。一开始我把根目录sideEffects设为false结果构建后样式全部消失了页面变成裸 HTML。原因很简单CSS 文件也被当成了“可摇掉”的部分Webpack 认为没有任何地方使用它们全部删除。后来改成数组形式把**/*.css、**/*.scss、**/*.less全部列入保留名单样式才恢复正常。如果你用了 CSS Modules也要注意*.module.css这类文件它们的副作用标记和普通全局样式不同建议同样显式声明在数组里。第二个坑是splitChunks 分组过度导致缓存失效。初始配置里我把lodash-es、dayjs、echarts每个都单独拆成一个 cacheGroup结果每个页面都引用这些库导致页面一更新相关的公共 chunk 文件名也全变了用户每次发版都要重新下载一大批文件。后来我才意识到拆包的核心不是“拆得越细越好”而是“拆完之后每个包的更新频率要尽量独立”。稳定的库放一起不稳定的业务公共代码单独一个组这样才能最大化缓存命中率。第三个坑是analyzer 插件的性能开销。在优化过程中webpack-bundle-analyzer在每次构建后都生成一份静态 HTMLCI 上构建时间多了将近 10 秒。我把它改成了只在手动触发参数时启用比如通过环境变量ANALYZEtrue控制是否加载这个插件。毕竟日常构建不需要每次都分析体积只有版本发布前看一下趋势就够了。还有一个值得注意的点是开发环境不要开启生产压缩。开发模式如果设置了optimization.minimize: true每次增量构建都会重新压缩所有文件热更新会变得极其缓慢。开发和生产配置最好完全分开开发环境专注热更新速度和 source-map 质量生产环境专注体积和性能。不同环境各司其职不要为了“统一配置”而牺牲开发体验。7. 总结从“能用”到“好用”前端工程优化是个持续过程这次优化做完已经有几个月现在团队日常开发构建稳定在 2 秒左右的热更新全量打包 48 秒产物体积可控首屏加载流畅。整个过程最深的体会是打包优化不是堆配置而是先建立数据基线、理解工具原理、再逐个方案验证效果。当下一次听到“项目构建好慢、包好大、页面好卡”时我建议你先别急着换框架也别急着抄一整套所谓的最佳实践配置。先打开webpack-bundle-analyzer看看体积分布跑一次全量构建记录时间模拟低端网络和低端机测一下首屏。数据到手之后再按这篇文章里的顺序从sideEffects、按需引入、拆包、缓存、并行、懒加载这六个方向逐一排查和优化。前端工程优化没有一劳永逸的方案。项目的依赖会变用户的设备和网络条件也在变每次大版本依赖升级之后重新审视一次构建配置把它们当成一项常规的工程健康检查比什么都靠谱。
返回列表