Tree Shaking原理与工程化避坑指南

2026-07-15 13 min read 0

一、一个让我加班的真实场景

2024年3月,我接手一个React中台项目。生产构建产物2.1MB,首屏加载6.8秒。排查发现:

  • node_modules里一个UI组件库(antd 5.12.8)打包进了全部组件,实际只用了12个
  • 工具库lodash(4.17.21)用了3个函数,打包进去400KB
  • 业务代码里import { Button } from 'antd',但Button内部引用了Table、Form等无关模块

这就是典型的Tree Shaking失效场景。我花了3天时间,把包体积从2.1MB砍到680KB,构建时间从45秒降到12秒。下面直接说怎么做到的。

二、Tree Shaking到底怎么工作的

Tree Shaking本质是ES Module的静态分析。Webpack 5.88.0和Rollup 3.29.4都基于这个原理,但实现细节不同。

2.1 核心机制:标记-清除

分两步:

  • 标记阶段:从入口文件开始,遍历所有import/export语句,标记哪些导出被使用了
  • 清除阶段:删除未被标记的导出及其相关代码

关键限制:只能处理ES Module(import/export),CommonJS的require()无法静态分析。

2.2 Webpack 5的实现

Webpack使用内置的optimization.usedExports: truesideEffects配置。源码在lib/optimize/FlagDependencyExportsPlugin.jslib/optimize/RemoveEmptyChunksPlugin.js

标记阶段:Webpack在编译时通过HarmonyExportImportedSpecifierDependency类追踪每个export的使用情况。如果某个export没有被任何模块import,就标记为unused

清除阶段:TerserPlugin(基于terser 5.27.0)在压缩时删除未使用代码。注意:Webpack自己不删除代码,只做标记,删除交给压缩工具。

2.3 Rollup 3的实现

Rollup更激进。它在打包阶段直接分析AST,生成只包含被使用代码的bundle。源码在src/ast/variables/ExternalVariable.tssrc/ast/variables/LocalVariable.ts

Rollup的Tree Shaking是编译时完成的,不依赖后续压缩。所以Rollup的产物通常比Webpack更干净。

三、两种方案的对比

维度Webpack 5.88.0Rollup 3.29.4
标记机制运行时标记,依赖压缩工具删除编译时AST分析,直接生成精简代码
CommonJS支持部分支持(通过harmony模块)不支持,必须ES Module
sideEffects处理通过package.json配置通过package.json配置
产物大小(相同代码)1.2MB(未压缩)980KB(未压缩)
构建时间(1000模块)45秒28秒
配置复杂度中等(需配optimization和压缩)低(默认开启)

测试环境:Node 20.11.0,代码包含300个ES Module文件,每个文件导出5个函数,实际使用30%。

四、完整工程化配置

4.1 Webpack 5配置

// webpack.config.js (Webpack 5.88.0)
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    clean: true
  },
  optimization: {
    // 开启Tree Shaking标记
    usedExports: true,
    // 开启模块合并(副作用优化)
    sideEffects: true,
    // 压缩配置
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            // 删除未使用的代码
            unused: true,
            // 删除死代码
            dead_code: true,
            // 删除console.log(生产环境)
            drop_console: true
          },
          mangle: true,
          output: {
            comments: false
          }
        },
        // 启用多线程压缩
        parallel: true
      })
    ],
    // 代码分割
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      }
    }
  },
  // 告诉Webpack哪些模块有副作用
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { modules: false }], // 保留ES Module
              '@babel/preset-react'
            ]
          }
        }
      }
    ]
  }
};

4.2 Rollup 3配置

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

export default {
  input: 'src/index.js',
  output: [
    {
      dir: 'dist',
      format: 'esm',
      entryFileNames: '[name].[hash].js',
      // 保留模块结构,有利于Tree Shaking
      preserveModules: true
    },
    {
      dir: 'dist/cjs',
      format: 'cjs',
      entryFileNames: '[name].[hash].js'
    }
  ],
  plugins: [
    nodeResolve({
      // 只解析ES Module
      extensions: ['.mjs', '.js', '.jsx']
    }),
    commonjs({
      // 将CommonJS转为ES Module
      include: /node_modules/
    }),
    babel({
      babelHelpers: 'bundled',
      presets: [
        ['@babel/preset-env', { modules: false }],
        '@babel/preset-react'
      ]
    }),
    terser({
      compress: {
        unused: true,
        dead_code: true,
        drop_console: true
      }
    })
  ],
  // 外部依赖(不打包进bundle)
  external: ['react', 'react-dom']
};

4.3 package.json配置

{
  "name": "my-app",
  "version": "1.0.0",
  "sideEffects": [
    "*.css",
    "*.scss",
    "*.less",
    "src/polyfills.js",
    "src/global.js"
  ],
  "module": "src/index.js",
  "main": "dist/cjs/index.js"
}

sideEffects字段告诉打包工具:除了列出的文件,其他模块都没有副作用,可以安全删除未使用的导出。

4.4 业务代码优化示例

// 错误写法:导入整个库
import _ from 'lodash'; // 打包全部400KB
_.debounce(fn, 300);

// 正确写法:按需导入
import debounce from 'lodash/debounce'; // 只打包20KB
debounce(fn, 300);

// 或者使用lodash-es(ES Module版本)
import { debounce } from 'lodash-es'; // 同样只打包20KB
// 错误写法:导入整个组件库
import { Button } from 'antd'; // 打包全部antd

// 正确写法:按需导入组件
import Button from 'antd/es/button';
import 'antd/es/button/style'; // 如果使用less

// 或者配合babel-plugin-import自动转换
// .babelrc配置
{
  "plugins": [
    ["import", {
      "libraryName": "antd",
      "libraryDirectory": "es",
      "style": "css"
    }]
  ]
}

4.5 验证Tree Shaking是否生效

# 使用webpack-bundle-analyzer分析产物
npm install --save-dev webpack-bundle-analyzer

# webpack.config.js添加
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
plugins: [
  new BundleAnalyzerPlugin({
    analyzerMode: 'static',
    reportFilename: 'report.html'
  })
]

# 构建后查看report.html,检查未使用的模块是否被移除
npm run build

五、效果数据

指标优化前优化后提升
总包体积2.1MB680KB67.6%
首屏JS体积1.5MB420KB72%
构建时间45秒12秒73.3%
首屏加载时间(3G网络)6.8秒2.1秒69.1%
Lighthouse Performance45分92分+47分

测试环境:MacBook Pro M1 Pro 16GB,Node 20.11.0,Webpack 5.88.0,Chrome 122。网络模拟:Fast 3G(750ms延迟,750kbps下行)。

六、避坑指南(我踩过的3个坑)

坑1:Babel把ES Module转成CommonJS

现象:配置了Tree Shaking,但包体积没变化。

原因:@babel/preset-env默认把ES Module转成CommonJS,Tree Shaking无法处理CommonJS。

解决:在babel配置中设置modules: false,保留ES Module语法。

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

坑2:sideEffects配置遗漏CSS文件

现象:构建后样式丢失,页面布局错乱。

原因:CSS文件有副作用(全局样式),但package.json的sideEffects没包含.css文件,打包工具认为CSS是未使用的导出,直接删除了。

解决:在sideEffects中明确列出所有有副作用的文件类型。

{
  "sideEffects": [
    "*.css",
    "*.scss",
    "*.less",
    "src/polyfills.js"
  ]
}

坑3:动态import导致Tree Shaking失效

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

原因:动态import是运行时执行的,打包工具无法静态分析哪些导出会被使用。

解决:动态加载的模块内部也使用按需导入,或者改用静态import配合代码分割。

// 错误:动态加载整个模块
const module = await import('./heavyModule');
module.doSomething();

// 正确:动态加载时只导入需要的函数
const { doSomething } = await import('./heavyModule');
doSomething();

七、总结

Tree Shaking不是银弹。它依赖ES Module的静态结构,需要配合正确的配置和代码写法。核心三点:

  • 确保Babel不转译ES Module(modules: false)
  • 正确配置sideEffects
  • 业务代码使用按需导入

做到这三点,包体积至少能砍掉60%。

IT搬运工

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

分类
链接
订阅

RSS 订阅关注最新文章

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