还记得两年前那个周五的凌晨两点,我盯着Jenkins的构建日志发呆。
当时的那个后台管理系统项目,经过半年的功能堆砌,首屏加载文件(Main Bundle)竟然膨胀到了 4.8MB。在弱网环境下测试,白屏时间长达 8 秒。老板在群里丢下一句:“这速度,客户能忍?”
我当时信心满满地回复:“放心,我开了 Tree Shaking,构建时会自动剔除没用的代码。”
然而现实给了我一记响亮的耳光。明明配置了 mode: production,明明用了 ES6 模块,为什么那些根本没用到的重型图表库、几年前遗留的加密函数,依然稳稳地躺在打包产物里?
Tree Shaking 并不是一个“开了就能用”的魔法开关。在实战中,我踩过无数坑,才发现让它失效的往往是我们习以为常的代码习惯。今天,我想带大家复盘那次优化经历,聊聊如何揪出那 50% 的冗余代码。
一、 被误杀的 CSS 与 sideEffects 的博弈
很多人(包括当年的我)认为,Tree Shaking 只是 Webpack 自己的事。
在那次项目中,为了激进地减少体积,我直接在 package.json 里加上了 "sideEffects": false。我想法很简单:只要没被引用的文件,统统干掉。
结果是灾难性的: 项目跑起来了,但样式全崩了。所有的全局重置样式(Reset CSS)和引入的第三方 UI 库样式瞬间消失。
这是因为 Webpack 的 Tree Shaking 机制依赖于静态分析。它看到 import './style.css',发现并没有任何变量从这里被导出使用,于是判定为“无用代码”直接移除。但 CSS 的引入本身就是一种“副作用”(Side Effect),它会修改全局 DOM 样式。
避坑指南与实操:
我们不能一刀切。正确的做法是告诉打包工具,“大部分文件是无副作用的,但这些文件你别动”。
我在 package.json 中将配置修正为数组形式,这招瞬间救回了样式,同时剔除了 20% 的无用 JS 文件:
{
"name": "my-project",
"sideEffects": [
"*.css",
"*.less",
"*.scss",
"./src/utils/global-polyfill.js"
]
}
关键点: 哪怕是 JS 文件,如果只执行逻辑不导出变量(比如挂载 window 对象、Polyfill),也必须加入这个白名单,否则不仅是样式丢失,连基础功能都会莫名其妙失效。
二、 Babel 的“热心肠”成了绊脚石
解决了样式问题,我发现体积只下降了一点点。用 webpack-bundle-analyzer 一看,很多没用到的 lodash 方法依然被打包进去了。
排查了一整天,我才发现真正的凶手竟然是 Babel。
Tree Shaking 的核心依赖于 ES6 的模块机制(import / export),因为它是静态的,打包工具可以在编译时确定哪些导出被使用了。然而,早期的 Babel 配置非常“智能”,它默认会把 ES6 模块转译成 CommonJS(require / module.exports)。
这就好比 Webpack 想做外科手术切除肿瘤,但 Babel 提前把病人裹成了一个严严实实的木乃伊(CommonJS),Webpack 根本无从下刀,只能把整个模块全盘保留。
修正方案:
检查你的 .babelrc 或 babel.config.js。一定要确保 @babel/preset-env 不会转换模块类型。
// babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
// 关键配置!关闭模块转换,留给 Webpack 处理
modules: false,
useBuiltIns: 'usage',
corejs: 3
}
]
]
};
修改完这一行配置并重新构建,Bundle 体积直接从 3.5MB 骤降到 2.1MB。那个瞬间,我真正体会到了“配置一行字,性能翻一倍”的快感。
三、 “全家桶”导出的隐形代价
随着项目迭代,为了方便开发,我们团队内部封装了一个 components/index.js,里面把所有的通用组件(按钮、表格、地图、富文本编辑器)全部 export 出来。
开发时爽了,写代码只需一行:
import { Button } from '@/components';
但这里藏着一个巨大的隐患。在某些老版本的构建工具或配置不严谨的情况下,这种“全家桶”式的导出(Barrel Export)会让 Tree Shaking 失效。
在那个项目中,我发现只要引入了一个小小的 Button,Webpack 就会认为你可能需要访问该文件下的所有导出,结果把体积巨大的 RichTextEditor 和 AMap(高德地图组件)也顺带打包了进来。这就是典型的“拔出萝卜带出泥”。
优化策略:
虽然现在的 Webpack 5 在处理这类“深层作用域分析”上已经很强了,但我亲测最稳妥、立竿见影的方法依然是保持引用的纯粹性。
我花了两个小时,用正则替换配合人工检查,将核心页面的引用方式改为按需引入:
// 优化前:引发“幽灵依赖”的风险
import { Button, UserCard } from '@/components';
// 优化后:路径清晰,配合 sideEffects 配置,100% 触发 Tree Shaking
import Button from '@/components/Button';
import UserCard from '@/components/UserCard';
对于第三方库(如 lodash 或 UI 组件库),如果不想手动改路径,可以配合 babel-plugin-import 等插件来实现自动按需加载。
经过这波操作,首页的主包体积最终定格在 1.2MB 左右。相比最初的 4.8MB,我们成功瘦身了 75%。
总结与行动
Tree Shaking 从来不是银弹,它更像是一场需要开发者、工具配置、代码规范三方配合的精密手术。我也养成了习惯,每周五下午都会跑一遍包体积分析,看看有没有新的“赘肉”混进来。
如果你正为首屏加载速度发愁,不妨按照以下 3 个步骤立刻行动起来:
- 检查 Babel 配置:确认
modules: false是否已开启,这是前提条件。 - 审计 sideEffects:在
package.json中明确标记无副作用的文件,尤其是纯函数工具库。 - 安装分析工具:运行
webpack-bundle-analyzer,肉眼定位那些本不该出现的大块头模块。
最后,想问问大家:
在日常开发中,为了开发效率使用 export * 统一导出,还是为了性能坚持写长路径的按需引入,你会选哪一种?A 还是 B? 欢迎在评论区告诉我你的选择。