构建优化深度解析:Tree Shaking与Code Splitting原理实战
构建优化核心不是让打包器跑更快,而是让产物更小加载更快:Tree Shaking 删没用的代码,Code Splitting 按需加载代码。 两者配合能把首屏 JS 从 500KB 降到 100KB 内,面试常考 Tree Shaking 的前提(ESM 加无副作用)与动态 import 分包。
一句话概括
构建优化的核心不是让 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 配置项都管用。