文章

构建优化深度解析:Tree Shaking与Code Splitting原理实战

构建优化核心不是让打包器跑更快,而是让产物更小加载更快:Tree Shaking 删没用的代码,Code Splitting 按需加载代码。 两者配合能把首屏 JS 从 500KB 降到 100KB 内,面试常考 Tree Shaking 的前提(ESM 加无副作用)与动态 import 分包。

构建优化深度解析:Tree Shaking与Code Splitting原理实战

一句话概括

构建优化的核心不是让 webpack 跑得更快,而是让产物体积更小、加载更快——Tree Shaking 负责”删掉没用的代码”,Code Splitting 负责”按需加载代码”,两者配合才能把 500KB 的首屏 JS 降到 100KB 以内。

核心知识点

1. Tree Shaking 的三个前提条件

Tree Shaking 不是开箱即用的功能,需要同时满足三个条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 条件1:使用 ES Module(静态 import/export)
// ✅ webpack 能静态分析
import { Button } from './ui';
export const add = (a, b) => a + b;

// ❌ CommonJS 无法 tree-shake
const { Button } = require('./ui');
module.exports = { add };

// 条件2:babel 不转译模块语法
// babel 配置中必须设置 modules: false,让 webpack 处理 ES Module
{
  "presets": [["@babel/preset-env", { "modules": false }]]
}

// 条件3:package.json 声明 sideEffects
{
  "name": "my-lib",
  "sideEffects": ["*.css", "./src/polyfill.js"]
  // sideEffects: false → 所有模块都可安全 tree-shake
}

一旦 babel 把 import 转成 require,Tree Shaking 就彻底失效了——这是最常见的坑。

2. Tree Shaking 失效的三种经典场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 场景1:库的入口用对象重导出(被 babel 转成 CJS 后)
// antd v3 时代:import { Button } from 'antd' → 打包整个 antd
// antd v4 后修复:支持 ES Module + sideEffects 声明
// 现在的解法:确保 node_modules 的库支持 ESM,或用 babel-plugin-import 按需引入

// 场景2:有副作用但没声明
// ThemeProvider 内部 import './global.css' → 有副作用
// 如果 package.json 没声明,webpack 不敢删 ThemeProvider
// 最终即使只 import Button,整个包都进来

// 场景3:动态属性访问
export const config = { a: 1, b: 2, c: 3 };
// 使用时:config[someDynamicKey] → webpack 无法判断用了哪些属性 → 全保留
// ✅ 改为:导出单独变量而不是整个对象

3. Code Splitting 三层策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 级别1:路由级分割(最常用,首屏收益最大)
const HomePage = lazy(() => import(/* webpackChunkName: "home" */ './pages/Home'));
const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ './pages/Profile'));
// 首屏只加载 HomePage 的代码,ProfilePage 的代码点击时才下载

// 级别2:SplitChunks 公共模块提取(减少重复打包)
// webpack.config.js
{
  optimization: {
    splitChunks: {
      chunks: 'all',     // 关键:同步和异步都分割
      cacheGroups: {
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'vendor-react',    // React 单独打包 → 浏览器缓存复用
          priority: 20
        },
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor-common',
          priority: 10,
          minChunks: 2              // 至少被 2 个页面引用才提取
        }
      }
    }
  }
}

// 级别3:组件级懒加载(重交互组件按需下载)
const HeavyChart = lazy(() => import('./HeavyChart'));
// 用户点击"显示图表"时才下载 echarts(200KB+),首屏完全不加载

4. 构建速度优化:换编译器是最快的优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// webpack.config.js
{
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        exclude: /node_modules/,
        // ❌ babel-loader:纯 JS 实现,大型项目构建 120s
        // use: 'babel-loader'

        // ✅ swc-loader:Rust 实现,快 10-20 倍 → 约 12s
        use: {
          loader: 'swc-loader',
          options: { jsc: { parser: { syntax: 'typescript', tsx: true } } }
        }
      }
    ]
  },
  // 持久化缓存:二次构建从 90s 降到 2s
  cache: { type: 'filesystem' }
}

投资回报排序:swc/esbuild 替换 babel > 持久化缓存 > 并行压缩 > 缩小 loader 范围

5. 产物体积分析三步走

1
2
3
4
5
6
7
8
9
10
11
12
# 1. 生成体积分析报告(可视化饼图)
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json

# 2. 用 source-map-explorer 看具体哪个模块占空间
npx source-map-explorer dist/js/*.js

# 3. 常见"体积刺客"排查清单:
#    - moment.js(自带所有 locale → 换 dayjs,体积 1/10)
#    - lodash(全量引入 → 用 lodash-es 或直接 import lodash/debounce)
#    - antd icons(全量注册 → 按需引入 @ant-design/icons)
#    - 重复打包(同一库的不同版本 → 用 yarn why 排查)

其实你每天都在用

  • 路由懒加载:你用 React Router 时写的 lazy(() => import('./xxx')) 就是在做 Code Splitting——首屏不加载其他页面的代码
  • SplitChunks:你访问电商网站时第二次打开比第一次快得多——因为 vendor-react.chunk.js 等公共 chunk 已被浏览器缓存
  • Tree Shaking:你在 package.json 里加 "sideEffects": false 或 ["*.css"] 就是在告诉 webpack”其他模块没副作用,放心删”
  • contenthash 文件名:你部署后改了一行代码用户不用重新下载整个 bundle——因为只有内容变化的那个 chunk 的 contenthash 变了
  • exclude: /node_modules/:你写的这条规则就是在告诉 babel”别碰 node_modules,里面的库已经编译好的”

常见误解(FAQ)

❌ 误区:Tree Shaking 能移除所有未使用的代码

Tree Shaking 只能移除”可以被静态分析确定未使用”的代码。动态 require、字符串拼接的模块路径(import('./dir/' + name))、通过对象属性间接访问、以及带有副作用的代码(如 polyfill 修改原型)都无法被移除。

❌ 误区:加大 TerserPlugin 的 passes 参数一定更好

passes: 3 会比 passes: 1 多压缩 2-3%,但压缩时间可能是 3 倍。除非你在做极致体积优化,否则 passes: 1 就够了。相比调压缩参数,先排查有没有把整个 moment.js 打进去收益大得多。

❌ 误区:Code Splitting 拆得越细越好

每个 chunk 都有约 200 字节的 webpack 运行时开销 + HTTP 请求开销。拆成 100 个 2KB 的 chunk 比 5 个 40KB 的 chunk 更慢。通常 5-15 个初始 chunk 是最优区间。

❌ 误区:filesystem cache 设置后就不会出问题

cache: { type: 'filesystem' } 的缓存可能因为 Node 版本升级、依赖变化、甚至磁盘空间不足而损坏。如果构建出现奇怪的错误(模块找不到、类型不匹配),先试试删掉 node_modules/.cache/webpack 重新构建。

一句话总结

构建优化的真相是”少写比少打包更重要,少打包比少压缩更重要”——选对库(dayjs 替代 moment)、用对引入方式(ESM + 按需引入)、配好 SplitChunks,这三点比调 10 个 webpack 配置项都管用。

本文由作者按照 CC BY 4.0 进行授权