tree shaking为什么失效

22 阅读5分钟

在前端工程化构建中,Tree Shaking 作为核心的代码优化手段,能帮我们剔除项目中未使用的冗余代码,大幅缩减打包体积。但在实际业务中,Tree Shaking 失效是很常见的问题,尤其当项目依赖了不规范的第三方包时,问题会变得隐蔽且难以定位。

最近我在使用 Rsbuild 构建项目时,就遇到了一个典型的 Tree Shaking 失效问题。

一、问题背景

项目依赖了内部工具包 @muh/utils,这个包提供了两个工具函数:

  1. objectToUrlParams:对象转 URL 参数(纯 JS 实现,无外部依赖)
  2. 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 静态模块规范 实现,核心依赖两个特性:

  1. ESM 静态导入 / 导出import/export 是语法层面的静态声明,构建工具可以在编译时(不执行代码) 分析模块依赖关系,判断哪些代码未被使用。
  2. 副作用(Side Effects)标记:如果一个模块 / 依赖没有副作用(即仅导出函数 / 变量,不修改全局环境、不执行独立逻辑),构建工具可以安全剔除未使用的代码。

Rsbuild 生效有几个条件:

  • 代码必须使用 ESM 模块(CommonJS 无法 Tree Shaking)——这是最重要的一点
  • 第三方依赖的 package.json 正确声明 sideEffects——这点并不是必要条件
  • 构建配置未禁用 Tree Shaking(生产环境默认开启)。

官方文档明确指出:Tree Shaking 无法识别带有副作用的模块,如果构建工具无法确定一个模块是否有副作用,会默认保留该模块的所有代码。

三、问题根因分析

结合 Rsbuild Tree Shaking 原理,我们能精准定位本次问题的核心原因

crypto-js 非 ESM 规范,无副作用声明

crypto-js 是一个老旧的依赖库,存在两个致命问题:

  1. 不支持 ESM 模块:仅提供 CommonJS 格式,构建工具无法对其做静态分析;
  2. 未声明 sideEffects: false:构建工具无法确定 crypto-js 是否有副作用,保守策略下会强制保留整个依赖,即使代码中没有使用它。

这两个问题只要修复其中任何一个,都能使tree shaking生效。

比如:手动修改 crypto-jspackage.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 的内容,它只是在模块图中建立了一条"声明性的导出链路"。

构建工具在静态分析时:

  1. 发现项目没有使用 c.js 导出的任何函数;
  2. 直接忽略整个 c.js 模块,连带它的 crypto-js 依赖一起剔除;
  3. 仅打包 b.js 的代码,完美实现 Tree Shaking。

方案二:项目侧替换依赖

可直接将 crypto-js 替换为 crypto-es

crypto-escrypto-js 的 ESM 版本:

  1. 完全兼容原有 API,无需修改业务代码;
  2. 原生支持 ESM 模块规范;
  3. 正确声明了 sideEffects: false

替换后,Rsbuild 可以正常对其做 Tree Shaking,未使用的代码会被自动剔除。

五、总结与避坑指南

本次 Tree Shaking 失效问题,本质是多模块规范混合 + bundle打包 + 依赖副作用未声明共同导致的。其任意一个都不会单独导致rsbuild的tree shaking失效,但这几个问题恰恰都是开发中很少关注的点,因此这个问题在实践中还挺常见的。

这里结合 Rsbuild 的特性,总结几个前端 Tree Shaking 避坑要点:

  1. 优先使用 ESM 规范的依赖:避免老旧 CommonJS 库,这是 Tree Shaking 的基础;
  2. 强烈建议第三方包声明 sideEffects:无副作用的模块建议标记 sideEffects: false
  3. 工具库推荐 Bundless 打包:当不得不使用到cjs模块的js时,不要合并模块,保留文件结构,方便构建工具做细粒度优化;