遇到打包体积过大的问题,先别急着把 tree shaking 相关的插件全部打开。tree shaking 不是一项独立的配置,它依赖模块语法、构建模式、副作用标记三件事同时成立。项目里只要有一处 CommonJS 的 require,或者某个第三方库的导出方式不干净,后续配置都可能白做。所以第一件事不是改 webpack.config.js,而是先确认当前代码被编译成什么样子。
先别急着改配置,确认模块语法和产物特征
要判断项目是否具备 tree shaking 的前提,可以先检查 package.json 中 sideEffects 字段是否存在,以及构建产物中的模块代码是否包含未使用的导出。如果 sideEffects 字段缺失或为 false,webpack 仍可能因为副作用标记不明确而保留部分无用代码。另一个常见信号是打包后的 bundle 中出现了大量从未被引用的工具函数,比如 lodash 的整个库被引入,这说明 tree shaking 没有真正生效。此时需要进一步确认是否使用了 ESM 模块语法,因为 CommonJS 的 require 无法被静态分析,会导致 tree shaking 完全失效。
这一步的判断动作很简单:在源码里搜一下 import 和 require 的使用比例。如果业务代码里还有不少 require,先不要期待 tree shaking 能解决体积问题,因为 require 是运行时加载,webpack 无法静态分析哪些导出被使用。即便把 sideEffects 设为 false,也只能让 webpack 跳过部分文件级副作用检查,没法把 CommonJS 模块里的未用导出摘出去。另一个检查点是用编辑器打开 node_modules 里某个库的 package.json,看它是否声明了 module 字段,并且实际指向的文件是不是 ESM 语法。如果只有 main 字段,这个库基本不会参与 tree shaking。
操作路径:统一 ESM,设置 sideEffects,再查依赖图
启用 tree shaking 时,建议先将项目中的模块规范统一为 ES Module,并确保 webpack 的 mode 设置为 production,因为 production 模式会自动开启 tree shaking 和代码压缩。随后在 package.json 中添加 sideEffects 字段,若项目没有全局副作用文件,可设为 false;若有 CSS 或 polyfill 等需要保留的文件,则使用数组形式列出具体路径。对于第三方库,优先选择 lodash-es 这类提供 ESM 构建的版本,或者使用 babel-plugin-import 按需引入,避免整个库被打包。操作完成后,可以通过 webpack-bundle-analyzer 查看打包后的模块依赖图,确认未使用的导出是否已被剔除。
这里的顺序很关键。如果先设置 sideEffects 再统一模块规范,babel 或 ts-loader 可能已经把 import 转换成 require,导致 sideEffects 标记无法作用到真正的模块上。建议先确认编译链路的 modules 选项。如果是 babel 编译,检查 @babel/preset-env 的配置,确认 modules 没有被设为 auto 或 commonjs,通常设为 false 才能保留 ESM 语法给 webpack 分析。TypeScript 项目则要检查 tsconfig.json 里的 module 字段,不能设为 commonjs,建议使用 esnext 或 es2015,同时 moduleResolution 要保持 node。
配置完成后,光看构建成功与否不够。你需要对比配置前后的 bundle 大小,但更重要的是看模块内容。webpack-bundle-analyzer 能显示每个 chunk 里包含哪些模块,但不会直接告诉你哪些导出是未使用的。这时要看一下 stats 文件里的 usedExports 信息。可以运行一次带 --json 参数的构建,然后搜索某个已知未使用导出函数的名字,如果它没有出现在 bundle 里,说明真正被剔除了。
改完后看这几个信号:未用导出是否消失,副作用代码是否报错
检查 tree shaking 是否生效,最直接的方法是观察构建产物的代码。在 production 模式下,被 tree shaking 移除的模块不会出现在 bundle 中,因此可以搜索一个从未被引用的导出函数名,如果搜索结果为空,说明该函数已被剔除。另一种方式是使用 webpack 的 stats 输出,在 package.json 的 build 脚本中加上 --json 参数,生成编译统计文件,然后借助 webpack-bundle-analyzer 或 stats-webpack-plugin 查看模块的 unused 标记。此外,还可以在开发模式下运行构建并查看控制台警告,webpack 会提示哪些模块存在未使用的导出,但需要额外配置 optimization.usedExports 为 true。
这个验证动作要注意环境差异。开发模式下 tree shaking 不会主动删除代码,只会标记 unused;生产模式下才会真正移除。所以不要因为在开发模式看到了未使用导出还在,就认为配置失效。另外,压缩器(terser-webpack-plugin)也会参与删除,但它是在 webpack 标记之后工作。如果遇到压缩后仍然出现奇怪的代码片段,可以尝试在 terser 的 compress 选项中显式开启 unused 和 dead_code,但这两个选项默认在 production 模式下已经是开启状态。
常见坑:副作用被误删,以及 babel 把 ESM 转成了 CommonJS
tree shaking 最常见的问题是误删带有副作用的代码,比如在模块顶层直接调用 API 或修改全局变量的文件,即使这些调用结果未被使用,也不能被移除。如果 sideEffects 设置为 false,这些副作用代码会被强制删除,导致运行时错误。另一个坑是 babel 编译时会将 ES Module 转换为 CommonJS,从而破坏静态分析的根基,需要在 babel 配置中将 modules 设为 false 或使用 babel-preset-env 的 modules: false。此外,有些第三方库虽然在 package.json 中声明了 module 字段,但实际构建出的代码可能不是纯 ESM,或者内部使用了动态导入,这些情况都可能让 tree shaking 失效,需要仔细审查库的实际导出形式。
判断副作用风险时,可以建立一个临时清单:哪些文件在 import 时就会执行逻辑,比如引入一个全局样式文件、初始化埋点、给 window 挂载属性。对这些文件,sideEffects 数组里必须显式列出路径。一个保守做法是先把 sideEffects 设为数组,包含 src/**/*.css 和需要保留的 polyfill,等构建稳定后再收紧成 false。不要一开始就全部设 false,否则报错后你很难定位是哪个模块被删了。
另外,动态 require 和条件编译是 tree shaking 的盲区。如果代码里有 require(variable) 这种写法,webpack 只能把整个目录打包进去。此时要么改为静态 import 列表,要么使用 DefinePlugin 定义环境变量,让 webpack 在编译时能把未选中的分支标记为死代码,再交给 terser 删除。动态 import 本身不一定会阻止 tree shaking,但会让被动态加载的模块单独分包,是否参与 tree shaking 取决于模块内部是否纯 ESM。
边界情况:有状态导出和无副作用断言要分开看待
tree shaking 只能处理纯函数和只读的常量导出,对于有内部状态或可变导出的模块,webpack 会保留整个模块以避免破坏引用关系。因此不建议在业务代码中编写带有副作用的导出,比如模块初始化时立即执行某些语句,或导出被修改过的对象。如果项目中存在条件编译或动态 require,tree shaking 也会无法分析,此时需要改用静态的 if 分支或使用 webpack 的 DefinePlugin 做环境判断。另外,当代码被压缩混淆后,未使用的导出可能以精简形式残留,可结合 terser 的 compress 选项中的 unused 和 dead_code 参数进一步清理,但要注意避免误删通过字符串引用或反射调用的代码。
检查到这里,需要明确一点:tree shaking 不是一次配置就能覆盖所有库的。有些库虽然提供了 ESM 版本,但内部的导出可能还被另一个入口重新导出,比如通过 index.js 统一导出所有组件,即使你只引用了其中一个组件,webpack 也可能因为 re-export 的关系保留部分内部模块。遇到这种情况,可以改用子路径导入,比如直接 import 库名/es/button 而不是 import 库名。另外,在 package.json 里声明 sideEffects 时,如果某个库本身带有 .css 文件,你需要把该库的样式文件路径也加进去,否则生产构建可能丢掉样式。
整个处理过程回滚的边界也比较清楚。如果设置 sideEffects 后出现运行时异常,优先把 sideEffects 改回数组,或者删除该字段,看问题是否消失。如果 babel 配置的 modules 被修改后构建失败,可以先恢复原值,改用 webpack 的 resolve.mainFields 让加载顺序优先指向 module 字段。这样至少不会破坏现有构建,再逐步推进优化。