Bundle 体积分析:从 webpack-bundle-analyzer 到优化执行路径

发布时间:2026/7/22 14:09:33

Bundle 体积分析:从 webpack-bundle-analyzer 到优化执行路径 Bundle 体积分析从 webpack-bundle-analyzer 到优化执行路径一、你的 bundle 里有三个版本的 lodash而构建工具一声不吭Bundle 体积膨胀是慢性病。今天引了个工具函数库明天装了图表组件后天产品经理上线就加一个弹窗——三周后打开 webpack-bundle-analyzer发现 800KB 的 chunk 里有三个不同版本的 lodash。构建工具不会提醒你这件事因为它不知道你不想依赖三个版本。体积分析不是一个工具能解决的事。webpack-bundle-analyzer 告诉你什么东西大但不告诉你为什么这东西被引入了以及怎么替代。真正的体积治理需要三条线的并行推进去重重复依赖、树摇无用代码、拆分按需加载。大多数人卡在第一步——知道 bundle 大但不知道该从哪里下手。解决方案不是从最大的模块开始砍最大的可能是业务代码你砍不动而是从不应该存在的代码开始清理重复依赖、未使用的导出、可以延迟加载的模块。二、底层机制与原理剖析体积优化的四个层次按投入产出比排序第一层去重最高 ROI。一个命令npx yarn-deduplicate可能就干掉 30% 的 node_modules 体积。原理yarn/npm 的依赖解析可能把同一个包的不同版本安装在 node_modules 的不同位置构建工具把每个版本都打包进去了。解决resolutionsYarn或overridesnpm强制统一版本。第二层树摇中等 ROI但需要代码配合。Tree shaking 不是 magic——它依赖 ES Module 的静态分析特性。如果你用require()导入或者你的库没有配置sideEffects: false构建工具就不敢摇。配置package.json的sideEffects字段是让 tree shaking 生效的前提。第三层拆分取决于业务场景。不是拆得越散越好——拆太散每个页面多一个 HTTP 请求HTTP/2 之前。合理的拆分粒度按路由拆分lazy(() import(./pages/Admin))、按可见性拆分IntersectionObserverimport()。第四层替换需谨慎。用更小的库替代大库——dayjs2KB替代moment72KBzod替代joi。但这需要改代码不是纯配置层面的改动。三、生产级代码实现// scripts/bundle-analyze.js /** * Bundle 体积分析脚本 * * 设计思路 * 1. 不依赖 webpack 插件——直接解析 webpack stats JSON * 可以离线分析历史构建数据 * 2. 输出机器可读的 JSON 报告 人类可读的控制台报告 * 3. 支持基线对比当前构建 vs 上一次构建 */ const fs require(fs); const path require(path); // 用户依赖白名单——不被标记为可替换的库 // 某些库虽然体积大但团队有充分理由内部封装、版本兼容等 const ALLOWED_LARGE_DEPS new Set([ echarts, // 图表场景不可替代 monaco-editor, // 代码编辑器不可替代 ant-design/icons, // 设计系统一致性需求 ]); /** * 解析 webpack stats JSON 并输出分析报告 */ function analyzeBundle(statsPath, baselinePath null) { const raw JSON.parse(fs.readFileSync(statsPath, utf-8)); // 提取所有 module 信息 const modules extractModules(raw); const chunks raw.chunks || []; // 重复依赖检测 const duplicates findDuplicates(modules); // 大模块检测 50KB 的单个文件 const largeModules modules .filter(m m.size 50 * 1024) .sort((a, b) b.size - a.size); // 建议替换的大依赖 const replaceable findReplaceableDeps(modules); // 总体积 const totalSize modules.reduce((sum, m) sum m.size, 0); // 构建报告 const report { timestamp: new Date().toISOString(), totals: { size: totalSize, sizeReadable: formatBytes(totalSize), modules: modules.length, chunks: chunks.length, }, duplicates, largeModules: largeModules.slice(0, 10).map(m ({ name: m.name, size: m.size, sizeReadable: formatBytes(m.size), })), replaceable, }; // 基线对比如果有上一次构建的数据 if (baselinePath fs.existsSync(baselinePath)) { const baseline JSON.parse(fs.readFileSync(baselinePath, utf-8)); report.comparison compareWithBaseline(report, baseline); } return report; } /** * 递归提取所有 module 信息 * 兼容 webpack 4 和 webpack 5 的 stats 格式差异 */ function extractModules(stats) { const result []; // webpack 5: stats.modules // webpack 4: stats.children[].modules function walk(node, parentPath ) { if (!node) return; // 如果是 module 节点 if (node.name node.size ! undefined) { result.push({ name: node.name, // 从 module name 中提取 npm 包名 // 格式: ./node_modules/lodash/get.js → lodash packageName: extractPackageName(node.name), size: node.size, reasons: (node.reasons || []).map(r r.moduleName), }); } // 递归处理子节点 if (node.children) { for (const child of node.children) { walk(child, node.name || parentPath); } } if (node.modules) { for (const mod of node.modules) { walk(mod, parentPath); } } } walk(stats); // 处理 webpack 4 的 children 结构 if (stats.children) { for (const child of stats.children) { walk(child); } } return result; } /** * 从 module 路径中提取 npm 包名 * 例如: ./node_modules/scope/package/file.js → scope/package */ function extractPackageName(moduleName) { const match moduleName.match(/node_modules\/((?:[^/]\/)?[^/])/); return match ? match[1] : null; } /** * 检测重复依赖 * 返回按总重复体积排序的重复包列表 */ function findDuplicates(modules) { const packageVersions {}; for (const mod of modules) { if (!mod.packageName) continue; // 从 module name 中提取版本号 // 有时版本号在路径中如 lodash4.17.21/get.js const versionMatch mod.name.match(new RegExp( mod.packageName.replace(/[.*?^${}()|[\]\\]/g, \\$) ([^/]) )); const version versionMatch ? versionMatch[1] : unknown; if (!packageVersions[mod.packageName]) { packageVersions[mod.packageName] {}; } if (!packageVersions[mod.packageName][version]) { packageVersions[mod.packageName][version] { size: 0, files: [] }; } packageVersions[mod.packageName][version].size mod.size; packageVersions[mod.packageName][version].files.push(mod.name); } // 过滤出有多个版本的包 const duplicates []; for (const [pkg, versions] of Object.entries(packageVersions)) { const versionList Object.keys(versions); if (versionList.length 1) { const totalExtra versionList .slice(1) // 除了第一个版本 .reduce((sum, v) sum versions[v].size, 0); duplicates.push({ package: pkg, versions: versionList, extraSize: totalExtra, extraSizeReadable: formatBytes(totalExtra), versionsDetail: Object.fromEntries( Object.entries(versions).map(([v, info]) [v, formatBytes(info.size)]) ), }); } } // 按额外体积降序排列 return duplicates.sort((a, b) b.extraSize - a.extraSize); } /** * 发现可用轻量替代库的大依赖 */ function findReplaceableDeps(modules) { const REPLACEMENT_MAP { moment: { alternative: dayjs, size: 2KB }, lodash: { alternative: lodash-es tree-shaking, size: 按需 }, lodash-es: { alternative: , size: }, jquery: { alternative: 原生 DOM API, size: 0 }, underscore: { alternative: lodash-es, size: 按需 }, async: { alternative: 原生 Promise async/await, size: 0 }, axios: { alternative: fetch 轻量 wrapper, size: ~2KB }, classnames: { alternative: clsx, size: ~500B }, uuid: { alternative: crypto.randomUUID(), size: 0 }, qs: { alternative: URLSearchParams, size: 0 }, }; const packageSizes {}; for (const mod of modules) { if (!mod.packageName) continue; packageSizes[mod.packageName] (packageSizes[mod.packageName] || 0) mod.size; } const replaceable []; for (const [pkg, alternative] of Object.entries(REPLACEMENT_MAP)) { if (packageSizes[pkg] !ALLOWED_LARGE_DEPS.has(pkg) alternative.alternative) { replaceable.push({ package: pkg, currentSize: formatBytes(packageSizes[pkg]), alternative: alternative.alternative, altSize: alternative.size, saving: formatBytes(packageSizes[pkg]), // 近似值 }); } } return replaceable; } /** * 与基线对比 */ function compareWithBaseline(current, baseline) { const diff current.totals.size - baseline.totals.size; const diffPercent baseline.totals.size 0 ? ((diff / baseline.totals.size) * 100).toFixed(1) : 0; return { sizeDiff: diff, sizeDiffReadable: (diff 0 ? : ) formatBytes(Math.abs(diff)), sizeDiffPercent: diffPercent %, direction: diff 0 ? 增加 : diff 0 ? 减少 : 不变, warning: diff 50 * 1024 ? Bundle 体积增加了 ${formatBytes(diff)}请检查新引入的依赖 : null, }; } function formatBytes(bytes) { if (bytes 0) return 0 B; const k 1024; const sizes [B, KB, MB, GB]; const i Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) sizes[i]; } function printReport(report) { console.log(\n Bundle 体积分析报告 ); console.log(总包体积: ${report.totals.sizeReadable}); console.log(模块数量: ${report.totals.modules} 个); console.log(Chunk 数量: ${report.totals.chunks} 个); if (report.duplicates.length 0) { console.log(\n--- 重复依赖 (${report.duplicates.length} 个包有多版本) ---); for (const dup of report.duplicates.slice(0, 5)) { console.log( ${dup.package}: ${dup.versions.join(, )} (多占 ${dup.extraSizeReadable})); } } if (report.largeModules.length 0) { console.log(\n--- 体积最大的模块 TOP 5 ---); for (const mod of report.largeModules.slice(0, 5)) { console.log( ${mod.sizeReadable.padEnd(10)} ${mod.name}); } } if (report.comparison) { const comp report.comparison; console.log(\n--- 与上次构建对比 ---); console.log( 体积变化: ${comp.direction} ${comp.sizeDiffReadable} (${comp.sizeDiffPercent})); if (comp.warning) { console.log( ⚠ ${comp.warning}); } } if (report.replaceable.length 0) { console.log(\n--- 可替换的大依赖 ---); for (const rep of report.replaceable) { console.log( ${rep.package} (${rep.currentSize}) → ${rep.alternative} (${rep.altSize})); } } console.log(\n\n); } // --------------------------------------------------------------------------- // CLI 入口 // --------------------------------------------------------------------------- if (require.main module) { const args process.argv.slice(2); const statsIdx args.indexOf(--stats); const baselineIdx args.indexOf(--baseline); const outputIdx args.indexOf(--output); if (statsIdx 0) { console.error(用法: node bundle-analyze.js --stats dist/stats.json [--baseline baseline-report.json] [--output report.json]); process.exit(1); } const statsPath args[statsIdx 1]; const baselinePath baselineIdx 0 ? args[baselineIdx 1] : null; const outputPath outputIdx 0 ? args[outputIdx 1] : null; if (!fs.existsSync(statsPath)) { console.error(Stats 文件不存在: ${statsPath}); process.exit(1); } const report analyzeBundle(statsPath, baselinePath); printReport(report); if (outputPath) { fs.writeFileSync(outputPath, JSON.stringify(report, null, 2)); console.log(报告已保存至: ${outputPath}); } }四、边界分析与架构权衡Bundle 分析的盲区只显示bundle 里有什么不显示运行时执行了什么——一个 50KB 的模块如果首屏不执行删除它没收益不支持分析间接引用——第三方库内部又引用了什么stats 能部分体现但不完整体积优化到一定程度后 ROI 快速递减——从 800KB 减到 400KB 很容易去重、替换大库从 400KB 减到 200KB 需要改架构级代码Tree shaking 的隐藏条件CommonJS 模块require/module.exports不能被 tree shake必须是 ES Module包管理器的sideEffects配置必须正确——sideEffects: false告诉构建工具可以安全地摇掉未使用的导出但副作用标注错了可能把 CSS 也摇掉动态导入require(variable)完全不能被静态分析里面的代码永不削减如何量化体积优化的收益不建议用文件大小衡量——Gzip 压缩后的大小才是用户实际下载的应该监控JS 解析时间——在低端设备上500KB 的 JS 解析可能花 500ms监控 TBTTotal Blocking Time——这个指标直接反映 JS 执行对交互的阻塞五、总结Bundle 体积分析不是一次性的优化动作是一个持续性流程分析 → 优化 → 设定预算 → 监控。去重最高 ROI→ 树摇中等 ROI→ 拆分/替换投入大。关键是建立 CI 中的体积预算机制——每次构建对比上次体积超预算直接拦截。如果没有这个机制三周后积累的小改动就会把优化成果全部覆盖掉。

相关新闻