在前端工程化构建中,Tree Shaking 作为核心的代码优化手段,能帮我们剔除项目中未使用的冗余代码,大幅缩减打包体积。但在实际业务中,Tree Shaking 失效是很常见的问题,尤其当项目依赖了不规范的第三方包时,问题会变得隐蔽且难以定位。
最近我在使用 Rsbuild 构建项目时,就遇到了一个典型的 Tree Shaking 失效问题。
一、问题背景
项目依赖了内部工具包 @muh/utils,这个包提供了两个工具函数:
objectToUrlParams:对象转 URL 参数(纯 JS 实现,无外部依赖)encodeBase64:Base64 编码(依赖crypto-js)
工具包打包后输出的 ESM 格式 index.js 是单文件整合形态:
// @muh/utils/dist/esm/index.js
import crypto_js from "crypto-js";
// 无依赖函数
const objectToUrlParams = (obj)=>{
if (!obj) return "";
return Object.keys(obj).map((key)=>`${key}=${obj[key]}`).join("&");
};
// 依赖 crypto-js 的函数
const encodeBase64 = (word)=>{
if (!word) return word;
return crypto_js.enc.Base64.stringify(crypto_js.enc.Utf8.parse(word));
};
// 统一导出
export {objectToUrlParams, encodeBase64 }
我的项目仅使用了 objectToUrlParams 函数,从代码结构上来说,虽然bundle成了一个文件,但并不会影响tree shaking,预期构建工具应该自动剔除未使用的依赖 crypto-js。但实际打包后,crypto-js 依然被完整打包进了项目 dist 目录,Tree Shaking 失效。
二、Rsbuild Tree Shaking 核心原理
在排查问题前,先深入理解 Rsbuild(基于 Rspack)的 Tree Shaking 机制,这是定位问题的关键。
Rsbuild 的 Tree Shaking 基于 ESM 静态模块规范 实现,核心依赖两个特性:
- ESM 静态导入 / 导出:
import/export是语法层面的静态声明,构建工具可以在编译时(不执行代码) 分析模块依赖关系,判断哪些代码未被使用。 - 副作用(Side Effects)标记:如果一个模块 / 依赖没有副作用(即仅导出函数 / 变量,不修改全局环境、不执行独立逻辑),构建工具可以安全剔除未使用的代码。
Rsbuild 生效有几个条件:
- 代码必须使用 ESM 模块(CommonJS 无法 Tree Shaking)——这是最重要的一点;
- 第三方依赖的
package.json正确声明sideEffects——这点并不是必要条件; - 构建配置未禁用 Tree Shaking(生产环境默认开启)。
官方文档明确指出:Tree Shaking 无法识别带有副作用的模块,如果构建工具无法确定一个模块是否有副作用,会默认保留该模块的所有代码。
三、问题根因分析
结合 Rsbuild Tree Shaking 原理,我们能精准定位本次问题的核心原因:
crypto-js 非 ESM 规范,无副作用声明
crypto-js 是一个老旧的依赖库,存在两个致命问题:
- 不支持 ESM 模块:仅提供 CommonJS 格式,构建工具无法对其做静态分析;
- 未声明
sideEffects: false:构建工具无法确定crypto-js是否有副作用,保守策略下会强制保留整个依赖,即使代码中没有使用它。
这两个问题只要修复其中任何一个,都能使tree shaking生效。
比如:手动修改 crypto-js 的 package.json 添加 sideEffects: false,Tree Shaking 立即生效,crypto-js 被成功剔除。虽然这一点实际生产中无法使用,但可以浮出rsbuild tree shaking的一个规则:
当rsbuild无法通过静态分析判断是否有副作用时,会使用sideEffects辅助判断。
四、解决方案:两种可落地的优化方案
方案一:工具包改用 Bundless 打包
Bundless(非合并打包):保留源码的文件结构,不合并模块,仅做编译转换。
改造后 @muh/utils 的输出结构:
plaintext
dist/esm/
├── b.js # 仅包含 objectToUrlParams,无依赖
├── c.js # 仅包含 encodeBase64 + crypto-js 依赖
└── index.js # 统一导出入口
文件代码:
// b.js(纯函数,无依赖)
export const objectToUrlParams = (obj) => {
if (!obj) return "";
return Object.keys(obj).map((key) => `${key}=${obj[key]}`).join("&");
};
// c.js(仅自身依赖 crypto-js)
import crypto_js from "crypto-js";
export const encodeBase64 = (word) => {
if (!word) return word;
return crypto_js.enc.Base64.stringify(crypto_js.enc.Utf8.parse(word));
};
// index.js(声明式导出,无实际执行逻辑)
export * from './b.js';
export * from './c.js';
为什么这个方案能生效?
这个方案利用了 ES Module 静态分析的核心能力。
export * from './c.js' 这个语法,打包器处理时并不会真正执行或引入 c.js 的内容,它只是在模块图中建立了一条"声明性的导出链路"。
构建工具在静态分析时:
- 发现项目没有使用
c.js导出的任何函数; - 直接忽略整个
c.js模块,连带它的crypto-js依赖一起剔除; - 仅打包
b.js的代码,完美实现 Tree Shaking。
方案二:项目侧替换依赖
可直接将 crypto-js 替换为 crypto-es。
crypto-es 是 crypto-js 的 ESM 版本:
- 完全兼容原有 API,无需修改业务代码;
- 原生支持 ESM 模块规范;
- 正确声明了
sideEffects: false。
替换后,Rsbuild 可以正常对其做 Tree Shaking,未使用的代码会被自动剔除。
五、总结与避坑指南
本次 Tree Shaking 失效问题,本质是多模块规范混合 + bundle打包 + 依赖副作用未声明共同导致的。其任意一个都不会单独导致rsbuild的tree shaking失效,但这几个问题恰恰都是开发中很少关注的点,因此这个问题在实践中还挺常见的。
这里结合 Rsbuild 的特性,总结几个前端 Tree Shaking 避坑要点:
- 优先使用 ESM 规范的依赖:避免老旧 CommonJS 库,这是 Tree Shaking 的基础;
- 强烈建议第三方包声明
sideEffects:无副作用的模块建议标记sideEffects: false; - 工具库推荐 Bundless 打包:当不得不使用到cjs模块的js时,不要合并模块,保留文件结构,方便构建工具做细粒度优化;