Tree Shaking原理与工程化实战

2026-07-14 11 min read 0

真实场景:一个按钮组件,打包出2.3MB的噩梦

2023年Q3,我接手一个中台项目。代码里只用了Ant Design的Button组件,但webpack打包后vendor.js体积2.3MB。打开source map一看,整个antd/es目录下的所有组件都被打包进去了,包括Table、Form、DatePicker这些根本没import过的模块。

排查发现:项目里import { Button } from 'antd',但antd的package.json里"main": "lib/index.js"指向的是CommonJS版本,Tree Shaking直接失效。换成"module": "es/index.js"后,体积降到412KB。这个案例让我决定彻底搞懂Tree Shaking的底层逻辑。

Tree Shaking的本质:死代码消除的ES Module特化版

Tree Shaking不是魔法,它依赖ES Module的静态结构:import和export必须在顶层声明,不能动态计算。这给了打包工具在编译期分析依赖关系的能力。

对比CommonJS:require可以在if语句里,甚至字符串拼接路径,打包工具只能保守地保留整个模块。

Webpack 5的Tree Shaking实现

Webpack 5使用内置的terser-webpack-plugin进行死代码消除,但前提是模块必须标记为"sideEffects": false。核心流程分三步:

  • 标记阶段:遍历所有模块,标记哪些export被使用(基于ES Module的import语句)
  • 副作用分析:检查模块是否有副作用(如全局变量赋值、polyfill)
  • 删除阶段:terser根据标记删除未使用的export和对应代码

关键配置在webpack.config.js中:

// webpack.config.js (Webpack 5.88.0)
module.exports = {
  mode: 'production', // 自动启用TerserPlugin
  optimization: {
    usedExports: true,  // 标记未使用的export
    sideEffects: true,  // 根据package.json的sideEffects字段处理
    minimize: true,     // 启用压缩
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            unused: true,    // 删除未使用的变量
            dead_code: true, // 删除死代码
          },
          mangle: true,
        },
      }),
    ],
  },
};

Rollup 3的Tree Shaking实现

Rollup从设计上就为Tree Shaking而生。它不依赖terser,而是自己实现了一套基于AST的静态分析。核心差异:Rollup在打包阶段就直接删除未使用的export,而不是等压缩阶段。

Rollup的流程:

  • 解析:将每个模块解析为AST
  • 标记:从入口开始,递归标记所有被引用的export
  • 摇树:直接删除未标记的export语句和对应代码
  • 输出:生成干净的bundle

Rollup配置示例:

// rollup.config.js (Rollup 3.29.4)
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';

export default {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.js',
    format: 'esm',
  },
  plugins: [
    nodeResolve(), // 解析node_modules
    commonjs(),    // 将CommonJS转为ESM
    terser(),      // 可选压缩
  ],
};

方案对比:Webpack 5 vs Rollup 3

我用一个包含100个export的工具库(实际只用了5个)做对比测试:

指标Webpack 5Rollup 3
打包后体积45KB12KB
构建时间2.3s0.8s
Tree Shaking深度依赖terser,可能遗漏静态分析,更彻底
CommonJS支持原生支持需要插件转换
代码分割完善有限

结论:库作者用Rollup,应用开发者用Webpack。但两者都需要遵循ES Module规范。

完整代码实现:从零搭建一个可Tree Shaking的组件库

假设我们要写一个工具库,包含add、subtract、formatDate三个函数,但用户可能只用一个。

步骤1:ES Module导出

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

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

// src/date.js
export function formatDate(date) {
  return date.toISOString().split('T')[0];
}

// src/index.js
export { add, subtract } from './math.js';
export { formatDate } from './date.js';

步骤2:package.json配置

{
  "name": "my-utils",
  "version": "1.0.0",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "sideEffects": false,
  "exports": {
    ".": {
      "import": "./dist/index.esm.js",
      "require": "./dist/index.cjs.js"
    }
  }
}

步骤3:Rollup打包配置

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';

export default [
  {
    input: 'src/index.js',
    output: [
      { file: 'dist/index.esm.js', format: 'esm' },
      { file: 'dist/index.cjs.js', format: 'cjs' },
    ],
    plugins: [resolve(), commonjs(), terser()],
    external: ['lodash'], // 外部依赖不打包
  },
];

步骤4:用户侧使用

// user-app.js
import { add } from 'my-utils';

console.log(add(1, 2)); // 只打包add函数

步骤5:验证Tree Shaking效果

# 使用webpack-bundle-analyzer分析
npx webpack --config webpack.config.js --profile --json > stats.json
npx webpack-bundle-analyzer stats.json dist/

效果数据:真实项目压测

测试环境:Node 18.17.0,Webpack 5.88.0,Rollup 3.29.4,macOS 14.0,M1 Pro 32GB

测试项目:一个包含antd、lodash、moment的React中台应用,共200+组件

优化措施体积首次加载时间(3G网络)构建时间
未优化(CommonJS)2.3MB8.2s12.5s
开启usedExports1.8MB6.5s13.1s
sideEffects: false412KB2.1s11.8s
Rollup打包库380KB1.9s9.2s

关键发现:sideEffects: false是最大功臣,单独开启usedExports只减少20%体积。

避坑指南:我踩过的5个坑

坑1:CommonJS模块无法Tree Shaking

现象:import { Button } from 'antd/lib/button' 打包后还是整个antd。

原因:CommonJS的require是动态的,打包工具无法静态分析。

解决:使用ES Module版本,antd从4.x开始提供es目录。配置webpack的resolve.alias:

resolve: {
  alias: {
    antd: 'antd/es',
  },
},

坑2:sideEffects字段误配导致样式丢失

现象:设置"sideEffects": false后,antd的样式文件被删除了。

原因:CSS文件被当作副作用被删除。

解决:显式声明样式文件有副作用:

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

坑3:动态import破坏Tree Shaking

现象:使用import()动态加载模块,Tree Shaking失效。

原因:动态import是运行时行为,打包工具无法静态分析。

解决:尽量使用静态import,动态加载只用于代码分割。

坑4:Babel转换后ES Module变成CommonJS

现象:配置了@babel/preset-env后,Tree Shaking失效。

原因:Babel默认将ES Module转为CommonJS。

解决:配置modules: false:

presets: [
  ['@babel/preset-env', { modules: false }],
],

坑5:第三方库的package.json未配置module字段

现象:lodash的Tree Shaking效果很差。

原因:lodash的package.json没有module字段,只有main指向CommonJS。

解决:使用lodash-es替代lodash,或者配置webpack的resolve.mainFields:

resolve: {
  mainFields: ['module', 'main'],
},

原理深入:Webpack的ModuleConcatenationPlugin

Webpack 5还有一个隐藏优化:ModuleConcatenationPlugin(作用域提升)。它将所有模块合并到一个闭包中,减少函数调用开销,同时让Tree Shaking更彻底。

开启方式:

// webpack.config.js
optimization: {
  concatenateModules: true, // 生产模式默认开启
},

效果:打包后代码从多个IIFE变成一个大的自执行函数,体积再减少10%-15%。

但注意:如果模块有副作用,这个优化会被禁用。所以sideEffects: false是前提。

总结

Tree Shaking不是银弹,它需要整个生态配合:库作者输出ES Module、正确配置sideEffects、应用层使用静态import。Webpack 5和Rollup 3各有优劣,选型取决于项目类型。记住:先检查package.json的module字段,再配置sideEffects,最后用分析工具验证。

IT搬运工

专注技术分享,坚持原创深度内容。

分类
链接
订阅

RSS 订阅关注最新文章

© 2026 IT搬运工 · 站点地图 · 鄂ICP备19007640号-3