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 的不同位置,构建工具把每个版本都打包进去了。解决:resolutions(Yarn)或overrides(npm)强制统一版本。
第二层:树摇(中等 ROI,但需要代码配合)。Tree shaking 不是 magic——它依赖 ES Module 的静态分析特性。如果你用require()导入,或者你的库没有配置sideEffects: false,构建工具就不敢摇。配置package.json的sideEffects字段是让 tree shaking 生效的前提。
第三层:拆分(取决于业务场景)。不是"拆得越散越好"——拆太散每个页面多一个 HTTP 请求(HTTP/2 之前)。合理的拆分粒度:按路由拆分(lazy(() => import('./pages/Admin')))、按可见性拆分(IntersectionObserver+import())。
第四层:替换(需谨慎)。用更小的库替代大库——dayjs(2KB)替代moment(72KB),zod替代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 中提取版本号 // 有时版本号在路径中,如 lodash@4.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+
- 监控 TBT(Total Blocking Time)——这个指标直接反映 JS 执行对交互的阻塞
五、总结
Bundle 体积分析不是一次性的优化动作,是一个持续性流程:分析 → 优化 → 设定预算 → 监控。去重(最高 ROI)→ 树摇(中等 ROI)→ 拆分/替换(投入大)。关键是建立 CI 中的体积预算机制——每次构建对比上次体积,超预算直接拦截。如果没有这个机制,三周后积累的小改动就会把优化成果全部覆盖掉。