你可能在某个午后兴冲冲地将项目中的import _ from 'lodash'改成了import { debounce } from 'lodash-es',期待着构建产物大幅瘦身。然而,当你打开webpack-bundle-analyzer,却发现那个庞大的lodash依然盘踞在bundle中,仿佛对你的优化努力嗤之以鼻。

这不是个例。Tree Shaking——这个被现代前端开发者奉为圭臬的优化技术,在实际应用中往往难以达到预期效果。问题不在于工具不够智能,而在于我们对其工作原理的理解存在偏差。

死代码消除与活代码包含的本质区别

2015年12月,Rollup作者Rich Harris在Medium上发表了一篇引发广泛讨论的文章,他提出了一个关键区分:Tree Shaking并非简单的死代码消除(Dead Code Elimination,DCE)。

传统的DCE是一种"事后诸葛亮"式的优化:先把所有代码打包进去,然后分析哪些代码从未被执行,再将其删除。这就像做蛋糕时先把整颗鸡蛋(包括蛋壳)都扔进面糊,等烤完再试图挑出蛋壳碎片——既不优雅,也难以彻底。

Tree Shaking采取了完全相反的思路:从入口点出发,只包含真正被用到的代码。用Harris的话说,这不是"排除死代码",而是"包含活代码"(Live Code Inclusion)。

这个区别看似只是语义游戏,实则影响深远。以ES Module为例:

// utils.js
export function a() { console.log('a'); }
export function b() { console.log('b'); }
export function c() { console.log('c'); }

// main.js
import { a } from './utils';
a();

在DCE模式下,打包器会先将a、b、c三个函数全部打包,然后在压缩阶段发现b和c未被使用,再将其删除。而在Tree Shaking模式下,打包器从一开始就只把函数a拉进来。

理论上结果相同,但实际并非如此。JavaScript是一门动态语言,静态分析存在天然的局限性。Harris指出,Rollup并非完美,最佳实践是两步结合:先用Rollup做Tree Shaking,再用UglifyJS做DCE。

ES Module的静态结构为何是Tree Shaking的前提

为什么Tree Shaking只对ES Module有效?答案藏在模块系统的设计哲学中。

CommonJS采用动态加载机制:

// 这在CommonJS中完全合法
if (process.env.NODE_ENV === 'development') {
  const bar = require('./bar');
  module.exports.foo = bar.something;
}

// 动态路径也OK
const moduleName = getUserInput();
const mod = require(`./modules/${moduleName}`);

这些代码在运行时才能确定导入什么、导出什么。打包器在构建阶段无法静态分析getUserInput()会返回什么,自然也就无法判断哪些代码是"死"的。

ES Module从规范层面堵死了这条路:

// 全部非法!
if (condition) {
  import { foo } from './bar';  // SyntaxError
}

const path = './' + name;
export * from path;  // SyntaxError

import(name);  // 这是动态导入,完全不同的机制

ES Module要求所有importexport语句必须出现在模块顶层,且模块路径必须是字符串常量。这意味着模块之间的依赖关系在编译时就已确定,与运行状态无关。

Webpack正是利用这一特性,通过静态分析构建出完整的模块依赖图,从而精确判断哪些导出值从未被其他模块引用。这是Tree Shaking技术的必要前提。

Webpack内部的Tree Shaking流程

理解Tree Shaking为何失效,需要深入Webpack的实现机制。Webpack中的Tree Shaking分为四个关键阶段。

第一阶段:收集模块导出

在Make阶段,Webpack会遍历所有模块,将ES Module的export语句转换为对应的Dependency对象:

  • 具名导出export const foo = 'foo'转换为HarmonyExportSpecifierDependency
  • 默认导出export default 42转换为HarmonyExportExpressionDependency

这些依赖对象被记录到ModuleGraph体系中。当所有模块编译完毕后,FlagDependencyExportsPlugin插件被触发,它会从入口开始遍历ModuleGraph,将所有导出信息收集到ExportInfo对象中。

第二阶段:标记使用情况

进入Seal阶段后,FlagDependencyUsagePlugin插件开始工作。它同样从入口出发,分析每个模块的导出值是否被其他模块引用:

// index.js
import { used } from './module';
console.log(used);

// module.js
export const used = 'I am used';
export const unused = 'I am dead';

分析结果被记录在exportInfo._usedInRuntime属性中。对于上面的例子,used会被标记为已使用,unused则保持未使用状态。

第三阶段:生成代码

代码生成阶段,Webpack根据使用标记生成不同的代码:

// 如果导出被使用
__webpack_require__.d(__webpack_exports__, {
  "used": () => used
});

// 如果导出未被使用,仅保留定义,不生成导出语句
const unused = 'I am dead';

注意此时unused的定义语句仍然存在于bundle中——它变成了"死代码"(Dead Code),即不可能被执行到的代码,但尚未被物理删除。

第四阶段:删除死代码

最终由Terser(或UglifyJS)等压缩工具负责删除这些死代码。Terser会分析代码的控制流,发现unused变量从未被引用,于是将其定义语句一并移除。

这解释了一个常见困惑:为什么开启了optimization.usedExports: true,代码体积却没有明显减少?因为标记只是第一步,真正的"摇树"发生在压缩阶段。如果忘记开启mode: 'production'optimization.minimize: true,死代码永远不会被删除。

sideEffects标记的正确理解

sideEffects是package.json中的一个字段,用于告诉打包器哪些文件"纯净"(pure)到可以安全移除。但它的作用范围与usedExports完全不同。

usedExports工作在导出级别:分析模块的哪些导出被使用,未被使用的导出会被标记为死代码。

sideEffects工作在模块级别:如果一个模块的所有导出都未被使用,且该模块被标记为"无副作用",那么整个模块都可以被跳过,连带它依赖的子模块也不会被处理。

一个具体例子:

// @shopify/polaris/components/index.js
export { default as Breadcrumbs } from "./Breadcrumbs";
export { default as Button } from "./Button";
export { default as Card } from "./Card";

// package.json
{
  "sideEffects": ["**/*.css"]
}

当你只import { Button } from "@shopify/polaris"时:

  • Breadcrumbs.jsCard.js没有任何导出被使用
  • 这两个文件都不在sideEffects数组中,说明它们是纯净的
  • 打包器可以直接排除这两个文件及其所有依赖

这比usedExports高效得多,因为它可以跳过整个模块子树,而不仅仅是删除未使用的导出语句。

sideEffects配置的常见陷阱

过度乐观地设置sideEffects: false是最常见的错误。考虑这个UI组件库:

// components/Button.js
import './Button.css';  // 副作用!样式被注入页面
export default function Button() { /* ... */ }

// package.json
{
  "sideEffects": false  // 危险!
}

当用户只导入Button组件时,Webpack看到Button.js被使用了,会保留这个文件。但Button.css没有导出任何东西,且整个包被标记为sideEffects: false,于是CSS导入被当作无副作用代码删除了——组件虽然存在,但样式全部丢失。

正确的配置:

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

另一种隐蔽的陷阱是polyfill:

// index.js
import './polyfill';  // 修改全局对象,有副作用!
export * from './components';

// package.json
{
  "sideEffects": false  // polyfill会被删除!
}

解决方案是将polyfill单独列入副作用数组,或者干脆不要全局配置sideEffects,改用更保守的逐文件标记。

__PURE__注释:细粒度的副作用声明

/*#__PURE__*/是一个行级注释,用于告诉打包器紧随其后的表达式没有副作用。它的作用范围比sideEffects更细。

React高阶组件是典型场景:

// 原代码
const Button$1 = withAppProvider()(Button);

// 如果Button未被使用,这行代码应该被删除
// 但withAppProvider()可能有副作用,打包器不敢删

加上/*#__PURE__*/后:

const Button$1 = /*#__PURE__*/ withAppProvider()(Button);

这告诉打包器:如果Button$1未被使用,整个表达式可以安全删除。注意,注释只作用于函数调用本身,不作用于参数。如果Button的求值有副作用,它仍然会被保留。

Webpack从5.0版本开始支持optimization.innerGraph,默认在生产模式下启用。它能够分析模块内部符号之间的依赖关系:

import { something } from './something';

function usingSomething() {
  return something;
}

export function test() {
  return usingSomething();
}

innerGraph分析会发现:something只在test导出被使用时才需要。如果test未被外部引用,something的导入就可以被删除。这被称为"内部模块Tree Shaking"或"深度作用域分析"。

Barrel文件:一个被忽视的性能杀手

Barrel文件(桶文件)是指集中re-export多个模块的index.js文件:

// src/components/index.ts
export * from './Button';
export * from './Input';
export * from './Form';
export * from './Modal';
// ... 还有50个组件

这种模式看起来很优雅,用户可以这样导入:

import { Button } from '@my-lib';
// 而不是
import { Button } from '@my-lib/Button';

但它是Tree Shaking的天敌。有开发者做了实验:

  • 从barrel导入单个组件:210KB
  • 直接导入该组件:47KB

差距近5倍,原因在于barrel文件的存在让打包器难以判断哪些代码是真正被使用的。当你import { Button }时,打包器必须遍历整个index.ts,解析所有的re-export语句,而这个过程可能触发全部组件模块的加载和解析。

解决方案之一是在package.json中正确配置sideEffects

{
  "sideEffects": false
}

但这要求你对每个组件的副作用有完全的了解,否则会删除不该删的代码。

更可靠的方案是使用package.json的exports字段提供子路径导入:

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./Button": {
      "import": "./dist/Button.js",
      "types": "./dist/Button.d.ts"
    },
    "./Input": {
      "import": "./dist/Input.js",
      "types": "./dist/Input.d.ts"
    }
  }
}

这样用户可以直接从子路径导入,完全绕过barrel文件:

import { Button } from '@my-lib/Button';

动态导入:Tree Shaking的盲区

import()语法看起来像ES Module,但它本质上是动态的:

const moduleName = await import(someCondition ? './a' : './b');

打包器无法静态确定someCondition的运行时值,因此无法进行有效的Tree Shaking。Webpack对动态导入的处理方式是:保留整个模块,不分析其导出的使用情况。

这并不意味着动态导入场景下完全没有优化空间。Webpack 5引入了对某些CommonJS构造的Tree Shaking支持,能够识别以下模式:

exports.foo = 1;
exports.bar = 2;
// 如果只有foo被require().foo使用,bar可以被删除

但这种支持是有限的。当检测到无法分析的代码时,Webpack会直接放弃对该模块的导出追踪,以保守的方式确保正确性。

不同打包工具的Tree Shaking策略对比

Webpack、Rollup、esbuild三者的Tree Shaking策略存在显著差异,这种差异源于它们各自的设计目标。

Webpack强调正确性优先,采用三层优化:

  • 模块级:optimization.sideEffects移除无副作用且无导出使用的整个模块
  • 导出级:optimization.usedExports标记未使用的导出
  • 代码级:压缩工具执行DCE

Webpack的输出格式将每个模块包装在函数中,这使得某些优化(如跨模块变量名压缩)变得困难,但确保了模块加载和执行的正确分离。

Rollup追求极致的bundle大小,从AST节点级别进行Tree Shaking。它不仅能删除未使用的导出,还能删除对象中未使用的属性:

const obj = {
  a: { ab: 1 },
  b: 2
};
console.log(obj.a.ab);
// Rollup可以删除b属性,只保留a.ab

Rollup v4开始实验性地支持更细粒度的AST节点级Tree Shaking,代价是分析开销增加,构建速度变慢。

esbuild采用part-level(顶层语句级别)策略。它将每个模块的顶层语句分割成独立的part,只有被标记为live的part会被包含。这种设计天然解决了innerGraph问题——每个顶层语句都可以独立分析。

Turbopack则试图结合Webpack的runtime设计和esbuild的模块分割理念,允许在顶层语句级别进行代码分割,同时保持模块加载和执行的正确分离。

实践中的常见问题与解决方案

为什么配置了sideEffects,代码还是没被删除?

检查以下清单:

  1. Babel是否转换了ES Module? @babel/preset-env默认会将ES Module转换为CommonJS,这会让Tree Shaking完全失效。必须在配置中设置modules: false

  2. 是否在生产模式下构建? usedExportssideEffectsminimize等优化只在mode: 'production'时默认启用。开发模式下构建的bundle不会经过完整优化。

  3. 是否有隐藏的副作用? 某些代码看似无害,实则修改了全局状态:

// 这被认为是副作用
Array.prototype.customMethod = function() {};

// 这也是副作用
window.myGlobal = something;
  1. 是否使用了默认导出对象?
// 无法Tree Shake
export default {
  a: 1,
  b: 2
};

// 可以Tree Shake
export const a = 1;
export const b = 2;

如何验证Tree Shaking是否生效?

使用webpack-bundle-analyzer分析bundle,重点关注:

  • 未使用的模块是否出现在bundle中
  • 已使用模块中是否包含未使用的导出代码

另一个技巧是在构建产物中搜索特定字符串。如果export const unused = 'I should be removed'中的字符串出现在bundle里,说明Tree Shaking未生效。

lodash-es vs lodash

lodash提供CommonJS版本,lodash-es提供ES Module版本。理论上lodash-es应该支持Tree Shaking,但实际效果取决于使用方式:

// 不会Tree Shake
import _ from 'lodash-es';
_.debounce();

// 会Tree Shake
import { debounce } from 'lodash-es';
debounce();

有开发者报告lodash-es在某些情况下反而比lodash体积更大,原因是ES Module的包装开销。对于只需要少量工具函数的场景,直接从子路径导入更可靠:

import debounce from 'lodash/debounce';

写在最后

Tree Shaking不是魔法,它是一套依赖静态分析的优化机制。这套机制的效能取决于三个条件:

  • 代码必须是静态可分析的(使用ES Module)
  • 打包器必须准确知道哪些代码"纯净"(正确配置sideEffects)
  • 压缩工具必须完成最后的清理(启用production模式)

每当代码体积不符合预期时,问题几乎总是出在这三个条件的某一个上。理解原理比记住配置更重要,因为只有这样,当面对一个新的打包工具或一个诡异的问题时,你才能系统地排查和解决。

参考资料: