打包优化
问题
如何优化前端项目的打包体积和构建速度?Tree Shaking、代码分割的原理是什么?
怎么优化打包体积和构建速度? 先区分「产物体积、开发反馈、生产加载」三个目标,并用构建统计和用户数据定位瓶颈:
- 减体积:删除无用代码和重复依赖,检查客户端边界,按路由或高成本功能拆包,再考虑替换依赖。
- 提速度:比较冷启动、热更新和完整构建耗时;合理使用持久化缓存、缩小处理范围,并在兼容性允许时使用 SWC、esbuild 等工具链。
- 优加载:控制首屏必需 JavaScript,按真实用户旅程加载;
prefetch、preload和手动分包都要结合瀑布图验证,避免过度分包和抢占带宽。 - 设预算:例如在中端移动设备、冷缓存和 4G 基线下,可以先把关键路由首屏 JS 的 gzip 体积预算设为 100KB,再根据业务复杂度和 RUM 数据调整。
- 找证据:使用
webpack-bundle-analyzer、构建产物可视化和 source map 分析,持续比较每次变更带来的绝对值与增量。
Tree Shaking 和代码分割的原理? 两者都靠 ESM 静态分析:
- Tree Shaking:基于 ES Module 的静态结构分析导入、导出和副作用,再由压缩阶段删除不可达代码。CommonJS 的动态特性会限制静态分析,但具体能优化多少取决于打包器和包的写法,不能简单说成完全无效。
- 生效条件:优先发布和消费 ESM;正确声明
sideEffects;避免模块顶层执行不可删除的副作用;生产构建启用 DCE。sideEffects: false只有在包内模块确实无副作用时才能使用。 - 代码分割:用动态
import('./xxx')在调用处生成 chunk 边界,运行时按需异步加载,配合路由懒加载和 React/Vue 的lazy+Suspense。 - 易踩坑:副作用代码(polyfill、CSS import)要在
sideEffects数组里白名单,不然会被错误删除。
答案
打包优化是前端性能优化的重要环节,主要从减少体积、提升构建速度、优化加载三个方向进行。
优化目标
| 目标 | 指标 | 优化方向 |
|---|---|---|
| 减少体积 | 按路由设置 gzip/Brotli 预算;例如关键路由 JS < 200KB | Tree Shaking、依赖治理、压缩、分包 |
| 提升构建速度 | 按团队基线设置;例如开发机冷启动 < 10s | 缓存、缩小范围、增量构建 |
| 优化加载 | 按设备和网络基线设置;例如首屏必需 JS < 100KB(gzip) | 客户端边界、代码分割、懒加载 |
Tree Shaking
Tree Shaking 是移除 JavaScript 中未使用代码的优化技术。
原理
生效条件
// ✅ ESM 语法,可 Tree Shaking
export function used() { }
export function unused() { } // 会被移除
// ⚠️ CommonJS 动态性强,通常会限制 Tree Shaking
module.exports = {
used: function() { },
unused: function() { }
};
配置
// vite.config.ts
export default {
build: {
// 生产环境默认开启
minify: 'terser',
terserOptions: {
compress: {
dead_code: true,
drop_console: true,
drop_debugger: true
}
}
}
};
// webpack.config.js
module.exports = {
mode: 'production', // 自动开启 Tree Shaking
optimization: {
usedExports: true,
sideEffects: true
}
};
sideEffects 配置
// package.json
{
"name": "my-package",
"sideEffects": false, // 告诉打包工具所有模块无副作用
// 或指定有副作用的文件
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js"
]
}
代码分割
入口分割
// webpack.config.js
module.exports = {
entry: {
main: './src/main.ts',
admin: './src/admin.ts'
},
output: {
filename: '[name].[contenthash].js'
}
};
动态导入
// React 懒加载
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
// Vue 路由懒加载
const routes = [
{
path: '/dashboard',
component: () => import('./pages/Dashboard.vue')
}
];
// 带注释的分包命名
const Chart = lazy(() => import(
/* webpackChunkName: "chart" */
/* webpackPrefetch: true */
'./components/Chart'
));
手动分包
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
// React 相关
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
// UI 库
'ui-vendor': ['antd', '@ant-design/icons'],
// 工具库
'utils': ['lodash-es', 'dayjs', 'axios']
}
}
}
}
};
// 函数式分包(更灵活)
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react')) return 'react-vendor';
if (id.includes('antd')) return 'ui-vendor';
return 'vendor';
}
}
}
}
}
};
Webpack SplitChunks
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 20000,
maxSize: 250000,
cacheGroups: {
// React 核心
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'react',
chunks: 'all',
priority: 40
},
// UI 组件库
antd: {
test: /[\\/]node_modules[\\/]antd[\\/]/,
name: 'antd',
chunks: 'all',
priority: 30
},
// 其他第三方库
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 20
},
// 公共模块
common: {
minChunks: 2,
name: 'common',
chunks: 'all',
priority: 10
}
}
}
}
};
压缩优化
JavaScript 压缩
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
minify: 'esbuild', // 默认,更快
// minify: 'terser', // 压缩率更高
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log']
},
mangle: {
safari10: true
}
}
}
});
CSS 压缩
// vite.config.ts
import cssnano from 'cssnano';
export default {
css: {
postcss: {
plugins: [
cssnano({
preset: ['default', {
discardComments: { removeAll: true },
normalizeWhitespace: true
}]
})
]
}
}
};
Gzip/Brotli 压缩
// vite-plugin-compression
import viteCompression from 'vite-plugin-compression';
export default {
plugins: [
// Gzip
viteCompression({
algorithm: 'gzip',
threshold: 10240 // 10KB 以上压缩
}),
// Brotli(压缩率更高)
viteCompression({
algorithm: 'brotliCompress'
})
]
};
依赖优化
分析依赖体积
# Webpack
npx webpack-bundle-analyzer stats.json
# Vite
npx vite-bundle-visualizer
替换大型依赖
// ❌ 整体引入 lodash(约 70KB)
import _ from 'lodash';
// ✅ 按需引入(约 2KB)
import debounce from 'lodash/debounce';
// ✅ 使用 lodash-es(支持 Tree Shaking)
import { debounce } from 'lodash-es';
// ✅ 使用更轻量的替代
// moment.js (~300KB) → dayjs (~2KB)
// lodash → 原生方法或 radash
按需加载 UI 库
// Ant Design 按需加载
// vite.config.ts
import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
export default {
plugins: [
Components({
resolvers: [AntDesignVueResolver()]
})
]
};
externals 外部化
// webpack.config.js
module.exports = {
externals: {
react: 'React',
'react-dom': 'ReactDOM',
echarts: 'echarts'
}
};
<!-- 使用 CDN 加载 -->
<script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
构建速度优化
Vite 优化
// vite.config.ts
export default {
// 预构建依赖
optimizeDeps: {
include: ['lodash-es', 'axios'],
exclude: ['some-local-package']
},
// esbuild 配置
esbuild: {
target: 'esnext',
drop: ['console', 'debugger']
},
// 构建缓存
build: {
// 启用 CSS 代码分割
cssCodeSplit: true,
// 构建目标
target: 'esnext'
}
};
Webpack 优化
// webpack.config.js
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
// 缓存
cache: {
type: 'filesystem',
cacheDirectory: path.resolve(__dirname, '.cache')
},
// 限制解析范围
resolve: {
modules: [path.resolve(__dirname, 'src'), 'node_modules'],
extensions: ['.ts', '.tsx', '.js'],
alias: {
'@': path.resolve(__dirname, 'src')
}
},
// 多线程压缩
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
compress: { drop_console: true }
}
})
]
},
// 排除不需要处理的模块
module: {
noParse: /jquery|lodash/,
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: 'ts-loader'
}
]
}
};
使用 SWC/esbuild
// 使用 SWC 替代部分 Babel 转译链路;收益取决于项目和插件配置
// .swcrc
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true
},
"transform": {
"react": {
"runtime": "automatic"
}
}
}
}
// webpack with swc-loader
module.exports = {
module: {
rules: [
{
test: /\.tsx?$/,
use: 'swc-loader',
exclude: /node_modules/
}
]
}
};
缓存策略
文件名哈希
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
// 入口文件
entryFileNames: 'js/[name].[hash].js',
// 代码分割产物
chunkFileNames: 'js/[name].[hash].js',
// 静态资源
assetFileNames: 'assets/[name].[hash][extname]'
}
}
}
};
内容哈希 vs 块哈希
// webpack.config.js
module.exports = {
output: {
// contenthash: 基于文件内容,推荐
filename: '[name].[contenthash:8].js',
// chunkhash: 基于 chunk 内容
// hash: 基于整体构建,不推荐
}
};
产物分析
// 分析打包体积
// package.json
{
"scripts": {
"analyze": "vite build && npx vite-bundle-visualizer",
"analyze:webpack": "webpack --profile --json > stats.json && npx webpack-bundle-analyzer stats.json"
}
}
替换转译器或压缩器前,需要确认目标浏览器、装饰器、JSX、source map、测试覆盖率和框架插件行为一致。不要只比较一个最小示例的速度,也要比较完整构建、增量构建和 CI 的端到端耗时。
现代项目的优化顺序
1. 先看模块图,而不是先改配置
一次有效的产物分析至少回答这些问题:
- 首屏真正执行了多少 JavaScript,而不仅是下载了多少?
- 是否同时打入多个版本的 React、日期库、编辑器或图表库?
- 依赖是否包含未使用的语言包、locale、polyfill 和 legacy 构建?
- source map 中的大模块来自业务代码、框架运行时还是第三方依赖?
- 某个服务端组件或静态页面,是否因为过大的 Client Component 边界被迫下发大量 JavaScript?
2. 区分开发速度和生产性能
Vite 的依赖预构建主要服务于开发阶段,默认会发现并缓存依赖;只有自动发现不足、monorepo 链接包或特殊 CommonJS 依赖出现问题时,才需要调整 optimizeDeps。开发服务器启动快,不代表生产包一定小;生产产物仍要单独分析。
同理,迁移构建工具是一项工程决策,不是通用的性能开关。大型项目还要评估插件兼容、SSR、测试、微前端、source map、缓存和 CI 资源消耗。
3. 防止过度分包
分包并不是越细越好。过多小 chunk 会增加调度、请求、模块解析和运行时开销,还可能让一次点击触发连续瀑布。常见做法是:
- 先按路由和明确的重功能划分异步边界。
- 只拆出体积大、命中率低或变化频率不同的依赖。
- 检查动态导入后是否出现
A -> B -> C的串行请求。 - 比较缓存命中、首屏执行时间和交互后的延迟,而不是只看 chunk 数量。
4. 控制缓存失效范围
稳定的 chunk 名称不能保证稳定缓存。运行时清单、模块 ID、公共依赖归属和手动分包策略变化,都可能让未修改的文件产生新哈希。需要在连续两次构建间比较产物,确认一次小改动没有让全部 vendor chunk 失效。
常见面试问题
Q1: Tree Shaking 的原理和条件?
答案:
原理:基于 ESM 的静态分析,标记未使用的 export,在压缩阶段移除。
生效条件:
- 使用 ESM(import/export)语法
- 生产模式构建
- 配置 sideEffects
- 使用支持的压缩工具
// ✅ 可 Tree Shaking
import { debounce } from 'lodash-es';
// ⚠️ CommonJS 依赖通常更难做精确的 Tree Shaking
const _ = require('lodash');
import _ from 'lodash';
Q2: 如何分析和优化打包体积?
答案:
// 1. 分析
// npx vite-bundle-visualizer
// npx webpack-bundle-analyzer
// 2. 常见优化
const optimizations = {
// 代码分割
splitChunks: '按路由/功能分割',
// 按需加载
lazyLoad: '动态 import',
// 依赖优化
dependencies: {
'lodash → lodash-es': '支持 Tree Shaking',
'moment → dayjs': '更小体积',
'antd': '按需加载组件'
},
// 外部化
externals: 'React/Vue 等用 CDN',
// 压缩
compress: 'Gzip/Brotli'
};
Q3: Webpack 和 Vite 的区别?
答案:
| 对比 | Webpack | Vite |
|---|---|---|
| 开发模式 | 通过模块图和增量编译提供开发服务 | 按需转换源码,依赖会预构建并缓存 |
| HMR | 更新受影响模块,性能依赖项目图和配置 | 基于 ESM 边界更新受影响模块 |
| 生产构建 | Webpack 自身的生产构建能力 | 具体底层实现随 Vite 版本和配置演进,应以当前官方文档为准 |
| 配置与生态 | 插件和存量工程经验丰富 | 默认配置精简,现代框架集成成熟 |
| 选择依据 | 现有插件、SSR、微前端、迁移成本和团队经验 | 同样要验证插件兼容、产物、SSR 和 CI 表现 |
面试时不要简单回答“Webpack 慢、Vite 快”。Vite 通常能改善现代项目的开发反馈,但迁移是否值得,要以当前项目的冷启动、HMR、完整构建和兼容性数据判断;生产运行性能更取决于最终产物,而不是工具名称。
Q4: 如何提升构建速度?
答案:
const buildOptimization = {
measure: '分别记录冷启动、HMR、完整构建、CI 和各阶段耗时',
cache: '开启并验证持久化缓存,配置和依赖变化时正确失效',
scope: '缩小 loader、插件、类型检查和 source map 的处理范围',
tools: '在语义兼容后,评估 SWC/esbuild 等替代链路',
parallel: '只有 CPU 密集且任务足够大时再验证并行收益',
dependencies: '治理重复依赖、巨型入口和异常的依赖预构建'
};
多线程会有进程启动、序列化和内存成本,小项目可能反而变慢。更稳妥的回答是先用构建 profiler 找出耗时阶段,再决定缓存、并行、替换工具还是减少工作量。
Q5: 代码分割的策略?
答案:
// 1. 路由级分割
const Home = lazy(() => import('./pages/Home'));
// 2. 组件级分割
const HeavyChart = lazy(() => import('./components/Chart'));
// 3. 第三方库分割
manualChunks: {
'react-vendor': ['react', 'react-dom'],
'ui-vendor': ['antd']
}
// 4. 公共模块提取
splitChunks: {
cacheGroups: {
common: { minChunks: 2 }
}
}
// 分割原则
// - 先按用户旅程和执行时机建立异步边界
// - 例如在中端移动设备、冷缓存和 4G 基线下,首屏必需 JS 可先以 100KB gzip 为预算
// - 避免一次交互触发串行 chunk 瀑布
// - 用真实缓存命中率和执行时间验证公共包拆分
固定的单 chunk 体积只能作为告警起点。一个 250KB 的低频编辑器异步包,与首屏同步加载的 250KB 运行时代价完全不同;还要同时看下载、解析、编译、执行和缓存失效。