你的 Tree shaking 在生产环境不生效,可能不是工具的问题,而是 sideEffects 没写对

Tree shaking 在生产环境不生效,十有八九不是 Webpack 或 Rollup 的锅,而是你的 sideEffects 字段写错了——要么没写,要么写成了 true,要么漏标了有副作用的文件。

先搞清楚:为什么明明写了 import { a },结果 b 还是被打进去了

很多人的第一反应是「工具没配置好」。但就我过去几年处理过的打包体积问题来看,真正因为工具配置失误导致 tree shaking 失效的情况,不到两成。绝大多数时候,问题出在模块本身:工具无法判断一个模块「删掉未使用的导出」是否安全,于是选择保守处理,全部保留。

这个判断依据,就是 package.json 里的 sideEffects 字段。

Webpack 4 从 2018 年 2 月发布的 4.0 版本开始正式支持 sideEffects,Rollup 则更早。它的作用很简单:告诉打包工具,这个包里的模块是否包含「导入后就会产生副作用」的代码。如果标记为 false,工具就可以大胆地删掉那些没有被使用的导出。如果不写,或者写了 true,工具就默认所有模块都可能有副作用,一个都不敢动。

我举个真实例子。假设你有一个工具库:

// utils.js
export function formatDate(date) {
  return date.toISOString().slice(0, 10);
}

export function formatCurrency(amount) {
  return '¥' + amount.toFixed(2);
}

// 这行代码有副作用:导入时就会执行
console.log('utils module loaded');

你在业务代码里只用了 formatDate

import { formatDate } from './utils';
console.log(formatDate(new Date()));

如果 sideEffects 没配置,Webpack 生产构建时会把 formatCurrency 和那行 console.log 全部保留。因为它不知道删掉 formatCurrency 会不会出问题——万一 formatCurrency 的定义过程里改了全局变量呢?万一那行 console.log 是你故意留的呢?

但如果你在 package.json 里写了:

{
  "name": "my-utils",
  "sideEffects": false
}

Webpack 就会放心地删掉 formatCurrency。至于那行 console.log,它属于模块顶层语句,会被保留,因为模块本身被导入了,顶层代码必须执行。真正能被删掉的,是那些没有被引用的导出声明。

sideEffects 写成数组时,最容易踩的坑是漏掉 CSS 和 polyfill

很多人知道 sideEffects: false 能优化,但不敢用,因为项目里有全局 CSS 或 polyfill。这类文件的典型特征就是:导入它们的目的不是为了拿导出值,而是为了执行副作用。如果你把 sideEffects 直接设为 false,Webpack 会把这类文件也摇掉,导致样式丢失或 polyfill 失效。

正确的做法是写数组,把有副作用的文件列出来:

{
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js"
  ]
}

这里有个经常被忽略的细节:数组里的路径匹配规则和 .gitignore 不一样,它用的是 glob 模式,并且 *.css 这种写法只匹配根目录下的 CSS 文件。如果你的样式文件在子目录里,比如 src/styles/theme.css*.css 是匹配不到的,需要写成 **/*.css

我去年帮一个团队排查过一个问题:他们用了 sideEffects: ["*.css"],结果生产环境里所有子目录下的组件样式全部丢失,但开发环境完全正常。排查了半天才发现是 glob 匹配范围的问题。改成 **/*.css 之后,打包体积立刻降了 40KB,样式也回来了。

另一个坑是 polyfill。很多人习惯在入口文件里 import './polyfills',然后在 sideEffects 数组里忘了把它列出来。结果就是生产构建时 polyfill 被 tree shaking 掉,线上环境在旧浏览器里直接白屏。这类问题最麻烦的地方在于:本地开发环境通常用的是现代浏览器,根本发现不了。

组件库开发者的噩梦:sideEffects 写对了,但导出方式不对

即使 sideEffects 配置正确,还有一种情况会导致 tree shaking 失效:组件库的导出方式。

假设你开发了一个组件库,入口文件是这样写的:

// index.js
import Button from './Button';
import Modal from './Modal';
import Table from './Table';

export { Button, Modal, Table };

用户这样使用:

import { Button } from 'my-component-lib';

理想情况下,ModalTable 的代码应该被摇掉。但如果你没有用 ES Modules 的方式导出,而是用了 CommonJS 的 module.exports,或者更糟的——在入口文件里直接执行了某些初始化逻辑,tree shaking 就会部分或完全失效。

这里的关键在于:Webpack 和 Rollup 的 tree shaking 依赖 ES Modules 的静态结构。export { Button, Modal, Table } 这种写法是静态可分析的,工具可以精确知道哪些导出被使用了。但如果你这样写:

// index.js
const components = {
  Button: require('./Button').default,
  Modal: require('./Modal').default,
  Table: require('./Table').default,
};

module.exports = components;

这就是完全动态的结构,工具无法判断 Button 被使用后 ModalTable 是否还需要。结果就是整个对象被打包进去,三个组件一个都跑不掉。

更隐蔽的情况是「半 ES Module」写法:

// index.js
import Button from './Button';
import Modal from './Modal';

export default {
  Button,
  Modal,
};

用户写 import { Button } from 'my-component-lib' 时,工具必须执行整个 export default 对象字面量,这意味着 Modal 也会被实例化。虽然代码可能最终被保留,但 tree shaking 的效果已经大打折扣。

生产环境验证:别只看构建日志,要看产物内容

很多开发者判断 tree shaking 是否生效,靠的是构建日志里的「asset size」变化。但这个方法不可靠。因为 Webpack 的 optimization.usedExportssideEffects 是两套机制,前者只标记未使用的导出,后者才真正删除代码。如果 sideEffects 没配置,usedExports 标记了也不会删。

我的建议是直接检查产物。构建完成后,在产物文件里搜索你预期被摇掉的函数名或组件名。比如你只用了 Button,就在产物里搜索 Modal 的特征字符串(比如组件的 displayName 或某个独特的 class 名)。如果搜到了,说明 tree shaking 没生效。

更精确的方法是使用 Webpack 的 stats 输出:

// webpack.config.js
module.exports = {
  // ...
  stats: {
    usedExports: true,
    providedExports: true,
    optimizationBailout: true,
  },
};

optimizationBailout 会告诉你每个模块为什么没有被优化。如果看到类似 "ModuleConcatenation bailout: Module is not an ECMAScript module""side effects optimization bailout" 的提示,就能快速定位问题根源。

常见问题

为什么我把 sideEffects 设为 false 了,打包体积没变化?

先确认你的代码是否真的存在可以被摇掉的未使用导出。如果业务代码里所有导入都被使用了,sideEffects 不会带来任何体积优化。其次检查你的构建工具版本:Webpack 需要 4.0 以上,并且 mode 必须设置为 production,因为 sideEffects 优化只在生产模式下启用。最后看看你的 Babel 配置,如果 @babel/preset-env 把 ES Modules 转换成了 CommonJS,tree shaking 会直接失效。需要设置 modules: false 来保留 ES Modules 语法。

我在组件库里用了 sideEffects: false,但用户那边打包后样式全丢了,怎么办?

问题出在组件库的样式导入方式。如果你在每个组件的 JS 文件里 import './Button.css',这些 CSS 导入会被视为「有副作用」的模块,但因为 sideEffects: false,Webpack 会把它们全部摇掉。解决方案有两个:一是把 sideEffects 改为数组,把所有 CSS 文件列进去;二是改变样式导入策略,让用户手动导入样式文件,或者使用 CSS-in-JS 方案。前者更简单,后者更彻底。

Webpack 5 和 Webpack 4 在 sideEffects 处理上有区别吗?

有,但不大。Webpack 5 在 sideEffects 的基础上增加了「模块连接」(Module Concatenation)的优化,可以让更多模块被安全地合并。但核心机制没有变化:sideEffects 字段仍然是决定 tree shaking 能否生效的关键开关。如果你从 Webpack 4 升级到 5 后发现 tree shaking 行为有变化,优先检查 optimization.sideEffects 是否被显式设置过,这个配置项在 Webpack 5 中默认是 true,但在某些迁移场景下可能被旧配置覆盖。

Vite 或 Rollup 项目里 sideEffects 不生效怎么办?

Rollup 对 sideEffects 的支持比 Webpack 更严格。如果你的包是作为库发布给其他项目使用的,必须在 package.json 里正确声明 sideEffects,并且确保发布的是 ES Modules 版本(module 字段指向 ES Modules 入口)。如果是应用项目内部的模块,Rollup 默认会尝试 tree shaking,但同样需要模块本身是纯的、没有顶层副作用。一个常见的坑是:某些 Babel 插件会在模块顶层注入 regeneratorRuntime 之类的代码,导致 Rollup 认为模块有副作用而拒绝摇掉。