体积减半?揭秘Tree Shaking“失效”的3个隐形杀手

还记得两年前那个周五的凌晨两点,我盯着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 根本无从下刀,只能把整个模块全盘保留。

修正方案:

检查你的 .babelrcbabel.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 就会认为你可能需要访问该文件下的所有导出,结果把体积巨大的 RichTextEditorAMap(高德地图组件)也顺带打包了进来。这就是典型的“拔出萝卜带出泥”。

优化策略:

虽然现在的 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 个步骤立刻行动起来:

  1. 检查 Babel 配置:确认 modules: false 是否已开启,这是前提条件。
  2. 审计 sideEffects:在 package.json 中明确标记无副作用的文件,尤其是纯函数工具库。
  3. 安装分析工具:运行 webpack-bundle-analyzer,肉眼定位那些本不该出现的大块头模块。

最后,想问问大家: 在日常开发中,为了开发效率使用 export * 统一导出,还是为了性能坚持写长路径的按需引入,你会选哪一种?A 还是 B? 欢迎在评论区告诉我你的选择。