在线工具集

JS bundle 体积优化完全指南:从 5MB 到 500KB

前端工程化十年下来,bundle 体积成为衡量项目健康度的最直观指标。一个未经治理的 SPA 首包动辄 3 到 5MB,4G 网络下白屏 8 秒以上,移动端流量耗费惊人,Core Web Vitals 自然全红。本文从分析工具到具体手术,把 bundle 体积优化的每个环节讲透:source-map-explorer 怎么读、tree shaking 真正生效需要哪些条件、动态 import 与路由分割如何配合、vendor chunking 拆得太细或太粗都有哪些坑、polyfill 怎么瘦身、重复依赖如何根除。所有结论来自生产项目实战,照做能让一个普通 React/Vue 项目首包从 5MB 降到 500KB 以内。

1. 先建立度量:bundle 体积分析三件套

没有度量就没有优化。开始任何手术前,先把现状量化清楚。三件工具按推荐顺序排开:source-map-explorer、webpack-bundle-analyzer、rollup-plugin-visualizer。它们的共同前提是构建产物带 source map,发布到生产前可以用 SOURCEMAP hidden 模式生成不暴露源码的 source map,仅供分析。

source-map-explorer 输入一个 JS 文件加对应 source map,输出 treemap,按目录展开能直接看到「lodash 占了 71KB、moment 占了 234KB、antd/icons 占了 380KB」。这是最快定位「胖子」的方法。webpack-bundle-analyzer 是 Webpack 专用,跑在构建过程中,输出交互式 treemap,能展示 stat size、parsed size、gzipped size 三个维度,对 chunk 划分调整尤其有用。Rollup/Vite 项目用 rollup-plugin-visualizer,效果类似。

三个尺寸口径要分清楚。stat size 是源码体积,parsed size 是 minify 后体积,gzipped size 是网络传输体积。线上首包评估看 gzipped;优化空间评估看 parsed;模块占比看 stat。一个常见误区是只看 gzipped 觉得「才 200KB 可以接受」,但 parsed 解压后 800KB 才是实际跑在浏览器主线程上的字节,解析与编译耗时与 parsed size 强相关。

2. tree shaking:让构建工具帮你删代码

tree shaking 是基于 ES Module 静态分析的死代码删除技术。理论上 import 进来但没使用的导出,构建工具会自动剔除。但实际生产项目里 tree shaking 经常半失效,原因有四个层面。

第一,依赖必须发布 ES Module 格式。lodash 默认是 CommonJS,几乎完全无法 tree-shake,要换成 lodash-es;moment.js 整个库是个庞大对象,tree-shaking 完全失效,要换成 date-fns 或 dayjs;ramda、rxjs 等老库都需关注是否提供 esm 入口。检查方法:看依赖 package.json 是否有 module 或 exports 字段。

第二,sideEffects 字段必须正确。Webpack 假设所有模块都有副作用(保守策略),除非 package.json 显式声明 sideEffects false。自家项目应在 package.json 加 sideEffects 字段,CSS 与 polyfill 文件需要列出来保留:sideEffects 数组里写 星号点 css、星号点 scss、点斜杠 src 斜杠 polyfills.ts。

第三,导入语法要细化。import _ from lodash 整个 default 拉过来基本删不掉;改成 import debounce from lodash-es 斜杠 debounce 才能精确按需。React 组件库一般支持「按名导入」,加上 babel-plugin-import 自动转换为路径导入;MUI 5 后默认支持,无需插件。

第四,Babel 不能把 ES Module 转成 CJS。preset-env 必须配置 modules false,让 Babel 保留 import/export,由 Webpack/Rollup 自己处理。这是初学者最常犯的错——配了 preset-env 默认 modules auto,把 ESM 转成 CJS,tree shaking 全失效。

3. 代码分割:路由级 + 组件级双轨制

代码分割的核心是把「所有用户都要的代码」与「特定路由或交互才需要的代码」分开。前者进首包,后者按需加载。两个层级缺一不可。

路由级分割是基础。React 项目用 React.lazy 加 Suspense:const Dashboard 等于 React.lazy 函数包裹 import 字符串 ./pages/Dashboard,每个页面对应独立 chunk。Vue Router 用 component 函数式 import 即可。Next.js、Nuxt、Astro 等元框架默认开启路由分割,无需手动配置。这一步通常能把首包砍掉 60。

组件级分割针对「首屏不可见但占体积大」的组件。典型对象有富文本编辑器(TinyMCE、Quill 几百 KB)、图表库(ECharts、Chart.js 数百 KB)、模态框(点击才显示)、PDF 预览、视频播放器、地图组件。手法是把它们包在 React.lazy 或 Vue defineAsyncComponent 里,触发条件可以是「用户点击按钮」「滚动到可视区」「鼠标移近」。

更高级的玩法是「prefetch / preload」预加载。当用户停留在首页时,浏览器空闲后台预拉用户大概率会去的下一个页面 chunk,等真正点击跳转时秒级显示。Webpack 用 magic comment 注释里写 webpackPrefetch true。Quicklink、instant.page 等库自动监听视口内的链接做预拉。配合 Core Web Vitals 优化 可以同时优化 LCP 与导航时间。

4. vendor chunking:让缓存命中率最大化

vendor chunking 是把 node_modules 依赖单独打包,与业务代码隔离。核心目的是缓存效率:业务代码每天都在改,依赖更新频率低,分开打包后业务代码迭代不会让依赖缓存失效。

错误做法一是不分割,所有代码打一个 main.js,发版改一行业务代码也要重新下载所有依赖。错误做法二是粗放分割,把所有 node_modules 打成一个 vendor.js(几 MB),任何依赖小版本升级都让整个 vendor.js hash 变化,全部用户重新下载。

合理做法是分层:framework 层(React + ReactDOM + 路由 + 状态管理这些铁打不动的依赖)、ui 层(antd / MUI / Element Plus 这类大型组件库)、utils 层(lodash-es、date-fns 等)、剩余 node_modules 按依赖大小阈值进 commons 或单独成 chunk。Webpack 用 splitChunks.cacheGroups 实现。

cacheGroups 中给 framework 写 test 正则匹配 react、react-dom、react-router 等包,priority 设最高;给 ui 写匹配 antd、ant-design 等;commons 兜底匹配剩余 node_modules。每组指定独立 chunk 名,构建产物长这样:framework.abc123.js、ui.def456.js、commons.ghi789.js。React 18 升级到 18.2 时,只有 framework chunk hash 变,其他全部缓存命中。

Vite 项目用 build.rollupOptions.output.manualChunks 函数式配置:根据模块路径返回不同的 chunk 名,逻辑同上。Vite 5 之后内置了 splitVendorChunkPlugin,开箱即用。

5. 找出并干掉重复依赖

大型项目最容易被忽视的肥点是重复依赖。同一个库出现 2 到 3 个版本同时被打入,体积翻倍且功能可能因版本差异冲突。检测方法:npm ls 加包名列出整棵依赖树看有几份;或用 npm dedupe / yarn dedupe / pnpm dedupe 命令一键去重;或专门工具 webpack-bundle-analyzer 看 treemap 是否同名包出现多次。

典型场景:一个项目同时用 lodash 4.17.x 与某依赖间接引入的 lodash 4.15.x,两份各 70KB;moment 与 dayjs 同时存在;core-js 2 与 3 共存(这个最痛)。

解决手段三招。第一,package.json 用 resolutions(Yarn)或 overrides(npm 8+、pnpm)字段强制锁定到统一版本。第二,pnpm 默认硬链接共享 store,多版本占空间但 bundle 仍可能多份;用 pnpm.overrides 强制收敛。第三,构建配置 alias,把所有 lodash 强制指向 lodash-es 同一物理路径。每次升级三方依赖后跑一次 npm dedupe 与 source-map-explorer 复核。

6. polyfill 瘦身:browserslist + core-js usage 模式

polyfill 是体积大头里最容易被忽视的部分。Babel preset-env 默认配置不当时会把 core-js 全量打入,单这一项 200 到 400KB。三步治理。

第一步,在 package.json 加 browserslist 字段明确目标浏览器。生产配置示例:last 2 versions、not dead、not IE 11、chrome 大于等于 80(针对国内市场)。这是 Babel、Autoprefixer、PostCSS 共享的目标声明。

第二步,Babel preset-env 配 useBuiltIns usage 与 corejs 3。usage 模式让 Babel 按代码实际用到的 API 注入对应 polyfill。entry 模式(旧默认)按 browserslist 一次性 import 全部 polyfill,体积大;usage 模式按需精确注入。

第三步,模块/无模块双产物(modern + legacy)。Vite 用 vitejs 斜杠 plugin-legacy,自动产出两份代码:现代浏览器加载 type module 的 ES2020 包(无 polyfill 或仅少量),老浏览器加载 nomodule 的 ES5 包带 polyfill。Webpack 项目可用 babel-loader 配两个 entry 实现。这套下来现代浏览器收到的包再瘦 30 到 50。

7. 图标、字体、CSS:常被忽视的体积黑洞

图标库是隐形肥点。Font Awesome 全量 CSS 加字体文件 600KB+,仅按需用 4 个图标也是这个体积;ant-design 斜杠 icons 全量 800KB+。手法:图标用 SVG sprite 或单文件按需 import;自家项目用 SVGR 把 SVG 转 React 组件,按需 import 哪个用哪个。Material Icons 用 mui 斜杠 icons-material 配合 babel-plugin-import 按需。

字体也容易超标。中文字体一份 7 到 10MB,未做子集化时直接拉爆首包加载。子集化方案见 Web 字体加载策略,配合 unicode-range 可把中文字体切到 200KB。英文字体相对小但全字重 500KB+,按需声明字重(400 + 700 通常够用)。

CSS 体积常被低估。Tailwind 未配 purge 时未压缩 3MB+,配置正确后通常 10 到 30KB;CSS-in-JS 的 runtime 也占体积(Emotion 大约 14KB gz)。Bootstrap 全量 CSS 200KB+,仅按需用栅格也是这个体积,要用 PostCSS PurgeCSS 移除未用类。

8. CI 卡关与性能预算:防止体积反弹

优化做完最痛的是「半年后又胖回去」。新人提 PR 引入一个 200KB 依赖,没人留意就合入;版本一升一降,总体积每周长 5。解决办法是把 bundle 体积纳入 CI 卡关与监控。

性能预算(performance budget)是最直接的工具。在 webpack 配置里设 performance.maxAssetSize 500000、maxEntrypointSize 800000,超过阈值构建警告或失败。Vite 项目用 build.chunkSizeWarningLimit。Lighthouse CI 也可以设 performance budget:JS 总量小于 300KB、Image 总量小于 500KB 等,PR 超标自动 block。

更好的方案是 size-limit 库。在 package.json 配置每个 entry 的 gzip 体积上限,CI 阶段跑 size-limit 命令,超标失败并打印增量来源。GitHub Actions 集成后每个 PR 都会留下增量字节的评论,让 reviewer 一眼看到体积变化。配合 bundlewatch 把数据上报到独立面板,长期跟踪趋势更清晰。

建立度量、定期复盘、把治理常态化,是 bundle 体积优化能从一次性运动变成长期工程的关键。配合 Critical CSS 实战图片优化深度教程,整套首屏体验能稳定保持在 Good 级。

常见问题

为什么我的 React 应用首包动辄 3MB?

常见原因有四类:第一,没做路由级分割,所有页面被打成一个 chunk,登录页也加载了后台报表的图表库;第二,引入了体积巨大的依赖(moment、lodash 全量、antd 全量、ECharts 全量),单个依赖就能贡献几百 KB;第三,Babel polyfill 把 core-js 全量打入,光这一项就 200 到 400KB;第四,CSS-in-JS 库或图标库未做按需加载。打开 source-map-explorer 报告,几乎一定能在 Top 10 看到这些罪魁。

tree shaking 为什么没生效?

tree shaking 依赖三个前提:模块必须是 ES Module(import/export 语法)、没有副作用(package.json 的 sideEffects 字段需正确声明)、构建工具开启 production 模式。常见失效原因:依赖发布的是 CommonJS 格式(lodash 经典坑,需用 lodash-es)、import 整个命名空间(import 星号 as _ from lodash)、副作用文件未在 sideEffects 中列出(CSS 通常需保留)、Babel preset-env 把 ES Module 转成 CJS(需配置 modules false 让 Webpack/Rollup 处理)。用 webpack-bundle-analyzer 查看具体哪些导出被保留,能定位问题。

动态 import 和路由分割的区别?

路由分割是动态 import 的一种特例。React Router 用 React.lazy 加 import 把每个路由拆成单独 chunk,只有访问到对应 URL 时才下载;这是宏观切割,粒度是页面。组件级动态 import 更细:把首屏不可见的弹窗、富文本编辑器、图表组件按需加载,只有用户点击触发时才下载。两者结合可以把首包从 2MB 压到 200 到 400KB,剩余资源在用户交互时按需到达。

vendor chunking 怎么拆才合理?

错误做法是把所有 node_modules 打成一个 vendor.js,任何依赖更新都让整个文件 hash 变化,缓存全失效。合理做法分三层:framework chunk(React、ReactDOM、router 等核心,长期不变)、ui chunk(antd、MUI 这类大型 UI 库)、common chunk(工具库 lodash、date-fns 等)。Webpack 用 splitChunks.cacheGroups 按 test 正则分组,Rollup 用 manualChunks 函数返回 chunk 名。目标是让 90 的发版只让一个小 chunk 失效。

怎么瘦身 polyfill?

关键是配置 browserslist 与 core-js useBuiltIns。第一步,在 package.json 加 browserslist 字段,明确支持的浏览器范围(生产环境通常 last 2 versions and not dead and not IE 11 即可,针对中国市场可加 chrome 大于等于 80)。第二步,Babel preset-env 设置 useBuiltIns usage 与 corejs 3,让 Babel 按代码实际用到的 API 注入 polyfill 而非全量。第三步,对现代浏览器输出 ES2020 包,用 module/nomodule 模式给老浏览器另发一份。这套下来 polyfill 体积通常从 300KB 降到 30KB。

相关工具