一、为什么你的Tree Shaking没生效

很多开发者都遇到过这种情况:明明只引入了几个函数,打包后的文件却体积很大,里面全是没用的代码。你检查了Webpack配置,也加了sideEffects: false,但死代码就是没被删掉。问题很可能出在两个地方——副作用标记和模块的导出方式。这两个东西看起来简单,但用错了能让Tree Shaking完全罢工。

Tree Shaking本身是Webpack用来删除没有被用到的代码的一种技术。它会把你的代码当成一棵树,摇一摇,那些没被引用的“枯枝败叶”就会掉下来。但前提是,Webpack得知道哪些代码是“干净”的,不会有副作用。副作用在这里指的是,一个模块在加载时除了导出变量,还干了别的事情,比如修改全局变量、调用某个API、或者执行了一段逻辑。一旦有副作用,Webpack就不敢随便删,因为它不确定这段代码会不会影响程序的正常执行。

二、副作用标记——给Webpack的“安全声明”

2.1 什么是sideEffects

package.json里加一个"sideEffects": false,是告诉Webpack:“我这个包里的所有模块都是纯导出,没有任何副作用,你随便删。”反过来,如果某个文件确实有副作用,就需要把它列出来,比如"sideEffects": ["./src/polyfill.js"],这样Webpack就会保留那个文件。

很多开发者以为只要加了sideEffects: false就万事大吉,但实际上,如果你的模块内部有隐含的副作用,比如在模块顶层调用了某个函数,即便那个函数是导出的,也可能被Tree Shaking错误地保留或删除。

2.2 一个常见的坑:副作用标记与文件中的全局代码

来看一个例子。假设我们有这样一个工具库,技术栈是JavaScript:

// src/utils.js
// 这个模块导出了两个函数,但顶部有一行代码会影响全局
Array.prototype.customMethod = function() {
  console.log('custom');
};

export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

然后在主入口里只用了add

// src/index.js
import { add } from './utils';
console.log(add(1, 2));

如果我们把package.json写成这样:

{
  "sideEffects": false
}

Webpack会认为整个utils.js都没有副作用,于是它会把add留下来,把multiply删除。但是,注意:上面那段修改Array原型的方法Array.prototype.customMethod = ...是副作用,它在模块加载时就会执行。如果utils.js没有被任何地方导入,那这段代码会被安全删除;但是如果utils.js被导入了(哪怕只用了add),Webpack会认为“既然你导入了这个模块,我就得执行它所有的顶层代码”。因为修改原型是副作用,而你把sideEffects设为false,Webpack会直接忽略这个副作用,认为它不存在,导致实际上原型被修改了但代码被保留了?等等,不对。

正确的逻辑是:当sideEffects: false时,Webpack会尝试删除那些未被使用的导出,但模块的顶层代码(副作用)仍然会被保留,因为Webpack不知道这个副作用到底有没有影响。实际上,对于有副作用的顶层代码,即使sideEffects: false,Webpack也不会删除它,因为它会执行模块的顶层代码。但这里有一个微妙之处:如果你设置了sideEffects: false,Webpack可能会对整个模块进行死代码消除,包括顶层副作用?不,实际上,sideEffects标记控制的是“该模块是否有副作用”,如果标记为false,Webpack可以假设删除整个模块(如果没有任何导出被使用)是安全的。但如果你导入了模块的一部分,模块的顶层代码仍然会执行,因为导入行为本身就会触发模块加载。

所以上面那个例子中,utils.js被从index.js导入,那么即使只用了add,顶层代码Array.prototype.customMethod也会被执行。这是符合预期的。而sideEffects: false在这里的影响是:假如你没有导入任何东西(比如只是import './utils'),但因为副作用标记为false,Webpack可能会认为这个导入没有意义而整个删除它,导致原型修改失效。这是一个常见的坑。

因此,副作用标记的正确用法是:只在那些确实没有副作用的模块上设置false。如果有副作用,一定要明确列出。

三、模块导出方式对Tree Shaking的影响

3.1 默认导出 vs 命名导出

JavaScript的模块导出有两种主要形式:export default(默认导出)和export(命名导出)。Webpack对这两种的处理方式不同,直接影响死代码能否被清除。

默认导出会把整个模块的内容打包成一个对象,然后把这个对象导出。当你导入这个默认导出时,你得到的是一整个对象,Webpack很难从中剥离掉未使用的部分。除非你在导入时使用解构,但即使解构,Webpack的Tree Shaking依然可能失效。

命名导出则不同,它是将每个导出成员作为单独的变量导出。Webpack可以更精确地追踪到哪些变量被使用了,从而安全地删除未使用的。

举个例子:

// src/math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

export default function multiply(a, b) {
  return a * b;
}

现在在主入口里:

// src/index.js
import multiply from './math'; // 只使用默认导出
console.log(multiply(2, 3));

虽然我们只用了multiply,但是addsubtract这两个命名导出也被打包进来了吗?不一定。对于命名导出,Webpack可以单独删除未被引用的addsubtract。但这里有一个问题:math.js的顶层代码除了导出声明没有其他副作用,所以addsubtract可以被删除。但默认导出是直接导出一个函数,这个函数本身没有被树摇掉的问题。实际上,在这个例子中,未使用的命名导出会被Tree Shaking掉,打包体积会减小。

但如果我们换成这样:

// src/math.js
export default {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b
};

然后这样导入:

// src/index.js
import math from './math';
console.log(math.add(2, 3));

这时整个对象都被导入,即使只用了addsubtractmultiply依然会存在于打包结果中。因为math是一个对象,对象属性无法被静态分析(除非使用解构并配合Webpack的optimization.usedExports,但依然有限制)。这就是为什么推荐尽可能使用命名导出。

3.2 混合导出时的陷阱

有时候我们会看到这样的写法:

// src/foo.js
export function foo() {}
export function bar() {}
export default foo;

然后在别处:

// src/index.js
import { bar } from './foo';
console.log(bar());

这里默认导出foo没有被使用,但是因为默认导出本身也是一个命名导出(实际上,export default foo会导出一个名为default的变量),Webpack会保留foo吗?可能会,因为foo被当作默认导出的值,而默认导出被使用了?不,这里我们用的是命名导入{ bar },没有使用默认导出。那么foo是否会被保留?取决于Webpack的处理。实际上,Webpack会把default作为一个额外的导出,如果没有任何地方导入default,那么foo函数本身(作为值)可以被删除,但注意:foo是被export default foo;引用的,所以如果foo本身没有被其他地方直接引用,它可能会被当作死代码删除。但这里foo函数定义了一个函数声明,它本身没有副作用,所以可以删除。不过,如果foo函数内部有副作用(比如调用了全局API),那就要小心了。

一个更常见的问题是:使用export default导出一个对象,而这个对象里引用了其他函数,比如:

// src/helper.js
export function utilA() {}
export function utilB() {}
export default {
  utilA,
  utilB
};

然后在主入口里只用了utilB

// src/index.js
import { utilB } from './helper';
utilB();

这时,虽然命名导出utilA没有被使用,但因为它也被包含在默认导出对象中,而默认导出对象本身没有被任何地方导入(我们只用了命名导入),所以utilA应该会被删除。但等一下,默认导出对象是作为一个整体存在的,utilA作为对象的一个属性,会不会被保留?Webpack会分析:export default { utilA, utilB }这个对象字面量创建了一个新对象,并引用了utilAutilB。如果这个默认导出没有被任何地方使用,那么整个对象字面量和它引用的函数都不会被保留。但这里我们只用了utilB的命名导出,默认导出没有被使用,所以对象字面量不会被保留,utilA也相应地被删除。这是正确的行为。

但如果我们在另一个文件中使用了默认导出的一部分(比如解构),那又不同了。所以了解这些细节很重要。

四、实际案例:一步步排查Tree Shaking失效

让我们用JavaScript写一个完整的示例来演示如何排查。假设我们有一个小项目,目录结构如下:

my-app/
├── package.json
├── webpack.config.js
├── src/
│   ├── index.js
│   └── math.js

4.1 初始化项目并编写代码

先看math.js,它包含一些数学函数,还有一个副作用:

// src/math.js
// 这个模块有副作用:在全局上添加一个方法
Math.__myFlag = true;

export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

// 这是一个默认导出对象,包含了两个函数
export default {
  add,
  multiply: (a, b) => a * b
};

注意:这里默认导出的multiply是一个箭头函数,单独定义了,而add是引用了同名的命名导出。另外,顶层代码Math.__myFlag = true是一个副作用。

然后index.js

// src/index.js
// 只导入命名导出中的add,忽略默认导出和其他命名导出
import { add } from './math';

console.log(add(1, 2));

4.2 配置Webpack

我们使用Webpack 5,开启production模式,它会自动启用Tree Shaking。

// package.json
{
  "name": "tree-shaking-demo",
  "version": "1.0.0",
  "sideEffects": false,
  "scripts": {
    "build": "webpack --mode production"
  },
  "devDependencies": {
    "webpack": "^5.0.0",
    "webpack-cli": "^4.0.0"
  }
}
// webpack.config.js
const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  // 不需要额外配置,mode: production 会启用 Tree Shaking
};

4.3 构建并分析

执行npm run build,打开dist/bundle.js查看结果。你会看到类似这样的代码(经过压缩):

(()=>{"use strict";Math.__myFlag=!0;console.log((1,2))})();

注意:Math.__myFlag = true仍然存在,因为它是副作用,而我们在package.json里设置了sideEffects: false,但副作用代码依然保留。为什么?因为Webpack知道这个模块被导入了,它必须执行顶层代码。但实际上,如果我们没有导入这个模块的任何东西,它就会被完全删除。但这里我们导入了add,所以副作用代码保留是正确的。

但是,我们只用了add函数,为什么subtract和默认导出里的multiply没有被删除?检查打包结果:add函数出现在哪里?实际上,上面结果显示没有add函数,而是直接内联了add(1,2)1+2?因为add是非常简单的函数,Webpack可以内联它。但注意,subtractmultiply并没有出现在打包结果中,说明Tree Shaking成功删除了它们。这个例子完美展示了:命名导出add被保留了(并内联),未使用的命名导出subtract被删除,默认导出对象由于没有被任何地方导入,也被整个删除。所以结果是正确的。

但是,假如我们修改一下,让默认导出被使用:

// src/index.js
import math from './math'; // 导入默认导出
console.log(math.add(1, 2));

现在构建结果会包含整个默认导出对象,包括multiply(即使没有被使用)。因为默认导出是一个整体,Webpack无法从对象中单独删除属性。这是典型的Tree Shaking失效场景。

4.4 如何解决

方案很简单:避免使用默认导出对象,只用命名导出。或者,如果必须使用默认导出,就确保它只包含一个函数,而不是多个属性。另一种方式是,在读取默认导出时使用解构,比如:

import { add, multiply } from './math'; // 直接命名导出
console.log(add(1,2));

这样multiply如果未被使用,会被删除。

另外,关于副作用标记:如果模块确实有副作用,要明确列出。比如上面的Math.__myFlag = true应该被标记为副作用。修正package.json

{
  "sideEffects": ["./src/math.js"]
}

或者,如果这个副作用只在这个模块被导入时才需要,那么保留也行。一般来说,第三方库会在package.json里设置"sideEffects": false,前提是它们确保所有顶层代码都是无副作用的。如果你自己写的代码有副作用,不要轻易设false。

五、应用场景与优缺点

5.1 应用场景

  • 工具库开发:比如你要发布一个npm包,包含几十个工具函数。你应该使用命名导出,并在package.json中设置sideEffects: false(确认没有副作用)。这样使用方只引入一个函数时,其余函数会被树摇掉,减小包体积。
  • 大型项目优化:项目中存在大量未被引用的代码(比如遗留的组件、工具函数),通过Tree Shaking可以自动清理,减少首屏加载时间。
  • 按需加载组件库:很多UI库(如antd)现在都支持按需加载,背后就是依赖Tree Shaking和模块的副作用标记。

5.2 优点

  • 自动减小打包体积:开发者只需遵循规范,无需手动删除代码。
  • 降低维护成本:你可以放心地在项目中保留所有功能代码,打包时只会包含用到的部分。
  • 与ES Module天然配合:现代JavaScript标准就是基于静态分析,Tree Shaking是这一优势的直接体现。

5.3 缺点

  • 对动态导入或CommonJS模块无效:比如requireimport()等动态表达式无法被静态分析。
  • 副作用判断依赖开发者自觉:如果模块有副作用但没有标记,可能导致生产环境出现bug(代码被误删)。
  • 默认导出对象无法被精细删除:这是设计上的局限,需要使用者注意。

六、注意事项与最佳实践

  1. 尽量使用命名导出:不要为了省事把所有东西塞到一个默认导出对象里。每个功能单独导出,让Webpack能精确追踪。
  2. 在package.json中正确声明sideEffects:如果你的模块没有任何副作用(比如修改原型、全局变量、执行类初始化等),就设为false。如果不确定,可以设为true(即所有模块都有副作用),这样Webpack会保守处理,但会失去一些优化。
  3. 避免模块顶层有副作用的代码:如果需要初始化,最好放在一个函数里,或者使用条件判断。例如,不要直接在顶层写window.addEventListener(...),而是封装成一个init函数,只在需要时调用。
  4. 使用Webpack的optimization.usedExports:在development模式下,这个选项默认开启,可以帮助你看到哪些导出被认为已使用。你可以在输出中看到注释标记。
  5. 检查打包产物:定期用webpack-bundle-analyzer或直接查看生成的文件,确认死代码是否被删除。如果发现意外保留的代码,逐一排查副作用和导出方式。
  6. 对于第三方库:确保导入方式匹配库的导出设计。一个常见的错误是:一个库使用CommonJS(如module.exports),而你用ESM导入,这样Tree Shaking很可能失效。

七、总结

Tree Shaking是一个强大的工具,但它不是魔法。它的生效依赖于几个前提:你的代码必须是ES Module、没有副作用、且导出方式便于静态分析。副作用标记和模块导出方式是两个最容易忽视的坑。错误地将有副作用的模块标记为无副作用,或者使用默认导出对象,都可能导致死代码消除失败。通过本文的分析和示例,你应该能更清楚如何排查和预防这些问题。记住,当你下次发现打包体积异常时,先检查sideEffects设置,再审视导出方式,往往就能找到症结所在。